fix(booth): the reveal button was inert; add kept-lane wipe and in-booth keep

Three operator reports, one of them a real defect I had claimed was working.

THE REVEAL BUTTON DID NOTHING, for a day. Its handler sat after the content
block's closing tag, and a child template's out-of-block content is silently
DISCARDED by Jinja. The button rendered. The handler never reached the browser.
Two commits and a README paragraph said click-to-reveal worked, and the suite
passed the entire time because nothing asserted against the SERVED page -- the
template really did contain the code.

Two guards, both confirmed to FAIL when the defect is reintroduced rather than
merely added and assumed protective:
  * test_reveal_handler_actually_reaches_the_served_page greps the response
  * test_no_orphaned_markup_after_the_content_block guards the structure

While moving it, caught a second instance of the same class: the explanatory
comment I wrote for the fix contained a literal Jinja endblock tag, which Jinja
would have parsed as a real tag and used to close the block early.

KEPT-LANE ×. Wiping a kept booth required release-then-find-it-in-the-other-
lane. That protected nothing and cost a hunt -- the board you just released is
loose in a feed that turns over, and you have to go find it to finish a job you
had already decided on. Protection now lives in the confirmation, which names
the booth and says KEPT, instead of in the number of lanes you must traverse.
Release stays as the reversible option.

IN-BOOTH KEEP. `☆ keep` / `★ kept — release` beside "Wipe now", so promoting
does not mean navigating back to the index. The booth page did not previously
know its own kept state; it does now. Both post a `next` field to stay put --
and `next` is a form field, so it is attacker-controlled: only same-site
absolute paths are honoured, with `//host`, schemes and backslashes refused,
tested.

173 tests pass.
This commit is contained in:
vh
2026-09-21 08:40:26 -07:00
parent 59ba9f5c10
commit 21f4afc033
6 changed files with 194 additions and 22 deletions
+16 -4
View File
@@ -704,6 +704,9 @@ def create_app(
**base_ctx,
"name": name,
"name_url": quote(name, safe=""),
# The page could not previously tell keep from release, so it
# offered neither and you had to go back to the index.
"kept": is_kept(booth),
# links.md is rendered AS the board below, so it must not also
# appear as a markdown doc tile — that would show the same
# content twice, once interactive and once not.
@@ -992,16 +995,25 @@ def create_app(
toggle_pin(resolve_booth(name), entry)
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
def _safe_next(nxt: str) -> str:
"""Where to land after keep/unkeep. Defaults to the index; a booth page
can ask to stay put. Only same-site absolute paths are honoured — `//`
and any scheme are refused, because a redirect target taken from a form
field is an open redirect if you do not check it."""
if nxt.startswith("/") and not nxt.startswith("//") and "\\" not in nxt:
return nxt
return "/"
@app.post("/b/{name}/keep")
def booth_keep(name: str):
def booth_keep(name: str, next: str = Form("/")):
(resolve_booth(name) / KEEP_MARKER).touch()
return RedirectResponse(url="/", status_code=303)
return RedirectResponse(url=_safe_next(next), status_code=303)
@app.post("/b/{name}/unkeep")
def booth_unkeep(name: str):
def booth_unkeep(name: str, next: str = Form("/")):
# 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)
return RedirectResponse(url=_safe_next(next), status_code=303)
@app.post("/b/{name}/blur")
def booth_blur(name: str, f: str = Form(...), on: str = Form("1")):