feat(booth): close the keep round trip, and add cosmetic per-item blur

Two operator requests.

KEEP, BOTH DIRECTIONS. The kept lane could already release a booth back to
ephemeral, but an ephemeral booth could only be promoted from a shell -- so the
round trip was closed only if you had ssh. The /keep route and the `booth keep`
verb both already existed; only the button was missing. Adds ★ to the ephemeral
card, mirroring × on the other shoulder.

BLUR. Per-item cosmetic censoring: `booth blur <name> <file>...`, a ◌/◉ toggle
in each caption row, and 👁 click-to-reveal. State is `.blurred` in the booth
dir, one booth-relative path per line -- the same filesystem-is-the-state idiom
as .pins and .forever. An empty set deletes the marker rather than leaving a
zero-byte file, so `ls -a` tells the truth.

⚠ BLUR IS NOT ACCESS CONTROL, and the code, the docs and a test all say so on
purpose. A blurred item is still served at its own URL, still in the zip, still
on disk. The Booth has no auth by design. test_blur_is_cosmetic_the_file_is_
still_served asserts the 200 deliberately: if someone later "hardens" this into
a 403 that test fails, and it should, because half-implemented access control is
more dangerous than none.

Reveal is per-viewer and never persisted; a reload re-hides. With JS off an item
stays blurred, which is the safe direction to fail in.

Two things the first pass got wrong, both caught by checking rather than
assuming:

  * The cover thumb. index.html has IDENTICAL markup in the kept and ephemeral
    lanes, so a single-occurrence replace patched only the kept one and the
    ephemeral front page happily displayed the thing someone had hidden. The
    test that caught it was itself wrong first -- it matched the bare string
    "blurred-thumb", which is in base.html's stylesheet on every page and so
    passed in both states. It now asserts the attribute.
  * Inline docs render through their own <figure> branch and were left
    unblurred -- the branch that puts readable text straight on the page, so it
    needed blur more than images do. The suite passed; a live curl caught it.

165 tests pass (154 pre-existing, unchanged).
This commit is contained in:
vh
2026-09-19 23:47:18 -07:00
parent 88d3cf436e
commit b569a5bb50
7 changed files with 365 additions and 6 deletions
+60 -1
View File
@@ -34,7 +34,7 @@ import time
import zipfile
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
from urllib.parse import quote, unquote
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import (
@@ -72,6 +72,45 @@ DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not ren
# state anywhere but the filesystem.
KEEP_MARKER = ".forever"
# Per-item blur state: one relative item path per line, like .pins is one id per
# line. Filesystem IS the state here, same as everything else in this service.
#
# ⚠⚠ 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.
BLUR_FILE = ".blurred"
def read_blurred(booth: Path) -> set[str]:
"""Blurred item paths for a booth. Missing file -> empty set."""
try:
text = (booth / BLUR_FILE).read_text()
except (OSError, UnicodeDecodeError):
return set()
return {ln.strip() for ln in text.splitlines() if ln.strip()}
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.
@@ -254,6 +293,11 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"count": len(files),
"kinds": kinds,
"thumb_url": thumb_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.
"thumb_blurred": thumb_url is not None
and unquote(thumb_url) in read_blurred(child),
"has_index": (child / "index.html").is_file(),
"uploaded": (child / UPLOAD_MARKER).exists(),
"kept": is_kept(child),
@@ -311,6 +355,7 @@ def build_gallery(child: Path) -> list[dict]:
pass
sidecars.add(rel)
blurred = read_blurred(child)
items = []
for rel in sorted(by_rel):
if rel in sidecars:
@@ -341,6 +386,7 @@ def build_gallery(child: Path) -> list[dict]:
"caption": caption.get(rel),
"rendered": rendered,
"rendered_html": rendered_html,
"blurred": rel in blurred,
}
)
return items
@@ -957,6 +1003,19 @@ def create_app(
(resolve_booth(name) / KEEP_MARKER).unlink(missing_ok=True)
return RedirectResponse(url="/", 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))