Skip to content

Commit 5c12539

Browse files
yurugerikmd
andauthored
feat: Offer better protections against solution overwriting (#372)
* Mechanism 1: We disable the automatic and implicit saving of the student answer when the browser tab is closed. Instead, we ask the user to confirm that she wants to leave the page (unless the answer has already been synchronized). Related: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example * Mechanism 2: When an answer has not been modified for 3 minutes, we check (upon next keystroke) if a more recent solution exists on the server. In that case, we ask the user if she/he wants to download the most recent version. * Mechanism 3: To avoid overloading the server with many synchronization requests, we disable the synchronization button when the answer is synchronized, and reactive it only when a modification is made on the answer. Close #316 Fix #467 Co-authored-by: Yann Regis-Gianas <[email protected]> Co-authored-by: Erik Martin-Dorel <[email protected]>
1 parent 82a314a commit 5c12539

10 files changed

+272
-108
lines changed

src/ace-lib/ace.ml

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
(* This file is part of Learn-OCaml.
22
*
3-
* Copyright (C) 2019 OCaml Software Foundation.
3+
* Copyright (C) 2019-2022 OCaml Software Foundation.
44
* Copyright (C) 2016-2018 OCamlPro.
55
*
66
* Learn-OCaml is distributed under the terms of the MIT license. See the
@@ -20,6 +20,8 @@ type 'a editor = {
2020
editor: ('a editor * 'a option) Ace_types.editor Js.t;
2121
mutable marks: int list;
2222
mutable keybinding_menu: bool;
23+
mutable synchronized : bool;
24+
mutable sync_observers : (bool -> unit) list;
2325
}
2426

2527
let ace : Ace_types.ace Js.t = Js.Unsafe.variable "ace"
@@ -30,10 +32,13 @@ let create_position r c =
3032
pos##.row := r;
3133
pos##.column := c;
3234
pos
35+
3336
let greater_position p1 p2 =
3437
p1##.row > p2##.row ||
3538
(p1##.row = p2##.row && p1##.column > p2##.column)
3639

40+
let register_sync_observer editor obs =
41+
editor.sync_observers <- obs :: editor.sync_observers
3742

3843
let create_range s e =
3944
let range : range Js.t = Js.Unsafe.obj [||] in
@@ -77,15 +82,37 @@ let get_contents ?range e =
7782
let r = create_range (create_position r1 c1) (create_position r2 c2) in
7883
Js.to_string @@ document##(getTextRange r)
7984

80-
let create_editor editor_div =
85+
let set_synchronized_status editor status =
86+
List.iter (fun obs -> obs status) editor.sync_observers;
87+
editor.synchronized <- status
88+
89+
let focus { editor } = editor##focus
90+
91+
let create_editor editor_div check_valid_state =
8192
let editor = edit editor_div in
8293
Js.Unsafe.set editor "$blockScrolling" (Js.Unsafe.variable "Infinity");
8394
let data =
84-
{ editor; editor_div; marks = []; keybinding_menu = false; } in
95+
{ editor; editor_div;
96+
marks = [];
97+
keybinding_menu = false;
98+
synchronized = true;
99+
sync_observers = []
100+
}
101+
in
85102
editor##.customData := (data, None);
86103
editor##setOption (Js.string "displayIndentGuides") (Js.bool false);
104+
editor##on (Js.string "change") (fun () ->
105+
check_valid_state (set_contents data) (fun () -> focus data)
106+
(fun () -> set_synchronized_status data true);
107+
set_synchronized_status data false);
87108
data
88109

110+
let set_synchronized editor =
111+
set_synchronized_status editor true
112+
113+
let is_synchronized editor =
114+
editor.synchronized
115+
89116
let get_custom_data { editor } =
90117
match snd editor##.customData with
91118
| None -> raise Not_found
@@ -168,7 +195,6 @@ let clear_marks editor =
168195
let record_event_handler editor event handler =
169196
editor.editor##(on (Js.string event) handler)
170197

171-
let focus { editor } = editor##focus
172198
let resize { editor } force = editor##(resize (Js.bool force))
173199

174200
let get_keybinding_menu e =

src/ace-lib/ace.mli

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ type loc = {
1717
loc_end: int * int;
1818
}
1919

20-
val create_editor: Dom_html.divElement Js.t -> 'a editor
20+
val create_editor: Dom_html.divElement Js.t
21+
-> ((string -> unit) -> (unit -> unit) -> (unit -> unit) -> unit) -> 'a editor
22+
23+
val is_synchronized : 'a editor -> bool
24+
25+
val set_synchronized : 'a editor -> unit
26+
27+
val register_sync_observer : 'a editor -> (bool -> unit) -> unit
2128

2229
val set_mode: 'a editor -> string -> unit
2330

src/ace-lib/ocaml_mode.ml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,8 +514,8 @@ let do_delete ace_editor =
514514
Ace.remove ace_editor "left"
515515
end
516516

517-
let create_ocaml_editor div =
518-
let ace = Ace.create_editor div in
517+
let create_ocaml_editor div check_valid_state =
518+
let ace = Ace.create_editor div check_valid_state in
519519
Ace.set_mode ace "ace/mode/ocaml.ocp";
520520
Ace.set_tab_size ace !config.indent.IndentConfig.i_base;
521521
let editor = { ace; current_error = None; current_warnings = [] } in

src/ace-lib/ocaml_mode.mli

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ type msg = {
2020
msg: string;
2121
}
2222

23-
2423
type error = msg list
2524

2625
type warning = error
2726

28-
val create_ocaml_editor: Dom_html.divElement Js.t -> editor
27+
val create_ocaml_editor:
28+
Dom_html.divElement Js.t -> ((string -> unit) -> (unit -> unit) -> (unit -> unit) -> unit) -> editor
2929
val get_editor: editor -> editor Ace.editor
3030

3131
val report_error: editor -> ?set_class: bool -> error option -> warning list -> unit Lwt.t

src/app/learnocaml_common.ml

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
(* This file is part of Learn-OCaml.
22
*
3-
* Copyright (C) 2019 OCaml Software Foundation.
3+
* Copyright (C) 2019-2020 OCaml Software Foundation.
44
* Copyright (C) 2016-2018 OCamlPro.
55
*
66
* Learn-OCaml is distributed under the terms of the MIT license. See the
@@ -434,30 +434,34 @@ let get_state_as_save_file ?(include_reports = false) () =
434434
all_exercise_toplevel_histories = retrieve all_exercise_toplevel_histories;
435435
}
436436

437-
let rec sync_save token save_file =
437+
let rec sync_save token save_file on_sync =
438438
Server_caller.request (Learnocaml_api.Update_save (token, save_file))
439439
>>= function
440-
| Ok save -> set_state_from_save_file ~token save; Lwt.return save
440+
| Ok save ->
441+
set_state_from_save_file ~token save;
442+
on_sync ();
443+
Lwt.return save
441444
| Error (`Not_found _) ->
442445
Server_caller.request_exn
443446
(Learnocaml_api.Create_token ("", Some token, None)) >>= fun _token ->
444447
assert (_token = token);
445448
Server_caller.request_exn
446449
(Learnocaml_api.Update_save (token, save_file)) >>= fun save ->
447450
set_state_from_save_file ~token save;
451+
on_sync ();
448452
Lwt.return save
449453
| Error e ->
450454
lwt_alert ~title:[%i"SYNC FAILED"] [
451455
H.p [H.txt [%i"Could not synchronise save with the server"]];
452456
H.code [H.txt (Server_caller.string_of_error e)];
453457
] ~buttons:[
454-
[%i"Retry"], (fun () -> sync_save token save_file);
455-
[%i"Ignore"], (fun () -> Lwt.return save_file);
458+
[%i"Retry"], (fun () -> sync_save token save_file on_sync);
459+
[%i"Ignore"], (fun () -> Lwt.return save_file);
456460
]
457461

458-
let sync token = sync_save token (get_state_as_save_file ())
462+
let sync token on_sync = sync_save token (get_state_as_save_file ()) on_sync
459463

460-
let sync_exercise token ?answer ?editor id =
464+
let sync_exercise token ?answer ?editor id on_sync =
461465
let handle_serverless () =
462466
(* save the text at least locally (but not the report & grade, that could
463467
be misleading) *)
@@ -494,7 +498,7 @@ let sync_exercise token ?answer ?editor id =
494498
} in
495499
match token with
496500
| Some token ->
497-
Lwt.catch (fun () -> sync_save token save_file)
501+
Lwt.catch (fun () -> sync_save token save_file on_sync)
498502
(fun e ->
499503
handle_serverless ();
500504
raise e)
@@ -708,11 +712,72 @@ let mouseover_toggle_signal elt sigvalue setter =
708712
in
709713
Manip.Ev.onmouseover elt hdl
710714
715+
(*
716+
717+
If a user has made no change to a solution for the exercise [id]
718+
for 180 seconds, [check_valid_editor_state id] ensures that there is
719+
no more recent version of this solution in the server. If this is
720+
the case, the user is asked if we should download this solution
721+
from the server.
722+
723+
This function reduces the risk of an involuntary overwriting of a
724+
student solution when the solution is open in several clients.
725+
726+
*)
727+
let is_synchronized_with_server_callback = ref (fun () -> false)
728+
729+
let is_synchronized_with_server () = !is_synchronized_with_server_callback ()
730+
731+
let check_valid_editor_state id =
732+
let last_changed = ref (Unix.gettimeofday ()) in
733+
fun update_content focus_back on_sync ->
734+
let update_local_copy checking_time () =
735+
let get_solution () =
736+
Learnocaml_local_storage.(retrieve (exercise_state id)).Answer.solution in
737+
try let mtime =
738+
Learnocaml_local_storage.(retrieve (exercise_state id)).Answer.mtime in
739+
if mtime > checking_time then begin
740+
let buttons =
741+
if is_synchronized_with_server () then
742+
[
743+
[%i "Fetch from server"],
744+
(fun () -> let solution = get_solution () in
745+
Lwt.return (focus_back (); update_content solution; on_sync ()));
746+
[%i "Ignore & keep editing"],
747+
(fun () -> Lwt.return (focus_back ()));
748+
]
749+
else
750+
[
751+
[%i "Ignore & keep editing"],
752+
(fun () -> Lwt.return (focus_back ()));
753+
[%i "Fetch from server & overwrite"],
754+
(fun () -> let solution = get_solution () in
755+
Lwt.return (focus_back (); update_content solution; on_sync ()));
756+
]
757+
in
758+
lwt_alert ~title:"Question"
759+
~buttons
760+
[ H.p [H.txt [%i "A more recent answer exists on the server. \
761+
Do you want to fetch the new version?"] ] ]
762+
end else Lwt.return_unit
763+
with
764+
| Not_found -> Lwt.return ()
765+
in
766+
let now = Unix.gettimeofday () in
767+
if now -. !last_changed > 180. then (
768+
let checking_time = !last_changed in
769+
last_changed := now;
770+
Lwt.async (update_local_copy checking_time)
771+
) else
772+
last_changed := now
773+
774+
711775
let ace_display tab =
712776
let ace = lazy (
713777
let answer =
714778
Ocaml_mode.create_ocaml_editor
715779
(Tyxml_js.To_dom.of_div tab)
780+
(fun _ _ _ -> ())
716781
in
717782
let ace = Ocaml_mode.get_editor answer in
718783
Ace.set_font_size ace 16;
@@ -874,7 +939,8 @@ end
874939
875940
module Editor_button (E : Editor_info) = struct
876941
877-
let editor_button = button ~container:E.buttons_container ~theme:"light"
942+
let editor_button =
943+
button ~container:E.buttons_container ~theme:"light"
878944
879945
let cleanup template =
880946
editor_button
@@ -901,16 +967,26 @@ module Editor_button (E : Editor_info) = struct
901967
select_tab "toplevel";
902968
Lwt.return_unit
903969
904-
let sync token id =
905-
editor_button
970+
let sync token id on_sync =
971+
let state = button_state () in
972+
(editor_button
973+
~state
906974
~icon: "sync" [%i"Sync"] @@ fun () ->
907975
token >>= fun token ->
908-
sync_exercise token id ~editor:(Ace.get_contents E.ace) >|= fun _save -> ()
976+
sync_exercise token id ~editor:(Ace.get_contents E.ace) on_sync
977+
>|= fun _save -> ());
978+
Ace.register_sync_observer E.ace (fun sync ->
979+
if sync then disable_button state else enable_button state)
980+
909981
end
910982
911-
let setup_editor solution =
983+
let setup_editor id solution =
912984
let editor_pane = find_component "learnocaml-exo-editor-pane" in
913-
let editor = Ocaml_mode.create_ocaml_editor (Tyxml_js.To_dom.of_div editor_pane) in
985+
let editor =
986+
Ocaml_mode.create_ocaml_editor
987+
(Tyxml_js.To_dom.of_div editor_pane)
988+
(check_valid_editor_state id)
989+
in
914990
let ace = Ocaml_mode.get_editor editor in
915991
Ace.set_contents ace ~reset_undo:true solution;
916992
Ace.set_font_size ace 18;
@@ -1022,7 +1098,7 @@ let setup_prelude_pane ace prelude =
10221098
(fun _ -> state := not !state ; update () ; true) ;
10231099
Manip.appendChildren prelude_pane
10241100
[ prelude_title ; prelude_container ]
1025-
1101+
10261102
let get_token ?(has_server = true) () =
10271103
if not has_server then
10281104
Lwt.return None
@@ -1041,7 +1117,7 @@ let get_token ?(has_server = true) () =
10411117
>|= fun token ->
10421118
Learnocaml_local_storage.(store sync_token) token;
10431119
Some token
1044-
1120+
10451121
module Display_exercise =
10461122
functor (
10471123
Q: sig

src/app/learnocaml_common.mli

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,22 @@ val set_state_from_save_file :
119119
(** Gets a save file containing the locally stored data *)
120120
val get_state_as_save_file : ?include_reports:bool -> unit -> Save.t
121121

122-
(** Sync the local save state with the server state, and returns the merged save
123-
file. The save will be created on the server if it doesn't exist.
122+
(**
123+
[sync token on_sync] synchronizes the local save state with the server state,
124+
and returns the merged save file. The save will be created on the server
125+
if it doesn't exist. [on_sync ()] is called when this is done.
124126
125-
This syncs student {b content}, but never the reports which are only synched
126-
on "Grade" *)
127-
val sync: Token.t -> Save.t Lwt.t
127+
Notice that this function synchronizes student {b,content} but not the
128+
reports which are only synchronized when an actual "grading" is done.
129+
*)
130+
val sync: Token.t -> (unit -> unit) -> Save.t Lwt.t
128131

129132
(** The same, but limiting the submission to the given exercise, using the given
130133
answer if any, and the given editor text, if any. *)
131134
val sync_exercise:
132135
Token.t option -> ?answer:Learnocaml_data.Answer.t -> ?editor:string ->
133136
Learnocaml_data.Exercise.id ->
137+
(unit -> unit) ->
134138
Save.t Lwt.t
135139

136140
val countdown:
@@ -211,10 +215,12 @@ module Editor_button (_ : Editor_info) : sig
211215
val cleanup : string -> unit
212216
val download : string -> unit
213217
val eval : Learnocaml_toplevel.t -> (string -> unit) -> unit
214-
val sync : Token.t option Lwt.t -> Learnocaml_data.SMap.key -> unit
218+
val sync : Token.t option Lwt.t -> Learnocaml_data.SMap.key -> (unit -> unit) -> unit
215219
end
216220

217-
val setup_editor : string -> Ocaml_mode.editor * Ocaml_mode.editor Ace.editor
221+
val setup_editor : string -> string -> Ocaml_mode.editor * Ocaml_mode.editor Ace.editor
222+
223+
val is_synchronized_with_server_callback : (unit -> bool) ref
218224

219225
val typecheck :
220226
Learnocaml_toplevel.t ->

0 commit comments

Comments
 (0)