Files
esh-pfi-infrastructure/services/booth/booth/app.py
T
vh 78c3a7c170 feat(booth): asks render INLINE in a verbatim report, placed by the author
Operator verdict on the separate /asks page: the question belongs with the
artifact it is about. A four-voice audition wants each voice's radio group
under that voice's audio, and one submit for the lot.

- booth/inline.py: data-booth-ask="stem" | "stem:key" | data-booth-ask-submit,
  plus <!-- booth:ask ... --> comments; unknown stem left alone, not blanked
- _ask_inline.html: self-contained fragments (own scoped styles, no JS), per-question
  groups bound to one form via the HTML5 form= attribute so a scattered
  multi-question ask still POSTs once
- unplaced questions and a missing submit block are appended, so a partially
  marked-up page can never produce an unsubmittable 400
- chip becomes a jump link to the first open ask; /asks page kept as a fallback
- 6 tests (one caught the partial-placement drop); v0.1.14
2026-09-09 14:16:03 -07:00

991 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.
* KEPT BOOTHS: a booth containing the KEEP_MARKER dotfile (`.forever`) is exempt
from the sweep and renders in its own lane above the ephemeral grid. That is the
home for durable operator-facing boards — chiefly the standing link board agent
sessions post to, whose whole purpose is to survive longer than the scrollback
it replaces. Opt-in per booth, so the ephemeral default is unchanged and nobody
inherits a cleanup chore; `rm` the sentinel and the booth rejoins the sweep.
State is the filesystem — `ls ~/booth-data` tells you everything. That is the whole point.
"""
from __future__ import annotations
import asyncio
import fcntl
import hashlib
import io
import os
import re
import secrets
import shutil
import time
import zipfile
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
RedirectResponse,
Response,
)
from fastapi.templating import Jinja2Templates
try:
import markdown as _markdown
except ImportError: # optional dep — .md then degrades to a plain-text view
_markdown = None
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
# Loose text docs that render as a readable in-booth page (not a download).
MARKDOWN_EXTS = {".md", ".markdown", ".mdown"}
TEXT_EXTS = {".txt", ".text", ".log"}
DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered
# Sentinel dotfile that exempts a booth from the TTL sweep — see the "kept
# booths" note in the module docstring. A dotfile because the existing listing
# code already skips dotfiles, so it costs nothing in item counts or galleries,
# and because `touch`/`rm` is the entire user interface: no flag to remember, no
# state anywhere but the filesystem.
KEEP_MARKER = ".forever"
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app.
from booth.asks import ( # noqa: E402
ANSWER_SUFFIX,
ASK_SUFFIX,
AskError,
is_answer_file,
is_ask_file,
list_asks,
load_ask,
valid_stem,
write_answer,
)
from booth.inline import ( # noqa: E402
form_id as ask_form_id,
has_placeholders,
place as place_asks,
)
from booth.links import ( # noqa: E402
LINK_LOCK,
LINKS_FILE,
PINS_FILE,
link_entry_id,
order_for_display,
parse_link_entries,
read_pins,
remove_link_entry,
toggle_pin,
)
def doc_kind(name: str) -> str | None:
"""'markdown' | 'text' | None — a booth file viewable as a readable page."""
ext = Path(name).suffix.lower()
if ext in MARKDOWN_EXTS:
return "markdown"
if ext in TEXT_EXTS:
return "text"
return None
def render_doc(text: str, kind: str) -> tuple[str, bool]:
"""(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists);
plain text — or markdown when the lib is unavailable — → raw text for <pre>."""
if kind == "markdown" and _markdown is not None:
html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
return html, True
return text, False
def booth_image_names(child: Path) -> list[str]:
"""Image files in a booth, in gallery (sorted-rel) order — for viewer prev/next."""
return sorted(
p.relative_to(child).as_posix()
for p in child.rglob("*")
if p.is_file() and not p.name.startswith(".") and classify(p.name) == "image"
)
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:
"""Pure age question. Deliberately does NOT consider the keep sentinel.
Expiry arithmetic (what `expires_in` renders) and reaper policy (what
actually gets deleted) are kept apart so they cannot drift into each other.
Only `sweep_once` honours the pin.
"""
return booth_age_seconds(path, now) > ttl_seconds
def is_kept(path: Path) -> bool:
"""True if this booth carries the keep sentinel and must never be swept."""
return (path / KEEP_MARKER).exists()
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.
A booth carrying KEEP_MARKER is exempt no matter how stale it is. That is
the one escape hatch from the 24h contract, and it is opt-in per booth: the
default stays ephemeral, so nobody inherits a cleanup chore they did not ask
for. Removing the sentinel hands the booth straight back to the sweeper.
"""
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_kept(child):
continue
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(".")
and not is_ask_file(p.name) and not is_answer_file(p.name)
]
# Asks are questions, not items: counted separately so the index can
# flag a booth that is waiting on the operator.
asks = list_asks(child)
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(),
"uploaded": (child / UPLOAD_MARKER).exists(),
"kept": is_kept(child),
"asks_total": len(asks),
"asks_open": sum(1 for a in asks if a["answer"] is None and not a["error"]),
"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(".")
# `*.ask.json` / `*.answer.json` render as the asks panel, not as tiles
and not is_ask_file(p.name) and not is_answer_file(p.name)
]
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]
dkind = doc_kind(p.name)
rendered = None
rendered_html = False
# Pre-render docs so the gallery can show them INLINE (collapsible)
# instead of linking out to a separate page. Bounded by DOC_MAX_BYTES:
# a giant log stays a download link rather than being inlined into every
# index render. Markdown → HTML (marked safe in the template); plain text
# is returned RAW and the template escapes it inside <pre> — pre-escaping
# here would double-encode under Jinja autoescape.
if dkind is not None:
try:
if p.stat().st_size <= DOC_MAX_BYTES:
text = p.read_text(errors="replace")
rendered, rendered_html = render_doc(text, dkind)
except OSError:
rendered = None
items.append(
{
"name": rel,
"kind": classify(p.name),
"doc": dkind,
"url": quote(rel, safe="/"),
"caption": caption.get(rel),
"rendered": rendered,
"rendered_html": rendered_html,
}
)
return items
def zip_booth(booth: Path) -> bytes:
"""Zip a booth's whole tree (dotfiles excluded) into an in-memory archive.
Lets a booth be downloaded as one artifact regardless of shape — the case a
verbatim `index.html` booth (e.g. a rendered brief + its assets) has no
per-file download affordance for, since the page is served raw.
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for p in sorted(booth.rglob("*")):
if p.is_file() and not p.name.startswith("."):
zf.write(p, p.relative_to(booth).as_posix())
return buf.getvalue()
def _zip_filename(name: str) -> str:
"""A Content-Disposition-safe `<booth>.zip` (strip quotes/control chars)."""
safe = "".join(c for c in name if c.isprintable() and c != '"')
return f"{safe or 'booth'}.zip"
# ---- verbatim-index.html wrapper -------------------------------------------
# Mirror of base.html's favicon (the app templates set it there; this is the copy
# injected into a booth's *verbatim* index.html so a raw page inherits the same
# icon). Keep the two in sync if the Booth's icon ever changes.
FAVICON_HREF = (
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
"%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
)
FAVICON_LINK = f'<link rel="icon" href="{FAVICON_HREF}">'
# A self-contained floating "back to all booths" chip injected into verbatim
# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
# page without touching its layout; hidden in print so downloaded reports stay clean.
_BACK_CHIP = (
'<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
# top-right: empty on left-aligned report layouts (a top-left chip clips the
# page title), and consistent with the zoom view's top-right back affordance.
"<style>.booth-nav-home{position:fixed;top:0;right:0;z-index:2147483647;"
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
"font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
"color:#dfe7ef;text-decoration:none;letter-spacing:.01em;"
"background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);border-radius:8px;"
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);"
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:background .18s,border-color .18s}"
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}"
"@media print{.booth-nav-home{display:none}}</style>"
)
# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
# the auto-gallery template — can never appear on it. Without this chip an ask
# posted into a custom-report booth is INVISIBLE to the operator with nothing to
# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
# showed nothing). Same injection mechanism as the back chip; it links to the
# standalone /asks page, which renders the real forms.
def asks_chip(name: str, open_count: int, href: str | None = None) -> str:
if open_count < 1:
return ""
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
href = href or f"/b/{quote(name, safe='')}/asks"
return (
f'<a href="{href}" class="booth-nav-asks">{label}</a>'
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
"font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
"color:#171a23;text-decoration:none;letter-spacing:.01em;"
"background:#ffe14e;border:1px solid #ffe14e;border-radius:8px;"
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:filter .18s}"
".booth-nav-asks:hover{filter:brightness(1.08)}"
"@media print{.booth-nav-asks{display:none}}</style>"
)
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
_HTML_CLOSE_RE = re.compile(r"</html\s*>", re.IGNORECASE)
def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
m = pattern.search(html)
if m:
return html[: m.start()] + snippet + html[m.start() :], True
return html, False
def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
m = pattern.search(html)
if m:
return html[: m.end()] + snippet + html[m.end() :], True
return html, False
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK, extra: str = "") -> str:
"""Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
declares none — into a booth's verbatim index.html, without altering the page's
rendered content.
Robust to the compact HTML real booths use (`<!doctype html><meta charset><title>
<style>…content`, no explicit head/body). The two hard constraints:
* NEVER put anything ahead of a leading <!doctype> — that forces quirks mode.
* Keep the charset <meta> within the first 1024 bytes so it's still honoured.
So the favicon lands at the first head-ish seam (before </head>, else after
<html>, else right after the doctype — a ~250B link keeps charset in range), and
the fixed-position chip is appended at the END of the document (before </body> /
</html> or appended), which renders top-left regardless and disturbs nothing.
"""
if favicon_link and not _ICON_RE.search(html):
for inserter, pat in (
(_insert_before, _HEAD_CLOSE_RE), # inside an explicit <head>
(_insert_after, _HTML_OPEN_RE), # top of an explicit <html>
(_insert_after, _DOCTYPE_RE), # right after the doctype (compact HTML)
):
html, done = inserter(html, pat, favicon_link)
if done:
break
else:
html = favicon_link + html # bare fragment, no doctype: safe to prepend
chips = _BACK_CHIP + (extra or "")
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
html, done = _insert_before(html, pat, chips)
if done:
break
else:
html = html + chips # no </body>/</html>: append to the end
return html
# ---- 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
@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),
"keep_marker": KEEP_MARKER, # shown in the kept lane so the mechanism is discoverable
}
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):
# Two lanes, split here rather than in the template: kept boards are a
# different KIND of thing from the ephemeral churn — durable, deliberate,
# operator-facing — and burying them in a feed that turns over daily is
# exactly how they would get lost, which is the problem they exist to
# solve. Kept renders first.
everything = list_booths(data_dir, ttl_seconds)
return templates.TemplateResponse(
request,
"index.html",
{
**base_ctx,
"kept": [b for b in everything if b["kept"]],
"booths": [b for b in everything if not b["kept"]],
},
)
@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, download: int = 0):
booth = resolve_booth(name)
if download:
# whole-booth zip — the download path for a verbatim index.html booth
# (which has no gallery/per-file chrome), and a "download all" for any.
return Response(
content=zip_booth(booth),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{_zip_filename(name)}"'},
)
own_index = booth / "index.html"
if own_index.is_file():
# Serve the operator's verbatim report, but inject a floating
# back-to-booths chip + the Booth favicon (if it declares none) so a
# raw page still has a way home. Small HTML -> read + wrap in memory;
# a pathological large file falls back to serving raw, unwrapped.
try:
if own_index.stat().st_size <= WRAP_MAX_BYTES:
raw = own_index.read_text(encoding="utf-8", errors="replace")
# Asks render INLINE, where the report author put them (or
# appended, if they marked nothing) — a question about an
# artifact belongs beside that artifact, not on another page.
body, tail = inject_asks(name, booth, raw)
return HTMLResponse(wrap_verbatim_html(body, extra=tail))
except OSError:
pass
return FileResponse(str(own_index), media_type="text/html")
return templates.TemplateResponse(
request,
"booth.html",
{
**base_ctx,
"name": name,
"name_url": quote(name, safe=""),
# links.md is rendered AS the board below, so it must not also
# appear as a markdown doc tile — that would show the same
# content twice, once interactive and once not.
"items": [
it for it in build_gallery(booth)
if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
],
# A booth carrying links.md is the standing link board: render
# its rows as real UI (link, provenance, pin, per-row + bulk
# remove) instead of a markdown blob you can only edit by hand.
# Ordered pinned-first then newest-first, each row stamped with a
# `pinned` flag. Empty list for every other booth, so the template
# branch simply does not fire.
"board": (
order_for_display(
parse_link_entries((booth / LINKS_FILE).read_text()),
read_pins(booth),
)
if (booth / LINKS_FILE).is_file() else []
),
# Asks: multiple-choice questions a session left for the
# operator, rendered as forms above the gallery (open ones)
# or as their recorded answer. See booth/asks.py.
"asks": list_asks(booth),
"uploaded": (booth / UPLOAD_MARKER).exists(),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
},
)
@app.post("/b/{name}/answer")
async def booth_answer(request: Request, name: str):
"""Record the operator's answer to one ask: validates every choice
against the ask and writes `<stem>.answer.json` atomically.
Re-submitting overwrites — the sidecar is the current answer.
Form fields: `ask` (stem); single-question → `choice` + `notes`;
multi-question → `choice.<key>` per question, optional `notes.<key>`,
plus the form-level `notes`. 404 for an unknown/invalid stem, 400 for
a missing choice or one the ask does not offer.
"""
booth = resolve_booth(name)
form = await request.form()
ask = form.get("ask")
if not isinstance(ask, str) or not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file():
raise HTTPException(status_code=404, detail="no such ask")
who = request.client.host if request.client else ""
try:
spec = load_ask(booth, ask)
if spec["multi"]:
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec["questions"]}
qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec["questions"]}
write_answer(booth, ask, choice, form.get("notes", ""), who=who, qnotes=qnotes)
else:
write_answer(booth, ask, form.get("choice"), form.get("notes", ""), who=who)
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# Land where the form was: the standalone /asks page for a verbatim booth
# (its own index.html cannot show the recorded answer), else the booth.
base = f"/b/{quote(name, safe='')}/"
if form.get("back") == "asks":
base = f"/b/{quote(name, safe='')}/asks"
return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303)
_frag = templates.env.get_template("_ask_inline.html").module
def inject_asks(name: str, booth: Path, html: str) -> tuple[str, str]:
"""(body, tail) for a verbatim booth: placeholders substituted in place,
and whatever still has to be appended before </body>.
Marked-up pages get each fragment exactly where the author put it. An
unmarked page gets the whole ask appended — an ask is NEVER invisible,
which is the guarantee; markup only moves it somewhere better. A stem
whose questions were placed but whose submit block was not gets that
block appended, so a scattered form is always submittable.
"""
asks = list_asks(booth)
if not asks:
return html, ""
url = quote(name, safe="")
seen: set[str] = set()
def render(kind: str, ask: dict, key: str | None) -> str:
fid = ask_form_id(ask["stem"])
if kind == "whole":
frag = str(_frag.whole(ask, fid, url))
elif kind == "submit":
frag = str(_frag.submit(ask, fid, url))
else:
q = next(q for q in ask["questions"] if q.get("key") == key)
frag = str(_frag.question(ask, q, fid, url))
# An anchor on the FIRST fragment of each stem, wherever it landed,
# so the floating chip can jump to it on a long report. Computed
# here rather than in the macros because only the caller knows
# which fragment came first.
if ask["stem"] not in seen:
seen.add(ask["stem"])
frag = f'<a id="bk-ask-{ask["stem"]}-top"></a>' + frag
return frag
tail = [str(_frag.styles())]
if has_placeholders(html):
html, placed, submitted = place_asks(html, asks, render)
for a in asks:
keys = placed.get(a["stem"])
if keys is None:
tail.append(render("whole", a, None)) # unmarked: never dropped
continue
if a["error"]:
continue
if None not in keys:
# Partially marked up: append every question the author did
# NOT place. A multi-question ask needs all of them or the
# POST is a 400 — met only after the operator fills it in.
for q in a["questions"]:
if q.get("key") not in keys:
tail.append(render("question", a, q.get("key")))
if a["stem"] not in submitted:
tail.append(render("submit", a, None)) # scattered but submittable
else:
for a in asks:
tail.append(render("whole", a, None))
# The chip is now a JUMP LINK to the inline block, not a way out to a
# separate page: on a long report the question can be well below the
# fold, and "there is a question waiting" still has to be visible at
# first paint.
first_open = next((a for a in asks if a["answer"] is None and not a["error"]), None)
open_n = sum(1 for a in asks if a["answer"] is None and not a["error"])
if first_open is not None:
tail.append(asks_chip(name, open_n, href=f'#bk-ask-{first_open["stem"]}-top'))
return html, "".join(tail)
@app.get("/b/{name}/asks", response_class=HTMLResponse)
def booth_asks_page(request: Request, name: str):
"""The asks panel on its own page. Reachable from any booth, and the ONLY
place a verbatim-index.html booth can show its asks — that page is served
untouched by design, so the inline panel never renders there."""
booth = resolve_booth(name)
return templates.TemplateResponse(
request,
"asks.html",
{**base_ctx, "name": name, "name_url": quote(name, safe=""),
"asks": list_asks(booth), "asks_page": True},
)
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str):
booth = resolve_booth(name)
try:
target = (booth / f).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")
file_url = quote(f, safe="/")
common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
if classify(target.name) == "image":
# prev/next image nav (wraps around; only when >1 image in the booth)
names = booth_image_names(booth)
prev_url = next_url = None
if f in names and len(names) > 1:
i = names.index(f)
prev_url = quote(names[(i - 1) % len(names)], safe="/")
next_url = quote(names[(i + 1) % len(names)], safe="/")
return templates.TemplateResponse(
request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
)
# .md renders, .txt/.log show as text — viewable in-booth, no download
dk = doc_kind(target.name)
if dk:
try:
if target.stat().st_size <= DOC_MAX_BYTES:
body, is_html = render_doc(target.read_text(encoding="utf-8", errors="replace"), dk)
return templates.TemplateResponse(
request, "doc.html", {**common, "kind": dk, "body": body, "is_html": is_html}
)
except OSError:
raise HTTPException(status_code=404, detail="no such file")
# nothing to render — hand back the raw file
return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
@app.get("/b/{name}/{filepath:path}")
def booth_file(name: str, filepath: str, dl: int = 0):
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")
# ?dl=1 forces a download (Content-Disposition: attachment) instead of the
# browser rendering inline — the fix for html/md/text that otherwise opens
# in-page with no easy "save".
if dl:
return FileResponse(str(target), filename=target.name)
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)
# Releasing a kept board. The kept lane has no wipe control on purpose —
# destroying a durable board should not be one misclick — but "deliberate"
# had been built as "impossible from the UI": the only ways out were ssh or
# a hand-written API call. These two routes make the release step reachable
# while keeping deletion two deliberate acts (release, then wipe).
#
# NOTE ON THE TTL, which is not intuitive: removing the sentinel BUMPS the
# booth directory's mtime, and booth_age_seconds reads the newest mtime in
# the tree — so a released board's clock resets to zero and it survives
# another full TTL. "Unkeep and let the sweeper take it" therefore does NOT
# delete promptly. Release is the step that makes the × available; the ×
# is what deletes. Anything relying on release-then-sweep is relying on a
# 24h delay it probably did not intend.
@app.post("/b/{name}/unlink")
def board_unlink(name: str, entry: str = Form(...)):
"""Remove ONE row from a link board, by content id.
Deliberately not by index: the board is append-only and multi-writer,
so between rendering the page and clicking × another session may have
posted. A content id either matches the row the operator saw or matches
nothing — it can never resolve to a neighbour.
"""
removed = remove_link_entry(resolve_booth(name), entry)
if removed is None:
# Already gone (double-click, stale tab, someone else pruned it).
# Not an error worth a 404 page — the desired end state holds.
pass
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/unlink-many")
def board_unlink_many(name: str, sel: list[str] = Form(default=[])):
"""Remove SEVERAL rows in one go — the multi-select delete.
Each `sel` is a content id (same identity the per-row × uses), so the same
race-safety holds: an id either matches the row the operator selected or
matches nothing, never a neighbour that another session appended in the
meantime. An empty selection is a no-op, not an error.
"""
board = resolve_booth(name)
for entry_id in sel:
remove_link_entry(board, entry_id)
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/pin")
def board_pin(name: str, entry: str = Form(...)):
"""Toggle a row's pinned (favorite) state, by content id. Pinned rows
float to the top of the board; toggling again unpins. Reversible, so no
confirmation — unlike removal."""
toggle_pin(resolve_booth(name), entry)
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/keep")
def booth_keep(name: str):
(resolve_booth(name) / KEEP_MARKER).touch()
return RedirectResponse(url="/", status_code=303)
@app.post("/b/{name}/unkeep")
def booth_unkeep(name: str):
# missing_ok: releasing an already-released board is a no-op, not a 500.
(resolve_booth(name) / KEEP_MARKER).unlink(missing_ok=True)
return RedirectResponse(url="/", status_code=303)
@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)
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()