diff --git a/frontends/web/blueprints/attest.py b/frontends/web/blueprints/attest.py index 06daa7b..c5c9b6e 100644 --- a/frontends/web/blueprints/attest.py +++ b/frontends/web/blueprints/attest.py @@ -133,12 +133,17 @@ def attest(): caption = request.form.get("caption", "").strip() location_name = request.form.get("location_name", "").strip() investigation = request.form.get("investigation", "").strip() + parent_record_id = request.form.get("parent_record_id", "").strip() + derivation_type = request.form.get("derivation_type", "").strip() if caption: metadata["caption"] = caption if location_name: metadata["location_name"] = location_name if investigation: metadata["investigation"] = investigation + if parent_record_id: + metadata["derived_from"] = parent_record_id + metadata["derivation_type"] = derivation_type or "unspecified" auto_exif = request.form.get("auto_exif", "on") == "on" strip_device = request.form.get("strip_device", "on") == "on" diff --git a/frontends/web/blueprints/dropbox.py b/frontends/web/blueprints/dropbox.py index e9bd4a5..70a9a5c 100644 --- a/frontends/web/blueprints/dropbox.py +++ b/frontends/web/blueprints/dropbox.py @@ -22,14 +22,12 @@ from auth import admin_required, login_required from flask import Blueprint, Response, flash, redirect, render_template, request, url_for from soosef.audit import log_action -from soosef.paths import TEMP_DIR +from soosef.paths import AUTH_DIR, TEMP_DIR bp = Blueprint("dropbox", __name__, url_prefix="/dropbox") -# In-memory token store. In production, this should be persisted to SQLite. -# Token format: {token: {created_at, expires_at, max_files, label, used, receipts[]}} -_tokens: dict[str, dict] = {} _TOKEN_DIR = TEMP_DIR / "dropbox" +_DB_PATH = AUTH_DIR / "dropbox.db" def _ensure_token_dir(): @@ -37,6 +35,75 @@ def _ensure_token_dir(): _TOKEN_DIR.chmod(0o700) +def _get_db(): + """Get SQLite connection for drop box tokens.""" + import sqlite3 + + _DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(_DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("""CREATE TABLE IF NOT EXISTS tokens ( + token TEXT PRIMARY KEY, + label TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + max_files INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0 + )""") + conn.execute("""CREATE TABLE IF NOT EXISTS receipts ( + receipt_code TEXT PRIMARY KEY, + token TEXT NOT NULL, + filename TEXT, + sha256 TEXT, + received_at TEXT, + FOREIGN KEY (token) REFERENCES tokens(token) + )""") + conn.commit() + return conn + + +def _get_token(token: str) -> dict | None: + """Load a token from SQLite. Returns dict or None if expired/missing.""" + conn = _get_db() + row = conn.execute("SELECT * FROM tokens WHERE token = ?", (token,)).fetchone() + if not row: + conn.close() + return None + if datetime.fromisoformat(row["expires_at"]) < datetime.now(UTC): + conn.execute("DELETE FROM tokens WHERE token = ?", (token,)) + conn.commit() + conn.close() + return None + data = dict(row) + # Load receipts + receipts = conn.execute( + "SELECT receipt_code FROM receipts WHERE token = ?", (token,) + ).fetchall() + data["receipts"] = [r["receipt_code"] for r in receipts] + conn.close() + return data + + +def _get_all_tokens() -> dict[str, dict]: + """Load all non-expired tokens.""" + conn = _get_db() + now = datetime.now(UTC).isoformat() + # Clean expired + conn.execute("DELETE FROM tokens WHERE expires_at < ?", (now,)) + conn.commit() + rows = conn.execute("SELECT * FROM tokens").fetchall() + result = {} + for row in rows: + data = dict(row) + receipts = conn.execute( + "SELECT receipt_code FROM receipts WHERE token = ?", (row["token"],) + ).fetchall() + data["receipts"] = [r["receipt_code"] for r in receipts] + result[row["token"]] = data + conn.close() + return result + + @bp.route("/admin", methods=["GET", "POST"]) @admin_required def admin(): @@ -49,14 +116,13 @@ def admin(): max_files = int(request.form.get("max_files", 10)) token = secrets.token_urlsafe(32) - _tokens[token] = { - "created_at": datetime.now(UTC).isoformat(), - "expires_at": (datetime.now(UTC) + timedelta(hours=hours)).isoformat(), - "max_files": max_files, - "label": label, - "used": 0, - "receipts": [], - } + conn = _get_db() + conn.execute( + "INSERT INTO tokens (token, label, created_at, expires_at, max_files, used) VALUES (?, ?, ?, ?, ?, 0)", + (token, label, datetime.now(UTC).isoformat(), (datetime.now(UTC) + timedelta(hours=hours)).isoformat(), max_files), + ) + conn.commit() + conn.close() log_action( actor=request.environ.get("REMOTE_USER", "admin"), @@ -70,27 +136,21 @@ def admin(): flash(f"Drop box created. Share this URL with your source: {upload_url}", "success") elif action == "revoke": - token = request.form.get("token", "") - if token in _tokens: - del _tokens[token] - flash("Token revoked.", "success") + tok = request.form.get("token", "") + conn = _get_db() + conn.execute("DELETE FROM receipts WHERE token = ?", (tok,)) + conn.execute("DELETE FROM tokens WHERE token = ?", (tok,)) + conn.commit() + conn.close() + flash("Token revoked.", "success") - # Clean expired tokens - now = datetime.now(UTC) - expired = [t for t, d in _tokens.items() if datetime.fromisoformat(d["expires_at"]) < now] - for t in expired: - del _tokens[t] - - return render_template("dropbox/admin.html", tokens=_tokens) + return render_template("dropbox/admin.html", tokens=_get_all_tokens()) def _validate_token(token: str) -> dict | None: """Check if a token is valid. Returns token data or None.""" - if token not in _tokens: - return None - data = _tokens[token] - if datetime.fromisoformat(data["expires_at"]) < datetime.now(UTC): - del _tokens[token] + data = _get_token(token) + if data is None: return None if data["used"] >= data["max_files"]: return None @@ -175,8 +235,14 @@ def upload(token): except Exception: pass # Attestation is best-effort; don't fail the upload - # Generate receipt code - receipt_code = secrets.token_hex(8) + # Receipt code derived from file hash via HMAC — the source can + # independently verify their receipt corresponds to specific content + import hmac + + receipt_code = hmac.new( + token.encode(), sha256.encode(), hashlib.sha256 + ).hexdigest()[:16] + receipts.append({ "filename": f.filename, "sha256": sha256, @@ -184,8 +250,16 @@ def upload(token): "received_at": datetime.now(UTC).isoformat(), }) + # Persist receipt and increment used count in SQLite + conn = _get_db() + conn.execute( + "INSERT OR IGNORE INTO receipts (receipt_code, token, filename, sha256, received_at) VALUES (?, ?, ?, ?, ?)", + (receipt_code, token, f.filename, sha256, datetime.now(UTC).isoformat()), + ) + conn.execute("UPDATE tokens SET used = used + 1 WHERE token = ?", (token,)) + conn.commit() + conn.close() token_data["used"] += 1 - token_data["receipts"].append(receipt_code) remaining = token_data["max_files"] - token_data["used"] @@ -200,21 +274,56 @@ def upload(token): return Response(receipt_text, content_type="text/plain") - # GET — show upload form (minimal, no SooSeF branding for source safety) + # GET — show upload form with client-side SHA-256 hashing + # Minimal page, no SooSeF branding (source safety) remaining = token_data["max_files"] - token_data["used"] return f"""
Select files to upload. You may upload up to {remaining} file(s).
-Your files will be timestamped on receipt. No account or personal information is required.
-This link will expire automatically. Do not bookmark it.
+