diff --git a/README.md b/README.md index e0ef2c6..d058734 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ still ephemeral, so nobody inherits a cleanup chore they didn't ask for. ```bash booth keep my-board # drop the sentinel — exempt from the sweep, forever -booth unkeep my-board # remove it — the board rejoins the sweep +booth unkeep my-board # release the pin — the board rejoins the sweep +booth rm my-board # delete it NOW (works on kept boards; says so when it was kept) ``` It is just a file, so the manual forms work identically and are the honest @@ -132,9 +133,31 @@ to a safe basename (no path traversal). | `GET /b//` | Serve a file out of the booth | | `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b//` (id in `Location`) | | `POST /b//delete` | Wipe a booth (the UI's "Wipe now" button) | +| `POST /b//keep` | Pin a booth — exempt from the sweep | +| `POST /b//unkeep` | Release the pin (the UI's "release" button on kept cards) | | `DELETE /b/` | Wipe a booth (curl/API) | | `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target | + +### Deleting a kept board + +Kept boards have no × in the UI on purpose — a one-click wipe next to the +durable stuff is a footgun. But *deliberate* must not mean *impossible*, which +is what it meant until 2026-08-23: the only routes out were ssh or a +hand-written API call. + +Now it is two deliberate steps. **Release** on the kept card drops the +sentinel and the board moves to the ephemeral lane, where the × already lives; +wipe it from there. Release is reversible — press keep again and nothing was +lost. From the CLI, `booth rm ` deletes a kept board immediately and +tells you it was kept. + +**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 TTL. +Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or `booth rm` when +you mean now. + ## Ops Runs as a **user-level** systemd service on nh3-dev (no root, no Docker), diff --git a/booth/app.py b/booth/app.py index e6903a8..1a63617 100644 --- a/booth/app.py +++ b/booth/app.py @@ -679,6 +679,31 @@ def create_app( return RedirectResponse(url=f"/b/{quote(booth_id, safe='')}/", status_code=303) + # Releasing a kept board. The kept lane has no wipe control on purpose — + # destroying a durable board should not be one misclick — but "deliberate" + # had been built as "impossible from the UI": the only ways out were ssh or + # a hand-written API call. These two routes make the release step reachable + # while keeping deletion two deliberate acts (release, then wipe). + # + # NOTE ON THE TTL, which is not intuitive: removing the sentinel BUMPS the + # booth directory's mtime, and booth_age_seconds reads the newest mtime in + # the tree — so a released board's clock resets to zero and it survives + # another full TTL. "Unkeep and let the sweeper take it" therefore does NOT + # delete promptly. Release is the step that makes the × available; the × + # is what deletes. Anything relying on release-then-sweep is relying on a + # 24h delay it probably did not intend. + + @app.post("/b/{name}/keep") + def booth_keep(name: str): + (resolve_booth(name) / KEEP_MARKER).touch() + return RedirectResponse(url="/", status_code=303) + + @app.post("/b/{name}/unkeep") + def booth_unkeep(name: str): + # missing_ok: releasing an already-released board is a no-op, not a 500. + (resolve_booth(name) / KEEP_MARKER).unlink(missing_ok=True) + return RedirectResponse(url="/", status_code=303) + @app.post("/b/{name}/delete") def booth_delete_form(name: str): shutil.rmtree(resolve_booth(name)) diff --git a/booth/templates/base.html b/booth/templates/base.html index 9824cae..03157f6 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -122,6 +122,14 @@ .card-kept{border-top:2px solid var(--aus-blue)} .card-kept:hover{border-color:var(--aus-blue);border-top-color:var(--aus-bright-blue)} .badge-kept{background:var(--aus-blue);color:var(--fg-on-accent)} + /* Release sits where the ephemeral card's × sits, but reads as a word rather + than a destructive glyph — it is not the delete, it is what unlocks it. */ + .release{position:absolute;top:.4rem;right:.4rem;opacity:0;transition:opacity .12s} + .card-kept:hover .release,.release:focus-within{opacity:1} + .release button{font:inherit;font-size:.72rem;line-height:1;padding:.22rem .45rem; + border-radius:.3rem;cursor:pointer;border:1px solid var(--aus-blue); + background:var(--rk-panel);color:var(--aus-blue)} + .release button:hover{background:var(--aus-blue);color:var(--fg-on-accent)} .pickup-note{margin:-.5rem 0 1.5rem;padding:.6rem .85rem;border:1px solid var(--border-subtle); border-left:3px solid var(--aus-bright-cyan);border-radius:var(--radius-md);background:var(--rk-well); diff --git a/booth/templates/index.html b/booth/templates/index.html index c4b8fc6..e4b8377 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -37,9 +37,21 @@ {{ b.name }}
{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · ⬇ zip
- {# No × here. Wiping a kept board should be a deliberate act — remove the - sentinel first (it rejoins the sweep), or delete the folder by hand. A - one-click wipe next to the durable stuff is a footgun. #} + {# Still no × here — a one-click wipe next to the durable stuff is a + footgun. But "deliberate" must not mean "impossible from the UI", + which is what it meant before: the only routes out were ssh or a + hand-written API call. Release drops the sentinel and the board moves + to the ephemeral lane, where the × already lives. Two deliberate + acts, both reachable, and the first one is reversible. + + The confirm says "wipe it from there" rather than "let it expire" on + purpose: releasing BUMPS the directory mtime, so the board's age + resets and it survives another full TTL. Unkeep-and-wait is a 24h + delay, not a delete. #} +
+ +
{% endfor %} diff --git a/scripts/booth b/scripts/booth index b31cab0..d7d46ff 100755 --- a/scripts/booth +++ b/scripts/booth @@ -20,6 +20,17 @@ # 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 ` 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. @@ -66,8 +77,13 @@ case "$cmd" in ;; 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" + echo "wiped $1$was_kept" ;; keep) [ $# -ge 1 ] || usage diff --git a/tests/test_booth.py b/tests/test_booth.py index ecf74c2..8364dbf 100644 --- a/tests/test_booth.py +++ b/tests/test_booth.py @@ -6,6 +6,7 @@ import pytest from fastapi.testclient import TestClient from booth.app import ( + booth_age_seconds, FAVICON_LINK, KEEP_MARKER, build_gallery, @@ -690,6 +691,103 @@ def test_list_booths_flags_kept(tmp_path): assert by_name["links"]["kept"] is True +# ---- releasing a kept board so it can be deleted --------------------------- +# +# The kept lane deliberately has no wipe control: destroying a durable board +# should not be one misclick. But "deliberate" had been implemented as +# "impossible from the UI" — the only routes out were ssh or a hand-crafted +# API call. These endpoints make the documented workflow (drop the sentinel, +# the board rejoins the sweep, then wipe it like anything else) actually +# reachable, while keeping it two deliberate steps rather than one. + + +def test_unkeep_releases_a_kept_board(client): + c, data = client + _touch(data / "links" / "a.png") + _touch(data / "links" / KEEP_MARKER) + + r = c.post("/b/links/unkeep", follow_redirects=False) + + assert r.status_code == 303 + assert not is_kept(data / "links"), "the sentinel must be gone" + assert (data / "links" / "a.png").exists(), "unkeep must not touch content" + + +def test_unkeep_is_idempotent_on_an_unkept_board(client): + """Releasing something already released is a no-op, not a 500.""" + c, data = client + _touch(data / "run1" / "a.png") + + r = c.post("/b/run1/unkeep", follow_redirects=False) + + assert r.status_code == 303 + assert (data / "run1" / "a.png").exists() + + +def test_keep_pins_a_board_and_round_trips(client): + """Reversible: the release step must not be a one-way door.""" + c, data = client + _touch(data / "board" / "a.png") + + assert c.post("/b/board/keep", follow_redirects=False).status_code == 303 + assert is_kept(data / "board") + + assert c.post("/b/board/unkeep", follow_redirects=False).status_code == 303 + assert not is_kept(data / "board") + + +def test_keep_and_unkeep_go_through_the_same_name_guard(client): + """Both mutating routes must use resolve_booth, not raw path joining. + + A name containing a slash never reaches the handler at all (the router has + no matching path), so the interesting cases are the ones that DO reach it: + a dotfile name and a name that simply is not a booth. Both must 404 rather + than create a stray sentinel somewhere. + """ + c, data = client + for route in ("keep", "unkeep"): + assert c.post(f"/b/.hidden/{route}").status_code == 404 + assert c.post(f"/b/nope/{route}").status_code == 404 + assert not (data / ".hidden").exists(), "must not have created anything" + assert list(data.iterdir()) == [], "data dir untouched by rejected calls" + + +def test_releasing_a_board_RESETS_its_ttl_clock(tmp_path): + """Counter-intuitive, and the reason release-then-sweep is not a delete path. + + Removing the sentinel bumps the booth directory's mtime, and age is the + newest mtime in the tree — so a board that was 10,000s stale reads as 0s + old the instant it is released, and survives another full TTL. This test + pins that behaviour deliberately: anyone who "unkeeps and waits" is waiting + a fresh 24h, not reaping something already expired. Delete via the wipe + route instead, which release is what unlocks. + """ + kept = tmp_path / "links" + _touch(kept / "a.png") + _touch(kept / KEEP_MARKER) + _stale(kept) + + assert sweep_once(tmp_path, ttl_seconds=3600) == [], "pinned: exempt" + assert booth_age_seconds(kept) > 3600 + + (kept / KEEP_MARKER).unlink() + + assert booth_age_seconds(kept) < 60, "unlink bumped the dir mtime" + assert sweep_once(tmp_path, ttl_seconds=3600) == [], "so it is NOT swept yet" + assert kept.exists() + + +def test_released_board_is_sweepable_once_it_ages_again(tmp_path): + """It does rejoin the sweep — just on a fresh clock, not the old one.""" + released = tmp_path / "links" + _touch(released / "a.png") + + _stale(released) + + assert sweep_once(tmp_path, ttl_seconds=3600) == ["links"] + assert not released.exists() + + def test_sentinel_is_not_counted_as_an_item(tmp_path): """It is a dotfile, so it must not inflate the item count or become a tile.""" _touch(tmp_path / "links" / "a.png")