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) {
save
cancel
- remove from catalog
+ remove — not a game
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)