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