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//! Routes and handlers. One `handle` call answers one HTTP request.
2
3const std = @import("std");
4const http = std.http;
5const Allocator = std.mem.Allocator;
6const Io = std.Io;
7const build_options = @import("build_options");
8const db = @import("db.zig");
9const view = @import("view.zig");
10const Filter = view.Filter;
11
12/// The body of `GET /healthz`. `make bump` edits this string.
13const health_body = "ok";
14
15const cookie_name = "todo_list";
16/// Raw token bytes; the cookie carries them hex-encoded (256 bits).
17const token_len = 32;
18const 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.
22const fetch_header = "x-requested-with";
23
24pub 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).
35const 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.
64const 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
72pub 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
129fn 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
145fn 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
151fn 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
164fn 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.
205fn 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
230fn 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
237const ItemAction = struct {
238 id: i64,
239 verb: enum { toggle, edit, destroy },
240};
241
242/// Parses `/todos/<id>/<verb>`.
243fn 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.
252const 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.
278const 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.
313fn 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
319fn 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
329fn 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
342test 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
356test 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
364test "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
372test 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
377test 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