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 8305145ce1
commit 2e7fd7157e
7 changed files with 365 additions and 6 deletions
+35
View File
@@ -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
+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>
+35 -1
View File
@@ -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
+162
View File
@@ -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"
)