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))
+26
View File
@@ -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);
+33 -2
View File
@@ -94,7 +94,14 @@
separate page. <details open> 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. #}
<figure class="item item-doc" data-name="{{ it.name }}">
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}">
{% 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. #}
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
{% endif %}
<details class="doc-inline" open>
<summary class="doc-bar">
<span class="doc-chevron" aria-hidden="true">▸</span>
@@ -112,7 +119,13 @@
</details>
</figure>
{% else %}
<figure class="item item-{{ it.kind }}">
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}" data-item="{{ it.name }}">
{% 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. #}
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
{% endif %}
{% if it.kind == 'image' %}
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
{% elif it.kind == 'video' %}
@@ -134,6 +147,12 @@
<figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span>
<form class="blurtoggle" method="post" action="/b/{{ name_url }}/blur">
<input type="hidden" name="f" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if it.blurred else '1' }}">
<button title="{{ 'un-blur this item' if it.blurred else 'blur this item (cosmetic only — the file is still served)' }}"
aria-label="{{ 'un-blur' if it.blurred else 'blur' }} {{ it.name }}">{{ '◉' if it.blurred else '◌' }}</button>
</form>
</figcaption>
{% endif %}
</figure>
@@ -249,3 +268,15 @@
})();
</script>
{% endblock %}
<script>
/* Progressive enhancement only: without JS a blurred item stays blurred.
Reveal is per-viewer and never persisted — reloading re-hides. */
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
btn.addEventListener('click', function () {
var fig = btn.closest('.item');
var on = fig.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal';
});
});
</script>
+14 -2
View File
@@ -21,7 +21,10 @@
<article class="card card-kept">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{# A cover blurred inside the booth must be blurred here too, or the
front page undoes the censoring the booth page applied. #}
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
@@ -70,7 +73,8 @@
<article class="card">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
@@ -87,6 +91,14 @@
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
</div>
{# 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. #}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
</form>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
<button title="wipe now" aria-label="wipe booth">×</button>