feat(r2): C3 server side — 204 on an explicit JSON Accept, and back=view

- wants_json: true only for an exact `application/json` entry with q > 0.
  Absent, empty, wildcard, application/*, near misses, q=0 and malformed
  headers all fall through to the 303.
- The four mark routes share one exit, _mark_done: 204 with no body for the
  in-place client, otherwise _mark_redirect unchanged.
- back=view lands on /b/<name>/view?f=<rel>#rail, only for a media item of
  this booth. It is built from the resolved rel and never echoed. Anything
  else takes the no-`back` landing.
- tests/golden/r2_mark_303.json: 108 responses recorded from the PRE-R2
  code (6 route cases x back absent|marks x 9 non-JSON Accepts), replayed
  byte for byte (INV-4). Two mutations (q>=0, substring match) turn it red.
- The contract now states the q=0 rule.
This commit is contained in:
vh
2026-09-23 08:36:54 -07:00
parent 277554a3f7
commit b9750d221a
4 changed files with 2315 additions and 6 deletions
+57 -4
View File
@@ -355,6 +355,35 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
pass
def wants_json(accept: str | None) -> bool:
"""Whether a mark POST asked for the in-place answer (R2 C3).
True ONLY when the Accept header lists `application/json` exactly —
parameters stripped — with a q-value that is absent or above zero. Absent,
empty, wildcard, `application/*`, a near miss like `application/jsonx`, an
explicit `q=0`, a malformed q: all False. It FAILS TOWARD THE 303, because
the plain form's redirect is the no-JS guarantee and a mis-parse must land
there, never on a 204 a browser would render as nothing happening.
"""
if not accept:
return False
try:
for entry in accept.split(","):
mtype, *params = entry.split(";")
if mtype.strip().lower() != "application/json":
continue
q = 1.0
for param in params:
key, _, value = param.partition("=")
if key.strip().lower() == "q":
q = float(value.strip())
if q > 0:
return True
except ValueError:
return False
return False
HOLD_UNREADABLE = "unreadable"
HOLD_OPEN = "open"
@@ -1183,8 +1212,32 @@ def create_app(
base = f"/b/{quote(name, safe='')}/"
if form.get("back") == "marks":
base = f"/b/{quote(name, safe='')}/marks"
elif form.get("back") == "view":
# R2 C3: judgment made at full size lands back at full size — the
# JS-off fix for being thrown out to the grid. Only for a MEDIA item
# of this booth; anything else takes the no-`back` landing above.
# Built from the resolved rel, never echoed from the form.
f = form.get("f")
if isinstance(f, str) and f:
try:
ring = review_chain(booth_items(resolve_booth(name)))
except HTTPException:
ring = []
if f in ring:
return RedirectResponse(
url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail",
status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)
def _mark_done(request: Request, name: str, form, anchor: str) -> Response:
"""The one exit for every mark route (R2 C3). A request that asked for
the in-place answer gets 204 and no body — the page fetches its own
fresh regions. Everything else gets `_mark_redirect`, byte for byte what
it got before R2 (INV-4)."""
if wants_json(request.headers.get("accept")):
return Response(status_code=204)
return _mark_redirect(name, form, anchor)
@app.post("/b/{name}/answer")
async def booth_answer(request: Request, name: str):
"""Record the operator's pick — one of N options a session declared in
@@ -1235,7 +1288,7 @@ def create_app(
_form_text(form, "choice"), notes, who=who)
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
return _mark_done(request, name, form, f"mark-{quote(mark_id, safe='')}")
@app.post("/b/{name}/note")
async def booth_note(request: Request, name: str):
@@ -1256,7 +1309,7 @@ def create_app(
who=request.client.host if request.client else "")
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}")
return _mark_done(request, name, form, f"mark-{quote(mark.id, safe='')}")
@app.post("/b/{name}/flag")
async def booth_flag(request: Request, name: str):
@@ -1278,7 +1331,7 @@ def create_app(
who=request.client.host if request.client else "")
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
return _mark_done(request, name, form, f"item-{quote(target, safe='')}")
@app.post("/b/{name}/unmark")
async def booth_unmark(request: Request, name: str):
@@ -1290,7 +1343,7 @@ def create_app(
if not isinstance(mark_id, str) or not mark_id:
raise HTTPException(status_code=400, detail="which mark?")
await run_in_threadpool(delete_mark, booth, mark_id)
return _mark_redirect(name, form, "marks")
return _mark_done(request, name, form, "marks")
@app.post("/b/{name}/import-asks")
async def booth_import_asks(request: Request, name: str):
+5 -2
View File
@@ -129,10 +129,13 @@ no route derives it.
### C3 — in-place judgment (app.py, base.html)
**`wants_json(request) -> bool`** is True **only** when the `Accept` header,
split on commas with parameters stripped, contains the exact media type
`application/json`.
split on commas, contains an entry whose media type, parameters stripped, is
exactly `application/json` and whose q-value is absent or greater than 0.
- Absent, empty, `*/*` or `application/*` → False.
- `application/json;q=0` → False. A client that explicitly refuses JSON gets
the redirect.
- A near miss such as `application/jsonx` → False.
- Any header that fails to parse → False.
- **It fails toward the 303.**
File diff suppressed because it is too large Load Diff
+73
View File
@@ -151,3 +151,76 @@ def test_a_look_that_cannot_be_recorded_still_serves_the_page(tmp_path):
b.chmod(0o755)
assert r.status_code == 200
assert not (b / ".seen").exists()
# ---- C3: in-place judgment --------------------------------------------------
GOLDEN = pathlib.Path(__file__).parent / "golden" / "r2_mark_303.json"
def _seed(root: pathlib.Path) -> TestClient:
"""The golden's fixture, byte for byte (see golden_gen in the R2 notes)."""
from booth.marks import declare_pick, write_note
root.mkdir(parents=True, exist_ok=True)
b = _booth(root, "g", {"a.png": PNG, "b b.png": PNG, "c.md": PNG})
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="a.png")
set_flag(b, "a.png", True)
write_note(b, "a.png", "seed")
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False),
follow_redirects=False)
def test_every_pre_r2_request_shape_gets_a_byte_identical_303(tmp_path):
"""INV-4. The golden was recorded from the PRE-R2 code: every mark route,
with `back` absent and `back=marks`, under nine Accept headers that must
NOT count as asking for JSON. Status, every header, and the body must match
exactly — the no-JS guarantee lives in these bytes."""
import json
cases = json.loads(GOLDEN.read_text())
assert len(cases) == 108
for i, case in enumerate(cases):
c = _seed(tmp_path / str(i))
headers = {} if case["accept"] is None else {"accept": case["accept"]}
r = c.post(case["path"], data=case["form"], headers=headers)
got = {"status": r.status_code,
"headers": sorted([k.lower(), v] for k, v in r.headers.items()),
"body": r.content.decode("latin-1")}
want = {k: case[k] for k in ("status", "headers", "body")}
assert got == want, (case["path"], case["form"], case["accept"])
@pytest.mark.parametrize("path,form", [
("/b/g/answer", {"ask": "q", "choice": "y"}),
("/b/g/note", {"target": "a.png", "text": "in place"}),
("/b/g/flag", {"target": "b b.png", "on": "1"}),
("/b/g/unmark", {"mark": "note-1"}),
])
@pytest.mark.parametrize("accept", ["application/json", "text/html, application/json;q=0.5"])
def test_an_explicit_json_accept_gets_204_and_the_write_still_lands(tmp_path, path, form, accept):
"""The in-place path: same write as the form, no redirect, no body."""
from booth.marks import marks_for
c = _seed(tmp_path)
before = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")]
r = c.post(path, data=form, headers={"accept": accept})
assert r.status_code == 204 and r.content == b""
assert "location" not in r.headers
after = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")]
assert after != before, "the write must happen exactly as for the form"
@pytest.mark.parametrize("f,landing", [
("a.png", "/b/g/view?f=a.png#rail"),
("b b.png", "/b/g/view?f=b%20b.png#rail"),
("c.md", "/b/g/#item-b%20b.png"), # a doc is not in the review ring
("gone.png", "/b/g/#item-b%20b.png"), # not an item
("", "/b/g/#item-b%20b.png"),
("../../etc/passwd", "/b/g/#item-b%20b.png"),
])
def test_back_view_lands_on_the_review_only_for_a_media_item(tmp_path, f, landing):
"""The JS-off fix for the bounce: a flag set at full size lands back at
full size. Anything that is not a media item in this booth falls back to
the booth page exactly as a form with no `back` does."""
c = _seed(tmp_path)
r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f})
assert r.status_code == 303
assert r.headers["location"] == landing