In-place client (base.html): - Saves are serialized: POST, re-fetch and swap complete before the next save starts, so an older snapshot can no longer land after a newer one. - A form already queued or in flight ignores another submit; a double-click writes one note. - Dirty controls (drafts, unsent radio choices) and disclosures carry by identity (form action + hidden ask/target/mark/f + name), not position. - Any non-tile structural difference, or a page with no region to swap, reloads instead of patching. Server and templates: - .seen is a JSON array read without following links or blocking, regular files of at most 1 MiB only; malformed, nested-too-deep or planted markers read as nothing seen. - landed_at reads symlinks by lstat and skips one unreadable entry instead of pinning the booth in "new". - The Desk counts flags on current items only; orphan flags are listed under the tray with an unmark form. - Agent-written bench and bookmark URLs link only when http(s). - Audio and video tiles carry a review link. - A rel the filesystem cannot represent is a 404, not a 500. - A non-finite Accept q-value fails to parse. - The standalone marks page has regions and updates in place. - The review's next arrow sits at the edge at phone width. Contract amended for each, plus an accepted-risks section (unlocked .seen read-modify-write, a planted .viewed symlink, Item.ordinal with no default). 741 passed. Each new browser test was mutation-checked against its fix; the serialization test forces the race with a held first refresh, since localhost alone never lost it.
2094 lines
100 KiB
Python
2094 lines
100 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 json
|
||
import math
|
||
import os
|
||
import re
|
||
import secrets
|
||
import shutil
|
||
import stat
|
||
import tempfile
|
||
import time
|
||
import zipfile
|
||
from contextlib import asynccontextmanager
|
||
from dataclasses import replace
|
||
from datetime import datetime, timezone
|
||
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,
|
||
review_chain,
|
||
REVIEW_KINDS,
|
||
SEEN_FILE,
|
||
read_seen,
|
||
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.manifest import ( # noqa: E402
|
||
MANIFEST_FILE,
|
||
SERVICE_HANDLE,
|
||
read_manifest,
|
||
write_manifest,
|
||
)
|
||
from booth.benches import ( # noqa: E402
|
||
BENCH_STATES,
|
||
normalize_bench_url,
|
||
read_benches,
|
||
remove_bench,
|
||
set_bench_state,
|
||
upsert_bench,
|
||
)
|
||
from booth.links import ( # noqa: E402
|
||
LINK_LOCK,
|
||
LINKS_FILE,
|
||
PINS_FILE,
|
||
booth_target,
|
||
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 _content_mtime(path: Path) -> float:
|
||
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
|
||
files and symlinks (by lstat) with no dot-component in their path. Deliberately NOT `_newest_mtime`
|
||
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
|
||
content, so none of them may make a booth read as newly landed.
|
||
|
||
Files only, never directories: creating `.viewed` bumps the booth
|
||
directory's own mtime, and counting that would make the first look at a
|
||
booth look like a delivery. An empty booth landed at 0.0. One unreadable
|
||
ENTRY is skipped; a booth whose walk cannot run at all reads as NOW, the
|
||
posture `_newest_mtime` takes and for a milder reason here: a booth we
|
||
cannot read is shown as new rather than hidden as old.
|
||
"""
|
||
newest = 0.0
|
||
try:
|
||
for p in path.rglob("*"):
|
||
rel = p.relative_to(path)
|
||
if any(part.startswith(".") for part in rel.parts):
|
||
continue
|
||
try:
|
||
# lstat: a posted SYMLINK counts by its own mtime — when it was
|
||
# placed — never by its target's. A link to a busy file outside
|
||
# the booth must not make the booth read as newly delivered.
|
||
st = p.lstat()
|
||
except OSError:
|
||
# One unreadable entry costs that entry, not the booth: reading
|
||
# the whole booth as landed NOW would pin it in "new" forever.
|
||
continue
|
||
if (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)) and st.st_mtime > newest:
|
||
newest = st.st_mtime
|
||
except OSError:
|
||
return time.time()
|
||
return newest
|
||
|
||
|
||
def _viewed_at(path: Path) -> float | None:
|
||
"""The mtime of the booth's `.viewed` marker (U4), or None if it has never
|
||
been looked at. `lstat`, like `is_kept`: a planted symlink is read as the
|
||
marker it claims to be, never followed."""
|
||
try:
|
||
return os.lstat(path / VIEW_MARKER).st_mtime
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def _stamp(created: str) -> datetime | None:
|
||
"""A mark's `created` as an aware datetime, or None when it will not parse.
|
||
Strings are never compared: two ISO stamps with different offsets sort
|
||
wrong as text. A naive stamp is read as UTC."""
|
||
try:
|
||
dt = datetime.fromisoformat(created)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||
|
||
|
||
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
|
||
|
||
|
||
def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
|
||
"""Note that `rel` was looked at full size (R2 C2).
|
||
|
||
Rewrites the whole marker — the previous set plus `rel`, pruned to rels that
|
||
are still items, sorted — so it is deduplicated and never outgrows the
|
||
booth. Atomic replace (CLAUDE.md invariant 5) through a temp file created
|
||
with O_EXCL: a planted `.seen.tmp` symlink cannot redirect the write, and
|
||
`os.replace` swaps a planted `.seen` symlink out rather than writing
|
||
through it.
|
||
|
||
NEVER RAISES, for `record_view`'s reason: not recording a look is a cost
|
||
this service can absorb, not answering the request is not.
|
||
"""
|
||
try:
|
||
live = {it.rel for it in items}
|
||
seen = (read_seen(booth) | {rel}) & live
|
||
# A JSON array, UTF-8 explicitly: a rel may hold a newline or a leading
|
||
# space, and the host locale must not decide whether a name encodes.
|
||
body = json.dumps(sorted(seen), ensure_ascii=False).encode("utf-8", "surrogateescape")
|
||
fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
|
||
try:
|
||
with os.fdopen(fd, "wb") as fh:
|
||
fh.write(body)
|
||
os.replace(tmp, booth / SEEN_FILE)
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
except (OSError, ValueError):
|
||
# ValueError covers an encode failure — it is not an OSError, and a
|
||
# look that cannot be recorded must never cost the page.
|
||
pass
|
||
|
||
|
||
def wants_json(accept: str | None) -> bool:
|
||
"""Whether a mark POST asked for the in-place answer (R2 C3).
|
||
|
||
True ONLY when the Accept header lists `application/json` exactly —
|
||
parameters stripped — with a q-value that is absent or above zero. Absent,
|
||
empty, wildcard, `application/*`, a near miss like `application/jsonx`, an
|
||
explicit `q=0`, a malformed q: all False. It FAILS TOWARD THE 303, because
|
||
the plain form's redirect is the no-JS guarantee and a mis-parse must land
|
||
there, never on a 204 a browser would render as nothing happening.
|
||
"""
|
||
if not accept:
|
||
return False
|
||
# EVERY entry is parsed before anything is decided: returning True at the
|
||
# first good JSON entry meant a malformed one after it was never read, so
|
||
# `application/json, application/json;q=broken` was a 204 and the same pair
|
||
# reversed a 303 (heid code-review, 3/4). One unparseable entry anywhere
|
||
# makes the whole header False.
|
||
wanted = False
|
||
try:
|
||
for entry in accept.split(","):
|
||
mtype, *params = entry.split(";")
|
||
q = 1.0
|
||
for param in params:
|
||
key, _, value = param.partition("=")
|
||
if key.strip().lower() == "q":
|
||
q = float(value.strip())
|
||
if not math.isfinite(q):
|
||
# inf, 1e999, nan parse as floats but are not q-values
|
||
raise ValueError("non-finite q")
|
||
if mtype.strip().lower() == "application/json" and q > 0:
|
||
wanted = True
|
||
except ValueError:
|
||
return False
|
||
return wanted
|
||
|
||
|
||
def flagged_targets(marks: Sequence[Mark]) -> set[str]:
|
||
"""THE flag predicate (R2): the items carrying a READABLE flag mark. The
|
||
Desk count, the tray, the filmstrip, the tape and the review button all
|
||
read this, so they cannot disagree about one item. An unreadable flag
|
||
entry is judgment nobody can see, and counts nowhere."""
|
||
return {m.target for m in marks
|
||
if m.shape == "flag" and m.error is None and m.target}
|
||
|
||
|
||
def _contiguous(keys: Sequence[str | None]) -> bool:
|
||
"""True when each non-None key occupies ONE unbroken run of the sequence."""
|
||
seen: set[str] = set()
|
||
prev = object()
|
||
for k in keys:
|
||
if k != prev:
|
||
if k is not None and k in seen:
|
||
return False
|
||
if k is not None:
|
||
seen.add(k)
|
||
prev = k
|
||
return True
|
||
|
||
|
||
# The Desk shows this many bookmarks and links to the board for the rest.
|
||
BOOKMARKS_SHOWN = 8
|
||
|
||
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)
|
||
# R2 C4: the oldest question still owed an answer, PARSED. Unparseable
|
||
# stamps are left out, so a booth whose every open pick is unparseable
|
||
# has no `open_since` and sorts after every booth that has one.
|
||
stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st]
|
||
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,
|
||
# ---- R2 C4, the Desk. All from the pass above; no second read.
|
||
# A booth held for UNREADABLE marks has no `open_since`, even if
|
||
# a readable pick sits beside the damage: it sorts after every
|
||
# dated question, because the damage is what needs fixing.
|
||
"open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
|
||
else None),
|
||
# items that EXIST: a flag on a file since deleted is shown on
|
||
# the booth page for withdrawal, not counted as a pick here
|
||
"flags": len(flagged_targets(marks) & {it.rel for it in items}),
|
||
# Two clocks, named apart (INV-5): `mtime` is activity,
|
||
# `landed_at` is content. "New since you looked" reads only the
|
||
# second, so a flag or a view never makes a booth look new.
|
||
"landed_at": _content_mtime(child),
|
||
"viewed_at": _viewed_at(child),
|
||
# The first four images in item order, as the originals shown
|
||
# small. Blurred ones stay blurred, the cover's rule.
|
||
"preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
|
||
}
|
||
)
|
||
# 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,
|
||
# U7. Derived in the resolver (INV-1); this only carries it.
|
||
"group": it.group,
|
||
# R2 C1. Same rule: the resolver numbers, this carries.
|
||
"ordinal": it.ordinal,
|
||
"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"
|
||
|
||
|
||
# ---- the declared embed seam (U3) ------------------------------------------
|
||
|
||
# Mirror of base.html's favicon. The app templates set it there; this copy is
|
||
# what `/b/<name>/embed.json` hands to a VERBATIM report, 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='%2315191d'/%3E%3Cpath d='M7 12V7h5"
|
||
"M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' "
|
||
"stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' "
|
||
"fill='%23b2cd12'/%3E%3C/svg%3E"
|
||
)
|
||
|
||
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
|
||
# the Booth appends it only when the page has not declared it itself.
|
||
EMBED_SRC = "/_booth/embed.js"
|
||
EMBED_SCRIPT_TAG = f'<script src="{EMBED_SRC}" defer></script>'
|
||
EMBED_JS_PATH = Path(__file__).parent / "static" / "embed.js"
|
||
|
||
# What counts as DECLARING the seam. Two substring tests, one per quote style,
|
||
# and each requires `src=` immediately before the path.
|
||
#
|
||
# The bare path was the first draft and it was wrong in the dangerous
|
||
# direction. A report that merely MENTIONS `/_booth/embed.js` — in a code
|
||
# sample, a comment, a sentence about this very feature — would have been read
|
||
# as declaring it, served untouched, and silently shown no chrome at all. The
|
||
# Booth's own design reports are exactly the pages that would quote it.
|
||
#
|
||
# These tests fail in the harmless direction instead. An unusual spelling
|
||
# (`src = "…"` with spaces, an unquoted attribute, a `?v=2` suffix) is read as
|
||
# NOT declared, so a second tag is appended — and embed.js mounts once
|
||
# regardless, because it guards on `window.__boothEmbed`. A missed declaration
|
||
# costs a duplicate tag; a false one costs the operator his chrome.
|
||
_EMBED_DECLARATIONS = (f'src="{EMBED_SRC}"', f"src='{EMBED_SRC}'")
|
||
|
||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw
|
||
|
||
|
||
def declares_embed(html: str) -> bool:
|
||
"""Whether a verbatim page already asks for the Booth's chrome.
|
||
|
||
TWO SUBSTRING TESTS. This is the entire detection half of what used to be
|
||
six regular expressions run against arbitrary author HTML — and the other
|
||
half, the insertion, is a `+`. See `_EMBED_DECLARATIONS` for why it matches
|
||
`src="…"` rather than the bare path: both spellings fail toward appending a
|
||
harmless duplicate rather than toward silently withholding the chrome.
|
||
"""
|
||
return any(d in html for d in _EMBED_DECLARATIONS)
|
||
|
||
|
||
def embed_verbatim(raw: bytes) -> bytes:
|
||
"""The ONLY thing the Booth does to a verbatim report. BYTES IN, BYTES OUT.
|
||
|
||
Appended, never inserted, and never prepended. That is what retires both of
|
||
the old wrapper's hard constraints rather than satisfying them more
|
||
carefully: nothing can displace a leading doctype into quirks mode and
|
||
nothing can push the charset <meta> out of its first-1024-byte detection
|
||
window, because nothing in front of them moves. Content after `</html>` is
|
||
parsed into the body by every browser, so there is no seam to find.
|
||
|
||
⚠ IT TAKES BYTES BECAUSE TEXT WAS QUIETLY EDITING THE DOCUMENT. The first
|
||
version read the file with `read_text()` and returned a str. That opens in
|
||
UNIVERSAL-NEWLINE mode, so a report written with CRLF came back with LF —
|
||
and `errors="replace"` turned any byte that was not valid UTF-8 into U+FFFD.
|
||
A declaring page was therefore NOT served as its author wrote it, which is
|
||
this unit's headline promise, and the test could not see it because its
|
||
fixture was LF-only ASCII. Found by a cross-frontier bug-hunt panel.
|
||
|
||
Decoding still happens — `declares_embed` needs a string to look in — but
|
||
the decoded copy is used ONLY to answer that question. What goes on the wire
|
||
is the original bytes, plus the tag's bytes when it is appended, so the
|
||
source is a byte-exact prefix of the response.
|
||
"""
|
||
text = raw.decode("utf-8", errors="replace")
|
||
return raw if declares_embed(text) else raw + EMBED_SCRIPT_TAG.encode("utf-8")
|
||
|
||
|
||
def ask_form_id(stem: str) -> str:
|
||
"""The shared `<form>` id a pick's scattered question groups bind to with
|
||
the HTML5 `form=` attribute.
|
||
|
||
Moved here from `booth/inline.py` when U3 deleted that module: it is not
|
||
placement machinery, it is what makes four radio groups spread down a report
|
||
submit as ONE POST, which is what a multi-question ask requires.
|
||
"""
|
||
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
||
|
||
|
||
# ---- 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,
|
||
links_board: str = "links",
|
||
) -> 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)
|
||
|
||
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
|
||
# kind of thing this repo serves, and the only one that would otherwise be
|
||
# free to hot-reload from the deployment root — which is the skew that put
|
||
# 19 of 25 booths at 500. One rule: nothing takes effect until you restart.
|
||
embed_js = EMBED_JS_PATH.read_text(encoding="utf-8")
|
||
embed_etag = '"%s"' % hashlib.sha256(embed_js.encode("utf-8")).hexdigest()[:16]
|
||
|
||
@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):
|
||
"""THE DESK (R2 C4) — the index triaged by what needs the operator.
|
||
|
||
Three sections, ALWAYS in this order, each booth in exactly one:
|
||
needs — an open pick, or marks that cannot be read (somebody has to
|
||
fix those, so they must not hide further down). Oldest open
|
||
question first; a booth with no parseable stamp after every
|
||
booth that has one; name breaks ties.
|
||
new — content landed since the booth was last looked at, or never
|
||
looked at. Newest content first; name breaks ties.
|
||
rest — everything else, in `list_booths`' own order (last activity
|
||
first, name as the tie-break). No second rule is stated.
|
||
The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so
|
||
the lanes sorted nothing. Kept status still shows on every row.
|
||
"""
|
||
everything = list_booths(data_dir, ttl_seconds)
|
||
needs = [b for b in everything
|
||
if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE]
|
||
needs.sort(key=lambda b: ((0, b["open_since"].timestamp())
|
||
if b["open_since"] else (1, 0.0), b["name"]))
|
||
in_needs = {b["name"] for b in needs}
|
||
new = [b for b in everything if b["name"] not in in_needs
|
||
and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])]
|
||
new.sort(key=lambda b: (-b["landed_at"], b["name"]))
|
||
in_new = {b["name"] for b in new}
|
||
rest = [b for b in everything
|
||
if b["name"] not in in_needs and b["name"] not in in_new]
|
||
benches, benches_error = read_benches(data_dir)
|
||
board = data_dir / links_board
|
||
bookmarks = [row for row in _board_rows(board)
|
||
if booth_target(row["url"]) is None] if board.is_dir() else []
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"index.html",
|
||
{
|
||
**base_ctx,
|
||
"needs": needs,
|
||
"new": new,
|
||
"rest": rest,
|
||
"benches": [b for b in benches if b.state != "retired"],
|
||
"benches_error": benches_error,
|
||
"bookmarks": bookmarks[:BOOKMARKS_SHOWN],
|
||
"bookmarks_total": len(bookmarks),
|
||
"board_url": f"/b/{quote(links_board, safe='')}/",
|
||
},
|
||
)
|
||
|
||
@app.get("/healthz")
|
||
def healthz():
|
||
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
|
||
|
||
@app.get(EMBED_SRC)
|
||
def embed_script():
|
||
"""The declared seam's one static asset.
|
||
|
||
Served from the startup read, with an ETag over its content so a
|
||
browser revalidates instead of holding a stale copy across a restart —
|
||
`no-cache` here means "ask me", not "do not store".
|
||
"""
|
||
return Response(
|
||
content=embed_js,
|
||
media_type="text/javascript; charset=utf-8",
|
||
headers={"ETag": embed_etag, "Cache-Control": "no-cache"},
|
||
)
|
||
|
||
@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, filter: str = "all"):
|
||
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():
|
||
# The operator's verbatim report. U3: the page declares the seam and
|
||
# the Booth mounts into it — so a page carrying the script tag is
|
||
# served exactly as written, and one that is not gets that single
|
||
# line appended. Nothing is parsed, matched or inserted.
|
||
#
|
||
# The read is still bounded: a pathological file falls back to
|
||
# serving raw, which costs it the chrome exactly as it did before.
|
||
try:
|
||
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
||
# ONE read, and it is a byte read: see embed_verbatim.
|
||
return Response(
|
||
content=embed_verbatim(own_index.read_bytes()),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
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)
|
||
rail, shown = _rail(gallery, marks, filter)
|
||
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),
|
||
# THE GRID RENDERS `shown`; everything else reads `gallery`.
|
||
# Filtering is a VIEW: `shown` is `gallery` with non-matching
|
||
# items removed and NOTHING re-sorted, so "the third one" means
|
||
# the same thing with a filter on as with it off. Sorting by
|
||
# anything filter-derived would look right and silently misfile
|
||
# the operator's judgment — CLAUDE.md invariant 6.
|
||
"items": shown,
|
||
"all_items": gallery,
|
||
"rail": rail,
|
||
"filter": rail["active"],
|
||
# 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),
|
||
# The bench registry, rendered on the STANDING BOARD's page and
|
||
# nowhere else: it belongs to exactly one booth, and a read per
|
||
# gallery page view would buy noise. `_board_rows` is empty for
|
||
# every other booth, so this pair is read only when it renders.
|
||
# `read_benches` never raises; a damaged registry costs its own
|
||
# panel and says so, which is the v0.2.2 lesson.
|
||
# `is_board` is PAGE IDENTITY, not page content. Gating the
|
||
# panel on `board or benches` hid it — and its registration
|
||
# form — exactly when the board was empty and the registry
|
||
# absent, which is the state a new deployment starts in and the
|
||
# one where "no benches registered yet" is most worth saying.
|
||
# A panel that disappears when it has nothing to show is the
|
||
# same defect as a damaged panel rendering as an absent one.
|
||
"is_board": (booth / LINKS_FILE).is_file(),
|
||
**dict(zip(("benches", "benches_error"),
|
||
read_benches(data_dir) if (booth / LINKS_FILE).is_file()
|
||
else ([], None))),
|
||
# 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. Keyed off the
|
||
# FULL gallery, not the filtered one, so a tile hidden by the
|
||
# current filter still has its marks if the filter changes.
|
||
"item_marks": {
|
||
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
|
||
},
|
||
"booth_marks": marks_for_target(marks, None),
|
||
# R2 C5: the flag tray — the flagged items in SET order, i.e.
|
||
# by ordinal, over the full gallery (a filter hides tiles, not
|
||
# judgments). Order is total with no tie-break: rels are unique.
|
||
# Inline group headers only when every group is ONE contiguous
|
||
# run in the rendered order. Groups come from basenames and the
|
||
# order from full paths, so they can interleave (d1/aa, d1/bb,
|
||
# d2/aa); a header then either repeats or files an item under
|
||
# the wrong group. The rail's jump links do not depend on this.
|
||
"inline_groups": bool(rail["groups"]) and _contiguous(
|
||
[it["group"] for it in shown]),
|
||
# THE flag predicate for this page — the tile class and the tile
|
||
# toggle read it too, so no surface on the page can disagree.
|
||
"flagged_set": flagged_targets(marks),
|
||
"tray": [it for it in gallery if it["name"] in flagged_targets(marks)],
|
||
# A flag whose file is gone from the booth: no tile to stamp and
|
||
# no tray slot, so it is listed apart with its withdraw control
|
||
# rather than vanishing from the page while staying in the file.
|
||
"orphan_flags": [m for m in marks
|
||
if m.shape == "flag" and m.error is None and m.target
|
||
and m.target not in {it["name"] for it in gallery}],
|
||
"ord_width": len(str(len(gallery))),
|
||
"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)),
|
||
},
|
||
)
|
||
|
||
FILTERS = ("all", "flagged", "annotated", "unanswered")
|
||
|
||
def _rail(gallery: list[dict], marks, requested: str) -> tuple[dict, list[dict]]:
|
||
"""Per-filter counts, and the items the grid should render.
|
||
|
||
`requested` ARRIVES FROM A URL, which is operator-editable and
|
||
link-shared, so an unknown value falls back to `all` rather than
|
||
indexing a dict by it. A filter nobody can mistype into a 500.
|
||
|
||
`unanswered` means HAS AN OPEN PICK — the U4 hold predicate, which
|
||
already exists and already has a home. The other reading ("has no mark
|
||
at all") is a genuinely different question and is an open question on
|
||
the U7 contract, not something to guess at here.
|
||
"""
|
||
active = requested if requested in FILTERS else "all"
|
||
open_ids = {m.id for m in open_marks(marks)}
|
||
buckets: dict[str, list[dict]] = {f: [] for f in FILTERS}
|
||
for it in gallery:
|
||
mine = marks_for_target(marks, it["name"])
|
||
buckets["all"].append(it)
|
||
if any(m.shape == "flag" and m.flagged for m in mine):
|
||
buckets["flagged"].append(it)
|
||
if any(m.shape == "note" for m in mine):
|
||
buckets["annotated"].append(it)
|
||
if any(m.id in open_ids for m in mine):
|
||
buckets["unanswered"].append(it)
|
||
shown = buckets[active]
|
||
rail = {
|
||
"active": active,
|
||
# ORDER: the declaration order of FILTERS. Stated because a rail is
|
||
# an ordered collection and invariant 6 binds to it like any other.
|
||
"counts": [{"key": f, "n": len(buckets[f])} for f in FILTERS],
|
||
"total": len(gallery),
|
||
"groups": _groups(shown),
|
||
}
|
||
return rail, shown
|
||
|
||
def _groups(shown: list[dict]) -> list[dict]:
|
||
"""The jump-to-group rows, or [] when grouping would not help.
|
||
|
||
DERIVED FROM `shown`, NOT FROM THE FULL GALLERY, so every anchor lands
|
||
on a tile the page actually rendered. A row pointing at an item the
|
||
current filter has hidden scrolls nowhere, which is the same defect as
|
||
a wrong id arriving by a different route.
|
||
|
||
ORDER: the position of each group's FIRST member in the rendered
|
||
sequence — which is `sorted(rel)` narrowed by the filter and never
|
||
re-sorted. So the rail reads in the direction the grid does, and adding
|
||
a file reshuffles nothing unless it lands first in its group. Settled by
|
||
the operator 2026-09-22; ROADMAP carries the row. `dict` preserves
|
||
insertion order, so walking `shown` once IS the rule.
|
||
|
||
⚠ THE RAIL IS ABSENT UNLESS GROUPING IS INFORMATIVE: two or more
|
||
groups, and the UPPER MEDIAN group holding more than one item — for an
|
||
even count that is the larger of the two central sizes, so `[1, 2]`
|
||
renders and `[1, 1, 2]` does not. Named precisely because "the middle
|
||
group" admitted both readings and two panel arms flagged the ambiguity.
|
||
TWO
|
||
degeneracies, not one. The contract named only the first --
|
||
`sindra` and `sc-iso-spread` put every file in ONE group, and a rail
|
||
with a single row cannot navigate. The second is the one the live set
|
||
actually exhibits: `pewpew-ui-brief` yields 23 groups for 34 items and
|
||
`dfa-concepts` 13 for 20, a rail that is a second copy of the grid.
|
||
Both render as no rail, because a navigation affordance that cannot
|
||
navigate is worse than none -- it occupies the space where the real one
|
||
would be.
|
||
"""
|
||
by_group: dict[str, list[dict]] = {}
|
||
for it in shown:
|
||
if it["group"] is not None:
|
||
by_group.setdefault(it["group"], []).append(it)
|
||
sizes = sorted(len(v) for v in by_group.values())
|
||
# `sizes[len(sizes) // 2]` is the UPPER median; the `< 2` term
|
||
# short-circuits, so the index is always valid. No upper bound on the
|
||
# ROW COUNT: 1,000 groups of two pass this and render a 1,000-row rail.
|
||
# Accepted known risk — the largest live booth is 66 items and picking
|
||
# a cap without a booth that needs one is the invented work the roadmap
|
||
# gate exists to prevent. Raised 2-of-4 by the panel, 2026-09-22.
|
||
if len(sizes) < 2 or sizes[len(sizes) // 2] <= 1:
|
||
return []
|
||
return [
|
||
# THE ANCHOR IS BUILT FROM `url`, NOT `name`, and the template
|
||
# stamps the tile id from `url` too. Both sides must use the same
|
||
# percent-encoded string or the jump lands on the wrong artifact.
|
||
#
|
||
# A browser matches a fragment against ids RAW FIRST and only then
|
||
# percent-decoded, so a raw rel on both sides is not merely
|
||
# "unencoded" — it is AMBIGUOUS. With `a b.png` and `a%20b.png` in
|
||
# one booth, the first's href resolves to the fragment
|
||
# `item-a%20b.png` and the raw pass matches the SECOND file's id.
|
||
# `Item.url` is `quote(rel, safe="/")`, which is injective here
|
||
# (`a b` -> `a%20b`, `a%20b` -> `a%2520b`), and it is the convention
|
||
# `booth_flag` has always used for exactly this reason.
|
||
#
|
||
# Found 4-of-4 by the heid bug-hunt panel, 2026-09-22. The original
|
||
# anchor test could not see it: it asserted the href occurred as
|
||
# SOME id on the page, which stayed true while pointing at the wrong
|
||
# one.
|
||
{"key": k, "n": len(v), "anchor": f"item-{v[0]['url']}"}
|
||
for k, v in by_group.items()
|
||
]
|
||
|
||
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 []
|
||
rows = order_for_display(
|
||
parse_link_entries((booth / LINKS_FILE).read_text()),
|
||
read_pins(booth),
|
||
)
|
||
# DEAD = the row points at a booth that no longer exists. 156 of the
|
||
# board's 221 rows are exactly that, and nothing on the page could
|
||
# tell them apart, so the bulk-delete control that has existed since
|
||
# before this unit was unusable at that scale. Marking is all this
|
||
# does: removal stays the operator's two deliberate clicks, because
|
||
# "a migration that deletes anything" is not in v1.
|
||
for row in rows:
|
||
target = booth_target(row["url"])
|
||
row["dead"] = target is not None and not _booth_exists(target)
|
||
return rows
|
||
except (OSError, ValueError, UnicodeDecodeError):
|
||
return []
|
||
|
||
def _booth_exists(name: str) -> bool:
|
||
"""Whether a booth name is a live directory. NEVER RAISES.
|
||
|
||
SEAM REVIEW SR-2: this deliberately does NOT call `resolve_booth`, which
|
||
raises HTTPException(404) — called once per board row, one swept booth
|
||
would 404 the whole page, which is the opposite of the marker's purpose.
|
||
`booth_target` has already applied the same addressability rules
|
||
`resolve_booth` enforces, so the two cannot disagree about what is
|
||
reachable; all that is left is the existence check itself.
|
||
|
||
Cost: one stat per booth-shaped row per render of the standing board —
|
||
178 of 221 rows today, on the ONE booth that carries a links.md.
|
||
"""
|
||
try:
|
||
candidate = (data_dir / name).resolve()
|
||
# THE SAME CONTAINMENT `resolve_booth` ENFORCES. Without it the two
|
||
# disagree on a symlink: the marker would call a booth pointing
|
||
# outside the data root ALIVE while the page 404s it, so the row
|
||
# renders healthy and the link is dead — the worst of both, and
|
||
# invisible. 3-of-4 cold bug-hunt arms found the disagreement.
|
||
return candidate.parent == data_dir and candidate.is_dir()
|
||
except (OSError, ValueError):
|
||
# ValueError, not only OSError: an embedded NUL raises it rather
|
||
# than an OSError, and this predicate runs once per board row — one
|
||
# bad row must never cost the other 220.
|
||
return False
|
||
|
||
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"
|
||
elif form.get("back") == "view":
|
||
# R2 C3: judgment made at full size lands back at full size — the
|
||
# JS-off fix for being thrown out to the grid. Only for a MEDIA item
|
||
# of this booth; anything else takes the no-`back` landing above.
|
||
# Built from the resolved rel, never echoed from the form.
|
||
f = form.get("f")
|
||
if isinstance(f, str) and f:
|
||
try:
|
||
ring = review_chain(booth_items(resolve_booth(name)))
|
||
except HTTPException:
|
||
ring = []
|
||
if f in ring:
|
||
return RedirectResponse(
|
||
url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail",
|
||
status_code=303)
|
||
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)
|
||
|
||
def _mark_done(request: Request, name: str, form, anchor: str) -> Response:
|
||
"""The one exit for every mark route (R2 C3). A request that asked for
|
||
the in-place answer gets 204 and no body — the page fetches its own
|
||
fresh regions. Everything else gets `_mark_redirect`, byte for byte what
|
||
it got before R2 (INV-4)."""
|
||
if wants_json(request.headers.get("accept")):
|
||
return Response(status_code=204)
|
||
return _mark_redirect(name, form, anchor)
|
||
|
||
@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_done(request, 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_done(request, 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_done(request, 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_done(request, 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 _pick_fragments(name: str, mark) -> dict:
|
||
"""One pick, rendered into the pieces a page can mount independently.
|
||
|
||
Rendered HERE, by the same Jinja macros the gallery page uses, so there
|
||
is exactly ONE renderer of an ask. embed.js places these; it never
|
||
builds one. A second renderer in JavaScript is the shape INV-1 was
|
||
written to stop after the zoom view re-derived an item and lost its
|
||
captions doing it.
|
||
"""
|
||
url = quote(name, safe="")
|
||
fid = ask_form_id(mark.id)
|
||
if mark.error:
|
||
# `whole` renders the broken-ask box. A question the session
|
||
# believes it posted has to be visible; the pieces of a pick that
|
||
# could not be read do not exist to offer.
|
||
return {"id": mark.id, "error": mark.error,
|
||
"whole": str(_frag.whole(mark, fid, url)), "submit": "",
|
||
"questions": []}
|
||
return {
|
||
"id": mark.id,
|
||
"error": None,
|
||
"whole": str(_frag.whole(mark, fid, url)),
|
||
"submit": str(_frag.submit(mark, fid, url)),
|
||
# A LIST, not an object keyed by question key: a single-question
|
||
# pick normalizes to one question whose key is None, which JSON
|
||
# would write as the string "null" and so invent a name. The list
|
||
# also carries declaration order in the format itself.
|
||
"questions": [
|
||
{"key": q.get("key"), "html": str(_frag.question(mark, q, fid, url))}
|
||
for q in mark.questions
|
||
],
|
||
}
|
||
|
||
def _safe_fragments(name: str, mark) -> dict:
|
||
"""`_pick_fragments`, with the promise that it cannot raise.
|
||
|
||
`marks_for` hydrates an entry whose JSON is well-formed but whose SHAPE
|
||
is wrong — `{"answer": {"answers": []}}` survives `_hydrate` with no
|
||
error and then raises `UndefinedError` in the template, because the
|
||
macro asks a list for `.get`. Verified, not assumed.
|
||
|
||
This endpoint renders every pick in the booth on every page load of the
|
||
operator's report, so one such entry would 500 the whole seam and the
|
||
report would show no chrome at all — while `hold_read` reported the file
|
||
as perfectly readable. Same leniency `_hydrate_safe` already applies one
|
||
layer down, at the layer that actually renders: one unreadable pick
|
||
costs that pick, never the page.
|
||
"""
|
||
try:
|
||
return _pick_fragments(name, mark)
|
||
except Exception as exc: # noqa: BLE001 - deliberate
|
||
broken = replace(mark, error=f"this question could not be rendered: {exc}")
|
||
try:
|
||
whole = str(_frag.whole(broken, ask_form_id(mark.id),
|
||
quote(name, safe="")))
|
||
except Exception: # noqa: BLE001 - deliberate
|
||
# THE HANDLER MUST SURVIVE THE FAILURE IT IS HANDLING. The
|
||
# fallback re-rendered through the SAME macro module that had
|
||
# just raised, so when `whole` itself was the broken thing this
|
||
# guard re-raised and took the report anyway — a guard that
|
||
# only works when the failure is somewhere else. Found while
|
||
# building a falsifier for the guard: the falsifier tripped it.
|
||
# Plain text, escaped by the caller, no macro involved.
|
||
whole = ""
|
||
return {"id": mark.id, "error": broken.error,
|
||
"whole": whole, "submit": "", "questions": []}
|
||
|
||
@app.get("/b/{name}/embed.json")
|
||
def booth_embed_json(name: str):
|
||
"""Everything a verbatim report needs to mount the Booth's chrome.
|
||
|
||
The READ half of the declared seam. `embed.js` fetches this and places
|
||
what comes back; every decision — what a mark says, whether it is still
|
||
open, what order the marks come in — is made here and never re-derived
|
||
on the page.
|
||
|
||
Marks are ordered `(created, id)`, which is what both readers below
|
||
sort by. Questions are in declaration order. `open` is `open_marks`,
|
||
the ONE openness predicate, so a half-answered multi-question pick
|
||
counts as open here exactly as it does on the index badge.
|
||
|
||
DOES NOT RECORD A VIEW. `booth_view` already did, above both of its
|
||
early returns; counting a script's fetch of the page it is already on
|
||
would reset the TTL on machinery rather than on the operator.
|
||
|
||
The read is LENIENT and the status stays 200, copied from
|
||
`/marks.json`: a damaged `.marks.json` must cost the chrome, never the
|
||
operator's report. That is the v0.2.2 lesson.
|
||
"""
|
||
booth = resolve_booth(name)
|
||
marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||
if read_err is not None:
|
||
marks = marks_for(booth)
|
||
picks = [m for m in marks if m.shape == "pick"]
|
||
body = {
|
||
"booth": name,
|
||
# No `home`: the way-home chip mounts from a constant BEFORE this
|
||
# fetch, so that a failed one still leaves the operator a way out.
|
||
# Carrying the value anyway would put a second representation of it
|
||
# on the wire for nothing to read.
|
||
"favicon": FAVICON_HREF,
|
||
# Picks only. It is also what keeps a flag's `flag:<target>` id —
|
||
# the one mark id containing the separator an anchor spec splits
|
||
# on — out of a payload whose specs split on the first colon.
|
||
"marks": [_safe_fragments(name, m) for m in picks],
|
||
"open": [m.id for m in open_marks(picks)],
|
||
}
|
||
if read_err is not None:
|
||
body["marks"] = []
|
||
body["error"] = "this booth's .marks.json cannot be read"
|
||
body["detail"] = read_err
|
||
return JSONResponse(body)
|
||
|
||
@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, ValueError):
|
||
# ValueError: an embedded NUL. Hostile input, like every other
|
||
# unresolvable `f` — a 404, never a 500.
|
||
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)
|
||
# R2 C2: WHICH item was looked at — media only, the ring the tape
|
||
# draws. Same gate as the view above, and it never raises either.
|
||
if item.kind in REVIEW_KINDS:
|
||
record_seen(booth, item.rel, items)
|
||
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": f in flagged_targets(marks),
|
||
}
|
||
|
||
if item is not None and item.kind in REVIEW_KINDS:
|
||
# THE REVIEW (R2 C6): images, video and audio at full size with the
|
||
# judgment on screen. The ring is `review_chain` — the item order
|
||
# filtered to MEDIA — and the prev/next, the filmstrip and the tape
|
||
# all read that ONE list, so they cannot disagree about "next".
|
||
ring = review_chain(items)
|
||
by_rel = {it.rel: it for it in items}
|
||
pos = ring.index(f)
|
||
prev_url = next_url = None
|
||
if len(ring) > 1:
|
||
prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
|
||
next_url = quote(ring[(pos + 1) % len(ring)], safe="/")
|
||
flagged_rels = flagged_targets(marks)
|
||
# recorded above, before this read: the current item counts as seen
|
||
seen = read_seen(booth) & set(ring)
|
||
film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal,
|
||
"kind": by_rel[r].kind, "blurred": by_rel[r].blurred,
|
||
"flagged": r in flagged_rels, "seen": r in seen,
|
||
"current": r == f} for r in ring]
|
||
# Position within the group, only when there IS grouping: two or
|
||
# more groups among the ring. One group for everything says nothing.
|
||
group = None
|
||
ring_groups = {by_rel[r].group for r in ring if by_rel[r].group}
|
||
if item.group and len(ring_groups) > 1:
|
||
members = [r for r in ring if by_rel[r].group == item.group]
|
||
group = {"key": item.group, "k": members.index(f) + 1, "n": len(members)}
|
||
open_now = open_marks(marks)
|
||
return templates.TemplateResponse(
|
||
request, "view.html", {
|
||
**common,
|
||
"kind": item.kind,
|
||
"ordinal": item.ordinal,
|
||
"ord_width": len(str(len(items))),
|
||
"ring_k": pos + 1,
|
||
"ring_m": len(ring),
|
||
"prev_url": prev_url,
|
||
"next_url": next_url,
|
||
"film": film,
|
||
"seen_n": len(seen),
|
||
"group": group,
|
||
# a question ABOUT this item is answerable here; the rest are
|
||
# a count and a link — until the last item, where the end of
|
||
# the set offers them all
|
||
"item_picks": [m for m in open_now if m.target == f],
|
||
"other_picks": [m for m in open_now if m.target != f],
|
||
"is_last": pos == len(ring) - 1,
|
||
"tray": [x for x in film if x["flagged"]],
|
||
"back_url": f"/b/{quote(name, safe='')}/#item-{item.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)
|
||
|
||
@app.post("/b/{name}/bench-add")
|
||
def bench_add(name: str, url: str = Form(...), bname: str = Form("", alias="name")):
|
||
"""Register or update a bench by normalized URL.
|
||
|
||
A rejected URL must not 500 the page it was posted from. THE REJECTION
|
||
IS SILENT HERE, and that is stated rather than dressed up: the form's
|
||
`type="url"` catches the ordinary typo in the browser before the post,
|
||
and this `except` is the last resort for what slips past it — the bench
|
||
simply does not appear. Surfacing the reason would need a flash message,
|
||
which this service has no mechanism for; inventing one for a path the
|
||
browser already guards is not worth a unit's scope.
|
||
|
||
`resolve_booth` is called for its 404: a POST at a booth that does not
|
||
exist is not a silent no-op.
|
||
"""
|
||
resolve_booth(name)
|
||
try:
|
||
upsert_bench(data_dir, url, bname, "operator")
|
||
except (ValueError, OSError):
|
||
pass
|
||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||
|
||
@app.post("/b/{name}/bench-state")
|
||
def bench_state(name: str, bench: str = Form(...), state: str = Form(...)):
|
||
resolve_booth(name)
|
||
if state in BENCH_STATES:
|
||
try:
|
||
set_bench_state(data_dir, bench, state)
|
||
except (ValueError, OSError):
|
||
pass
|
||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||
|
||
@app.post("/b/{name}/bench-remove")
|
||
def bench_remove(name: str, bench: str = Form(...)):
|
||
resolve_booth(name)
|
||
try:
|
||
remove_bench(data_dir, bench)
|
||
except (ValueError, OSError):
|
||
pass
|
||
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,
|
||
# The board the CLI's `booth link` writes (scripts/booth reads the same
|
||
# variable), so the Desk's bookmarks come from where they are written.
|
||
links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"),
|
||
)
|
||
|
||
|
||
app = _from_env()
|