# todo-spinel: TodoMVC, server-rendered, compiled to one native binary. # # tep (`bin/tep build app.rb`) translates this Sinatra-style source and # drives Spinel, matz's ahead-of-time Ruby compiler, to produce a static # executable. There is no Ruby at runtime, so this file sticks to the # subset Spinel compiles: explicit loops, String/Integer values, and # tep's request/response objects. # # app --socket --db (on Carloku) # app --listen 127.0.0.1: --db require 'sinatra' # documentation only; the tep translator ignores it require_relative "version" # APP_VERSION, written by the Makefile # `make bump` rewrites this string so the next build has to recompile the # file holding the GET / handler. It ends up in an HTML comment. BUILD_NOTE = "todo-spinel" COOKIE_NAME = "tid" # --------------------------------------------------------------------------- # Command line and environment. # The value after `flag` in ARGV, or "" when it isn't there. def arg_value(flag) v = "" i = 0 while i < ARGV.length if ARGV[i] == flag && i + 1 < ARGV.length v = ARGV[i + 1] end i += 1 end v end # This app's own origin, which a POST's Origin header must match: # https://$CARLOS_HOST on Carloku, http:// locally. def own_origin host = ENV["CARLOS_HOST"].to_s if host.length > 0 return "https://" + host end listen = arg_value("--listen") if listen.length > 0 return "http://" + listen end "" end def served_over_https(req) if ENV["CARLOS_HOST"].to_s.length > 0 return true end req.headers["x-forwarded-proto"].to_s == "https" end # --------------------------------------------------------------------------- # Storage. One SQLite file in WAL mode. Migrations only ever add. DB = Tep::SQLite.new MIGRATIONS = [ "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()))", "CREATE INDEX todos_list ON todos (list, id)", # Tokens this server has issued; a cookie not in here is not a list. "CREATE TABLE visitors (token TEXT PRIMARY KEY, created_at INTEGER NOT NULL DEFAULT (unixepoch())) WITHOUT ROWID", # Lists that existed before the table did were all issued by us. "INSERT OR IGNORE INTO visitors (token) SELECT DISTINCT list FROM todos" ] def open_db(path) if path.length == 0 puts "todo-spinel: --db is required" exit(2) end if !DB.open(path) puts "todo-spinel: cannot open " + path exit(1) end DB.exec("PRAGMA journal_mode=WAL") DB.exec("PRAGMA synchronous=NORMAL") DB.exec("PRAGMA busy_timeout=5000") version = DB.first_int("PRAGMA user_version", "") while version < MIGRATIONS.length DB.exec("BEGIN") if !DB.exec(MIGRATIONS[version]) DB.exec("ROLLBACK") puts "todo-spinel: migration " + (version + 1).to_s + " failed" exit(1) end version += 1 DB.exec("PRAGMA user_version=" + version.to_s) DB.exec("COMMIT") end end configure do open_db(arg_value("--db")) end # One visitor's todos, read in id order into parallel arrays (tep's # SQLite shim has a single cursor, so rows are copied out before any # other query runs). class TodoList attr_accessor :ids, :titles, :done def initialize @ids = [0] @ids.delete_at(0) @titles = [""] @titles.delete_at(0) @done = [0] @done.delete_at(0) end def self.load(list) l = TodoList.new DB.prepare("SELECT id, title, completed FROM todos WHERE list = ? ORDER BY id") DB.bind_str(1, list) while DB.step == 1 l.ids.push(DB.col_int(0)) l.titles.push(DB.col_str(1) + "") l.done.push(DB.col_int(2)) end DB.finalize l end def size @ids.length end def completed_count n = 0 i = 0 while i < @done.length if @done[i] != 0 n += 1 end i += 1 end n end end def run_sql(sql, list, id) DB.prepare(sql) DB.bind_str(1, list) if id > 0 DB.bind_int(2, id) end DB.step DB.finalize end def todo_exists(list, id) DB.prepare("SELECT 1 FROM todos WHERE list = ? AND id = ?") DB.bind_str(1, list) DB.bind_int(2, id) found = DB.step == 1 DB.finalize found end def add_todo(list, title) DB.prepare("INSERT INTO todos (list, title) VALUES (?, ?)") DB.bind_str(1, list) DB.bind_str(2, title) DB.step DB.finalize end def rename_todo(list, id, title) DB.prepare("UPDATE todos SET title = ? WHERE list = ? AND id = ?") DB.bind_str(1, title) DB.bind_str(2, list) DB.bind_int(3, id) DB.step DB.finalize end # --------------------------------------------------------------------------- # Visitors. Each one's list is keyed by an anonymous cookie holding 128 # random bits (22 base64url characters from getrandom). def valid_token(t) if t.length != 22 return false end i = 0 while i < t.length c = t[i] ok = (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "-" || c == "_" if !ok return false end i += 1 end true end def issued_token(t) DB.prepare("SELECT 1 FROM visitors WHERE token = ?") DB.bind_str(1, t) found = DB.step == 1 DB.finalize found end def record_token(t) DB.prepare("INSERT INTO visitors (token) VALUES (?)") DB.bind_str(1, t) DB.step DB.finalize end # The caller's list token. A missing cookie, or one this server never # issued (garbled, made up, or from another deployment), gets a fresh # random token, and so a fresh, empty list, set on this response. def visitor(req, res) t = req.cookies[COOKIE_NAME].to_s if valid_token(t) && issued_token(t) return t end t = Crypto.sp_crypto_random_b64url(16).to_s record_token(t) opts = Tep.str_hash opts["Path"] = "/" opts["Max-Age"] = "31536000" opts["HttpOnly"] = "" opts["SameSite"] = "Lax" if served_over_https(req) opts["Secure"] = "" end res.set_cookie(COOKIE_NAME, t, opts) t end # --------------------------------------------------------------------------- # Rendering. def h(s) s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """).gsub("'", "'") end def filter_name(f) if f == "active" || f == "completed" return f end "all" end def filter_path(f) if f == "active" return "/active" end if f == "completed" return "/completed" end "/" end def filter_hidden(f) "" end def shown(filter, done) if filter == "active" return done == 0 end if filter == "completed" return done != 0 end true end def render_item(id, title, done, filter, editing) sid = id.to_s et = h(title) cls = "" if done != 0 cls = "completed" end if editing cls = (cls + " editing").strip end out = "
  • 0 out = out + " class=\"" + cls + "\"" end out = out + ">\n
    \n" out = out + "\n\n" out = out + "\n" out = out + "edit\n" out = out + "\n
    \n" out = out + "
    " out = out + "" + filter_hidden(filter) + "
    \n" out = out + "\n" out = out + "\n" out + "
  • \n" end def filter_link(f, label, current) sel = "" if f == current sel = " class=\"selected\"" end "
  • \n" + label + "\n
  • \n" end # section.main and footer.footer for one filter: the part of the page an # action changes, and what the JavaScript swaps in after each POST. # Empty when the list is. def render_lists(list, filter, edit_id) l = TodoList.load(list) if l.size == 0 return "" end done = l.completed_count left = l.size - done out = "
    \n" out = out + "\n" out = out + "\n\n" out = out + "\n" out = out + "
      \n" i = 0 while i < l.size if shown(filter, l.done[i]) out = out + render_item(l.ids[i], l.titles[i], l.done[i], filter, l.ids[i] == edit_id) end i += 1 end out = out + "
    \n
    \n
    \n" word = "items" if left == 1 word = "item" end out = out + "" + left.to_s + " " + word + " left\n" out = out + "
      \n" + filter_link("all", "All", filter) + filter_link("active", "Active", filter) + filter_link("completed", "Completed", filter) + "
    \n" if done > 0 out = out + "\n" out = out + "\n" end out + "
    \n" end # --------------------------------------------------------------------------- # Actions. Every POST answers 303 back to its filter, or, when the page's # own script asks with `X-Fragment: 1`, 200 with the fresh fragment. def origin_allowed(req) o = req.headers["origin"].to_s if o.length == 0 return true end o == own_origin end def finish_post(req, res, list) f = filter_name(req.params["filter"].to_s) if req.headers["x-fragment"].to_s == "1" res.headers["Cache-Control"] = "no-store" return render_lists(list, f, 0) end res.set_status(303) res.headers["Location"] = filter_path(f) "" end def refuse(res, code, msg) res.set_status(code) res.headers["Content-Type"] = "text/plain; charset=utf-8" msg end # The numeric id in the path, or 0 when it isn't one. def path_id(req) s = req.params["id"].to_s if s.length == 0 || s.length > 18 return 0 end i = 0 while i < s.length if s[i] < "0" || s[i] > "9" return 0 end i += 1 end s.to_i end # The whole page. Its markup follows TodoMVC's own template # (shared/reference.html); render_lists fills in the middle. def layout(filter, lists, autofocus) out = "\n\n\n\n" out = out + "\n" out = out + "TodoMVC\n" out = out + "\n" out = out + "\n" out = out + "\n" out = out + "\n" out = out + "\n" out = out + "\n\n\n" out = out + "
    \n
    \n

    todos

    \n" out = out + "
    \n" out = out + "\n" + filter_hidden(filter) + "\n
    \n
    \n" out = out + lists out = out + "
    \n\n\n\n" out end def page(req, res, filter) list = visitor(req, res) edit_id = 0 e = req.params["edit"].to_s if e.length > 0 && e.length < 19 edit_id = e.to_i end res.headers["Cache-Control"] = "no-store" lists = render_lists(list, filter, edit_id) # The item being edited takes the focus; otherwise the new-todo box. layout(filter, lists, !lists.include?(" autofocus>")) end # --------------------------------------------------------------------------- # Routes. get '/' do page(request, response, "all") end get '/active' do page(request, response, "active") end get '/completed' do page(request, response, "completed") end get '/healthz' do content_type 'text/plain; charset=utf-8' "ok" end get '/api/version' do content_type 'text/plain; charset=utf-8' res.headers["Cache-Control"] = "no-store" APP_VERSION end post '/todos' do if !origin_allowed(request) return refuse(response, 403, "cross-origin POST refused") end list = visitor(request, response) title = request.params["title"].to_s.strip if title.length > 0 add_todo(list, title) end finish_post(request, response, list) end post '/todos/toggle-all' do if !origin_allowed(request) return refuse(response, 403, "cross-origin POST refused") end list = visitor(request, response) l = TodoList.load(list) if l.size > 0 && l.completed_count == l.size run_sql("UPDATE todos SET completed = 0 WHERE list = ?", list, 0) else run_sql("UPDATE todos SET completed = 1 WHERE list = ?", list, 0) end finish_post(request, response, list) end post '/todos/clear-completed' do if !origin_allowed(request) return refuse(response, 403, "cross-origin POST refused") end list = visitor(request, response) run_sql("DELETE FROM todos WHERE list = ? AND completed != 0", list, 0) finish_post(request, response, list) end post '/todos/:id/toggle' do if !origin_allowed(request) return refuse(response, 403, "cross-origin POST refused") end list = visitor(request, response) id = path_id(request) if id == 0 || !todo_exists(list, id) return refuse(response, 404, "no such todo") end run_sql("UPDATE todos SET completed = 1 - completed WHERE list = ? AND id = ?", list, id) finish_post(request, response, list) end post '/todos/:id/edit' do if !origin_allowed(request) return refuse(response, 403, "cross-origin POST refused") end list = visitor(request, response) id = path_id(request) if id == 0 || !todo_exists(list, id) return refuse(response, 404, "no such todo") end title = request.params["title"].to_s.strip if title.length > 0 rename_todo(list, id, title) else run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id) end finish_post(request, response, list) end post '/todos/:id/destroy' do if !origin_allowed(request) return refuse(response, 403, "cross-origin POST refused") end list = visitor(request, response) id = path_id(request) if id == 0 || !todo_exists(list, id) return refuse(response, 404, "no such todo") end run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id) finish_post(request, response, list) end