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:
co-authored by
Claude Fable 5
parent
429c519237
commit
2936d21f0a
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+68
-11
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -64,8 +64,10 @@ function render(state, photos) {
|
||||
<tr>
|
||||
<td class="t">${esc(c.title_raw)}</td>
|
||||
<td>${statusChip(c)}</td>
|
||||
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
|
||||
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
|
||||
<td class="meta">${[
|
||||
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>`
|
||||
: `<p class="empty">${info.extracted
|
||||
? "No titles were read from this photo."
|
||||
|
||||
@@ -19,7 +19,7 @@ function render(q) {
|
||||
? 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>
|
||||
<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>`;
|
||||
|
||||
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>`;
|
||||
|
||||
@@ -27,7 +27,7 @@ function editorRow(c) {
|
||||
<span class="editactions">
|
||||
<button type="submit" class="primary">save</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 class="edithint">saving re-queues this title for resolve with the corrected data</span>
|
||||
</form>
|
||||
@@ -59,9 +59,11 @@ function render() {
|
||||
${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>
|
||||
<td>${statusChip(c)}</td>
|
||||
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
|
||||
${c.version_name ? " · " + esc(c.version_name) : ""}
|
||||
${c.type === "rpgitem" ? ` <span class="chip open">RPG · local only</span>` : ""}</td>
|
||||
<td class="meta">${[
|
||||
c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : "",
|
||||
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 =>
|
||||
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
|
||||
).join(", ")}</td>
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user