Initial commit

This commit is contained in:
Henrik Højmark committed 2026-04-11 04:38:11 +02:00
commit 9c7df76fbd
91 files changed
+36572

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# Routes — Architecture Rules
## SSE constraints
SSE routes must live in `gdpr_scanner.py`, not blueprints — blueprints can't stream.
M365 scan emits `scan_done`; Google emits `google_scan_done`; file scan emits `file_scan_done`. Never mix them up.
## scan_progress source field
All three scan engines must include `"source": "m365"` / `"google"` / `"file"` in every `scan_progress` SSE event. Never remove this field — the frontend uses it to route progress to the correct segment.
## file_sources
`file_sources` in profiles are stored as source ID strings by the JS frontend. The scheduler resolves them via `_load_file_sources()` before calling `run_file_scan()`.
## Circular import prohibition
`scan_engine.py` and `gdpr_scanner.py` must not import each other. `scan_engine` imports from `sse`, `checkpoint`, `app_config`, `cpr_detector`; `gdpr_scanner` imports scan functions from `scan_engine`.
## Gotchas
- **`_load_settings()` return** — does NOT include `file_sources`. Returns only: sources, user_ids, options, retention_years, fiscal_year_end, email_to.
- **`_save_settings()` clobbers profile fields** — called on every M365 scan start with only M365 sources/user_ids/options. The fix in `app_config.py` preserves `google_sources` and `file_sources` and rebuilds `sources` as `m365_src + google_src + file_src`. Do not simplify away this merge logic.
- **`loadLastScanSummary()` timing** — must only be called after the first `/api/scan/status` poll resolves (inside `_sseWatchdog` in `results.js`, guarded by `_initialStatusChecked`). Calling it on `DOMContentLoaded` shows a stale "no results" card during a live scan after a hard refresh.
+8
View File
@@ -0,0 +1,8 @@
"""
GDPR Scanner — Flask route blueprints.
Each module registers one Blueprint and imports shared state from
gdpr_scanner (the application entry point). Import order matters:
blueprints must be registered after `app` and all shared globals
(flagged_items, _connector, etc.) are defined.
"""
+386
View File
@@ -0,0 +1,386 @@
"""
App-level routes: about, language, version
"""
from __future__ import annotations
import sys
from flask import Blueprint, Response, jsonify, request
from pathlib import Path
from routes import state
from app_config import _set_lang_override, _load_lang_forced
bp = Blueprint("app_routes", __name__)
_APP_VERSION = (Path(__file__).parent.parent / "VERSION").read_text().strip()
_LANG_DIR = (Path(sys._MEIPASS) if getattr(sys, "frozen", False)
else Path(__file__).parent.parent) / "lang"
@bp.route("/api/about")
def about_info():
import platform
info = {"python": platform.python_version(), "app": _APP_VERSION}
try:
import msal as _msal
info["msal"] = getattr(_msal, "__version__", "installed")
except ImportError:
info["msal"] = "not installed"
try:
import requests as _req
info["requests"] = getattr(_req, "__version__", "installed")
except ImportError:
info["requests"] = "not installed"
try:
import openpyxl as _xl
info["openpyxl"] = getattr(_xl, "__version__", "installed")
except ImportError:
info["openpyxl"] = "not installed"
return jsonify(info)
@bp.route("/api/langs")
def get_langs():
display_names = {
"da": "Dansk", "en": "English", "de": "Deutsch",
"fr": "Français", "nl": "Nederlands", "sv": "Svenska",
"no": "Norsk", "fi": "Suomi", "es": "Español",
"it": "Italiano", "pl": "Polski", "pt": "Português",
}
langs = []
if _LANG_DIR.exists():
seen = set()
for f in sorted(list(_LANG_DIR.glob("*.json")) + list(_LANG_DIR.glob("*.lang"))):
code = f.stem
if code not in seen:
seen.add(code)
langs.append({"code": code, "name": display_names.get(code, code.upper())})
langs.sort(key=lambda x: x["code"])
return jsonify({"langs": langs, "current": state.LANG.get("_lang_code", "en")})
@bp.route("/api/set_lang", methods=["POST"])
def set_lang():
data = request.get_json(force=True) or {}
code = str(data.get("lang", "en")).strip().lower()[:10]
_set_lang_override(code)
state.LANG = _load_lang_forced(code)
return jsonify({"status": "ok", "lang": code, "translations": state.LANG})
@bp.route("/api/lang")
def get_lang_json():
"""Return the current language translations as JSON."""
return jsonify(state.LANG)
@bp.route("/manual")
def manual():
"""Serve the user manual as a styled, printable HTML page.
Respects ?lang=da|en; falls back to the current UI language."""
import sys as _sys
lang = request.args.get("lang", "").strip().lower() or \
state.LANG.get("_lang_code", "da")
lang = lang if lang in ("da", "en") else "da"
_here = Path(_sys._MEIPASS) if getattr(_sys, "frozen", False) \
else Path(__file__).parent.parent
fname = "MANUAL-DA.md" if lang == "da" else "MANUAL-EN.md"
md_path = _here / "docs" / "manuals" / fname
if not md_path.exists():
return f"Manual file not found: {fname}", 404
md_text = md_path.read_text(encoding="utf-8")
body_html = _md_to_html(md_text)
title = "GDPR Scanner — Brugermanual" if lang == "da" \
else "GDPR Scanner — User Manual"
print_label = "Udskriv" if lang == "da" else "Print"
other_lang = "en" if lang == "da" else "da"
other_label = "English" if lang == "da" else "Dansk"
page = f"""<!DOCTYPE html>
<html lang="{lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title>
<style>
:root {{
--text: #1a1a1a;
--muted: #555;
--border: #ddd;
--accent: #0060b0;
--bg: #fff;
--surface: #f6f8fa;
--code-bg: #f0f0f0;
}}
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.7;
color: var(--text);
background: var(--bg);
max-width: 860px;
margin: 0 auto;
padding: 32px 24px 64px;
}}
h1 {{ font-size: 1.9em; margin: 0 0 4px; color: var(--text); }}
h2 {{ font-size: 1.35em; margin: 2.2em 0 .6em; padding-bottom: .3em;
border-bottom: 2px solid var(--border); color: var(--text); }}
h3 {{ font-size: 1.1em; margin: 1.6em 0 .4em; color: var(--text); }}
h4 {{ font-size: 1em; margin: 1.2em 0 .3em; color: var(--muted); }}
p {{ margin: .6em 0; }}
a {{ color: var(--accent); text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
strong {{ font-weight: 600; }}
em {{ font-style: italic; }}
hr {{ border: none; border-top: 1px solid var(--border); margin: 1.8em 0; }}
blockquote {{
border-left: 3px solid var(--accent);
margin: .8em 0;
padding: .4em 1em;
background: var(--surface);
border-radius: 0 4px 4px 0;
color: var(--muted);
}}
code {{
font-family: "SF Mono", Consolas, "Liberation Mono", monospace;
font-size: .88em;
background: var(--code-bg);
padding: 1px 5px;
border-radius: 3px;
}}
pre {{
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
overflow-x: auto;
margin: .8em 0;
font-size: .85em;
line-height: 1.5;
}}
pre code {{ background: none; padding: 0; font-size: inherit; }}
ul, ol {{ margin: .5em 0 .5em 1.6em; }}
li {{ margin: .25em 0; }}
table {{
border-collapse: collapse;
width: 100%;
margin: .8em 0;
font-size: .93em;
}}
th, td {{
border: 1px solid var(--border);
padding: 7px 12px;
text-align: left;
vertical-align: top;
}}
th {{
background: var(--surface);
font-weight: 600;
}}
tr:nth-child(even) td {{ background: #fafafa; }}
/* ── Top toolbar ── */
.manual-toolbar {{
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 28px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}}
.manual-toolbar .spacer {{ flex: 1; }}
.toolbar-btn {{
font-size: 13px;
padding: 5px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
display: inline-block;
}}
.toolbar-btn:hover {{ background: var(--border); }}
.toolbar-btn.primary {{
background: var(--accent);
color: #fff;
border-color: var(--accent);
}}
.toolbar-btn.primary:hover {{ opacity: .88; }}
/* ── Table of contents ── */
.toc {{
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin: 1.2em 0 2em;
font-size: .93em;
}}
.toc ol {{ margin: .3em 0 0 1.2em; }}
.toc li {{ margin: .3em 0; }}
/* ── Print ── */
@media print {{
.manual-toolbar {{ display: none !important; }}
body {{ max-width: 100%; padding: 0; font-size: 12pt; }}
h2 {{ page-break-before: always; }}
h2:first-of-type {{ page-break-before: avoid; }}
pre, blockquote, table {{ page-break-inside: avoid; }}
a {{ color: var(--text); text-decoration: none; }}
a[href^="http"]::after {{ content: " (" attr(href) ")"; font-size: .8em; color: var(--muted); }}
tr:nth-child(even) td {{ background: #f5f5f5; }}
}}
</style>
</head>
<body>
<div class="manual-toolbar">
<strong style="font-size:14px">{title}</strong>
<span class="spacer"></span>
<a class="toolbar-btn" href="/manual?lang={other_lang}">{other_label}</a>
<button class="toolbar-btn primary" onclick="window.print()">🖨 {print_label}</button>
</div>
{body_html}
</body>
</html>"""
return Response(page, mimetype="text/html")
def _md_to_html(md: str) -> str:
"""Lightweight Markdown → HTML converter (no external dependencies).
Handles headings, tables, lists, blockquotes, code blocks, bold/italic,
inline code, links, and horizontal rules."""
import re, html as _html
def inline(text: str) -> str:
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
text = re.sub(r'`(.+?)`', lambda m: '<code>' + _html.escape(m.group(1)) + '</code>', text)
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
return text
def make_anchor(text: str) -> str:
return re.sub(r'[^\w\s-]', '', text.lower()).strip().replace(' ', '-')
result = []
lines = md.splitlines()
i = 0
in_code = False
code_buf = []
in_list = False
list_type = None
list_buf = []
in_table = False
tbl_buf = []
def flush_list():
nonlocal in_list, list_type, list_buf
if not in_list:
return
tag = list_type
result.append(f'<{tag}>')
for item in list_buf:
result.append(f' <li>{inline(item)}</li>')
result.append(f'</{tag}>')
in_list = False; list_buf = []; list_type = None
def flush_table():
nonlocal in_table, tbl_buf
if not in_table or len(tbl_buf) < 2:
in_table = False; tbl_buf = []; return
heads = [c.strip() for c in tbl_buf[0].strip('|').split('|')]
result.append('<table>')
result.append('<thead><tr>' + ''.join(f'<th>{inline(h)}</th>' for h in heads) + '</tr></thead>')
result.append('<tbody>')
for row in tbl_buf[2:]:
cols = [c.strip() for c in row.strip('|').split('|')]
result.append('<tr>' + ''.join(f'<td>{inline(c)}</td>' for c in cols) + '</tr>')
result.append('</tbody></table>')
in_table = False; tbl_buf = []
while i < len(lines):
line = lines[i]
i += 1
# ── fenced code block ──────────────────────────────────────────
if line.startswith('```'):
if not in_code:
flush_list(); flush_table()
in_code = True; code_buf = []
else:
in_code = False
escaped = _html.escape('\n'.join(code_buf))
result.append(f'<pre><code>{escaped}</code></pre>')
continue
if in_code:
code_buf.append(line)
continue
# ── table row ─────────────────────────────────────────────────
if line.strip().startswith('|') and '|' in line[1:]:
flush_list()
in_table = True
tbl_buf.append(line)
continue
elif in_table:
flush_table()
# ── blank line ────────────────────────────────────────────────
if not line.strip():
flush_list()
result.append('')
continue
# ── heading ───────────────────────────────────────────────────
m = re.match(r'^(#{1,6})\s+(.+)$', line)
if m:
flush_list()
lvl = len(m.group(1))
text = m.group(2)
anc = make_anchor(text)
result.append(f'<h{lvl} id="{anc}">{inline(text)}</h{lvl}>')
continue
# ── horizontal rule ───────────────────────────────────────────
if re.match(r'^-{3,}$', line.strip()):
flush_list()
result.append('<hr>')
continue
# ── blockquote ────────────────────────────────────────────────
if line.startswith('> '):
flush_list()
result.append(f'<blockquote>{inline(line[2:])}</blockquote>')
continue
# ── unordered list ────────────────────────────────────────────
m = re.match(r'^- (.+)$', line)
if m:
if not in_list or list_type != 'ul':
flush_list()
in_list = True; list_type = 'ul'; list_buf = []
list_buf.append(m.group(1))
continue
# ── ordered list ─────────────────────────────────────────────
m = re.match(r'^\d+\. (.+)$', line)
if m:
if not in_list or list_type != 'ol':
flush_list()
in_list = True; list_type = 'ol'; list_buf = []
list_buf.append(m.group(1))
continue
# ── paragraph ────────────────────────────────────────────────
flush_list()
result.append(f'<p>{inline(line)}</p>')
flush_list()
flush_table()
return '\n'.join(result)
+179
View File
@@ -0,0 +1,179 @@
"""
Microsoft 365 authentication routes
"""
from __future__ import annotations
import threading
from flask import Blueprint, jsonify, request
from routes import state
from app_config import _load_config, _save_config
try:
from m365_connector import M365Connector, M365Error, MSAL_OK
except ImportError:
MSAL_OK = False
M365Connector = None # type: ignore[assignment,misc]
class M365Error(Exception): pass # type: ignore[no-redef]
bp = Blueprint("auth", __name__)
@bp.route("/api/auth/status")
def auth_status():
cfg = _load_config()
if not MSAL_OK:
return jsonify({"authenticated": False, "error": "msal not installed",
"client_id": cfg.get("client_id",""), "tenant_id": cfg.get("tenant_id","")})
saved_secret = cfg.get("client_secret", "")
saved_cid = cfg.get("client_id", "")
saved_tid = cfg.get("tenant_id", "")
# Rebuild connector if:
# • none exists yet, OR
# • the saved secret doesn't match what the current connector was built with
# (user entered a secret after previously connecting without one)
connector_secret = getattr(state.connector, "client_secret", None)
need_rebuild = (
not state.connector
or connector_secret != saved_secret
or getattr(state.connector, "client_id", None) != saved_cid
)
if need_rebuild and saved_cid and saved_tid:
try:
state.connector = M365Connector(saved_cid, saved_tid, client_secret=saved_secret)
if state.connector.is_app_mode:
state.connector.authenticate_app_mode()
except Exception:
state.connector = None
if state.connector and state.connector.is_authenticated():
try:
info = state.connector.get_user_info()
return jsonify({"authenticated": True,
"display_name": info.get("displayName",""),
"email": info.get("mail") or info.get("userPrincipalName",""),
"client_id": saved_cid,
"tenant_id": saved_tid,
"client_secret": saved_secret,
"app_mode": state.connector.is_app_mode})
except Exception:
pass
return jsonify({"authenticated": False,
"client_id": saved_cid,
"tenant_id": saved_tid,
"client_secret": saved_secret})
@bp.route("/api/auth/start", methods=["POST"])
def auth_start():
if not MSAL_OK:
return jsonify({"error": "msal not installed — run: pip install msal"})
data = request.get_json() or {}
client_id = data.get("client_id","").strip()
tenant_id = data.get("tenant_id","").strip()
client_secret = data.get("client_secret","").strip()
if not client_id or not tenant_id:
return jsonify({"error": "client_id and tenant_id required"})
try:
state.connector = M365Connector(client_id, tenant_id, client_secret=client_secret)
if state.connector.is_app_mode:
# Application mode — acquire token immediately, no device code
state.connector.authenticate_app_mode()
_save_config({"client_id": client_id, "tenant_id": tenant_id,
"client_secret": client_secret})
return jsonify({"mode": "application"})
# Delegated mode — start device code flow
state.pending_flow = state.connector.get_device_code_flow()
state.auth_poll_result = None
_save_config({"client_id": client_id, "tenant_id": tenant_id, "client_secret": ""})
flow_copy = state.pending_flow
def _do_auth():
try:
ok = state.connector.complete_device_code_flow(flow_copy)
state.auth_poll_result = "ok" if ok else "Sign-in failed"
except M365Error as e:
state.auth_poll_result = str(e)
except Exception as e:
state.auth_poll_result = str(e)
threading.Thread(target=_do_auth, daemon=True).start()
return jsonify({
"mode": "delegated",
"user_code": state.pending_flow["user_code"],
"verification_uri": state.pending_flow["verification_uri"],
"message": state.pending_flow["message"],
})
except Exception as e:
return jsonify({"error": str(e)})
@bp.route("/api/auth/poll", methods=["POST"])
def auth_poll():
if not state.connector or not state.pending_flow:
return jsonify({"status": "error", "error": "No pending flow"})
# Return current poll result (set by background thread)
result = state.auth_poll_result
if result == "ok":
state.auth_poll_result = None
state.pending_flow = None
return jsonify({"status": "ok"})
elif result and result != "pending":
state.auth_poll_result = None
state.pending_flow = None
return jsonify({"status": "error", "error": result})
return jsonify({"status": "pending"})
@bp.route("/api/auth/userinfo")
def auth_userinfo():
if not state.connector:
return jsonify({"error": "not connected"}), 401
try:
return jsonify(state.connector.get_user_info())
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/api/auth/signout", methods=["POST"])
def auth_signout():
if state.connector:
try: state.connector.sign_out()
except Exception: pass
state.connector = None
# Also clear the delegated token cache so a fresh sign-in is required
from m365_connector import _TOKEN_CACHE_FILE
try:
if _TOKEN_CACHE_FILE.exists():
_TOKEN_CACHE_FILE.unlink()
except Exception:
pass
return jsonify({"status": "ok"})
@bp.route("/api/auth/config", methods=["GET", "POST"])
def auth_config():
"""GET: return saved config (secret masked). POST: update config directly."""
if request.method == "POST":
data = request.get_json() or {}
client_id = data.get("client_id", "").strip()
tenant_id = data.get("tenant_id", "").strip()
client_secret = data.get("client_secret", "").strip()
if not client_id or not tenant_id:
return jsonify({"error": "client_id and tenant_id required"}), 400
_save_config({"client_id": client_id, "tenant_id": tenant_id,
"client_secret": client_secret})
# Force connector rebuild on next request
state.connector = None
return jsonify({"status": "saved", "app_mode": bool(client_secret)})
cfg = _load_config()
secret = cfg.get("client_secret", "")
return jsonify({
"client_id": cfg.get("client_id", ""),
"tenant_id": cfg.get("tenant_id", ""),
"has_secret": bool(secret),
"secret_preview": (secret[:4] + "…" + secret[-4:]) if len(secret) > 8 else ("***" if secret else ""),
})
+591
View File
@@ -0,0 +1,591 @@
"""
Database stats, disposition, export/import, admin PIN, preview, thumbnail
"""
from __future__ import annotations
import base64
from pathlib import Path
from flask import Blueprint, Response, jsonify, request
from routes import state
from app_config import _set_admin_pin, _verify_admin_pin, _admin_pin_is_set
from checkpoint import _clear_checkpoint, _DELTA_PATH
from cpr_detector import _extract_exif, _html_esc, _placeholder_svg
try:
from gdpr_db import get_db as _get_db
DB_OK = True
except ImportError:
DB_OK = False
def _get_db(*a, **kw): return None # type: ignore[misc]
try:
import document_scanner as _ds # noqa: F401
SCANNER_OK = True
except ImportError:
SCANNER_OK = False
bp = Blueprint("database", __name__)
@bp.route("/api/db/stats")
def db_stats():
"""Return stats for the latest (or specified) scan, plus aggregate counts."""
if not DB_OK: return jsonify({"error": "database not available"}), 503
scan_id = request.args.get("scan_id", type=int)
db = _get_db()
data = db.get_stats(scan_id) or {}
# Add aggregate counts the Settings panel needs — query directly so they
# are correct even if no scan has finished_at set yet
try:
import sqlite3 as _sq
con = _sq.connect(db._path)
con.row_factory = _sq.Row
data["total_items"] = con.execute("SELECT COUNT(*) FROM flagged_items").fetchone()[0]
data["flagged_items"] = data["total_items"]
data["total_scans"] = con.execute("SELECT COUNT(*) FROM scans").fetchone()[0]
data["finished_scans"]= con.execute("SELECT COUNT(*) FROM scans WHERE finished_at IS NOT NULL").fetchone()[0]
if not data.get("flagged_count"):
data["flagged_count"] = data["total_items"]
if not data.get("total_scanned"):
data["total_scanned"] = con.execute("SELECT COALESCE(SUM(total_scanned),0) FROM scans").fetchone()[0]
con.close()
except Exception:
data.setdefault("total_items", 0)
data.setdefault("flagged_items", 0)
data.setdefault("total_scans", 0)
return jsonify(data)
@bp.route("/api/db/trend")
def db_trend():
"""Return scan history for trend chart (last 20 scans)."""
if not DB_OK: return jsonify({"error": "database not available"}), 503
n = request.args.get("n", default=20, type=int)
return jsonify(_get_db().get_trend(n))
@bp.route("/api/db/scans")
def db_scans():
"""List recent completed scans."""
if not DB_OK: return jsonify({"error": "database not available"}), 503
return jsonify(_get_db().scans_list())
@bp.route("/api/db/subject", methods=["POST"])
def db_subject_lookup():
"""Find all items containing a given CPR number.
Body: {cpr: "DDMMYY-XXXX"}
The CPR is hashed before querying -- never stored in plaintext.
"""
if not DB_OK: return jsonify({"error": "database not available"}), 503
data = request.get_json() or {}
cpr = data.get("cpr", "").strip().replace("-", "").replace(" ", "")
if not cpr:
return jsonify({"error": "cpr required"}), 400
items = _get_db().lookup_data_subject(cpr)
return jsonify({"count": len(items), "items": items})
@bp.route("/api/db/overdue")
def db_overdue():
"""Return items older than the retention threshold.
Query params:
years int, default 5
fiscal_year_end MM-DD string, e.g. 12-31 (omit for rolling window)
scan_id int (omit for latest scan)
"""
if not DB_OK: return jsonify({"error": "database not available"}), 503
years = request.args.get("years", default=5, type=int)
fiscal_year_end = request.args.get("fiscal_year_end", default=None)
scan_id = request.args.get("scan_id", type=int)
try:
from gdpr_db import overdue_cutoff
cutoff = overdue_cutoff(years, fiscal_year_end)
items = _get_db().get_overdue_items(years, scan_id, fiscal_year_end)
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify({
"count": len(items),
"cutoff_date": cutoff,
"cutoff_mode": "fiscal" if fiscal_year_end else "rolling",
"fiscal_year_end": fiscal_year_end,
"years": years,
"items": items,
})
@bp.route("/api/db/disposition", methods=["POST"])
def db_set_disposition():
"""Set a compliance disposition on a flagged item.
Body: {item_id, status, legal_basis?, notes?, reviewed_by?}
Status values: unreviewed | retain-legal | retain-legitimate | retain-contract |
delete-scheduled | deleted | personal-use
"""
if not DB_OK: return jsonify({"error": "database not available"}), 503
data = request.get_json() or {}
item_id = data.get("item_id", "")
if not item_id:
return jsonify({"error": "item_id required"}), 400
_get_db().set_disposition(
item_id,
status = data.get("status", "unreviewed"),
legal_basis = data.get("legal_basis", ""),
notes = data.get("notes", ""),
reviewed_by = data.get("reviewed_by", ""),
)
return jsonify({"status": "saved"})
@bp.route("/api/db/disposition/<item_id>")
def db_get_disposition(item_id):
"""Get the current disposition for an item."""
if not DB_OK: return jsonify({"error": "database not available"}), 503
d = _get_db().get_disposition(item_id)
return jsonify(d or {"status": "unreviewed"})
@bp.route("/api/db/flagged")
def db_flagged_items():
"""Return flagged items from the most recent completed scan session.
Used by the read-only viewer to load results without an active SSE connection.
"""
if not DB_OK: return jsonify([])
items = _get_db().get_session_items()
# Normalise JSON-encoded columns the same way scan_engine does for SSE cards
import json as _json
out = []
for row in items:
row["special_category"] = _json.loads(row.get("special_category") or "[]") if isinstance(row.get("special_category"), str) else row.get("special_category", [])
row["exif"] = _json.loads(row.get("exif_json") or "{}") if isinstance(row.get("exif_json"), str) else row.get("exif", {})
row.pop("exif_json", None)
out.append(row)
return jsonify(out)
@bp.route("/api/db/deletion_log")
def db_deletion_log():
"""Return the deletion audit log.
Query params: limit (int, default 500), reason (str filter)
"""
if not DB_OK: return jsonify({"error": "database not available"}), 503
limit = request.args.get("limit", default=500, type=int)
reason = request.args.get("reason", default=None)
rows = _get_db().get_deletion_log(limit=limit, reason=reason)
stats = _get_db().deletion_log_stats()
return jsonify({"stats": stats, "entries": rows})
@bp.route("/api/db/reset", methods=["POST"])
def db_reset():
"""Reset the database and clear in-memory scan results.
Requires {confirm: "yes", pin: "<admin_pin>"} in request body.
"""
data = request.get_json() or {}
if data.get("confirm") != "yes":
return jsonify({"error": "confirm=yes required"}), 400
if _admin_pin_is_set():
pin = data.get("pin", "")
if not _verify_admin_pin(pin):
return jsonify({"error": "incorrect_pin"}), 403
if not DB_OK:
return jsonify({"error": "database not available"}), 503
try:
_get_db().reset()
state.flagged_items = []
state.scan_meta = {}
_clear_checkpoint()
if _DELTA_PATH.exists():
_DELTA_PATH.unlink()
return jsonify({"ok": True, "message": "Database reset. All scan results cleared."})
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/api/admin/pin", methods=["GET"])
def admin_pin_status():
"""Return whether an admin PIN has been set."""
return jsonify({"pin_set": _admin_pin_is_set()})
@bp.route("/api/admin/pin", methods=["POST"])
def admin_pin_set():
"""Set or change the admin PIN.
Body: {current_pin: "..", new_pin: ".."}
If no PIN is currently set, current_pin is not required.
"""
data = request.get_json() or {}
new_pin = data.get("new_pin", "").strip()
if not new_pin:
return jsonify({"error": "new_pin required"}), 400
if _admin_pin_is_set():
if not _verify_admin_pin(data.get("current_pin", "")):
return jsonify({"error": "incorrect_pin"}), 403
_set_admin_pin(new_pin)
return jsonify({"ok": True})
@bp.route("/api/db/export")
def db_export():
"""Export the database to a structured ZIP and return it as a download.
The ZIP contains 8 JSON files (see ScanDB.export_db for details).
CPR numbers are stored as SHA-256 hashes only — never in plaintext.
Thumbnails are stripped to keep the download small. (#11)
"""
if not DB_OK:
return jsonify({"error": "database not available"}), 503
import tempfile, datetime as _dt
try:
ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"gdpr_export_{ts}.zip"
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf:
tmp = Path(tf.name)
try:
_get_db().export_db(tmp)
data = tmp.read_bytes()
finally:
try: tmp.unlink()
except Exception: pass
return Response(
data,
mimetype="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except Exception as e:
import traceback
return jsonify({"error": str(e), "detail": traceback.format_exc()}), 500
@bp.route("/api/db/import", methods=["POST"])
def db_import():
"""Import a previously exported ZIP archive into the database. (#11)
Multipart form:
file — the export ZIP
mode — "merge" (default) or "replace"
confirm — must be "yes" when mode == "replace"
"""
if not DB_OK:
return jsonify({"error": "database not available"}), 503
import tempfile
f = request.files.get("file")
if not f:
return jsonify({"error": "no file uploaded"}), 400
mode = request.form.get("mode", "merge")
confirm = request.form.get("confirm", "")
if mode == "replace" and confirm != "yes":
return jsonify({"error": "confirm=yes required for replace mode"}), 400
try:
tmp = Path(tempfile.mktemp(suffix=".zip", prefix="gdpr_import_"))
f.save(str(tmp))
result = _get_db().import_db(tmp, mode=mode)
tmp.unlink(missing_ok=True)
return jsonify({"ok": True, "mode": mode, "imported": result})
except (ValueError, FileNotFoundError) as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/api/preview/<item_id>")
def get_preview(item_id):
"""Return a preview URL or HTML for a flagged item."""
source_type = request.args.get("source_type", "")
account_id = request.args.get("account_id", "me") or "me"
# Local and SMB file sources — re-read file and render preview
if source_type in ("local", "smb"):
item_meta = next((x for x in state.flagged_items if x.get("id") == item_id), {})
full_path = item_meta.get("full_path", "")
name = item_meta.get("name", "")
ext = Path(name).suffix.lower() if name else ""
if not full_path:
return jsonify({"error": "File path not available — rescan to enable preview"})
if source_type == "smb":
return jsonify({
"type": "info",
"html": f"<p style='color:var(--muted);font-size:12px'>SMB preview requires re-reading the file over the network. Open the file directly: <code>{full_path}</code></p>",
})
try:
file_path = Path(full_path).expanduser()
if not file_path.exists():
return jsonify({"error": f"File not found: {full_path}"})
size = file_path.stat().st_size
# Images — return as data URI
if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}:
import base64 as _b64
mime = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
"gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp"}.get(ext.lstrip("."), "image/jpeg")
data = _b64.b64encode(file_path.read_bytes()).decode()
_exif = item_meta.get("exif") or _extract_exif(file_path.read_bytes(), name)
exif_html = ""
if _exif:
rows = []
if _exif.get("gps"):
g = _exif["gps"]
rows.append(f'<tr><td>📍 GPS</td><td><a href="{g["maps_url"]}" target="_blank" style="color:#7ec8d0">{g["lat"]}, {g["lon"]}</a></td></tr>')
if _exif.get("author"):
rows.append(f'<tr><td>👤 Author</td><td>{_html_esc(_exif["author"])}</td></tr>')
if _exif.get("datetime"):
rows.append(f'<tr><td>📅 Date</td><td>{_html_esc(_exif["datetime"])}</td></tr>')
if _exif.get("device"):
rows.append(f'<tr><td>📷 Device</td><td>{_html_esc(_exif["device"])}</td></tr>')
for field, val in (_exif.get("pii_fields") or {}).items():
if field not in ("Artist",):
rows.append(f'<tr><td>{_html_esc(field)}</td><td>{_html_esc(str(val)[:200])}</td></tr>')
if rows:
exif_html = ('<details style="margin:8px 12px;font-size:11px">'
'<summary style="cursor:pointer;color:#888">EXIF data</summary>'
'<table style="border-collapse:collapse;width:100%;margin-top:6px">'
+ "".join(f'<tr style="border-top:1px solid #333"><td style="padding:4px 8px;color:#888;width:120px;white-space:nowrap">{r.split("</td><td>")[0].replace("<tr><td>","")}</td><td style="padding:4px 8px;word-break:break-all">{r.split("</td><td>")[1].replace("</td></tr>","")}</td></tr>' for r in rows)
+ '</table></details>')
html = f'<div style="text-align:center;padding:12px"><img src="data:{mime};base64,{data}" style="max-width:100%;max-height:60vh;border-radius:6px"></div>{exif_html}'
return jsonify({"type": "html", "html": html})
# Text-based files — render with highlighted CPR numbers
if ext in {".txt", ".csv", ".eml", ".md", ".log", ".xml", ".json", ".html", ".htm"}:
if size > 2 * 1024 * 1024:
return jsonify({"error": "File too large for inline preview (>2 MB)"})
raw = file_path.read_bytes().decode("utf-8", errors="replace")
import html as _html, re as _re
escaped = _html.escape(raw[:50000])
escaped = _re.sub(
r"(\d{6}[-\s]?\d{4})",
r'<mark style="background:#ff444455;color:#ff8888;border-radius:2px">\1</mark>',
escaped
)
html_out = (
'<pre style="font-family:var(--mono);font-size:11px;white-space:pre-wrap;'
'word-break:break-all;padding:12px;color:var(--text);line-height:1.6">'
+ escaped + "</pre>"
)
return jsonify({"type": "html", "html": html_out})
# PDF — render first 5 pages as text using pdfplumber
if ext == ".pdf":
if size > 20 * 1024 * 1024:
return jsonify({"error": "File too large for preview (>20 MB)"})
if SCANNER_OK:
try:
import pdfplumber as _plumber, io as _io, html as _h
pages_html = []
with _plumber.open(_io.BytesIO(file_path.read_bytes())) as pdf:
total = len(pdf.pages)
for i, page in enumerate(pdf.pages[:5]):
text = page.extract_text() or ""
if not text.strip():
text = f"[Page {i+1}: image-only or OCR required]"
import re as _re
escaped = _re.sub(
r"(\d{6}[-\s]?\d{4})",
r'<mark style="background:#ff444455;color:#ff8888;border-radius:2px">\1</mark>',
_h.escape(text)
)
pages_html.append(
f'<div style="border-bottom:1px solid #333;padding:10px 0;margin-bottom:8px">'
f'<div style="font-size:9px;color:#666;margin-bottom:4px">Page {i+1}</div>'
f'<pre style="font-size:11px;white-space:pre-wrap;word-break:break-all;margin:0;line-height:1.6">{escaped}</pre>'
f'</div>'
)
note = f'<div style="font-size:10px;color:#666;padding:6px 0">Showing {min(5,total)} of {total} page(s)</div>' if total > 5 else ""
html_out = f'<div style="padding:10px">{note}{"".join(pages_html)}</div>'
return jsonify({"type": "html", "html": html_out})
except Exception:
pass
html_out = (
f'<div style="padding:24px;text-align:center;font-family:sans-serif">'
f'<div style="font-size:40px">📄</div>'
f'<div style="font-size:13px;font-weight:600;margin:8px 0">{_html_esc(name)}</div>'
f'<div style="font-size:11px;color:var(--muted)">{round(size/1024,1)} KB</div>'
f'<div style="margin-top:12px;font-size:11px;color:var(--muted)">{_html_esc(full_path)}</div>'
f'</div>'
)
return jsonify({"type": "html", "html": html_out})
# Word/Excel/CSV — render content or show metadata
if SCANNER_OK and ext in {".xlsx", ".xlsm", ".csv"}:
try:
import html as _hh, re as _re, io as _io
if ext == ".csv":
raw = file_path.read_bytes().decode("utf-8", errors="replace")
rows = [r for r in raw.splitlines()[:50]]
table_rows = ""
for i, row in enumerate(rows):
cols = row.split(",")
style = "background:#2a2a2a" if i % 2 == 0 else ""
cells = "".join(f'<td style="padding:3px 8px;border:1px solid #333;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{_hh.escape(str(c)[:80])}</td>' for c in cols)
table_rows += f'<tr style="{style}">{cells}</tr>'
html_out = f'<div style="padding:8px;overflow-x:auto"><table style="border-collapse:collapse;font-size:11px;color:var(--text)">{table_rows}</table></div>'
else:
import openpyxl as _xl
wb = _xl.load_workbook(_io.BytesIO(file_path.read_bytes()), read_only=True, data_only=True)
tabs = []
for sheet_name in wb.sheetnames[:3]:
ws = wb[sheet_name]
table_rows = ""
for i, row in enumerate(ws.iter_rows(max_row=50, values_only=True)):
style = "background:#2a2a2a" if i % 2 == 0 else ""
cells = "".join(
f'<td style="padding:3px 8px;border:1px solid #333;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'
f'{_hh.escape(str(c)[:80]) if c is not None else ""}</td>'
for c in row
)
table_rows += f'<tr style="{style}">{cells}</tr>'
tabs.append(
f'<div style="margin-bottom:12px">'
f'<div style="font-size:10px;color:#888;margin-bottom:4px">📋 {_hh.escape(sheet_name)}</div>'
f'<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:11px;color:var(--text)">{table_rows}</table></div>'
f'</div>'
)
html_out = '<div style="padding:8px">' + "".join(tabs) + '</div>'
return jsonify({"type": "html", "html": html_out})
except Exception:
pass
if SCANNER_OK and ext in {".docx", ".doc"}:
try:
import io as _io, html as _hh, re as _re
from docx import Document as _Doc
doc = _Doc(_io.BytesIO(file_path.read_bytes()))
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()][:80]
text = "\n".join(paragraphs)
escaped = _re.sub(
r"(\d{6}[-\s]?\d{4})",
r'<mark style="background:#ff444455;color:#ff8888;border-radius:2px">\1</mark>',
_hh.escape(text)
)
html_out = f'<div style="padding:12px"><pre style="font-size:11px;white-space:pre-wrap;word-break:break-all;line-height:1.7">{escaped}</pre></div>'
return jsonify({"type": "html", "html": html_out})
except Exception:
pass
html_out = (
f'<div style="padding:24px;text-align:center;font-family:sans-serif">'
f'<div style="font-size:40px">📄</div>'
f'<div style="font-size:13px;font-weight:600;margin:8px 0">{_html_esc(name)}</div>'
f'<div style="font-size:11px;color:var(--muted)">{round(size/1024,1)} KB · {ext.upper().lstrip(".")} file</div>'
f'<div style="margin-top:12px;font-size:11px;color:var(--muted)">{_html_esc(full_path)}</div>'
f'</div>'
)
return jsonify({"type": "html", "html": html_out})
except PermissionError:
return jsonify({"error": f"Permission denied: {full_path}"})
except Exception as e:
return jsonify({"error": str(e)})
if not state.connector:
return jsonify({"error": "not authenticated"}), 401
item_meta = next((x for x in state.flagged_items if x.get("id") == item_id), {})
drive_id = item_meta.get("drive_id", "")
try:
if source_type == "email":
uid = account_id
try:
msg = state.connector._get(
f"/{'me' if uid == 'me' else 'users/' + uid}/messages/{item_id}",
{"$select": "subject,from,receivedDateTime,body"}
)
except Exception as e:
return jsonify({"error": f"Could not load email: {e}"})
sender = msg.get("from", {}).get("emailAddress", {})
from_str = f"{sender.get('name', '')} &lt;{sender.get('address', '')}&gt;"
date_str = (msg.get("receivedDateTime") or "")[:10]
body_html = msg.get("body", {}).get("content", "") or ""
content_type = msg.get("body", {}).get("contentType", "text")
import html as _html
if content_type == "text":
body_html = "<pre style='white-space:pre-wrap;font-family:sans-serif'>" + _html.escape(body_html) + "</pre>"
att_list = item_meta.get("attachments", [])
att_html = ""
if att_list:
def _att_row(a):
cpr_badge = f'<span class="att-cpr">{a["cpr_count"]} CPR</span>' if a["cpr_count"] else ''
name_esc = _html.escape(a["name"])
return f'<div class="att-row"><span class="att-name">{name_esc}</span>{cpr_badge}</div>'
rows = "".join(_att_row(a) for a in att_list)
att_html = f"""
<div class="att-section">
<div class="att-header">📎 Attachments ({len(att_list)})</div>
{rows}
</div>"""
page = f"""<!DOCTYPE html><html><head><meta charset="utf-8">
<style>
*, *::before, *::after {{ box-sizing: border-box; max-width: 100%; }}
html, body {{ margin: 0; padding: 0; overflow-x: hidden; }}
body {{ font-family: -apple-system, sans-serif; font-size: 13px; padding: 12px 16px;
background: #fff; color: #111; word-break: break-word; }}
img {{ max-width: 100% !important; height: auto !important; }}
table {{ max-width: 100% !important; table-layout: fixed; word-break: break-word; }}
.hdr {{ border-bottom: 1px solid #eee; margin-bottom: 12px; padding-bottom: 10px; }}
.hdr-row {{ color: #555; font-size: 12px; margin-bottom: 3px; }}
.hdr-row b {{ color: #111; }}
.att-section {{ margin-top: 16px; border-top: 1px solid #eee; padding-top: 10px; }}
.att-header {{ font-size: 12px; font-weight: 600; color: #555; margin-bottom: 6px; }}
.att-row {{ display: flex; align-items: center; gap: 8px; font-size: 12px;
padding: 4px 0; border-bottom: 1px solid #f0f0f0; }}
.att-name {{ flex: 1; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.att-cpr {{ background: #fff0f0; color: #c00; font-size: 11px; padding: 1px 6px;
border-radius: 10px; font-weight: 600; white-space: nowrap; }}
::-webkit-scrollbar {{ width: 4px; height: 4px; }}
::-webkit-scrollbar-track {{ background: transparent; }}
::-webkit-scrollbar-thumb {{ background: #aaa; border-radius: 2px; }}
* {{ scrollbar-width: thin; scrollbar-color: #aaa transparent; }}
</style></head><body>
<div class="hdr">
<div class="hdr-row"><b>From:</b> {from_str}</div>
<div class="hdr-row"><b>Date:</b> {date_str}</div>
<div class="hdr-row"><b>Subject:</b> {_html.escape(msg.get('subject', '(no subject)'))}</div>
</div>
{body_html}{att_html}
</body></html>"""
return jsonify({"type": "html", "html": page})
else:
# OneDrive / SharePoint / Teams — use Graph's embed preview API
preview_url = None
errors = []
endpoints_to_try = []
if drive_id:
endpoints_to_try.append(f"/drives/{drive_id}/items/{item_id}/preview")
uid = account_id
if uid and uid != "me":
endpoints_to_try.append(f"/users/{uid}/drive/items/{item_id}/preview")
endpoints_to_try.append(f"/me/drive/items/{item_id}/preview")
for ep in endpoints_to_try:
try:
data = state.connector._post(ep, {})
preview_url = data.get("getUrl") or data.get("postUrl")
if preview_url:
break
except Exception as e:
errors.append(str(e))
if preview_url:
return jsonify({"type": "iframe", "url": preview_url})
return jsonify({"error": "No preview available for this file type. " + "; ".join(errors[:1])})
except Exception as e:
return jsonify({"error": str(e)})
@bp.route("/api/thumb")
def thumb():
"""Fallback thumbnail for non-image files."""
name = request.args.get("name", "file")
ext = Path(name).suffix.lower()
svg_b64 = _placeholder_svg(ext, name)
data = base64.b64decode(svg_b64)
return Response(data, mimetype="image/svg+xml",
headers={"Cache-Control": "public, max-age=3600"})
+303
View File
@@ -0,0 +1,303 @@
"""
SMTP configuration, test, and report sending
"""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from routes import state
from app_config import _load_smtp_config, _save_smtp_config
from routes.export import _build_excel_bytes
bp = Blueprint("email", __name__)
def _send_report_email(xl_bytes: bytes, fname: str,
smtp_cfg: dict, recipients: list[str]) -> None:
"""Send the scan report Excel as an email attachment via SMTP."""
import smtplib as _smtp
import email.mime.text as _mime_text
import email.mime.multipart as _mime_mp
import email.mime.base as _mime_base
import email.encoders as _encoders
import datetime as _dt
host = smtp_cfg.get("host", "").strip()
port = int(smtp_cfg.get("port", 587))
username = smtp_cfg.get("username", "").strip()
password = smtp_cfg.get("password", "")
from_addr = smtp_cfg.get("from_addr", "").strip() or username
use_ssl = bool(smtp_cfg.get("use_ssl", False))
use_tls = bool(smtp_cfg.get("use_tls", True)) and not use_ssl
if not host:
raise ValueError("No SMTP host configured")
subject = f"GDPR Scanner \u2014 scan report {_dt.datetime.now().strftime('%Y-%m-%d')}"
body_html = (
"<html><body style='font-family:Arial,sans-serif;color:#333;padding:24px'>"
"<h2 style='color:#1F3864'>\u2601\ufe0f GDPR Scanner \u2014 scan report</h2>"
f"<p>Please find the latest scan report attached ({fname}).</p>"
f"<p style='color:#888;font-size:12px'>Generated: {_dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>"
"</body></html>"
)
msg = _mime_mp.MIMEMultipart("mixed")
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = ", ".join(recipients)
msg.attach(_mime_text.MIMEText(body_html, "html"))
part = _mime_base.MIMEBase(
"application",
"vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
part.set_payload(xl_bytes)
_encoders.encode_base64(part)
part.add_header("Content-Disposition", f'attachment; filename="{fname}"')
msg.attach(part)
if use_ssl:
server = _smtp.SMTP_SSL(host, port, timeout=30)
else:
server = _smtp.SMTP(host, port, timeout=30)
with server:
server.ehlo()
if use_tls:
server.starttls()
server.ehlo()
if username and password:
server.login(username, password)
server.sendmail(from_addr, recipients, msg.as_string())
def _send_email_graph(subject: str, html_body: str,
recipients: list[str],
attachment_bytes: bytes = None,
attachment_name: str = None) -> None:
"""Send an email via Microsoft Graph API using the current connector token.
Requires Mail.Send permission (delegated or application).
Raises on failure."""
if not state.connector or not state.connector.is_authenticated():
raise RuntimeError("Not connected to Microsoft 365")
to_list = [{"emailAddress": {"address": r}} for r in recipients]
message: dict = {
"subject": subject,
"body": {"contentType": "HTML", "content": html_body},
"toRecipients": to_list,
}
if attachment_bytes and attachment_name:
import base64 as _b64
message["attachments"] = [{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": attachment_name,
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"contentBytes": _b64.b64encode(attachment_bytes).decode(),
}]
if state.connector.is_app_mode:
smtp_cfg = _load_smtp_config()
sender = smtp_cfg.get("from_addr") or smtp_cfg.get("username") or recipients[0]
state.connector._post(f"/users/{sender}/sendMail", {"message": message, "saveToSentItems": False})
else:
state.connector._post("/me/sendMail", {"message": message, "saveToSentItems": False})
@bp.route("/api/smtp/config", methods=["GET"])
def smtp_config_get():
"""Return saved SMTP config (password redacted — never sent to client)."""
cfg = _load_smtp_config()
safe = {k: v for k, v in cfg.items() if k != "password"}
safe["has_password"] = bool(cfg.get("password"))
return jsonify(safe)
@bp.route("/api/smtp/config", methods=["POST"])
def smtp_config_save():
"""Save SMTP config. Omitting 'password' preserves any previously saved password."""
data = request.get_json() or {}
existing = _load_smtp_config()
if not data.get("password") and existing.get("password"):
data["password"] = existing["password"]
_save_smtp_config(data)
return jsonify({"status": "saved"})
@bp.route("/api/smtp/test", methods=["POST"])
def smtp_test():
"""Send a test email. Tries Microsoft Graph API first (no SMTP config needed),
falls back to SMTP if Graph is unavailable."""
import datetime as _dt
saved = _load_smtp_config()
recipients = saved.get("recipients", [])
if isinstance(recipients, str):
recipients = [r.strip() for r in recipients.replace(";", ",").split(",") if r.strip()]
if not recipients:
return jsonify({"error": "No recipients configured — add at least one recipient and save first"}), 400
subject = f"GDPR Scanner — test email ({_dt.datetime.now().strftime('%Y-%m-%d %H:%M')})"
body_html = (
"<html><body style='font-family:Arial,sans-serif;color:#333;padding:24px'>"
"<h2 style='color:#1F3864'>☁️ GDPR Scanner — test email</h2>"
"<p>This is a test email confirming that your email configuration is working correctly.</p>"
f"<p style='color:#888;font-size:12px'>Sent: {_dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>"
"</body></html>"
)
# Try Graph API first
if state.connector and state.connector.is_authenticated():
try:
_send_email_graph(subject, body_html, recipients)
return jsonify({"ok": True,
"message": f"Test email sent via Microsoft Graph to {', '.join(recipients)}"})
except Exception as graph_err:
graph_error_str = str(graph_err)
else:
graph_error_str = None
# Fall back to SMTP
host = saved.get("host", "").strip()
port = int(saved.get("port", 587))
username = saved.get("username", "").strip()
password = saved.get("password", "")
from_addr = saved.get("from_addr", "").strip() or username
use_ssl = bool(saved.get("use_ssl", False))
use_tls = bool(saved.get("use_tls", True)) and not use_ssl
if not host:
return jsonify({"error": "No SMTP host configured. To send via Microsoft 365 Graph (no SMTP needed), add Mail.Send to your Azure app registration."}), 400
try:
import smtplib as _smtp
import email.mime.text as _mime_text
import email.mime.multipart as _mime_mp
msg = _mime_mp.MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = ", ".join(recipients)
msg.attach(_mime_text.MIMEText(body_html, "html"))
if use_ssl:
server = _smtp.SMTP_SSL(host, port, timeout=15)
else:
server = _smtp.SMTP(host, port, timeout=15)
with server:
server.ehlo()
if use_tls:
server.starttls()
server.ehlo()
if username and password:
server.login(username, password)
server.sendmail(from_addr, recipients, msg.as_string())
suffix = " (⚠ Graph also failed — Mail.Send permission not granted)" if graph_error_str else ""
return jsonify({"ok": True, "message": f"Test email sent via SMTP to {', '.join(recipients)}{suffix}"})
except Exception as smtp_err:
err_str = str(smtp_err)
_h = host.lower()
_corp_m365 = "office365" in _h or "microsoft" in _h
_personal_ms = not _corp_m365 and any(s in _h for s in ("outlook", "live", "hotmail"))
_gmail_host = "gmail" in _h or "smtp.google" in _h
_auth_err = "5.7.57" in err_str or "530" in err_str or "535" in err_str or \
"534" in err_str or "not authenticated" in err_str.lower() or \
"Username and Password" in err_str
_conn_err = "nodename nor servname" in err_str or "Name or service not known" in err_str or \
"getaddrinfo" in err_str or "Connection refused" in err_str or \
"Errno 8" in err_str or "Errno 111" in err_str or "Errno 61" in err_str or \
"timed out" in err_str.lower()
if _conn_err:
err_str = (f"Could not connect to SMTP server \"{host}\" on port {port}. "
f"Check that the hostname and port are correct.")
elif _corp_m365 and _auth_err:
err_str = ("M365 blocked SMTP AUTH. Fix: enable Authenticated SMTP in the M365 admin centre "
"(Users → Active users → [user] → Mail → Manage email apps → Authenticated SMTP), "
"or add Mail.Send to your Azure app to use Graph instead.")
elif (_personal_ms or _gmail_host) and _auth_err:
provider = "Microsoft" if _personal_ms else "Google"
url = "account.microsoft.com/security" if _personal_ms else "myaccount.google.com → Security → 2-Step Verification"
err_str = (f"Authentication failed — {provider} blocks regular passwords for SMTP when MFA is enabled.\n\n"
f"Fix: create an App Password at {url} → App passwords "
f"and use that instead of your normal password.")
elif graph_error_str:
err_str = f"SMTP: {err_str} | Graph also unavailable (Mail.Send not granted)"
return jsonify({"error": err_str}), 200
@bp.route("/api/send_report", methods=["POST"])
def send_report():
"""Build Excel and email it to the requested recipients.
Tries Microsoft Graph API first, falls back to SMTP."""
if not state.flagged_items:
return jsonify({"error": "No results to send — run a scan first"}), 400
data = request.get_json() or {}
smtp_cfg = _load_smtp_config()
recipients = data.get("recipients", []) or smtp_cfg.get("recipients", [])
if isinstance(recipients, str):
recipients = [r.strip() for r in recipients.replace(";", ",").split(",") if r.strip()]
if data.get("smtp"):
smtp_cfg = {**smtp_cfg, **data["smtp"]}
if not recipients:
return jsonify({"error": "No recipients specified"}), 400
try:
xl_bytes, fname = _build_excel_bytes()
except Exception as e:
return jsonify({"error": f"Excel build failed: {e}"}), 500
import datetime as _dt
subject = f"GDPR Scanner — scan report {_dt.datetime.now().strftime('%Y-%m-%d')}"
body_html = (
"<html><body style='font-family:Arial,sans-serif;color:#333;padding:24px'>"
"<h2 style='color:#1F3864'>\u2601\ufe0f GDPR Scanner \u2014 scan report</h2>"
f"<p>Please find the latest scan report attached ({fname}).</p>"
f"<p style='color:#888;font-size:12px'>Generated: {_dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>"
f"Items flagged: {len(state.flagged_items)}</p>"
"</body></html>"
)
# Try Graph API first
if state.connector and state.connector.is_authenticated():
try:
_send_email_graph(subject, body_html, recipients,
attachment_bytes=xl_bytes, attachment_name=fname)
return jsonify({"status": "sent", "method": "graph",
"recipients": recipients, "filename": fname})
except Exception as graph_err:
graph_err_str = str(graph_err)
if "403" in graph_err_str or "Forbidden" in graph_err_str \
or "Mail.Send" in graph_err_str or "insufficient" in graph_err_str.lower():
return jsonify({"error": (
"Mail.Send permission not granted on the Azure app registration. "
"Go to Azure AD → App registrations → [your app] → API permissions → "
"Add → Microsoft Graph → Mail.Send → Grant admin consent."
)}), 500
# Fall back to SMTP
try:
_send_report_email(xl_bytes, fname, smtp_cfg, recipients)
return jsonify({"status": "sent", "method": "smtp",
"recipients": recipients, "filename": fname})
except Exception as e:
err = str(e)
_h2 = smtp_cfg.get("host", "").lower()
_p2 = int(smtp_cfg.get("port", 587))
_corp_m365_2 = "office365" in _h2 or "microsoft" in _h2
_personal_ms_2 = not _corp_m365_2 and any(s in _h2 for s in ("outlook", "live", "hotmail"))
_gmail_2 = "gmail" in _h2 or "smtp.google" in _h2
_auth_err_2 = "5.7.57" in err or "530" in err or "535" in err or \
"534" in err or "not authenticated" in err.lower()
_conn_err_2 = "nodename nor servname" in err or "Name or service not known" in err or \
"getaddrinfo" in err or "Connection refused" in err or \
"Errno 8" in err or "Errno 111" in err or "Errno 61" in err or \
"timed out" in err.lower()
if _conn_err_2:
err = (f"Could not connect to SMTP server \"{_h2}\" on port {_p2}. "
f"Check that the hostname and port are correct.")
elif _corp_m365_2 and _auth_err_2:
err = (f"{err}\n\nTip: Enable SMTP AUTH for this mailbox in the Microsoft 365 admin centre, "
"or connect to M365 first so the scanner can send via Microsoft Graph instead.")
elif (_personal_ms_2 or _gmail_2) and _auth_err_2:
provider2 = "Microsoft" if _personal_ms_2 else "Google"
url2 = "account.microsoft.com/security" if _personal_ms_2 else "myaccount.google.com → Security → 2-Step Verification"
err = (f"Authentication failed — {provider2} blocks regular passwords for SMTP when MFA is enabled.\n\n"
f"Fix: create an App Password at {url2} → App passwords "
f"and use that instead of your normal password.")
return jsonify({"error": err}), 500
+1222
View File
File diff suppressed because it is too large. Load diff
+246
View File
@@ -0,0 +1,246 @@
"""
Google Workspace authentication routes.
Endpoints:
GET /api/google/auth/status — is a service account loaded?
POST /api/google/auth/connect — save key JSON + optional admin_email
POST /api/google/auth/disconnect — remove saved key + clear connector
"""
from __future__ import annotations
from flask import Blueprint, jsonify, request
import json
import threading
from routes import state
bp = Blueprint("google_auth", __name__)
def __getattr__(name):
import gdpr_scanner as _m
if hasattr(_m, name):
return getattr(_m, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@bp.route("/api/google/auth/status")
def google_auth_status():
"""Return current Google connection state."""
from google_connector import GOOGLE_AUTH_OK, load_saved_key
if not GOOGLE_AUTH_OK:
return jsonify({
"connected": False,
"error": "google-auth not installed — run: pip install google-auth google-auth-httplib2 google-api-python-client",
"libs_ok": False,
})
key = load_saved_key()
if not key:
return jsonify({"connected": False, "libs_ok": True})
sa_email = key.get("client_email", "")
project_id = key.get("project_id", "")
admin_email = ""
# Read persisted admin_email from config
cfg = _load_google_config()
admin_email = cfg.get("admin_email", "")
# Rebuild connector in state if not present
if not state.google_connector:
try:
from google_connector import GoogleConnector
state.google_connector = GoogleConnector(key, admin_email=admin_email)
except Exception as e:
return jsonify({"connected": False, "libs_ok": True,
"error": str(e), "sa_email": sa_email})
return jsonify({
"connected": True,
"libs_ok": True,
"sa_email": sa_email,
"project_id": project_id,
"admin_email": admin_email,
})
@bp.route("/api/google/auth/connect", methods=["POST"])
def google_auth_connect():
"""
Accept a service account key JSON + optional admin_email.
Body: { "key_json": "<raw JSON string or object>", "admin_email": "admin@domain.com" }
"""
from google_connector import GOOGLE_AUTH_OK, save_key, GoogleConnector
if not GOOGLE_AUTH_OK:
return jsonify({"error": "google-auth not installed"}), 503
data = request.get_json() or {}
raw_key = data.get("key_json", "")
admin_email = data.get("admin_email", "").strip()
# Accept both a JSON string and an already-parsed object
if isinstance(raw_key, str):
try:
key_dict = json.loads(raw_key)
except json.JSONDecodeError as e:
return jsonify({"error": f"Invalid JSON: {e}"}), 400
elif isinstance(raw_key, dict):
key_dict = raw_key
else:
return jsonify({"error": "key_json must be a JSON string or object"}), 400
if key_dict.get("type") != "service_account":
return jsonify({"error": "File must be a service_account JSON key (type != service_account)"}), 400
# Validate by building a connector
try:
conn = GoogleConnector(key_dict, admin_email=admin_email)
if not conn.is_authenticated():
return jsonify({"error": "Credentials did not validate — check the key file"}), 400
except Exception as e:
return jsonify({"error": str(e)}), 400
save_key(key_dict)
_save_google_config({"admin_email": admin_email})
state.google_connector = conn
return jsonify({
"ok": True,
"sa_email": key_dict.get("client_email", ""),
"project_id": key_dict.get("project_id", ""),
})
@bp.route("/api/google/auth/disconnect", methods=["POST"])
def google_auth_disconnect():
"""Remove saved service account key and clear the connector."""
from google_connector import delete_key
delete_key()
_save_google_config({})
state.google_connector = None
return jsonify({"ok": True})
# ── Personal Google account (device-code OAuth) ───────────────────────────────
@bp.route("/api/google/personal/status")
def google_personal_status():
"""Check whether a personal Google OAuth token is present and valid."""
from google_connector import GOOGLE_AUTH_OK, load_personal_token, PersonalGoogleConnector
if not GOOGLE_AUTH_OK:
return jsonify({"connected": False, "libs_ok": False, "auth_mode": "personal"})
token_data = load_personal_token()
if not token_data:
return jsonify({"connected": False, "libs_ok": True, "auth_mode": "personal"})
if not isinstance(state.google_connector, PersonalGoogleConnector):
try:
conn = PersonalGoogleConnector(token_data)
if conn.is_authenticated():
state.google_connector = conn
else:
return jsonify({"connected": False, "libs_ok": True, "auth_mode": "personal"})
except Exception as e:
return jsonify({"connected": False, "libs_ok": True, "auth_mode": "personal",
"error": str(e)})
try:
info = state.google_connector.get_user_info()
return jsonify({
"connected": True,
"libs_ok": True,
"auth_mode": "personal",
"email": info.get("email", ""),
"displayName": info.get("displayName", ""),
})
except Exception as e:
return jsonify({"connected": False, "libs_ok": True, "auth_mode": "personal",
"error": str(e)})
@bp.route("/api/google/personal/start", methods=["POST"])
def google_personal_start():
"""Initiate a Google device-code flow for a personal account."""
from google_connector import GOOGLE_AUTH_OK, PersonalGoogleConnector
if not GOOGLE_AUTH_OK:
return jsonify({"error": "google-auth not installed"}), 503
data = request.get_json() or {}
client_id = data.get("client_id", "").strip()
client_secret = data.get("client_secret", "").strip()
if not client_id or not client_secret:
return jsonify({"error": "client_id and client_secret required"}), 400
try:
flow = PersonalGoogleConnector.get_device_code_flow(client_id, client_secret)
except Exception as e:
return jsonify({"error": str(e)}), 400
state.google_pending_flow = flow
state.google_poll_result = None
def _do_auth():
try:
conn = PersonalGoogleConnector.complete_device_code_flow(flow)
state.google_connector = conn
state.google_poll_result = "ok"
except Exception as e:
state.google_poll_result = str(e)
threading.Thread(target=_do_auth, daemon=True).start()
return jsonify({
"user_code": flow["user_code"],
"verification_url": flow["verification_url"],
})
@bp.route("/api/google/personal/poll", methods=["POST"])
def google_personal_poll():
"""Check whether the device-code sign-in has completed."""
result = state.google_poll_result
if result == "ok":
state.google_poll_result = None
state.google_pending_flow = None
return jsonify({"status": "ok"})
if result and result != "pending":
state.google_poll_result = None
state.google_pending_flow = None
return jsonify({"status": "error", "error": result})
return jsonify({"status": "pending"})
@bp.route("/api/google/personal/signout", methods=["POST"])
def google_personal_signout():
"""Delete the stored personal OAuth token and clear the connector."""
from google_connector import delete_personal_token, PersonalGoogleConnector
delete_personal_token()
if isinstance(state.google_connector, PersonalGoogleConnector):
state.google_connector = None
return jsonify({"ok": True})
# ── Config helpers ────────────────────────────────────────────────────────────
from pathlib import Path as _Path
_DATA_DIR = _Path.home() / ".gdprscanner"
_DATA_DIR.mkdir(exist_ok=True)
_GOOGLE_CONFIG = _DATA_DIR / "google.json"
def _load_google_config() -> dict:
if _GOOGLE_CONFIG.exists():
try:
return json.loads(_GOOGLE_CONFIG.read_text())
except Exception:
pass
return {}
def _save_google_config(cfg: dict) -> None:
try:
_GOOGLE_CONFIG.write_text(json.dumps(cfg, indent=2))
except Exception:
pass
+328
View File
@@ -0,0 +1,328 @@
"""
Google Workspace scan routes.
Endpoints:
POST /api/google/scan/start — kick off a Gmail + Drive scan
POST /api/google/scan/cancel — abort running Google scan
GET /api/google/scan/users — list workspace users via Admin SDK
"""
from __future__ import annotations
from flask import Blueprint, jsonify, request
import logging
import threading
logger = logging.getLogger(__name__)
from routes import state
from routes.state import _google_scan_lock as _scan_lock, _google_scan_abort as _scan_abort
bp = Blueprint("google_scan", __name__)
def __getattr__(name):
import gdpr_scanner as _m
if hasattr(_m, name):
return getattr(_m, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# ── Scan lock shared with M365 scan so both can't run simultaneously ──────────
# _scan_lock / _scan_abort live in routes/state.py; resolved via gdpr_scanner.__getattr__.
@bp.route("/api/google/scan/users")
def google_scan_users():
"""Return list of workspace users available via Admin SDK."""
conn = state.google_connector
if not conn:
return jsonify({"error": "not connected"}), 401
try:
users = conn.list_users()
return jsonify({"users": users})
except Exception as e:
return jsonify({"error": str(e)}), 500
@bp.route("/api/google/scan/start", methods=["POST"])
def google_scan_start():
"""
Start a Google Workspace scan.
Body (all optional):
{
"sources": ["gmail", "gdrive"], // default: both
"user_emails": ["a@dom.com"], // default: all users via Admin SDK
"options": {
"max_messages": 2000,
"max_files": 5000,
"max_attach_mb": 20,
"scan_body": true,
"scan_attachments":true,
"max_file_mb": 50
}
}
"""
conn = state.google_connector
if not conn:
return jsonify({"error": "not connected to Google Workspace"}), 401
if not _scan_lock.acquire(blocking=False):
return jsonify({"error": "scan already running"}), 409
options = request.get_json() or {}
_scan_abort.clear()
def _run():
try:
_run_google_scan(options)
finally:
_scan_lock.release()
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@bp.route("/api/google/scan/cancel", methods=["POST"])
def google_scan_cancel():
_scan_abort.set()
return jsonify({"status": "cancelling"})
# ── Scan engine ───────────────────────────────────────────────────────────────
def _run_google_scan(options: dict):
"""
Core Google Workspace scan loop.
Mirrors the M365 scan structure:
broadcast("scan_start")
for each user:
for each source (gmail / gdrive):
for each item:
scan bytes → broadcast card
broadcast("scan_done")
"""
import gdpr_scanner as _m
broadcast = _m.broadcast
_scan_bytes = _m._scan_bytes
flagged_items = _m.flagged_items
LANG = _m.LANG
# Import DB helpers
try:
from gdpr_db import get_db as _get_db
DB_OK = True
except ImportError:
DB_OK = False
def _get_db(*a, **kw): return None
from scan_engine import _with_disposition
conn = state.google_connector
if not conn:
broadcast("scan_error", {"file": "auth", "error": "Not connected to Google Workspace"})
broadcast("google_scan_done", {"flagged_count": 0, "total_scanned": 0})
return
import time as _time
_sse_buffer_clear = getattr(_m, '_sse_buffer', None)
if _sse_buffer_clear is not None:
_sse_buffer_clear.clear()
sources = options.get("sources", ["gmail", "gdrive"])
# user_emails may come at top level or inside options
user_emails = options.get("user_emails", [])
scan_opts = options.get("options", {})
max_messages = int(scan_opts.get("max_messages", 2000))
max_files = int(scan_opts.get("max_files", 5000))
max_attach_mb = float(scan_opts.get("max_attach_mb", 20.0))
max_file_mb = float(scan_opts.get("max_file_mb", 50.0))
scan_body = bool(scan_opts.get("scan_body", True))
scan_att = bool(scan_opts.get("scan_attachments", True))
# Resolve users: explicit list → Admin SDK → fall back to SA email itself
_user_role_map: dict = {} # email → role
_user_display_map: dict = {} # email → display name
if not user_emails:
try:
ws_users = conn.list_users()
user_emails = [u["email"] for u in ws_users if u.get("email")]
_user_role_map = {u["email"]: u.get("userRole", "other") for u in ws_users}
_user_display_map = {u["email"]: u.get("displayName", u["email"]) for u in ws_users}
except Exception as e:
# Admin SDK unavailable — scan only the delegated admin account
broadcast("scan_phase", {"phase": f"Admin SDK unavailable ({e}) — scanning service account email only"})
user_emails = [conn.get_service_account_email()]
# SA email itself is not a mailbox; use admin_email if set
if conn._admin_email:
user_emails = [conn._admin_email]
# If user_emails came from the request, try to get display names and roles
if user_emails and not _user_role_map:
try:
ws_users = conn.list_users()
_user_role_map = {u["email"]: u.get("userRole", "other") for u in ws_users}
_user_display_map = {u["email"]: u.get("displayName", u["email"]) for u in ws_users}
except Exception:
_user_display_map = {}
if not user_emails:
broadcast("scan_error", {"file": "users", "error": "No users to scan — set admin email or provide user_emails"})
broadcast("google_scan_done", {"flagged_count": 0, "total_scanned": 0})
return
source_labels = []
if "gmail" in sources: source_labels.append("Gmail")
if "gdrive" in sources: source_labels.append("Google Drive")
broadcast("scan_start", {"sources": source_labels})
broadcast("scan_phase", {"phase": f"Google Workspace scan · {len(user_emails)} user(s) · " + ", ".join(source_labels)})
# Open DB
_db = _get_db() if DB_OK else None
_db_scan_id = None
if _db:
try:
_db_scan_id = _db.begin_scan(options)
except Exception as e:
logger.error("[google_scan] begin_scan failed: %s", e)
total_flagged = 0
total_scanned = 0
t_start = _time.monotonic()
def _check_abort():
from gdpr_scanner import _scan_abort as _sa
if _sa.is_set():
broadcast("scan_cancelled", {"completed": total_scanned})
return True
return False
def _broadcast_card(item_meta: dict, cprs: list, pii_counts=None):
nonlocal total_flagged
card = {
"id": item_meta.get("id", ""),
"name": item_meta.get("name", ""),
"source": item_meta.get("_source", ""),
"source_type": item_meta.get("_source_type", ""),
"cpr_count": len(cprs),
"url": item_meta.get("_url", ""),
"size_kb": round(item_meta.get("size", 0) / 1024, 1),
"modified": (item_meta.get("lastModifiedDateTime") or item_meta.get("receivedDateTime") or "")[:10],
"thumb_b64": "",
"thumb_mime": "image/svg+xml",
"risk": None,
"account_id": item_meta.get("_account_id", ""),
"account_name": item_meta.get("_account", ""),
"user_role": _user_role_map.get(user_email, "other"),
"drive_id": "",
"attachments": [],
"folder": "",
"transfer_risk": "",
"special_category": [],
"face_count": 0,
"exif": {},
}
flagged_items.append(card)
broadcast("scan_file_flagged", _with_disposition(card, _db))
total_flagged += 1
if _db and _db_scan_id:
try:
_db.save_item(_db_scan_id, card, cprs, pii_counts=pii_counts)
except Exception as e:
logger.error("[google_scan] save_item failed: %s", e)
# ── Per-user scan loop ────────────────────────────────────────────────────
from google_connector import GoogleError
for user_email in user_emails:
_display_name = _user_display_map.get(user_email, user_email)
if _check_abort():
return
broadcast("scan_phase", {"phase": f"Google Workspace \u2014 {user_email}"})
# ── Gmail ─────────────────────────────────────────────────────────────
if "gmail" in sources:
try:
broadcast("scan_phase", {"phase": f"{user_email} — Gmail"})
for meta, data in conn.iter_gmail_messages(
user_email,
max_messages=max_messages,
scan_body=scan_body,
scan_attachments=scan_att,
max_attach_mb=max_attach_mb,
):
if _check_abort():
return
total_scanned += 1
broadcast("scan_file", {"file": meta.get("name", "")})
broadcast("scan_progress", {
"scanned": total_scanned,
"flagged": total_flagged,
"file": meta.get("name", ""),
"pct": min(90, 10 + total_scanned // 10),
"source": "google",
})
try:
meta["_account"] = _display_name
result = _scan_bytes(data, meta.get("name", "msg.txt"))
except Exception as e:
broadcast("scan_error", {"file": meta.get("name", ""), "error": str(e)})
continue
cprs = result.get("cprs", [])
pii_counts = result.get("pii_counts")
if cprs or (pii_counts and any(pii_counts.values())):
_broadcast_card(meta, cprs, pii_counts)
except GoogleError as e:
broadcast("scan_error", {"file": f"Gmail/{user_email}", "error": str(e)})
except Exception as e:
broadcast("scan_error", {"file": f"Gmail/{user_email}", "error": str(e)})
# ── Google Drive ──────────────────────────────────────────────────────
if "gdrive" in sources:
try:
broadcast("scan_phase", {"phase": f"{user_email} — Google Drive"})
for meta, data in conn.iter_drive_files(
user_email,
max_files=max_files,
max_file_mb=max_file_mb,
):
if _check_abort():
return
total_scanned += 1
broadcast("scan_file", {"file": meta.get("name", "")})
broadcast("scan_progress", {
"scanned": total_scanned,
"flagged": total_flagged,
"file": meta.get("name", ""),
"pct": min(90, 10 + total_scanned // 10),
"source": "google",
})
try:
meta["_account"] = _display_name
result = _scan_bytes(data, meta.get("name", "file"))
except Exception as e:
broadcast("scan_error", {"file": meta.get("name", ""), "error": str(e)})
continue
cprs = result.get("cprs", [])
pii_counts = result.get("pii_counts")
if cprs or (pii_counts and any(pii_counts.values())):
_broadcast_card(meta, cprs, pii_counts)
except GoogleError as e:
broadcast("scan_error", {"file": f"Drive/{user_email}", "error": str(e)})
except Exception as e:
broadcast("scan_error", {"file": f"Drive/{user_email}", "error": str(e)})
elapsed = _time.monotonic() - t_start
broadcast("scan_done", {
"flagged_count": total_flagged,
"total_scanned": total_scanned,
"elapsed_seconds": round(elapsed, 1),
})
if _db and _db_scan_id:
try:
_db.end_scan(_db_scan_id, total_scanned, total_flagged)
except Exception:
pass
+47
View File
@@ -0,0 +1,47 @@
"""
Scan profiles
"""
from __future__ import annotations
from flask import Blueprint, jsonify, request
from app_config import _profiles_load, _profile_save, _profile_delete, _profile_get
bp = Blueprint("profiles", __name__)
@bp.route("/api/profiles", methods=["GET"])
def profiles_list():
"""Return all saved profiles."""
return jsonify({"profiles": _profiles_load()})
@bp.route("/api/profiles/save", methods=["POST"])
def profiles_save():
"""Create or update a profile."""
profile = request.get_json() or {}
if not profile.get("name"):
return jsonify({"error": "name required"}), 400
saved = _profile_save(profile)
return jsonify({"status": "saved", "profile": saved})
@bp.route("/api/profiles/delete", methods=["POST"])
def profiles_delete():
"""Delete a profile by name or id."""
data = request.get_json() or {}
key = data.get("name") or data.get("id", "")
if not key:
return jsonify({"error": "name or id required"}), 400
ok = _profile_delete(key)
return jsonify({"status": "deleted" if ok else "not_found"})
@bp.route("/api/profiles/get")
def profiles_get():
"""Return a single profile by name or id."""
key = request.args.get("name") or request.args.get("id", "")
p = _profile_get(key)
if not p:
return jsonify({"error": "not found"}), 404
return jsonify({"profile": p})
+137
View File
@@ -0,0 +1,137 @@
"""
Scan stream, start/stop, checkpoint, settings, delta
"""
from __future__ import annotations
import threading
from flask import Blueprint, jsonify, request
from routes import state
from app_config import (
_save_settings, _load_settings,
_load_src_toggles, _save_src_toggles,
)
from checkpoint import (
_checkpoint_key, _load_checkpoint, _clear_checkpoint,
_load_delta_tokens, _DELTA_PATH,
)
bp = Blueprint("scan", __name__)
@bp.route("/api/scan/status")
def scan_status():
"""Lightweight status check — is a scan running? What scan_id?"""
import sse as _sse_mod
acquired = state._scan_lock.acquire(blocking=False)
if acquired:
state._scan_lock.release()
return jsonify({
"running": not acquired,
"scan_id": _sse_mod._current_scan_id or None,
})
@bp.route("/api/src_toggles", methods=["GET", "POST"])
def src_toggles():
"""GET: return source toggle state. POST: save."""
if request.method == "POST":
_save_src_toggles(request.get_json() or {})
return jsonify({"ok": True})
return jsonify(_load_src_toggles())
@bp.route("/api/scan/start", methods=["POST"])
def scan_start():
if not state.connector:
return jsonify({"error": "not authenticated"}), 401
if not state._scan_lock.acquire(blocking=False):
return jsonify({"error": "scan already running"}), 409
options = request.get_json() or {}
state._scan_abort.clear()
profile_id = options.pop("profile_id", None)
_save_settings({
"sources": options.get("sources", []),
"user_ids": options.get("user_ids", []),
"options": options.get("options", {}),
}, profile_id=profile_id)
def _run():
from scan_engine import run_scan
try:
run_scan(options)
finally:
state._scan_lock.release()
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
@bp.route("/api/scan/stop", methods=["POST"])
def scan_stop():
state._scan_abort.set()
return jsonify({"status": "stopping"})
@bp.route("/api/scan/checkpoint", methods=["POST"])
def scan_checkpoint_info():
"""Return info about any saved checkpoint for the given scan options.
If check_only=true, just reports whether a scan is currently running."""
options = request.get_json() or {}
if options.get("check_only"):
acquired = state._scan_lock.acquire(blocking=False)
if acquired:
state._scan_lock.release()
return jsonify({"running": not acquired})
key = _checkpoint_key(options)
cp = _load_checkpoint(key)
if not cp:
return jsonify({"exists": False})
return jsonify({
"exists": True,
"scanned_count": len(cp.get("scanned_ids", [])),
"flagged_count": len(cp.get("flagged", [])),
"started_at": cp.get("meta", {}).get("started_at"),
})
@bp.route("/api/scan/clear_checkpoint", methods=["POST"])
def scan_clear_checkpoint():
"""Discard any saved checkpoint so the next scan starts fresh."""
_clear_checkpoint()
return jsonify({"status": "cleared"})
@bp.route("/api/settings/save", methods=["POST"])
def settings_save():
"""Persist scan settings so they can be reused by --headless mode."""
payload = request.get_json() or {}
_save_settings(payload)
return jsonify({"status": "saved"})
@bp.route("/api/settings/load")
def settings_load():
"""Return previously saved scan settings (for --headless setup guidance)."""
s = _load_settings()
if not s:
return jsonify({"exists": False})
return jsonify({"exists": True, "settings": s})
@bp.route("/api/delta/status")
def delta_status():
"""Return info about stored delta tokens."""
tokens = _load_delta_tokens()
return jsonify({
"count": len(tokens),
"keys": list(tokens.keys()),
"exists": len(tokens) > 0,
})
@bp.route("/api/delta/clear", methods=["POST"])
def delta_clear():
"""Discard all stored delta tokens (next scan will be a full scan)."""
try:
if _DELTA_PATH.exists():
_DELTA_PATH.unlink()
except Exception as e:
return jsonify({"error": str(e)}), 500
return jsonify({"status": "cleared"})
+156
View File
@@ -0,0 +1,156 @@
"""
Scheduler API routes — multi-job CRUD, status, history, run-now.
"""
from __future__ import annotations
from flask import Blueprint, jsonify, request
import sys, os, threading
bp = Blueprint("scheduler", __name__)
# Return JSON for any unhandled exception in this blueprint
@bp.errorhandler(Exception)
def _handle_error(e):
import traceback; traceback.print_exc()
return jsonify({"error": str(e)}), 500
# Ensure the project root is on sys.path so `import scheduler` finds
# our scheduler.py and not any stdlib module.
def _sm():
import scan_scheduler as _s
return _s
def _sched():
import scan_scheduler as _s
return _s.scan_scheduler
def _db():
import gdpr_scanner as _m
return _m._get_db() if _m.DB_OK else None
# ── Job list ──────────────────────────────────────────────────────────────────
@bp.route("/api/scheduler/jobs", methods=["GET"])
def scheduler_jobs_list():
return jsonify({"jobs": _sm().load_jobs()})
@bp.route("/api/scheduler/jobs/save", methods=["POST"])
def scheduler_jobs_save():
try:
sm = _sm()
data = request.get_json() or {}
jobs = sm.load_jobs()
job_id = (data.get("id") or "").strip()
if job_id:
for i, j in enumerate(jobs):
if j["id"] == job_id:
jobs[i] = {**sm._DEFAULT_JOB, **j, **data}
sm.save_jobs(jobs)
try:
_sched().reload()
except Exception:
pass
return jsonify({"ok": True, "job": jobs[i]})
# New job
job = sm._new_job(data)
jobs.append(job)
sm.save_jobs(jobs)
try:
_sched().reload()
except Exception:
pass
return jsonify({"ok": True, "job": job})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@bp.route("/api/scheduler/jobs/delete", methods=["POST"])
def scheduler_jobs_delete():
try:
sm = _sm()
job_id = (request.get_json() or {}).get("id", "")
if not job_id:
return jsonify({"error": "id required"}), 400
jobs = [j for j in sm.load_jobs() if j["id"] != job_id]
sm.save_jobs(jobs)
try:
_sched().reload()
except Exception:
pass
return jsonify({"ok": True})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
# ── Run now ───────────────────────────────────────────────────────────────────
@bp.route("/api/scheduler/jobs/run_now", methods=["POST"])
def scheduler_jobs_run_now():
job_id = (request.get_json() or {}).get("id", "")
s = _sched()
if job_id in s._running_jobs:
return jsonify({"error": "Job already running"}), 409
if s.is_running:
return jsonify({"error": "Another scan is already running"}), 409
threading.Thread(target=s._execute_scan, args=[job_id], daemon=True).start()
return jsonify({"status": "started"})
# ── Status ────────────────────────────────────────────────────────────────────
@bp.route("/api/scheduler/status")
def scheduler_status():
return jsonify(_sched().get_status())
# ── History ───────────────────────────────────────────────────────────────────
@bp.route("/api/scheduler/history")
def scheduler_history():
db = _db()
if not db:
return jsonify({"runs": []})
try:
limit = int(request.args.get("limit", 20))
job_id = request.args.get("job_id")
try:
runs = db.get_schedule_runs(limit=limit, job_id=job_id)
except TypeError:
runs = db.get_schedule_runs(limit=limit)
return jsonify({"runs": runs})
except Exception as e:
return jsonify({"runs": [], "error": str(e)})
# ── Backward-compat single-job endpoints ─────────────────────────────────────
@bp.route("/api/scheduler/config", methods=["GET"])
def scheduler_config_get():
return jsonify(_sm().load_schedule_config())
@bp.route("/api/scheduler/config", methods=["POST"])
def scheduler_config_save():
sm = _sm()
data = request.get_json() or {}
merged = {**sm.load_schedule_config(), **data}
sm.save_schedule_config(merged)
s = _sched()
s.reload()
return jsonify({"status": "saved", "config": merged,
"next_run": s.next_run_time()})
@bp.route("/api/scheduler/run_now", methods=["POST"])
def scheduler_run_now():
s = _sched()
if s.is_running:
return jsonify({"error": "Scheduled scan already running"}), 409
threading.Thread(target=s._execute_scan, args=[None], daemon=True).start()
return jsonify({"status": "started"})
+100
View File
@@ -0,0 +1,100 @@
"""
File sources and file scan
"""
from __future__ import annotations
import threading
from flask import Blueprint, jsonify, request
from routes import state
from app_config import _load_file_sources, _save_file_sources
try:
from file_scanner import store_smb_password, SMB_OK as _SMB_OK
_FILE_SCANNER_OK = True
except ImportError:
_FILE_SCANNER_OK = False
_SMB_OK = False
def store_smb_password(*a, **kw): return False # type: ignore[misc]
bp = Blueprint("sources", __name__)
@bp.route("/api/file_sources", methods=["GET"])
def file_sources_list():
"""Return all saved file source definitions."""
sources = _load_file_sources()
return jsonify({
"sources": sources,
"smb_available": _SMB_OK,
"scanner_ok": _FILE_SCANNER_OK,
})
@bp.route("/api/file_sources/save", methods=["POST"])
def file_sources_save():
"""Add or update a file source. Assigns a UUID if id is missing."""
import uuid as _uuid
data = request.get_json() or {}
path = data.get("path", "").strip()
if not path:
return jsonify({"error": "path required"}), 400
sources = _load_file_sources()
uid = data.get("id") or ""
for i, s in enumerate(sources):
if s.get("id") == uid:
sources[i] = {**s, **data}
_save_file_sources(sources)
return jsonify({"ok": True, "source": sources[i]})
data["id"] = data.get("id") or str(_uuid.uuid4())
sources.append(data)
_save_file_sources(sources)
return jsonify({"ok": True, "source": data})
@bp.route("/api/file_sources/delete", methods=["POST"])
def file_sources_delete():
"""Remove a file source by id."""
uid = (request.get_json() or {}).get("id", "")
if not uid:
return jsonify({"error": "id required"}), 400
sources = [s for s in _load_file_sources() if s.get("id") != uid]
_save_file_sources(sources)
return jsonify({"ok": True})
@bp.route("/api/file_sources/store_creds", methods=["POST"])
def file_sources_store_creds():
"""Store SMB password in the OS keychain."""
if not _FILE_SCANNER_OK:
return jsonify({"error": "file_scanner not available"}), 503
data = request.get_json() or {}
smb_host = data.get("smb_host", "")
smb_user = data.get("smb_user", "")
password = data.get("password", "")
key = data.get("keychain_key") or smb_user
if not smb_user or not password:
return jsonify({"error": "smb_user and password required"}), 400
ok = store_smb_password(smb_host, smb_user, password, key)
if ok:
return jsonify({"ok": True, "keychain_key": key})
return jsonify({"error": "keyring not available — install: pip install keyring"}), 500
@bp.route("/api/file_scan/start", methods=["POST"])
def file_scan_start():
"""Start a file system scan for a single file source."""
if not _FILE_SCANNER_OK:
return jsonify({"error": "file_scanner not available"}), 503
if not state._scan_lock.acquire(blocking=False):
return jsonify({"error": "scan already running"}), 409
source = request.get_json() or {}
state._scan_abort.clear()
def _run():
from scan_engine import run_file_scan
try:
run_file_scan(source)
finally:
state._scan_lock.release()
threading.Thread(target=_run, daemon=True).start()
return jsonify({"status": "started"})
+41
View File
@@ -0,0 +1,41 @@
"""
Shared mutable state for GDPR Scanner.
All modules (gdpr_scanner.py and route blueprints) import from here.
This avoids circular imports while keeping a single source of truth
for every global that routes need to read or write.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from m365_connector import M365Connector
# ── Auth ──────────────────────────────────────────────────────────────────────
connector: "M365Connector | None" = None
pending_flow: "dict | None" = None
auth_poll_result: "dict | None" = None
# ── Google Workspace ──────────────────────────────────────────────────────────
google_connector = None # GoogleConnector | PersonalGoogleConnector | None
google_pending_flow: "dict | None" = None
google_poll_result: "str | None" = None
# ── Scan concurrency ──────────────────────────────────────────────────────────
import threading as _threading
_scan_lock = _threading.Lock()
_scan_abort = _threading.Event()
_google_scan_lock = _threading.Lock()
_google_scan_abort = _threading.Event()
# ── Scan results (in-memory session cache) ────────────────────────────────────
flagged_items: list = []
scan_meta: dict = {}
# ── i18n ─────────────────────────────────────────────────────────────────────
LANG: dict = {}
# ── Art. 9 keyword data ───────────────────────────────────────────────────────
compiled_keywords: list = [] # list of compiled re.Pattern
keyword_data: dict = {} # raw keyword dict from JSON
keyword_flat: list = [] # flat list of keyword strings
+217
View File
@@ -0,0 +1,217 @@
"""
User listing, role overrides, license debug
"""
from __future__ import annotations
import logging
import traceback
from flask import Blueprint, jsonify, request
from routes import state
from app_config import (
_load_role_overrides, _save_role_overrides, _resolve_display_name,
)
bp = Blueprint("users", __name__)
logger = logging.getLogger(__name__)
@bp.route("/api/users")
def get_users():
"""List all tenant users for account selection."""
if not state.connector:
return jsonify({"error": "not authenticated"}), 401
try:
users = state.connector.list_users()
out = []
seen = set()
# Build SKU map for role classification.
# get_subscribed_skus() tries /subscribedSkus → /me/licenseDetails.
# Then always merge per-user licenseDetails on top — this ensures we
# have skuPartNumbers for every distinct SKU in the tenant, not just
# the admin's own license (which is all /me/licenseDetails returns).
try:
sku_map = state.connector.get_subscribed_skus()
except Exception:
sku_map = {}
try:
per_user = state.connector.build_sku_map_from_users(users)
if per_user:
added = len(set(per_user) - set(sku_map))
sku_map.update(per_user)
if added:
logger.info("[skus] merged %d additional SKU(s) from per-user licenseDetails", added)
except Exception:
pass
# Load any manual role overrides set by the admin
_role_overrides = _load_role_overrides()
def _build_user(u: dict, is_me: bool = False) -> dict:
_em = u.get("mail") or u.get("userPrincipalName", "")
_auto = state.connector.classify_user_role(
u.get("assignedLicenses", []), sku_map
)
# Manual override takes precedence over auto-classification
_role = _role_overrides.get(u["id"], _auto)
return {
"id": u["id"],
"displayName": _resolve_display_name(u.get("displayName", ""), _em),
"email": _em,
"isMe": is_me,
"userRole": _role,
"roleOverride": u["id"] in _role_overrides,
}
if state.connector.is_app_mode:
for u in users:
uid = u.get("id")
if uid and uid not in seen:
seen.add(uid)
out.append(_build_user(u))
else:
me = state.connector.get_user_info()
me_id = me.get("id")
for u in ([me] + users):
uid = u.get("id")
if uid and uid not in seen:
seen.add(uid)
out.append(_build_user(u, is_me=(uid == me_id)))
# Log a warning when no users were classified — helps diagnose
# tenants with SKUs not yet in m365_skus.json
classified = [u for u in out if u["userRole"] in ("student", "staff")]
if out and not classified:
unknown_skus: set = set()
for u in users[:20]: # sample first 20 to keep it brief
for lic in u.get("assignedLicenses", []):
sid = lic.get("skuId", "")
if sid:
unknown_skus.add(sid)
logger.warning(
"[role] 0/%d users classified — no SKUs in m365_skus.json matched. "
"Unrecognised SKU IDs (sample): %s. "
"Add them to classification/m365_skus.json or use /api/users/license_debug.",
len(out), sorted(unknown_skus)[:10],
)
return jsonify({
"users": out,
"sku_map_available": bool(sku_map),
"unclassified": len(out) - len(classified),
})
except Exception as e:
return jsonify({"error": str(e), "detail": traceback.format_exc()}), 500
@bp.route("/api/users/license_debug")
def license_debug():
"""Full diagnostic: runtime SKU sets, sku_map, per-user trace, and step-by-step
classification walk for every user — enough to diagnose any remaining issue."""
if not state.connector:
return jsonify({"error": "not authenticated"}), 401
try:
users = state.connector.list_users()
sku_map = state.connector.get_subscribed_skus()
try:
sku_map.update(state.connector.build_sku_map_from_users(users))
except Exception:
pass
# Per-user trace with step-by-step classification walk
out = []
for u in users[:100]:
lics = u.get("assignedLicenses", [])
role = state.connector.classify_user_role(lics, sku_map)
# Walk each licence exactly as classify_user_role does
lic_trace = []
for lic in lics:
raw_id = lic.get("skuId", "")
low_id = raw_id.lower()
name = sku_map.get(low_id) or sku_map.get(raw_id) or "?"
lic_trace.append({
"skuId": raw_id,
"skuName": name,
"in_staff": low_id in state.connector._STAFF_SKU_IDS,
"in_student": low_id in state.connector._STUDENT_SKU_IDS,
"frag_staff": next((f for f in state.connector._STAFF_SKU_FRAGMENTS
if f in name.upper()), None),
"frag_student": next((f for f in state.connector._STUDENT_SKU_FRAGMENTS
if f in name.upper()), None),
})
out.append({
"displayName": u.get("displayName", ""),
"email": u.get("mail") or u.get("userPrincipalName", ""),
"role": role,
"licences": lic_trace,
})
return jsonify({
# Runtime state — proves whether m365_skus.json loaded correctly
"runtime": {
"student_ids_count": len(state.connector._STUDENT_SKU_IDS),
"staff_ids_count": len(state.connector._STAFF_SKU_IDS),
"student_fragments": list(state.connector._STUDENT_SKU_FRAGMENTS),
"staff_fragments": list(state.connector._STAFF_SKU_FRAGMENTS),
"sku_map_entries": len(sku_map),
"sku_file_path": str(state.connector._sku_file_path()),
},
"student_ids": sorted(state.connector._STUDENT_SKU_IDS),
"staff_ids": sorted(state.connector._STAFF_SKU_IDS),
"sku_map": sku_map,
"users": out,
})
except Exception as e:
return jsonify({"error": str(e), "detail": traceback.format_exc()}), 500
@bp.route("/api/users/lookup")
def lookup_user():
"""Look up a single user by UPN or email."""
if not state.connector:
return jsonify({"error": "not authenticated"}), 401
upn = request.args.get("upn", "").strip()
if not upn:
return jsonify({"error": "upn required"}), 400
try:
data = state.connector._get(f"/users/{upn}", {"$select": "id,displayName,mail,userPrincipalName"})
_email = data.get("mail") or data.get("userPrincipalName", upn)
return jsonify({
"id": data["id"],
"displayName": _resolve_display_name(data.get("displayName", ""), _email, upn),
"email": _email,
"isMe": False,
})
except Exception as e:
return jsonify({"error": str(e)}), 404
@bp.route("/api/users/role_override", methods=["GET"])
def role_override_get():
"""Return all manual role overrides as {user_id: role}."""
return jsonify(_load_role_overrides())
@bp.route("/api/users/role_override", methods=["POST"])
def role_override_set():
"""Set or clear a manual role override for one user.
Body: {user_id, role} — role is 'student' | 'staff' | 'other' | '' (clear).
"""
data = request.get_json() or {}
uid = data.get("user_id", "").strip()
role = data.get("role", "").strip().lower()
if not uid:
return jsonify({"error": "user_id required"}), 400
if role and role not in ("student", "staff", "other"):
return jsonify({"error": "role must be student | staff | other | '' (clear)"}), 400
overrides = _load_role_overrides()
if role:
overrides[uid] = role
else:
overrides.pop(uid, None)
_save_role_overrides(overrides)
return jsonify({"ok": True, "user_id": uid, "role": role or None,
"total_overrides": len(overrides)})
+152
View File
@@ -0,0 +1,152 @@
"""
Read-only viewer token + PIN management routes (#33).
"""
from __future__ import annotations
import time
from flask import Blueprint, jsonify, request, session
from app_config import (
create_viewer_token,
validate_viewer_token,
revoke_viewer_token,
cleanup_expired_viewer_tokens,
_load_viewer_tokens,
get_viewer_pin_hash,
set_viewer_pin,
verify_viewer_pin,
clear_viewer_pin,
)
bp = Blueprint("viewer", __name__)
# Simple brute-force guard: keyed by remote IP.
_pin_attempts: dict[str, list[float]] = {}
_MAX_ATTEMPTS = 5
_WINDOW_S = 300 # 5 minutes
def _pin_rate_limit(ip: str) -> bool:
"""Return True if the IP is rate-limited (too many recent failures)."""
now = time.time()
times = [t for t in _pin_attempts.get(ip, []) if now - t < _WINDOW_S]
_pin_attempts[ip] = times
return len(times) >= _MAX_ATTEMPTS
def _pin_record_failure(ip: str) -> None:
now = time.time()
_pin_attempts.setdefault(ip, []).append(now)
def _pin_clear_failures(ip: str) -> None:
_pin_attempts.pop(ip, None)
# ── Token endpoints ───────────────────────────────────────────────────────────
@bp.route("/api/viewer/tokens", methods=["GET"])
def list_tokens():
cleanup_expired_viewer_tokens()
tokens = _load_viewer_tokens()
safe = [
{
"token_hint": t["token"][:8] + "…",
"token": t["token"],
"label": t.get("label", ""),
"created_at": t.get("created_at"),
"expires_at": t.get("expires_at"),
"last_used_at": t.get("last_used_at"),
}
for t in tokens
]
return jsonify(safe)
@bp.route("/api/viewer/tokens", methods=["POST"])
def create_token():
body = request.get_json(silent=True) or {}
label = str(body.get("label", "")).strip()
expires_days = body.get("expires_days")
if expires_days is not None:
try:
expires_days = int(expires_days)
if expires_days <= 0:
return jsonify({"error": "expires_days must be a positive integer"}), 400
except (TypeError, ValueError):
return jsonify({"error": "expires_days must be a positive integer"}), 400
entry = create_viewer_token(label=label, expires_days=expires_days)
return jsonify(entry), 201
@bp.route("/api/viewer/tokens/<token>", methods=["DELETE"])
def delete_token(token: str):
if not token:
return jsonify({"error": "token required"}), 400
removed = revoke_viewer_token(token)
if not removed:
return jsonify({"error": "token not found"}), 404
return jsonify({"ok": True})
@bp.route("/api/viewer/tokens/validate", methods=["POST"])
def validate_token():
body = request.get_json(silent=True) or {}
token = str(body.get("token", "")).strip()
entry = validate_viewer_token(token)
if entry is None:
return jsonify({"valid": False}), 401
return jsonify({"valid": True, "label": entry.get("label", ""), "expires_at": entry.get("expires_at")})
# ── PIN endpoints ─────────────────────────────────────────────────────────────
@bp.route("/api/viewer/pin", methods=["GET"])
def pin_status():
"""Return whether a viewer PIN is currently set."""
return jsonify({"pin_set": bool(get_viewer_pin_hash())})
@bp.route("/api/viewer/pin", methods=["POST"])
def pin_set():
"""Set or change the viewer PIN.
Body: {pin: "...", current_pin: "..."}
current_pin required only when a PIN is already set.
"""
body = request.get_json(silent=True) or {}
new_pin = str(body.get("pin", "")).strip()
if not new_pin:
return jsonify({"error": "pin required"}), 400
if not new_pin.isdigit() or not (4 <= len(new_pin) <= 8):
return jsonify({"error": "PIN must be 4–8 digits"}), 400
if get_viewer_pin_hash():
if not verify_viewer_pin(str(body.get("current_pin", "")).strip()):
return jsonify({"error": "current PIN is incorrect"}), 403
set_viewer_pin(new_pin)
return jsonify({"ok": True})
@bp.route("/api/viewer/pin", methods=["DELETE"])
def pin_clear():
"""Remove the viewer PIN. Requires current PIN if one is set."""
body = request.get_json(silent=True) or {}
if get_viewer_pin_hash():
if not verify_viewer_pin(str(body.get("current_pin", "")).strip()):
return jsonify({"error": "current PIN is incorrect"}), 403
clear_viewer_pin()
return jsonify({"ok": True})
@bp.route("/api/viewer/pin/verify", methods=["POST"])
def pin_verify():
"""Verify a PIN submission and set a viewer session on success."""
ip = request.remote_addr or "unknown"
if _pin_rate_limit(ip):
return jsonify({"error": "Too many failed attempts. Try again later."}), 429
body = request.get_json(silent=True) or {}
pin = str(body.get("pin", "")).strip()
if not verify_viewer_pin(pin):
_pin_record_failure(ip)
remaining = _MAX_ATTEMPTS - len(_pin_attempts.get(ip, []))
return jsonify({"error": "Incorrect PIN", "remaining": max(0, remaining)}), 401
_pin_clear_failures(ip)
session["viewer_ok"] = True
return jsonify({"ok": True})