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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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,10 +1036,22 @@ 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}"
|
||||||
typer.echo(
|
extra_hosts = lan_hosts() if lan else None
|
||||||
f"bggpipe web UI: {url} (localhost only; dashboard at /, review "
|
if lan:
|
||||||
"at /review; every decision saves immediately — Ctrl-C anytime)"
|
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:
|
if open_browser:
|
||||||
# give uvicorn a beat to bind before the tab loads
|
# give uvicorn a beat to bind before the tab loads
|
||||||
threading.Timer(0.8, webbrowser.open, args=(url,)).start()
|
threading.Timer(0.8, webbrowser.open, args=(url,)).start()
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user