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:
+57
-4
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user