diff --git a/README.md b/README.md index 1fddaf6..1aa3cb3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index fd8097f..5d18e2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "playwright>=1.62.0", "pydantic>=2.13.4", "python-multipart>=0.0.32", + "qrcode>=8.2", ] [project.scripts] diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index 3291784..d120179 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -61,7 +61,7 @@

Your data, on disk

Everything lives in flat files under data/ — inspectable, hand-editable, and git-friendly. The pipeline artifacts: titles.json (what was read), matches.csv (what it matched), to_add.csv/to_update.csv (what upload will do), upload_log.csv (what it did), games.json (the library). Your curation: title_edits.json, title_splits.json, title_removals.json, unidentified_dismissed.json.

-

Credentials never live in files — only environment variables, set up by bggpipe init. The app serves localhost only, unless started with --lan — that opens it to your network behind a per-run access key printed at startup (no login beyond the key; trusted networks only).

+

Credentials never live in files — only environment variables, set up by bggpipe init. The app serves localhost only, unless started with --lan — 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 data/.lan_key to revoke every device. No login beyond the key; trusted networks only.

More depth: the README covers setup and photo technique; docs/bgg-upload-flow.md documents the upload automation.

diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index dc95b1f..81e821f 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -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 " diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 607063d..774c753 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -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 diff --git a/uv.lock b/uv.lock index 6cd3a3c..f4c191c 100644 --- a/uv.lock +++ b/uv.lock @@ -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"