Initial commit
This commit is contained in:
commit
9c7df76fbd
91 files changed
+36572
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
@@ -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 ""),
|
||||
})
|
||||
@@ -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', '')} <{sender.get('address', '')}>"
|
||||
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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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
@@ -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)})
|
||||
@@ -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})
|
||||
Reference in new issue
Block a user