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:
parent
fcf32f3751
commit
c0e45df440
@ -9,6 +9,12 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
||||
|
||||
## [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
|
||||
|
||||
@ -50,7 +50,9 @@ python -m pytest tests/ -q
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@ -354,6 +354,18 @@ def get_claude_api_key() -> str:
|
||||
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) ─────────────────────────────────────────────────────
|
||||
_SETTINGS_PATH = _DATA_DIR / "settings.json"
|
||||
_SRC_TOGGLES_PATH = _DATA_DIR / "src_toggles.json"
|
||||
|
||||
@ -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_scan import bp as google_scan_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,
|
||||
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)
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
@ -2296,5 +2297,14 @@ Example --settings file with SMTP:
|
||||
except Exception as _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")
|
||||
app.run(host=args.host, port=args.port, debug=False, threaded=True)
|
||||
|
||||
15
lang/da.json
15
lang/da.json
@ -891,5 +891,16 @@
|
||||
"m365_ai_test_ok": "API-nøgle er gyldig",
|
||||
"m365_ai_test_fail": "Test mislykkedes",
|
||||
"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."
|
||||
}
|
||||
|
||||
15
lang/de.json
15
lang/de.json
@ -891,5 +891,16 @@
|
||||
"m365_ai_test_ok": "API-Schlüssel gültig",
|
||||
"m365_ai_test_fail": "Test fehlgeschlagen",
|
||||
"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."
|
||||
}
|
||||
|
||||
15
lang/en.json
15
lang/en.json
@ -891,5 +891,16 @@
|
||||
"m365_ai_test_ok": "API key valid",
|
||||
"m365_ai_test_fail": "Test failed",
|
||||
"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."
|
||||
}
|
||||
|
||||
@ -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`.
|
||||
- **`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.
|
||||
- **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.
|
||||
|
||||
## 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": "..."}`.
|
||||
- **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
|
||||
|
||||
- **`/view` auth chain** — token (`?token=`) → session cookie (`session["viewer_ok"]`) → PIN form → 403. Never skip this order.
|
||||
|
||||
195
routes/updates.py
Normal file
195
routes/updates.py
Normal 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
|
||||
@ -244,6 +244,7 @@ function switchSettingsTab(tab) {
|
||||
if (pane) pane.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 === 'email') stLoadSmtp();
|
||||
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() {
|
||||
const inp = document.getElementById('aiApiKey');
|
||||
const btn = document.getElementById('aiShowKeyBtn');
|
||||
@ -362,5 +463,9 @@ window.stLoadAiSettings = stLoadAiSettings;
|
||||
window.stAiSave = stAiSave;
|
||||
window.stAiTest = stAiTest;
|
||||
window.stAiToggleKey = stAiToggleKey;
|
||||
window.stLoadUpdateSettings = stLoadUpdateSettings;
|
||||
window.stSaveAutoUpdate = stSaveAutoUpdate;
|
||||
window.stCheckUpdate = stCheckUpdate;
|
||||
window.stApplyUpdate = stApplyUpdate;
|
||||
window._M365_SOURCES = _M365_SOURCES;
|
||||
window._pinCallback = _pinCallback;
|
||||
|
||||
@ -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>openpyxl</span><span id="st-about-openpyxl" style="color:var(--muted)">—</span></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>
|
||||
|
||||
<!-- ── Security pane ─────────────────────────────────────────────────── -->
|
||||
|
||||
205
tests/test_updates.py
Normal file
205
tests/test_updates.py
Normal 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
83
update_gdpr.sh
Executable 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."
|
||||
Loading…
x
Reference in New Issue
Block a user