From f82489716a50424549d63487d5fbd5f08c3885a3 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 3 Aug 2026 16:39:24 -0400 Subject: [PATCH] --lan key persists across restarts; stale tabs get a readable 403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key rotated on every server restart, stranding every phone that held the previous cookie — during active development that guaranteed a wall of refusals from stale polling tabs after each restart. The key now lives in data/.lan_key (gitignored, 0600 — the Playwright-state treatment for credential-adjacent files) and is reused across restarts; delete the file to rotate. A keyless browser navigation now gets a one-line HTML page saying what to do instead of raw JSON. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- .gitignore | 1 + data/title_edits.json | 5 +++++ data/title_removals.json | 6 ++++++ data/titles.json | 17 ++--------------- src/bggpipe/config.py | 6 ++++++ src/bggpipe/webreview.py | 41 ++++++++++++++++++++++++++++------------ tests/test_webreview.py | 15 +++++++++++++++ 7 files changed, 64 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index a2e2b6b..a4be516 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Secrets & credential-adjacent state .env *.storage_state.json +data/.lan_key playwright/.auth/ storage_state.json diff --git a/data/title_edits.json b/data/title_edits.json index af2ae58..567830b 100644 --- a/data/title_edits.json +++ b/data/title_edits.json @@ -47,5 +47,10 @@ "IMG_4502.jpeg" ], "confidence": "high" + }, + { + "match": "ACTION CASTLE", + "title_raw": "Parsely", + "confidence": "high" } ] diff --git a/data/title_removals.json b/data/title_removals.json index 6705734..1a96662 100644 --- a/data/title_removals.json +++ b/data/title_removals.json @@ -64,5 +64,11 @@ "photos": [ "IMG_4507.jpeg" ] + }, + { + "title": "ACTION CASTLE I", + "photos": [ + "IMG_4573.jpeg" + ] } ] diff --git a/data/titles.json b/data/titles.json index 0d61c87..1e9c63a 100644 --- a/data/titles.json +++ b/data/titles.json @@ -1656,7 +1656,7 @@ "title_normalized": "botany expansion perilous perfumes poisonous carnivorous parasitic and bizarre plants" }, { - "title_raw": "ACTION CASTLE", + "title_raw": "Parsely", "confidence": "high", "publisher_hint": "Memento More Computers Inc.", "edition_hint": "", @@ -1666,20 +1666,7 @@ "source_photos": [ "IMG_4573.jpeg" ], - "title_normalized": "action castle" - }, - { - "title_raw": "ACTION CASTLE I", - "confidence": "medium", - "publisher_hint": "", - "edition_hint": "", - "year_hint": null, - "language_hint": "English", - "art_notes": "Dark spine box among a stack of illustrated game boxes on the book cover art", - "source_photos": [ - "IMG_4573.jpeg" - ], - "title_normalized": "action castle i" + "title_normalized": "parsely" }, { "title_raw": "SIX-GUN SHOWDOWN", diff --git a/src/bggpipe/config.py b/src/bggpipe/config.py index a158a29..20c75ad 100644 --- a/src/bggpipe/config.py +++ b/src/bggpipe/config.py @@ -95,6 +95,12 @@ class Config: self.data_dir / "collection_snapshot_expansions.xml", ) + @property + def lan_key_path(self) -> Path: + # the --lan access key, persisted so restarts don't strand phones' + # cookies; credential-adjacent like Playwright state — gitignored + return self.data_dir / ".lan_key" + @property def storage_state_path(self) -> Path: # cwd-relative on purpose (credential-adjacent, gitignored) but diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index d91f336..dc95b1f 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -360,14 +360,17 @@ def create_app( f"(host {host!r}): missing or wrong access key", err=True, ) - return JSONResponse( - { - "detail": "missing or wrong access key — open the " - "exact URL printed where the server started " - "(it ends in ?k=...)" - }, - status_code=403, + hint = ( + "missing or wrong access key — open the exact URL " + "printed where the server started (it ends in ?k=...)" ) + if "text/html" in request.headers.get("accept", ""): + # a tapped link or stale tab: a person is looking + return HTMLResponse( + f"

bggpipe: access key needed

{hint}

", + status_code=403, + ) + return JSONResponse({"detail": hint}, status_code=403) if request.method in ("GET", "HEAD") and request.query_params.get("k"): # the key arrived in the typed URL: commit it to a cookie # via redirect BEFORE the document loads (the preload @@ -1102,6 +1105,22 @@ def _dev_app() -> FastAPI: return create_app(load_config(Path(path) if path else None), lan_token=token) +def _lan_key(cfg: Config) -> str: + """The --lan access key: created once, reused across restarts so a + phone's cookie keeps working. Delete the file to rotate the key.""" + path = cfg.lan_key_path + if path.exists(): + key = path.read_text().strip() + if key: + return key + key = secrets.token_urlsafe(16) + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as handle: + handle.write(key + "\n") + return key + + def run_web_review( cfg: Config, *, @@ -1115,10 +1134,7 @@ def run_web_review( import uvicorn url = f"http://127.0.0.1:{port}{landing}" - # 16 bytes = 128-bit key: standard session-credential strength. The - # cost is a longer one-time URL to get onto the phone — copy it, or - # type it once; the cookie remembers it from then on. - token = secrets.token_urlsafe(16) if lan else None + token = _lan_key(cfg) if lan else None if lan: primary = _route_ip() spares = sorted( @@ -1150,7 +1166,8 @@ def run_web_review( typer.echo( " --lan: no login beyond that key — anyone who has it can run " "stages, change your data, and (once unlocked) drive uploads " - "to your BGG account. Use only on a network you trust." + "to your BGG account. Use only on a network you trust. (The " + "key persists across restarts; delete data/.lan_key to rotate.)" ) else: typer.echo( diff --git a/tests/test_webreview.py b/tests/test_webreview.py index ad621b2..607063d 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -1021,6 +1021,12 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys wr.run_web_review(cfg, port=9999, lan=True) out = capsys.readouterr().out assert captured["host"] == "0.0.0.0" + # the key persists (0600) so restarts don't strand the phone's cookie + assert (cfg.lan_key_path.stat().st_mode & 0o777) == 0o600 + key = cfg.lan_key_path.read_text().strip() + assert f"?k={key}" in out + wr.run_web_review(cfg, port=9999, lan=True) + assert f"?k={key}" in capsys.readouterr().out # same key after restart # the default-route address leads; other interfaces are fallbacks assert "on your phone, open: http://192.168.1.5:9999/?k=" in out fallback = next(line for line in out.splitlines() if "try:" in line) @@ -1030,3 +1036,12 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys assert "Use only on a network you trust" in out wr.run_web_review(cfg, port=9999, lan=False) assert captured["host"] == "127.0.0.1" + + +def test_lan_403_is_html_for_navigations(tmp_path): + cfg = make_cfg(tmp_path) + app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret") + phone = TestClient(app, base_url="http://192.168.1.99:8377") + res = phone.get("/titles", headers={"accept": "text/html,application/xhtml+xml"}) + assert res.status_code == 403 + assert "access key needed" in res.text # a person sees prose, not JSON