Pairing: scan a QR off the terminal once; the cookie lasts a year
The key already persisted across restarts, but the cookie was a session cookie — Safari eventually drops those and the paste ritual returned. The pairing cookie now lasts a year, and startup prints a QR code of the pairing URL (qrcode dep, ASCII render) so a phone pairs by pointing its camera at the terminal. Revoke every paired device by deleting data/.lan_key. 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
78debdd62f
commit
44dd03d06a
@@ -90,7 +90,7 @@ Non-secret knobs (`photos_dir`, `data_dir`, the vision model, the rate limit) li
|
||||
uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
|
||||
```
|
||||
|
||||
The app is localhost-only by default. `--lan` also serves it to your local network — handy for proofreading from the couch or snapping shelf photos on your phone and uploading them straight into the Photos page. It prints a link carrying a per-run access key (`?k=...`): open that exact link on the phone once and a cookie remembers it. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead).
|
||||
The app is localhost-only by default. `--lan` also serves it to your local network — handy for proofreading from the couch or snapping shelf photos on your phone and uploading them straight into the Photos page. It prints a pairing link carrying an access key (`?k=...`) plus a QR code — point the phone's camera at the terminal and tap. Pairing is one-time per device: the key persists across restarts (`data/.lan_key`; delete it to revoke every paired device) and the cookie lasts a year. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead).
|
||||
|
||||
Six pages in one local app: **Pipeline** (run stages, watch live output), **Photos** (drag-and-drop upload, gallery, reshoot tickets), **Review** (keyboard-first match and edition decisions), **Titles** (every read off your shelves, alphabetized — and where you proofread them: fix misreads, add cues, split multi-copy lines, remove non-games), **Queue** (exactly what upload will do, plus its full log), and **Library** (your enriched collection, browsable once real BGG data lands). The real upload sits behind a confirmation and behind the stub-data lock. Prefer the terminal? Every stage is also a command, and the two interfaces share all state:
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"playwright>=1.62.0",
|
||||
"pydantic>=2.13.4",
|
||||
"python-multipart>=0.0.32",
|
||||
"qrcode>=8.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<h2 id="files">Your data, on disk</h2>
|
||||
<div class="card prose">
|
||||
<p>Everything lives in flat files under <code>data/</code> — inspectable, hand-editable, and git-friendly. The pipeline artifacts: <code>titles.json</code> (what was read), <code>matches.csv</code> (what it matched), <code>to_add.csv</code>/<code>to_update.csv</code> (what upload will do), <code>upload_log.csv</code> (what it did), <code>games.json</code> (the library). Your curation: <code>title_edits.json</code>, <code>title_splits.json</code>, <code>title_removals.json</code>, <code>unidentified_dismissed.json</code>.</p>
|
||||
<p>Credentials never live in files — only environment variables, set up by <code>bggpipe init</code>. The app serves localhost only, unless started with <code>--lan</code> — that opens it to your network behind a per-run access key printed at startup (no login beyond the key; trusted networks only).</p>
|
||||
<p>Credentials never live in files — only environment variables, set up by <code>bggpipe init</code>. The app serves localhost only, unless started with <code>--lan</code> — that opens it to your network behind an access key: scan the QR code the server prints (or open the printed link) once per device, and a year-long cookie keeps it paired. Delete <code>data/.lan_key</code> to revoke every device. No login beyond the key; trusted networks only.</p>
|
||||
<p>More depth: the README covers setup and photo technique; <code>docs/bgg-upload-flow.md</code> documents the upload automation.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -380,8 +380,15 @@ def create_app(
|
||||
status_code=303,
|
||||
headers={"Location": request.url.path or "/"},
|
||||
)
|
||||
# a YEAR, not a session: pairing a device should be a
|
||||
# one-time act (the key file persists too; delete
|
||||
# data/.lan_key to revoke every paired device)
|
||||
response.set_cookie(
|
||||
LAN_COOKIE, lan_token, httponly=True, samesite="lax"
|
||||
LAN_COOKIE,
|
||||
lan_token,
|
||||
max_age=365 * 24 * 3600,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
if request.method not in ("GET", "HEAD", "OPTIONS") and (
|
||||
@@ -1105,6 +1112,21 @@ def _dev_app() -> FastAPI:
|
||||
return create_app(load_config(Path(path) if path else None), lan_token=token)
|
||||
|
||||
|
||||
def _print_qr(url: str) -> None:
|
||||
"""Pairing without typing: the phone's camera reads the URL straight
|
||||
off the terminal."""
|
||||
import io as _io
|
||||
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(border=1)
|
||||
qr.add_data(url)
|
||||
buffer = _io.StringIO()
|
||||
qr.print_ascii(out=buffer, invert=True)
|
||||
for line in buffer.getvalue().splitlines():
|
||||
typer.echo(f" {line}")
|
||||
|
||||
|
||||
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."""
|
||||
@@ -1143,6 +1165,8 @@ def run_web_review(
|
||||
typer.echo(f"bggpipe web UI: {url} (this machine needs no key)")
|
||||
if primary:
|
||||
typer.echo(f" on your phone, open: http://{primary}:{port}/?k={token}")
|
||||
typer.echo(" or point its camera at this code:")
|
||||
_print_qr(f"http://{primary}:{port}/?k={token}")
|
||||
if spares:
|
||||
typer.echo(
|
||||
" (several network interfaces here — if that address "
|
||||
|
||||
@@ -926,6 +926,8 @@ def test_lan_token_gates_every_request(tmp_path):
|
||||
assert first.status_code == 303 # cookie commits BEFORE the document
|
||||
assert first.headers["location"] == "/titles" # key scrubbed from URL
|
||||
assert "bggpipe_key" in first.cookies
|
||||
# pairing is one-time per device: a durable cookie, not a session one
|
||||
assert "Max-Age=31536000" in first.headers["set-cookie"]
|
||||
assert phone.get("/titles").status_code == 200 # cookie carries it now
|
||||
# cookie carries the session: mutations work from ANY host the phone
|
||||
# used (no allowlist dependence — DHCP/multi-interface safe), with a
|
||||
@@ -1029,6 +1031,7 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys
|
||||
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
|
||||
assert "▀" in out or "█" in out # the pairing QR rendered
|
||||
fallback = next(line for line in out.splitlines() if "try:" in line)
|
||||
assert "192.168.64.1" in fallback and "erics-mac.local" in fallback
|
||||
# no phone can reach loopback: only the desktop line mentions it
|
||||
|
||||
@@ -65,6 +65,7 @@ dependencies = [
|
||||
{ name = "playwright" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "qrcode" },
|
||||
{ name = "rapidfuzz" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
@@ -88,6 +89,7 @@ requires-dist = [
|
||||
{ name = "playwright", specifier = ">=1.62.0" },
|
||||
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.32" },
|
||||
{ name = "qrcode", specifier = ">=8.2" },
|
||||
{ name = "rapidfuzz", specifier = ">=3.9" },
|
||||
{ name = "rich", specifier = ">=15.0.0" },
|
||||
{ name = "typer", specifier = ">=0.12" },
|
||||
@@ -671,6 +673,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidfuzz"
|
||||
version = "3.14.5"
|
||||
|
||||
Reference in New Issue
Block a user