11 Commits
Author SHA1 Message Date
StyxX65andClaude Fable 5 841311a6bd Release 1.7.6
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled
- CHANGELOG: cut the 1.7.6 release (dated 2026-06-11); reset Unreleased.
- VERSION: 1.7.5 -> 1.7.6.
- Manuals (DA + EN): bump version stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:02:44 +02:00
StyxX65andClaude Fable 5 dd19be8bbf Close leaked listening socket on update restart
Werkzeug sets its server socket inheritable unconditionally, so the
os.execv restart carried it into the new process as a zombie listener:
one PID listening on both 5100 (never accepted) and 5101 (the real
server). Mark all fds above stderr close-on-exec before exec'ing so
the old socket dies and the new server rebinds the original port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:01:17 +02:00
StyxX65andClaude Fable 5 c43725ca7f Release 1.7.5
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled
- CHANGELOG: cut the 1.7.5 release (dated 2026-06-11); reset Unreleased.
- VERSION: 1.7.4 -> 1.7.5.
- Manuals (DA + EN): bump version stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:42:06 +02:00
StyxX65andClaude Fable 5 a1712ae178 Make static files revalidate so the UI is fresh after updates
No Cache-Control header meant browsers cached JS/CSS heuristically for
days; after a server update (including the in-app self-update reload)
the backend was new but the frontend stayed stale. SEND_FILE_MAX_AGE
_DEFAULT=0 forces ETag revalidation — 304 when unchanged, fresh file
immediately after an update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:39:45 +02:00
StyxX65andClaude Fable 5 c1cddb8ea7 Release 1.7.4
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled
- CHANGELOG: cut the 1.7.4 release (dated 2026-06-10); reset Unreleased.
- VERSION: 1.7.3 -> 1.7.4.
- Manuals (DA + EN): bump version stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:33:16 +02:00
StyxX65andClaude Fable 5 9cbd93e1f5 Reset all share modal fields after creating a link
Create only cleared the label; scope type, user email, date range, and
expiry carried over, so the next link silently inherited the previous
link's scope. Extracted openShareModal's reset logic into
_resetShareForm() and call it after every successful create.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:32:03 +02:00
StyxX65andClaude Fable 5 d4cf2db347 Release 1.7.3
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled
- CHANGELOG: cut the 1.7.3 release (dated 2026-06-10); reset Unreleased.
- VERSION: 1.7.2 -> 1.7.3.
- Manuals (DA + EN): bump version stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:24:27 +02:00
StyxX65andClaude Fable 5 d6bf80a68a Keep the same port across app restarts
The port probe did a plain bind() without SO_REUSEADDR, so TIME_WAIT
connections left by the previous instance (e.g. the in-app update
restart) made the port look occupied and the app hopped to the next
one. Probe with SO_REUSEADDR like Werkzeug binds, and give the
requested port a 10-second grace period before auto-incrementing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:23:18 +02:00
StyxX65andClaude Fable 5 679f91da2c Use page origin for share links except when browsing at localhost
The LAN-IP rewrite in _getShareBaseUrl() exists to fix unusable
127.0.0.1 links; applying it to every origin meant links copied behind
a reverse proxy pointed at http://<LAN-IP>:5100, bypassing TLS. HTTPS
and non-localhost origins are now used as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:14:33 +02:00
StyxX65andClaude Fable 5 c79e7097ea Release 1.7.2
Build — Windows, Linux & macOS / GDPRScanner / linux (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / macos (push) Has been cancelled
Build — Windows, Linux & macOS / GDPRScanner / windows (push) Has been cancelled
Build — Windows, Linux & macOS / Create GitHub Release (push) Has been cancelled
- CHANGELOG: cut the 1.7.2 release (dated 2026-06-10); reset Unreleased.
- VERSION: 1.7.1 -> 1.7.2.
- Manuals (DA + EN): bump version stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:10:59 +02:00
StyxX65andClaude Fable 5 35e767b506 Fix copy buttons doing nothing over plain HTTP
navigator.clipboard is undefined in non-secure contexts, so the direct
writeText() call threw synchronously and the execCommand fallback in its
.catch() never ran. _copyText() now feature-detects the API, falls back
to execCommand('copy'), then to a prompt() for manual copying. log.js
reuses the helper; _getShareBaseUrl() caches the LAN-IP lookup so token
Copy buttons stay within the click gesture execCommand requires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:09:34 +02:00
10 changed files with 165 additions and 31 deletions

No files matched your search

+42
View File
@@ -11,6 +11,48 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
---
## [1.7.6] — 2026-06-11
### Fixed
- **Update restart leaked the listening socket and hopped to port 5101** — Werkzeug marks its server socket inheritable (`srv.socket.set_inheritable(True)`, unconditionally, for its debug reloader), so the in-app update's `os.execv` restart carried the old listening socket into the new process as a zombie listener: same PID listening on both 5100 (never accepted — clients hang) and 5101 (the actual server). The 1.7.3 `SO_REUSEADDR`/grace-period fix couldn't help because the port genuinely was occupied — by the restarting process itself. `_restart_self()` now marks every fd above stderr close-on-exec before the exec (`_mark_fds_cloexec()`, enumerating `/proc/self/fd` on Linux), so the old socket dies with the exec and the new server rebinds 5100 immediately.
---
## [1.7.5] — 2026-06-11
### Fixed
- **Stale UI after updating the server** — Flask served `/static/` files with no `Cache-Control` header, so browsers cached JS/CSS heuristically (often for days). After a server update — including the new in-app self-update, whose post-install reload hit the cache — the backend was new but the frontend stayed old, and fixes appeared "not to work" until a hard refresh. `SEND_FILE_MAX_AGE_DEFAULT = 0` now makes every static file revalidate via ETag: unchanged files answer with a cheap 304, changed files are re-fetched immediately on the next normal page load.
---
## [1.7.4] — 2026-06-10
### Fixed
- **Share modal kept stale input after creating a link** — clicking "Create" only cleared the label field; scope type, user email, date range, and expiry kept their values, so the next link silently inherited the previous link's scope settings. The form-reset logic from `openShareModal()` is now a shared `_resetShareForm()` helper called after every successful create (the generated link row stays visible for copying).
---
## [1.7.3] — 2026-06-10
### Fixed
- **App restart no longer hops to a new port** — the in-app update restart (and any quick stop/start) left connections from the previous instance in TIME_WAIT, and the startup port probe did a plain `bind()` that treats TIME_WAIT as occupied — so the restarted app silently came up on 5101 and the browser's reload poll never found it. The probe now sets `SO_REUSEADDR` (matching how Werkzeug actually binds, so an actively listening port is still detected as occupied), and the requested port gets a 10-second grace period before the auto-increment fallback kicks in, covering the brief window where the old process hasn't fully released the socket.
- **Share links now respect a reverse proxy** — `_getShareBaseUrl()` rewrote every copied share link to `http://<LAN-IP>:5100` (via `/api/local_ip`), which would bypass TLS when the scanner sits behind a reverse proxy (Zoraxy, Caddy, nginx, …): a DPO opening the link would silently fall back to plain HTTP. The LAN-IP rewrite now only applies in the case it was built for — browsing the app at `localhost` over HTTP, where `window.location.origin` would produce links unusable from other machines. Any HTTPS or non-localhost origin is used as-is.
---
## [1.7.2] — 2026-06-10
### Fixed
- **Copy buttons did nothing over plain HTTP** — the share modal's "Copy" buttons (new link + active links) and the log panel's copy button called `navigator.clipboard.writeText()` directly. The Clipboard API only exists in secure contexts (HTTPS or localhost), so when the scanner is reached at `http://<LAN-IP>:5100` the call threw synchronously and the intended `execCommand` fallback never ran — the button silently did nothing. `_copyText()` in `viewer.js` now feature-detects the API, falls back to `document.execCommand('copy')`, and as a last resort shows the link in a `prompt()` for manual copying; `log.js` reuses the same helper via `window._copyText`. `_getShareBaseUrl()` now caches the LAN-IP lookup so the token-list Copy buttons copy synchronously within the click gesture (required for `execCommand`).
---
## [1.7.1] — 2026-06-10
### Added
+1 -1
View File
@@ -1 +1 @@
1.7.1
1.7.6
+2 -2
View File
@@ -1,6 +1,6 @@
# GDPR Scanner — Brugermanual
Version 1.7.1
Version 1.7.6
---
@@ -671,4 +671,4 @@ For en typisk skole- eller kommunescanning er omkostningen ubetydelig — Claude
---
*GDPR Scanner v1.7.1 — teknisk opsætning og konfiguration: se README.md*
*GDPR Scanner v1.7.6 — teknisk opsætning og konfiguration: se README.md*
+2 -2
View File
@@ -1,6 +1,6 @@
# GDPR Scanner — User Manual
Version 1.7.1
Version 1.7.6
---
@@ -671,4 +671,4 @@ For a typical school or municipality scan the cost is negligible — Claude Haik
---
*GDPR Scanner v1.7.1 — for technical setup and configuration see README.md*
*GDPR Scanner v1.7.6 — for technical setup and configuration see README.md*
+28 -4
View File
@@ -317,6 +317,11 @@ app = Flask(__name__,
template_folder=_os.path.join(_BASE_DIR, "templates"),
static_folder=_os.path.join(_BASE_DIR, "static"))
# Static files must revalidate on every load (cheap 304s via ETag). Without
# this there is no Cache-Control header and browsers cache JS/CSS heuristically
# for days — after a self-update the backend is new but the UI stays stale.
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
# Session secret — derived from machine_id so it survives restarts without a separate file.
# machine_id is also the Fernet key (base64-encoded 32 bytes); we use its raw bytes as the secret.
try:
@@ -2261,14 +2266,33 @@ Example --settings file with SMTP:
# Find a free port — auto-increment from the requested port if in use.
import socket as _socket
def _find_free_port(start: int, host: str) -> int:
for p in range(start, start + 100):
def _can_bind(p: int, host: str) -> bool:
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
# Probe with SO_REUSEADDR, matching how Werkzeug binds.
# Without it, connections left in TIME_WAIT by a previous
# instance (e.g. the in-app update restart) make the port
# look occupied and the app silently moves to the next one.
s.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
try:
s.bind((host, p))
return p
return True
except OSError:
continue
return False
def _find_free_port(start: int, host: str) -> int:
# Give the requested port a grace period — after a self-restart
# the previous process may not have released it yet.
deadline = time.time() + 10
while True:
if _can_bind(start, host):
return start
if time.time() >= deadline:
break
time.sleep(0.5)
for p in range(start + 1, start + 100):
if _can_bind(p, host):
return p
raise RuntimeError(f"No free port found in range {start}–{start + 99}")
actual_port = _find_free_port(args.port, args.host)
+23 -2
View File
@@ -118,13 +118,34 @@ def apply_update() -> dict:
"from": chk["current"], "to": chk["latest"]}
def _mark_fds_cloexec() -> None:
"""Mark every fd above stderr close-on-exec.
Werkzeug calls ``srv.socket.set_inheritable(True)`` unconditionally
(for its debug reloader), so without this the listening socket leaks
into the exec'd process: it sits on the port as a zombie listener no
one accepts from, the port probe sees the port as busy, and the new
server hops to port+1 while clients hang against the dead socket.
"""
try:
fds = [int(f) for f in os.listdir("/proc/self/fd")] # Linux
except (OSError, ValueError):
fds = list(range(3, 4096))
for fd in fds:
if fd > 2:
try:
os.set_inheritable(fd, False)
except OSError:
pass
def _restart_self() -> None:
"""Re-exec the current process so the updated code is loaded.
Keeps the same PID, so it works both under systemd and when launched
manually via start_gdpr.sh. Listening sockets are close-on-exec, so
the new process can rebind the port.
manually via start_gdpr.sh.
"""
_mark_fds_cloexec()
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
except OSError:
+3 -1
View File
@@ -65,11 +65,13 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
- **`window.VIEWER_MODE`** — injected by Jinja2. `auth.js` adds `viewer-mode` class to `<body>`; all hide rules are CSS (`body.viewer-mode …`) except `delBtn` which is also guarded in JS.
- **`window.VIEWER_SCOPE`** — injected alongside `VIEWER_MODE`. If `VIEWER_SCOPE.role` is set, `auth.js` pre-sets `#filterRole` and hides the dropdown.
- **Token onclick attributes** — Copy/Revoke buttons pass the token as a single-quoted JS string literal, never via `JSON.stringify` (which produces double-quoted strings that break `onclick="…"` attributes).
- **Share link base URL** — `_getShareBaseUrl()` fetches `/api/local_ip` (LAN IP via UDP probe to `8.8.8.8`) so copied links are routable from other machines. Both `createShareLink` and `copyTokenLink` are `async`. Do not revert to `window.location.origin` — that produces `127.0.0.1` links.
- **Share link base URL** — `_getShareBaseUrl()` uses `window.location.origin` whenever the page is served over HTTPS or from a non-localhost host (a reverse-proxied hostname or LAN IP is already routable, and rewriting it to `http://<LAN-IP>` would bypass the proxy's TLS). Only when browsing at `localhost`/`127.0.0.1` over HTTP does it fetch `/api/local_ip` (LAN IP via UDP probe to `8.8.8.8`) so copied links work from other machines. The result is cached in `_shareBaseUrl` so Copy buttons stay within the click gesture. Both `createShareLink` and `copyTokenLink` are `async`. Do not make it return bare `window.location.origin` unconditionally — that reintroduces unusable `127.0.0.1` links.
- **Settings Security pane** — Admin PIN and Viewer PIN groups live in `stPaneSecurity`. `switchSettingsTab('security')` triggers both `stLoadPinStatus()` and `stLoadViewerPinStatus()`.
## Gotchas
- **`navigator.clipboard` is `undefined` over plain HTTP** — the app is normally reached at `http://<LAN-IP>:5100`, a non-secure context where the Clipboard API does not exist, so calling `navigator.clipboard.writeText(...)` throws synchronously (a `.catch()` on it never runs). Always copy via `window._copyText(text, btn)` (defined in `viewer.js`) — it feature-detects the API and falls back to `document.execCommand('copy')`, then to a `prompt()`. Because `execCommand` needs a user gesture, don't `await` network calls between the click and the copy; `_getShareBaseUrl()` caches its result for this reason.
- **`scheduler.js` strings must use `t()`** — frequency labels, "Next", "Running...", "Disabled", empty-job text, and empty-history text all have translation keys. Do not hard-code English strings in `schedLoad()` or `schedRenderJobs()`.
- **Scheduler UI — `schedToggleReportOnly()`** — dims the Profile row, shows/hides `#schedReportOnlyHint`, and forces `#schedAutoEmail` checked. Called from the checkbox `onchange` handler and at the start of `schedAddJob()` / `schedEditJob()`.
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked.
+2 -3
View File
@@ -161,10 +161,9 @@ function copyLog() {
document.querySelectorAll('#logPanel .log-line:not(#logLive)').forEach(function(d) {
lines.push(d.textContent);
});
navigator.clipboard.writeText(lines.join('\n')).then(function() {
const btn = document.querySelector('.log-copy-btn');
if (btn) { btn.textContent = '✓ Copied'; setTimeout(function(){ btn.textContent = '⎘ Copy'; }, 1500); }
}).catch(function() {});
// _copyText (viewer.js) handles HTTP contexts where navigator.clipboard is undefined.
if (btn) window._copyText(lines.join('\n'), btn);
}
function _restoreLog() {
+45 -16
View File
@@ -2,18 +2,32 @@
// Share button → modal to create, copy, and revoke read-only viewer links.
import { S } from './state.js';
let _shareBaseUrl = null; // cached so Copy buttons can build the URL synchronously
async function _getShareBaseUrl() {
// Use the machine's LAN IP so links work for remote users, not just localhost.
if (_shareBaseUrl) return _shareBaseUrl;
// The LAN-IP probe exists only to fix links when the operator browses the
// app at localhost — those would be unusable for remote users. Any other
// origin (LAN IP, or a reverse-proxied HTTPS hostname) is already routable,
// and rewriting it to http://<LAN-IP> would bypass the proxy's TLS.
const host = window.location.hostname;
if (window.location.protocol === 'https:' ||
(host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]')) {
_shareBaseUrl = window.location.origin;
return _shareBaseUrl;
}
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;
_shareBaseUrl = 'http://' + d.ip + ':' + window.location.port;
return _shareBaseUrl;
}
}
} catch(e) {}
return window.location.origin;
_shareBaseUrl = window.location.origin;
return _shareBaseUrl;
}
// ── User autocomplete for Share modal ────────────────────────────────────────
@@ -124,9 +138,7 @@ function _shareScopeTypeChanged() {
if (type === 'user') _initUserAutocomplete();
}
function openShareModal() {
document.getElementById('shareBackdrop').classList.add('open');
document.getElementById('shareNewLinkRow').style.display = 'none';
function _resetShareForm() {
document.getElementById('shareLabel').value = '';
document.getElementById('shareExpiry').value = '30';
const scopeType = document.getElementById('shareScopeType');
@@ -138,6 +150,12 @@ function openShareModal() {
if (scopeDrop) scopeDrop.style.display = 'none';
const vf = document.getElementById('shareValidFrom'); if (vf) vf.value = '';
const vt = document.getElementById('shareValidTo'); if (vt) vt.value = '';
}
function openShareModal() {
document.getElementById('shareBackdrop').classList.add('open');
document.getElementById('shareNewLinkRow').style.display = 'none';
_resetShareForm();
_renderTokenList();
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
const el = document.getElementById('sharePinStatus');
@@ -251,7 +269,7 @@ async function createShareLink() {
urlInput.value = url;
document.getElementById('shareNewLinkRow').style.display = 'block';
document.getElementById('shareCopyBtn').textContent = t('log_copy', 'Copy');
document.getElementById('shareLabel').value = '';
_resetShareForm();
_renderTokenList();
} catch(e) {
alert(t('share_create_error', 'Failed to create link:') + ' ' + e.message);
@@ -269,25 +287,35 @@ async function copyTokenLink(token, btn) {
}
function _copyText(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const done = () => {
const orig = btn.textContent;
btn.textContent = t('share_copied', 'Copied!');
setTimeout(() => { btn.textContent = orig; }, 1800);
}).catch(() => {
// Fallback for HTTP contexts
};
// Fallback for HTTP contexts, where navigator.clipboard is undefined
// (the Clipboard API only exists in secure contexts — HTTPS or localhost).
const fallback = () => {
let ok = false;
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
ta.setAttribute('readonly', '');
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
ok = document.execCommand('copy');
document.body.removeChild(ta);
const orig = btn.textContent;
btn.textContent = t('share_copied', 'Copied!');
setTimeout(() => { btn.textContent = orig; }, 1800);
} catch(_) {}
});
} catch(_) { ok = false; }
if (ok) done();
// Last resort: show the link in a prompt so it can be copied manually.
else prompt(t('share_copy_link_prompt', 'Copy link:'), text);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(fallback);
} else {
fallback();
}
}
async function revokeToken(token, rowEl) {
@@ -475,6 +503,7 @@ window.openShareModal = openShareModal;
window.closeShareModal = closeShareModal;
window.createShareLink = createShareLink;
window.copyShareLink = copyShareLink;
window._copyText = _copyText;
window.copyTokenLink = copyTokenLink;
window.revokeToken = revokeToken;
window.stLoadViewerPinStatus = stLoadViewerPinStatus;
+17
View File
@@ -188,6 +188,23 @@ def test_apply_installs_requirements_when_changed(client, monkeypatch):
assert "pip" in pip_calls[0] and "-r" in pip_calls[0]
# ── Restart fd hygiene ────────────────────────────────────────────────────────
def test_mark_fds_cloexec_unmarks_inheritable_socket():
"""Werkzeug sets the listening socket inheritable; the restart must undo
that or the socket leaks through execv and squats on the port."""
import socket
import routes.updates as upd
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.set_inheritable(True)
assert s.get_inheritable() is True
upd._mark_fds_cloexec()
assert s.get_inheritable() is False
finally:
s.close()
# ── /api/update/settings ──────────────────────────────────────────────────────
def test_settings_roundtrip(client, monkeypatch):