--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
|
# Secrets & credential-adjacent state
|
||||||
.env
|
.env
|
||||||
*.storage_state.json
|
*.storage_state.json
|
||||||
|
data/.lan_key
|
||||||
playwright/.auth/
|
playwright/.auth/
|
||||||
storage_state.json
|
storage_state.json
|
||||||
|
|
||||||
|
|||||||
@@ -47,5 +47,10 @@
|
|||||||
"IMG_4502.jpeg"
|
"IMG_4502.jpeg"
|
||||||
],
|
],
|
||||||
"confidence": "high"
|
"confidence": "high"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "ACTION CASTLE",
|
||||||
|
"title_raw": "Parsely",
|
||||||
|
"confidence": "high"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -64,5 +64,11 @@
|
|||||||
"photos": [
|
"photos": [
|
||||||
"IMG_4507.jpeg"
|
"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_normalized": "botany expansion perilous perfumes poisonous carnivorous parasitic and bizarre plants"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title_raw": "ACTION CASTLE",
|
"title_raw": "Parsely",
|
||||||
"confidence": "high",
|
"confidence": "high",
|
||||||
"publisher_hint": "Memento More Computers Inc.",
|
"publisher_hint": "Memento More Computers Inc.",
|
||||||
"edition_hint": "",
|
"edition_hint": "",
|
||||||
@@ -1666,20 +1666,7 @@
|
|||||||
"source_photos": [
|
"source_photos": [
|
||||||
"IMG_4573.jpeg"
|
"IMG_4573.jpeg"
|
||||||
],
|
],
|
||||||
"title_normalized": "action castle"
|
"title_normalized": "parsely"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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_raw": "SIX-GUN SHOWDOWN",
|
"title_raw": "SIX-GUN SHOWDOWN",
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ class Config:
|
|||||||
self.data_dir / "collection_snapshot_expansions.xml",
|
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
|
@property
|
||||||
def storage_state_path(self) -> Path:
|
def storage_state_path(self) -> Path:
|
||||||
# cwd-relative on purpose (credential-adjacent, gitignored) but
|
# 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",
|
f"(host {host!r}): missing or wrong access key",
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
hint = (
|
||||||
{
|
"missing or wrong access key — open the exact URL "
|
||||||
"detail": "missing or wrong access key — open the "
|
"printed where the server started (it ends in ?k=...)"
|
||||||
"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,
|
status_code=403,
|
||||||
)
|
)
|
||||||
|
return JSONResponse({"detail": hint}, status_code=403)
|
||||||
if request.method in ("GET", "HEAD") and request.query_params.get("k"):
|
if request.method in ("GET", "HEAD") and request.query_params.get("k"):
|
||||||
# the key arrived in the typed URL: commit it to a cookie
|
# the key arrived in the typed URL: commit it to a cookie
|
||||||
# via redirect BEFORE the document loads (the preload
|
# 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)
|
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(
|
def run_web_review(
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
*,
|
*,
|
||||||
@@ -1115,10 +1134,7 @@ def run_web_review(
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
url = f"http://127.0.0.1:{port}{landing}"
|
url = f"http://127.0.0.1:{port}{landing}"
|
||||||
# 16 bytes = 128-bit key: standard session-credential strength. The
|
token = _lan_key(cfg) if lan else None
|
||||||
# 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
|
|
||||||
if lan:
|
if lan:
|
||||||
primary = _route_ip()
|
primary = _route_ip()
|
||||||
spares = sorted(
|
spares = sorted(
|
||||||
@@ -1150,7 +1166,8 @@ def run_web_review(
|
|||||||
typer.echo(
|
typer.echo(
|
||||||
" --lan: no login beyond that key — anyone who has it can run "
|
" --lan: no login beyond that key — anyone who has it can run "
|
||||||
"stages, change your data, and (once unlocked) drive uploads "
|
"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:
|
else:
|
||||||
typer.echo(
|
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)
|
wr.run_web_review(cfg, port=9999, lan=True)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert captured["host"] == "0.0.0.0"
|
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
|
# the default-route address leads; other interfaces are fallbacks
|
||||||
assert "on your phone, open: http://192.168.1.5:9999/?k=" in out
|
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)
|
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
|
assert "Use only on a network you trust" in out
|
||||||
wr.run_web_review(cfg, port=9999, lan=False)
|
wr.run_web_review(cfg, port=9999, lan=False)
|
||||||
assert captured["host"] == "127.0.0.1"
|
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