Add software update from Settings GUI and update_gdpr.sh script

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
StyxX65 2026-06-10 12:54:29 +02:00
parent fcf32f3751
commit c0e45df440
13 changed files with 681 additions and 9 deletions

View File

@ -9,6 +9,12 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
## [Unreleased] ## [Unreleased]
### Added
- **Software update from the GUI** — a new **Settings → General → Software update** group lets the operator check for and install updates without touching the server shell. "Check for updates" fetches origin and shows either "You are running the latest version" or the list of pending commits; "Install update" fast-forwards the git checkout to `origin/<branch>`, reinstalls dependencies only if `requirements.txt` changed, writes an `app_update` audit-log entry, and restarts the app in place by re-exec'ing the process (`os.execv` — same PID, so it works both under systemd and when launched via `start_gdpr.sh`). The page polls until the server is back and reloads itself. Local server-side edits are auto-stashed (kept, never discarded) before the merge. Updating is refused with a clear message while any scan is running. An **"Install updates automatically"** toggle (stored in `config.json` under `auto_update`) enables a background thread that checks once a day and installs unattended, skipping (and retrying hourly) while a scan runs. The group is only shown when the app runs from a git checkout — the frozen desktop build hides it. New blueprint `routes/updates.py` with `GET /api/update/check`, `POST /api/update/apply`, `GET/POST /api/update/settings`; 11 new tests in `tests/test_updates.py` with fully mocked git.
- **`update_gdpr.sh`** — standalone CLI/cron equivalent of the GUI update: fetch + fast-forward-only merge with auto-stash of local hotfixes, dependency reinstall only when `requirements.txt` changed, and a `systemctl restart` if a `gdprscanner.service` unit exists (override with `GDPR_SERVICE`). `./update_gdpr.sh --check` reports pending commits without changing anything; safe to run from cron (quiet no-op when already up to date).
--- ---
## [1.7.0] — 2026-06-10 ## [1.7.0] — 2026-06-10

View File

@ -50,7 +50,9 @@ python -m pytest tests/ -q
## Tests ## Tests
201 tests in `tests/`. No integration tests for live M365/Google connections. 212 tests in `tests/`. No integration tests for live M365/Google connections.
**`tests/test_updates.py`** — 11 tests for the software-update routes (`routes/updates.py`). All git interaction goes through a mocked `_git()`; `_schedule_restart` is patched so no test re-execs the process, and `gdpr_db.log_audit_event` is patched so no test writes the real database.
**`tests/test_google_scan.py`** — 19 tests for the Google Workspace scan module. Route tests for `GET /api/google/scan/users`, `POST /api/google/scan/start`, `POST /api/google/scan/cancel`. Engine tests for `_run_google_scan` using synchronous invocation with mocked `broadcast`, `_scan_bytes`, `checkpoint.*`, `scan_engine._with_disposition`, and `gdpr_db.get_db`. The `clean_google_state` autouse fixture releases `_google_scan_lock` and clears `_google_scan_abort` after each test. **`tests/test_google_scan.py`** — 19 tests for the Google Workspace scan module. Route tests for `GET /api/google/scan/users`, `POST /api/google/scan/start`, `POST /api/google/scan/cancel`. Engine tests for `_run_google_scan` using synchronous invocation with mocked `broadcast`, `_scan_bytes`, `checkpoint.*`, `scan_engine._with_disposition`, and `gdpr_db.get_db`. The `clean_google_state` autouse fixture releases `_google_scan_lock` and clears `_google_scan_abort` after each test.

View File

@ -354,6 +354,18 @@ def get_claude_api_key() -> str:
return _decrypt_password(_load_config().get("claude_api_key", "")) return _decrypt_password(_load_config().get("claude_api_key", ""))
# ── Software update config ────────────────────────────────────────────────────
def get_update_config() -> dict:
return {"auto_update": bool(_load_config().get("auto_update", False))}
def save_update_config(auto_update: bool) -> None:
cfg = _load_config()
cfg["auto_update"] = bool(auto_update)
_save_config(cfg)
# ── Profile storage (15a) ───────────────────────────────────────────────────── # ── Profile storage (15a) ─────────────────────────────────────────────────────
_SETTINGS_PATH = _DATA_DIR / "settings.json" _SETTINGS_PATH = _DATA_DIR / "settings.json"
_SRC_TOGGLES_PATH = _DATA_DIR / "src_toggles.json" _SRC_TOGGLES_PATH = _DATA_DIR / "src_toggles.json"

View File

