## TodoMVC on CARLOS: the HTTP server and its routes. ## ## app --socket --db (on Carloku) ## app --listen 127.0.0.1: --db (local runs and tests) import std/[asyncdispatch, asyncnet, cookies, importutils, options, nativesockets, net, os, parseopt, posix, strutils, strtabs, unicode, uri] import httpserver, store, views const appVersion {.strdefine.} = "dev" # `make bump` edits this string so the next build has to recompile this file. serverName = "todo-nim" cookieName = "todo_list" baseCss = staticRead("../../../shared/base.css") indexCss = staticRead("../../../shared/index.css") appCss = staticRead("../static/app.css") appJs = staticRead("../static/app.js") # A 10,000-character title of 4-byte characters, percent-encoded, is # about 120 KB. Bodies past this are refused, chunked or not. maxBody = 1024 * 1024 type Config = object socket, listen, db: string App = ref object store: Store origin: string ## the only Origin a POST may carry secure: bool ## served over https, so the cookie gets `Secure` Reply = object code: HttpCode body: string headers: seq[(string, string)] # Requests proc header(req: Request, name: string): string = if req.headers.hasKey(name): $req.headers[name] else: "" proc parseFilter(s: string): Filter = case s of "active": fActive of "completed": fCompleted else: fAll const titleSpaces = block: # What JavaScript's String.prototype.trim removes, as TodoMVC does. var r: seq[Rune] for c in [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF]: r.add Rune(c) for c in 0x2000..0x200A: r.add Rune(c) r proc trimTitle(s: string): string = ## Call only on valid UTF-8: `unicode.strip` indexes out of bounds on a ## truncated sequence, and that defect would stop the whole server. unicode.strip(s, runes = titleSpaces) proc formFields(req: Request): StringTableRef = result = newStringTable(modeCaseSensitive) for (k, v) in decodeQuery(req.body): result[k] = v proc queryParam(req: Request, name: string): string = for (k, v) in decodeQuery(req.url.query): if k == name: return v # Responses proc reply(code: HttpCode, body = "", contentType = "text/plain; charset=utf-8"): Reply = Reply(code: code, body: body, headers: @{"Content-Type": contentType}) proc html(body: string): Reply = result = reply(Http200, body, "text/html; charset=utf-8") result.headers.add ("Cache-Control", "no-store") proc asset(body, contentType: string): Reply = result = reply(Http200, body, contentType) result.headers.add ("Cache-Control", "public, max-age=86400") proc seeOther(path: string): Reply = result = reply(Http303) result.headers.add ("Location", path) # The visitor's list proc listFor(app: App, req: Request, r: var Reply): string = ## The list the visitor's cookie names, or a new one with a cookie that ## says so. Garbled and unknown cookies get a new list too. let jar = parseCookies(req.header("Cookie")) result = jar.getOrDefault(cookieName) if not app.store.listExists(result): result = app.store.createList() let cookie = setCookie(cookieName, result, path = "/", noName = true, secure = app.secure, httpOnly = true, maxAge = some(400 * 24 * 3600), sameSite = Lax) r.headers.add ("Set-Cookie", cookie) proc view(app: App, list: string, filter: Filter, editing = 0'i64): View = let (total, completed) = app.store.counts(list) View(todos: app.store.todos(list, filter), total: total, completed: completed, filter: filter, editing: editing, version: appVersion) # Routes proc page(app: App, req: Request, filter: Filter): Reply = var r: Reply let list = app.listFor(req, r) let editing = try: parseBiggestInt(req.queryParam("edit")) except ValueError: 0 result = html(renderPage(app.view(list, filter, editing))) result.headers.add r.headers proc action(app: App, req: Request, path: string): Reply = ## A form POST: change the list, then 303 back to the filter, or render the ## changed list as a fragment when the page's JavaScript asks for one. let origin = req.header("Origin") if origin != "" and origin != app.origin: return reply(Http403, "Cross-origin request refused\n") var r: Reply let list = app.listFor(req, r) let form = req.formFields() let filter = parseFilter(form.getOrDefault("filter")) # Browsers always send UTF-8; anything else is refused before it's used. if validateUtf8(form.getOrDefault("title")) != -1: return reply(Http400, "The title isn't valid UTF-8\n") let parts = path.split('/') # "", "todos", , [verb] var found = true if parts.len == 2: let title = trimTitle(form.getOrDefault("title")) if title.len > 0: app.store.add(list, title) elif parts.len == 3 and parts[2] == "toggle-all": app.store.toggleAll(list) elif parts.len == 3 and parts[2] == "clear-completed": app.store.clearCompleted(list) elif parts.len == 4: let id = try: parseBiggestInt(parts[2]) except ValueError: -1 case parts[3] of "toggle": found = app.store.toggle(list, id) of "destroy": found = app.store.destroy(list, id) of "edit": let title = trimTitle(form.getOrDefault("title")) found = if title.len > 0: app.store.rename(list, id, title) else: app.store.destroy(list, id) else: return reply(Http404, "Not found\n") else: return reply(Http404, "Not found\n") result = if not found: reply(Http404, "No such item in your list\n") elif req.header("X-Fragment") == "1": html(renderMain(app.view(list, filter))) else: seeOther(filterPath(filter)) result.headers.add r.headers proc route(app: App, req: Request): Reply = let path = req.url.path case req.reqMethod of HttpGet: case path of "/": app.page(req, fAll) of "/active": app.page(req, fActive) of "/completed": app.page(req, fCompleted) of "/healthz": reply(Http200, "ok\n") of "/api/version": reply(Http200, appVersion) of "/static/base.css": asset(baseCss, "text/css; charset=utf-8") of "/static/index.css": asset(indexCss, "text/css; charset=utf-8") of "/static/app.css": asset(appCss, "text/css; charset=utf-8") of "/static/app.js": asset(appJs, "text/javascript; charset=utf-8") else: reply(Http404, "Not found\n") of HttpPost: if path == "/todos" or path.startsWith("/todos/"): app.action(req, path) else: reply(Http404, "Not found\n") else: reply(Http405, "Method not allowed\n") proc handle(app: App, req: Request) {.async.} = var r: Reply try: r = app.route(req) except CatchableError as e: stderr.writeLine "error: ", req.reqMethod, " ", req.url.path, ": ", e.msg r = reply(Http500, "Internal error\n") r.headers.add ("Server", serverName) # The server sets Content-Length from the body, in bytes. await req.respond(r.code, r.body, newHttpHeaders(r.headers)) # Startup and shutdown const usage = "usage: app (--socket | --listen 127.0.0.1:) --db " proc parseArgs(): Config = # Every flag takes a value; listing one with none makes `--flag value` work. var p = initOptParser(commandLineParams(), longNoVal = @["help"]) for kind, key, val in p.getopt(): if kind != cmdLongOption: quit "unexpected argument: " & key, 2 case key of "socket": result.socket = val of "listen": result.listen = val of "db": result.db = val of "help": quit usage, 0 else: quit "unknown flag --" & key, 2 if result.db == "" or (result.socket == "") == (result.listen == ""): quit usage, 2 proc unixServer(path: string): AsyncHttpServer = ## The server only listens on TCP; hand it a unix socket instead. privateAccess(AsyncHttpServer) result = newAsyncHttpServer(maxBody = maxBody) if fileExists(path) or symlinkExists(path): removeFile(path) let sock = newAsyncSocket(Domain.AF_UNIX, SockType.SOCK_STREAM, Protocol.IPPROTO_IP) sock.bindUnix(path) sock.listen() result.socket = sock result.maxFDs = try: maxDescriptors() except OSError: nimMaxDescriptorsFallback proc main() = let cfg = parseArgs() let app = App(store: store.open(cfg.db)) var server: AsyncHttpServer if cfg.socket != "": server = unixServer(cfg.socket) app.origin = "https://" & getEnv("CARLOS_HOST") app.secure = true else: let i = cfg.listen.rfind(':') let host = if i < 0: "" else: cfg.listen[0 ..< i] let port = try: parseInt(cfg.listen[i + 1 .. ^1]) except ValueError: -1 if host != "127.0.0.1" or port notin 1..65535: quit "--listen must be 127.0.0.1:", 2 server = newAsyncHttpServer(maxBody = maxBody) server.listen(Port(port), host) app.origin = "http://" & cfg.listen proc stop(fd: AsyncFD): bool = server.close() if cfg.socket != "": discard tryRemoveFile(cfg.socket) app.store.close() quit 0 addSignal(SIGTERM.int, stop) addSignal(SIGINT.int, stop) stderr.writeLine "todo-nim ", appVersion, " listening on ", (if cfg.socket != "": cfg.socket else: cfg.listen) proc serve() {.async.} = while true: if server.shouldAcceptRequest(): await server.acceptRequest(proc (req: Request): Future[void] = app.handle(req)) else: await sleepAsync(10) waitFor serve() when isMainModule and not defined(testing): main()