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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
booth-data/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""The Booth — ephemeral media drop board. See booth.app for the server."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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 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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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"]
|
||||
Executable
+47
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user