[OCaml] High Intensity Training Online
feat hide import before public release
Changed files
lib/presenter/pages.mli
@@ -92,6 +92,7 @@
92
92
Dream.request ->
93
93
?workout_started:int ->
94
94
viewer:View_model.Viewer.t ->
95
Added:
import_open:bool ->
95
96
?username_error:string ->
96
97
?password_error:string ->
97
98
?notice:string ->
lib/presenter/pages_profile.ml
@@ -6,8 +6,8 @@
6
6
independent forms: rename the account, and change the password. Each form
7
7
reports its own error inline. [notice] confirms a successful change. Both are
8
8
plain authenticated app-forms, so they work with or without script. *)
9
Removed:
let profile request ?workout_started ~viewer ?username_error ?password_error
10
Removed:
?notice () =
9
Added:
let profile request ?workout_started ~viewer ~import_open ?username_error
10
Added:
?password_error ?notice () =
11
11
let notice_block =
12
12
match notice with
13
13
| Some message -> [ tag "p" [ class_ "notice" ] [ txt "%s" message ] ]
@@ -177,70 +177,78 @@
177
177
]
178
178
@ error_block password_error);
179
179
];
180
Removed:
(* Import: a multipart form uploads a native Hevy CSV export. The file
180
Added:
]
181
Added:
@ (if import_open then
182
Added:
[
183
Added:
(* Import: a multipart form uploads a native Hevy CSV export. The file
181
184
field is [csv], the wall-clock offset is [utc_offset_seconds], and an
182
185
optional checkbox overrides duplicate-source rejection. The form is a
183
186
plain multipart POST with its CSRF token, so it needs no script. *)
184
Removed:
tag "section"
185
Removed:
[ class_ "profile-section" ]
186
Removed:
[
187
Removed:
tag "h2" [] [ txt "Import" ];
188
Removed:
tag "form"
189
Removed:
[
190
Removed:
action Routes.import_upload;
191
Removed:
post_form;
192
Removed:
Dream_html.string_attr "enctype" "multipart/form-data";
193
Removed:
class_ "profile-form";
194
Removed:
]
195
Removed:
[
196
Removed:
Dream_html.csrf_tag request;
197
Removed:
tag "div"
198
Removed:
[ class_ "field" ]
199
Removed:
[
200
Removed:
tag "label"
201
Removed:
[ Dream_html.string_attr "for" "csv" ]
202
Removed:
[ txt "Hevy CSV export" ];
203
Removed:
void "input"
204
Removed:
[
205
Removed:
type_ "file";
206
Removed:
name "csv";
207
Removed:
Dream_html.string_attr "id" "csv";
208
Removed:
Dream_html.string_attr "accept" ".csv,text/csv";
209
Removed:
required;
210
Removed:
];
211
Removed:
];
212
Removed:
tag "div"
213
Removed:
[ class_ "field" ]
214
Removed:
[
215
Removed:
tag "label"
216
Removed:
[ Dream_html.string_attr "for" "utc_offset_seconds" ]
217
Removed:
[ txt "UTC offset in seconds" ];
218
Removed:
void "input"
219
Removed:
[
220
Removed:
type_ "number";
221
Removed:
name "utc_offset_seconds";
222
Removed:
Dream_html.string_attr "id" "utc_offset_seconds";
223
Removed:
Dream_html.string_attr "value" "0";
224
Removed:
required;
225
Removed:
];
226
Removed:
];
227
Removed:
tag "div"
228
Removed:
[ class_ "field" ]
229
Removed:
[
230
Removed:
void "input"
231
Removed:
[
232
Removed:
type_ "checkbox";
233
Removed:
name "allow_duplicate";
234
Removed:
Dream_html.string_attr "id" "allow_duplicate";
235
Removed:
value "true";
236
Removed:
];
237
Removed:
tag "label"
238
Removed:
[ Dream_html.string_attr "for" "allow_duplicate" ]
239
Removed:
[ txt "Import even if this export was imported before" ];
240
Removed:
];
241
Removed:
void "input" [ type_ "submit"; value "Upload import" ];
242
Removed:
];
243
Removed:
];
187
Added:
tag "section"
188
Added:
[ class_ "profile-section" ]
189
Added:
[
190
Added:
tag "h2" [] [ txt "Import" ];
191
Added:
tag "form"
192
Added:
[
193
Added:
action Routes.import_upload;
194
Added:
post_form;
195
Added:
Dream_html.string_attr "enctype" "multipart/form-data";
196
Added:
class_ "profile-form";
197
Added:
]
198
Added:
[
199
Added:
Dream_html.csrf_tag request;
200
Added:
tag "div"
201
Added:
[ class_ "field" ]
202
Added:
[
203
Added:
tag "label"
204
Added:
[ Dream_html.string_attr "for" "csv" ]
205
Added:
[ txt "Hevy CSV export" ];
206
Added:
void "input"
207
Added:
[
208
Added:
type_ "file";
209
Added:
name "csv";
210
Added:
Dream_html.string_attr "id" "csv";
211
Added:
Dream_html.string_attr "accept" ".csv,text/csv";
212
Added:
required;
213
Added:
];
214
Added:
];
215
Added:
tag "div"
216
Added:
[ class_ "field" ]
217
Added:
[
218
Added:
tag "label"
219
Added:
[ Dream_html.string_attr "for" "utc_offset_seconds" ]
220
Added:
[ txt "UTC offset in seconds" ];
221
Added:
void "input"
222
Added:
[
223
Added:
type_ "number";
224
Added:
name "utc_offset_seconds";
225
Added:
Dream_html.string_attr "id" "utc_offset_seconds";
226
Added:
Dream_html.string_attr "value" "0";
227
Added:
required;
228
Added:
];
229
Added:
];
230
Added:
tag "div"
231
Added:
[ class_ "field" ]
232
Added:
[
233
Added:
void "input"
234
Added:
[
235
Added:
type_ "checkbox";
236
Added:
name "allow_duplicate";
237
Added:
Dream_html.string_attr "id" "allow_duplicate";
238
Added:
value "true";
239
Added:
];
240
Added:
tag "label"
241
Added:
[ Dream_html.string_attr "for" "allow_duplicate" ]
242
Added:
[
243
Added:
txt "Import even if this export was imported before";
244
Added:
];
245
Added:
];
246
Added:
void "input" [ type_ "submit"; value "Upload import" ];
247
Added:
];
248
Added:
];
249
Added:
]
250
Added:
else [])
251
Added:
@ [
244
252
tag "section"
245
253
[ class_ "profile-section profile-link-section" ]
246
254
[
lib/web/handlers.ml
@@ -38,7 +38,9 @@
38
38
let routes t =
39
39
Auth.routes t @ Overview.routes t @ Current_workout.routes t
40
40
@ Logbook.routes t @ App_feedback.routes t @ Profile.routes t
41
Removed:
@ Settings.routes t @ Import.routes t @ Assets.routes
41
Added:
@ Settings.routes t
42
Added:
@ (if t.import_open then Import.routes t else [])
43
Added:
@ Assets.routes
42
44
@ [ catch_all t ]
43
45
44
46
(* A branded error page for every failure Dream routes here: unmatched paths,
lib/web/handlers.mli
@@ -12,11 +12,12 @@
12
12
repo:R.t ->
13
13
?now:(unit -> Recovery.timestamp) ->
14
14
?registration_open:bool ->
15
Added:
?import_open:bool ->
15
16
unit ->
16
17
t
17
18
(** [registration_open] exposes the public [/register] routes and the sign-in
18
Removed:
page's Register link. Defaults to [false]: the deployment runs on
19
Removed:
pre-seeded accounts until public sign-up returns. *)
19
Added:
page's Register link. [import_open] exposes the Hevy import routes and the
20
Added:
profile import form. Both default to [false]. *)
20
21
21
22
val routes : t -> Dream.route list
22
23
(** The route table. Session and CSRF middleware are applied per handler. Wrap
lib/web/handlers_kit.ml
@@ -44,17 +44,20 @@
44
44
service : Service.t;
45
45
now : unit -> Recovery.timestamp;
46
46
registration_open : bool;
47
Added:
import_open : bool;
47
48
sign_in_limiter : Sign_in_limiter.t;
48
49
}
49
50
50
51
let default_now () =
51
52
Recovery.timestamp_of_unix_seconds (int_of_float (Unix.gettimeofday ()))
52
53
53
Removed:
let make ~repo ?(now = default_now) ?(registration_open = false) () =
54
Added:
let make ~repo ?(now = default_now) ?(registration_open = false)
55
Added:
?(import_open = false) () =
54
56
{
55
57
service = Service.make ~repo;
56
58
now;
57
59
registration_open;
60
Added:
import_open;
58
61
sign_in_limiter = Sign_in_limiter.make ();
59
62
}
60
63
lib/web/handlers_profile.ml
@@ -23,7 +23,7 @@
23
23
html
24
24
(Pages.profile request
25
25
?workout_started:(workout_started in_progress)
26
Removed:
~viewer ?notice ())
26
Added:
~viewer ~import_open:t.import_open ?notice ())
27
27
28
28
let render_error t trainee request ?username_error ?password_error () =
29
29
let viewer = viewer_of trainee in
@@ -31,7 +31,7 @@
31
31
html ~status:`Bad_Request
32
32
(Pages.profile request
33
33
?workout_started:(workout_started in_progress)
34
Removed:
~viewer ?username_error ?password_error ())
34
Added:
~viewer ~import_open:t.import_open ?username_error ?password_error ())
35
35
36
36
let change_username t trainee request =
37
37
decode_form Decode.profile_username request >>= function
test/web/import.ml
@@ -34,7 +34,7 @@
34
34
( "a valid native export uploads to a stored batch with immutable source",
35
35
`Quick,
36
36
fun () ->
37
Removed:
let c, repo = client_with_repo () in
37
Added:
let c, repo = client_with_repo ~import_open:true () in
38
38
let token = signed_in_profile_token c in
39
39
let trainee = the_trainee repo ~username:"lifter" in
40
40
let source =
@@ -62,7 +62,7 @@
62
62
( "a duplicate upload is refused, the override stores a second batch",
63
63
`Quick,
64
64
fun () ->
65
Removed:
let c, repo = client_with_repo () in
65
Added:
let c, repo = client_with_repo ~import_open:true () in
66
66
let token = signed_in_profile_token c in
67
67
let trainee = the_trainee repo ~username:"lifter" in
68
68
let source =
@@ -113,7 +113,7 @@
113
113
( "warmup and unsupported rows persist as warnings, not sets",
114
114
`Quick,
115
115
fun () ->
116
Removed:
let c, repo = client_with_repo () in
116
Added:
let c, repo = client_with_repo ~import_open:true () in
117
117
let token = signed_in_profile_token c in
118
118
let trainee = the_trainee repo ~username:"lifter" in
119
119
let source =
@@ -147,7 +147,7 @@
147
147
( "map, confirm, then promote writes an imported record",
148
148
`Quick,
149
149
fun () ->
150
Removed:
let c, repo = client_with_repo () in
150
Added:
let c, repo = client_with_repo ~import_open:true () in
151
151
let token = signed_in_profile_token c in
152
152
let trainee = the_trainee repo ~username:"lifter" in
153
153
(* A single normal set: it needs both a mapping and a normal-set
@@ -210,7 +210,7 @@
210
210
(* A drop set is discarded on import — HD1 keeps only the drive to
211
211
failure. A workout of nothing but a drop set therefore has no
212
212
promotable set and writes no record. *)
213
Removed:
let c, repo = client_with_repo () in
213
Added:
let c, repo = client_with_repo ~import_open:true () in
214
214
let token = signed_in_profile_token c in
215
215
let trainee = the_trainee repo ~username:"lifter" in
216
216
let source =
@@ -261,7 +261,7 @@
261
261
the blocked one",
262
262
`Quick,
263
263
fun () ->
264
Removed:
let c, repo = client_with_repo () in
264
Added:
let c, repo = client_with_repo ~import_open:true () in
265
265
let token = signed_in_profile_token c in
266
266
let trainee = the_trainee repo ~username:"lifter" in
267
267
(* Two workouts: one ready failure set, one dropset-only workout
@@ -329,7 +329,7 @@
329
329
nothing",
330
330
`Quick,
331
331
fun () ->
332
Removed:
let c, repo = client_with_repo () in
332
Added:
let c, repo = client_with_repo ~import_open:true () in
333
333
let token = signed_in_profile_token c in
334
334
let trainee = the_trainee repo ~username:"lifter" in
335
335
(* Unknown batch id: the review GET is a 404. *)
test/web/profile.ml
@@ -115,6 +115,18 @@
115
115
Alcotest.(check int)
116
116
"profile served separately" 200
117
117
(status (get c "/profile")) );
118
Added:
( "the initial release hides the import",
119
Added:
`Quick,
120
Added:
fun () ->
121
Added:
let c = client ~import_open:false () in
122
Added:
let _ = sign_in_new c in
123
Added:
let profile = body (get c "/profile") in
124
Added:
Alcotest.(check bool)
125
Added:
"profile offers no upload" true
126
Added:
(Option.is_none (index_from ~needle:"Upload import" profile 0));
127
Added:
Alcotest.(check int)
128
Added:
"import route is not served" 404
129
Added:
(status (post c "/profile/import" [])) );
118
130
( "the settings theme form redirects after a valid update",
119
131
`Quick,
120
132
fun () ->
test/web/support.ml
@@ -12,9 +12,10 @@
12
12
(* A fresh, fully wired application: secret, in-memory sessions, routes.
13
13
Registration is opened here so the auth-flow tests can create trainees via
14
14
[/register]; production keeps it closed. *)
15
Removed:
let app () =
15
Added:
let app ?(import_open = true) () =
16
16
let handlers =
17
Removed:
Handlers.make ~repo:(Test_repo.create ()) ~registration_open:true ()
17
Added:
Handlers.make ~repo:(Test_repo.create ()) ~registration_open:true
18
Added:
~import_open ()
18
19
in
19
20
Dream.memory_sessions @@ Handlers.security_headers
20
21
@@ Dream.router (Handlers.routes handlers)
@@ -41,7 +42,7 @@
41
42
42
43
type client = { app : Dream.handler; mutable jar : string list }
43
44
44
Removed:
let client () = { app = app (); jar = [] }
45
Added:
let client ?(import_open = true) () = { app = app ~import_open (); jar = [] }
45
46
46
47
let cookie_header client =
47
48
if client.jar = [] then [] else [ ("Cookie", String.concat "; " client.jar) ]
@@ -194,9 +195,9 @@
194
195
(* A client that hands back its repository, so a test can assert the domain
195
196
state the flow produced — the log, the stored feedback, the renamed account —
196
197
rather than reading it back out of rendered HTML. *)
197
Removed:
let client_with_repo () =
198
Added:
let client_with_repo ?(import_open = true) () =
198
199
let repo = Test_repo.create () in
199
Removed:
let handlers = Handlers.make ~repo ~registration_open:true () in
200
Added:
let handlers = Handlers.make ~repo ~registration_open:true ~import_open () in
200
201
let app =
201
202
Dream.memory_sessions @@ Dream.router (Handlers.routes handlers) |> fun h ->
202
203
Dream.set_secret "test-secret-value" h