--lan key persists across restarts; stale tabs get a readable 403
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
344a930a8a
commit
f82489716a
@@ -1,6 +1,7 @@
|
||||
# Secrets & credential-adjacent state
|
||||
.env
|
||||
*.storage_state.json
|
||||
data/.lan_key
|
||||
playwright/.auth/
|
||||
storage_state.json
|
||||
|
||||
|
||||
@@ -47,5 +47,10 @@
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "ACTION CASTLE",
|
||||
"title_raw": "Parsely",
|
||||
"confidence": "high"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -64,5 +64,11 @@
|
||||
"photos": [
|
||||
"IMG_4507.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ACTION CASTLE I",
|
||||
"photos": [
|
||||
"IMG_4573.jpeg"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+2
-15
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-11
@@ -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=...)"
|
||||
},
|
||||
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"<h1>bggpipe: access key needed</h1><p>{hint}</p>",
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user