Agent sessions hand the operator URLs and they drown in terminal scrollback. The Booth is the right home for them — it already has the one property that decides adoption, which is that a session can publish with mkdir and cp, no API key, no schema, no deploy — but everything in it dies in 24h. So: a booth containing `.forever` is never swept, and renders in its own Kept lane at the top of the index. Opt-in per booth, so the ephemeral default is untouched and nobody inherits a cleanup chore. `rm` the sentinel and the board rejoins the sweep; the CLI verbs are sugar over exactly that, which keeps the filesystem-is-the-state model honest. The pin is deliberately NOT wired into is_expired(). That stays a pure age question feeding the `expires_in` countdown; only sweep_once() honours the sentinel. Keeping expiry arithmetic and reaper policy apart means they cannot drift into each other. Kept cards are visually separated per Australis: a 2px top edge in aurora blue, the one accent border the system sanctions. They show "kept" instead of a countdown, and they deliberately lose the one-click wipe button — a × next to the durable stuff is a footgun, so removing a kept board is a two-step act. `booth link <url> [description]` appends to the standing `links` board, creating and keeping it on first use. Entries carry provenance (handle or hostname, plus a timestamp) because a bare URL is unreadable three days later. The append is one printf of one line to an O_APPEND fd — atomic under PIPE_BUF on POSIX — which matters because many agents post to one board and interleaved half-lines would be the obvious failure mode. Seven tests cover the sentinel: detection, survival of a sweep that wipes its neighbour, the deliberate is_expired/sweep_once split, the listing flag, the sentinel not inflating item counts, and both lane-rendering directions. Two of them originally asserted on the bare strings "Kept" and "kept-grid", which passed for the wrong reason — those also appear in the inlined stylesheet served on every page — so they now assert the full class attribute. 55 pass. Also corrects the Homepage card's description, which advertised a flat 24h TTL that is no longer the whole story.
695 lines
28 KiB
Python
695 lines
28 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 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, 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"
|
||
|
||
|
||
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(".")]
|
||
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),
|
||
"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),
|
||
"doc": doc_kind(p.name),
|
||
"url": quote(rel, safe="/"),
|
||
"caption": caption.get(rel),
|
||
}
|
||
)
|
||
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>"
|
||
)
|
||
|
||
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) -> 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
|
||
|
||
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
|
||
html, done = _insert_before(html, pat, _BACK_CHIP)
|
||
if done:
|
||
break
|
||
else:
|
||
html = html + _BACK_CHIP # 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:
|
||
return HTMLResponse(
|
||
wrap_verbatim_html(own_index.read_text(encoding="utf-8", errors="replace"))
|
||
)
|
||
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=""),
|
||
"items": build_gallery(booth),
|
||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
||
},
|
||
)
|
||
|
||
@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)
|
||
|
||
@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()
|