merge(blur): the blur set round-trips any rel, in .blurred.json, with one writer

Operator-ruled 2026-09-23 ("fix the blur"). 4cfbce5 is the fix: a JSON-array
blur set through stdlib-only booth/blur.py, shared by the service and `booth
blur`, plus Item.blurred_self so blur state has one reader. c1f5543 folds the
heid bug-hunt on it (hulda, regin, kimi). The format moves to its own name,
.blurred.json, because sniffing one file for two formats recreated the
wrong-item bug. The writer is judged by its reader, so a planted directory is
a 409 and not a 500. A lone surrogate is dropped, the writer respects the
reader's size cap, and the route and the CLI share one check_rel predicate.
853 passed on the branch; blur_storage.toml 20/20.
This commit is contained in:
vh
2026-09-23 23:07:07 -07:00
12 changed files with 949 additions and 98 deletions
+20 -29
View File
@@ -102,6 +102,9 @@ from booth.items import ( # noqa: E402,F401
render_doc,
render_doc_body,
)
# The per-item blur writer lives with its reader in booth/blur.py, stdlib-only so
# `scripts/booth blur` shares both. Re-exported: tests import it from here.
from booth.blur import BlurUnwritable, check_rel, set_blurred # noqa: E402,F401
# 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
@@ -163,24 +166,6 @@ def set_booth_blurred(booth: Path, on: bool) -> bool:
return False
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.
@@ -818,10 +803,6 @@ def build_gallery(child: Path) -> list[dict]:
suite reaches for it by name in nine places.
"""
out = []
# r2b D2b: the item's OWN blur, apart from the booth's. `blurred` is the
# composed fact the surfaces render; the per-item control changes only
# this, and must not claim an un-blur the booth flag would override.
own_blur = read_blurred(child)
for it in booth_items(child):
body = render_doc_body(child, it)
rendered, rendered_html = body if body is not None else (None, False)
@@ -844,7 +825,9 @@ def build_gallery(child: Path) -> list[dict]:
"rendered": rendered,
"rendered_html": rendered_html,
"blurred": it.blurred,
"blurred_self": it.rel in own_blur,
# r2b D2b: the item's OWN blur, apart from the booth's — off the
# record, from the one read `blurred` came from (invariant 3).
"blurred_self": it.blurred_self,
}
)
return out
@@ -2238,12 +2221,20 @@ def create_app(
"""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", ""))
# NEVER STRIPPED: " a.png" and "a.png" are two items, and a stripped
# `f` blurred the neighbour (heid bug-hunt on r2b merge 1). A leading
# "/" is never part of a rel. What else a blur entry may be is
# `check_rel`'s one predicate, shared with `booth blur`, so the route
# and the CLI cannot disagree about which items are addressable.
rel = f.lstrip("/")
try:
set_blurred(booth, rel, on not in ("0", "false", ""))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except BlurUnwritable as exc:
# The state on disk is wrong (a planted directory, a permission),
# not the request: a refusal, never a 500.
raise HTTPException(status_code=409, detail=str(exc))
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/delete")