| 1 | //! Routes and handlers. One `handle` call answers one HTTP request. |
| 2 | |
| 3 | const std = @import("std"); |
| 4 | const http = std.http; |
| 5 | const Allocator = std.mem.Allocator; |
| 6 | const Io = std.Io; |
| 7 | const build_options = @import("build_options"); |
| 8 | const db = @import("db.zig"); |
| 9 | const view = @import("view.zig"); |
| 10 | const Filter = view.Filter; |
| 11 | |
| 12 | /// The body of `GET /healthz`. `make bump` edits this string. |
| 13 | const health_body = "ok"; |
| 14 | |
| 15 | const cookie_name = "todo_list"; |
| 16 | /// Raw token bytes; the cookie carries them hex-encoded (256 bits). |
| 17 | const token_len = 32; |
| 18 | const max_body = 1 << 20; |
| 19 | |
| 20 | /// The request header app.js sends; with it, actions answer 200 with the |
| 21 | /// re-rendered page instead of a 303. |
| 22 | const fetch_header = "x-requested-with"; |
| 23 | |
| 24 | pub const App = struct { |
| 25 | store: *db.Store, |
| 26 | io: Io, |
| 27 | /// Origins a POST may come from. Empty means "match the Host header". |
| 28 | origins: []const []const u8, |
| 29 | /// Whether the app is served over https, so its cookie is `Secure`. |
| 30 | https: bool, |
| 31 | }; |
| 32 | |
| 33 | /// The headers this app reads, copied out before the body is read |
| 34 | /// (reading the body invalidates the request's header memory). |
| 35 | const Headers = struct { |
| 36 | cookie: ?[]const u8 = null, |
| 37 | origin: ?[]const u8 = null, |
| 38 | host: ?[]const u8 = null, |
| 39 | content_type: ?[]const u8 = null, |
| 40 | forwarded_proto: ?[]const u8 = null, |
| 41 | from_script: bool = false, |
| 42 | |
| 43 | fn read(req: *const http.Server.Request) Headers { |
| 44 | var h: Headers = .{ .content_type = req.head.content_type }; |
| 45 | var it = req.iterateHeaders(); |
| 46 | while (it.next()) |field| { |
| 47 | if (std.ascii.eqlIgnoreCase(field.name, "cookie")) { |
| 48 | h.cookie = field.value; |
| 49 | } else if (std.ascii.eqlIgnoreCase(field.name, "origin")) { |
| 50 | h.origin = field.value; |
| 51 | } else if (std.ascii.eqlIgnoreCase(field.name, "host")) { |
| 52 | h.host = field.value; |
| 53 | } else if (std.ascii.eqlIgnoreCase(field.name, "x-forwarded-proto")) { |
| 54 | h.forwarded_proto = field.value; |
| 55 | } else if (std.ascii.eqlIgnoreCase(field.name, fetch_header)) { |
| 56 | h.from_script = true; |
| 57 | } |
| 58 | } |
| 59 | return h; |
| 60 | } |
| 61 | }; |
| 62 | |
| 63 | /// What a handler decided; `handle` turns it into bytes on the wire. |
| 64 | const Response = struct { |
| 65 | status: http.Status = .ok, |
| 66 | content_type: []const u8 = "text/plain; charset=utf-8", |
| 67 | body: []const u8 = "", |
| 68 | location: ?[]const u8 = null, |
| 69 | cache: []const u8 = "no-store", |
| 70 | }; |
| 71 | |
| 72 | pub fn handle(app: *const App, req: *http.Server.Request, arena: Allocator) !void { |
| 73 | // Copy what we need before reading the body invalidates it. |
| 74 | const target = try arena.dupe(u8, req.head.target); |
| 75 | const method = req.head.method; |
| 76 | var headers = Headers.read(req); |
| 77 | inline for (.{ "cookie", "origin", "host", "content_type", "forwarded_proto" }) |name| { |
| 78 | if (@field(headers, name)) |v| @field(headers, name) = try arena.dupe(u8, v); |
| 79 | } |
| 80 | |
| 81 | const query_start = std.mem.findScalar(u8, target, '?'); |
| 82 | const path = target[0 .. query_start orelse target.len]; |
| 83 | const query = if (query_start) |q| target[q + 1 ..] else ""; |
| 84 | |
| 85 | var visitor = Visitor{ .app = app, .headers = &headers }; |
| 86 | const res: Response = switch (method) { |
| 87 | .GET, .HEAD => try get(app, &visitor, arena, path, query), |
| 88 | .POST => post: { |
| 89 | // A body we can't size can't be read safely on a kept-alive |
| 90 | // connection; refuse it rather than guess. |
| 91 | if (req.head.content_length == null and req.head.transfer_encoding == .none) { |
| 92 | break :post .{ .status = .length_required, .body = "length required\n" }; |
| 93 | } |
| 94 | if (!originAllowed(app, &headers)) break :post .{ .status = .forbidden, .body = "cross-origin request refused\n" }; |
| 95 | const reader = req.readerExpectContinue(&.{}) catch return error.WriteFailed; |
| 96 | const body = reader.allocRemaining(arena, .limited(max_body)) catch |err| switch (err) { |
| 97 | error.StreamTooLong => break :post .{ .status = .payload_too_large, .body = "too large\n" }, |
| 98 | else => return err, |
| 99 | }; |
| 100 | break :post try post(app, &visitor, arena, path, body, headers.from_script); |
| 101 | }, |
| 102 | else => .{ .status = .method_not_allowed, .body = "method not allowed\n" }, |
| 103 | }; |
| 104 | |
| 105 | var extra: [4]http.Header = undefined; |
| 106 | var n: usize = 0; |
| 107 | extra[n] = .{ .name = "content-type", .value = res.content_type }; |
| 108 | n += 1; |
| 109 | extra[n] = .{ .name = "cache-control", .value = res.cache }; |
| 110 | n += 1; |
| 111 | if (res.location) |loc| { |
| 112 | extra[n] = .{ .name = "location", .value = loc }; |
| 113 | n += 1; |
| 114 | } |
| 115 | if (visitor.set_cookie) |cookie| { |
| 116 | extra[n] = .{ .name = "set-cookie", .value = cookie }; |
| 117 | n += 1; |
| 118 | } |
| 119 | // After a refused or oversized body, the rest of it may still be on |
| 120 | // the wire, so the connection can't carry another request. |
| 121 | const keep_alive = res.status != .length_required and res.status != .payload_too_large; |
| 122 | try req.respond(res.body, .{ |
| 123 | .status = res.status, |
| 124 | .extra_headers = extra[0..n], |
| 125 | .keep_alive = keep_alive, |
| 126 | }); |
| 127 | } |
| 128 | |
| 129 | fn get(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, query: []const u8) !Response { |
| 130 | if (std.mem.eql(u8, path, "/healthz")) return .{ .body = health_body }; |
| 131 | if (std.mem.eql(u8, path, "/api/version")) return .{ .body = build_options.version }; |
| 132 | if (std.mem.startsWith(u8, path, "/static/")) return static(path["/static/".len..]); |
| 133 | |
| 134 | const filter = Filter.fromPath(path) orelse return .{ .status = .not_found, .body = "not found\n" }; |
| 135 | const list = try visitor.list(); |
| 136 | const todos = try app.store.todos(arena, list); |
| 137 | var editing: ?i64 = null; |
| 138 | var params = std.mem.splitScalar(u8, query, '&'); |
| 139 | while (params.next()) |param| { |
| 140 | if (std.mem.startsWith(u8, param, "edit=")) editing = std.fmt.parseInt(i64, param["edit=".len..], 10) catch null; |
| 141 | } |
| 142 | return page(arena, .{ .todos = todos, .filter = filter, .editing = editing }); |
| 143 | } |
| 144 | |
| 145 | fn page(arena: Allocator, p: view.Page) !Response { |
| 146 | var out: Io.Writer.Allocating = try .initCapacity(arena, 4096 + 512 * p.todos.len); |
| 147 | try view.render(&out.writer, p); |
| 148 | return .{ .content_type = "text/html; charset=utf-8", .body = out.written() }; |
| 149 | } |
| 150 | |
| 151 | fn static(name: []const u8) Response { |
| 152 | const files = .{ |
| 153 | .{ "base.css", "text/css; charset=utf-8", @embedFile("base.css") }, |
| 154 | .{ "index.css", "text/css; charset=utf-8", @embedFile("index.css") }, |
| 155 | .{ "app.css", "text/css; charset=utf-8", @embedFile("app.css") }, |
| 156 | .{ "app.js", "text/javascript; charset=utf-8", @embedFile("app.js") }, |
| 157 | }; |
| 158 | inline for (files) |f| { |
| 159 | if (std.mem.eql(u8, name, f[0])) return .{ .content_type = f[1], .body = f[2], .cache = "public, max-age=300" }; |
| 160 | } |
| 161 | return .{ .status = .not_found, .body = "not found\n" }; |
| 162 | } |
| 163 | |
| 164 | fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, body: []u8, from_script: bool) !Response { |
| 165 | var form: Form = .{}; |
| 166 | form.parse(body); |
| 167 | const filter = Filter.fromField(form.filter orelse ""); |
| 168 | const store = app.store; |
| 169 | |
| 170 | const not_found: Response = .{ .status = .not_found, .body = "no such todo\n" }; |
| 171 | if (std.mem.eql(u8, path, "/todos")) { |
| 172 | const title = trimTitle(form.title orelse ""); |
| 173 | const list = try visitor.list(); |
| 174 | if (title.len > 0) try store.add(list, title); |
| 175 | } else if (std.mem.eql(u8, path, "/todos/toggle-all")) { |
| 176 | try store.toggleAll(try visitor.list()); |
| 177 | } else if (std.mem.eql(u8, path, "/todos/clear-completed")) { |
| 178 | try store.clearCompleted(try visitor.list()); |
| 179 | } else if (itemAction(path)) |action| { |
| 180 | const list = try visitor.list(); |
| 181 | const found = switch (action.verb) { |
| 182 | .toggle => try store.toggle(list, action.id), |
| 183 | .destroy => try store.destroy(list, action.id), |
| 184 | .edit => edit: { |
| 185 | const title = trimTitle(form.title orelse ""); |
| 186 | break :edit if (title.len == 0) |
| 187 | try store.destroy(list, action.id) |
| 188 | else |
| 189 | try store.rename(list, action.id, title); |
| 190 | }, |
| 191 | }; |
| 192 | if (!found) return not_found; |
| 193 | } else { |
| 194 | return .{ .status = .not_found, .body = "not found\n" }; |
| 195 | } |
| 196 | |
| 197 | if (from_script) { |
| 198 | return page(arena, .{ .todos = try store.todos(arena, try visitor.list()), .filter = filter }); |
| 199 | } |
| 200 | return .{ .status = .see_other, .location = filter.path() }; |
| 201 | } |
| 202 | |
| 203 | /// Trims Unicode whitespace (the White_Space property, plus U+FEFF as |
| 204 | /// JavaScript's `trim` does) from both ends of a UTF-8 title. |
| 205 | fn trimTitle(s: []const u8) []const u8 { |
| 206 | var start: usize = 0; |
| 207 | while (start < s.len) { |
| 208 | const len = std.unicode.utf8ByteSequenceLength(s[start]) catch break; |
| 209 | if (start + len > s.len) break; |
| 210 | const cp = std.unicode.utf8Decode(s[start..][0..len]) catch break; |
| 211 | if (!isSpace(cp)) break; |
| 212 | start += len; |
| 213 | } |
| 214 | var end = s.len; |
| 215 | while (end > start) { |
| 216 | var i = end - 1; |
| 217 | while (i > start and s[i] & 0xC0 == 0x80) i -= 1; |
| 218 | // utf8Decode asserts its input is one whole sequence, so check the |
| 219 | // lead byte claims exactly the bytes found; anything else is |
| 220 | // invalid UTF-8 and ends the trim. |
| 221 | const len = std.unicode.utf8ByteSequenceLength(s[i]) catch break; |
| 222 | if (len != end - i) break; |
| 223 | const cp = std.unicode.utf8Decode(s[i..end]) catch break; |
| 224 | if (!isSpace(cp)) break; |
| 225 | end = i; |
| 226 | } |
| 227 | return s[start..end]; |
| 228 | } |
| 229 | |
| 230 | fn isSpace(cp: u21) bool { |
| 231 | return switch (cp) { |
| 232 | 0x09...0x0D, 0x20, 0x85, 0xA0, 0x1680, 0x2000...0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF => true, |
| 233 | else => false, |
| 234 | }; |
| 235 | } |
| 236 | |
| 237 | const ItemAction = struct { |
| 238 | id: i64, |
| 239 | verb: enum { toggle, edit, destroy }, |
| 240 | }; |
| 241 | |
| 242 | /// Parses `/todos/<id>/<verb>`. |
| 243 | fn itemAction(path: []const u8) ?ItemAction { |
| 244 | const rest = std.mem.cutPrefix(u8, path, "/todos/") orelse return null; |
| 245 | const slash = std.mem.findScalar(u8, rest, '/') orelse return null; |
| 246 | const id = std.fmt.parseInt(i64, rest[0..slash], 10) catch return null; |
| 247 | const verb = std.meta.stringToEnum(@FieldType(ItemAction, "verb"), rest[slash + 1 ..]) orelse return null; |
| 248 | return .{ .id = id, .verb = verb }; |
| 249 | } |
| 250 | |
| 251 | /// The fields of an `application/x-www-form-urlencoded` body this app reads. |
| 252 | const Form = struct { |
| 253 | title: ?[]const u8 = null, |
| 254 | filter: ?[]const u8 = null, |
| 255 | |
| 256 | fn parse(form: *Form, body: []u8) void { |
| 257 | var pairs = std.mem.splitScalar(u8, body, '&'); |
| 258 | while (pairs.next()) |pair_const| { |
| 259 | // The split hands back slices of `body`, which is ours to decode in place. |
| 260 | const pair = @constCast(pair_const); |
| 261 | const eq = std.mem.findScalar(u8, pair, '=') orelse continue; |
| 262 | const key = decode(pair[0..eq]); |
| 263 | const value = decode(pair[eq + 1 ..]); |
| 264 | if (std.mem.eql(u8, key, "title")) form.title = value; |
| 265 | if (std.mem.eql(u8, key, "filter")) form.filter = value; |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | fn decode(s: []u8) []u8 { |
| 270 | for (s) |*ch| { |
| 271 | if (ch.* == '+') ch.* = ' '; |
| 272 | } |
| 273 | return std.Uri.percentDecodeInPlace(s); |
| 274 | } |
| 275 | }; |
| 276 | |
| 277 | /// The visitor's list, found from (or minted into) their cookie on first use. |
| 278 | const Visitor = struct { |
| 279 | app: *const App, |
| 280 | headers: *const Headers, |
| 281 | list_id: ?i64 = null, |
| 282 | set_cookie: ?[]const u8 = null, |
| 283 | cookie_buf: [160]u8 = undefined, |
| 284 | |
| 285 | fn list(v: *Visitor) !i64 { |
| 286 | if (v.list_id) |id| return id; |
| 287 | if (v.headers.cookie) |header| { |
| 288 | if (cookieValue(header, cookie_name)) |token_hex| { |
| 289 | if (token_hex.len == token_len * 2) { |
| 290 | if (try v.app.store.findList(&hashToken(token_hex))) |id| { |
| 291 | v.list_id = id; |
| 292 | return id; |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | // No cookie, or one we don't recognise: a fresh, empty list. |
| 298 | var token: [token_len]u8 = undefined; |
| 299 | try v.app.io.randomSecure(&token); |
| 300 | const token_hex = std.fmt.bytesToHex(token, .lower); |
| 301 | const id = try v.app.store.createList(&hashToken(&token_hex)); |
| 302 | const secure = v.app.https or |
| 303 | (if (v.headers.forwarded_proto) |p| std.ascii.eqlIgnoreCase(p, "https") else false); |
| 304 | v.set_cookie = try std.fmt.bufPrint(&v.cookie_buf, "{s}={s}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000{s}", .{ |
| 305 | cookie_name, token_hex, if (secure) "; Secure" else "", |
| 306 | }); |
| 307 | v.list_id = id; |
| 308 | return id; |
| 309 | } |
| 310 | }; |
| 311 | |
| 312 | /// The database keeps only a hash of each token, hex-encoded. |
| 313 | fn hashToken(token_hex: []const u8) [64]u8 { |
| 314 | var digest: [32]u8 = undefined; |
| 315 | std.crypto.hash.sha2.Sha256.hash(token_hex, &digest, .{}); |
| 316 | return std.fmt.bytesToHex(digest, .lower); |
| 317 | } |
| 318 | |
| 319 | fn cookieValue(header: []const u8, name: []const u8) ?[]const u8 { |
| 320 | var it = std.mem.splitScalar(u8, header, ';'); |
| 321 | while (it.next()) |part| { |
| 322 | const kv = std.mem.trim(u8, part, " "); |
| 323 | const eq = std.mem.findScalar(u8, kv, '=') orelse continue; |
| 324 | if (std.mem.eql(u8, kv[0..eq], name)) return kv[eq + 1 ..]; |
| 325 | } |
| 326 | return null; |
| 327 | } |
| 328 | |
| 329 | fn originAllowed(app: *const App, h: *const Headers) bool { |
| 330 | const origin = h.origin orelse return true; |
| 331 | for (app.origins) |allowed| { |
| 332 | if (std.mem.eql(u8, origin, allowed)) return true; |
| 333 | } |
| 334 | if (app.origins.len > 0) return false; |
| 335 | // No configured origin: accept the request's own host, either scheme. |
| 336 | const host = h.host orelse return false; |
| 337 | const after_scheme = std.mem.cutPrefix(u8, origin, "https://") orelse |
| 338 | std.mem.cutPrefix(u8, origin, "http://") orelse return false; |
| 339 | return std.mem.eql(u8, after_scheme, host); |
| 340 | } |
| 341 | |
| 342 | test trimTitle { |
| 343 | try std.testing.expectEqualStrings("", trimTitle("\u{a0}\u{2003} \t\u{3000}")); |
| 344 | try std.testing.expectEqualStrings("café ✓", trimTitle("\u{feff} café ✓\u{a0}\n")); |
| 345 | try std.testing.expectEqualStrings("a\u{a0}b", trimTitle("a\u{a0}b")); |
| 346 | try std.testing.expectEqualStrings("日本語", trimTitle("日本語")); |
| 347 | try std.testing.expectEqualStrings("\xff x", trimTitle("\xff x")); |
| 348 | // Malformed UTF-8 is left alone, never decoded. |
| 349 | try std.testing.expectEqualStrings("a\x80\x80\x80\x80", trimTitle("a\x80\x80\x80\x80")); |
| 350 | try std.testing.expectEqualStrings("\x80\x80", trimTitle(" \x80\x80 ")); |
| 351 | try std.testing.expectEqualStrings("x\xe2\x80", trimTitle("x\xe2\x80")); |
| 352 | try std.testing.expectEqualStrings("x\xc3", trimTitle("x\xc3")); |
| 353 | try std.testing.expectEqualStrings("\xf0\x80\x80\x80\x80", trimTitle("\xf0\x80\x80\x80\x80")); |
| 354 | } |
| 355 | |
| 356 | test itemAction { |
| 357 | try std.testing.expectEqual(ItemAction{ .id = 12, .verb = .toggle }, itemAction("/todos/12/toggle").?); |
| 358 | try std.testing.expectEqual(ItemAction{ .id = 3, .verb = .edit }, itemAction("/todos/3/edit").?); |
| 359 | try std.testing.expectEqual(null, itemAction("/todos/x/toggle")); |
| 360 | try std.testing.expectEqual(null, itemAction("/todos/3/explode")); |
| 361 | try std.testing.expectEqual(null, itemAction("/todos/toggle-all")); |
| 362 | } |
| 363 | |
| 364 | test "form decoding" { |
| 365 | var body = "title=+caf%C3%A9+%26+%3Cb%3E+&filter=active".*; |
| 366 | var form: Form = .{}; |
| 367 | form.parse(&body); |
| 368 | try std.testing.expectEqualStrings(" café & <b> ", form.title.?); |
| 369 | try std.testing.expectEqualStrings("active", form.filter.?); |
| 370 | } |
| 371 | |
| 372 | test cookieValue { |
| 373 | try std.testing.expectEqualStrings("abc", cookieValue("a=1; todo_list=abc; b=2", "todo_list").?); |
| 374 | try std.testing.expectEqual(null, cookieValue("a=1", "todo_list")); |
| 375 | } |
| 376 | |
| 377 | test originAllowed { |
| 378 | const configured: App = .{ .store = undefined, .io = undefined, .origins = &.{"https://todo.example"}, .https = true }; |
| 379 | try std.testing.expect(originAllowed(&configured, &.{})); |
| 380 | try std.testing.expect(originAllowed(&configured, &.{ .origin = "https://todo.example" })); |
| 381 | try std.testing.expect(!originAllowed(&configured, &.{ .origin = "https://evil.example" })); |
| 382 | const unconfigured: App = .{ .store = undefined, .io = undefined, .origins = &.{}, .https = false }; |
| 383 | try std.testing.expect(originAllowed(&unconfigured, &.{ .origin = "http://h:1", .host = "h:1" })); |
| 384 | try std.testing.expect(!originAllowed(&unconfigured, &.{ .origin = "http://evil", .host = "h:1" })); |
| 385 | } |
| 386 | |