@ -1572,10 +1572,11 @@ from routes.scheduler import bp as scheduler_bp
from routes.google_auth import bp as google_auth_bp from routes.google_auth import bp as google_auth_bp
from routes.google_scan import bp as google_scan_bp from routes.google_scan import bp as google_scan_bp
from routes.viewer import bp as viewer_bp from routes.viewer import bp as viewer_bp
from routes.updates import bp as updates_bp
for _bp in [auth_bp, users_bp, scan_bp, sources_bp, profiles_bp, for _bp in [auth_bp, users_bp, scan_bp, sources_bp, profiles_bp,
email_bp, database_bp, export_bp, app_routes_bp, scheduler_bp, email_bp, database_bp, export_bp, app_routes_bp, scheduler_bp,
google_auth_bp, google_scan_bp, viewer_bp]: google_auth_bp, google_scan_bp, viewer_bp, updates_bp]:
app.register_blueprint(_bp) app.register_blueprint(_bp)
# ── Entry point ─────────────────────────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────────────────────────
@ -2296,5 +2297,14 @@ Example --settings file with SMTP:
except Exception as _sched_err: except Exception as _sched_err:
print(f" Scheduler: failed to start ({_sched_err})") print(f" Scheduler: failed to start ({_sched_err})")
# Auto-update background thread (Settings → General → Software update)
try:
from routes.updates import start_auto_update_thread
from app_config import get_update_config as _get_upd_cfg
if start_auto_update_thread() and _get_upd_cfg().get("auto_update"):
print(" Auto-update: enabled (checked daily)")
except Exception as _upd_err:
print(f" Auto-update: failed to start ({_upd_err})")
print(f" Press Ctrl+C to stop\n") print(f" Press Ctrl+C to stop\n")
app.run(host=args.host, port=args.port, debug=False, threaded=True) app.run(host=args.host, port=args.port, debug=False, threaded=True)

View File

@ -891,5 +891,16 @@
"m365_ai_test_ok": "API-nøgle er gyldig", "m365_ai_test_ok": "API-nøgle er gyldig",
"m365_ai_test_fail": "Test mislykkedes", "m365_ai_test_fail": "Test mislykkedes",
"m365_ai_saved": "Gemt", "m365_ai_saved": "Gemt",
"m365_ai_model_note": "Model: claude-haiku-4-5 · faktureres efter Anthropics token-priser · resultater caches pr. dokument." "m365_ai_model_note": "Model: claude-haiku-4-5 · faktureres efter Anthropics token-priser · resultater caches pr. dokument.",
"m365_settings_updates": "Softwareopdatering",
"m365_update_idle": "Tjek om der findes en nyere version.",
"m365_update_auto": "Installér opdateringer automatisk (tjekkes dagligt — programmet genstarter selv)",
"m365_update_check": "Søg efter opdateringer",
"m365_update_install": "Installér opdatering",
"m365_update_checking": "Tjekker…",
"m365_update_uptodate": "Du kører den nyeste version.",
"m365_update_available": "Opdatering tilgængelig",
"m365_update_installing": "Installerer opdatering — programmet genstarter…",
"m365_update_failed": "Opdateringstjek mislykkedes",
"m365_update_scan_running": "Kan ikke opdatere, mens en scanning kører."
} }

View File

@ -891,5 +891,16 @@
"m365_ai_test_ok": "API-Schlüssel gültig", "m365_ai_test_ok": "API-Schlüssel gültig",
"m365_ai_test_fail": "Test fehlgeschlagen", "m365_ai_test_fail": "Test fehlgeschlagen",
"m365_ai_saved": "Gespeichert", "m365_ai_saved": "Gespeichert",
"m365_ai_model_note": "Modell: claude-haiku-4-5 · Abrechnung nach Anthropic-Token-Tarifen · Ergebnisse werden pro Dokument gecacht." "m365_ai_model_note": "Modell: claude-haiku-4-5 · Abrechnung nach Anthropic-Token-Tarifen · Ergebnisse werden pro Dokument gecacht.",
"m365_settings_updates": "Softwareaktualisierung",
"m365_update_idle": "Prüfen, ob eine neuere Version verfügbar ist.",
"m365_update_auto": "Updates automatisch installieren (tägliche Prüfung — die App startet sich selbst neu)",
"m365_update_check": "Nach Updates suchen",
"m365_update_install": "Update installieren",
"m365_update_checking": "Wird geprüft…",
"m365_update_uptodate": "Sie verwenden die neueste Version.",
"m365_update_available": "Update verfügbar",
"m365_update_installing": "Update wird installiert — die App startet neu…",
"m365_update_failed": "Updateprüfung fehlgeschlagen",
"m365_update_scan_running": "Update nicht möglich, während ein Scan läuft."
} }

View File

@ -891,5 +891,16 @@
"m365_ai_test_ok": "API key valid", "m365_ai_test_ok": "API key valid",
"m365_ai_test_fail": "Test failed", "m365_ai_test_fail": "Test failed",
"m365_ai_saved": "Saved", "m365_ai_saved": "Saved",
"m365_ai_model_note": "Model: claude-haiku-4-5 · billed at Anthropic token rates · results cached per document." "m365_ai_model_note": "Model: claude-haiku-4-5 · billed at Anthropic token rates · results cached per document.",
"m365_settings_updates": "Software update",
"m365_update_idle": "Check whether a newer version is available.",
"m365_update_auto": "Install updates automatically (checked daily — the app restarts itself)",
"m365_update_check": "Check for updates",
"m365_update_install": "Install update",
"m365_update_checking": "Checking…",
"m365_update_uptodate": "You are running the latest version.",
"m365_update_available": "Update available",
"m365_update_installing": "Installing update — the app will restart…",
"m365_update_failed": "Update check failed",
"m365_update_scan_running": "Cannot update while a scan is running."
} }

