//! Routes and handlers. One `handle` call answers one HTTP request. const std = @import("std"); const http = std.http; const Allocator = std.mem.Allocator; const Io = std.Io; const build_options = @import("build_options"); const db = @import("db.zig"); const view = @import("view.zig"); const Filter = view.Filter; /// The body of `GET /healthz`. `make bump` edits this string. const health_body = "ok"; const cookie_name = "todo_list"; /// Raw token bytes; the cookie carries them hex-encoded (256 bits). const token_len = 32; const max_body = 1 << 20; /// The request header app.js sends; with it, actions answer 200 with the /// re-rendered page instead of a 303. const fetch_header = "x-requested-with"; pub const App = struct { store: *db.Store, io: Io, /// Origins a POST may come from. Empty means "match the Host header". origins: []const []const u8, /// Whether the app is served over https, so its cookie is `Secure`. https: bool, }; /// The headers this app reads, copied out before the body is read /// (reading the body invalidates the request's header memory). const Headers = struct { cookie: ?[]const u8 = null, origin: ?[]const u8 = null, host: ?[]const u8 = null, content_type: ?[]const u8 = null, forwarded_proto: ?[]const u8 = null, from_script: bool = false, fn read(req: *const http.Server.Request) Headers { var h: Headers = .{ .content_type = req.head.content_type }; var it = req.iterateHeaders(); while (it.next()) |field| { if (std.ascii.eqlIgnoreCase(field.name, "cookie")) { h.cookie = field.value; } else if (std.ascii.eqlIgnoreCase(field.name, "origin")) { h.origin = field.value; } else if (std.ascii.eqlIgnoreCase(field.name, "host")) { h.host = field.value; } else if (std.ascii.eqlIgnoreCase(field.name, "x-forwarded-proto")) { h.forwarded_proto = field.value; } else if (std.ascii.eqlIgnoreCase(field.name, fetch_header)) { h.from_script = true; } } return h; } }; /// What a handler decided; `handle` turns it into bytes on the wire. const Response = struct { status: http.Status = .ok, content_type: []const u8 = "text/plain; charset=utf-8", body: []const u8 = "", location: ?[]const u8 = null, cache: []const u8 = "no-store", }; pub fn handle(app: *const App, req: *http.Server.Request, arena: Allocator) !void { // Copy what we need before reading the body invalidates it. const target = try arena.dupe(u8, req.head.target); const method = req.head.method; var headers = Headers.read(req); inline for (.{ "cookie", "origin", "host", "content_type", "forwarded_proto" }) |name| { if (@field(headers, name)) |v| @field(headers, name) = try arena.dupe(u8, v); } const query_start = std.mem.findScalar(u8, target, '?'); const path = target[0 .. query_start orelse target.len]; const query = if (query_start) |q| target[q + 1 ..] else ""; var visitor = Visitor{ .app = app, .headers = &headers }; const res: Response = switch (method) { .GET, .HEAD => try get(app, &visitor, arena, path, query), .POST => post: { // A body we can't size can't be read safely on a kept-alive // connection; refuse it rather than guess. if (req.head.content_length == null and req.head.transfer_encoding == .none) { break :post .{ .status = .length_required, .body = "length required\n" }; } if (!originAllowed(app, &headers)) break :post .{ .status = .forbidden, .body = "cross-origin request refused\n" }; const reader = req.readerExpectContinue(&.{}) catch return error.WriteFailed; const body = reader.allocRemaining(arena, .limited(max_body)) catch |err| switch (err) { error.StreamTooLong => break :post .{ .status = .payload_too_large, .body = "too large\n" }, else => return err, }; break :post try post(app, &visitor, arena, path, body, headers.from_script); }, else => .{ .status = .method_not_allowed, .body = "method not allowed\n" }, }; var extra: [4]http.Header = undefined; var n: usize = 0; extra[n] = .{ .name = "content-type", .value = res.content_type }; n += 1; extra[n] = .{ .name = "cache-control", .value = res.cache }; n += 1; if (res.location) |loc| { extra[n] = .{ .name = "location", .value = loc }; n += 1; } if (visitor.set_cookie) |cookie| { extra[n] = .{ .name = "set-cookie", .value = cookie }; n += 1; } // After a refused or oversized body, the rest of it may still be on // the wire, so the connection can't carry another request. const keep_alive = res.status != .length_required and res.status != .payload_too_large; try req.respond(res.body, .{ .status = res.status, .extra_headers = extra[0..n], .keep_alive = keep_alive, }); } fn get(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, query: []const u8) !Response { if (std.mem.eql(u8, path, "/healthz")) return .{ .body = health_body }; if (std.mem.eql(u8, path, "/api/version")) return .{ .body = build_options.version }; if (std.mem.startsWith(u8, path, "/static/")) return static(path["/static/".len..]); const filter = Filter.fromPath(path) orelse return .{ .status = .not_found, .body = "not found\n" }; const list = try visitor.list(); const todos = try app.store.todos(arena, list); var editing: ?i64 = null; var params = std.mem.splitScalar(u8, query, '&'); while (params.next()) |param| { if (std.mem.startsWith(u8, param, "edit=")) editing = std.fmt.parseInt(i64, param["edit=".len..], 10) catch null; } return page(arena, .{ .todos = todos, .filter = filter, .editing = editing }); } fn page(arena: Allocator, p: view.Page) !Response { var out: Io.Writer.Allocating = try .initCapacity(arena, 4096 + 512 * p.todos.len); try view.render(&out.writer, p); return .{ .content_type = "text/html; charset=utf-8", .body = out.written() }; } fn static(name: []const u8) Response { const files = .{ .{ "base.css", "text/css; charset=utf-8", @embedFile("base.css") }, .{ "index.css", "text/css; charset=utf-8", @embedFile("index.css") }, .{ "app.css", "text/css; charset=utf-8", @embedFile("app.css") }, .{ "app.js", "text/javascript; charset=utf-8", @embedFile("app.js") }, }; inline for (files) |f| { if (std.mem.eql(u8, name, f[0])) return .{ .content_type = f[1], .body = f[2], .cache = "public, max-age=300" }; } return .{ .status = .not_found, .body = "not found\n" }; } fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, body: []u8, from_script: bool) !Response { var form: Form = .{}; form.parse(body); const filter = Filter.fromField(form.filter orelse ""); const store = app.store; const not_found: Response = .{ .status = .not_found, .body = "no such todo\n" }; if (std.mem.eql(u8, path, "/todos")) { const title = trimTitle(form.title orelse ""); const list = try visitor.list(); if (title.len > 0) try store.add(list, title); } else if (std.mem.eql(u8, path, "/todos/toggle-all")) { try store.toggleAll(try visitor.list()); } else if (std.mem.eql(u8, path, "/todos/clear-completed")) { try store.clearCompleted(try visitor.list()); } else if (itemAction(path)) |action| { const list = try visitor.list(); const found = switch (action.verb) { .toggle => try store.toggle(list, action.id), .destroy => try store.destroy(list, action.id), .edit => edit: { const title = trimTitle(form.title orelse ""); break :edit if (title.len == 0) try store.destroy(list, action.id) else try store.rename(list, action.id, title); }, }; if (!found) return not_found; } else { return .{ .status = .not_found, .body = "not found\n" }; } if (from_script) { return page(arena, .{ .todos = try store.todos(arena, try visitor.list()), .filter = filter }); } return .{ .status = .see_other, .location = filter.path() }; } /// Trims Unicode whitespace (the White_Space property, plus U+FEFF as /// JavaScript's `trim` does) from both ends of a UTF-8 title. fn trimTitle(s: []const u8) []const u8 { var start: usize = 0; while (start < s.len) { const len = std.unicode.utf8ByteSequenceLength(s[start]) catch break; if (start + len > s.len) break; const cp = std.unicode.utf8Decode(s[start..][0..len]) catch break; if (!isSpace(cp)) break; start += len; } var end = s.len; while (end > start) { var i = end - 1; while (i > start and s[i] & 0xC0 == 0x80) i -= 1; // utf8Decode asserts its input is one whole sequence, so check the // lead byte claims exactly the bytes found; anything else is // invalid UTF-8 and ends the trim. const len = std.unicode.utf8ByteSequenceLength(s[i]) catch break; if (len != end - i) break; const cp = std.unicode.utf8Decode(s[i..end]) catch break; if (!isSpace(cp)) break; end = i; } return s[start..end]; } fn isSpace(cp: u21) bool { return switch (cp) { 0x09...0x0D, 0x20, 0x85, 0xA0, 0x1680, 0x2000...0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF => true, else => false, }; } const ItemAction = struct { id: i64, verb: enum { toggle, edit, destroy }, }; /// Parses `/todos//`. fn itemAction(path: []const u8) ?ItemAction { const rest = std.mem.cutPrefix(u8, path, "/todos/") orelse return null; const slash = std.mem.findScalar(u8, rest, '/') orelse return null; const id = std.fmt.parseInt(i64, rest[0..slash], 10) catch return null; const verb = std.meta.stringToEnum(@FieldType(ItemAction, "verb"), rest[slash + 1 ..]) orelse return null; return .{ .id = id, .verb = verb }; } /// The fields of an `application/x-www-form-urlencoded` body this app reads. const Form = struct { title: ?[]const u8 = null, filter: ?[]const u8 = null, fn parse(form: *Form, body: []u8) void { var pairs = std.mem.splitScalar(u8, body, '&'); while (pairs.next()) |pair_const| { // The split hands back slices of `body`, which is ours to decode in place. const pair = @constCast(pair_const); const eq = std.mem.findScalar(u8, pair, '=') orelse continue; const key = decode(pair[0..eq]); const value = decode(pair[eq + 1 ..]); if (std.mem.eql(u8, key, "title")) form.title = value; if (std.mem.eql(u8, key, "filter")) form.filter = value; } } fn decode(s: []u8) []u8 { for (s) |*ch| { if (ch.* == '+') ch.* = ' '; } return std.Uri.percentDecodeInPlace(s); } }; /// The visitor's list, found from (or minted into) their cookie on first use. const Visitor = struct { app: *const App, headers: *const Headers, list_id: ?i64 = null, set_cookie: ?[]const u8 = null, cookie_buf: [160]u8 = undefined, fn list(v: *Visitor) !i64 { if (v.list_id) |id| return id; if (v.headers.cookie) |header| { if (cookieValue(header, cookie_name)) |token_hex| { if (token_hex.len == token_len * 2) { if (try v.app.store.findList(&hashToken(token_hex))) |id| { v.list_id = id; return id; } } } } // No cookie, or one we don't recognise: a fresh, empty list. var token: [token_len]u8 = undefined; try v.app.io.randomSecure(&token); const token_hex = std.fmt.bytesToHex(token, .lower); const id = try v.app.store.createList(&hashToken(&token_hex)); const secure = v.app.https or (if (v.headers.forwarded_proto) |p| std.ascii.eqlIgnoreCase(p, "https") else false); v.set_cookie = try std.fmt.bufPrint(&v.cookie_buf, "{s}={s}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000{s}", .{ cookie_name, token_hex, if (secure) "; Secure" else "", }); v.list_id = id; return id; } }; /// The database keeps only a hash of each token, hex-encoded. fn hashToken(token_hex: []const u8) [64]u8 { var digest: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(token_hex, &digest, .{}); return std.fmt.bytesToHex(digest, .lower); } fn cookieValue(header: []const u8, name: []const u8) ?[]const u8 { var it = std.mem.splitScalar(u8, header, ';'); while (it.next()) |part| { const kv = std.mem.trim(u8, part, " "); const eq = std.mem.findScalar(u8, kv, '=') orelse continue; if (std.mem.eql(u8, kv[0..eq], name)) return kv[eq + 1 ..]; } return null; } fn originAllowed(app: *const App, h: *const Headers) bool { const origin = h.origin orelse return true; for (app.origins) |allowed| { if (std.mem.eql(u8, origin, allowed)) return true; } if (app.origins.len > 0) return false; // No configured origin: accept the request's own host, either scheme. const host = h.host orelse return false; const after_scheme = std.mem.cutPrefix(u8, origin, "https://") orelse std.mem.cutPrefix(u8, origin, "http://") orelse return false; return std.mem.eql(u8, after_scheme, host); } test trimTitle { try std.testing.expectEqualStrings("", trimTitle("\u{a0}\u{2003} \t\u{3000}")); try std.testing.expectEqualStrings("café ✓", trimTitle("\u{feff} café ✓\u{a0}\n")); try std.testing.expectEqualStrings("a\u{a0}b", trimTitle("a\u{a0}b")); try std.testing.expectEqualStrings("日本語", trimTitle("日本語")); try std.testing.expectEqualStrings("\xff x", trimTitle("\xff x")); // Malformed UTF-8 is left alone, never decoded. try std.testing.expectEqualStrings("a\x80\x80\x80\x80", trimTitle("a\x80\x80\x80\x80")); try std.testing.expectEqualStrings("\x80\x80", trimTitle(" \x80\x80 ")); try std.testing.expectEqualStrings("x\xe2\x80", trimTitle("x\xe2\x80")); try std.testing.expectEqualStrings("x\xc3", trimTitle("x\xc3")); try std.testing.expectEqualStrings("\xf0\x80\x80\x80\x80", trimTitle("\xf0\x80\x80\x80\x80")); } test itemAction { try std.testing.expectEqual(ItemAction{ .id = 12, .verb = .toggle }, itemAction("/todos/12/toggle").?); try std.testing.expectEqual(ItemAction{ .id = 3, .verb = .edit }, itemAction("/todos/3/edit").?); try std.testing.expectEqual(null, itemAction("/todos/x/toggle")); try std.testing.expectEqual(null, itemAction("/todos/3/explode")); try std.testing.expectEqual(null, itemAction("/todos/toggle-all")); } test "form decoding" { var body = "title=+caf%C3%A9+%26+%3Cb%3E+&filter=active".*; var form: Form = .{}; form.parse(&body); try std.testing.expectEqualStrings(" café & ", form.title.?); try std.testing.expectEqualStrings("active", form.filter.?); } test cookieValue { try std.testing.expectEqualStrings("abc", cookieValue("a=1; todo_list=abc; b=2", "todo_list").?); try std.testing.expectEqual(null, cookieValue("a=1", "todo_list")); } test originAllowed { const configured: App = .{ .store = undefined, .io = undefined, .origins = &.{"https://todo.example"}, .https = true }; try std.testing.expect(originAllowed(&configured, &.{})); try std.testing.expect(originAllowed(&configured, &.{ .origin = "https://todo.example" })); try std.testing.expect(!originAllowed(&configured, &.{ .origin = "https://evil.example" })); const unconfigured: App = .{ .store = undefined, .io = undefined, .origins = &.{}, .https = false }; try std.testing.expect(originAllowed(&unconfigured, &.{ .origin = "http://h:1", .host = "h:1" })); try std.testing.expect(!originAllowed(&unconfigured, &.{ .origin = "http://evil", .host = "h:1" })); }