Files
booth/booth/app.py
T
vh 95228299bc feat(booth): kept boards — a .forever sentinel and a standing link board
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.
2026-08-19 09:34:53 -07:00

695 lines
28 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 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()