fix Prevent feedback submission races

Lock each feedback form after its first score or action so rapid clicks cannot schedule duplicate stale submissions. If an intercepted feedback response cannot replace the app shell, return to /logbook instead of navigating GET /feedback, which has no GET route. Assert stale steps redirect to the logbook.

Commit
b627b2c8fe8302a818a609184ef8652877737685
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/web/pages.ml
index 674dbe5c..ed1cc4a0 100644..100644
@@ -1191,6 +1191,7 @@
1191 1191 action Routes.feedback;
1192 1192 post_form;
1193 1193 class_ "feedback-form";
1194 Added: Dream_html.attr "data-hito-feedback-form";
1194 1195 Dream_html.attr "data-hito-app-form";
1195 1196 ]
1196 1197 [ tag "fieldset" [] body ]
lib/web/workout_client.ml
index 12aca865..599fcffb 100644..100644
@@ -275,6 +275,10 @@
275 275 let workout =
276 276 Js.to_bool (form##hasAttribute (Js.string "data-hito-workout-form"))
277 277 in
278 Added: let feedback =
279 Added: Js.to_bool
280 Added: (form##hasAttribute (Js.string "data-hito-feedback-form"))
281 Added: in
278 282 let submit =
279 283 match
280 284 Js.Opt.to_option
@@ -317,7 +321,7 @@
317 321 push_url response.url;
318 322 Lwt.return_unit)
319 323 else (
320 Removed: fallback action;
324 Added: fallback (if feedback then "/logbook" else action);
321 325 Lwt.return_unit)));
322 326 Js._false)
323 327
@@ -398,17 +402,30 @@
398 402 | None -> Js._true
399 403 | Some form -> (
400 404 match
401 Removed: Js.Opt.to_option (action##getAttribute (Js.string "value"))
405 Added: Js.Opt.to_option
406 Added: (form##getAttribute
407 Added: (Js.string "data-hito-feedback-pending"))
402 408 with
403 Removed: | None -> Js._true
404 Removed: | Some value -> (
409 Added: | Some _ ->
410 Added: Dom.preventDefault event;
411 Added: Js._false
412 Added: | None -> (
405 413 match
406 Removed: query_one form "[data-hito-feedback-action-value]"
414 Added: Js.Opt.to_option
415 Added: (action##getAttribute (Js.string "value"))
407 416 with
408 417 | None -> Js._true
409 Removed: | Some hidden ->
410 Removed: hidden##setAttribute (Js.string "value") value;
411 Removed: Js._true)))))
418 Added: | Some value -> (
419 Added: match
420 Added: query_one form "[data-hito-feedback-action-value]"
421 Added: with
422 Added: | None -> Js._true
423 Added: | Some hidden ->
424 Added: form##setAttribute
425 Added: (Js.string "data-hito-feedback-pending")
426 Added: (Js.string "");
427 Added: hidden##setAttribute (Js.string "value") value;
428 Added: Js._true))))))
412 429
413 430 (* Selecting a score advances to the next factor without a separate Next
414 431 action. Handle click rather than change so selecting the current score again
@@ -421,17 +438,27 @@
421 438 match closest "form" choice with
422 439 | None -> Js._true
423 440 | Some form -> (
424 Removed: match query_one form "[data-hito-feedback-action-value]" with
425 Removed: | None -> Js._true
426 Removed: | Some hidden ->
427 Removed: ignore
428 Removed: (Dom_html.window##setTimeout
429 Removed: (Js.wrap_callback (fun () ->
430 Removed: hidden##setAttribute (Js.string "value")
431 Removed: (Js.string "next");
432 Removed: ignore (Js.Unsafe.meth_call form "requestSubmit" [||])))
433 Removed: (Js.number_of_float 0.));
434 Removed: Js._true))
441 Added: match
442 Added: Js.Opt.to_option
443 Added: (form##getAttribute (Js.string "data-hito-feedback-pending"))
444 Added: with
445 Added: | Some _ -> Js._false
446 Added: | None -> (
447 Added: match query_one form "[data-hito-feedback-action-value]" with
448 Added: | None -> Js._true
449 Added: | Some hidden ->
450 Added: form##setAttribute
451 Added: (Js.string "data-hito-feedback-pending")
452 Added: (Js.string "");
453 Added: ignore
454 Added: (Dom_html.window##setTimeout
455 Added: (Js.wrap_callback (fun () ->
456 Added: hidden##setAttribute (Js.string "value")
457 Added: (Js.string "next");
458 Added: ignore
459 Added: (Js.Unsafe.meth_call form "requestSubmit" [||])))
460 Added: (Js.number_of_float 0.));
461 Added: Js._true)))
435 462
436 463 (* The exercise dropdown navigates the moment its selection changes. It lives in
437 464 a GET form ([data-hito-exercise-form]) that submits to the workout path. The
test/test_web.ml
index 6e479a86..0e82a2b2 100644..100644
@@ -1586,6 +1586,9 @@
1586 1586 in
1587 1587 Alcotest.(check int)
1588 1588 "mismatched step redirects" 303 (status mismatched);
1589 Added: Alcotest.(check string)
1590 Added: "stale feedback returns to the logbook" "/logbook"
1591 Added: (location mismatched);
1589 1592 (* The mismatched post advanced nothing and stored no report. *)
1590 1593 Alcotest.(check int)
1591 1594 "no feedback stored" 0