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
991 lines
42 KiB
Python
991 lines
42 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.
|
||
* 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()
|