Re-audit round 4: 5 blind reviewers over the new surface — 24 fixes, +28 tests

The findings clustered exactly where prediction said: the unreviewed web
layer. The big ones: decisions made while an extract/resolve job runs
are now refused with a 409 (the job's end-of-run rewrite from a
start-of-run snapshot would silently revert them); a cross-origin guard
blocks preflight-free mutations from hostile webpages (bodyless run
triggers, cross-site photo form posts); the JobRunner sets terminal
status in a finally catching BaseException (a greenlet death could
wedge every future run behind 409s) and writes tracebacks into the
visible job log; and a boot token lets clients accept the revision
reset after a server restart instead of freezing forever.

Even the thrice-audited core yielded one HIGH: an unvetoed bare
typo-read sibling of a confident row duplicated its add when the game
wasn't in the collection — diff now treats it as satisfied. Second-copy
adds carry a flag through to_add.csv and the upload log so verify
honestly reports them unverifiable instead of OK. Also: merged_into
chains collapse transitively; diff/enrich treat a BGG queue timeout
like a missing token; enrich prunes orphaned games.json keys; the
wizard shell-quotes .env values and creates the file 0600 from the
first byte; fsio stats the tmp inode before replace and uses unique tmp
names; an explicit missing --config errors; storage state is
owner-only; extract re-extracts corrupt caches, aborts on 3 identical
failures, and exits nonzero when nothing succeeded; torn JSON artifacts
degrade with in-browser warnings instead of 500ing every page; photo
uploads are atomic with cache-invalidation ordered first; the pipeline
page computes `running` before the buttons that depend on it; the
photo dropzone alerts on network failure; and lost-contact banners
clear on recovery everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 18:13:34 -04:00
co-authored by Claude Fable 5
parent 16003ee39f
commit 08b741671d
25 changed files with 798 additions and 66 deletions
+5
View File
@@ -95,6 +95,11 @@ class Config:
def load_config(path: Path | None = None) -> Config:
cfg = Config()
p = path or DEFAULT_CONFIG_PATH
if path is not None and not path.exists():
# an EXPLICIT --config that doesn't exist must not silently fall
# back to defaults — that reads photos/ while the user believes
# their prod config is active
raise FileNotFoundError(f"--config {path} does not exist")
if p.exists():
raw = tomllib.loads(p.read_text())
known = {
+25 -6
View File
@@ -19,12 +19,18 @@ Outputs both artifacts:
from __future__ import annotations
import os
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
import typer
from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for
from bggpipe.bgg_client import (
BGGAuthError,
BGGClient,
BGGQueueTimeout,
client_for,
)
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_csv
from bggpipe.models import (
@@ -44,6 +50,7 @@ TO_ADD_COLUMNS = [
"version_name",
"title_raw",
"source_photos",
"second_copy", # "1": game already had copies — verify can't confirm it
]
TO_UPDATE_COLUMNS = ["collid", "bgg_id", "bgg_name", "version_id", "version_name"]
@@ -100,7 +107,10 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
p for p in row["source_photos"].split(";") if p
)
def add_row(row: dict, confident: bool) -> dict:
queued_adds: Counter[int] = Counter() # per game, across all passes
def add_row(row: dict, confident: bool, second_copy: bool = False) -> dict:
queued_adds[int(row["bgg_id"])] += 1
photos = {p for p in row["source_photos"].split(";") if p}
photos |= merged_photos.get(row["title_raw"], set())
return {
@@ -112,6 +122,7 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
"version_name": row["version_name"] if confident else "",
"title_raw": row["title_raw"],
"source_photos": ";".join(sorted(photos)),
"second_copy": "1" if second_copy else "",
}
recognized: list[dict] = []
@@ -212,7 +223,7 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
"left untouched"
)
else:
result.to_add.append(add_row(row, True))
result.to_add.append(add_row(row, True, second_copy=True))
result.second_copies.append(
f"{row['title_raw']}: adding as a NEW copy with version "
f"{row['version_name']!r} ({row['version_id']}) — every "
@@ -226,14 +237,22 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
bgg_id = int(row["bgg_id"])
copies = by_object.get(bgg_id, [])
if not copies:
result.to_add.append(add_row(row, False))
if queued_adds[bgg_id] and not row.get("dedupe_veto"):
# an unvetoed bare row is a typo-read sibling: a pass-1 add
# for the same absent game already covers the physical box —
# queueing it again would upload a duplicate entry
result.already_owned.append(row["title_raw"])
else:
result.to_add.append(
add_row(row, False, second_copy=bool(queued_adds[bgg_id]))
)
continue
remaining = unconsumed(bgg_id)
if remaining:
consumed_collids.add(remaining[0].coll_id)
result.already_owned.append(row["title_raw"])
elif row.get("dedupe_veto"):
result.to_add.append(add_row(row, False))
result.to_add.append(add_row(row, False, second_copy=True))
result.second_copies.append(
f"{row['title_raw']}: adding as a NEW version-less copy — "
"human-vetoed duplicate, every existing entry claimed by "
@@ -263,7 +282,7 @@ def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult:
client = client or client_for(cfg)
try:
collection = client.collection_full(cfg.bgg_username, refresh=True)
except BGGAuthError as err:
except (BGGAuthError, BGGQueueTimeout) as err:
# a present-but-invalid token must not traceback when the
# snapshot fallback is sitting right there
typer.echo(f"Live fetch failed ({err}) — using snapshot files.")
+21 -2
View File
@@ -19,7 +19,12 @@ import json
import typer
from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for
from bggpipe.bgg_client import (
BGGAuthError,
BGGClient,
BGGQueueTimeout,
client_for,
)
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import is_confident_version, is_recognized
@@ -80,7 +85,7 @@ def run_enrich(
try:
for thing in client.things_full(batch, refresh=refresh):
fetched[thing["bgg_id"]] = thing
except BGGAuthError:
except (BGGAuthError, BGGQueueTimeout):
blocked = True
break
@@ -90,6 +95,20 @@ def run_enrich(
games[key] = {**fetched[bgg_id], "version": version}
updated += 1
# prune keys no current target claims: a row whose version was approved
# after a bare-key run (or was later rejected) must not leave an orphan
# entry in the frontend seed data. Only safe when nothing was blocked —
# a token-less run knows too little to declare anything stale.
if not blocked:
current = {key for key, _, _ in targets}
stale = [k for k in games if k not in current]
for k in stale:
del games[k]
if stale:
typer.echo(
f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}"
)
games_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(
games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
+33 -3
View File
@@ -265,7 +265,13 @@ def rebuild_artifacts(
entries: list[dict] = []
unidentified: dict[str, list[dict]] = {}
for raw_file in sorted(raw_dir.glob("*.json")):
data = json.loads(raw_file.read_text())
try:
data = json.loads(raw_file.read_text())
except json.JSONDecodeError as err:
raise ValueError(
f"{raw_file} is corrupt ({err}) — delete it (or re-upload "
"the photo) and run extract again"
) from err
if isinstance(data, list): # bare-array shape
entries.extend(data)
continue
@@ -315,17 +321,37 @@ def run_extract(
vision = vision or default_vision(cfg.model)
failed: list[str] = []
consecutive: tuple[str, int] = ("", 0)
attempted = 0
for photo in photos:
raw_path = raw_dir / f"{photo.name}.json"
if raw_path.exists() and not only and not force:
typer.echo(f" {photo.name}: already extracted, skipping")
continue
try:
json.loads(raw_path.read_text())
typer.echo(f" {photo.name}: already extracted, skipping")
continue
except json.JSONDecodeError:
typer.echo(f" {photo.name}: cache corrupt — re-extracting")
attempted += 1
try:
result = extract_photo(photo, vision)
except Exception as err: # one bad photo must not block the rest
failed.append(photo.name)
typer.echo(f" {photo.name}: FAILED ({err}) — continuing")
kind = type(err).__name__
consecutive = (
kind,
consecutive[1] + 1 if kind == consecutive[0] else 1,
)
if consecutive[1] >= 3:
typer.echo(
" aborting — 3 identical consecutive failures look "
"systemic (bad API key? wrong model in config.toml?), "
"not per-photo; nothing more will be attempted"
)
break
continue
consecutive = ("", 0)
atomic_write_text(
raw_path, json.dumps(result, indent=2, ensure_ascii=False) + "\n"
)
@@ -352,6 +378,10 @@ def run_extract(
f"\n{len(failed)} photo(s) failed extraction (re-run to retry): "
+ ", ".join(failed)
)
if len(failed) == attempted:
# every attempted photo failed: this run accomplished nothing
# and a wrapper (or the web job runner) must not report success
raise typer.Exit(code=1)
if unidentified:
typer.echo(
+21 -3
View File
@@ -9,12 +9,26 @@ from __future__ import annotations
import csv
import os
import uuid
from pathlib import Path
def _tmp_for(path: Path) -> Path:
# unique per writer: a FIXED tmp name lets two concurrent processes
# interleave writes into one inode and replace garbage into place
return path.with_name(f"{path.name}.{uuid.uuid4().hex[:8]}.tmp")
def atomic_write_bytes(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = _tmp_for(path)
tmp.write_bytes(data)
os.replace(tmp, path)
def atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp = _tmp_for(path)
tmp.write_text(text)
os.replace(tmp, path)
@@ -23,12 +37,16 @@ def atomic_write_csv(path: Path, columns: list[str], rows: list[dict]) -> int:
"""Atomic CSV rewrite (tmp + os.replace). Returns the written file's
mtime_ns so callers tracking their own writes avoid a re-stat race."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp = _tmp_for(path)
with tmp.open("w", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=columns, extrasaction="ignore", restval=""
)
writer.writeheader()
writer.writerows(rows)
# stat the TMP inode before the replace: statting the destination after
# could hand back a FOREIGN writer's mtime landing in the gap, and the
# caller would record someone else's write as its own
mtime = tmp.stat().st_mtime_ns
os.replace(tmp, path)
return path.stat().st_mtime_ns
return mtime
+26 -9
View File
@@ -77,20 +77,34 @@ class InitReport:
def _env_file_keys(env_path: Path) -> set[str]:
"""Key names with non-empty values in .env. Values are never read into
variables that outlive this parse and are never printed."""
"""Key names with non-empty values in .env. Handles `export KEY=v` and
quoted values; a quoted-empty value ("" / '') counts as NOT set. Values
never outlive this parse and are never printed."""
if not env_path.exists():
return set()
present = set()
for line in env_path.read_text().splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
key, _, value = line.partition("=")
if value.strip():
present.add(key.strip())
if "=" not in line or line.startswith("#"):
continue
key, _, value = line.partition("=")
key = key.strip()
if key.startswith("export "):
key = key.removeprefix("export ").strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
value = value[1:-1]
if value:
present.add(key)
return present
def _quote_env_value(value: str) -> str:
"""Single-quote for `source`/direnv safety: spaces, $, backslashes and
quotes must survive the shell verbatim."""
return "'" + value.replace("'", "'\\''") + "'"
def _default_secret_prompt(label: str) -> str:
return typer.prompt(label, default="", show_default=False, hide_input=True)
@@ -162,11 +176,14 @@ def run_init(
report.keys_missing.append(key)
continue
is_new_file = not env_path.exists()
with env_path.open("a") as f:
# owner-only from the FIRST byte — chmod-after-write leaves a
# world-readable window holding a credential
fd = os.open(env_path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
with os.fdopen(fd, "a") as f:
if is_new_file:
f.write(ENV_HEADER)
f.write(f"{key}={value}\n")
env_path.chmod(0o600) # credentials: owner-only, every time
f.write(f"{key}={_quote_env_value(value)}\n")
env_path.chmod(0o600) # older files created by hand tighten up too
report.keys_written.append(key)
typer.echo(f" {key}: saved to {env_path}")
+26 -14
View File
@@ -13,11 +13,14 @@ from __future__ import annotations
import io
import threading
import time
import traceback
from collections.abc import Callable
from contextlib import redirect_stdout
import typer
MAX_LOG_LINES = 1000 # buffer cap; snapshots serve the last 200
class _LineBuffer(io.TextIOBase):
def __init__(self) -> None:
@@ -30,6 +33,8 @@ class _LineBuffer(io.TextIOBase):
self._partial += text
*complete, self._partial = self._partial.split("\n")
self._lines.extend(complete)
if len(self._lines) > MAX_LOG_LINES: # bound memory on long runs
del self._lines[: len(self._lines) - MAX_LOG_LINES]
return len(text)
def lines(self) -> list[str]:
@@ -68,20 +73,27 @@ class JobRunner:
def _run(self, fn: Callable[[], object]) -> None:
status, error = "done", ""
try:
with redirect_stdout(self._buffer):
fn()
except typer.Exit as exc:
if exc.exit_code:
status, error = "failed", f"exited with code {exc.exit_code}"
except SystemExit as exc:
if exc.code:
status, error = "failed", f"exited with code {exc.code}"
except Exception as exc: # surfaced in the UI, never swallowed
status, error = "failed", f"{type(exc).__name__}: {exc}"
with self._lock:
self._status = status
self._error = error
self._finished = time.time()
try:
with redirect_stdout(self._buffer):
fn()
except typer.Exit as exc:
if exc.exit_code:
status, error = "failed", f"exited with code {exc.exit_code}"
except SystemExit as exc:
if exc.code:
status, error = "failed", f"exited with code {exc.code}"
except BaseException as exc: # incl. GreenletExit et al: the
# runner must NEVER stay "running" — that wedges every
# future stage behind a 409 until a server restart
status, error = "failed", f"{type(exc).__name__}: {exc}"
# the short error names the exception; the log carries the
# traceback, or a failure is undiagnosable from the UI
self._buffer.write("\n" + traceback.format_exc())
finally:
with self._lock:
self._status = status
self._error = error
self._finished = time.time()
def snapshot(self) -> dict:
with self._lock:
+17
View File
@@ -475,6 +475,23 @@ def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEven
bgg_id=bgg_id,
)
)
# A survivor can itself lose in a later run; rows pointing at it would
# form a chain diff's one-level photo hop can't follow. Rewrite every
# merged row to its terminal survivor.
status_by_title = {r["title_raw"]: r for r in rows}
for row in rows:
if row["match_status"] != "merged":
continue
target, hops = row["merged_into"], 0
while (
hops < 10
and (nxt := status_by_title.get(target)) is not None
and nxt["match_status"] == "merged"
and nxt["merged_into"]
):
target = nxt["merged_into"]
hops += 1
row["merged_into"] = target
return events
+5 -2
View File
@@ -16,8 +16,11 @@ function showBanner(html) {
}
function errorBanner(detail) {
showBanner(`<div class="banner error">lost contact with the server ` +
`(${esc(detail)}) — check its terminal</div>`);
// an HTTP status means the server answered — the problem is its data
const httpish = /^\d{3}$/.test(String(detail));
showBanner(`<div class="banner error">${httpish
? `the server hit an error (HTTP ${esc(detail)}) — a data file may be broken`
: `lost contact with the server (${esc(detail)})`} — check its terminal</div>`);
}
async function fetchJSON(url) {
+1 -1
View File
@@ -51,5 +51,5 @@ async function refresh() {
document.getElementById("catsearch").addEventListener("input", render);
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000);
pollLoop(refresh, 5000, () => showBanner(""));
</script>
+1 -1
View File
@@ -57,5 +57,5 @@ async function refresh() {
document.getElementById("libsearch").addEventListener("input", render);
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 10000);
pollLoop(refresh, 10000, () => showBanner(""));
</script>
+9 -3
View File
@@ -87,15 +87,21 @@ async function sendPhotos(files) {
if (!files.length) return;
const form = new FormData();
for (const f of files) form.append("files", f);
const res = await fetch("/api/photos", {method: "POST", body: form});
let res;
try {
res = await fetch("/api/photos", {method: "POST", body: form});
} catch (err) {
alert("Upload failed (no response from the server): " + err);
return;
}
if (!res.ok) {
const detail = await res.json().then(d => d.detail).catch(() => null);
alert("Upload failed: " + (detail ?? res.statusText));
return;
}
refresh();
refresh().catch(() => {}); // the next poll self-heals a refresh hiccup
}
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000);
pollLoop(refresh, 5000, () => showBanner(""));
</script>
+2 -2
View File
@@ -11,7 +11,7 @@ let running = false;
async function runStage(stage, body) {
const res = await apiPost(`/api/run/${stage}`, body);
if (res) refresh();
if (res) refresh().catch(() => {}); // the poll self-heals a hiccup
}
function stageCard(num, name, facts, actions) {
@@ -28,6 +28,7 @@ function runBtn(stage, label) {
function render() {
const m = P.matches, log = P.upload_log;
running = P.job.status === "running"; // buttons below depend on this
const banners = [];
if (P.stub_data) banners.push(
@@ -67,7 +68,6 @@ function render() {
});
const job = P.job;
running = job.status === "running";
const state = document.getElementById("jobstate");
if (job.status === "idle") state.textContent = "idle";
else state.innerHTML = `<span class="${esc(job.status)}">${esc(job.stage)}: ${esc(job.status)}</span>` +
+1 -1
View File
@@ -53,5 +53,5 @@ async function refresh() {
}
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000);
pollLoop(refresh, 5000, () => showBanner(""));
</script>
+4 -1
View File
@@ -267,7 +267,10 @@ refresh().catch(err => errorBanner(err.message || err));
let lastGood = null;
pollLoop(async () => {
const fresh = await fetchJSON("/api/state");
if (STATE && fresh.revision < STATE.revision) return;
// discard stale responses — but only within one server lifetime: a
// restart resets revision to 0 (boot changes), and refusing forever
// would freeze the page
if (STATE && fresh.boot === STATE.boot && fresh.revision < STATE.revision) return;
if (JSON.stringify(fresh) !== JSON.stringify(STATE)) {
STATE = fresh;
render();
+22
View File
@@ -48,6 +48,7 @@ UPLOAD_LOG_COLUMNS = [
"collid",
"name",
"version_id",
"second_copy",
"status",
"timestamp",
"error",
@@ -64,6 +65,7 @@ class UploadJob:
version_id: str = ""
version_name: str = ""
collid: str = ""
second_copy: bool = False # game had prior copies: verify can't confirm
@property
def key(self) -> tuple[str, str, str]:
@@ -135,6 +137,7 @@ def build_queue(
name=row["bgg_name"],
version_id=row.get("version_id", ""),
version_name=row.get("version_name", ""),
second_copy=bool(row.get("second_copy")),
)
for row in to_add
] + [
@@ -161,11 +164,19 @@ def build_queue(
deferred: list[UploadJob] = []
update_game_seen: set[str] = set()
seen: Counter[tuple[str, str, str]] = Counter()
queued_versions: dict[str, set[str]] = {}
for job in candidates:
if job.action == "add":
queued_versions.setdefault(job.bgg_id, set()).add(job.version_id)
for job in candidates:
prior = done_versions.get(
("update", job.collid) if job.action == "update" else ("add", job.bgg_id),
set(),
)
if job.action == "add" and prior & queued_versions.get(job.bgg_id, set()):
# the done version is still queued alongside this one: a
# multi-edition game partway through, not a re-review drift
prior = set()
if prior and job.version_id not in prior:
typer.echo(
f" note: {job.name} was previously {job.action}ed with a "
@@ -303,6 +314,7 @@ class PlaywrightUploader:
"page changed — see docs/bgg-upload-flow.md)"
) from err
self._context.storage_state(path=str(self._storage_state))
self._storage_state.chmod(0o600) # session cookies: owner-only
self._authed = True
def _open_dialog(self, opener):
@@ -444,6 +456,7 @@ def _process(
"collid": job.collid,
"name": job.name,
"version_id": job.version_id,
"second_copy": "1" if job.second_copy else "",
"status": status,
"timestamp": now(),
# the column carries degradation notes on successes too
@@ -488,6 +501,15 @@ def verify_uploads(log_rows: list[dict], collection: list[CollectionItem]) -> li
added_copies[int(row["bgg_id"])] += 1
for row in latest.values():
if row["status"] in ("added", "added_no_version"):
if row.get("second_copy"):
# the game had copies before this add: collection state can't
# distinguish "new entry created" from "existing entry
# edited" (the failure the unverified dialog could produce)
problems.append(
f"{row['name']}: second-copy add can't be verified from "
"collection counts — confirm by eye on BGG"
)
continue
copies = by_object.get(int(row["bgg_id"]), [])
if not copies:
problems.append(f"{row['name']}: logged added but not in collection")
+79 -15
View File
@@ -18,6 +18,7 @@ import io
import json
import os
import threading
import time
import warnings
import webbrowser
import xml.etree.ElementTree as ET
@@ -26,17 +27,18 @@ from collections.abc import Callable
from functools import partial
from importlib import resources
from pathlib import Path
from urllib.parse import urlsplit
import typer
from defusedxml.ElementTree import fromstring as _safe_fromstring
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from pydantic import BaseModel
from rich.console import Console
from bggpipe.bgg_client import BGGClient, cached_paths
from bggpipe.config import DEFAULT_REVIEW_PORT, Config
from bggpipe.fsio import atomic_write_text
from bggpipe.fsio import atomic_write_bytes, atomic_write_text
from bggpipe.jobs import JobRunner
from bggpipe.models import (
CONFIDENT_VERSION_STATUSES,
@@ -219,13 +221,39 @@ def create_app(
input_fn=lambda prompt: "",
client=client,
)
thumbnails = load_thumbnails(cfg.cache_dir)
dismissed = DismissStore(cfg.dismissed_path)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
thumbnails = load_thumbnails(cfg.cache_dir)
dismissed = DismissStore(cfg.dismissed_path)
startup_notes = [str(w.message) for w in caught]
# FastAPI runs sync endpoints in a threadpool: without this, a polled
# GET /api/state can freshen()-swap session.rows out from under a
# concurrent decision POST, silently dropping the decision.
lock = threading.Lock()
revision = {"n": 0} # bumped on every mutation and reload
boot = time.time() # clients detect restarts (revision resets with us)
# server-side degradations, shown in-browser (quarantined dismiss file,
# unreadable thumbnails, torn artifacts)
app_warnings: list[str] = list(startup_notes)
ALLOWED_HOSTS = {"127.0.0.1", "localhost", "testserver"}
@app.middleware("http")
async def origin_guard(request, call_next):
# A hostile webpage can fire preflight-free cross-origin POSTs at a
# localhost server (bodyless run triggers, multipart photo posts).
# Mutations must come from us: same-host, and no foreign Origin.
if request.method not in ("GET", "HEAD", "OPTIONS"):
host = (request.headers.get("host") or "").split(":")[0]
origin = request.headers.get("origin")
origin_host = urlsplit(origin).hostname if origin else None
if host not in ALLOWED_HOSTS or (
origin_host is not None and origin_host not in ALLOWED_HOSTS
):
return JSONResponse(
{"detail": "cross-origin request refused"}, status_code=403
)
return await call_next(request)
def freshen() -> None:
"""Serve every request from the current file state: an extract or
@@ -239,7 +267,18 @@ def create_app(
available = photo_names()
sightings = []
if cfg.unidentified_path.exists():
for photo, entries in json.loads(cfg.unidentified_path.read_text()).items():
try:
data = json.loads(cfg.unidentified_path.read_text())
except (json.JSONDecodeError, OSError) as err:
# one torn file must not 500 every page and badge at once
note = (
f"{cfg.unidentified_path.name} unreadable ({err}) — "
"reshoot list unavailable"
)
if note not in app_warnings:
app_warnings.append(note)
return []
for photo, entries in data.items():
for s in entries:
if _sighting_key(photo, s) in dismissed.keys:
continue
@@ -248,6 +287,17 @@ def create_app(
)
return sightings
def read_games() -> dict:
if not cfg.games_path.exists():
return {}
try:
return json.loads(cfg.games_path.read_text())
except (json.JSONDecodeError, OSError) as err:
note = f"{cfg.games_path.name} unreadable ({err}) — library unavailable"
if note not in app_warnings:
app_warnings.append(note)
return {}
def find_row(title_raw: str, source_photos: str, row_ix: int | None = None) -> dict:
freshen()
row = session.find_row(title_raw, source_photos, row_ix)
@@ -345,7 +395,8 @@ def create_app(
]
return {
"revision": revision["n"],
"warnings": session.warnings[-10:],
"boot": boot,
"warnings": (session.warnings + app_warnings)[-10:],
"pending": [row_payload(r) for r in session.pending_rows()],
"versions": [version_payload(r) for r in session.version_rows()],
"merges": merges,
@@ -453,9 +504,7 @@ def create_app(
@app.get("/api/library")
def api_library() -> list[dict]:
if not cfg.games_path.exists():
return []
games = json.loads(cfg.games_path.read_text())
games = read_games()
return sorted(games.values(), key=lambda g: (g.get("name") or "").casefold())
def _csv_count(path: Path) -> int:
@@ -476,10 +525,9 @@ def create_app(
import csv as _csv
log_counts = Counter(row["status"] for row in _csv.DictReader(f))
games = 0
if cfg.games_path.exists():
games = len(json.loads(cfg.games_path.read_text()))
games = len(read_games())
return {
"boot": boot,
# key NAMES only — never values (credentials stay out of
# every payload and log)
"env": {
@@ -532,12 +580,13 @@ def create_app(
raise HTTPException(400, f"not a photo: {name or '(unnamed)'}")
target = cfg.photos_dir / name
data = await upload_file.read()
target.write_bytes(data)
# a re-uploaded photo means "re-extract this one": drop its raw
# cache (regenerable) so the next extract run picks it up
# a re-uploaded photo means "re-extract this one": the raw cache
# goes FIRST, so a crash can never leave the new photo paired
# with the old photo's extraction
stale = cfg.extract_raw_dir / f"{name}.json"
if stale.exists():
stale.unlink()
atomic_write_bytes(target, data)
saved.append(name)
return {"saved": saved, "photos": len(photo_names())}
@@ -552,7 +601,20 @@ def create_app(
revision["n"] += 1
return _decide(body)
def _refuse_if_rewriting() -> None:
snap = jobs.snapshot()
if snap["status"] == "running" and snap["stage"] in ("extract", "resolve"):
# the job holds a start-of-run snapshot and will atomically
# rewrite matches.csv/titles.json at the end — a decision saved
# now would be silently reverted by that rewrite
raise HTTPException(
409,
f"a {snap['stage']} run is rewriting the pipeline data — "
"wait for it to finish, then decide",
)
def _decide(body: DecisionBody) -> dict:
_refuse_if_rewriting()
row = find_row(body.title_raw, body.source_photos, body.row_ix)
if body.action == "pick":
candidates = json.loads(row["candidates_json"] or "[]")
@@ -579,6 +641,7 @@ def create_app(
return _version(body)
def _version(body: VersionBody) -> dict:
_refuse_if_rewriting()
row = find_row(body.title_raw, body.source_photos, body.row_ix)
if body.action == "unknown":
session.decide_version(row, None)
@@ -595,6 +658,7 @@ def create_app(
def api_veto_merge(body: VetoBody) -> dict:
with lock:
revision["n"] += 1
_refuse_if_rewriting()
row = find_row(body.title_raw, body.source_photos, body.row_ix)
if row["match_status"] != "merged":
raise HTTPException(400, "row is not merged")