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:
+60
-1
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user