| 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> |
| 11 | require 'sinatra' # documentation only; the tep translator ignores it |
| 12 | |
| 13 | require_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. |
| 17 | BUILD_NOTE = "todo-spinel" |
| 18 | |
| 19 | COOKIE_NAME = "tid" |
| 20 | |
| 21 | # --------------------------------------------------------------------------- |
| 22 | # Command line and environment. |
| 23 | |
| 24 | # The value after `flag` in ARGV, or "" when it isn't there. |
| 25 | def 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 |
| 35 | end |
| 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. |
| 39 | def 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 | "" |
| 49 | end |
| 50 | |
| 51 | def 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" |
| 56 | end |
| 57 | |
| 58 | # --------------------------------------------------------------------------- |
| 59 | # Storage. One SQLite file in WAL mode. Migrations only ever add. |
| 60 | |
| 61 | DB = Tep::SQLite.new |
| 62 | |
| 63 | MIGRATIONS = [ |
| 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 | |
| 72 | def 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 |
| 96 | end |
| 97 | |
| 98 | configure do |
| 99 | open_db(arg_value("--db")) |
| 100 | end |
| 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). |
| 105 | class 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 |
| 145 | end |
| 146 | |
| 147 | def 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 |
| 155 | end |
| 156 | |
| 157 | def 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 |
| 164 | end |
| 165 | |
| 166 | def 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 |
| 172 | end |
| 173 | |
| 174 | def 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 |
| 181 | end |
| 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 | |
| 187 | def 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 |
| 201 | end |
| 202 | |
| 203 | def 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 |
| 209 | end |
| 210 | |
| 211 | def record_token(t) |
| 212 | DB.prepare("INSERT INTO visitors (token) VALUES (?)") |
| 213 | DB.bind_str(1, t) |
| 214 | DB.step |
| 215 | DB.finalize |
| 216 | end |
| 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. |
| 221 | def 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 |
| 238 | end |
| 239 | |
| 240 | # --------------------------------------------------------------------------- |
| 241 | # Rendering. |
| 242 | |
| 243 | def h(s) |
| 244 | s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """).gsub("'", "'") |
| 245 | end |
| 246 | |
| 247 | def filter_name(f) |
| 248 | if f == "active" || f == "completed" |
| 249 | return f |
| 250 | end |
| 251 | "all" |
| 252 | end |
| 253 | |
| 254 | def filter_path(f) |
| 255 | if f == "active" |
| 256 | return "/active" |
| 257 | end |
| 258 | if f == "completed" |
| 259 | return "/completed" |
| 260 | end |
| 261 | "/" |
| 262 | end |
| 263 | |
| 264 | def filter_hidden(f) |
| 265 | "<input type=\"hidden\" name=\"filter\" value=\"" + f + "\">" |
| 266 | end |
| 267 | |
| 268 | def 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 |
| 276 | end |
| 277 | |
| 278 | def 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" |
| 310 | end |
| 311 | |
| 312 | def 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" |
| 318 | end |
| 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. |
| 323 | def 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" |
| 358 | end |
| 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 | |
| 364 | def origin_allowed(req) |
| 365 | o = req.headers["origin"].to_s |
| 366 | if o.length == 0 |
| 367 | return true |
| 368 | end |
| 369 | o == own_origin |
| 370 | end |
| 371 | |
| 372 | def 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 | "" |
| 381 | end |
| 382 | |
| 383 | def refuse(res, code, msg) |
| 384 | res.set_status(code) |
| 385 | res.headers["Content-Type"] = "text/plain; charset=utf-8" |
| 386 | msg |
| 387 | end |
| 388 | |
| 389 | # The numeric id in the path, or 0 when it isn't one. |
| 390 | def 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 |
| 403 | end |
| 404 | |
| 405 | # The whole page. Its markup follows TodoMVC's own template |
| 406 | # (shared/reference.html); render_lists fills in the middle. |
| 407 | def 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 |
| 429 | end |
| 430 | |
| 431 | def 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>")) |
| 442 | end |
| 443 | |
| 444 | # --------------------------------------------------------------------------- |
| 445 | # Routes. |
| 446 | |
| 447 | get '/' do |
| 448 | page(request, response, "all") |
| 449 | end |
| 450 | |
| 451 | get '/active' do |
| 452 | page(request, response, "active") |
| 453 | end |
| 454 | |
| 455 | get '/completed' do |
| 456 | page(request, response, "completed") |
| 457 | end |
| 458 | |
| 459 | get '/healthz' do |
| 460 | content_type 'text/plain; charset=utf-8' |
| 461 | "ok" |
| 462 | end |
| 463 | |
| 464 | get '/api/version' do |
| 465 | content_type 'text/plain; charset=utf-8' |
| 466 | res.headers["Cache-Control"] = "no-store" |
| 467 | APP_VERSION |
| 468 | end |
| 469 | |
| 470 | post '/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) |
| 480 | end |
| 481 | |
| 482 | post '/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) |
| 494 | end |
| 495 | |
| 496 | post '/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) |
| 503 | end |
| 504 | |
| 505 | post '/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) |
| 516 | end |
| 517 | |
| 518 | post '/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) |
| 534 | end |
| 535 | |
| 536 | post '/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) |
| 547 | end |
| 548 | |