feat: interface PIN, bulk disposition tagging, Google Drive delta scan, OCR memory fixes
- Interface PIN: optional session-level auth gate for the main scanner UI
(Settings → Security → Interface PIN). Salted SHA-256 in config.json,
rate-limited (5 attempts/5 min per IP). /view and viewer auth exempt.
New /login page, before_request hook, GET/POST/DELETE /api/interface/pin,
POST /api/interface/pin/verify, POST /api/interface/logout.
- Bulk disposition tagging: Select mode (filter bar "Vælg" button) reveals
per-card checkboxes. Bulk tag bar at bottom of grid; POST /api/db/disposition/bulk.
Disposition stats bar (total · unreviewed · retain · delete · % reviewed)
updates after every save.
- Google Drive delta scan: uses Drive Changes API when delta is enabled.
Per-user token stored as gdrive:{email} in delta.json. Load-then-merge
save avoids racing with concurrent M365 token writes.
- PDF OCR OOM fix: render one page at a time with convert_from_path
(first_page=N, last_page=N). Added _ocr_mem_ok() psutil guard (500 MB
threshold) before each page render across scan_pdf, redact_fitz_pdf,
redact_pdf.
- Email test message translation fix: routes/email.py returns structured
{ok, method, recipients} instead of a hardcoded English string;
scheduler.js builds the translated message client-side.
- Docs: CHANGELOG, README, TODO, MANUAL-EN, MANUAL-DA all updated.
Lang files (en/da/de) extended with bulk, interface PIN, and SMTP keys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2bfa40f27
commit
d8083eb0c0
26 files changed
+1059
-128
No files matched your search
+67
-2
@@ -146,7 +146,7 @@ _migrate_to_data_dir()
|
||||
|
||||
# ── Flask ─────────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
from flask import Flask, Response, jsonify, render_template, request, session
|
||||
from flask import Flask, Response, jsonify, redirect, render_template, request, session
|
||||
except ImportError:
|
||||
print("Flask required: pip install flask")
|
||||
sys.exit(1)
|
||||
@@ -368,7 +368,72 @@ def _sync_state():
|
||||
# JavaScript served from static/app.js via Flask static file handling.
|
||||
|
||||
|
||||
# ── Auth state ─────────────────────────────────────────────────────────────────
|
||||
# ── Interface PIN auth ────────────────────────────────────────────────────────
|
||||
|
||||
_iface_pin_attempts: dict[str, list[float]] = {}
|
||||
_IFACE_MAX_ATTEMPTS = 5
|
||||
_IFACE_WINDOW_S = 300
|
||||
|
||||
|
||||
def _iface_rate_limited(ip: str) -> bool:
|
||||
now = time.time()
|
||||
times = [t for t in _iface_pin_attempts.get(ip, []) if now - t < _IFACE_WINDOW_S]
|
||||
_iface_pin_attempts[ip] = times
|
||||
return len(times) >= _IFACE_MAX_ATTEMPTS
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _require_interface_pin():
|
||||
from app_config import get_interface_pin_hash
|
||||
if not get_interface_pin_hash():
|
||||
return # feature disabled — open access
|
||||
path = request.path
|
||||
# Always-exempt paths
|
||||
if (path.startswith("/static/")
|
||||
or path in ("/login", "/view", "/manual", "/favicon.ico")
|
||||
or path == "/api/interface/pin/verify"
|
||||
or path == "/api/viewer/pin/verify"):
|
||||
return
|
||||
# Authenticated sessions (interface or viewer) pass through
|
||||
if session.get("interface_ok") or session.get("viewer_ok"):
|
||||
return
|
||||
if path.startswith("/api/"):
|
||||
return jsonify({"error": "authentication required"}), 401
|
||||
return redirect("/login")
|
||||
|
||||
|
||||
@app.route("/login")
|
||||
def login_page():
|
||||
from app_config import get_interface_pin_hash
|
||||
if not get_interface_pin_hash():
|
||||
return redirect("/")
|
||||
if session.get("interface_ok"):
|
||||
return redirect("/")
|
||||
return render_template("interface_login.html", LANG=LANG)
|
||||
|
||||
|
||||
@app.route("/api/interface/pin/verify", methods=["POST"])
|
||||
def interface_pin_verify():
|
||||
from app_config import verify_interface_pin
|
||||
ip = request.remote_addr or "unknown"
|
||||
if _iface_rate_limited(ip):
|
||||
return jsonify({"error": "Too many failed attempts. Try again later."}), 429
|
||||
body = request.get_json(silent=True) or {}
|
||||
pin = str(body.get("pin", "")).strip()
|
||||
if not verify_interface_pin(pin):
|
||||
_iface_pin_attempts.setdefault(ip, []).append(time.time())
|
||||
return jsonify({"error": "Incorrect PIN"}), 401
|
||||
_iface_pin_attempts.pop(ip, None)
|
||||
session["interface_ok"] = True
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/interface/logout", methods=["POST"])
|
||||
def interface_logout():
|
||||
session.pop("interface_ok", None)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/")
|
||||
|
||||
Reference in new issue
Block a user