From 2e7fd7157ee707f17697f0a460f84d1e2e0c024c Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sat, 19 Sep 2026 23:47:18 -0700 Subject: [PATCH] feat(booth): close the keep round trip, and add cosmetic per-item blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ...`, 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
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). --- services/booth/README.md | 35 +++++ services/booth/booth/app.py | 61 +++++++- services/booth/booth/templates/base.html | 26 ++++ services/booth/booth/templates/booth.html | 35 ++++- services/booth/booth/templates/index.html | 16 ++- services/booth/scripts/booth | 36 ++++- services/booth/tests/test_booth.py | 162 ++++++++++++++++++++++ 7 files changed, 365 insertions(+), 6 deletions(-) diff --git a/services/booth/README.md b/services/booth/README.md index 3730f50..79653be 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -39,6 +39,41 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/ Then hand the operator `http://10.100.10.50:8090/b/my-run/`. +## Blurring an item (cosmetic censoring) + +โš โš  **Blur is NOT access control.** A blurred item is still served at its own +URL, still included in the zip, still on disk. It hides a thing from a *glance* +โ€” a shoulder, a screen-share, a scroll past something you did not want +full-size โ€” and nothing else. 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.** + +```bash +booth blur ... # hide from a glance +booth unblur ... +``` + +Or the โ—Œ / โ—‰ toggle in each item's caption row on the booth page. + +- **State** is `.blurred` in the booth dir โ€” one booth-relative item path per + line, the same filesystem-is-the-state idiom as `.pins` and `.forever`. An + empty set deletes the file rather than leaving a zero-byte one, so `ls -a` + tells the truth about whether anything here is blurred. +- **Reveal is per-viewer and never persisted.** Click ๐Ÿ‘ reveal; a reload + re-hides. With JS off it stays blurred, which is the safe direction to fail. +- **Covers inherit it.** If a booth's cover image is blurred, the index card's + thumb is blurred too โ€” otherwise the front page undoes the censoring. +- **Inline docs are blurred too**, not just images and video. That branch puts + readable text straight on the page, so it needs this more than a picture does. + +## Keeping a booth (round trip, both directions) + +`โ˜…` on an ephemeral card promotes it to the kept lane; `release` in the kept +lane sends it back. Equivalent CLI: `booth keep ` / `booth unkeep `. + +โš  Until 2026-09-19 the UI only went one way โ€” the kept lane could release, but +an ephemeral booth could only be kept from a shell. The `/keep` route and the +CLI verb both already existed; only the button was missing. + ## Kept boards โ€” the one exception to the 24h rule A booth containing a **`.forever`** dotfile is **never swept**, and renders in diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index 435081c..624bccb 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -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)) diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html index cff119a..021014a 100644 --- a/services/booth/booth/templates/base.html +++ b/services/booth/booth/templates/base.html @@ -250,6 +250,32 @@ .card .sub{color:var(--fg-3);font-size:.72rem;font-family:var(--font-mono);letter-spacing:.03em;margin-top:.3rem} .wipe{position:absolute;top:.5rem;right:.5rem;margin:0} + /* โ˜… keep, mirroring .wipe on the other shoulder of the card. Same + hover-to-reveal language as .release in the kept lane. */ + .keepit{position:absolute;top:.5rem;left:.5rem;margin:0;opacity:0;transition:opacity .12s} + .card:hover .keepit,.keepit:focus-within{opacity:1} + .keepit button{font:inherit;font-size:.9rem;line-height:1;padding:.1rem .34rem; + cursor:pointer;border:1px solid var(--line);border-radius:.3rem; + background:var(--bg);color:var(--fg)} + .keepit button:hover{background:var(--aus-blue);color:var(--fg-on-accent)} + + /* โš  Blur is COSMETIC. The file is still served at its own URL and still in + the zip. This hides an item from a glance, nothing more. */ + .item.blurred img,.item.blurred video, + .item.blurred .doc-body,.item.blurred .textview{filter:blur(22px);transition:filter .15s} + .item.blurred.revealed img,.item.blurred.revealed video, + .item.blurred.revealed .doc-body,.item.blurred.revealed .textview{filter:none} + .item.blurred{position:relative} + .item.blurred .reveal{position:absolute;top:.5rem;left:.5rem;z-index:2; + font:inherit;font-size:.72rem;line-height:1;padding:.24rem .5rem;cursor:pointer; + border:1px solid var(--line);border-radius:.3rem;background:var(--bg);color:var(--fg)} + .item.blurred .reveal:hover{background:var(--aus-blue);color:var(--fg-on-accent)} + .blurtoggle{display:inline;margin:0} + .blurtoggle button{font:inherit;font-size:.78rem;line-height:1;padding:0 .2rem; + cursor:pointer;border:0;background:none;color:var(--muted)} + .blurtoggle button:hover{color:var(--fg)} + /* Cover thumbs on the index inherit the blur so the front page cannot undo it. */ + .blurred-thumb{filter:blur(16px)} /* opaque dark control-scrim + always-light glyph โ€” legible over any thumbnail AND in both themes (glyph must NOT follow --fg-*, which flips dark on light). */ .wipe button{cursor:pointer;border:1px solid rgba(255,255,255,.16);background:rgba(16,18,25,.86); diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html index 2cd3de9..6a6b36b 100644 --- a/services/booth/booth/templates/booth.html +++ b/services/booth/booth/templates/booth.html @@ -94,7 +94,14 @@ separate page.
is native collapse (works with JS off); the โœ• hides the item for the session (JS, progressive enhancement). The item spans the full grid width so prose has room to read. #} -
+
+ {% if it.blurred %} + {# Inline docs need this MORE than images, not less: a rendered doc puts + its text straight on the page, so "blur the picture" logic that skips + the doc branch leaves the most readable content unblurred. Missed on + the first pass; caught by a live check, not by the suite. #} + + {% endif %}
@@ -112,7 +119,13 @@
{% else %} -
+
+ {% if it.blurred %} + {# Click-to-reveal is per-viewer and client-side: nothing is persisted, so + a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the + safe direction to fail in. #} + + {% endif %} {% if it.kind == 'image' %} {{ it.name }} {% elif it.kind == 'video' %} @@ -134,6 +147,12 @@
โฌ‡ {{ it.caption or it.name }} +
+ + + +
{% endif %}
@@ -249,3 +268,15 @@ })(); {% endblock %} + + diff --git a/services/booth/booth/templates/index.html b/services/booth/booth/templates/index.html index 21540ff..efabb84 100644 --- a/services/booth/booth/templates/index.html +++ b/services/booth/booth/templates/index.html @@ -21,7 +21,10 @@
{% if b.thumb_url %} - + {# A cover blurred inside the booth must be blurred here too, or the + front page undoes the censoring the booth page applied. #} + {% elif b.has_index %}
โ–ฆ page
{% elif b.kinds.video %} @@ -70,7 +73,8 @@
{% if b.thumb_url %} - + {% elif b.has_index %}
โ–ฆ page
{% elif b.kinds.video %} @@ -87,6 +91,14 @@
{{ b.name }}
{{ b.count }} item{{ '' if b.count == 1 else 's' }} ยท expires in {{ b.expires_in|dur }} ยท โฌ‡ zip
+ {# Promote to the kept lane. The /keep route and the `booth keep` CLI verb + both predate this button; until 2026-09-19 the UI could only RELEASE a + kept booth, never keep an ephemeral one, so the round trip was only + closed if you had a shell. Reversible, so no confirmation โ€” the ร— next + to it is the destructive one and keeps its prompt. #} +
+ +
diff --git a/services/booth/scripts/booth b/services/booth/scripts/booth index 8bb97ed..728945e 100755 --- a/services/booth/scripts/booth +++ b/services/booth/scripts/booth @@ -63,10 +63,11 @@ set -euo pipefail DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}" URL="${BOOTH_URL:-http://10.100.10.50:8090}" KEEP=".forever" # must match KEEP_MARKER in booth/app.py +BLUR=".blurred" # one booth-relative item path per line; see `blur` below LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}" usage() { - echo "usage: booth {new |add ...|url |ls|rm |keep |unkeep |link [description]|links|unlink |ask