diff --git a/README.md b/README.md index e4321ee..923ca9e 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ 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 — but mind the trade: there is no login, so anyone on the network can operate the pipeline. Use it on 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: ```sh diff --git a/src/bggpipe/cli.py b/src/bggpipe/cli.py index ae2f62d..8a91b69 100644 --- a/src/bggpipe/cli.py +++ b/src/bggpipe/cli.py @@ -96,6 +96,14 @@ def web( no_browser: Annotated[ bool, typer.Option("--no-browser", help="Don't open a browser tab") ] = False, + lan: Annotated[ + bool, + typer.Option( + "--lan", + help="Also serve to your local network (phone, tablet). The app " + "has no login — only use on a network you trust.", + ), + ] = False, config: ConfigOpt = None, ) -> None: """The whole pipeline in a local web UI: photos, stages, review.""" @@ -106,6 +114,7 @@ def web( cfg, port=port, dev=dev, + lan=lan, config_path=config, landing="/", open_browser=not no_browser, diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index b651af1..f250ded 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -405,10 +405,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # #jobstate .running { color: var(--accent-ink); font-weight: 600; } #jobstate .failed { color: var(--stop-ink); font-weight: 600; } -@media (max-width: 700px) { - .card, .ticket { flex-direction: column; } - .shots { flex-basis: auto; } -} +/* (phone-width rules live in the responsive section at the end) */ /* -- photos page: gallery + status ------------------------------------- */ .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .9rem; } @@ -465,18 +462,78 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # border: var(--line); border-radius: var(--radius); background: #fff; } -/* -- responsive: rail collapses to a top strip -------------------------- */ +/* -- responsive -------------------------------------------------------- */ + +/* The rail collapses to a top bar: brand line, then one horizontally + * scrollable nav row (no wrapping — a second row costs shelf space). */ @media (max-width: 900px) { body { display: block; } .sidebar { - position: sticky; height: auto; flex-direction: row; align-items: center; - flex-wrap: wrap; gap: 0 .5rem; z-index: 5; + position: sticky; top: 0; height: auto; display: block; z-index: 5; border-right: none; border-bottom: 4px solid transparent; border-image: var(--path) 1; } - .brand { padding: .5rem .8rem; } - nav[aria-label="Primary"] { flex-direction: row; flex-wrap: wrap; } - nav[aria-label="Primary"] a { border-left: none; border-bottom: 3px solid transparent; padding: .35rem .6rem; } - nav[aria-label="Primary"] a[aria-current="page"] { border-bottom-color: var(--gold); } + .brand { padding: .5rem .8rem .1rem; } + .brand img { width: 30px; height: 30px; } + .wordmark { font-size: 1.1rem; } + .wordmark small { display: inline; margin-left: .5rem; font-size: .68rem; } + nav[aria-label="Primary"] { + flex-direction: row; padding: 0 .4rem .1rem; + overflow-x: auto; -webkit-overflow-scrolling: touch; + } + nav[aria-label="Primary"] a { + flex: 0 0 auto; + border-left: none; border-bottom: 3px solid transparent; + padding: .45rem .6rem; + } + nav[aria-label="Primary"] a[aria-current="page"] { + border-bottom-color: var(--gold); background: none; + } + .navbadge { margin-left: .35rem; } .piperbox { display: none; } } + +/* Phone-width layout: stacked cards, stacked tables, touch targets. */ +@media (max-width: 700px) { + main { padding: 1rem .9rem 4rem; } + h1 { font-size: 1.3rem; margin: .9rem 0 .7rem; } + .keyhelp { display: none; } /* no keyboard on a phone */ + button, .linkbtn { padding: .45rem .9rem; } /* finger-sized */ + + .card, .ticket { flex-direction: column; } + .shots { flex-basis: auto; } + .ticket .stencil { + writing-mode: horizontal-tb; letter-spacing: .35em; + border-right: none; border-bottom: 1px solid var(--gold-ink); + padding: 0 0 .3rem; + } + .done { padding: 1.1rem .9rem; } + .done .nums { flex-wrap: wrap; gap: .8rem 1.6rem; } + + .filterbar input[type=search] { min-width: 0; flex: 1 1 12rem; } + .editform { flex-direction: column; align-items: stretch; } + .editform input { width: 100%; } + .editactions { flex-wrap: wrap; } + .editactions button { flex: 1 1 auto; } + + /* the titles/photo tables become stacked line-cards: one bordered + * block per game, cells flowing top to bottom, empty cells gone */ + .catalog table, .catalog tbody, .catalog tr, .catalog td { display: block; } + .catalog tr { padding: .6rem 0; border-top: 1px solid var(--board-edge); } + .catalog tr:first-child { border-top: none; } + .catalog tr:hover td { background: none; } + .catalog td { border-top: none; padding: .12rem 0; } + .catalog td:empty { display: none; } + .catalog .t { max-width: none; font-size: .95rem; } + .catalog td.actions { + text-align: left; white-space: normal; + display: flex; gap: .5rem; flex-wrap: wrap; padding-top: .4rem; + } + .catalog td.actions button { padding: .4rem .8rem; } + .catalog tr.editrow { padding-top: 0; border-top: none; } + + /* queue/library ledgers keep their columns but scroll inside their + * own card; long photo lists wrap within their cell */ + .ledger { overflow-x: auto; } + .ledger td { overflow-wrap: anywhere; min-width: 6rem; } +} diff --git a/src/bggpipe/templates/pages/photo.html b/src/bggpipe/templates/pages/photo.html index 31a6e00..dc41e76 100644 --- a/src/bggpipe/templates/pages/photo.html +++ b/src/bggpipe/templates/pages/photo.html @@ -64,8 +64,10 @@ function render(state, photos) { ${esc(c.title_raw)} ${statusChip(c)} - ${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""} - ${c.version_name ? " · " + esc(c.version_name) : ""} + ${[ + c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : "", + c.version_name ? esc(c.version_name) : "", + ].filter(Boolean).join(" · ")} `).join("") + `` : `

${info.extracted ? "No titles were read from this photo." diff --git a/src/bggpipe/templates/pages/queue.html b/src/bggpipe/templates/pages/queue.html index 933cb1a..79d1e32 100644 --- a/src/bggpipe/templates/pages/queue.html +++ b/src/bggpipe/templates/pages/queue.html @@ -19,7 +19,7 @@ function render(q) { ? table(["game", "version", "seen in"], q.to_add.map(r => ` ${esc(r.bgg_name)} · ${esc(r.bgg_id)} ${r.version_name ? esc(r.version_name) : `no version`} - ${esc(r.source_photos)}`)) + ${esc(r.source_photos.split(";").join(", "))}`)) : `

Nothing queued — run diff from the pipeline first.

`; html += `

Version updates — ${q.to_update.length} existing entr${q.to_update.length === 1 ? "y" : "ies"} gaining a version

`; diff --git a/src/bggpipe/templates/pages/titles.html b/src/bggpipe/templates/pages/titles.html index 4f8cd11..f4c910e 100644 --- a/src/bggpipe/templates/pages/titles.html +++ b/src/bggpipe/templates/pages/titles.html @@ -27,7 +27,7 @@ function editorRow(c) { - + saving re-queues this title for resolve with the corrected data @@ -59,9 +59,11 @@ function render() { ${c.shaky ? `shaky read` : ""} ${statusChip(c)} - ${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""} - ${c.version_name ? " · " + esc(c.version_name) : ""} - ${c.type === "rpgitem" ? ` RPG · local only` : ""} + ${[ + c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : "", + c.version_name ? esc(c.version_name) : "", + c.type === "rpgitem" ? `RPG · local only` : "", + ].filter(Boolean).join(" · ")} ${c.photos.map(p => `${esc(p)}` ).join(", ")} @@ -111,7 +113,7 @@ document.getElementById("catbody").addEventListener("click", async e => { const rm = e.target.closest("button.removetitle"); if (rm) { const f = rm.closest("form.editform"); - if (!confirm(`Remove "${f.dataset.title}" from the catalog? ` + + if (!confirm(`Remove "${f.dataset.title}" from the titles list? ` + `Re-running extract won't bring it back — the removal is saved ` + `in data/title_removals.json (delete its record there to undo).`)) return; const res = await apiPost("/api/remove-title", { diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index f16e3c3..1dbfe97 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -17,6 +17,7 @@ from __future__ import annotations import io import json import os +import socket import threading import time import warnings @@ -245,12 +246,39 @@ NAV_PAGES = ( PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"} +def lan_hosts() -> set[str]: + """This machine's names and addresses on the local network — what a + phone's browser will put in the Host header. An allowlist (never a + wildcard): DNS-rebinding attacks arrive under the attacker's hostname, + which is exactly what an allowlist rejects.""" + hosts: set[str] = set() + hostname = socket.gethostname() + hosts.add(hostname.lower()) + hosts.add(hostname.split(".")[0].lower() + ".local") + try: + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probe.connect(("192.0.2.1", 80)) # never sent; just picks the route + hosts.add(probe.getsockname()[0]) + probe.close() + except OSError: + pass + try: + for info in socket.getaddrinfo(hostname, None): + address = info[4][0] + if ":" not in address: # the guard compares hostnames, not v6 forms + hosts.add(address) + except OSError: + pass + return hosts + + def create_app( cfg: Config, *, client: BGGClient | None = None, stages: dict[str, Callable[..., object]] | None = None, jobs: JobRunner | None = None, + allowed_hosts: set[str] | None = None, ) -> FastAPI: app = FastAPI(title="bggpipe") stages = stages or _default_stages(cfg) @@ -276,7 +304,7 @@ def create_app( # unreadable thumbnails, torn artifacts) app_warnings: list[str] = list(startup_notes) - ALLOWED_HOSTS = {"127.0.0.1", "localhost", "testserver"} + ALLOWED_HOSTS = {"127.0.0.1", "localhost", "testserver"} | (allowed_hosts or set()) @app.middleware("http") async def origin_guard(request, call_next): @@ -991,7 +1019,8 @@ def _dev_app() -> FastAPI: from bggpipe.config import load_config path = os.environ.get("BGGPIPE_CONFIG") or None - return create_app(load_config(Path(path) if path else None)) + extra = lan_hosts() if os.environ.get("BGGPIPE_LAN") else None + return create_app(load_config(Path(path) if path else None), allowed_hosts=extra) def run_web_review( @@ -999,6 +1028,7 @@ def run_web_review( *, port: int = DEFAULT_REVIEW_PORT, dev: bool = False, + lan: bool = False, config_path: Path | None = None, landing: str = "/review", open_browser: bool = False, @@ -1006,10 +1036,22 @@ def run_web_review( import uvicorn url = f"http://127.0.0.1:{port}{landing}" - typer.echo( - f"bggpipe web UI: {url} (localhost only; dashboard at /, review " - "at /review; every decision saves immediately — Ctrl-C anytime)" - ) + extra_hosts = lan_hosts() if lan else None + if lan: + addresses = ", ".join(f"http://{h}:{port}/" for h in sorted(extra_hosts or [])) + typer.echo( + f"bggpipe web UI: {url} — ALSO reachable from your network: {addresses}" + ) + typer.echo( + " --lan: the app has NO login. Anyone on this network can run " + "stages, change your data, and (once unlocked) drive uploads " + "to your BGG account. Use only on a network you trust." + ) + else: + typer.echo( + f"bggpipe web UI: {url} (localhost only; dashboard at /, review " + "at /review; every decision saves immediately — Ctrl-C anytime)" + ) if open_browser: # give uvicorn a beat to bind before the tab loads threading.Timer(0.8, webbrowser.open, args=(url,)).start() @@ -1019,15 +1061,22 @@ def run_web_review( # write — i.e. on every review decision. if config_path: os.environ["BGGPIPE_CONFIG"] = str(config_path) + if lan: + os.environ["BGGPIPE_LAN"] = "1" typer.echo(" --dev: restarting on source changes") uvicorn.run( "bggpipe.webreview:_dev_app", factory=True, reload=True, reload_dirs=[str(Path(__file__).parent)], - host="127.0.0.1", + host="0.0.0.0" if lan else "127.0.0.1", # noqa: S104 — opted in port=port, log_level="warning", ) else: - uvicorn.run(create_app(cfg), host="127.0.0.1", port=port, log_level="warning") + uvicorn.run( + create_app(cfg, allowed_hosts=extra_hosts), + host="0.0.0.0" if lan else "127.0.0.1", # noqa: S104 — opted in + port=port, + log_level="warning", + ) diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 2274058..8235b41 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -869,3 +869,34 @@ def test_confirm_marks_shaky_read_verified_without_requeue(tmp_path): ).status_code == 400 ) + + +def test_lan_allowed_hosts_admit_network_but_not_strangers(tmp_path): + cfg = make_cfg(tmp_path) + app = create_app( + cfg, + client=unauthorized_client(tmp_path), + allowed_hosts={"192.168.1.5", "erics-mac.local"}, + ) + body = {"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True} + lan = TestClient(app, base_url="http://192.168.1.5") + assert lan.post("/api/edit-title", json=body).status_code == 200 + # a hostname NOT on the allowlist (DNS rebinding shape) is still refused + stranger = TestClient(app, base_url="http://attacker.example") + assert stranger.post("/api/edit-title", json=body).status_code == 403 + # and without the opt-in, the LAN host is refused too + plain = create_app(cfg, client=unauthorized_client(tmp_path)) + assert ( + TestClient(plain, base_url="http://192.168.1.5") + .post("/api/edit-title", json=body) + .status_code + == 403 + ) + + +def test_lan_hosts_reports_this_machine(tmp_path): + from bggpipe.webreview import lan_hosts + + hosts = lan_hosts() + assert hosts # at least the hostname + assert all(h == h.lower() or "." in h for h in hosts)