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# todo-spinel: TodoMVC, server-rendered, compiled to one native binary.
2#
3# tep (`bin/tep build app.rb`) translates this Sinatra-style source and
4# drives Spinel, matz's ahead-of-time Ruby compiler, to produce a static
5# executable. There is no Ruby at runtime, so this file sticks to the
6# subset Spinel compiles: explicit loops, String/Integer values, and
7# tep's request/response objects.
8#
9# app --socket <path> --db <path> (on Carloku)
10# app --listen 127.0.0.1:<port> --db <path>
11require 'sinatra' # documentation only; the tep translator ignores it
12
13require_relative "version" # APP_VERSION, written by the Makefile
14
15# `make bump` rewrites this string so the next build has to recompile the
16# file holding the GET / handler. It ends up in an HTML comment.
17BUILD_NOTE = "todo-spinel"
18
19COOKIE_NAME = "tid"
20
21# ---------------------------------------------------------------------------
22# Command line and environment.
23
24# The value after `flag` in ARGV, or "" when it isn't there.
25def arg_value(flag)
26 v = ""
27 i = 0
28 while i < ARGV.length
29 if ARGV[i] == flag && i + 1 < ARGV.length
30 v = ARGV[i + 1]
31 end
32 i += 1
33 end
34 v
35end
36
37# This app's own origin, which a POST's Origin header must match:
38# https://$CARLOS_HOST on Carloku, http://<listen address> locally.
39def own_origin
40 host = ENV["CARLOS_HOST"].to_s
41 if host.length > 0
42 return "https://" + host
43 end
44 listen = arg_value("--listen")
45 if listen.length > 0
46 return "http://" + listen
47 end
48 ""
49end
50
51def served_over_https(req)
52 if ENV["CARLOS_HOST"].to_s.length > 0
53 return true
54 end
55 req.headers["x-forwarded-proto"].to_s == "https"
56end
57
58# ---------------------------------------------------------------------------
59# Storage. One SQLite file in WAL mode. Migrations only ever add.
60
61DB = Tep::SQLite.new
62
63MIGRATIONS = [
64 "CREATE TABLE todos (id INTEGER PRIMARY KEY, list TEXT NOT NULL, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()))",
65 "CREATE INDEX todos_list ON todos (list, id)",
66 # Tokens this server has issued; a cookie not in here is not a list.
67 "CREATE TABLE visitors (token TEXT PRIMARY KEY, created_at INTEGER NOT NULL DEFAULT (unixepoch())) WITHOUT ROWID",
68 # Lists that existed before the table did were all issued by us.
69 "INSERT OR IGNORE INTO visitors (token) SELECT DISTINCT list FROM todos"
70]
71
72def open_db(path)
73 if path.length == 0
74 puts "todo-spinel: --db <path> is required"
75 exit(2)
76 end
77 if !DB.open(path)
78 puts "todo-spinel: cannot open " + path
79 exit(1)
80 end
81 DB.exec("PRAGMA journal_mode=WAL")
82 DB.exec("PRAGMA synchronous=NORMAL")
83 DB.exec("PRAGMA busy_timeout=5000")
84 version = DB.first_int("PRAGMA user_version", "")
85 while version < MIGRATIONS.length
86 DB.exec("BEGIN")
87 if !DB.exec(MIGRATIONS[version])
88 DB.exec("ROLLBACK")
89 puts "todo-spinel: migration " + (version + 1).to_s + " failed"
90 exit(1)
91 end
92 version += 1
93 DB.exec("PRAGMA user_version=" + version.to_s)
94 DB.exec("COMMIT")
95 end
96end
97
98configure do
99 open_db(arg_value("--db"))
100end
101
102# One visitor's todos, read in id order into parallel arrays (tep's
103# SQLite shim has a single cursor, so rows are copied out before any
104# other query runs).
105class TodoList
106 attr_accessor :ids, :titles, :done
107
108 def initialize
109 @ids = [0]
110 @ids.delete_at(0)
111 @titles = [""]
112 @titles.delete_at(0)
113 @done = [0]
114 @done.delete_at(0)
115 end
116
117 def self.load(list)
118 l = TodoList.new
119 DB.prepare("SELECT id, title, completed FROM todos WHERE list = ? ORDER BY id")
120 DB.bind_str(1, list)
121 while DB.step == 1
122 l.ids.push(DB.col_int(0))
123 l.titles.push(DB.col_str(1) + "")
124 l.done.push(DB.col_int(2))
125 end
126 DB.finalize
127 l
128 end
129
130 def size
131 @ids.length
132 end
133
134 def completed_count
135 n = 0
136 i = 0
137 while i < @done.length
138 if @done[i] != 0
139 n += 1
140 end
141 i += 1
142 end
143 n
144 end
145end
146
147def run_sql(sql, list, id)
148 DB.prepare(sql)
149 DB.bind_str(1, list)
150 if id > 0
151 DB.bind_int(2, id)
152 end
153 DB.step
154 DB.finalize
155end
156
157def todo_exists(list, id)
158 DB.prepare("SELECT 1 FROM todos WHERE list = ? AND id = ?")
159 DB.bind_str(1, list)
160 DB.bind_int(2, id)
161 found = DB.step == 1
162 DB.finalize
163 found
164end
165
166def add_todo(list, title)
167 DB.prepare("INSERT INTO todos (list, title) VALUES (?, ?)")
168 DB.bind_str(1, list)
169 DB.bind_str(2, title)
170 DB.step
171 DB.finalize
172end
173
174def rename_todo(list, id, title)
175 DB.prepare("UPDATE todos SET title = ? WHERE list = ? AND id = ?")
176 DB.bind_str(1, title)
177 DB.bind_str(2, list)
178 DB.bind_int(3, id)
179 DB.step
180 DB.finalize
181end
182
183# ---------------------------------------------------------------------------
184# Visitors. Each one's list is keyed by an anonymous cookie holding 128
185# random bits (22 base64url characters from getrandom).
186
187def valid_token(t)
188 if t.length != 22
189 return false
190 end
191 i = 0
192 while i < t.length
193 c = t[i]
194 ok = (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "-" || c == "_"
195 if !ok
196 return false
197 end
198 i += 1
199 end
200 true
201end
202
203def issued_token(t)
204 DB.prepare("SELECT 1 FROM visitors WHERE token = ?")
205 DB.bind_str(1, t)
206 found = DB.step == 1
207 DB.finalize
208 found
209end
210
211def record_token(t)
212 DB.prepare("INSERT INTO visitors (token) VALUES (?)")
213 DB.bind_str(1, t)
214 DB.step
215 DB.finalize
216end
217
218# The caller's list token. A missing cookie, or one this server never
219# issued (garbled, made up, or from another deployment), gets a fresh
220# random token, and so a fresh, empty list, set on this response.
221def visitor(req, res)
222 t = req.cookies[COOKIE_NAME].to_s
223 if valid_token(t) && issued_token(t)
224 return t
225 end
226 t = Crypto.sp_crypto_random_b64url(16).to_s
227 record_token(t)
228 opts = Tep.str_hash
229 opts["Path"] = "/"
230 opts["Max-Age"] = "31536000"
231 opts["HttpOnly"] = ""
232 opts["SameSite"] = "Lax"
233 if served_over_https(req)
234 opts["Secure"] = ""
235 end
236 res.set_cookie(COOKIE_NAME, t, opts)
237 t
238end
239
240# ---------------------------------------------------------------------------
241# Rendering.
242
243def h(s)
244 s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub("\"", "&quot;").gsub("'", "&#39;")
245end
246
247def filter_name(f)
248 if f == "active" || f == "completed"
249 return f
250 end
251 "all"
252end
253
254def filter_path(f)
255 if f == "active"
256 return "/active"
257 end
258 if f == "completed"
259 return "/completed"
260 end
261 "/"
262end
263
264def filter_hidden(f)
265 "<input type=\"hidden\" name=\"filter\" value=\"" + f + "\">"
266end
267
268def shown(filter, done)
269 if filter == "active"
270 return done == 0
271 end
272 if filter == "completed"
273 return done != 0
274 end
275 true
276end
277
278def render_item(id, title, done, filter, editing)
279 sid = id.to_s
280 et = h(title)
281 cls = ""
282 if done != 0
283 cls = "completed"
284 end
285 if editing
286 cls = (cls + " editing").strip
287 end
288 out = "<li data-id=\"" + sid + "\""
289 if cls.length > 0
290 out = out + " class=\"" + cls + "\""
291 end
292 out = out + ">\n<div class=\"view\">\n"
293 out = out + "<input class=\"toggle\" type=\"checkbox\" form=\"toggle-" + sid + "\""
294 if done != 0
295 out = out + " checked"
296 end
297 out = out + ">\n<label>" + et + "</label>\n"
298 out = out + "<button class=\"nojs toggle-submit\" data-action=\"toggle\" form=\"toggle-" + sid + "\">Toggle</button>\n"
299 out = out + "<a class=\"nojs edit-link\" data-action=\"edit\" href=\"" + filter_path(filter) + "?edit=" + sid + "\">edit</a>\n"
300 out = out + "<button class=\"destroy\" form=\"destroy-" + sid + "\" title=\"Delete\"></button>\n</div>\n"
301 out = out + "<form class=\"edit-form\" method=\"post\" action=\"/todos/" + sid + "/edit\">"
302 out = out + "<input class=\"edit\" name=\"title\" value=\"" + et + "\""
303 if editing
304 out = out + " autofocus"
305 end
306 out = out + ">" + filter_hidden(filter) + "</form>\n"
307 out = out + "<form id=\"toggle-" + sid + "\" method=\"post\" action=\"/todos/" + sid + "/toggle\" hidden>" + filter_hidden(filter) + "</form>\n"
308 out = out + "<form id=\"destroy-" + sid + "\" method=\"post\" action=\"/todos/" + sid + "/destroy\" hidden>" + filter_hidden(filter) + "</form>\n"
309 out + "</li>\n"
310end
311
312def filter_link(f, label, current)
313 sel = ""
314 if f == current
315 sel = " class=\"selected\""
316 end
317 "<li>\n<a" + sel + " href=\"" + filter_path(f) + "\">" + label + "</a>\n</li>\n"
318end
319
320# section.main and footer.footer for one filter: the part of the page an
321# action changes, and what the JavaScript swaps in after each POST.
322# Empty when the list is.
323def render_lists(list, filter, edit_id)
324 l = TodoList.load(list)
325 if l.size == 0
326 return ""
327 end
328 done = l.completed_count
329 left = l.size - done
330 out = "<section class=\"main\">\n"
331 out = out + "<form id=\"toggle-all-form\" method=\"post\" action=\"/todos/toggle-all\" hidden>" + filter_hidden(filter) + "</form>\n"
332 out = out + "<input id=\"toggle-all\" class=\"toggle-all\" type=\"checkbox\" form=\"toggle-all-form\""
333 if left == 0
334 out = out + " checked"
335 end
336 out = out + ">\n<label for=\"toggle-all\">Mark all as complete</label>\n"
337 out = out + "<button class=\"nojs toggle-all-submit\" data-action=\"toggle-all\" form=\"toggle-all-form\">Mark all as complete</button>\n"
338 out = out + "<ul class=\"todo-list\">\n"
339 i = 0
340 while i < l.size
341 if shown(filter, l.done[i])
342 out = out + render_item(l.ids[i], l.titles[i], l.done[i], filter, l.ids[i] == edit_id)
343 end
344 i += 1
345 end
346 out = out + "</ul>\n</section>\n<footer class=\"footer\">\n"
347 word = "items"
348 if left == 1
349 word = "item"
350 end
351 out = out + "<span class=\"todo-count\"><strong>" + left.to_s + "</strong> " + word + " left</span>\n"
352 out = out + "<ul class=\"filters\">\n" + filter_link("all", "All", filter) + filter_link("active", "Active", filter) + filter_link("completed", "Completed", filter) + "</ul>\n"
353 if done > 0
354 out = out + "<form id=\"clear-completed-form\" method=\"post\" action=\"/todos/clear-completed\" hidden>" + filter_hidden(filter) + "</form>\n"
355 out = out + "<button class=\"clear-completed\" form=\"clear-completed-form\">Clear completed</button>\n"
356 end
357 out + "</footer>\n"
358end
359
360# ---------------------------------------------------------------------------
361# Actions. Every POST answers 303 back to its filter, or, when the page's
362# own script asks with `X-Fragment: 1`, 200 with the fresh fragment.
363
364def origin_allowed(req)
365 o = req.headers["origin"].to_s
366 if o.length == 0
367 return true
368 end
369 o == own_origin
370end
371
372def finish_post(req, res, list)
373 f = filter_name(req.params["filter"].to_s)
374 if req.headers["x-fragment"].to_s == "1"
375 res.headers["Cache-Control"] = "no-store"
376 return render_lists(list, f, 0)
377 end
378 res.set_status(303)
379 res.headers["Location"] = filter_path(f)
380 ""
381end
382
383def refuse(res, code, msg)
384 res.set_status(code)
385 res.headers["Content-Type"] = "text/plain; charset=utf-8"
386 msg
387end
388
389# The numeric id in the path, or 0 when it isn't one.
390def path_id(req)
391 s = req.params["id"].to_s
392 if s.length == 0 || s.length > 18
393 return 0
394 end
395 i = 0
396 while i < s.length
397 if s[i] < "0" || s[i] > "9"
398 return 0
399 end
400 i += 1
401 end
402 s.to_i
403end
404
405# The whole page. Its markup follows TodoMVC's own template
406# (shared/reference.html); render_lists fills in the middle.
407def layout(filter, lists, autofocus)
408 out = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
409 out = out + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
410 out = out + "<title>TodoMVC</title>\n"
411 out = out + "<link rel=\"stylesheet\" href=\"/static/base.css\">\n"
412 out = out + "<link rel=\"stylesheet\" href=\"/static/index.css\">\n"
413 out = out + "<link rel=\"stylesheet\" href=\"/static/app.css\">\n"
414 out = out + "<script>document.documentElement.classList.add(\"js\")</script>\n"
415 out = out + "<script type=\"module\" src=\"/static/app.js\"></script>\n"
416 out = out + "<!-- " + BUILD_NOTE + " -->\n</head>\n<body>\n"
417 out = out + "<section class=\"todoapp\">\n<header class=\"header\">\n<h1>todos</h1>\n"
418 out = out + "<form method=\"post\" action=\"/todos\">\n"
419 out = out + "<input class=\"new-todo\" name=\"title\" placeholder=\"What needs to be done?\" autocomplete=\"off\""
420 if autofocus
421 out = out + " autofocus"
422 end
423 out = out + ">\n" + filter_hidden(filter) + "\n</form>\n</header>\n"
424 out = out + lists
425 out = out + "</section>\n<footer class=\"info\">\n<p>Double-click to edit a todo</p>\n"
426 out = out + "<p>Created by <a href=\"https://bakeoff.bfj.oncarlos.com\">the CARLOS bakeoff</a> \u00b7 Spinel</p>\n"
427 out = out + "<p>Part of <a href=\"http://todomvc.com\">TodoMVC</a></p>\n</footer>\n</body>\n</html>\n"
428 out
429end
430
431def page(req, res, filter)
432 list = visitor(req, res)
433 edit_id = 0
434 e = req.params["edit"].to_s
435 if e.length > 0 && e.length < 19
436 edit_id = e.to_i
437 end
438 res.headers["Cache-Control"] = "no-store"
439 lists = render_lists(list, filter, edit_id)
440 # The item being edited takes the focus; otherwise the new-todo box.
441 layout(filter, lists, !lists.include?(" autofocus>"))
442end
443
444# ---------------------------------------------------------------------------
445# Routes.
446
447get '/' do
448 page(request, response, "all")
449end
450
451get '/active' do
452 page(request, response, "active")
453end
454
455get '/completed' do
456 page(request, response, "completed")
457end
458
459get '/healthz' do
460 content_type 'text/plain; charset=utf-8'
461 "ok"
462end
463
464get '/api/version' do
465 content_type 'text/plain; charset=utf-8'
466 res.headers["Cache-Control"] = "no-store"
467 APP_VERSION
468end
469
470post '/todos' do
471 if !origin_allowed(request)
472 return refuse(response, 403, "cross-origin POST refused")
473 end
474 list = visitor(request, response)
475 title = request.params["title"].to_s.strip
476 if title.length > 0
477 add_todo(list, title)
478 end
479 finish_post(request, response, list)
480end
481
482post '/todos/toggle-all' do
483 if !origin_allowed(request)
484 return refuse(response, 403, "cross-origin POST refused")
485 end
486 list = visitor(request, response)
487 l = TodoList.load(list)
488 if l.size > 0 && l.completed_count == l.size
489 run_sql("UPDATE todos SET completed = 0 WHERE list = ?", list, 0)
490 else
491 run_sql("UPDATE todos SET completed = 1 WHERE list = ?", list, 0)
492 end
493 finish_post(request, response, list)
494end
495
496post '/todos/clear-completed' do
497 if !origin_allowed(request)
498 return refuse(response, 403, "cross-origin POST refused")
499 end
500 list = visitor(request, response)
501 run_sql("DELETE FROM todos WHERE list = ? AND completed != 0", list, 0)
502 finish_post(request, response, list)
503end
504
505post '/todos/:id/toggle' do
506 if !origin_allowed(request)
507 return refuse(response, 403, "cross-origin POST refused")
508 end
509 list = visitor(request, response)
510 id = path_id(request)
511 if id == 0 || !todo_exists(list, id)
512 return refuse(response, 404, "no such todo")
513 end
514 run_sql("UPDATE todos SET completed = 1 - completed WHERE list = ? AND id = ?", list, id)
515 finish_post(request, response, list)
516end
517
518post '/todos/:id/edit' do
519 if !origin_allowed(request)
520 return refuse(response, 403, "cross-origin POST refused")
521 end
522 list = visitor(request, response)
523 id = path_id(request)
524 if id == 0 || !todo_exists(list, id)
525 return refuse(response, 404, "no such todo")
526 end
527 title = request.params["title"].to_s.strip
528 if title.length > 0
529 rename_todo(list, id, title)
530 else
531 run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id)
532 end
533 finish_post(request, response, list)
534end
535
536post '/todos/:id/destroy' do
537 if !origin_allowed(request)
538 return refuse(response, 403, "cross-origin POST refused")
539 end
540 list = visitor(request, response)
541 id = path_id(request)
542 if id == 0 || !todo_exists(list, id)
543 return refuse(response, 404, "no such todo")
544 end
545 run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id)
546 finish_post(request, response, list)
547end
548