carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1(* Routes and actions, independent of the HTTP server: a request in, a
2 response out. bin/main.ml adapts this to cohttp-eio, and the tests call
3 it directly. *)
4
5type request = {
6 meth : string;
7 path : string;
8 query : (string * string) list;
9 header : string -> string option; (** by lower-case name *)
10 body : string;
11}
12
13type response = { status : int; headers : (string * string) list; body : string }
14
15type t = {
16 store : Store.t;
17 origin : string; (** this app's own origin, e.g. https://todo-ocaml.bfj.oncarlos.com *)
18 version : string;
19}
20
21(* `make bump` edits this constant so the next build must recompile this file. *)
22let build_marker = "todomvc"
23
24let create ~store ~origin ~version = { store; origin; version }
25
26let cookie_name = "todomvc_list"
27
28(* Kept for 400 days, the longest a browser will keep a cookie. *)
29let cookie_max_age = 400 * 24 * 60 * 60
30
31(* The inline script that marks the page as having JavaScript, and its hash
32 for the Content-Security-Policy. *)
33let csp =
34 "default-src 'self'; script-src 'self' 'sha256-qOhFsq0QMV2REqNwk5hGH5bZE9SkX6gt6yeyKdYPv+U='; \
35 img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
36
37let respond ?(headers = []) status body = { status; headers; body }
38let text ?(headers = []) status body =
39 respond status body ~headers:(("content-type", "text/plain; charset=utf-8") :: headers)
40
41let html ?(headers = []) status body =
42 respond status body
43 ~headers:
44 ([ ("content-type", "text/html; charset=utf-8");
45 ("cache-control", "private, no-cache");
46 ("content-security-policy", csp);
47 ("x-content-type-options", "nosniff");
48 ("referrer-policy", "same-origin") ]
49 @ headers)
50
51let is_token s =
52 String.length s = 32 && String.for_all (function '0' .. '9' | 'a' .. 'f' -> true | _ -> false) s
53
54(* The caller's list id. An unknown or garbled cookie gets a fresh list,
55 and the response sets the cookie for it. *)
56let visitor t req =
57 let known =
58 Option.bind (req.header "cookie") (fun h -> Form.cookie h cookie_name)
59 |> Option.map String.trim
60 |> Option.to_list
61 |> List.find_opt (fun id -> is_token id && Store.list_exists t.store id)
62 in
63 match known with
64 | Some id -> (id, [])
65 | None ->
66 let id = Rand.token () in
67 Store.create_list t.store id;
68 let secure = String.starts_with ~prefix:"https:" t.origin in
69 ( id,
70 [ ( "set-cookie",
71 Printf.sprintf "%s=%s; Max-Age=%d; Path=/; HttpOnly; SameSite=Lax%s" cookie_name id
72 cookie_max_age (if secure then "; Secure" else "") ) ] )
73
74let static name =
75 let asset body ctype =
76 respond 200 body
77 ~headers:[ ("content-type", ctype); ("cache-control", "public, max-age=86400");
78 ("x-content-type-options", "nosniff") ]
79 in
80 match name with
81 | "base.css" -> Some (asset Assets.base_css "text/css; charset=utf-8")
82 | "index.css" -> Some (asset Assets.index_css "text/css; charset=utf-8")
83 | "app.css" -> Some (asset Assets.app_css "text/css; charset=utf-8")
84 | "app.js" -> Some (asset Assets.app_js "text/javascript; charset=utf-8")
85 | _ -> None
86
87(* GET /, /active and /completed. *)
88let page t req filter =
89 let list, cookie = visitor t req in
90 let editing = Option.bind (List.assoc_opt "edit" req.query) int_of_string_opt in
91 html 200 ~headers:cookie (View.page ~version:t.version ~filter ?editing (Store.todos t.store ~list))
92
93type action =
94 | Add
95 | Toggle_all
96 | Clear_completed
97 | Toggle of int
98 | Edit of int
99 | Destroy of int
100
101let action_of_path path =
102 let id s =
103 if s <> "" && String.for_all (function '0' .. '9' -> true | _ -> false) s then int_of_string_opt s
104 else None
105 in
106 match String.split_on_char '/' path with
107 | [ ""; "todos" ] -> Some Add
108 | [ ""; "todos"; "toggle-all" ] -> Some Toggle_all
109 | [ ""; "todos"; "clear-completed" ] -> Some Clear_completed
110 | [ ""; "todos"; n; verb ] -> (
111 match (id n, verb) with
112 | Some n, "toggle" -> Some (Toggle n)
113 | Some n, "edit" -> Some (Edit n)
114 | Some n, "destroy" -> Some (Destroy n)
115 | _ -> None)
116 | _ -> None
117
118let perform store ~list fields = function
119 | Add ->
120 let title = Form.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) in
121 if title <> "" then Store.add store ~list title;
122 true
123 | Toggle_all -> Store.toggle_all store ~list; true
124 | Clear_completed -> Store.clear_completed store ~list; true
125 | Toggle id -> Store.toggle store ~list id
126 | Destroy id -> Store.delete store ~list id
127 | Edit id -> (
128 match Form.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) with
129 | "" -> Store.delete store ~list id
130 | title -> Store.rename store ~list id title)
131
132(* Every action is a form POST answering 303 back to the filter. With
133 X-Fragment: todoapp (sent by app.js) it answers section.todoapp instead. *)
134let post t req action =
135 match req.header "origin" with
136 | Some o when o <> t.origin -> text 403 "Cross-origin request refused\n"
137 | _ when not (String.is_valid_utf_8 req.body) -> text 400 "Bad form body\n"
138 | _ ->
139 let fields = Form.parse req.body in
140 if not (List.for_all (fun (_, v) -> String.is_valid_utf_8 v) fields) then text 400 "Bad form body\n"
141 else
142 let list, cookie = visitor t req in
143 let filter = View.filter_of_string (Option.value ~default:"" (List.assoc_opt "filter" fields)) in
144 if not (perform t.store ~list fields action) then text 404 ~headers:cookie "No such todo\n"
145 else if req.header "x-fragment" = Some "todoapp" then
146 html 200 ~headers:cookie (View.fragment ~filter (Store.todos t.store ~list))
147 else respond 303 "" ~headers:(("location", View.filter_path filter) :: cookie)
148
149let handle t req =
150 let filter = function
151 | "/" -> Some View.All
152 | "/active" -> Some View.Active
153 | "/completed" -> Some View.Completed
154 | _ -> None
155 in
156 match (req.meth, req.path) with
157 | ("GET" | "HEAD"), "/healthz" -> text 200 "ok\n"
158 | ("GET" | "HEAD"), "/api/version" -> text 200 t.version
159 | ("GET" | "HEAD"), p when String.starts_with ~prefix:"/static/" p -> (
160 match static (String.sub p 8 (String.length p - 8)) with
161 | Some r -> r
162 | None -> text 404 "Not found\n")
163 | ("GET" | "HEAD"), p when filter p <> None -> page t req (Option.get (filter p))
164 | "POST", p when action_of_path p <> None -> post t req (Option.get (action_of_path p))
165 | _, p when filter p <> None || action_of_path p <> None ->
166 text 405 "Method not allowed\n" ~headers:[ ("allow", if filter p <> None then "GET, HEAD" else "POST") ]
167 | _ -> text 404 "Not found\n"
168