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## 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
6import std/[asyncdispatch, asyncnet, cookies, importutils, options,
7 nativesockets, net, os, parseopt, posix, strutils, strtabs, unicode, uri]
8import httpserver, store, views
9
10const
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
23type
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
39proc header(req: Request, name: string): string =
40 if req.headers.hasKey(name): $req.headers[name] else: ""
41
42proc parseFilter(s: string): Filter =
43 case s
44 of "active": fActive
45 of "completed": fCompleted
46 else: fAll
47
48const 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
57proc 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
62proc formFields(req: Request): StringTableRef =
63 result = newStringTable(modeCaseSensitive)
64 for (k, v) in decodeQuery(req.body):
65 result[k] = v
66
67proc 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
73proc reply(code: HttpCode, body = "", contentType = "text/plain; charset=utf-8"): Reply =
74 Reply(code: code, body: body, headers: @{"Content-Type": contentType})
75
76proc html(body: string): Reply =
77 result = reply(Http200, body, "text/html; charset=utf-8")
78 result.headers.add ("Cache-Control", "no-store")
79
80proc asset(body, contentType: string): Reply =
81 result = reply(Http200, body, contentType)
82 result.headers.add ("Cache-Control", "public, max-age=86400")
83
84proc seeOther(path: string): Reply =
85 result = reply(Http303)
86 result.headers.add ("Location", path)
87
88# The visitor's list
89
90proc 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
102proc 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
109proc 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
116proc 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
157proc 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
178proc 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
191const usage = "usage: app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>"
192
193proc 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
208proc 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
219proc 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
255when isMainModule and not defined(testing):
256 main()
257