diff --git a/configs/homepage/services.yaml b/configs/homepage/services.yaml index 8aff8c7..fe4afeb 100644 --- a/configs/homepage/services.yaml +++ b/configs/homepage/services.yaml @@ -24,7 +24,7 @@ href: http://10.100.10.50:8090/ icon: mdi-filmstrip siteMonitor: http://10.100.10.50:8090/healthz - description: Ephemeral media drop — CC-session A/B & smoke results (nh3-dev, 24h TTL) + description: Ephemeral media drop + upload-for-pickup (human-readable ids) — nh3-dev, 24h TTL # The AI tab is fully Docker-auto-discovered. Each inference service carries # a homepage.group=AI - label on its compose file (AI - Inference, diff --git a/services/booth/README.md b/services/booth/README.md index 24928b2..810a906 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -1,10 +1,16 @@ # The Booth -A dead-simple standing web server for surfacing **ephemeral media** to the -operator — A/B renders, smoke-test screenshots, audio/video samples. A Claude -Code session drops a folder of files somewhere on disk; the Booth renders it as -a browsable "booth" and **wipes it 24h after the last activity**. No database, -no upload API — the filesystem *is* the state. +A dead-simple standing web server for shuttling **ephemeral files** between the +operator and CC sessions — A/B renders, smoke-test screenshots, audio/video +samples, or anything you want to hand off. It works both directions: + +- **Session → operator:** a session drops a folder of files on disk; the Booth + renders it as a browsable "booth". +- **Operator/anyone → pickup:** upload files through the browser (or `curl -F`) + and get a **human-readable pickup id** like `4-wombat` or `star-84`. + +Either way it **wipes 24h after the last activity**. No database — the +filesystem *is* the state. - **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth* - **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth) @@ -33,6 +39,27 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/ Then hand the operator `http://10.100.10.50:8090/b/my-run/`. +## Upload for pickup + +The reverse direction — put files in through the web, pick them up by id: + +- **Browser:** the index page has an *Upload files for pickup* panel + (drag-drop or click). Submit → you land on a booth with a **human-readable + id** (`4-wombat`, `star-84`) whose files each have a ⬇ download link. +- **curl (a remote session with no ssh to nh3-dev can use this too):** + ```bash + curl -sS -i -F 'files=@out/a.png' -F 'files=@out/b.png' \ + http://10.100.10.50:8090/upload | grep -i location + # Location: /b/star-84/ <- the pickup id + ``` +- **Pick up** at `http://10.100.10.50:8090/b//` (download links), or on + nh3-dev straight off disk at `~/booth-data//`. + +Uploads are stamped as pickup booths (a `⬆ pickup` badge in the UI) and expire +on the same 24h TTL. Limits: `BOOTH_MAX_FILES` files (default 50) and +`BOOTH_MAX_UPLOAD_MB` total per submission (default 1024); filenames are reduced +to a safe basename (no path traversal). + ## What a booth renders - **Has its own `index.html`?** → served **verbatim** (its relative assets — @@ -57,6 +84,7 @@ Then hand the operator `http://10.100.10.50:8090/b/my-run/`. | `GET /` | Index — one card per booth (newest first), with expiry countdown | | `GET /b//` | A booth (its `index.html`, else auto-gallery) | | `GET /b//` | Serve a file out of the booth | +| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b//` (id in `Location`) | | `POST /b//delete` | Wipe a booth (the UI's "Wipe now" button) | | `DELETE /b/` | Wipe a booth (curl/API) | | `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target | @@ -73,13 +101,14 @@ journalctl --user -u booth.service -f # sweeper logs "[booth] swept … ``` Config is env in the unit (`booth.service`): -`BOOTH_DATA_DIR`, `BOOTH_TTL_HOURS`, `BOOTH_HOST_LABEL`, `BOOTH_SWEEP_INTERVAL_MIN`. +`BOOTH_DATA_DIR`, `BOOTH_TTL_HOURS`, `BOOTH_HOST_LABEL`, `BOOTH_SWEEP_INTERVAL_MIN`, +`BOOTH_MAX_UPLOAD_MB` (default 1024), `BOOTH_MAX_FILES` (default 50). ### Install / update ```bash cd services/booth -uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 # runtime deps +uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 python-multipart # runtime deps cp booth.service ~/.config/systemd/user/booth.service systemctl --user daemon-reload && systemctl --user enable --now booth.service ``` @@ -97,8 +126,8 @@ cd services/booth && uv pip install pytest httpx && .venv/bin/python -m pytest - ## Notes / non-goals - **No auth.** LAN/WG-internal only, ephemeral content — don't drop secrets in a - booth. (The data dir is world-readable-on-LAN via the server.) -- **No upload API** by design. Sessions have filesystem access to nh3-dev; a - folder drop is simpler and more debuggable than an HTTP upload path. -- Booth names with `/`, `..`, or a leading `.` are rejected; file serving is - guarded against path traversal and symlink escape. + booth, and note anyone on the LAN can upload (bounded by the size/file limits). + Uploaded files are served back with their own content-type, so an uploaded + `index.html` renders as a page (a feature for custom reports; keep it in mind). +- Booth names with `/`, `..`, or a leading `.` are rejected; file serving and + uploaded filenames are guarded against path traversal and symlink escape. diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index c48ff66..df9575f 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -18,13 +18,14 @@ from __future__ import annotations import asyncio import os +import secrets import shutil import time from contextlib import asynccontextmanager from pathlib import Path from urllib.parse import quote -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, File, HTTPException, Request, UploadFile from fastapi.responses import ( FileResponse, HTMLResponse, @@ -141,6 +142,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> "kinds": kinds, "thumb_url": thumb_url, "has_index": (child / "index.html").is_file(), + "uploaded": (child / UPLOAD_MARKER).exists(), "expires_in": max(0.0, ttl_seconds - (now - mtime)), "mtime": mtime, } @@ -204,16 +206,71 @@ def build_gallery(child: Path) -> list[dict]: return items +# ---- uploads (browser drop-off for pickup) --------------------------------- + +UPLOAD_MARKER = ".uploaded" # dotfile stamped into upload booths (excluded from listings) + +# Friendly, unambiguous words for human-readable pickup ids (4-wombat / star-84). +PICKUP_WORDS = ( + "wombat otter panda koala tiger walrus gecko heron badger beaver falcon marmot " + "lemur narwhal ocelot puffin quokka raccoon tapir urchin vulture weasel yak zebra " + "alpaca bison cobra dingo egret ferret gibbon hare ibis jaguar llama moose newt " + "osprey possum quail robin seal toad viper wren lynx mole swan crane finch sloth " + "shrew stoat skunk heronry orca walnut sparrow " + "star comet moon cloud river maple cedar birch fern moss reef dune mesa cove glade " + "brook pine cedarwood kelp coral amber opal jade onyx slate flint ember spark frost " + "storm tide wave ridge peak vale marsh delta atoll canyon fjord geyser lagoon prairie " + "anchor beacon lantern kettle copper brass velvet cobalt indigo crimson violet olive " + "hazel cocoa mango guava papaya plum kiwi lime pear quince radish turnip acorn clover " + "thistle poppy aster dahlia iris lily sage thyme basil clove nutmeg ginger honey" +).split() + + +def safe_upload_name(name: str, fallback: str) -> str: + """Reduce a client-supplied filename to a safe basename (no path, no hidden).""" + base = (name or "").replace("\\", "/").split("/")[-1].strip() + base = base.lstrip(".") # a leading dot would hide the file from every listing + return base[:200] or fallback + + +def _dedupe_name(name: str, used: set) -> str: + if name not in used: + return name + stem, dot, ext = name.partition(".") + i = 1 + while f"{stem}-{i}{dot}{ext}" in used: + i += 1 + return f"{stem}-{i}{dot}{ext}" + + +def generate_pickup_id(exists) -> str: + """A human-readable id like '4-wombat' or 'star-84'. `exists(name)->bool` gates collisions.""" + for _ in range(400): + word = secrets.choice(PICKUP_WORDS) + num = secrets.randbelow(99) + 1 + name = f"{num}-{word}" if secrets.randbelow(2) else f"{word}-{num}" + if not exists(name): + return name + # astronomically unlikely fallback: two words keep it human-readable + while True: + name = f"{secrets.choice(PICKUP_WORDS)}-{secrets.choice(PICKUP_WORDS)}-{secrets.randbelow(999) + 1}" + if not exists(name): + return name + + def create_app( data_dir, ttl_hours: float = 24.0, host_label: str = "", start_sweeper: bool = True, sweep_interval_s: int = 900, + max_upload_mb: float = 1024.0, + max_files: int = 50, ) -> FastAPI: data_dir = Path(data_dir).expanduser().resolve() data_dir.mkdir(parents=True, exist_ok=True) ttl_seconds = ttl_hours * 3600.0 + max_upload_bytes = int(max_upload_mb * 1024 * 1024) templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates.env.filters["dur"] = human_dur @@ -287,6 +344,7 @@ def create_app( "name": name, "name_url": quote(name, safe=""), "items": build_gallery(booth), + "uploaded": (booth / UPLOAD_MARKER).exists(), "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)), }, ) @@ -302,6 +360,44 @@ def create_app( raise HTTPException(status_code=404, detail="no such file") return FileResponse(str(target)) + @app.post("/upload") + async def upload(files: list[UploadFile] = File(...)): + """Browser/curl drop-off: files land in a new booth with a human-readable + pickup id (e.g. 4-wombat), sweep-expiring in the usual TTL. Redirects (303) + to the pickup page; curl clients read the Location header for the id.""" + files = [f for f in files if f and f.filename] + if not files: + raise HTTPException(status_code=400, detail="no files uploaded") + if len(files) > max_files: + raise HTTPException(status_code=413, detail=f"too many files (max {max_files})") + + booth_id = generate_pickup_id(lambda n: (data_dir / n).exists()) + dest = data_dir / booth_id + dest.mkdir(parents=True) + (dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed) + + total = 0 + used: set = {UPLOAD_MARKER} + try: + for i, f in enumerate(files): + name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used) + used.add(name) + with (dest / name).open("wb") as out: + while chunk := await f.read(1024 * 1024): + total += len(chunk) + if total > max_upload_bytes: + raise HTTPException( + status_code=413, + detail=f"upload too large (max {max_upload_mb:g} MB)", + ) + out.write(chunk) + await f.close() + except Exception: + shutil.rmtree(dest, ignore_errors=True) # never leave a half-written booth + raise + + return RedirectResponse(url=f"/b/{quote(booth_id, safe='')}/", status_code=303) + @app.post("/b/{name}/delete") def booth_delete_form(name: str): shutil.rmtree(resolve_booth(name)) @@ -320,7 +416,16 @@ def _from_env() -> FastAPI: ttl = float(os.environ.get("BOOTH_TTL_HOURS", "24")) host = os.environ.get("BOOTH_HOST_LABEL", "") interval = int(float(os.environ.get("BOOTH_SWEEP_INTERVAL_MIN", "15")) * 60) - return create_app(data, ttl_hours=ttl, host_label=host, sweep_interval_s=interval) + max_mb = float(os.environ.get("BOOTH_MAX_UPLOAD_MB", "1024")) + max_n = int(os.environ.get("BOOTH_MAX_FILES", "50")) + return create_app( + data, + ttl_hours=ttl, + host_label=host, + sweep_interval_s=interval, + max_upload_mb=max_mb, + max_files=max_n, + ) app = _from_env() diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html index c7ca678..82cbc9f 100644 --- a/services/booth/booth/templates/base.html +++ b/services/booth/booth/templates/base.html @@ -83,6 +83,38 @@ main{flex:1;width:100%;max-width:1240px;margin:0 auto;padding:1.7rem 1.5rem 3rem} + /* ---- upload / pickup ---- */ + .uploader{display:flex;gap:.9rem;align-items:stretch;margin-bottom:1.7rem;flex-wrap:wrap} + .drop{flex:1 1 300px;position:relative;display:flex;flex-direction:column;align-items:center; + justify-content:center;gap:.15rem;text-align:center;cursor:pointer;padding:1.1rem 1rem; + border:1.5px dashed var(--border-default);border-radius:var(--radius-lg);background:var(--rk-well); + transition:border-color .16s var(--ease-out),background .16s var(--ease-out),box-shadow .16s var(--ease-out)} + .drop:hover{border-color:var(--aus-cyan)} + .drop.over{border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.06);box-shadow:var(--glow-cyan)} + .drop.has{border-style:solid;border-color:var(--aus-cyan)} + .drop input[type=file]{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer} + .drop-icon{font-size:1.2rem;color:var(--aus-bright-cyan)} + .drop-main{font-family:var(--font-display);font-weight:600;color:var(--fg-0);font-size:.98rem} + .drop-sub{font-family:var(--font-mono);font-size:.7rem;letter-spacing:.03em;color:var(--fg-3)} + .up-go{flex:0 0 auto;align-self:stretch;cursor:pointer;font-family:var(--font-mono);font-size:.74rem; + letter-spacing:.1em;text-transform:uppercase;padding:0 1.15rem;border-radius:var(--radius-lg); + background:transparent;border:1px solid var(--aus-cyan);color:var(--aus-bright-cyan); + transition:.14s var(--ease-out)} + .up-go:hover{background:var(--aus-bright-cyan);color:var(--fg-on-accent);border-color:var(--aus-bright-cyan)} + + .badge{display:inline-block;font-family:var(--font-mono);font-size:.6rem;letter-spacing:.1em; + text-transform:uppercase;font-weight:600;color:var(--fg-on-accent);background:var(--aus-bright-cyan); + padding:.08rem .42rem;border-radius:var(--radius-pill);vertical-align:middle} + .card .thumb{position:relative} + .thumb .badge{position:absolute;top:.5rem;left:.5rem;box-shadow:var(--shadow-2)} + + .pickup-note{margin:-.5rem 0 1.5rem;padding:.6rem .85rem;border:1px solid var(--border-subtle); + border-left:3px solid var(--aus-bright-cyan);border-radius:var(--radius-md);background:var(--rk-well); + font-family:var(--font-mono);font-size:.78rem;color:var(--fg-2)} + .dl-link{color:var(--aus-bright-cyan);text-decoration:none;margin-right:.4rem;font-size:.95em} + .dl-link:hover{color:var(--aus-cyan)} + .cap-text{color:var(--fg-2)} + .foot{border-top:1px solid var(--border-subtle);color:var(--fg-muted); font-size:.72rem;font-family:var(--font-mono);letter-spacing:.04em;padding:1rem 1.5rem;text-align:center} diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html index 4bf87ab..cdb2866 100644 --- a/services/booth/booth/templates/booth.html +++ b/services/booth/booth/templates/booth.html @@ -4,13 +4,19 @@
‹ all booths

