| 1 | ## TodoMVC on CARLOS: the HTTP server and its routes. |
| 2 | ## |
| 3 | ## app --socket <path> --db <path> (on Carloku) |
| 4 | ## app --listen 127.0.0.1:<port> --db <path> (local runs and tests) |
| 5 | |
| 6 | import std/[asyncdispatch, asyncnet, cookies, importutils, options, |
| 7 | nativesockets, net, os, parseopt, posix, strutils, strtabs, unicode, uri] |
| 8 | import httpserver, store, views |
| 9 | |
| 10 | const |
| 11 | appVersion {.strdefine.} = "dev" |
| 12 | # `make bump` edits this string so the next build has to recompile this file. |
| 13 | serverName = "todo-nim" |
| 14 | cookieName = "todo_list" |
| 15 | baseCss = staticRead("../../../shared/base.css") |
| 16 | indexCss = staticRead("../../../shared/index.css") |
| 17 | appCss = staticRead("../static/app.css") |
| 18 | appJs = staticRead("../static/app.js") |
| 19 | # A 10,000-character title of 4-byte characters, percent-encoded, is |
| 20 | # about 120 KB. Bodies past this are refused, chunked or not. |
| 21 | maxBody = 1024 * 1024 |
| 22 | |
| 23 | type |
| 24 | Config = object |
| 25 | socket, listen, db: string |
| 26 | |
| 27 | App = ref object |
| 28 | store: Store |
| 29 | origin: string ## the only Origin a POST may carry |
| 30 | secure: bool ## served over https, so the cookie gets `Secure` |
| 31 | |
| 32 | Reply = object |
| 33 | code: HttpCode |
| 34 | body: string |
| 35 | headers: seq[(string, string)] |
| 36 | |
| 37 | # Requests |
| 38 | |
| 39 | proc header(req: Request, name: string): string = |
| 40 | if req.headers.hasKey(name): $req.headers[name] else: "" |
| 41 | |
| 42 | proc parseFilter(s: string): Filter = |
| 43 | case s |
| 44 | of "active": fActive |
| 45 | of "completed": fCompleted |
| 46 | else: fAll |
| 47 | |
| 48 | const titleSpaces = block: |
| 49 | # What JavaScript's String.prototype.trim removes, as TodoMVC does. |
| 50 | var r: seq[Rune] |
| 51 | for c in [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680, 0x2028, 0x2029, |
| 52 | 0x202F, 0x205F, 0x3000, 0xFEFF]: |
| 53 | r.add Rune(c) |
| 54 | for c in 0x2000..0x200A: r.add Rune(c) |
| 55 | r |
| 56 | |
| 57 | proc trimTitle(s: string): string = |
| 58 | ## Call only on valid UTF-8: `unicode.strip` indexes out of bounds on a |
| 59 | ## truncated sequence, and that defect would stop the whole server. |
| 60 | unicode.strip(s, runes = titleSpaces) |
| 61 | |
| 62 | proc formFields(req: Request): StringTableRef = |
| 63 | result = newStringTable(modeCaseSensitive) |
| 64 | for (k, v) in decodeQuery(req.body): |
| 65 | result[k] = v |
| 66 | |
| 67 | proc queryParam(req: Request, name: string): string = |
| 68 | for (k, v) in decodeQuery(req.url.query): |
| 69 | if k == name: return v |
| 70 | |
| 71 | # Responses |
| 72 | |
| 73 | proc reply(code: HttpCode, body = "", contentType = "text/plain; charset=utf-8"): Reply = |
| 74 | Reply(code: code, body: body, headers: @{"Content-Type": contentType}) |
| 75 | |
| 76 | proc html(body: string): Reply = |
| 77 | result = reply(Http200, body, "text/html; charset=utf-8") |
| 78 | result.headers.add ("Cache-Control", "no-store") |
| 79 | |
| 80 | proc asset(body, contentType: string): Reply = |
| 81 | result = reply(Http200, body, contentType) |
| 82 | result.headers.add ("Cache-Control", "public, max-age=86400") |
| 83 | |
| 84 | proc seeOther(path: string): Reply = |
| 85 | result = reply(Http303) |
| 86 | result.headers.add ("Location", path) |
| 87 | |
| 88 | # The visitor's list |
| 89 | |
| 90 | proc listFor(app: App, req: Request, r: var Reply): string = |
| 91 | ## The list the visitor's cookie names, or a new one with a cookie that |
| 92 | ## says so. Garbled and unknown cookies get a new list too. |
| 93 | let jar = parseCookies(req.header("Cookie")) |
| 94 | result = jar.getOrDefault(cookieName) |
| 95 | if not app.store.listExists(result): |
| 96 | result = app.store.createList() |
| 97 | let cookie = setCookie(cookieName, result, path = "/", noName = true, |
| 98 | secure = app.secure, httpOnly = true, maxAge = some(400 * 24 * 3600), |
| 99 | sameSite = Lax) |
| 100 | r.headers.add ("Set-Cookie", cookie) |
| 101 | |
| 102 | proc view(app: App, list: string, filter: Filter, editing = 0'i64): View = |
| 103 | let (total, completed) = app.store.counts(list) |
| 104 | View(todos: app.store.todos(list, filter), total: total, completed: completed, |
| 105 | filter: filter, editing: editing, version: appVersion) |
| 106 | |
| 107 | # Routes |
| 108 | |
| 109 | proc page(app: App, req: Request, filter: Filter): Reply = |
| 110 | var r: Reply |
| 111 | let list = app.listFor(req, r) |
| 112 | let editing = try: parseBiggestInt(req.queryParam("edit")) except ValueError: 0 |
| 113 | result = html(renderPage(app.view(list, filter, editing))) |
| 114 | result.headers.add r.headers |
| 115 | |
| 116 | proc action(app: App, req: Request, path: string): Reply = |
| 117 | ## A form POST: change the list, then 303 back to the filter, or render the |
| 118 | ## changed list as a fragment when the page's JavaScript asks for one. |
| 119 | let origin = req.header("Origin") |
| 120 | if origin != "" and origin != app.origin: |
| 121 | return reply(Http403, "Cross-origin request refused\n") |
| 122 | var r: Reply |
| 123 | let list = app.listFor(req, r) |
| 124 | let form = req.formFields() |
| 125 | let filter = parseFilter(form.getOrDefault("filter")) |
| 126 | # Browsers always send UTF-8; anything else is refused before it's used. |
| 127 | if validateUtf8(form.getOrDefault("title")) != -1: |
| 128 | return reply(Http400, "The title isn't valid UTF-8\n") |
| 129 | let parts = path.split('/') # "", "todos", <id or verb>, [verb] |
| 130 | var found = true |
| 131 | if parts.len == 2: |
| 132 | let title = trimTitle(form.getOrDefault("title")) |
| 133 | if title.len > 0: app.store.add(list, title) |
| 134 | elif parts.len == 3 and parts[2] == "toggle-all": |
| 135 | app.store.toggleAll(list) |
| 136 | elif parts.len == 3 and parts[2] == "clear-completed": |
| 137 | app.store.clearCompleted(list) |
| 138 | elif parts.len == 4: |
| 139 | let id = try: parseBiggestInt(parts[2]) except ValueError: -1 |
| 140 | case parts[3] |
| 141 | of "toggle": found = app.store.toggle(list, id) |
| 142 | of "destroy": found = app.store.destroy(list, id) |
| 143 | of "edit": |
| 144 | let title = trimTitle(form.getOrDefault("title")) |
| 145 | found = if title.len > 0: app.store.rename(list, id, title) |
| 146 | else: app.store.destroy(list, id) |
| 147 | else: return reply(Http404, "Not found\n") |
| 148 | else: |
| 149 | return reply(Http404, "Not found\n") |
| 150 | result = |
| 151 | if not found: reply(Http404, "No such item in your list\n") |
| 152 | elif req.header("X-Fragment") == "1": |
| 153 | html(renderMain(app.view(list, filter))) |
| 154 | else: seeOther(filterPath(filter)) |
| 155 | result.headers.add r.headers |
| 156 | |
| 157 | proc route(app: App, req: Request): Reply = |
| 158 | let path = req.url.path |
| 159 | case req.reqMethod |
| 160 | of HttpGet: |
| 161 | case path |
| 162 | of "/": app.page(req, fAll) |
| 163 | of "/active": app.page(req, fActive) |
| 164 | of "/completed": app.page(req, fCompleted) |
| 165 | of "/healthz": reply(Http200, "ok\n") |
| 166 | of "/api/version": reply(Http200, appVersion) |
| 167 | of "/static/base.css": asset(baseCss, "text/css; charset=utf-8") |
| 168 | of "/static/index.css": asset(indexCss, "text/css; charset=utf-8") |
| 169 | of "/static/app.css": asset(appCss, "text/css; charset=utf-8") |
| 170 | of "/static/app.js": asset(appJs, "text/javascript; charset=utf-8") |
| 171 | else: reply(Http404, "Not found\n") |
| 172 | of HttpPost: |
| 173 | if path == "/todos" or path.startsWith("/todos/"): app.action(req, path) |
| 174 | else: reply(Http404, "Not found\n") |
| 175 | else: |
| 176 | reply(Http405, "Method not allowed\n") |
| 177 | |
| 178 | proc handle(app: App, req: Request) {.async.} = |
| 179 | var r: Reply |
| 180 | try: |
| 181 | r = app.route(req) |
| 182 | except CatchableError as e: |
| 183 | stderr.writeLine "error: ", req.reqMethod, " ", req.url.path, ": ", e.msg |
| 184 | r = reply(Http500, "Internal error\n") |
| 185 | r.headers.add ("Server", serverName) |
| 186 | # The server sets Content-Length from the body, in bytes. |
| 187 | await req.respond(r.code, r.body, newHttpHeaders(r.headers)) |
| 188 | |
| 189 | # Startup and shutdown |
| 190 | |
| 191 | const usage = "usage: app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>" |
| 192 | |
| 193 | proc parseArgs(): Config = |
| 194 | # Every flag takes a value; listing one with none makes `--flag value` work. |
| 195 | var p = initOptParser(commandLineParams(), longNoVal = @["help"]) |
| 196 | for kind, key, val in p.getopt(): |
| 197 | if kind != cmdLongOption: |
| 198 | quit "unexpected argument: " & key, 2 |
| 199 | case key |
| 200 | of "socket": result.socket = val |
| 201 | of "listen": result.listen = val |
| 202 | of "db": result.db = val |
| 203 | of "help": quit usage, 0 |
| 204 | else: quit "unknown flag --" & key, 2 |
| 205 | if result.db == "" or (result.socket == "") == (result.listen == ""): |
| 206 | quit usage, 2 |
| 207 | |
| 208 | proc unixServer(path: string): AsyncHttpServer = |
| 209 | ## The server only listens on TCP; hand it a unix socket instead. |
| 210 | privateAccess(AsyncHttpServer) |
| 211 | result = newAsyncHttpServer(maxBody = maxBody) |
| 212 | if fileExists(path) or symlinkExists(path): removeFile(path) |
| 213 | let sock = newAsyncSocket(Domain.AF_UNIX, SockType.SOCK_STREAM, Protocol.IPPROTO_IP) |
| 214 | sock.bindUnix(path) |
| 215 | sock.listen() |
| 216 | result.socket = sock |
| 217 | result.maxFDs = try: maxDescriptors() except OSError: nimMaxDescriptorsFallback |
| 218 | |
| 219 | proc main() = |
| 220 | let cfg = parseArgs() |
| 221 | let app = App(store: store.open(cfg.db)) |
| 222 | var server: AsyncHttpServer |
| 223 | if cfg.socket != "": |
| 224 | server = unixServer(cfg.socket) |
| 225 | app.origin = "https://" & getEnv("CARLOS_HOST") |
| 226 | app.secure = true |
| 227 | else: |
| 228 | let i = cfg.listen.rfind(':') |
| 229 | let host = if i < 0: "" else: cfg.listen[0 ..< i] |
| 230 | let port = try: parseInt(cfg.listen[i + 1 .. ^1]) except ValueError: -1 |
| 231 | if host != "127.0.0.1" or port notin 1..65535: |
| 232 | quit "--listen must be 127.0.0.1:<port>", 2 |
| 233 | server = newAsyncHttpServer(maxBody = maxBody) |
| 234 | server.listen(Port(port), host) |
| 235 | app.origin = "http://" & cfg.listen |
| 236 | |
| 237 | proc stop(fd: AsyncFD): bool = |
| 238 | server.close() |
| 239 | if cfg.socket != "": discard tryRemoveFile(cfg.socket) |
| 240 | app.store.close() |
| 241 | quit 0 |
| 242 | addSignal(SIGTERM.int, stop) |
| 243 | addSignal(SIGINT.int, stop) |
| 244 | |
| 245 | stderr.writeLine "todo-nim ", appVersion, " listening on ", |
| 246 | (if cfg.socket != "": cfg.socket else: cfg.listen) |
| 247 | proc serve() {.async.} = |
| 248 | while true: |
| 249 | if server.shouldAcceptRequest(): |
| 250 | await server.acceptRequest(proc (req: Request): Future[void] = app.handle(req)) |
| 251 | else: |
| 252 | await sleepAsync(10) |
| 253 | waitFor serve() |
| 254 | |
| 255 | when isMainModule and not defined(testing): |
| 256 | main() |
| 257 | |