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:
@@ -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 <name> <file>... # hide from a glance
|
||||
booth unblur <name> <file>...
|
||||
```
|
||||
|
||||
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 <name>` / `booth unkeep <name>`.
|
||||
|
||||
⚠ 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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]|links|unlink <id|index>|ask <name> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
|
||||
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -117,6 +118,39 @@ case "$cmd" in
|
||||
rm -f -- "$DATA/$1/$KEEP"
|
||||
echo "unkept — $1 rejoins the 24h sweep"
|
||||
;;
|
||||
blur|unblur)
|
||||
# ⚠ COSMETIC ONLY. A blurred item is still served at its own URL, still in
|
||||
# the zip, still on disk. This hides it from a glance — a shoulder, a
|
||||
# screen-share, a scroll past something you did not want full-size. The
|
||||
# Booth has no auth by design: if a thing must not be SEEN, it must not be
|
||||
# in a booth.
|
||||
[ $# -ge 2 ] || usage
|
||||
b="$1"; shift
|
||||
[ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
|
||||
f="$DATA/$b/$BLUR"
|
||||
for item in "$@"; do
|
||||
item="${item#"$DATA/$b/"}"; item="${item#/}"
|
||||
case "$item" in
|
||||
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
|
||||
touch "$f"
|
||||
if [ "$cmd" = blur ]; then
|
||||
grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
|
||||
else
|
||||
grep -vxF -- "$item" "$f" > "$f.tmp" || true
|
||||
mv -- "$f.tmp" "$f"
|
||||
fi
|
||||
done
|
||||
# An empty marker is a lie by omission — `ls -a` should say whether
|
||||
# anything here is blurred at all.
|
||||
[ -s "$f" ] || rm -f -- "$f"
|
||||
if [ "$cmd" = blur ]; then
|
||||
echo "blurred (cosmetic — still served): $URL/b/$b/"
|
||||
else
|
||||
echo "un-blurred: $URL/b/$b/"
|
||||
fi
|
||||
;;
|
||||
link)
|
||||
[ $# -ge 1 ] || usage
|
||||
link_url="$1"; shift
|
||||
|
||||
@@ -1285,3 +1285,165 @@ def test_board_page_orders_newest_first_and_pinned_on_top(client):
|
||||
body2 = _body(c, "/b/links/")
|
||||
assert body2.index("first-posted") < body2.index("last-posted"), "pinned row floats to the top"
|
||||
assert "1 pinned" in body2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blur (cosmetic censoring) + the keep button on ephemeral cards.
|
||||
# Both added 2026-09-19 on operator request.
|
||||
#
|
||||
# ⚠ Every blur test below asserts the COSMETIC contract deliberately: the file
|
||||
# stays reachable. If someone later "fixes" that by 403-ing blurred items, these
|
||||
# tests fail and that is correct — it would be a different feature with a
|
||||
# different name, and half-implemented access control is worse than none.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from booth.app import BLUR_FILE, read_blurred, set_blurred # noqa: E402
|
||||
|
||||
|
||||
def _png(p):
|
||||
p.write_bytes(
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00"
|
||||
b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def test_blur_state_roundtrip(tmp_path):
|
||||
assert read_blurred(tmp_path) == set(), "missing file must read as empty"
|
||||
set_blurred(tmp_path, "a/x.png", True)
|
||||
set_blurred(tmp_path, "b.png", True)
|
||||
assert read_blurred(tmp_path) == {"a/x.png", "b.png"}
|
||||
set_blurred(tmp_path, "b.png", False)
|
||||
assert read_blurred(tmp_path) == {"a/x.png"}
|
||||
|
||||
|
||||
def test_blur_state_removes_file_when_empty(tmp_path):
|
||||
"""An empty set deletes the marker rather than leaving a zero-byte file, so
|
||||
`ls -a` tells the truth about whether anything in here is blurred."""
|
||||
set_blurred(tmp_path, "x.png", True)
|
||||
assert (tmp_path / BLUR_FILE).exists()
|
||||
set_blurred(tmp_path, "x.png", False)
|
||||
assert not (tmp_path / BLUR_FILE).exists()
|
||||
|
||||
|
||||
def test_blur_state_is_idempotent_both_ways(tmp_path):
|
||||
set_blurred(tmp_path, "x.png", False) # unblur what was never blurred
|
||||
assert read_blurred(tmp_path) == set()
|
||||
set_blurred(tmp_path, "x.png", True)
|
||||
set_blurred(tmp_path, "x.png", True) # blur twice
|
||||
assert read_blurred(tmp_path) == {"x.png"}
|
||||
|
||||
|
||||
def test_build_gallery_flags_blurred_items(tmp_path):
|
||||
_png(tmp_path / "a.png")
|
||||
_png(tmp_path / "b.png")
|
||||
set_blurred(tmp_path, "a.png", True)
|
||||
by_name = {i["name"]: i for i in build_gallery(tmp_path)}
|
||||
assert by_name["a.png"]["blurred"] is True
|
||||
assert by_name["b.png"]["blurred"] is False, "unblurred items must not be flagged"
|
||||
|
||||
|
||||
def test_blur_route_toggles_both_directions(client):
|
||||
c, root = client
|
||||
d = root / "bo"
|
||||
d.mkdir()
|
||||
_png(d / "x.png")
|
||||
|
||||
c.post("/b/bo/blur", data={"f": "x.png", "on": "1"}, follow_redirects=False)
|
||||
assert read_blurred(d) == {"x.png"}
|
||||
c.post("/b/bo/blur", data={"f": "x.png", "on": "0"}, follow_redirects=False)
|
||||
assert read_blurred(d) == set()
|
||||
|
||||
|
||||
def test_blur_route_rejects_traversal(client):
|
||||
"""A blur entry is always booth-relative. Without this the marker file is a
|
||||
write-primitive pointed at an attacker-chosen path."""
|
||||
c, root = client
|
||||
(root / "bo").mkdir()
|
||||
r = c.post("/b/bo/blur", data={"f": "../escape.png", "on": "1"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 400
|
||||
assert read_blurred(root / "bo") == set()
|
||||
|
||||
|
||||
def test_blurred_item_renders_blurred_and_unblurred_does_not(client):
|
||||
"""Both states, because a blur class that is always present is the same
|
||||
instrument as one that is never present."""
|
||||
c, root = client
|
||||
d = root / "bo"
|
||||
d.mkdir()
|
||||
_png(d / "hidden.png")
|
||||
_png(d / "shown.png")
|
||||
|
||||
body = c.get("/b/bo/").text
|
||||
assert "item-image blurred" not in body, "nothing blurred yet"
|
||||
|
||||
set_blurred(d, "hidden.png", True)
|
||||
body = c.get("/b/bo/").text
|
||||
assert "blurred" in body
|
||||
assert 'data-item="hidden.png"' in body
|
||||
|
||||
|
||||
def test_blur_is_cosmetic_the_file_is_still_served(client):
|
||||
"""The contract, asserted on purpose. Blur hides an item from a glance; it
|
||||
is NOT access control and must never be mistaken for it."""
|
||||
c, root = client
|
||||
d = root / "bo"
|
||||
d.mkdir()
|
||||
_png(d / "x.png")
|
||||
set_blurred(d, "x.png", True)
|
||||
assert c.get("/b/bo/x.png").status_code == 200
|
||||
|
||||
|
||||
def test_index_blurs_the_cover_thumb_only_when_the_cover_is_blurred(client):
|
||||
"""Otherwise the front page cheerfully displays the exact thing someone
|
||||
asked to hide inside the booth."""
|
||||
c, root = client
|
||||
d = root / "bo"
|
||||
d.mkdir()
|
||||
_png(d / "cover.png")
|
||||
|
||||
# Assert the ATTRIBUTE, not the bare string: `.blurred-thumb{...}` is in
|
||||
# base.html's stylesheet on every page, so a substring check passes in both
|
||||
# states and proves nothing. This test failed usefully on exactly that.
|
||||
assert 'class="blurred-thumb"' not in c.get("/").text
|
||||
set_blurred(d, "cover.png", True)
|
||||
assert 'class="blurred-thumb"' in c.get("/").text
|
||||
|
||||
|
||||
def test_ephemeral_card_offers_keep_and_keeping_works(client):
|
||||
"""The /keep route and `booth keep` predate this button; until 2026-09-19
|
||||
the UI could only RELEASE a kept booth, so the round trip needed a shell."""
|
||||
c, root = client
|
||||
d = root / "bo"
|
||||
d.mkdir()
|
||||
_png(d / "x.png")
|
||||
|
||||
body = c.get("/").text
|
||||
assert 'action="/b/bo/keep"' in body, "ephemeral card must offer keep"
|
||||
|
||||
c.post("/b/bo/keep", follow_redirects=False)
|
||||
assert (d / KEEP_MARKER).exists()
|
||||
|
||||
# ...and the round trip closes: released booths lose the marker again.
|
||||
c.post("/b/bo/unkeep", follow_redirects=False)
|
||||
assert not (d / KEEP_MARKER).exists()
|
||||
|
||||
|
||||
def test_blur_applies_to_inline_docs_not_just_images(client):
|
||||
"""Regression: the first implementation only patched the image/video
|
||||
<figure>. Inline docs render through their OWN branch and were left
|
||||
unblurred — the branch that puts readable text straight on the page. The
|
||||
suite passed; a live check caught it."""
|
||||
c, root = client
|
||||
d = root / "bo"
|
||||
d.mkdir()
|
||||
(d / "plain.txt").write_text("visible")
|
||||
(d / "secret.txt").write_text("hidden")
|
||||
set_blurred(d, "secret.txt", True)
|
||||
|
||||
body = c.get("/b/bo/").text
|
||||
assert '<figure class="item item-doc blurred"' in body
|
||||
assert body.count('<figure class="item item-doc blurred"') == 1, (
|
||||
"exactly the blurred doc, not every doc"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user