Files
booth/booth/app.py
T
vh 55c0655126 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).
2026-07-20 10:17:40 -07:00

327 lines
11 KiB
Python

"""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()