View File

@ -59,7 +59,7 @@ Exception hierarchy (all inherit `M365Error(Exception)`):
- **`audit_log` table** — created by `_DDL` (`CREATE TABLE IF NOT EXISTS`), auto-appears on next server start. Schema: `id, ts (Unix float), action, actor, detail, ip`. - **`audit_log` table** — created by `_DDL` (`CREATE TABLE IF NOT EXISTS`), auto-appears on next server start. Schema: `id, ts (Unix float), action, actor, detail, ip`.
- **`log_audit_event(action, detail, actor, ip)`** — module-level helper; silently no-ops on any exception. Import: `from gdpr_db import log_audit_event as _audit`. - **`log_audit_event(action, detail, actor, ip)`** — module-level helper; silently no-ops on any exception. Import: `from gdpr_db import log_audit_event as _audit`.
- **`GET /api/audit_log?limit=200&action=<filter>`** — in `routes/app_routes.py`. No auth gate. - **`GET /api/audit_log?limit=200&action=<filter>`** — in `routes/app_routes.py`. No auth gate.
- **Recorded events**`profile_save/delete`, `token_create/revoke`, `viewer_pin_set/change/clear`, `interface_pin_set/change/clear`, `source_add/update/delete`, `scheduler_job_save/delete`, `scan_start/stop`, `smtp_save`, `disposition`, `disposition_bulk`, `admin_pin_set/change`, `item_delete`, `item_redact`. - **Recorded events**`profile_save/delete`, `token_create/revoke`, `viewer_pin_set/change/clear`, `interface_pin_set/change/clear`, `source_add/update/delete`, `scheduler_job_save/delete`, `scan_start/stop`, `smtp_save`, `disposition`, `disposition_bulk`, `admin_pin_set/change`, `item_delete`, `item_redact`, `app_update`.
- **`actor` always empty** — no per-user login; field reserved for future use. - **`actor` always empty** — no per-user login; field reserved for future use.
## Email sending — routes/email.py + m365_connector.py ## Email sending — routes/email.py + m365_connector.py
@ -89,6 +89,14 @@ Optional AI-powered NER replacing spaCy. Activated via `config.json` keys `claud
- **`POST /api/settings/claude/test`** — minimal 8-token API call; returns `{"ok": true}` or `{"ok": false, "error": "..."}`. - **`POST /api/settings/claude/test`** — minimal 8-token API call; returns `{"ok": true}` or `{"ok": false, "error": "..."}`.
- **Do not import `anthropic` at module level outside `document_scanner.py`**`routes/app_routes.py` imports it locally inside the function body so the server starts without the package. - **Do not import `anthropic` at module level outside `document_scanner.py`**`routes/app_routes.py` imports it locally inside the function body so the server starts without the package.
## Software update — routes/updates.py
- **Git-checkout only**`_supported()` requires a `.git` dir and not `sys.frozen`. The frozen desktop build gets `{"supported": false}` and the UI hides the Settings group.
- **`POST /api/update/apply`** — stash-if-dirty → `merge --ff-only origin/<branch>` → pip install only if `requirements.txt` changed → audit `app_update``_schedule_restart()` re-execs the process via `os.execv` (same PID; works under systemd and `start_gdpr.sh`). Refuses with `code: "scan_running"` (409) while `state._scan_lock` or `state._google_scan_lock` is held.
- **`apply_update()` never restarts itself** — callers decide. Tests patch `_schedule_restart`; the auto-update thread calls `_restart_self()` directly.
- **Auto-update thread**`start_auto_update_thread()` called from `gdpr_scanner.py` `__main__`. Hourly tick, applies at most once per 24 h when `config.json["auto_update"]` is true; skips (and retries next tick) while a scan runs.
- **`update_gdpr.sh`** — standalone CLI/cron equivalent of the same logic; keep stash/ff-only/requirements behaviour in sync.
## Viewer mode — routes/viewer.py ## Viewer mode — routes/viewer.py
- **`/view` auth chain** — token (`?token=`) → session cookie (`session["viewer_ok"]`) → PIN form → 403. Never skip this order. - **`/view` auth chain** — token (`?token=`) → session cookie (`session["viewer_ok"]`) → PIN form → 403. Never skip this order.

195
routes/updates.py Normal file
View File

@ -0,0 +1,195 @@
"""
Software update routes: check origin for new commits, apply the update,
and an optional auto-update background thread.
Only available when running from a git checkout the frozen desktop
build (PyInstaller) reports supported=False and the UI hides the group.
Applying an update fast-forwards to origin/<branch>, reinstalls
dependencies if requirements.txt changed, then re-execs the process so
the new code is loaded. Local edits are stashed (kept), never discarded.
"""
from __future__ import annotations
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
from flask import Blueprint, jsonify, request
from routes import state
from app_config import get_update_config, save_update_config
bp = Blueprint("updates", __name__)
_REPO_DIR = Path(__file__).parent.parent
_GIT_TIMEOUT = 30
_AUTO_CHECK_INTERVAL = 24 * 3600 # auto-update checks once per day
_last_auto_check = [0.0]
def _supported() -> bool:
return (not getattr(sys, "frozen", False)) and (_REPO_DIR / ".git").exists()
def _git(*args: str, timeout: int = _GIT_TIMEOUT) -> subprocess.CompletedProcess:
return subprocess.run(
["git", *args], cwd=_REPO_DIR,
capture_output=True, text=True, timeout=timeout,
)
def _scan_running() -> bool:
return state._scan_lock.locked() or state._google_scan_lock.locked()
def check_for_update() -> dict:
"""Fetch origin and compare HEAD against the tracked branch."""
if not _supported():
return {"supported": False}
try:
branch = _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "main"
fetch = _git("fetch", "origin", branch, timeout=60)
if fetch.returncode != 0:
return {"supported": True, "error": fetch.stderr.strip()[:300] or "git fetch failed"}
local = _git("rev-parse", "HEAD").stdout.strip()
remote = _git("rev-parse", f"origin/{branch}").stdout.strip()
except (subprocess.TimeoutExpired, OSError) as e:
return {"supported": True, "error": str(e)[:300]}
info = {
"supported": True, "branch": branch,
"current": local[:7], "latest": remote[:7],
"up_to_date": local == remote, "commits": [],
}
if local != remote:
lg = _git("log", "--oneline", f"HEAD..origin/{branch}")
info["commits"] = lg.stdout.strip().splitlines()[:20]
return info
def apply_update() -> dict:
"""Fast-forward to origin/<branch>; returns {"ok", "updated", ...}.
Does NOT restart the process callers decide (the route schedules a
re-exec, the auto-update thread restarts directly).
"""
chk = check_for_update()
if not chk.get("supported"):
return {"ok": False, "code": "unsupported",
"error": "Updates require running from a git checkout."}
if chk.get("error"):
return {"ok": False, "code": "check_failed", "error": chk["error"]}
if chk.get("up_to_date"):
return {"ok": True, "updated": False, "current": chk["current"]}
if _scan_running():
return {"ok": False, "code": "scan_running",
"error": "Cannot update while a scan is running."}
branch = chk["branch"]
try:
if _git("diff-index", "--quiet", "HEAD", "--").returncode != 0:
_git("stash", "push", "-m",
"auto-stash before update " + time.strftime("%Y-%m-%d %H:%M:%S"))
reqs_changed = _git(
"diff", "--quiet", f"HEAD..origin/{branch}", "--", "requirements.txt"
).returncode != 0
merge = _git("merge", "--ff-only", f"origin/{branch}")
if merge.returncode != 0:
return {"ok": False, "code": "merge_failed",
"error": (merge.stderr.strip() or "git merge failed")[:300]}
if reqs_changed:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-q", "-r",
str(_REPO_DIR / "requirements.txt")],
cwd=_REPO_DIR, capture_output=True, timeout=600,
)
except (subprocess.TimeoutExpired, OSError) as e:
return {"ok": False, "code": "apply_failed", "error": str(e)[:300]}
try:
from gdpr_db import log_audit_event as _audit
_audit("app_update", f"{chk['current']} -> {chk['latest']}",
ip=(request.remote_addr if request else ""))
except Exception:
pass
return {"ok": True, "updated": True,
"from": chk["current"], "to": chk["latest"]}
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.
"""
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
except OSError:
# Last resort: exit and rely on a supervisor (systemd Restart=) to
# bring the app back up.
os._exit(0)
def _schedule_restart(delay: float = 1.5) -> None:
def _later():
time.sleep(delay)
_restart_self()
threading.Thread(target=_later, daemon=True, name="update-restart").start()
# ── Routes ────────────────────────────────────────────────────────────────────
@bp.route("/api/update/check")
def update_check():
return jsonify(check_for_update())
@bp.route("/api/update/apply", methods=["POST"])
def update_apply():
res = apply_update()
if res.get("updated"):
res["restarting"] = True
_schedule_restart()
return jsonify(res), (200 if res.get("ok") else 409)
@bp.route("/api/update/settings", methods=["GET", "POST"])
def update_settings():
if request.method == "GET":
return jsonify({"supported": _supported(), **get_update_config()})
data = request.get_json(silent=True) or {}
save_update_config(bool(data.get("auto_update", False)))
return jsonify({"ok": True})
# ── Auto-update background thread ─────────────────────────────────────────────
def _auto_update_loop() -> None:
while True:
time.sleep(3600)
try:
if not get_update_config().get("auto_update"):
continue
if time.time() - _last_auto_check[0] < _AUTO_CHECK_INTERVAL:
continue
_last_auto_check[0] = time.time()
if _scan_running():
_last_auto_check[0] = 0.0 # retry on the next hourly tick
continue
res = apply_update()
if res.get("updated"):
print(f" Auto-update: {res['from']} -> {res['to']} — restarting")
_restart_self()
except Exception:
pass
def start_auto_update_thread() -> bool:
"""Called once at startup from gdpr_scanner.py. No-op for frozen builds."""
if not _supported():
return False
threading.Thread(target=_auto_update_loop, daemon=True, name="auto-update").start()
return True

View File

@ -244,6 +244,7 @@ function switchSettingsTab(tab) {
if (pane) pane.classList.toggle('active', t === tab); if (pane) pane.classList.toggle('active', t === tab);
if (btn) btn.classList.toggle('active', t === tab); if (btn) btn.classList.toggle('active', t === tab);
}); });
if (tab === 'general') stLoadUpdateSettings();
if (tab === 'security') { stLoadPinStatus(); if (typeof stLoadViewerPinStatus === 'function') stLoadViewerPinStatus(); if (typeof stLoadInterfacePinStatus === 'function') stLoadInterfacePinStatus(); } if (tab === 'security') { stLoadPinStatus(); if (typeof stLoadViewerPinStatus === 'function') stLoadViewerPinStatus(); if (typeof stLoadInterfacePinStatus === 'function') stLoadInterfacePinStatus(); }
if (tab === 'email') stLoadSmtp(); if (tab === 'email') stLoadSmtp();
if (tab === 'database') stLoadDbStats(); if (tab === 'database') stLoadDbStats();
@ -332,6 +333,106 @@ async function stAiTest() {
} }
} }
// ── Software updates ─────────────────────────────────────────────────────────
async function stLoadUpdateSettings() {
try {
const cfg = await fetch('/api/update/settings').then(r => r.json());
const grp = document.getElementById('stUpdateGroup');
if (grp) grp.style.display = cfg.supported ? '' : 'none';
const cb = document.getElementById('stAutoUpdate');
if (cb) cb.checked = !!cfg.auto_update;
} catch(e) { /* ignore */ }
}
async function stSaveAutoUpdate() {
const cb = document.getElementById('stAutoUpdate');
try {
await fetch('/api/update/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ auto_update: !!(cb && cb.checked) }),
});
} catch(e) { /* ignore */ }
}
async function stCheckUpdate() {
const status = document.getElementById('stUpdateStatus');
const commits = document.getElementById('stUpdateCommits');
const applyBtn = document.getElementById('stApplyUpdateBtn');
if (status) { status.textContent = t('m365_update_checking', 'Checking…'); status.style.color = 'var(--muted)'; }
if (commits) commits.style.display = 'none';
if (applyBtn) applyBtn.style.display = 'none';
try {
const res = await fetch('/api/update/check').then(r => r.json());
if (!status) return;
if (res.error) {
status.textContent = t('m365_update_failed', 'Update check failed') + ': ' + res.error;
status.style.color = 'var(--danger)';
} else if (res.up_to_date) {
status.textContent = t('m365_update_uptodate', 'You are running the latest version.') + ' (' + res.current + ')';
status.style.color = 'var(--success)';
} else {
status.textContent = t('m365_update_available', 'Update available') + ': ' + res.current + ' → ' + res.latest;
status.style.color = 'var(--accent)';
if (commits && res.commits && res.commits.length) {
commits.innerHTML = res.commits.map(function(c) { return window._escHtml(c); }).join('<br>');
commits.style.display = '';
}
if (applyBtn) applyBtn.style.display = '';
}
} catch(e) {
if (status) { status.textContent = String(e); status.style.color = 'var(--danger)'; }
}
}
async function stApplyUpdate() {
const status = document.getElementById('stUpdateStatus');
const applyBtn = document.getElementById('stApplyUpdateBtn');
const checkBtn = document.getElementById('stCheckUpdateBtn');
if (applyBtn) applyBtn.disabled = true;
if (checkBtn) checkBtn.disabled = true;
if (status) { status.textContent = t('m365_update_installing', 'Installing update — the app will restart…'); status.style.color = 'var(--muted)'; }
try {
const res = await fetch('/api/update/apply', { method: 'POST' }).then(r => r.json());
if (!res.ok) {
const msg = res.code === 'scan_running'
? t('m365_update_scan_running', 'Cannot update while a scan is running.')
: (res.error || 'Update failed');
if (status) { status.textContent = msg; status.style.color = 'var(--danger)'; }
if (applyBtn) applyBtn.disabled = false;
if (checkBtn) checkBtn.disabled = false;
return;
}
if (!res.updated) { // already up to date
if (status) { status.textContent = t('m365_update_uptodate', 'You are running the latest version.'); status.style.color = 'var(--success)'; }
if (applyBtn) { applyBtn.disabled = false; applyBtn.style.display = 'none'; }
if (checkBtn) checkBtn.disabled = false;
return;
}
_stWaitForRestart();
} catch(e) {
if (status) { status.textContent = String(e); status.style.color = 'var(--danger)'; }
if (applyBtn) applyBtn.disabled = false;
if (checkBtn) checkBtn.disabled = false;
}
}
// Poll until the server has gone down and come back, then reload the page.
function _stWaitForRestart() {
let tries = 0, sawDown = false;
const iv = setInterval(async function() {
tries++;
try {
await fetch('/api/about', { cache: 'no-store' }).then(r => { if (!r.ok) throw new Error(); });
if (sawDown || tries >= 5) { clearInterval(iv); location.reload(); }
} catch(e) {
sawDown = true;
}
if (tries > 90) clearInterval(iv); // give up after ~3 minutes
}, 2000);
}
function stAiToggleKey() { function stAiToggleKey() {
const inp = document.getElementById('aiApiKey'); const inp = document.getElementById('aiApiKey');
const btn = document.getElementById('aiShowKeyBtn'); const btn = document.getElementById('aiShowKeyBtn');
@ -362,5 +463,9 @@ window.stLoadAiSettings = stLoadAiSettings;
window.stAiSave = stAiSave; window.stAiSave = stAiSave;
window.stAiTest = stAiTest; window.stAiTest = stAiTest;
window.stAiToggleKey = stAiToggleKey; window.stAiToggleKey = stAiToggleKey;
window.stLoadUpdateSettings = stLoadUpdateSettings;
window.stSaveAutoUpdate = stSaveAutoUpdate;
window.stCheckUpdate = stCheckUpdate;
window.stApplyUpdate = stApplyUpdate;
window._M365_SOURCES = _M365_SOURCES; window._M365_SOURCES = _M365_SOURCES;
window._pinCallback = _pinCallback; window._pinCallback = _pinCallback;

View File

@ -641,6 +641,19 @@ document.addEventListener('DOMContentLoaded', applyI18n);
<div class="settings-about-row"><span>Requests</span><span id="st-about-requests" style="color:var(--muted)"></span></div> <div class="settings-about-row"><span>Requests</span><span id="st-about-requests" style="color:var(--muted)"></span></div>
<div class="settings-about-row"><span>openpyxl</span><span id="st-about-openpyxl" style="color:var(--muted)"></span></div> <div class="settings-about-row"><span>openpyxl</span><span id="st-about-openpyxl" style="color:var(--muted)"></span></div>
</div> </div>
<div class="settings-group" id="stUpdateGroup" style="display:none">
<div class="settings-group-title" data-i18n="m365_settings_updates">Software update</div>
<div id="stUpdateStatus" style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="m365_update_idle">Check whether a newer version is available.</div>
<div id="stUpdateCommits" style="display:none;font-size:11px;color:var(--muted);font-family:monospace;line-height:1.6;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:6px 10px;margin-bottom:8px;max-height:120px;overflow-y:auto"></div>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
<label class="toggle" style="flex:unset"><input type="checkbox" id="stAutoUpdate" onchange="stSaveAutoUpdate()"><span class="toggle-slider"></span></label>
<span style="font-size:12px" data-i18n="m365_update_auto">Install updates automatically (checked daily — the app restarts itself)</span>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button type="button" onclick="stCheckUpdate()" id="stCheckUpdateBtn" style="height:26px;padding:0 14px;background:none;border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px;cursor:pointer;box-sizing:border-box" data-i18n="m365_update_check">Check for updates</button>
<button type="button" onclick="stApplyUpdate()" id="stApplyUpdateBtn" style="display:none;height:26px;padding:0 14px;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:12px;cursor:pointer;font-weight:600;box-sizing:border-box" data-i18n="m365_update_install">Install update</button>
</div>
</div>
</div> </div>
<!-- ── Security pane ─────────────────────────────────────────────────── --> <!-- ── Security pane ─────────────────────────────────────────────────── -->

205
tests/test_updates.py Normal file
View File

@ -0,0 +1,205 @@
"""
Tests for the software-update routes (routes/updates.py).
All git interaction is mocked no test touches the real repository,
the network, or restarts the process.
"""
from __future__ import annotations
import subprocess
import pytest
@pytest.fixture(scope="module")
def flask_app():
import gdpr_scanner
gdpr_scanner.app.config["TESTING"] = True
return gdpr_scanner.app
@pytest.fixture()
def client(flask_app):
with flask_app.test_client() as c:
yield c
def _cp(returncode=0, stdout="", stderr=""):
return subprocess.CompletedProcess(args=[], returncode=returncode,
stdout=stdout, stderr=stderr)
def _fake_git(*, local="aaaaaaa1", remote="aaaaaaa1", branch="main",
fetch_rc=0, dirty=False, reqs_changed=False, merge_rc=0,
commits=""):
"""Build a _git() replacement dispatching on the git subcommand."""
calls = []
def fake(*args, timeout=None):
calls.append(args)
if args[:2] == ("rev-parse", "--abbrev-ref"):
return _cp(stdout=branch + "\n")
if args == ("rev-parse", "HEAD"):
return _cp(stdout=local + "\n")
if args[0] == "rev-parse":
return _cp(stdout=remote + "\n")
if args[0] == "fetch":
return _cp(returncode=fetch_rc, stderr="fetch failed" if fetch_rc else "")
if args[0] == "log":
return _cp(stdout=commits)
if args[0] == "diff-index":
return _cp(returncode=1 if dirty else 0)
if args[0] == "diff":
return _cp(returncode=1 if reqs_changed else 0)
if args[0] == "merge":
return _cp(returncode=merge_rc, stderr="not a fast-forward" if merge_rc else "")
if args[0] == "stash":
return _cp()
raise AssertionError(f"unexpected git call: {args}")
fake.calls = calls
return fake
@pytest.fixture(autouse=True)
def supported(monkeypatch):
import routes.updates as upd
monkeypatch.setattr(upd, "_supported", lambda: True)
@pytest.fixture(autouse=True)
def no_audit(monkeypatch):
import gdpr_db
monkeypatch.setattr(gdpr_db, "log_audit_event", lambda *a, **k: None)
# ── /api/update/check ─────────────────────────────────────────────────────────
def test_check_unsupported(client, monkeypatch):
import routes.updates as upd
monkeypatch.setattr(upd, "_supported", lambda: False)
r = client.get("/api/update/check")
assert r.status_code == 200
assert r.get_json() == {"supported": False}
def test_check_up_to_date(client, monkeypatch):
import routes.updates as upd
monkeypatch.setattr(upd, "_git", _fake_git())
d = client.get("/api/update/check").get_json()
assert d["supported"] and d["up_to_date"]
assert d["commits"] == []
def test_check_update_available(client, monkeypatch):
import routes.updates as upd
monkeypatch.setattr(upd, "_git", _fake_git(
local="aaaaaaa1", remote="bbbbbbb2",
commits="bbbbbbb2 Fix thing\nccccccc3 Add thing\n"))
d = client.get("/api/update/check").get_json()
assert d["up_to_date"] is False
assert d["current"] == "aaaaaaa"
assert d["latest"] == "bbbbbbb"
assert len(d["commits"]) == 2
def test_check_fetch_failure(client, monkeypatch):
import routes.updates as upd
monkeypatch.setattr(upd, "_git", _fake_git(fetch_rc=1))
d = client.get("/api/update/check").get_json()
assert d["supported"] is True
assert "fetch failed" in d["error"]
# ── /api/update/apply ─────────────────────────────────────────────────────────
def test_apply_up_to_date_is_noop(client, monkeypatch):
import routes.updates as upd
monkeypatch.setattr(upd, "_git", _fake_git())
monkeypatch.setattr(upd, "_schedule_restart", lambda *a, **k: pytest.fail("must not restart"))
r = client.post("/api/update/apply")
assert r.status_code == 200
d = r.get_json()
assert d["ok"] is True and d["updated"] is False
def test_apply_refused_while_scan_running(client, monkeypatch):
import routes.updates as upd
from routes import state
monkeypatch.setattr(upd, "_git", _fake_git(remote="bbbbbbb2"))
monkeypatch.setattr(upd, "_schedule_restart", lambda *a, **k: pytest.fail("must not restart"))
assert state._scan_lock.acquire(blocking=False)
try:
r = client.post("/api/update/apply")
finally:
state._scan_lock.release()
assert r.status_code == 409
assert r.get_json()["code"] == "scan_running"
def test_apply_happy_path(client, monkeypatch):
import routes.updates as upd
fake = _fake_git(remote="bbbbbbb2", commits="bbbbbbb2 Fix\n")
monkeypatch.setattr(upd, "_git", fake)
restarts = []
monkeypatch.setattr(upd, "_schedule_restart", lambda *a, **k: restarts.append(1))
r = client.post("/api/update/apply")
assert r.status_code == 200
d = r.get_json()
assert d["ok"] and d["updated"] and d["restarting"]
assert d["from"] == "aaaaaaa" and d["to"] == "bbbbbbb"
assert restarts == [1]
assert ("merge", "--ff-only", "origin/main") in fake.calls
# tree was clean — no stash
assert not any(c[0] == "stash" for c in fake.calls)
def test_apply_stashes_dirty_tree(client, monkeypatch):
import routes.updates as upd
fake = _fake_git(remote="bbbbbbb2", dirty=True)
monkeypatch.setattr(upd, "_git", fake)
monkeypatch.setattr(upd, "_schedule_restart", lambda *a, **k: None)
r = client.post("/api/update/apply")
assert r.status_code == 200
assert any(c[0] == "stash" for c in fake.calls)
def test_apply_merge_failure(client, monkeypatch):
import routes.updates as upd
monkeypatch.setattr(upd, "_git", _fake_git(remote="bbbbbbb2", merge_rc=1))
monkeypatch.setattr(upd, "_schedule_restart", lambda *a, **k: pytest.fail("must not restart"))
r = client.post("/api/update/apply")
assert r.status_code == 409
d = r.get_json()
assert d["code"] == "merge_failed"
assert "fast-forward" in d["error"]
def test_apply_installs_requirements_when_changed(client, monkeypatch):
import routes.updates as upd
fake = _fake_git(remote="bbbbbbb2", reqs_changed=True)
monkeypatch.setattr(upd, "_git", fake)
monkeypatch.setattr(upd, "_schedule_restart", lambda *a, **k: None)
pip_calls = []
monkeypatch.setattr(upd.subprocess, "run",
lambda cmd, **kw: pip_calls.append(cmd) or _cp())
r = client.post("/api/update/apply")
assert r.status_code == 200
assert len(pip_calls) == 1
assert "pip" in pip_calls[0] and "-r" in pip_calls[0]
# ── /api/update/settings ──────────────────────────────────────────────────────
def test_settings_roundtrip(client, monkeypatch):
import routes.updates as upd
store = {"auto_update": False}
monkeypatch.setattr(upd, "get_update_config", lambda: dict(store))
monkeypatch.setattr(upd, "save_update_config",
lambda v: store.__setitem__("auto_update", bool(v)))
d = client.get("/api/update/settings").get_json()
assert d == {"supported": True, "auto_update": False}
r = client.post("/api/update/settings", json={"auto_update": True})
assert r.get_json() == {"ok": True}
assert store["auto_update"] is True
d = client.get("/api/update/settings").get_json()
assert d["auto_update"] is True

83
update_gdpr.sh Executable file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env bash
# GDPRScanner — self-update script.
#
# Pulls the latest release from origin, reinstalls dependencies if they
# changed, and restarts the systemd service if one is installed.
# Safe to run from cron: exits quietly when already up to date, and
# auto-stashes local hotfixes instead of aborting the merge.
#
# Usage:
# ./update_gdpr.sh # update if origin has new commits
# ./update_gdpr.sh --check # report status only, change nothing
#
# Environment:
# GDPR_BRANCH branch to track (default: main)
# GDPR_SERVICE systemd unit to restart (default: gdprscanner, if it exists)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BRANCH="${GDPR_BRANCH:-main}"
SERVICE="${GDPR_SERVICE:-gdprscanner}"
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
cd "$SCRIPT_DIR"
if [ ! -d .git ]; then
log "ERROR: $SCRIPT_DIR is not a git checkout — cannot self-update."
exit 1
fi
git fetch origin "$BRANCH" --quiet
LOCAL="$(git rev-parse HEAD)"
REMOTE="$(git rev-parse "origin/$BRANCH")"
if [ "$LOCAL" = "$REMOTE" ]; then
log "Already up to date ($(git describe --always HEAD))."
exit 0
fi
log "Update available: $(git rev-parse --short HEAD) -> $(git rev-parse --short "$REMOTE")"
git log --oneline "HEAD..origin/$BRANCH" | sed 's/^/ /'
if [ "${1:-}" = "--check" ]; then
exit 0
fi
# Local edits (e.g. a hotfix applied directly on the server) would make the
# merge abort. Stash them so the update proceeds; the stash is kept so
# nothing is lost.
if ! git diff-index --quiet HEAD --; then
log "Local changes detected — stashing:"
git diff --stat HEAD | sed 's/^/ /'
git stash push --quiet -m "update_gdpr.sh auto-stash $(date '+%Y-%m-%d %H:%M:%S')"
log "Recover later with: git stash show -p / git stash pop"
fi
REQS_CHANGED=false
if ! git diff --quiet "HEAD..origin/$BRANCH" -- requirements.txt; then
REQS_CHANGED=true
fi
# Fast-forward only: the server checkout must never diverge from origin.
git merge --ff-only --quiet "origin/$BRANCH"
log "Updated to $(git rev-parse --short HEAD)."
if [ "$REQS_CHANGED" = true ]; then
log "requirements.txt changed — updating dependencies..."
"$SCRIPT_DIR/venv/bin/pip" install --quiet -r requirements.txt
log "Dependencies updated."
fi
if command -v systemctl >/dev/null 2>&1 \
&& systemctl list-unit-files --type=service 2>/dev/null | grep -q "^$SERVICE\.service"; then
log "Restarting $SERVICE.service..."
systemctl restart "$SERVICE"
log "Service restarted."
else
log "No systemd unit '$SERVICE' found — restart GDPRScanner manually."
fi
log "Done."