{{ name }}

- {{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }} + {% if uploaded %}⬆ pickup {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}
+{% if uploaded %} +
+ 📦 Pickup {{ name }} — download files below, or on nh3-dev grab ~/booth-data/{{ name }}/ +
+{% endif %} + {% if not items %}
This booth is empty.
{% else %} @@ -24,9 +30,16 @@ {% elif it.kind == 'audio' %} {% else %} - ⬇ {{ it.name }} + ⬇ {{ it.name }} + {% endif %} + {% if it.kind == 'other' %} + {% if it.caption %}
{{ it.caption }}
{% endif %} + {% else %} +
+ + {{ it.caption or it.name }} +
{% endif %} -
{{ it.caption or it.name }}
{% endfor %} diff --git a/services/booth/booth/templates/index.html b/services/booth/booth/templates/index.html index ce94df1..754707e 100644 --- a/services/booth/booth/templates/index.html +++ b/services/booth/booth/templates/index.html @@ -1,9 +1,18 @@ {% extends "base.html" %} {% block content %} +
+ + +
+ {% if not booths %}
- No booths yet.
- Drop a folder of media into {{ data_dir }} and it lands here. + No booths yet. Upload files above, or drop a folder into {{ data_dir }}.
{% else %}
@@ -21,6 +30,7 @@ {% else %}
◆ files
{% endif %} + {% if b.uploaded %}⬆ pickup{% endif %}
{{ b.name }} @@ -34,4 +44,35 @@ {% endfor %}
{% endif %} + + {% endblock %} diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index 94249e1..ce3375d 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -1,12 +1,13 @@ [project] name = "booth" -version = "0.1.0" -description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator." +version = "0.1.1" +description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator." requires-python = ">=3.11" dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.34", "jinja2>=3.1", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/services/booth/tests/test_booth.py b/services/booth/tests/test_booth.py index a761ec3..658b2dd 100644 --- a/services/booth/tests/test_booth.py +++ b/services/booth/tests/test_booth.py @@ -1,4 +1,5 @@ import os +import re import time import pytest @@ -8,11 +9,15 @@ from booth.app import ( build_gallery, classify, create_app, + generate_pickup_id, human_dur, is_expired, + safe_upload_name, sweep_once, ) +PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$") + # ---- pure helpers ----------------------------------------------------------- @@ -182,3 +187,102 @@ def test_healthz(client): assert r.status_code == 200 body = r.json() assert body["ok"] is True and body["booths"] == 1 + + +# ---- uploads / pickup ------------------------------------------------------- + + +def test_generate_pickup_id_format(): + for _ in range(100): + pid = generate_pickup_id(lambda n: False) + assert PICKUP_RE.match(pid), pid + + +def test_generate_pickup_id_avoids_collision(): + taken = {"4-wombat", "star-84"} + for _ in range(50): + pid = generate_pickup_id(lambda n: n in taken) + assert pid not in taken + + +def test_safe_upload_name(): + assert safe_upload_name("../../etc/passwd", "fb") == "passwd" + assert safe_upload_name("C:\\Users\\x\\shot.png", "fb") == "shot.png" + assert safe_upload_name("", "fb") == "fb" + assert safe_upload_name(" ", "fb") == "fb" + assert safe_upload_name(".hidden", "fb") == "hidden" + assert safe_upload_name("...", "fb") == "fb" + + +def _upload(client, files): + return client.post("/upload", files=files, follow_redirects=False) + + +def test_upload_creates_pickup_booth(client): + c, data = client + r = _upload(c, [ + ("files", ("a.png", b"\x89PNG\r\n\x1a\n" + b"0" * 20, "image/png")), + ("files", ("notes.txt", b"pick me up", "text/plain")), + ]) + assert r.status_code == 303 + loc = r.headers["location"] + pid = loc.split("/b/")[1].rstrip("/") + assert PICKUP_RE.match(pid), pid + booth = data / pid + assert (booth / "a.png").is_file() + assert (booth / "notes.txt").read_bytes() == b"pick me up" + assert (booth / ".uploaded").is_file() # marker present + + +def test_upload_booth_renders_pickup_ui(client): + c, data = client + r = _upload(c, [("files", ("shot.png", b"\x89PNG" + b"0" * 30, "image/png"))]) + pid = r.headers["location"].split("/b/")[1].rstrip("/") + page = c.get(f"/b/{pid}/") + assert page.status_code == 200 + assert "pickup" in page.text.lower() # badge / note + assert 'download' in page.text # per-item download link + # and it shows up flagged as an upload on the index + assert pid in c.get("/").text + + +def test_upload_sanitizes_traversal(client): + c, data = client + r = _upload(c, [("files", ("../../../etc/passwd", b"x", "text/plain"))]) + pid = r.headers["location"].split("/b/")[1].rstrip("/") + booth = data / pid + assert (booth / "passwd").is_file() # basename only + assert not (data.parent / "passwd").exists() # nothing escaped upward + + +def test_upload_rejects_too_many_files(tmp_path): + app = create_app(tmp_path, start_sweeper=False, max_files=2) + c = TestClient(app) + files = [("files", (f"f{i}.txt", b"x", "text/plain")) for i in range(3)] + r = c.post("/upload", files=files, follow_redirects=False) + assert r.status_code == 413 + # no partial booth left behind + assert list(tmp_path.iterdir()) == [] + + +def test_upload_rejects_too_large(tmp_path): + app = create_app(tmp_path, start_sweeper=False, max_upload_mb=0.0001) # ~104 bytes + c = TestClient(app) + r = c.post( + "/upload", + files=[("files", ("big.bin", b"0" * 500, "application/octet-stream"))], + follow_redirects=False, + ) + assert r.status_code == 413 + assert list(tmp_path.iterdir()) == [] # partial write cleaned up + + +def test_upload_dedupes_repeated_names(client): + c, data = client + r = _upload(c, [ + ("files", ("shot.png", b"a" * 10, "image/png")), + ("files", ("shot.png", b"b" * 10, "image/png")), + ]) + pid = r.headers["location"].split("/b/")[1].rstrip("/") + names = sorted(p.name for p in (data / pid).iterdir() if not p.name.startswith(".")) + assert names == ["shot-1.png", "shot.png"]