Mobile: every page earns its phone layout, and --lan serves the network

Verified page by page in a 390px viewport against live data. The rail
collapses to a brand line plus one horizontally scrollable nav row
(was: two wrapped rows). The Titles table becomes stacked line-cards —
title, chips, match, photos, finger-sized actions — with empty cells
dropped (the templates now emit them tight so :empty applies); the
editor stacks full-width inputs. Queue/library ledgers scroll inside
their card and photo lists wrap at commas instead of clipping. The
review done-card stats wrap, keyboard hints hide on touch widths,
reshoot stencils go horizontal, buttons get touch-sized padding.
Also: post-rename copy on the remove button.

bggpipe web --lan binds 0.0.0.0 and extends the mutation guard's host
allowlist with this machine's names and addresses (never a wildcard —
DNS-rebinding arrives under the attacker's hostname, which an allowlist
rejects), prints every reachable URL, and warns loudly that the app has
no login. Default stays localhost-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 16:02:08 -04:00
co-authored by Claude Fable 5
parent 429c519237
commit 2936d21f0a
8 changed files with 179 additions and 27 deletions
+2
View File
@@ -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 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: 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 ```sh
+9
View File
@@ -96,6 +96,14 @@ def web(
no_browser: Annotated[ no_browser: Annotated[
bool, typer.Option("--no-browser", help="Don't open a browser tab") bool, typer.Option("--no-browser", help="Don't open a browser tab")
] = False, ] = 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, config: ConfigOpt = None,
) -> None: ) -> None:
"""The whole pipeline in a local web UI: photos, stages, review.""" """The whole pipeline in a local web UI: photos, stages, review."""
@@ -106,6 +114,7 @@ def web(
cfg, cfg,
port=port, port=port,
dev=dev, dev=dev,
lan=lan,
config_path=config, config_path=config,
landing="/", landing="/",
open_browser=not no_browser, open_browser=not no_browser,
+68 -11
View File
@@ -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 .running { color: var(--accent-ink); font-weight: 600; }
#jobstate .failed { color: var(--stop-ink); font-weight: 600; } #jobstate .failed { color: var(--stop-ink); font-weight: 600; }
@media (max-width: 700px) { /* (phone-width rules live in the responsive section at the end) */
.card, .ticket { flex-direction: column; }
.shots { flex-basis: auto; }
}
/* -- photos page: gallery + status ------------------------------------- */ /* -- photos page: gallery + status ------------------------------------- */
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .9rem; } .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; 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) { @media (max-width: 900px) {
body { display: block; } body { display: block; }
.sidebar { .sidebar {
position: sticky; height: auto; flex-direction: row; align-items: center; position: sticky; top: 0; height: auto; display: block; z-index: 5;
flex-wrap: wrap; gap: 0 .5rem; z-index: 5;
border-right: none; border-bottom: 4px solid transparent; border-right: none; border-bottom: 4px solid transparent;
border-image: var(--path) 1; border-image: var(--path) 1;
} }
.brand { padding: .5rem .8rem; } .brand { padding: .5rem .8rem .1rem; }
nav[aria-label="Primary"] { flex-direction: row; flex-wrap: wrap; } .brand img { width: 30px; height: 30px; }
nav[aria-label="Primary"] a { border-left: none; border-bottom: 3px solid transparent; padding: .35rem .6rem; } .wordmark { font-size: 1.1rem; }
nav[aria-label="Primary"] a[aria-current="page"] { border-bottom-color: var(--gold); } .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; } .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; }
}
+4 -2
View File
@@ -64,8 +64,10 @@ function render(state, photos) {
<tr> <tr>
<td class="t">${esc(c.title_raw)}</td> <td class="t">${esc(c.title_raw)}</td>
<td>${statusChip(c)}</td> <td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""} <td class="meta">${[
${c.version_name ? " · " + esc(c.version_name) : ""}</td> c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : "",
c.version_name ? esc(c.version_name) : "",
].filter(Boolean).join(" · ")}</td>
</tr>`).join("") + `</table></div>` </tr>`).join("") + `</table></div>`
: `<p class="empty">${info.extracted : `<p class="empty">${info.extracted
? "No titles were read from this photo." ? "No titles were read from this photo."
+1 -1
View File
@@ -19,7 +19,7 @@ function render(q) {
? table(["game", "version", "seen in"], q.to_add.map(r => ` ? table(["game", "version", "seen in"], q.to_add.map(r => `
<tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td> <tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td>
<td>${r.version_name ? esc(r.version_name) : `<span class="meta">no version</span>`}</td> <td>${r.version_name ? esc(r.version_name) : `<span class="meta">no version</span>`}</td>
<td class="meta">${esc(r.source_photos)}</td></tr>`)) <td class="meta">${esc(r.source_photos.split(";").join(", "))}</td></tr>`))
: `<p class="empty">Nothing queued — run <b>diff</b> from the <a href="/">pipeline</a> first.</p>`; : `<p class="empty">Nothing queued — run <b>diff</b> from the <a href="/">pipeline</a> first.</p>`;
html += `<h2>Version updates <span class="count">— ${q.to_update.length} existing entr${q.to_update.length === 1 ? "y" : "ies"} gaining a version</span></h2>`; html += `<h2>Version updates <span class="count">— ${q.to_update.length} existing entr${q.to_update.length === 1 ? "y" : "ies"} gaining a version</span></h2>`;
+7 -5
View File
@@ -27,7 +27,7 @@ function editorRow(c) {
<span class="editactions"> <span class="editactions">
<button type="submit" class="primary">save</button> <button type="submit" class="primary">save</button>
<button type="button" class="canceledit">cancel</button> <button type="button" class="canceledit">cancel</button>
<button type="button" class="removetitle danger">remove from catalog</button> <button type="button" class="removetitle danger">remove — not a game</button>
</span> </span>
<span class="edithint">saving re-queues this title for resolve with the corrected data</span> <span class="edithint">saving re-queues this title for resolve with the corrected data</span>
</form> </form>
@@ -59,9 +59,11 @@ function render() {
${c.shaky ? `<span class="chip shaky" ${c.shaky ? `<span class="chip shaky"
title="the model wasn't sure of this read — press ✓ if it's right, or edit it">shaky read</span>` : ""}</td> title="the model wasn't sure of this read — press ✓ if it's right, or edit it">shaky read</span>` : ""}</td>
<td>${statusChip(c)}</td> <td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""} <td class="meta">${[
${c.version_name ? " · " + esc(c.version_name) : ""} c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : "",
${c.type === "rpgitem" ? ` <span class="chip open">RPG · local only</span>` : ""}</td> c.version_name ? esc(c.version_name) : "",
c.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : "",
].filter(Boolean).join(" · ")}</td>
<td class="meta">${c.photos.map(p => <td class="meta">${c.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>` `<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(", ")}</td> ).join(", ")}</td>
@@ -111,7 +113,7 @@ document.getElementById("catbody").addEventListener("click", async e => {
const rm = e.target.closest("button.removetitle"); const rm = e.target.closest("button.removetitle");
if (rm) { if (rm) {
const f = rm.closest("form.editform"); 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 ` + `Re-running extract won't bring it back — the removal is saved ` +
`in data/title_removals.json (delete its record there to undo).`)) return; `in data/title_removals.json (delete its record there to undo).`)) return;
const res = await apiPost("/api/remove-title", { const res = await apiPost("/api/remove-title", {
+53 -4
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import io import io
import json import json
import os import os
import socket
import threading import threading
import time import time
import warnings import warnings
@@ -245,12 +246,39 @@ NAV_PAGES = (
PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"} 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( def create_app(
cfg: Config, cfg: Config,
*, *,
client: BGGClient | None = None, client: BGGClient | None = None,
stages: dict[str, Callable[..., object]] | None = None, stages: dict[str, Callable[..., object]] | None = None,
jobs: JobRunner | None = None, jobs: JobRunner | None = None,
allowed_hosts: set[str] | None = None,
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title="bggpipe") app = FastAPI(title="bggpipe")
stages = stages or _default_stages(cfg) stages = stages or _default_stages(cfg)
@@ -276,7 +304,7 @@ def create_app(
# unreadable thumbnails, torn artifacts) # unreadable thumbnails, torn artifacts)
app_warnings: list[str] = list(startup_notes) 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") @app.middleware("http")
async def origin_guard(request, call_next): async def origin_guard(request, call_next):
@@ -991,7 +1019,8 @@ def _dev_app() -> FastAPI:
from bggpipe.config import load_config from bggpipe.config import load_config
path = os.environ.get("BGGPIPE_CONFIG") or None 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( def run_web_review(
@@ -999,6 +1028,7 @@ def run_web_review(
*, *,
port: int = DEFAULT_REVIEW_PORT, port: int = DEFAULT_REVIEW_PORT,
dev: bool = False, dev: bool = False,
lan: bool = False,
config_path: Path | None = None, config_path: Path | None = None,
landing: str = "/review", landing: str = "/review",
open_browser: bool = False, open_browser: bool = False,
@@ -1006,6 +1036,18 @@ 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}"
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( typer.echo(
f"bggpipe web UI: {url} (localhost only; dashboard at /, review " f"bggpipe web UI: {url} (localhost only; dashboard at /, review "
"at /review; every decision saves immediately — Ctrl-C anytime)" "at /review; every decision saves immediately — Ctrl-C anytime)"
@@ -1019,15 +1061,22 @@ def run_web_review(
# write — i.e. on every review decision. # write — i.e. on every review decision.
if config_path: if config_path:
os.environ["BGGPIPE_CONFIG"] = str(config_path) os.environ["BGGPIPE_CONFIG"] = str(config_path)
if lan:
os.environ["BGGPIPE_LAN"] = "1"
typer.echo(" --dev: restarting on source changes") typer.echo(" --dev: restarting on source changes")
uvicorn.run( uvicorn.run(
"bggpipe.webreview:_dev_app", "bggpipe.webreview:_dev_app",
factory=True, factory=True,
reload=True, reload=True,
reload_dirs=[str(Path(__file__).parent)], 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, port=port,
log_level="warning", log_level="warning",
) )
else: 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",
)
+31
View File
@@ -869,3 +869,34 @@ def test_confirm_marks_shaky_read_verified_without_requeue(tmp_path):
).status_code ).status_code
== 400 == 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)