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:
@@ -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