Fix viewer share links to use LAN IP; bind Flask to 0.0.0.0
Share links copied from the Share modal were built with window.location.origin, producing 127.0.0.1 URLs that remote viewers could never reach. - Bind Flask to 0.0.0.0 in gdpr_scanner.py (--host default), m365_launcher.py, and build_gdpr.py so the server is reachable on the local network. Internal loopback URLs (urllib exports, webview window, port probe) intentionally keep 127.0.0.1. - Add /api/local_ip endpoint: UDP probe to 8.8.8.8 discovers the active LAN IP without sending real traffic. - Add _getShareBaseUrl() in viewer.js: fetches /api/local_ip and substitutes the LAN IP; falls back to window.location.origin. - createShareLink and copyTokenLink are now async and await _getShareBaseUrl() before building the viewer URL. - Update CLAUDE.md and static/js/CLAUDE.md with the new invariants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
66bbf35192
commit
3ad68b45f7
@ -6,7 +6,7 @@ A GDPR compliance scanner for Danish educational and municipal organisations. Sc
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
source venv/bin/activate
|
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
|
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.
|
- **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')`.
|
- **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.
|
- **`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
|
## Sources panel resize — static/js/log.js + sources.js
|
||||||
|
|
||||||
|
|||||||
@ -323,7 +323,7 @@ _activate_venv()
|
|||||||
|
|
||||||
def start_flask(port: int):
|
def start_flask(port: int):
|
||||||
import gdpr_scanner as _app
|
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)
|
threaded=True, use_reloader=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import os
|
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")
|
@app.route("/api/scan/stream")
|
||||||
def scan_stream():
|
def scan_stream():
|
||||||
q = queue.Queue(maxsize=512)
|
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("--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",
|
parser.add_argument("--headless", action="store_true",
|
||||||
help="Run a non-interactive scan and export Excel, then exit")
|
help="Run a non-interactive scan and export Excel, then exit")
|
||||||
parser.add_argument("--output", default=".",
|
parser.add_argument("--output", default=".",
|
||||||
|
|||||||
@ -218,7 +218,7 @@ _activate_venv()
|
|||||||
|
|
||||||
def start_flask(port: int):
|
def start_flask(port: int):
|
||||||
import gdpr_scanner as _app
|
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)
|
threaded=True, use_reloader=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
- **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`.
|
- **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.
|
||||||
|
|||||||
@ -1,6 +1,20 @@
|
|||||||
// ── Viewer token management (#33) ─────────────────────────────────────────────
|
// ── Viewer token management (#33) ─────────────────────────────────────────────
|
||||||
// Share button → modal to create, copy, and revoke read-only viewer links.
|
// 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() {
|
function openShareModal() {
|
||||||
document.getElementById('shareBackdrop').classList.add('open');
|
document.getElementById('shareBackdrop').classList.add('open');
|
||||||
document.getElementById('shareNewLinkRow').style.display = 'none';
|
document.getElementById('shareNewLinkRow').style.display = 'none';
|
||||||
@ -69,7 +83,7 @@ async function createShareLink() {
|
|||||||
});
|
});
|
||||||
if (!r.ok) throw new Error('Server error ' + r.status);
|
if (!r.ok) throw new Error('Server error ' + r.status);
|
||||||
const entry = await r.json();
|
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');
|
const urlInput = document.getElementById('shareNewLinkUrl');
|
||||||
urlInput.value = url;
|
urlInput.value = url;
|
||||||
document.getElementById('shareNewLinkRow').style.display = 'block';
|
document.getElementById('shareNewLinkRow').style.display = 'block';
|
||||||
@ -86,8 +100,8 @@ function copyShareLink() {
|
|||||||
_copyText(url, document.getElementById('shareCopyBtn'));
|
_copyText(url, document.getElementById('shareCopyBtn'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyTokenLink(token, btn) {
|
async function copyTokenLink(token, btn) {
|
||||||
const url = window.location.origin + '/view?token=' + encodeURIComponent(token);
|
const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(token);
|
||||||
_copyText(url, btn);
|
_copyText(url, btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user