Files
esh-pfi-infrastructure/services/booth/booth/templates/booth.html
T
vh 2e7fd7157e 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).
2026-09-19 23:47:18 -07:00

283 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ name }} · The Booth{% endblock %}
{% block content %}
<div class="boothhead">
<a class="back" href="/">‹ all booths</a>
<h1>{{ name }}</h1>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }}</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{# A durable multi-writer board gets no one-click wipe — same rule as the
kept lane on the index. Remove rows with the per-row ×, or release the
board from the index and wipe it from there. #}
{% if not board %}
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')">
<button>Wipe now</button>
</form>
{% endif %}
</div>
{% if uploaded %}
<div class="pickup-note">
📦 Pickup <code>{{ name }}</code>
<button type="button" class="copy-btn" data-copy="{{ name }}" title="copy id to clipboard">⧉ copy</button>
— download files below, or on nh3-dev grab <code>~/booth-data/{{ name }}/</code>
</div>
{% endif %}
{% if asks %}
{% include "_asks.html" %}
{% endif %}
{% if board %}
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
this is the one booth where the useful granularity is the ROW, not the
folder. Rendered as real UI rather than a markdown blob so a dead link can
be removed without hand-editing the file — and so provenance (who posted
it, when) is readable at a glance, which is the whole reason a bare URL
three days old is useless.
ORDER: pinned rows first, then newest-first (order_for_display). Pin a row
with the ★ so the ones you care about stop scrolling off the bottom.
ONE <form>, not one-per-row: checkboxes drive the bulk delete, while the
per-row × and ★ are submit buttons with their own `formaction`. That keeps
all three actions in a single form (nested forms are invalid HTML) AND lets
every one work with JS off — JS only adds select-all and the live count.
Every action posts a CONTENT ID, never a row number: another session can
append between this page rendering and a click, and an index would then hit
a neighbour. An id matches the row the operator saw, or nothing. #}
{% set pinned_n = board | selectattr('pinned') | list | length %}
<form class="board" method="post" action="/b/{{ name_url }}/unlink-many" id="boardform">
<div class="board-head">
<label class="board-selall" title="select all"><input type="checkbox" id="board-selall"></label>
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %}</span>
<span class="board-note">pinned first · newest on top · ★ pins a row · tick rows to delete</span>
<span class="board-spacer"></span>
<button type="submit" class="board-del-sel" id="board-del-sel"
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
</div>
{% for e in board %}
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
<div class="board-main">
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
<div class="board-url">{{ e.url }}</div>
</div>
<div class="board-meta">
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
</div>
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
name="entry" value="{{ e.id }}" title="remove this link"
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
</div>
{% endfor %}
</form>
{% endif %}
{% if not items and not board and not asks %}
<div class="empty">This booth is empty.</div>
{% elif items %}
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
links.md is rendered as the board above and filtered out), so a plain else
would emit an empty <div class="gallery"> under the board. #}
<div class="gallery">
{% for it in items %}
{% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a
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{% 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>
<span class="doc-name">{{ it.name }}</span>
<span class="doc-spacer"></span>
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
</summary>
{% if it.rendered_html %}
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
{% else %}
<pre class="textview doc-body">{{ it.rendered }}</pre>
{% endif %}
</details>
</figure>
{% else %}
<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' %}
{# preload="none": a booth of a dozen webms was fetching them
all at page load ("metadata" still pulls real ranges per
file); nothing loads until the viewer hits play #}
<video controls preload="none" src="{{ it.url }}"></video>
{% elif it.kind == 'audio' %}
<audio controls preload="none" src="{{ it.url }}"></audio>
{% elif it.doc %}
{# a doc too large to inline (over DOC_MAX_BYTES) still links out #}
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
{% else %}
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
{% endif %}
{% if it.kind == 'other' %}
{% if it.caption %}<figcaption><span class="cap-text">{{ it.caption }}</span></figcaption>{% endif %}
{% else %}
<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>
{% endif %}
{% endfor %}
</div>
{% endif %}
<script>
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
only) — so fall back to a hidden-textarea execCommand('copy'). */
(function () {
function copyText(t) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(t);
}
var ta = document.createElement('textarea');
ta.value = t;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-1000px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
document.querySelectorAll('.copy-btn').forEach(function (btn) {
var label = btn.textContent;
btn.addEventListener('click', function () {
copyText(btn.getAttribute('data-copy')).then(function () {
btn.classList.add('copied');
btn.textContent = '✓ copied';
setTimeout(function () { btn.classList.remove('copied'); btn.textContent = label; }, 1300);
});
});
});
})();
/* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
inside <summary>, so without this its click would just toggle the <details>
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
"collapse". Collapse stays available via the rest of the summary bar. With
JS off the button is inert and collapse via <details> still works. */
(function () {
document.querySelectorAll('.doc-close').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var item = btn.closest('.item-doc');
if (item) item.classList.add('is-closed');
});
});
})();
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
per-row × confirm reads desc/url from data-* attributes rather than being
interpolated into an inline handler, so an arbitrary agent-posted description
(quotes, newlines) can never break out into the page's JS. */
(function () {
var form = document.getElementById('boardform');
if (!form) return;
var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check'));
var selall = document.getElementById('board-selall');
var delBtn = document.getElementById('board-del-sel');
var countEl = document.getElementById('board-selcount');
function selected() { return boxes.filter(function (b) { return b.checked; }); }
function refresh() {
var n = selected().length;
if (countEl) countEl.textContent = n;
if (delBtn) delBtn.disabled = n === 0;
if (selall) {
selall.checked = n > 0 && n === boxes.length;
selall.indeterminate = n > 0 && n < boxes.length;
}
}
if (selall) {
selall.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = selall.checked; });
refresh();
});
}
boxes.forEach(function (b) { b.addEventListener('change', refresh); });
// Bulk delete: confirm with the count. Attached to the button (not the form's
// submit) so the per-row × / ★ submits — which share this form — are unaffected.
if (delBtn) {
delBtn.addEventListener('click', function (ev) {
var n = selected().length;
if (n === 0) { ev.preventDefault(); return; }
if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
});
}
form.querySelectorAll('.board-rm-btn').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
var d = btn.getAttribute('data-desc') || '';
var u = btn.getAttribute('data-url') || '';
if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
});
});
refresh();
})();
</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>