Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4cf2db347 | ||
|
|
d6bf80a68a | ||
|
|
679f91da2c | ||
|
|
c79e7097ea | ||
|
|
35e767b506 |
No files matched your search
@@ -11,6 +11,24 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [1.7.1] — 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GDPR Scanner — Brugermanual
|
# GDPR Scanner — Brugermanual
|
||||||
|
|
||||||
Version 1.7.1
|
Version 1.7.3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.3 — teknisk opsætning og konfiguration: se README.md*
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GDPR Scanner — User Manual
|
# GDPR Scanner — User Manual
|
||||||
|
|
||||||
Version 1.7.1
|
Version 1.7.3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.3 — for technical setup and configuration see README.md*
|
||||||
+23
-4
@@ -2261,14 +2261,33 @@ Example --settings file with SMTP:
|
|||||||
|
|
||||||
# Find a free port — auto-increment from the requested port if in use.
|
# Find a free port — auto-increment from the requested port if in use.
|
||||||
import socket as _socket
|
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:
|
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:
|
try:
|
||||||
s.bind((host, p))
|
s.bind((host, p))
|
||||||
return p
|
return True
|
||||||
except OSError:
|
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}")
|
raise RuntimeError(f"No free port found in range {start}–{start + 99}")
|
||||||
|
|
||||||
actual_port = _find_free_port(args.port, args.host)
|
actual_port = _find_free_port(args.port, args.host)
|
||||||
|
|||||||
+3
-1
@@ -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_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.
|
- **`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).
|
- **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()`.
|
- **Settings Security pane** — Admin PIN and Viewer PIN groups live in `stPaneSecurity`. `switchSettingsTab('security')` triggers both `stLoadPinStatus()` and `stLoadViewerPinStatus()`.
|
||||||
|
|
||||||
## Gotchas
|
## 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.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()`.
|
- **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.
|
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked.
|
||||||
|
|||||||
+2
-3
@@ -161,10 +161,9 @@ function copyLog() {
|
|||||||
document.querySelectorAll('#logPanel .log-line:not(#logLive)').forEach(function(d) {
|
document.querySelectorAll('#logPanel .log-line:not(#logLive)').forEach(function(d) {
|
||||||
lines.push(d.textContent);
|
lines.push(d.textContent);
|
||||||
});
|
});
|
||||||
navigator.clipboard.writeText(lines.join('\n')).then(function() {
|
|
||||||
const btn = document.querySelector('.log-copy-btn');
|
const btn = document.querySelector('.log-copy-btn');
|
||||||
if (btn) { btn.textContent = '✓ Copied'; setTimeout(function(){ btn.textContent = '⎘ Copy'; }, 1500); }
|
// _copyText (viewer.js) handles HTTP contexts where navigator.clipboard is undefined.
|
||||||
}).catch(function() {});
|
if (btn) window._copyText(lines.join('\n'), btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _restoreLog() {
|
function _restoreLog() {
|
||||||
|
|||||||
+37
-12
@@ -2,18 +2,32 @@
|
|||||||
// Share button → modal to create, copy, and revoke read-only viewer links.
|
// Share button → modal to create, copy, and revoke read-only viewer links.
|
||||||
import { S } from './state.js';
|
import { S } from './state.js';
|
||||||
|
|
||||||
|
let _shareBaseUrl = null; // cached so Copy buttons can build the URL synchronously
|
||||||
|
|
||||||
async function _getShareBaseUrl() {
|
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 {
|
try {
|
||||||
const r = await fetch('/api/local_ip');
|
const r = await fetch('/api/local_ip');
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.ip && d.ip !== '127.0.0.1') {
|
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) {}
|
} catch(e) {}
|
||||||
return window.location.origin;
|
_shareBaseUrl = window.location.origin;
|
||||||
|
return _shareBaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── User autocomplete for Share modal ────────────────────────────────────────
|
// ── User autocomplete for Share modal ────────────────────────────────────────
|
||||||
@@ -269,25 +283,35 @@ async function copyTokenLink(token, btn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _copyText(text, btn) {
|
function _copyText(text, btn) {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
const done = () => {
|
||||||
const orig = btn.textContent;
|
const orig = btn.textContent;
|
||||||
btn.textContent = t('share_copied', 'Copied!');
|
btn.textContent = t('share_copied', 'Copied!');
|
||||||
setTimeout(() => { btn.textContent = orig; }, 1800);
|
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 {
|
try {
|
||||||
const ta = document.createElement('textarea');
|
const ta = document.createElement('textarea');
|
||||||
ta.value = text;
|
ta.value = text;
|
||||||
ta.style.position = 'fixed'; ta.style.opacity = '0';
|
ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||||
|
ta.setAttribute('readonly', '');
|
||||||
document.body.appendChild(ta);
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
ta.select();
|
ta.select();
|
||||||
document.execCommand('copy');
|
ok = document.execCommand('copy');
|
||||||
document.body.removeChild(ta);
|
document.body.removeChild(ta);
|
||||||
const orig = btn.textContent;
|
} catch(_) { ok = false; }
|
||||||
btn.textContent = t('share_copied', 'Copied!');
|
if (ok) done();
|
||||||
setTimeout(() => { btn.textContent = orig; }, 1800);
|
// Last resort: show the link in a prompt so it can be copied manually.
|
||||||
} catch(_) {}
|
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) {
|
async function revokeToken(token, rowEl) {
|
||||||
@@ -475,6 +499,7 @@ window.openShareModal = openShareModal;
|
|||||||
window.closeShareModal = closeShareModal;
|
window.closeShareModal = closeShareModal;
|
||||||
window.createShareLink = createShareLink;
|
window.createShareLink = createShareLink;
|
||||||
window.copyShareLink = copyShareLink;
|
window.copyShareLink = copyShareLink;
|
||||||
|
window._copyText = _copyText;
|
||||||
window.copyTokenLink = copyTokenLink;
|
window.copyTokenLink = copyTokenLink;
|
||||||
window.revokeToken = revokeToken;
|
window.revokeToken = revokeToken;
|
||||||
window.stLoadViewerPinStatus = stLoadViewerPinStatus;
|
window.stLoadViewerPinStatus = stLoadViewerPinStatus;
|
||||||
|
|||||||
Reference in new issue
Block a user