feat(booth): add The Booth — ephemeral media drop board for CC sessions

A standing user-level web server (nh3-dev :8090) that renders drop-folders
under ~/booth-data as ephemeral media "booths" so Claude Code sessions can
surface A/B renders and smoke results to the operator, then let them self-wipe.

- Scan-and-serve model, no database, no upload API — a booth is just a folder.
  A folder's own index.html is served verbatim; otherwise an auto-gallery of
  images / webm+mp4 video / audio is rendered, with <file>.txt caption sidecars
  folded in (labels A/B pairs).
- 24h TTL from newest mtime in the tree; background sweeper wipes stale booths.
- Path-traversal + symlink-escape guarded; delete via UI button or DELETE API.
- FastAPI + Jinja2, runs from the checkout under systemctl --user (booth.service),
  alongside the other nh3-dev fleet sidecars. 15 tests, all green.
- Homepage tile added (Apps -> The Booth, siteMonitor /healthz).
- Harden the homepage rsync doc: exclude *.bak* and logs/ so --delete can't
  wipe the host's dated services.yaml backups (footgun found deploying this).
This commit is contained in:
2026-07-20 10:17:40 -07:00
parent a5dcad8bd3
commit f4a5ba7c31
13 changed files with 909 additions and 0 deletions
+9
View File
@@ -85,12 +85,21 @@ Current workflow — push this directory onto the host:
```bash
rsync -av --delete \
--exclude='.env' --exclude='.env.*' \
--exclude='*.bak*' --exclude='logs/' \
configs/homepage/ esh-docker-vm:/opt/docker/conf/homepage/
```
The real `.env` lives on `esh-docker-vm` next to the compose file and must
not be overwritten (holds Plex/Jellyfin keys).
> **`--delete` footgun (learned 2026-07-20):** the host keeps dated
> `services.yaml.bak-*` safety copies and a live `logs/` dir that are *not*
> in this repo. A bare `--delete` rsync wipes both. The `--exclude='*.bak*'`
> and `--exclude='logs/'` above protect them. For a one-file tweak, skip
> `--delete` entirely and push the single file:
> `rsync -av configs/homepage/services.yaml esh-docker-vm:/opt/docker/conf/homepage/services.yaml`
> (back up the host copy first: `ssh esh-docker-vm 'cp -a …/services.yaml …/services.yaml.bak-<date>-<what>'`).
The homepage container reloads most files on-change; if a new group in
`settings.yaml` doesn't show up, `docker compose restart` on the host.
+9
View File
@@ -17,6 +17,15 @@
siteMonitor: http://10.0.50.45:3001
description: Uptime monitor (esh-docker-vm)
- Apps:
# Manual entry — the Booth is a user-level systemd service on nh3-dev
# (not a Docker-labeled stack), so it can't auto-discover; list it here.
- The Booth:
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)
# The AI tab is fully Docker-auto-discovered. Each inference service carries
# a homepage.group=AI - <role> label on its compose file (AI - Inference,
# AI - Eval & Retrieval, AI - Gateways & Chat, AI - Speech (TTS),
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
.pytest_cache/
booth-data/
+104
View File
@@ -0,0 +1,104 @@
# 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.
- **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)
- **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while
you're touching it, self-destructs 24h after you stop)
## How a session posts
A booth is **just a folder** under the data dir. Three ways, cheapest first:
```bash
# 1. On nh3-dev — the helper (services/booth/scripts/booth):
booth add my-run out/a.png out/b.png # creates booth + copies, prints URL
booth new my-run # empty booth, then cp/mv into ~/booth-data/my-run/
booth url my-run # just print the URL
booth ls # list booths
booth rm my-run # wipe now (TTL would anyway)
# 2. On nh3-dev — raw, no helper:
mkdir -p ~/booth-data/my-run && cp out/*.png ~/booth-data/my-run/
# -> http://10.100.10.50:8090/b/my-run/
# 3. From another host — rsync into the data dir:
rsync -a ./out/ nh3-dev:booth-data/my-run/
```
Then hand the operator `http://10.100.10.50:8090/b/my-run/`.
## What a booth renders
- **Has its own `index.html`?** → served **verbatim** (its relative assets —
`chart.png`, `report.css` — resolve out of the same folder). Build whatever
page you want.
- **No `index.html`?** → **auto-gallery** of the folder's media:
- images (`png jpg jpeg gif webp avif svg bmp`) → `<img>`
- video (`webm mp4 ogv m4v mov`) → `<video controls>`
- audio (`mp3 wav ogg flac m4a opus aac`) → `<audio controls>`
- anything else → a download link
- **Captions:** a `<file>.txt` or same-stem `<stem>.txt` sidecar is folded in as
that item's caption — the natural way to label an A/B pair:
```
a.png b.png
a.txt "baseline" b.png.txt "cudaMallocAsync (winner)"
```
## Routes
| Route | Purpose |
|---|---|
| `GET /` | Index — one card per booth (newest first), with expiry countdown |
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
| `GET /b/<name>/<file>` | Serve a file out of the booth |
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
| `DELETE /b/<name>` | Wipe a booth (curl/API) |
| `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target |
## Ops
Runs as a **user-level** systemd service on nh3-dev (no root, no Docker),
alongside the other fleet sidecars (herald, zellij-web, ttyd).
```bash
systemctl --user status booth.service
systemctl --user restart booth.service
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`.
### Install / update
```bash
cd services/booth
uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 # runtime deps
cp booth.service ~/.config/systemd/user/booth.service
systemctl --user daemon-reload && systemctl --user enable --now booth.service
```
Code runs straight from this checkout (the unit's `WorkingDirectory` /
`ExecStart` point here), so "deploy an update" = edit + `systemctl --user
restart booth.service`.
### Tests
```bash
cd services/booth && uv pip install pytest httpx && .venv/bin/python -m pytest -q
```
## 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.
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=The Booth — ephemeral media drop board (scan+serve ~/booth-data, 24h TTL)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/lkraven/development/eshpfi-management/services/booth
ExecStart=/home/lkraven/development/eshpfi-management/services/booth/.venv/bin/uvicorn booth.app:app --host 0.0.0.0 --port 8090
Environment=BOOTH_DATA_DIR=/home/lkraven/booth-data
Environment=BOOTH_TTL_HOURS=24
Environment=BOOTH_HOST_LABEL=nh3-dev 10.100.10.50
Environment=BOOTH_SWEEP_INTERVAL_MIN=15
Restart=on-failure
RestartSec=3
# User-level unit: install to ~/.config/systemd/user/booth.service and
# systemctl --user daemon-reload && systemctl --user enable --now booth.service
# (loginctl enable-linger lkraven — so it survives logout, already set on nh3-dev)
[Install]
WantedBy=default.target
+3
View File
@@ -0,0 +1,3 @@
"""The Booth — ephemeral media drop board. See booth.app for the server."""
__version__ = "0.1.0"
+326
View File
@@ -0,0 +1,326 @@
"""The Booth — a standing web server that renders drop-folders as ephemeral media booths.
Model (deliberately dead-simple, no database):
* The data dir holds one subfolder per "booth". A booth is created by a CC
session simply making a folder and dropping files in — there is no upload API.
* GET / -> index: a card per booth (scan of the data dir).
* GET /b/<name>/ -> if <name>/index.html exists, serve it verbatim; otherwise
auto-render a gallery of the images / webm-videos / audio in it.
* GET /b/<name>/<file> -> serve a file out of the booth (also feeds a custom index.html's assets).
* 24h TTL: a background sweeper wipes any booth untouched for TTL hours. A booth's
age is measured from the *newest* mtime in its tree, so it lives while it's being
worked on and self-destructs TTL hours after the last activity.
State is the filesystem — `ls ~/booth-data` tells you everything. That is the whole point.
"""
from __future__ import annotations
import asyncio
import os
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.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
RedirectResponse,
)
from fastapi.templating import Jinja2Templates
TEMPLATES_DIR = Path(__file__).parent / "templates"
# Browser-playable media buckets. Anything else renders as a download link.
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"}
VIDEO_EXTS = {".webm", ".mp4", ".ogv", ".m4v", ".mov"}
AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus", ".aac"}
CAPTION_MAX = 800 # chars of a sidecar .txt caption we render
def classify(name: str) -> str:
"""image | video | audio | other, by extension."""
ext = Path(name).suffix.lower()
if ext in IMAGE_EXTS:
return "image"
if ext in VIDEO_EXTS:
return "video"
if ext in AUDIO_EXTS:
return "audio"
return "other"
def human_dur(seconds: float) -> str:
s = int(seconds)
if s <= 0:
return "expired"
h, rem = divmod(s, 3600)
m, _ = divmod(rem, 60)
if h and m:
return f"{h}h {m}m"
if h:
return f"{h}h"
if m:
return f"{m}m"
return "<1m"
def _newest_mtime(path: Path) -> float:
"""Newest mtime among a folder and everything under it."""
try:
newest = path.stat().st_mtime
except OSError:
return 0.0
for p in path.rglob("*"):
try:
m = p.stat().st_mtime
except OSError:
continue
if m > newest:
newest = m
return newest
def booth_age_seconds(path: Path, now: float | None = None) -> float:
now = time.time() if now is None else now
return now - _newest_mtime(path)
def is_expired(path: Path, ttl_seconds: float, now: float | None = None) -> bool:
return booth_age_seconds(path, now) > ttl_seconds
def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[str]:
"""Wipe every direct-child booth older than the TTL. Returns names wiped.
Only ever removes direct children of data_dir (never data_dir itself), and
skips dotfolders so a stray control dir can opt out.
"""
wiped: list[str] = []
if not data_dir.is_dir():
return wiped
for child in data_dir.iterdir():
if not child.is_dir() or child.name.startswith("."):
continue
try:
if is_expired(child, ttl_seconds, now):
shutil.rmtree(child)
wiped.append(child.name)
except OSError:
pass
return wiped
def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[dict]:
now = time.time() if now is None else now
booths: list[dict] = []
if not data_dir.is_dir():
return booths
for child in data_dir.iterdir():
if not child.is_dir() or child.name.startswith("."):
continue
files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")]
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
thumb_url = None
for f in files:
k = classify(f.name)
kinds[k] += 1
if k == "image" and thumb_url is None:
thumb_url = quote(f.relative_to(child).as_posix(), safe="/")
mtime = _newest_mtime(child)
booths.append(
{
"name": child.name,
"name_url": quote(child.name, safe=""),
"count": len(files),
"kinds": kinds,
"thumb_url": thumb_url,
"has_index": (child / "index.html").is_file(),
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime,
}
)
booths.sort(key=lambda b: b["mtime"], reverse=True)
return booths
def build_gallery(child: Path) -> list[dict]:
"""Files in a booth as render items, with caption sidecars folded in.
A `<file>.txt` (e.g. `a.png.txt`) or a same-stem `<stem>.txt` (e.g. `a.txt`
next to `a.png`) is consumed as that item's caption rather than shown itself —
the natural way to label an A/B pair.
"""
all_files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")]
by_rel = {p.relative_to(child).as_posix(): p for p in all_files}
caption: dict[str, str] = {}
sidecars: set[str] = set()
for rel, p in by_rel.items():
if not rel.lower().endswith(".txt"):
continue
target = None
base_full = rel[:-4] # strip ".txt" -> "a.png.txt" => "a.png"
if base_full in by_rel:
target = base_full
else: # "a.txt" beside "a.png"
parent = str(Path(rel).parent)
stem = Path(rel).stem
for q_rel, q in by_rel.items():
if q_rel == rel:
continue
if (
str(Path(q_rel).parent) == parent
and Path(q_rel).stem == stem
and classify(q.name) != "other"
):
target = q_rel
break
if target is not None:
try:
caption[target] = p.read_text(errors="replace").strip()[:CAPTION_MAX]
except OSError:
pass
sidecars.add(rel)
items = []
for rel in sorted(by_rel):
if rel in sidecars:
continue
p = by_rel[rel]
items.append(
{
"name": rel,
"kind": classify(p.name),
"url": quote(rel, safe="/"),
"caption": caption.get(rel),
}
)
return items
def create_app(
data_dir,
ttl_hours: float = 24.0,
host_label: str = "",
start_sweeper: bool = True,
sweep_interval_s: int = 900,
) -> FastAPI:
data_dir = Path(data_dir).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True)
ttl_seconds = ttl_hours * 3600.0
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.filters["dur"] = human_dur
@asynccontextmanager
async def lifespan(app: FastAPI):
task = None
if start_sweeper:
async def loop():
while True:
try:
wiped = sweep_once(data_dir, ttl_seconds)
if wiped:
print(f"[booth] swept {len(wiped)} expired: {', '.join(wiped)}", flush=True)
except Exception as exc: # never let the sweeper die
print(f"[booth] sweep error: {exc}", flush=True)
await asyncio.sleep(sweep_interval_s)
task = asyncio.create_task(loop())
try:
yield
finally:
if task is not None:
task.cancel()
app = FastAPI(title="The Booth", lifespan=lifespan)
ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours
base_ctx = {"ttl_hours": ttl_display, "host": host_label, "data_dir": str(data_dir)}
def resolve_booth(name: str) -> Path:
if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name:
raise HTTPException(status_code=404, detail="no such booth")
candidate = data_dir / name
try:
resolved = candidate.resolve()
except OSError:
raise HTTPException(status_code=404, detail="no such booth")
# resolved.parent must be the data dir itself — blocks symlink escape + nesting.
if resolved.parent != data_dir or not resolved.is_dir():
raise HTTPException(status_code=404, detail="no such booth")
return resolved
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse(
request, "index.html", {**base_ctx, "booths": list_booths(data_dir, ttl_seconds)}
)
@app.get("/healthz")
def healthz():
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
@app.get("/b/{name}", include_in_schema=False)
def booth_redirect(name: str):
resolve_booth(name)
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)
@app.get("/b/{name}/", response_class=HTMLResponse)
def booth_view(request: Request, name: str):
booth = resolve_booth(name)
own_index = booth / "index.html"
if own_index.is_file():
return FileResponse(str(own_index), media_type="text/html")
return templates.TemplateResponse(
request,
"booth.html",
{
**base_ctx,
"name": name,
"name_url": quote(name, safe=""),
"items": build_gallery(booth),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
},
)
@app.get("/b/{name}/{filepath:path}")
def booth_file(name: str, filepath: str):
booth = resolve_booth(name)
try:
target = (booth / filepath).resolve()
except OSError:
raise HTTPException(status_code=404, detail="no such file")
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such file")
return FileResponse(str(target))
@app.post("/b/{name}/delete")
def booth_delete_form(name: str):
shutil.rmtree(resolve_booth(name))
return RedirectResponse(url="/", status_code=303)
@app.delete("/b/{name}")
def booth_delete_api(name: str):
shutil.rmtree(resolve_booth(name))
return JSONResponse({"wiped": name})
return app
def _from_env() -> FastAPI:
data = os.environ.get("BOOTH_DATA_DIR", str(Path.home() / "booth-data"))
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)
app = _from_env()
+103
View File
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}The Booth{% endblock %}</title>
<style>
:root{
--bg:#0e0f12; --panel:#16181d; --panel-2:#1c1f26; --edge:#2a2e37;
--ink:#e7e9ee; --ink-dim:#9aa0ac; --ink-faint:#6b7280;
--accent:#ff5a4d; --accent-2:#ffb020; --link:#7bb0ff;
--radius:14px; --shadow:0 6px 24px rgba(0,0,0,.35);
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
@media (prefers-color-scheme: light){
:root{
--bg:#f4f5f7; --panel:#ffffff; --panel-2:#f0f1f4; --edge:#e0e3e9;
--ink:#181b20; --ink-dim:#5b616e; --ink-faint:#8a909c;
--accent:#e23b2e; --accent-2:#c77700; --link:#2563c9;
--shadow:0 4px 16px rgba(20,24,40,.10);
}
}
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--bg);color:var(--ink);font-family:var(--sans);
line-height:1.5;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--link);text-decoration:none}
a:hover{text-decoration:underline}
code{font-family:var(--mono);font-size:.86em;background:var(--panel-2);
padding:.12em .4em;border-radius:6px;border:1px solid var(--edge)}
.topbar{display:flex;align-items:baseline;gap:1rem;flex-wrap:wrap;
padding:1.1rem 1.5rem;border-bottom:1px solid var(--edge);
background:linear-gradient(180deg,var(--panel),transparent)}
.brand{display:inline-flex;align-items:center;gap:.55rem;font-weight:800;
font-size:1.32rem;letter-spacing:.3px;color:var(--ink)}
.brand:hover{text-decoration:none}
.brand .dot{width:.6rem;height:.6rem;border-radius:50%;background:var(--accent);
box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 22%,transparent);
animation:pulse 2.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
.tagline{color:var(--ink-dim);font-size:.86rem;font-family:var(--mono)}
main{flex:1;width:100%;max-width:1200px;margin:0 auto;padding:1.6rem 1.5rem 3rem}
.foot{border-top:1px solid var(--edge);color:var(--ink-faint);
font-size:.78rem;font-family:var(--mono);padding:1rem 1.5rem;text-align:center}
.empty{border:1px dashed var(--edge);border-radius:var(--radius);
padding:3rem 1.5rem;text-align:center;color:var(--ink-dim);background:var(--panel)}
/* index grid */
.grid{display:grid;gap:1.1rem;grid-template-columns:repeat(auto-fill,minmax(240px,1fr))}
.card{position:relative;background:var(--panel);border:1px solid var(--edge);
border-radius:var(--radius);overflow:hidden;transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}
.card:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--accent) 55%,var(--edge));box-shadow:var(--shadow)}
.card .thumb{display:block;aspect-ratio:16/10;background:var(--panel-2);overflow:hidden}
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}
.card .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;
color:var(--ink-faint);font-family:var(--mono);font-size:.9rem;letter-spacing:.5px}
.card .meta{padding:.7rem .85rem .85rem}
.card .name{display:block;font-weight:700;color:var(--ink);word-break:break-word}
.card .name:hover{text-decoration:none;color:var(--accent)}
.card .sub{color:var(--ink-dim);font-size:.78rem;font-family:var(--mono);margin-top:.25rem}
.wipe{position:absolute;top:.5rem;right:.5rem;margin:0}
.wipe button{cursor:pointer;border:1px solid var(--edge);background:color-mix(in srgb,var(--bg) 60%,transparent);
color:var(--ink-dim);width:1.9rem;height:1.9rem;border-radius:8px;font-size:1.15rem;line-height:1;
backdrop-filter:blur(4px);transition:.12s}
.wipe button:hover{border-color:var(--accent);color:#fff;background:var(--accent)}
/* booth page */
.boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
padding-bottom:1rem;margin-bottom:1.3rem;border-bottom:1px solid var(--edge)}
.boothhead .back{font-family:var(--mono);font-size:.82rem;color:var(--ink-dim)}
.boothhead h1{margin:0;font-size:1.5rem;word-break:break-word;flex:1 1 auto}
.boothhead .sub{color:var(--ink-dim);font-size:.82rem;font-family:var(--mono)}
.wipe-lg{position:static}
.wipe-lg button{width:auto;height:auto;padding:.4rem .8rem;border-radius:9px;font-size:.82rem;font-family:var(--mono)}
.gallery{display:grid;gap:1.4rem;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}
.item{margin:0;background:var(--panel);border:1px solid var(--edge);border-radius:var(--radius);
overflow:hidden;display:flex;flex-direction:column}
.item img,.item video{width:100%;height:auto;display:block;background:#000}
.item audio{width:100%;margin:1.2rem .9rem .3rem;max-width:calc(100% - 1.8rem)}
.item .dl{padding:1.4rem .9rem;font-family:var(--mono);font-size:.88rem;word-break:break-all}
.item figcaption{padding:.6rem .85rem .75rem;color:var(--ink-dim);font-size:.82rem;
font-family:var(--mono);border-top:1px solid var(--edge);word-break:break-word}
.item-audio figcaption,.item-other figcaption{border-top:none}
</style>
</head>
<body>
<header class="topbar">
<a class="brand" href="/"><span class="dot"></span>The&nbsp;Booth</a>
<span class="tagline">ephemeral media drop · auto-wipes {{ ttl_hours }}h after last activity</span>
</header>
<main>{% block content %}{% endblock %}</main>
<footer class="foot">
drop a folder into <code>{{ data_dir }}</code>{% if host %} on {{ host }}{% endif %} — it shows up here
</footer>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}{{ name }} · The Booth{% endblock %}
{% block content %}
<div class="boothhead">
<a class="back" href="/"> all booths</a>
<h1>{{ name }}</h1>
<span class="sub">{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}</span>
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')">
<button>Wipe now</button>
</form>
</div>
{% if not items %}
<div class="empty">This booth is empty.</div>
{% else %}
<div class="gallery">
{% for it in items %}
<figure class="item item-{{ it.kind }}">
{% if it.kind == 'image' %}
<a href="{{ it.url }}" target="_blank" rel="noopener"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
{% elif it.kind == 'video' %}
<video controls preload="metadata" src="{{ it.url }}"></video>
{% elif it.kind == 'audio' %}
<audio controls preload="metadata" src="{{ it.url }}"></audio>
{% else %}
<a class="dl" href="{{ it.url }}">⬇ {{ it.name }}</a>
{% endif %}
<figcaption>{{ it.caption or it.name }}</figcaption>
</figure>
{% endfor %}
</div>
{% endif %}
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block content %}
{% if not booths %}
<div class="empty">
No booths yet.<br>
Drop a folder of media into <code>{{ data_dir }}</code> and it lands here.
</div>
{% else %}
<div class="grid">
{% for b in booths %}
<article class="card">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }}</div>
</div>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
<button title="wipe now" aria-label="wipe booth">×</button>
</form>
</article>
{% endfor %}
</div>
{% endif %}
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
[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."
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"jinja2>=3.1",
]
[project.optional-dependencies]
test = [
"pytest>=8.0",
"httpx>=0.27", # fastapi TestClient
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["booth"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# booth — post media to The Booth (dead simple). A booth is just a folder under
# $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL back.
#
# booth new <name> make an empty booth, print its URL
# booth add <name> <file>... copy files into a booth (creates it), print URL
# booth url <name> print a booth's URL
# booth ls list booths
# booth rm <name> wipe a booth now (TTL would eventually anyway)
#
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
# rsync -a ./out/ nh3-dev:booth-data/my-run/
set -euo pipefail
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
usage() { echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>}" >&2; exit 2; }
cmd="${1:-}"; shift || true
case "$cmd" in
new)
[ $# -ge 1 ] || usage
mkdir -p -- "$DATA/$1"
echo "$URL/b/$1/"
;;
add)
[ $# -ge 2 ] || usage
name="$1"; shift
mkdir -p -- "$DATA/$name"
cp -- "$@" "$DATA/$name/"
echo "$URL/b/$name/"
;;
url)
[ $# -ge 1 ] || usage
echo "$URL/b/$1/"
;;
ls)
ls -1 -- "$DATA" 2>/dev/null || true
;;
rm)
[ $# -ge 1 ] || usage
rm -rf -- "${DATA:?}/$1"
echo "wiped $1"
;;
*) usage ;;
esac
+184
View File
@@ -0,0 +1,184 @@
import os
import time
import pytest
from fastapi.testclient import TestClient
from booth.app import (
build_gallery,
classify,
create_app,
human_dur,
is_expired,
sweep_once,
)
# ---- pure helpers -----------------------------------------------------------
def test_classify():
assert classify("a.PNG") == "image"
assert classify("clip.webm") == "video"
assert classify("v.mp4") == "video"
assert classify("song.mp3") == "audio"
assert classify("notes.txt") == "other"
assert classify("archive.tar.gz") == "other"
def test_human_dur():
assert human_dur(0) == "expired"
assert human_dur(-5) == "expired"
assert human_dur(30) == "<1m"
assert human_dur(90) == "1m"
assert human_dur(3600) == "1h"
assert human_dur(3660) == "1h 1m"
def _touch(path, when=None):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x")
if when is not None:
os.utime(path, (when, when))
def test_is_expired_uses_newest_mtime(tmp_path):
booth = tmp_path / "b"
_touch(booth / "old.png", when=time.time() - 10_000)
# freshly touched second file keeps the booth alive despite the old one
_touch(booth / "new.png")
assert not is_expired(booth, ttl_seconds=3600)
old = tmp_path / "stale"
t = time.time() - 10_000
_touch(old / "x.png", when=t)
os.utime(old, (t, t))
assert is_expired(old, ttl_seconds=3600)
def test_sweep_only_removes_expired(tmp_path):
fresh = tmp_path / "fresh"
_touch(fresh / "a.png")
old = tmp_path / "old"
t = time.time() - 10_000
_touch(old / "a.png", when=t)
os.utime(old, (t, t))
dotdir = tmp_path / ".control"
t2 = time.time() - 10_000
dotdir.mkdir()
os.utime(dotdir, (t2, t2))
wiped = sweep_once(tmp_path, ttl_seconds=3600)
assert wiped == ["old"]
assert fresh.exists()
assert not old.exists()
assert dotdir.exists() # dotfolders are never swept
def test_build_gallery_folds_caption_sidecars(tmp_path):
booth = tmp_path / "b"
_touch(booth / "a.png")
(booth / "a.txt").write_text("variant A: cudaMalloc")
_touch(booth / "b.png")
(booth / "b.png.txt").write_text("variant B: cudaMallocAsync")
_touch(booth / "loose.txt") # no media partner -> shown as its own item
items = build_gallery(booth)
by_name = {it["name"]: it for it in items}
assert by_name["a.png"]["caption"] == "variant A: cudaMalloc"
assert by_name["b.png"]["caption"] == "variant B: cudaMallocAsync"
assert "a.txt" not in by_name and "b.png.txt" not in by_name
assert "loose.txt" in by_name # a caption with nothing to caption stays visible
# ---- HTTP surface -----------------------------------------------------------
@pytest.fixture
def client(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), tmp_path
def test_index_empty(client):
c, _ = client
r = c.get("/")
assert r.status_code == 200
assert "No booths yet" in r.text
def test_index_lists_booth(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.get("/")
assert r.status_code == 200
assert "run1" in r.text
def test_booth_autogallery_renders_media(client):
c, data = client
_touch(data / "run1" / "shot.png")
r = c.get("/b/run1/")
assert r.status_code == 200
assert "<img" in r.text
assert "shot.png" in r.text
def test_booth_serves_own_index_html(client):
c, data = client
d = data / "custom"
d.mkdir()
(d / "index.html").write_text("<h1>MY CUSTOM REPORT</h1>")
r = c.get("/b/custom/")
assert r.status_code == 200
assert "MY CUSTOM REPORT" in r.text
def test_booth_serves_file(client):
c, data = client
(data / "run1").mkdir()
(data / "run1" / "a.bin").write_bytes(b"\x00\x01payload")
r = c.get("/b/run1/a.bin")
assert r.status_code == 200
assert r.content == b"\x00\x01payload"
def test_missing_booth_404(client):
c, _ = client
assert c.get("/b/nope/").status_code == 404
def test_traversal_rejected(client):
c, data = client
(data / "run1").mkdir()
# a name with a slash or .. can never resolve to a direct child
assert c.get("/b/..%2f..%2fetc/").status_code == 404
assert c.get("/b/run1/../../../etc/passwd").status_code == 404
def test_delete_form_wipes(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.post("/b/run1/delete", follow_redirects=False)
assert r.status_code == 303
assert not (data / "run1").exists()
def test_delete_api_wipes(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.request("DELETE", "/b/run1")
assert r.status_code == 200
assert r.json() == {"wiped": "run1"}
assert not (data / "run1").exists()
def test_healthz(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.get("/healthz")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True and body["booths"] == 1