Files
booth/scripts/booth
T
vh b569a5bb50 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

298 lines
13 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# booth — post media and links to The Booth (dead simple). A booth is just a
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
# back.
#
# booth new <name> make an empty booth, print its URL
# booth add <name> <file>... copy files into a booth (creates it), print URL
# booth url <name> print a booth's URL
# booth ls list booths (kept ones marked ★)
# booth rm <name> wipe a booth now (TTL would eventually anyway)
#
# booth keep <name> exempt a booth from the 24h sweep, forever
# booth unkeep <name> hand it back to the sweeper
# booth link <url> [description] append a link to the standing link board
# booth links list the board, numbered, with entry ids
# booth unlink <id|index> remove ONE link from the board
#
# booth ask <name> <stem> <prompt> <option>... [--no-notes]
# pose a multiple-choice question in a booth
# booth asks <name> list a booth's asks and whether each is answered
# booth answer <name> <stem> [--wait [SECS]]
# print the answer JSON (exit 1 if unanswered);
# --wait polls until it lands (default 3600 s)
#
# ASKS. A session needs the operator to pick one of N things — which render,
# which plan, go/no-go — and act on the pick. `ask` writes <stem>.ask.json into
# a booth; the page renders it as a radio form with a notes field; submitting
# writes <stem>.answer.json next to it. `answer --wait` blocks until that file
# exists and prints it, so a session can `booth ask … && booth answer --wait …`
# and carry on. Re-answering overwrites: the sidecar is the CURRENT answer.
# Several questions in ONE form: write <stem>.ask.json by hand with a
# `questions` list (see services/booth/README.md § Asks); `asks` and `answer`
# handle both shapes.
# Remote sessions: rsync the ask in, then poll
# http://10.100.10.50:8090/b/<name>/<stem>.answer.json (404 until answered).
#
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
# sweep and moves it into its own lane at the top of the index. Use it for
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
# the sentinel, so putting a board back under the sweeper costs nothing.
#
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
# NOW — it announces that the board was kept, so wiping something durable is
# never silent. In the web UI it is two deliberate steps: `release` on the kept
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
# it from there.
#
# DO NOT "unkeep and let it expire". Removing the sentinel BUMPS the booth
# directory's mtime, and a booth's age is the newest mtime in its tree — so a
# released board's clock RESETS and it survives another full 24h. Unkeep-and-wait
# is a delay, not a delete. Use `rm` (or the UI ×) when you mean now.
#
# `link` is the reason the exception exists: agent sessions hand the operator
# URLs that then drown in terminal scrollback. They go on a standing kept board
# instead, with provenance, so they outlive the session that produced them.
#
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
# rsync -a ./out/ nh3-dev:booth-data/my-run/
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>|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
}
cmd="${1:-}"; shift || true
case "$cmd" in
new)
[ $# -ge 1 ] || usage
mkdir -p -- "$DATA/$1"
echo "$URL/b/$1/"
;;
add)
[ $# -ge 2 ] || usage
name="$1"; shift
mkdir -p -- "$DATA/$name"
cp -- "$@" "$DATA/$name/"
echo "$URL/b/$name/"
;;
url)
[ $# -ge 1 ] || usage
echo "$URL/b/$1/"
;;
ls)
[ -d "$DATA" ] || exit 0
for d in "$DATA"/*/; do
[ -d "$d" ] || continue
n="$(basename -- "$d")"
if [ -e "$d$KEEP" ]; then echo "★ $n"; else echo " $n"; fi
done
;;
rm)
[ $# -ge 1 ] || usage
# Say so when the thing destroyed was durable. Not a block — a CLI user
# naming a booth is being explicit — but a kept board disappearing must not
# look identical to run output disappearing.
was_kept=""
[ -e "$DATA/$1/$KEEP" ] && was_kept=" (was KEPT — durable board)"
rm -rf -- "${DATA:?}/$1"
echo "wiped $1$was_kept"
;;
keep)
[ $# -ge 1 ] || usage
[ -d "$DATA/$1" ] || { echo "no such booth: $1" >&2; exit 1; }
: > "$DATA/$1/$KEEP"
echo "kept (exempt from the sweep): $URL/b/$1/"
;;
unkeep)
[ $# -ge 1 ] || usage
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
desc="${*:-}"
board="$DATA/$LINKS_BOARD"
mkdir -p -- "$board"
: > "$board/$KEEP" # the board is durable by definition
# Provenance, because a bare URL is unreadable three days later: who posted
# it, from where, and when.
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
when="$(date '+%Y-%m-%d %H:%M')"
# ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
# atomic on POSIX, so concurrent sessions cannot interleave a line — which
# matters here precisely because many agents post to one board.
# flock on the same sidecar the Python remover uses. The append is
# atomic by itself, but `unlink` does read-modify-write, and without a
# shared lock this line could land inside that window and be rewritten
# away by the prune.
touch -- "$board/.links.lock"
flock "$board/.links.lock" \
printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
echo "$URL/b/$LINKS_BOARD/"
;;
links)
board="$DATA/$LINKS_BOARD/links.md"
[ -f "$board" ] || { echo "no link board yet"; exit 0; }
# The id is the same content hash the web UI and `unlink` use, so a row can
# be named unambiguously even while other sessions are appending to the board.
n=0
while IFS= read -r line; do
case "$line" in "- ["*) ;; *) continue ;; esac
n=$((n+1))
id="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sha1sum | cut -c1-8)"
printf '%3d %s %s\n' "$n" "$id" "$line"
done < "$board"
# `if`, NOT `[ ... ] && echo`: as the LAST statement of the branch that
# idiom returns 1 whenever the board is non-empty, so `booth links` exits
# non-zero on success — and `unlink`'s index lookup, which calls it inside
# $( ) under `set -e`, then dies silently.
if [ "$n" -eq 0 ]; then echo "board has no link rows"; fi
;;
unlink)
[ $# -ge 1 ] || usage
board="$DATA/$LINKS_BOARD"
[ -f "$board/links.md" ] || { echo "no link board" >&2; exit 1; }
target="$1"
# A bare number is accepted for convenience but resolved to the row's
# CONTENT ID before anything is deleted: between `booth links` and
# `booth unlink` another session may have appended, and deleting by POSITION
# would then take the wrong row. An id either matches the row you saw or
# matches nothing.
# DISAMBIGUATE BY SHAPE, not by "is it numeric". A content id is exactly 8
# hex chars, and roughly one id in forty is all digits — those were being
# read as row numbers and silently resolving to nothing. Match the id's
# actual shape first; anything else numeric is an index.
case "$target" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
;; # already a content id
''|*[!0-9]*)
echo "not an entry id (8 hex chars) or a row number: $target" >&2; exit 1 ;;
*)
target="$("$0" links | awk -v n="$target" '$1==n{print $2}')"
[ -n "$target" ] || { echo "no row $1 on the board" >&2; exit 1; } ;;
esac
# `|| exit 1` so a failure is reported rather than swallowed; `set -e` inside
# a command substitution elsewhere in this script has bitten us already.
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import remove_link_entry # stdlib only — no venv needed
removed = remove_link_entry(pathlib.Path(sys.argv[1]), sys.argv[2])
if removed is None:
sys.exit("no such entry: %s (already removed?)" % sys.argv[2])
print("removed: %s %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
;;
ask)
# booth ask <name> <stem> <prompt> <opt>... [--no-notes]
[ $# -ge 5 ] || usage
name="$1"; stem="$2"; prompt="$3"; shift 3
notes=1; opts=()
for a in "$@"; do
case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
done
[ "${#opts[@]}" -ge 2 ] || { echo "an ask needs at least 2 options" >&2; exit 1; }
# Validated through the SAME normaliser the page uses, so a session cannot
# post a question the renderer would refuse. stdlib only — no venv needed.
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import AskError, write_ask
booth, stem, prompt, *opts = sys.argv[1:]
try:
write_ask(pathlib.Path(booth), stem, prompt, opts, notes=os.environ["ASK_NOTES"] == "1")
except AskError as exc:
sys.exit("bad ask: %s" % exc)
' "$DATA/$name" "$stem" "$prompt" "${opts[@]}"
echo "$URL/b/$name/#ask-$stem"
;;
asks)
[ $# -ge 1 ] || usage
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import list_asks
asks = list_asks(pathlib.Path(sys.argv[1]))
if not asks:
print("no asks in this booth")
for a in asks:
if a["error"]:
state = "BROKEN " + a["error"]
elif a["answer"] and a["multi"]:
picks = ", ".join("%s=%s" % (k, v["label"]) for k, v in a["answer"]["answers"].items())
state = "answered %s (%s)" % (picks, a["answer"]["answered_at"])
elif a["answer"]:
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
elif a["multi"]:
state = "open (%d questions)" % len(a["questions"])
else:
state = "open"
print("%-24s %s" % (a["stem"], state))
' "$DATA/$1"
;;
answer)
# booth answer <name> <stem> [--wait [SECS]]
[ $# -ge 2 ] || usage
name="$1"; stem="$2"; shift 2
wait_s=0
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
f="$DATA/$name/$stem.answer.json"
[ -f "$DATA/$name/$stem.ask.json" ] || { echo "no such ask: $name/$stem" >&2; exit 1; }
# Poll, do not inotify: the answer is written by a different process via
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
deadline=$(( $(date +%s) + wait_s ))
while [ ! -f "$f" ]; do
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#ask-$stem" >&2; exit 1; fi
if [ "$(date +%s)" -ge "$deadline" ]; then echo "timed out after ${wait_s}s waiting on $name/$stem" >&2; exit 1; fi
sleep 2
done
cat -- "$f"
;;
*) usage ;;
esac