All four pre-date U4 and sit in files it touched, which is why a diff-scoped robustness lens saw them. They are separated from the unit's own commit so the feature history stays readable; the release tags both. * A booth name reached a JS string context. The confirm dialogs interpolated the name into a string literal inside `onsubmit`. Jinja's autoescape is HTML-attribute escaping, not JS-string escaping: the browser decodes the entity back to a quote before the JS parser sees it, so a name crafted to close the string executed on submit. Booth names are agent-authored — making a folder under the data dir is the whole API — so this was a live path, not a theoretical one. The name now travels as a data attribute to a delegated handler, where escaping is escaping. * An unreadable `links.md` returned 500 for the whole booth page. `is_file()` then an unguarded `read_text()`. The board is one tile on that page, and a page that will not load is worse than one missing a tile — the posture `read_blurred`, `marks_for` and `read_manifest` already take. * The index order had no tie-breaker, which violates the deterministic-order invariant. Equal-mtime booths fell back to whatever `iterdir()` yielded, and two booths landed by one `rsync` batch share an mtime exactly. Now `(mtime, name)` reverse: newest first, then name. The operator refers to cards positionally, so a sequence that moves between renders misfiles his judgment rather than crashing. * `/b/<n>/marks.json` reported damage as empty success. `booth marks` exits 3 on an unreadable file precisely so a caller can tell "not yet" from "broken"; the HTTP mirror — the only reader a remote session has — returned the same empty list for both. It now carries `error` and `detail`. The status stays 200 deliberately: reads are lenient here, and a pinned status code is a promise to remote clients this fix has no business breaking. Each has a regression test. 410 tests.
1528 lines
71 KiB
Python
1528 lines
71 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).
|
||
* LIFETIME IS DERIVED, not set by a boolean (U4). Three states, and `sweep_once`
|
||
is the only thing that honours the first two:
|
||
KEPT `.forever` present. Never swept, own lane at the top of the index.
|
||
Durable operator-facing boards — chiefly the standing link board,
|
||
whose whole purpose is to outlive the scrollback it replaces.
|
||
HELD an open pick in `.marks.json`, or marks that cannot be read at all.
|
||
A booth the operator still owes an answer to is not the sweeper's
|
||
to take, and one whose judgment we failed to READ is certainly not.
|
||
EPHEMERAL everything else: wiped TTL hours after the last activity. Age is the
|
||
*newest* mtime in the tree, so a booth lives while it is being
|
||
worked on and self-destructs once it stops.
|
||
* VIEWING IS ACTIVITY. A deliberate GET of a booth's own page writes VIEW_MARKER,
|
||
which the age rule already counts — if the operator is still looking at it, it
|
||
is still alive. Browsing the index is not a view, and neither is a session
|
||
polling `marks.json`: an agent must not be able to hold its own booth open.
|
||
* WHY DERIVED. `.forever` was the ONLY way to say three different things, and the
|
||
measurement showed it carrying all of them — 17 of 24 live booths on 2026-09-22
|
||
(70%, up from 54%), with three of the four booths awaiting an answer ALSO pinned
|
||
by hand. Only "this is durable" is what keep means. The other two are facts the
|
||
service already held and did not consult.
|
||
|
||
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 typing import Sequence
|
||
from urllib.parse import quote, unquote
|
||
|
||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||
from fastapi.responses import (
|
||
FileResponse,
|
||
HTMLResponse,
|
||
JSONResponse,
|
||
RedirectResponse,
|
||
Response,
|
||
)
|
||
from fastapi.templating import Jinja2Templates
|
||
from starlette.concurrency import run_in_threadpool
|
||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||
|
||
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"
|
||
|
||
# The item record lives in booth/items.py — ONE resolver every surface reads.
|
||
# These names are RE-EXPORTED rather than merely moved: 22 existing test sites
|
||
# import them from booth.app by name, and a silent drop would be found by a
|
||
# consumer instead of by us. `test_app_still_exports_the_moved_names` asserts it.
|
||
from booth.items import ( # noqa: E402,F401
|
||
AUDIO_EXTS,
|
||
BLUR_FILE,
|
||
CAPTION_MAX,
|
||
DOC_MAX_BYTES,
|
||
IMAGE_EXTS,
|
||
MARKDOWN_EXTS,
|
||
TEXT_EXTS,
|
||
VIDEO_EXTS,
|
||
Item,
|
||
booth_items,
|
||
classify,
|
||
doc_kind,
|
||
find_item,
|
||
image_chain,
|
||
read_blurred,
|
||
render_doc,
|
||
render_doc_body,
|
||
)
|
||
|
||
# Sentinel dotfile that exempts a booth from the TTL sweep. 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"
|
||
|
||
# Records the last deliberate look at a booth (U4). A dotfile for the same two
|
||
# reasons KEEP_MARKER is one — `booth_items` and `zip_booth` skip it, so it
|
||
# costs nothing in counts, galleries or zips — and NOT a `.lock` dotfile, so
|
||
# `_newest_mtime` COUNTS it and the existing age rule picks the view up with no
|
||
# new arithmetic. That is the whole integration: a view is one more thing in
|
||
# the tree, not a second term in the formula.
|
||
VIEW_MARKER = ".viewed"
|
||
|
||
# ⚠⚠ BLUR IS COSMETIC, NOT ACCESS CONTROL. The file is still served at its own
|
||
# URL, still in the zip, still on disk. This hides an item from a glance — a
|
||
# shoulder, a screen-share, a scroll past something you did not want to see
|
||
# full-size — and nothing more. The Booth has no auth by design; if a thing
|
||
# must not be seen by whoever can reach port 8090, it must not be in a booth.
|
||
# Anyone who reads this marker as protection has misread it.
|
||
|
||
|
||
def set_blurred(booth: Path, rel: str, on: bool) -> set[str]:
|
||
"""Add or remove one item from the blur set. Atomic replace, so a crash
|
||
mid-write cannot leave a half-file that read_blurred would parse as a
|
||
shorter — and therefore more revealing — set. Returns the new set."""
|
||
current = read_blurred(booth)
|
||
if on:
|
||
current.add(rel)
|
||
else:
|
||
current.discard(rel)
|
||
path = booth / BLUR_FILE
|
||
if not current:
|
||
path.unlink(missing_ok=True)
|
||
return current
|
||
tmp = path.with_suffix(".tmp")
|
||
tmp.write_text("".join(f"{r}\n" for r in sorted(current)))
|
||
tmp.replace(path)
|
||
return current
|
||
|
||
# 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,
|
||
valid_stem,
|
||
)
|
||
from booth.marks import ( # noqa: E402
|
||
MARKS_FILE,
|
||
Mark,
|
||
MarksCorrupt,
|
||
answer_pick,
|
||
as_dict,
|
||
declare_pick,
|
||
delete_mark,
|
||
import_legacy_asks,
|
||
hold_read,
|
||
marks_for,
|
||
marks_for_target,
|
||
open_marks,
|
||
set_flag,
|
||
write_note,
|
||
)
|
||
from booth.inline import ( # noqa: E402
|
||
form_id as ask_form_id,
|
||
has_placeholders,
|
||
place as place_asks,
|
||
)
|
||
from booth.manifest import ( # noqa: E402
|
||
MANIFEST_FILE,
|
||
SERVICE_HANDLE,
|
||
read_manifest,
|
||
write_manifest,
|
||
)
|
||
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 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 — OUR LOCKS EXCEPT.
|
||
|
||
A booth's age is how long since somebody touched it, and a lock sidecar is
|
||
machinery: `marks.py` and `links.py` each create one on the way into a
|
||
read-modify-write, including one that turns out to change nothing. Counting
|
||
it made reading-through-a-write-path look like activity, and a no-op mark
|
||
POST on a dead booth reset its clock.
|
||
|
||
The exclusion is `.<something>.lock` — a DOTfile, which is the Booth's own
|
||
namespace. An agent that posts a real artifact called `build.lock` still
|
||
gets its clock counted. Everything else counts too, dotfiles included,
|
||
because `.marks.json`, `.blurred` and `.pins` are the operator doing
|
||
something.
|
||
|
||
⚠ A STAT WE CANNOT DO READS AS *FRESH*, NEVER AS EPOCH-OLD. This function
|
||
feeds `is_expired`, which feeds `rmtree`. Returning 0.0 for a booth whose
|
||
own stat fails made it maximally ancient and therefore the FIRST thing the
|
||
sweeper takes — a permissions or ELOOP problem resolving to a deletion. The
|
||
bug-hunt panel found this as one of four paths into the same shape. Not
|
||
knowing a booth's age is a reason to leave it alone.
|
||
|
||
`FileNotFoundError` on an entry is the exception, and it stays a skip: a
|
||
dangling symlink and a file removed mid-scan both raise it, and neither is
|
||
a thing with an mtime worth counting. Any OTHER per-entry OSError means we
|
||
could not read something that IS there, so the age is unknowable and the
|
||
booth reads as fresh.
|
||
"""
|
||
now = time.time()
|
||
try:
|
||
newest = path.stat().st_mtime
|
||
except OSError:
|
||
return now
|
||
for p in path.rglob("*"):
|
||
if p.name.startswith(".") and p.name.endswith(".lock"):
|
||
continue
|
||
try:
|
||
m = p.stat().st_mtime
|
||
except FileNotFoundError:
|
||
continue # dangling symlink, or gone mid-scan
|
||
except OSError:
|
||
return now # cannot read it — cannot judge the age
|
||
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.
|
||
|
||
`lstat`, not `Path.exists()`, and an unreadable answer counts as KEPT. The
|
||
old form collapsed ELOOP and EACCES into False, so a kept booth whose
|
||
sentinel could not be stat'd became eligible for the sweep — a failed read
|
||
authorizing a delete, which is the shape the bug-hunt panel found four ways
|
||
into. `lstat` also means a `.forever` SYMLINK counts, dangling or not:
|
||
somebody put it there to mean keep.
|
||
"""
|
||
try:
|
||
(path / KEEP_MARKER).lstat()
|
||
return True
|
||
except FileNotFoundError:
|
||
return False
|
||
except OSError:
|
||
return True
|
||
|
||
|
||
def record_view(booth: Path) -> None:
|
||
"""Note that somebody deliberately looked at this booth (U4).
|
||
|
||
Touches VIEW_MARKER and lets `_newest_mtime` do the rest — a view enters
|
||
the age rule as a file in the tree, not as a new term in the arithmetic.
|
||
|
||
NEVER RAISES. A read-only mount, a booth owned by another uid, a full disk,
|
||
a booth deleted between the route's resolve and this call: every one of
|
||
those costs the timestamp, not the page. The same trade `_Locked.__enter__`
|
||
makes on its `os.utime`, and for the same stated reason — not recording the
|
||
look is a cost this service can absorb, not answering the request is not.
|
||
A booth whose view cannot be recorded simply ages on its content mtime,
|
||
which is what every booth did before this existed.
|
||
"""
|
||
# O_NOFOLLOW, not `Path.touch()`. `touch` on an existing symlink follows it,
|
||
# so a booth carrying a planted `.viewed -> /anywhere` turned EVERY page
|
||
# view into an mtime write at an arbitrary path under the service uid — and
|
||
# any fleet session can write into a booth, because making a folder is the
|
||
# whole API. Three of four bug-hunt arms found it independently. A symlink
|
||
# here now raises ELOOP into the swallow below: view-recording quietly stops
|
||
# for that booth, which is the right way to lose this argument.
|
||
#
|
||
# O_CREAT alone does not move the mtime of a file that already exists, so
|
||
# the utime is not decoration: the marker must read as NOW or the whole
|
||
# mechanism is a file nobody's clock looks at.
|
||
try:
|
||
fd = os.open(booth / VIEW_MARKER,
|
||
os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o644)
|
||
try:
|
||
os.utime(fd)
|
||
finally:
|
||
os.close(fd)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
HOLD_UNREADABLE = "unreadable"
|
||
HOLD_OPEN = "open"
|
||
|
||
|
||
def hold_reason(marks: Sequence[Mark], error: str | None) -> str | None:
|
||
"""WHY this booth must not be swept, or None if it may be. THE hold predicate.
|
||
|
||
Returns a reason rather than a bool so the surface that has to say why can
|
||
read it off the same value the sweeper acts on. A boolean plus a separate
|
||
error string is two representations of one state, and they drift.
|
||
|
||
PURE — it takes the result of a read and does none of its own, so the index
|
||
card and the sweeper cannot answer differently about the same booth. That is
|
||
U1's rule (one resolver, every surface reads the record) applied to lifetime.
|
||
|
||
FAIL-SAFE ON BOTH LEVELS OF DAMAGE, which is the correction the bug-hunt
|
||
panel forced (2026-09-22). `marks_for` is lenient because a review page that
|
||
will not load is worse than one missing an annotation — the right trade for
|
||
a RENDER and the wrong one for a DELETE, where the same leniency wipes the
|
||
booth whose judgment we had just failed to read, artifacts and all. The
|
||
first cut of this caught FILE-level damage only:
|
||
|
||
* file-level — `.marks.json` will not parse at all. `hold_read` reports it.
|
||
* ENTRY-level — the document parses, but one mark fails normalization and
|
||
`_hydrate_safe` hands back a `Mark` carrying `error`. `_is_open` returns
|
||
False for an errored pick, ON PURPOSE (a broken pick can never be
|
||
answered; the CLI spells that exit code 4) — so such a booth read as
|
||
`not held` and SWEPT, while the panel beside it rendered the broken mark
|
||
in full. Four arms found four ways into that shape; this was the worst.
|
||
|
||
A mark that cannot be read is judgment we cannot see. Deleting the booth it
|
||
belongs to is the one thing we must not do with it.
|
||
|
||
Openness itself is `open_marks` and nothing else (U2 INV-2): a partially
|
||
answered pick is STILL open and still holds, which is the reading that
|
||
makes this rule correct rather than one that sweeps a review in flight.
|
||
"""
|
||
if error is not None or any(m.error is not None for m in marks):
|
||
return HOLD_UNREADABLE
|
||
if open_marks(marks):
|
||
return HOLD_OPEN
|
||
return None
|
||
|
||
|
||
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.
|
||
|
||
TWO exemptions, and this is the only function that honours either.
|
||
|
||
A booth carrying KEEP_MARKER is exempt no matter how stale it is. That is
|
||
the explicit escape hatch, 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.
|
||
|
||
A booth that is HELD — an open pick, or marks we cannot read — is exempt for
|
||
as long as that holds (U4). This is the derived half: the operator was
|
||
pressing `.forever` to mean "not yet" because nothing else could say it, and
|
||
the service already knew. 17 of 24 live booths carried the sentinel on
|
||
2026-09-22, and three of the four booths in the fleet awaiting an answer
|
||
carried it too — the "not yet" case, caught in the act.
|
||
|
||
Reading the marks costs ONE strict read per booth per tick — `hold_read`,
|
||
which answers both halves of the hold question at once. It is deliberately
|
||
not two calls: two reads of one file are not one read of one state, and the
|
||
pair that loses that race is the pair that deletes. Do not "optimize" this
|
||
back into `marks_for` plus `read_error`.
|
||
"""
|
||
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 hold_reason(*hold_read(child)): # ONE read — see hold_read
|
||
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]:
|
||
"""One card per booth for the index.
|
||
|
||
Counts and the cover come from `booth_items`, so the index agrees with the
|
||
booth page about what an item IS. It did not before: this function counted
|
||
every non-dot, non-ask file, which meant a caption sidecar was counted as an
|
||
item here and (correctly) not counted there — an A/B pair with two captions
|
||
read as "4 items" on the index and showed two tiles when you opened it.
|
||
|
||
Doc bodies are deliberately NOT rendered — see `booth_items`. The index
|
||
touches every booth on every page load.
|
||
"""
|
||
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
|
||
items = booth_items(child)
|
||
# Marks are judgment, not items: counted separately so the index can
|
||
# flag a booth that is waiting on the operator. ONE file read per booth
|
||
# — which is why marks live in one file per booth rather than a sidecar
|
||
# per mark. This loop runs on every index page load.
|
||
# ONE read for BOTH the badge and the lifetime decision. It has to be
|
||
# one: `marks_for` is lenient, so an unreadable `.marks.json` reads as
|
||
# no marks — fine for a card, wrong for the reaper, which would then
|
||
# delete the booth whose judgment it had just failed to read. And
|
||
# asking the two questions with two reads is not one read of one state:
|
||
# a write landing between them yields `([], None)`, the pair that
|
||
# deletes. `hold_read` answers both from one read; the lenient reader
|
||
# comes back only on the error path, where leniency is the point.
|
||
held_marks, read_err = hold_read(child)
|
||
# The DECISION comes from that one read and nothing else. The lenient
|
||
# re-read below is for DISPLAY only — feeding it back into the predicate
|
||
# would rebuild the two-read seam this call exists to close.
|
||
hold = hold_reason(held_marks, read_err)
|
||
marks = held_marks if read_err is None else marks_for(child)
|
||
# The booth's own announcement — who posted it and why. One more small
|
||
# read per booth, beside the marks read already here, and `read_manifest`
|
||
# cannot raise for the same reason `marks_for` must not: this loop runs
|
||
# over EVERY booth on every index page load.
|
||
manifest = read_manifest(child)
|
||
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
|
||
thumb_url = None
|
||
thumb_blurred = False
|
||
for it in items:
|
||
kinds[it.kind] += 1
|
||
if it.kind == "image" and thumb_url is None:
|
||
thumb_url = it.url
|
||
# If the cover image is blurred inside the booth, blur it on the
|
||
# index too — otherwise the front page cheerfully displays the
|
||
# exact thing someone asked to hide. Read off the record now,
|
||
# rather than re-opening .blurred here.
|
||
thumb_blurred = it.blurred
|
||
mtime = _newest_mtime(child)
|
||
booths.append(
|
||
{
|
||
"name": child.name,
|
||
"name_url": quote(child.name, safe=""),
|
||
"manifest": manifest,
|
||
"count": len(items),
|
||
"kinds": kinds,
|
||
"thumb_url": thumb_url,
|
||
"thumb_blurred": thumb_blurred,
|
||
"has_index": (child / "index.html").is_file(),
|
||
"uploaded": (child / UPLOAD_MARKER).exists(),
|
||
"kept": is_kept(child),
|
||
"marks_total": len(marks),
|
||
# `open_marks` and nothing else (INV-2). The count this replaced
|
||
# tested `answer is None`, so a half-answered pick read as closed
|
||
# here while the panel beside it rendered `◐ partial`.
|
||
"marks_open": len(open_marks(marks)),
|
||
# U4: WHY this booth is or is not counting down. The card must
|
||
# never just stop the clock silently — `.forever` was at least
|
||
# visible as a lane, and an invisible rule would be worse than
|
||
# the boolean it replaces.
|
||
# WHY it is or is not counting down — the reason, not a bool
|
||
# beside a string that can disagree with it.
|
||
"hold": hold,
|
||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||
"mtime": mtime,
|
||
}
|
||
)
|
||
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
|
||
# booths ordered by whatever `iterdir()` yielded, which is not a rule — and
|
||
# invariant 6 is not "usually stable", it is a sentence you can write down.
|
||
# Two booths created by one `rsync` batch share an mtime exactly, and the
|
||
# operator refers to cards positionally.
|
||
booths.sort(key=lambda b: (b["mtime"], b["name"]), reverse=True)
|
||
return booths
|
||
|
||
|
||
def build_gallery(child: Path) -> list[dict]:
|
||
"""The gallery's render dicts — a thin adapter over `booth_items`.
|
||
|
||
The resolver owns every fact about an item; this only shapes them for the
|
||
template and pulls doc bodies for the one surface that inlines them. Kept as
|
||
a function (rather than inlined at the call site) because the existing test
|
||
suite reaches for it by name in nine places.
|
||
"""
|
||
out = []
|
||
for it in booth_items(child):
|
||
body = render_doc_body(child, it)
|
||
rendered, rendered_html = body if body is not None else (None, False)
|
||
out.append(
|
||
{
|
||
"name": it.rel,
|
||
"kind": it.kind,
|
||
"doc": it.doc,
|
||
"url": it.url,
|
||
"section": it.section,
|
||
"caption": it.caption,
|
||
"rendered": rendered,
|
||
"rendered_html": rendered_html,
|
||
"blurred": it.blurred,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
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 _form_text(form, key: str) -> str:
|
||
"""One form field as text, or "" for anything that is not text.
|
||
|
||
A multipart FILE part named `notes` parses to an UploadFile, not a str, and
|
||
every downstream cleaner calls `.replace` on what it is handed. Coercing
|
||
here keeps that decision in one place instead of one `isinstance` per call
|
||
site — which is how `/note` came to have the guard and `/answer` not to.
|
||
"""
|
||
value = form.get(key)
|
||
return value if isinstance(value, str) else ""
|
||
|
||
|
||
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 ARE CACHED AT STARTUP, DELIBERATELY — `auto_reload=False`.
|
||
#
|
||
# `booth.service` runs uvicorn with WorkingDirectory set to this repo, so the
|
||
# repo IS the deployment root: there is no build step and no staging copy.
|
||
# Jinja's default FileSystemLoader re-reads a template from disk on every
|
||
# render, while the Python stays as it was when the process started. That
|
||
# gives the two halves of the service different staleness rules, and editing
|
||
# a template deploys it INSTANTLY against Python that may know nothing about
|
||
# the context it wants.
|
||
#
|
||
# It cost an outage on 2026-09-21: 19 of 25 live booths returned 500 with
|
||
# `UndefinedError: 'item_marks' is undefined` — new markup, old context, both
|
||
# running at once, and neither version broken on its own. The Python had
|
||
# started at 22:03 and the templates were from 23:40.
|
||
#
|
||
# With reload off there is ONE rule — nothing takes effect until you restart
|
||
# — so the running process is always a coherent snapshot of one commit. The
|
||
# price is that template work needs a `systemctl --user restart
|
||
# booth.service` to see; that price is the whole point.
|
||
env = Environment(
|
||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||
autoescape=select_autoescape(["html", "xml"]),
|
||
auto_reload=False,
|
||
)
|
||
env.filters["dur"] = human_dur
|
||
templates = Jinja2Templates(env=env)
|
||
|
||
@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)
|
||
# The template environment, reachable for assertion: the snapshot property
|
||
# above is a promise about the DEPLOYED service, so it needs a test, and a
|
||
# test needs a handle on the env that the app actually renders with.
|
||
app.state.templates = templates
|
||
|
||
@app.exception_handler(MarksCorrupt)
|
||
async def _marks_corrupt(request: Request, exc: MarksCorrupt):
|
||
"""A write was refused because the booth's mark file is damaged.
|
||
|
||
409, not 500: the service is fine and the request was well-formed — the
|
||
state on disk is not, and the refusal is deliberate. Says what to do,
|
||
because the alternative the operator will otherwise reach for is
|
||
deleting the file, which is the thing being protected.
|
||
"""
|
||
return JSONResponse(
|
||
status_code=409,
|
||
content={
|
||
"error": "this booth's .marks.json cannot be read, so nothing was written",
|
||
"detail": str(exc),
|
||
"why": "writing would replace every mark in the booth with just this one",
|
||
"fix": "repair or move the file by hand; the marks panel still renders as empty",
|
||
},
|
||
)
|
||
|
||
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)
|
||
# U4: viewing is activity. ABOVE both early returns — the zip download
|
||
# and the verbatim-index.html branch are looks at this booth too, and a
|
||
# verbatim report is the shape the operator stares at longest.
|
||
record_view(booth)
|
||
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")
|
||
# 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.
|
||
gallery = [
|
||
it for it in build_gallery(booth)
|
||
if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
|
||
]
|
||
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||
hold = hold_reason(held_marks, read_err)
|
||
marks = held_marks if read_err is None else marks_for(booth)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"booth.html",
|
||
{
|
||
**base_ctx,
|
||
"name": name,
|
||
"name_url": quote(name, safe=""),
|
||
# The page could not previously tell keep from release, so it
|
||
# offered neither and you had to go back to the index.
|
||
"kept": is_kept(booth),
|
||
"items": gallery,
|
||
# 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.
|
||
# `is_file()` then an UNGUARDED read was a 500 waiting on a
|
||
# mode change or an EIO: the board is one tile on this page, and
|
||
# a page that will not load is worse than one missing a tile —
|
||
# the same posture `read_blurred`, `marks_for` and
|
||
# `read_manifest` already take. A booth whose `links.md` cannot
|
||
# be read renders as a booth with no board.
|
||
"board": _board_rows(booth),
|
||
# Marks: operator judgment attached to this booth or to one of
|
||
# its items — a session's question (`pick`), the operator's own
|
||
# remark (`note`), the operator's selection (`flag`). Rendered
|
||
# as the panel above the gallery, and per item on each tile.
|
||
# See booth/marks.py.
|
||
"marks": marks,
|
||
"marks_open": len(open_marks(marks)),
|
||
# Per-item marks, keyed by rel, so a tile reads its own judgment
|
||
# without every tile re-filtering the whole list.
|
||
"item_marks": {
|
||
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
|
||
},
|
||
"booth_marks": marks_for_target(marks, None),
|
||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||
# The same provenance line the index card carries. Deliberate:
|
||
# a booth URL handed to the operator lands HERE, never on the
|
||
# index, and job 5 is "operator, look at this".
|
||
"manifest": read_manifest(booth),
|
||
# The lifetime line, same three states as the index card: a
|
||
# booth URL handed to the operator lands HERE, not on the index,
|
||
# so "why is this not counting down" has to be answerable here.
|
||
"hold": hold,
|
||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
||
},
|
||
)
|
||
|
||
def _board_rows(booth: Path) -> list[dict]:
|
||
"""The link board's rows, or [] for a board that cannot be read.
|
||
|
||
NEVER RAISES, for the reason every other read on this page does not:
|
||
one damaged file must cost its own tile, not the booth page."""
|
||
try:
|
||
if not (booth / LINKS_FILE).is_file():
|
||
return []
|
||
return order_for_display(
|
||
parse_link_entries((booth / LINKS_FILE).read_text()),
|
||
read_pins(booth),
|
||
)
|
||
except (OSError, ValueError, UnicodeDecodeError):
|
||
return []
|
||
|
||
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
|
||
"""Land where the form was: the standalone marks page for a verbatim
|
||
booth (its own index.html cannot show the recorded judgment), else the
|
||
booth page, scrolled to the mark that was just written."""
|
||
base = f"/b/{quote(name, safe='')}/"
|
||
if form.get("back") == "marks":
|
||
base = f"/b/{quote(name, safe='')}/marks"
|
||
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)
|
||
|
||
@app.post("/b/{name}/answer")
|
||
async def booth_answer(request: Request, name: str):
|
||
"""Record the operator's pick — one of N options a session declared in
|
||
advance. Validates every choice against the declaration and rewrites
|
||
`.marks.json` atomically. Re-submitting overwrites: the mark is the
|
||
CURRENT judgment, not a log.
|
||
|
||
Form fields: `ask` (the mark id); single-question → `choice` + `notes`;
|
||
multi-question → `choice.<key>` per question, optional `notes.<key>`,
|
||
plus the form-level `notes`. 404 for an unknown id, 400 for a missing
|
||
choice or one the declaration does not offer.
|
||
|
||
Kept at `/answer` with an `ask` field rather than renamed: the inline
|
||
fragments a report author has already marked up POST here, and breaking
|
||
every landed verbatim report to tidy a URL is not a trade worth making.
|
||
"""
|
||
booth = resolve_booth(name)
|
||
form = await request.form()
|
||
mark_id = form.get("ask")
|
||
if not isinstance(mark_id, str) or not valid_stem(mark_id):
|
||
raise HTTPException(status_code=404, detail="no such pick")
|
||
spec = next((m for m in marks_for(booth) if m.id == mark_id and m.shape == "pick"), None)
|
||
if spec is None:
|
||
raise HTTPException(status_code=404, detail="no such pick")
|
||
if spec.error is not None:
|
||
raise HTTPException(status_code=400, detail=spec.error)
|
||
who = request.client.host if request.client else ""
|
||
# `notes` is whatever the form parser yielded. A multipart FILE part
|
||
# named `notes` is an UploadFile, and `_clean_notes` calls `.replace` on
|
||
# it — a 500 on hostile-but-legal input, where the sibling `/note` route
|
||
# returns 400 for exactly the same class of value. Same parser, same
|
||
# question, one answer.
|
||
notes = _form_text(form, "notes")
|
||
try:
|
||
if spec.multi:
|
||
choice = {q["key"]: _form_text(form, f"choice.{q['key']}")
|
||
for q in spec.questions}
|
||
qnotes = {q["key"]: _form_text(form, f"notes.{q['key']}")
|
||
for q in spec.questions}
|
||
await run_in_threadpool(answer_pick, booth, mark_id, choice, notes,
|
||
who=who, qnotes=qnotes)
|
||
else:
|
||
# `choice` through the same reader as `notes`. It was raw, so a
|
||
# multipart FILE part named `choice` reached the answer builder
|
||
# as an UploadFile — the asymmetry that had already been fixed
|
||
# once on the field beside it.
|
||
await run_in_threadpool(answer_pick, booth, mark_id,
|
||
_form_text(form, "choice"), notes, who=who)
|
||
except AskError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
|
||
|
||
@app.post("/b/{name}/note")
|
||
async def booth_note(request: Request, name: str):
|
||
"""Attach free text to one item, or to the booth itself.
|
||
|
||
The operator telling the session — a direction that had no mechanism at
|
||
all before marks, which is exactly why it was running through chat.
|
||
`target` empty or absent means the booth. 400 on empty text.
|
||
"""
|
||
booth = resolve_booth(name)
|
||
form = await request.form()
|
||
raw_target = form.get("target")
|
||
target = raw_target if isinstance(raw_target, str) and raw_target else None
|
||
text = form.get("text")
|
||
try:
|
||
mark = await run_in_threadpool(
|
||
write_note, booth, target, text if isinstance(text, str) else "",
|
||
who=request.client.host if request.client else "")
|
||
except AskError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}")
|
||
|
||
@app.post("/b/{name}/flag")
|
||
async def booth_flag(request: Request, name: str):
|
||
"""Flag or unflag one item — the operator pointing at the good ones.
|
||
|
||
The shape that makes a 270-image booth tractable, and the one that
|
||
closes the loop `golden-candidates` / `sindra-finalists` / the
|
||
`pancake-*` ladders were running through conversation.
|
||
"""
|
||
booth = resolve_booth(name)
|
||
form = await request.form()
|
||
target = form.get("target")
|
||
if not isinstance(target, str) or not target:
|
||
raise HTTPException(status_code=400, detail="a flag needs a target")
|
||
on = str(form.get("on", "1")) not in ("0", "", "false", "off")
|
||
try:
|
||
await run_in_threadpool(
|
||
set_flag, booth, target, on,
|
||
who=request.client.host if request.client else "")
|
||
except AskError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
|
||
|
||
@app.post("/b/{name}/unmark")
|
||
async def booth_unmark(request: Request, name: str):
|
||
"""Withdraw one mark — the operator's undo. Withdrawing a judgment is
|
||
his to do; nothing else here removes a mark."""
|
||
booth = resolve_booth(name)
|
||
form = await request.form()
|
||
mark_id = form.get("mark")
|
||
if not isinstance(mark_id, str) or not mark_id:
|
||
raise HTTPException(status_code=400, detail="which mark?")
|
||
await run_in_threadpool(delete_mark, booth, mark_id)
|
||
return _mark_redirect(name, form, "marks")
|
||
|
||
@app.post("/b/{name}/import-asks")
|
||
async def booth_import_asks(request: Request, name: str):
|
||
"""Import this booth's legacy `*.ask.json` sidecars into `.marks.json`.
|
||
|
||
Idempotent, and it deletes nothing — the sidecars stay on disk. Exposed
|
||
as a route as well as a CLI verb so a booth that predates marks can be
|
||
migrated from the page you are already looking at.
|
||
"""
|
||
booth = resolve_booth(name)
|
||
await run_in_threadpool(import_legacy_asks, booth)
|
||
form = await request.form()
|
||
return _mark_redirect(name, form, "marks")
|
||
|
||
_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.
|
||
"""
|
||
picks = [m for m in marks_for(booth) if m.shape == "pick"]
|
||
if not picks:
|
||
return html, ""
|
||
url = quote(name, safe="")
|
||
|
||
seen: set[str] = set()
|
||
|
||
def render(kind: str, mark, key: str | None) -> str:
|
||
fid = ask_form_id(mark.id)
|
||
if kind == "whole":
|
||
frag = str(_frag.whole(mark, fid, url))
|
||
elif kind == "submit":
|
||
frag = str(_frag.submit(mark, fid, url))
|
||
else:
|
||
q = next(q for q in mark.questions if q.get("key") == key)
|
||
frag = str(_frag.question(mark, q, fid, url))
|
||
# An anchor on the FIRST fragment of each pick, 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 mark.id not in seen:
|
||
seen.add(mark.id)
|
||
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
|
||
return frag
|
||
|
||
tail = [str(_frag.styles())]
|
||
if has_placeholders(html):
|
||
html, placed, submitted = place_asks(html, picks, render)
|
||
for m in picks:
|
||
keys = placed.get(m.id)
|
||
if keys is None:
|
||
tail.append(render("whole", m, None)) # unmarked: never dropped
|
||
continue
|
||
if m.error:
|
||
continue
|
||
if None not in keys:
|
||
# Partially marked up: append every question the author did
|
||
# NOT place. A multi-question pick needs all of them or the
|
||
# POST is a 400 — met only after the operator fills it in.
|
||
for q in m.questions:
|
||
if q.get("key") not in keys:
|
||
tail.append(render("question", m, q.get("key")))
|
||
if m.id not in submitted:
|
||
tail.append(render("submit", m, None)) # scattered but submittable
|
||
else:
|
||
for m in picks:
|
||
tail.append(render("whole", m, None))
|
||
|
||
# The chip is 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.
|
||
still_open = open_marks(picks) # INV-2: not re-derived here
|
||
if still_open:
|
||
tail.append(asks_chip(name, len(still_open),
|
||
href=f'#bk-ask-{still_open[0].id}-top'))
|
||
return html, "".join(tail)
|
||
|
||
@app.get("/b/{name}/marks", response_class=HTMLResponse)
|
||
def booth_marks_page(request: Request, name: str):
|
||
"""The marks panel on its own page. Reachable from any booth, and the ONLY
|
||
place a verbatim-index.html booth can show its marks — that page is served
|
||
untouched by design, so the inline panel never renders there."""
|
||
booth = resolve_booth(name)
|
||
# U4: for a verbatim booth this IS the booth page. `/b/<n>/asks` is a
|
||
# 308 into here, so the legacy URL records through this call and must
|
||
# not get one of its own.
|
||
record_view(booth)
|
||
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||
hold = hold_reason(held_marks, read_err)
|
||
marks = held_marks if read_err is None else marks_for(booth)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"marks.html",
|
||
{**base_ctx, "name": name, "name_url": quote(name, safe=""),
|
||
"marks": marks, "marks_open": len(open_marks(marks)),
|
||
"booth_marks": marks_for_target(marks, None), "marks_page": True,
|
||
# U4 INV-4, and this page is WHY the invariant needs a third home.
|
||
# A verbatim booth's own index.html is served untouched, so it has
|
||
# no Booth-rendered header to carry the lifetime line — this page
|
||
# is the only surface besides the index card where the Booth owns
|
||
# the chrome. Without it, the booths most likely to be held (a
|
||
# report that ASKS something is the archetype) would be the ones
|
||
# that never say they are.
|
||
"kept": is_kept(booth),
|
||
"hold": hold,
|
||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth))},
|
||
)
|
||
|
||
@app.get("/b/{name}/asks", include_in_schema=False)
|
||
def booth_asks_redirect(name: str):
|
||
"""`/asks` moved to `/marks` when asks became one shape of mark. A
|
||
redirect rather than a 404: the URL is in the operator's history and in
|
||
landed reports, and a dead link teaches nothing."""
|
||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/marks", status_code=308)
|
||
|
||
@app.get("/b/{name}/marks.json")
|
||
def booth_marks_json(name: str):
|
||
"""Every mark in the booth, as JSON — the READ path for a session that
|
||
is not on this host.
|
||
|
||
`booth marks` covers a session with filesystem access; a session on
|
||
another box rsyncs its work in and has only HTTP. Before marks it polled
|
||
`<stem>.answer.json` and waited for a 404 to become a 200, which is why
|
||
this endpoint has to exist: without it, moving picks out of per-question
|
||
sidecars would take that capability away. One request now answers for
|
||
the whole booth instead of one question at a time.
|
||
"""
|
||
booth = resolve_booth(name)
|
||
marks, read_err = hold_read(booth)
|
||
body = {
|
||
"booth": name,
|
||
"marks": [as_dict(m) for m in marks],
|
||
"open": [m.id for m in open_marks(marks)],
|
||
}
|
||
# A DAMAGED file used to come back as an empty list and nothing else,
|
||
# which is indistinguishable from "you were never asked anything" — and
|
||
# this endpoint is the ONLY reader a remote session has. Its filesystem
|
||
# sibling has told the truth since U2: `booth marks` exits 3 on an
|
||
# unreadable file precisely so a caller can tell "not yet" from
|
||
# "broken". One question, two surfaces, two answers.
|
||
#
|
||
# The STATUS stays 200 and that is deliberate. Reads are lenient here —
|
||
# the same rule that keeps a poisoned booth from 500ing the index — and
|
||
# a pinned status code is a promise to remote clients this fix has no
|
||
# business breaking. The information goes in the body instead: a client
|
||
# that wants the CLI's exit-3 parity reads `error`, and one that does
|
||
# not behaves exactly as it does today.
|
||
if read_err is not None:
|
||
body["error"] = "this booth's .marks.json cannot be read"
|
||
body["detail"] = read_err
|
||
return JSONResponse(body)
|
||
|
||
@app.get("/b/{name}/view", response_class=HTMLResponse)
|
||
def booth_view_file(request: Request, name: str, f: str):
|
||
"""Full-size view of ONE item — image zoom, or a doc as a readable page.
|
||
|
||
Reads the item's RECORD rather than re-deriving it. That is the whole
|
||
point of U1: this route used to call classify/doc_kind/render_doc and
|
||
booth_image_names itself, deriving a strictly smaller set of facts than
|
||
the gallery did, and the fact it lacked was the caption. An annotated
|
||
image lost its annotation at exactly the size where it is most readable.
|
||
"""
|
||
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")
|
||
|
||
items = booth_items(booth)
|
||
item = find_item(items, f)
|
||
# U4: a bookmarked zoom URL is somebody looking — but only once we know
|
||
# there is an ITEM to look at. Below the 404s, and gated on the record,
|
||
# because `f` is any path that stats inside the booth: the bug-hunt
|
||
# panel pointed `?f=.marks.lock` at this and held a booth open with a
|
||
# file the service created itself. A dotfile is not an item, and a view
|
||
# of a thing that is not an item is not a view of the booth.
|
||
if item is not None:
|
||
record_view(booth)
|
||
marks = marks_for(booth)
|
||
item_marks = marks_for_target(marks, f)
|
||
common = {
|
||
**base_ctx,
|
||
"name": name,
|
||
"name_url": quote(name, safe=""),
|
||
"file": f,
|
||
"file_url": quote(f, safe="/"),
|
||
# The facts this route never used to carry.
|
||
"caption": item.caption if item else None,
|
||
"section": item.section if item else None,
|
||
"blurred": item.blurred if item else False,
|
||
# INV-3, U1's rule extended from the caption to the judgment: the
|
||
# notes and the flag state travel to full size, which is the size at
|
||
# which the judgment is actually being made.
|
||
"marks": item_marks,
|
||
"flagged": any(m.shape == "flag" for m in item_marks),
|
||
}
|
||
|
||
if item is not None and item.kind == "image":
|
||
# prev/next ring (wraps; only when there is more than one image)
|
||
names = image_chain(items)
|
||
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
|
||
if item is not None:
|
||
body = render_doc_body(booth, item)
|
||
if body is not None:
|
||
rendered, is_html = body
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"doc.html",
|
||
{**common, "kind": item.doc, "body": rendered, "is_html": is_html},
|
||
)
|
||
|
||
# nothing to render — hand back the raw file
|
||
return RedirectResponse(
|
||
url=f"/b/{quote(name, safe='')}/{quote(f, safe='/')}", 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)
|
||
|
||
total = 0
|
||
# Both markers are belt-and-braces: `safe_upload_name` strips leading
|
||
# dots, so an uploaded file can never be named either of them. Listed
|
||
# anyway so the set says what the directory already contains.
|
||
used: set = {UPLOAD_MARKER, MANIFEST_FILE}
|
||
try:
|
||
(dest / UPLOAD_MARKER).write_text("") # dotfile, not listed
|
||
# A booth the SERVICE made says so, rather than being exempted from
|
||
# the unannounced marker. INSIDE the guard, with the marker: both
|
||
# sat above it, so a failure here left a half-booth on disk with no
|
||
# files in it — and the manifest's unique temp name meant a leaked
|
||
# `.booth.json.<hex>.tmp` was never overwritten, was not a `.lock`,
|
||
# and so kept that empty booth alive past every sweep. Found 4/4.
|
||
write_manifest(dest, SERVICE_HANDLE, title=booth_id,
|
||
why="browser upload, for pickup")
|
||
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)
|
||
|
||
def _safe_next(nxt: str) -> str:
|
||
"""Where to land after keep/unkeep. Defaults to the index; a booth page
|
||
can ask to stay put. Only same-site absolute paths are honoured — `//`
|
||
and any scheme are refused, because a redirect target taken from a form
|
||
field is an open redirect if you do not check it."""
|
||
if nxt.startswith("/") and not nxt.startswith("//") and "\\" not in nxt:
|
||
return nxt
|
||
return "/"
|
||
|
||
@app.post("/b/{name}/keep")
|
||
def booth_keep(name: str, next: str = Form("/")):
|
||
(resolve_booth(name) / KEEP_MARKER).touch()
|
||
return RedirectResponse(url=_safe_next(next), status_code=303)
|
||
|
||
@app.post("/b/{name}/unkeep")
|
||
def booth_unkeep(name: str, next: str = Form("/")):
|
||
# missing_ok: releasing an already-released board is a no-op, not a 500.
|
||
booth = resolve_booth(name)
|
||
marker = booth / KEEP_MARKER
|
||
try:
|
||
marker.unlink()
|
||
released = True
|
||
except FileNotFoundError:
|
||
released = False # already released: a no-op, not a 500
|
||
except OSError:
|
||
# A `.forever` that is a DIRECTORY raised IsADirectoryError straight
|
||
# through this route and 500'd it, which made the card's release
|
||
# button permanently dead for that booth. Pre-existing; the panel
|
||
# re-exposed it. Removing it is still best-effort, and failing to is
|
||
# not worth refusing the request over.
|
||
released = False
|
||
if not released:
|
||
return RedirectResponse(url=_safe_next(next), status_code=303)
|
||
# U4: RELEASE IS ACTIVITY, and now it is a rule rather than an accident.
|
||
# A released board already survived another full TTL, because unlinking
|
||
# a file bumps the directory's mtime — behaviour the note above calls
|
||
# "not intuitive" precisely because nothing declared it. The behaviour
|
||
# is unchanged; its reason is now stated. Releasing a board is somebody
|
||
# touching it, so it gets one full TTL, the same as any other look.
|
||
#
|
||
# ONLY when something was actually released, which is the correction the
|
||
# bug-hunt panel forced: an unconditional call made POSTing release at
|
||
# an already-released booth an endless TTL refresh, contradicting this
|
||
# route's own no-op promise and diverging from the CLI, which `rm`s the
|
||
# sentinel without recording anything.
|
||
record_view(booth)
|
||
return RedirectResponse(url=_safe_next(next), status_code=303)
|
||
|
||
@app.post("/b/{name}/blur")
|
||
def booth_blur(name: str, f: str = Form(...), on: str = Form("1")):
|
||
"""Toggle one item's blur. Reversible and cosmetic, so no confirmation.
|
||
See BLUR_FILE: this hides an item from a glance, it does not protect it."""
|
||
booth = resolve_booth(name)
|
||
# Guard the path the same way the file route must: a blur entry is only
|
||
# ever a booth-relative path, never an escape.
|
||
rel = f.strip().lstrip("/")
|
||
if ".." in Path(rel).parts:
|
||
raise HTTPException(status_code=400, detail="bad item path")
|
||
set_blurred(booth, rel, on not in ("0", "false", ""))
|
||
return RedirectResponse(url=f"/b/{quote(name, 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()
|