diff --git a/CLAUDE.md b/CLAUDE.md index 971ca70..5a83d15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ A GDPR compliance scanner for Danish educational and municipal organisations. Sc ```bash source venv/bin/activate -python gdpr_scanner.py # http://localhost:5100 +python gdpr_scanner.py # http://0.0.0.0:5100 (all interfaces) python -m pytest tests/ -q ``` @@ -51,6 +51,8 @@ Read-only access for DPOs and reviewers. Key invariants: - **Token onclick attributes** — Copy/Revoke buttons in `_renderTokenList()` pass the token as a single-quoted JS string literal (`'\'' + tok.token + '\''`), never via `JSON.stringify`. `JSON.stringify` produces double-quoted strings that break the surrounding `onclick="…"` HTML attribute. - **Settings Security pane** — Admin PIN and Viewer PIN groups live in `stPaneSecurity`, not `stPaneGeneral`. `switchSettingsTab('security')` in `sources.js` triggers both `stLoadPinStatus()` and `stLoadViewerPinStatus()`. The Share modal Configure button opens `openSettings('security')`. - **`stClearViewerPin` guard** — validates that the current-PIN field is non-empty client-side before sending the DELETE request; shows an inline error and focuses the field if empty. +- **Share link base URL** — `_getShareBaseUrl()` in `viewer.js` fetches `/api/local_ip` (returns the machine's LAN IP via a UDP probe to `8.8.8.8`) and substitutes it so copied links are routable from other machines. Falls back to `window.location.origin` on error. Both `createShareLink` and `copyTokenLink` are `async` and `await` this helper. Do not revert to a bare `window.location.origin` — that produces `127.0.0.1` links useless to remote viewers. +- **Flask binds to `0.0.0.0`** — `gdpr_scanner.py` default `--host`, `m365_launcher.py`, and `build_gdpr.py` all use `host="0.0.0.0"`. Internal loopback URLs (urllib exports, webview window, port probe) intentionally keep `127.0.0.1` — do not change those to `0.0.0.0`. ## Sources panel resize — static/js/log.js + sources.js diff --git a/build_gdpr.py b/build_gdpr.py index 1bd2fc0..e51f822 100755 --- a/build_gdpr.py +++ b/build_gdpr.py @@ -323,7 +323,7 @@ _activate_venv() def start_flask(port: int): import gdpr_scanner as _app - _app.app.run(host="127.0.0.1", port=port, debug=False, + _app.app.run(host="0.0.0.0", port=port, debug=False, threaded=True, use_reloader=False) diff --git a/gdpr_scanner.py b/gdpr_scanner.py index aaa3fb9..61a41ad 100644 --- a/gdpr_scanner.py +++ b/gdpr_scanner.py @@ -15,6 +15,7 @@ import base64 import hashlib import io import json +import socket import logging import logging.handlers import os @@ -1432,6 +1433,18 @@ def _build_article30_docx() -> tuple[bytes, str]: +@app.route("/api/local_ip") +def local_ip(): + """Return the machine's LAN IP so viewer links point to a routable address.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as _s: + _s.connect(("8.8.8.8", 80)) + ip = _s.getsockname()[0] + except Exception: + ip = "127.0.0.1" + return jsonify({"ip": ip}) + + @app.route("/api/scan/stream") def scan_stream(): q = queue.Queue(maxsize=512) @@ -1538,7 +1551,7 @@ Example --settings file with SMTP: """, ) parser.add_argument("--port", type=int, default=5100) - parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--headless", action="store_true", help="Run a non-interactive scan and export Excel, then exit") parser.add_argument("--output", default=".", diff --git a/m365_launcher.py b/m365_launcher.py index 55a882b..3d8042f 100644 --- a/m365_launcher.py +++ b/m365_launcher.py @@ -218,7 +218,7 @@ _activate_venv() def start_flask(port: int): import gdpr_scanner as _app - _app.app.run(host="127.0.0.1", port=port, debug=False, + _app.app.run(host="0.0.0.0", port=port, debug=False, threaded=True, use_reloader=False) diff --git a/static/js/CLAUDE.md b/static/js/CLAUDE.md index ab86e84..36181a9 100644 --- a/static/js/CLAUDE.md +++ b/static/js/CLAUDE.md @@ -26,3 +26,4 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos - **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked. - **Date presets** — stored as `years * 365` (integer days). Do not use `* 365.25`. +- **`copyTokenLink` is async** — called from `onclick` attributes as a fire-and-forget (the Promise is unhandled, which is fine). It `await`s `_getShareBaseUrl()` to get the machine's LAN IP before building the URL. Do not make it synchronous or revert to `window.location.origin` directly. diff --git a/static/js/viewer.js b/static/js/viewer.js index ce19203..e334bb4 100644 --- a/static/js/viewer.js +++ b/static/js/viewer.js @@ -1,6 +1,20 @@ // ── Viewer token management (#33) ───────────────────────────────────────────── // Share button → modal to create, copy, and revoke read-only viewer links. +async function _getShareBaseUrl() { + // Use the machine's LAN IP so links work for remote users, not just localhost. + try { + const r = await fetch('/api/local_ip'); + if (r.ok) { + const d = await r.json(); + if (d.ip && d.ip !== '127.0.0.1') { + return 'http://' + d.ip + ':' + window.location.port; + } + } + } catch(e) {} + return window.location.origin; +} + function openShareModal() { document.getElementById('shareBackdrop').classList.add('open'); document.getElementById('shareNewLinkRow').style.display = 'none'; @@ -69,7 +83,7 @@ async function createShareLink() { }); if (!r.ok) throw new Error('Server error ' + r.status); const entry = await r.json(); - const url = window.location.origin + '/view?token=' + encodeURIComponent(entry.token); + const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(entry.token); const urlInput = document.getElementById('shareNewLinkUrl'); urlInput.value = url; document.getElementById('shareNewLinkRow').style.display = 'block'; @@ -86,8 +100,8 @@ function copyShareLink() { _copyText(url, document.getElementById('shareCopyBtn')); } -function copyTokenLink(token, btn) { - const url = window.location.origin + '/view?token=' + encodeURIComponent(token); +async function copyTokenLink(token, btn) { + const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(token); _copyText(url, btn); }