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).
298 lines
13 KiB
Bash
Executable File
298 lines
13 KiB
Bash
Executable File
#!/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
|