this repo has no description
1const std = @import("std");
2const sqlite = @import("sqlite");
3const httpz = @import("httpz");
4const uuid = @import("uuid");
5const xev = @import("xev");
6
7const Allocator = std.mem.Allocator;
8const log = @import("log.zig");
9const irc = @import("irc.zig");
10const IrcServer = @import("Server.zig");
11const sanitize = @import("sanitize.zig");
12const Url = @import("Url.zig");
13
14const public_html_index = @embedFile("public/html/index.html");
15const public_html_channel = @embedFile("public/html/channel.html");
16const public_html_channel_list = @embedFile("public/html/channel-list.html");
17const public_css_reset = @embedFile("public/css/reset.css");
18const public_css_style = @embedFile("public/css/style.css");
19const public_js_htmx = @embedFile("public/js/htmx-2.0.4.js");
20const public_js_htmx_sse = @embedFile("public/js/htmx-ext-sse.js");
21const public_js_stick_to_bottom = @embedFile("public/js/stick-to-bottom.js");
22
23pub const Server = struct {
24 gpa: std.mem.Allocator,
25 channels: *std.StringArrayHashMapUnmanaged(*irc.Channel),
26 db_pool: *sqlite.Pool,
27 csp_nonce: []const u8 = undefined,
28 nonce: []u8 = undefined,
29 irc_server: *IrcServer,
30
31 pub fn hasChannel(self: *const Server, channel: []const u8) bool {
32 for (self.channels.keys()) |c| {
33 if (std.mem.eql(u8, c[1..], channel)) return true;
34 }
35 return false;
36 }
37
38 /// Generates a 256-bit, base64-encoded nonce and adds a Content-Security-Policy header.
39 fn addContentSecurityPolicy(
40 self: *Server,
41 action: httpz.Action(*Server),
42 req: *httpz.Request,
43 res: *httpz.Response,
44 ) !void {
45 _ = action;
46 _ = req;
47
48 // TODO: Use static buffers, we know the lenghts of everything here, so we should be
49 // able to skip all the allocations here.
50
51 const nonce_buf = try res.arena.alloc(u8, 32);
52
53 std.crypto.random.bytes(nonce_buf);
54 const encoder: std.base64.Base64Encoder = .init(
55 std.base64.standard.alphabet_chars,
56 std.base64.standard.pad_char,
57 );
58
59 const b64_len = encoder.calcSize(nonce_buf.len);
60 self.nonce = try res.arena.alloc(u8, b64_len);
61 _ = encoder.encode(self.nonce, nonce_buf);
62
63 const header = try std.fmt.allocPrint(
64 res.arena,
65 "script-src 'nonce-{s}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none';",
66 .{self.nonce},
67 );
68
69 res.header("Content-Security-Policy", header);
70 }
71
72 pub fn dispatch(
73 self: *Server,
74 action: httpz.Action(*Server),
75 req: *httpz.Request,
76 res: *httpz.Response,
77 ) !void {
78 try self.addContentSecurityPolicy(action, req, res);
79 try action(self, req, res);
80 }
81};
82
83pub const EventStream = struct {
84 stream: xev.TCP,
85 channel: *irc.Channel,
86 write_buf: std.ArrayListUnmanaged(u8),
87
88 /// EventStream owns it's own completion. We do this because we can only ever write to the
89 /// stream. We have full control over the lifetime of this completion. For other IRC
90 /// connections, we could have an inflight write and receive a connection closed on our read
91 /// call. This makes managing the lifetime difficult. For EventStream, we will only error out on
92 /// the write - and then we can dispose of the connection
93 write_c: xev.Completion,
94
95 pub fn print(
96 self: *EventStream,
97 gpa: std.mem.Allocator,
98 comptime format: []const u8,
99 args: anytype,
100 ) std.mem.Allocator.Error!void {
101 return self.write_buf.writer(gpa).print(format, args);
102 }
103
104 pub fn writeAll(self: *EventStream, gpa: Allocator, buf: []const u8) Allocator.Error!void {
105 return self.write_buf.appendSlice(gpa, buf);
106 }
107};
108
109pub fn getIndex(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void {
110 _ = req;
111
112 const html_size = std.mem.replacementSize(u8, public_html_index, "$nonce", ctx.nonce);
113 const html_with_nonce = try res.arena.alloc(u8, html_size);
114 _ = std.mem.replace(u8, public_html_index, "$nonce", ctx.nonce, html_with_nonce);
115
116 res.status = 200;
117 res.body = html_with_nonce;
118 res.content_type = .HTML;
119}
120
121pub fn getAsset(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void {
122 _ = ctx;
123 const asset_type = req.param("type").?;
124 const name = req.param("name").?;
125
126 if (std.mem.eql(u8, asset_type, "css")) {
127 if (std.mem.eql(u8, "reset.css", name)) {
128 res.status = 200;
129 res.body = public_css_reset;
130 res.content_type = .CSS;
131 // Cache indefinitely in the browser.
132 res.header("Cache-Control", "max-age=31536000, immutable");
133 return;
134 }
135 if (std.mem.eql(u8, "style-1.0.0.css", name)) {
136 res.status = 200;
137 res.body = public_css_style;
138 res.content_type = .CSS;
139 // Cache indefinitely in the browser.
140 res.header("Cache-Control", "max-age=31536000, immutable");
141 return;
142 }
143 }
144
145 if (std.mem.eql(u8, asset_type, "js")) {
146 if (std.mem.eql(u8, "htmx-2.0.4.js", name)) {
147 res.status = 200;
148 res.body = public_js_htmx;
149 res.content_type = .JS;
150 // Cache indefinitely in the browser.
151 res.header("Cache-Control", "max-age=31536000, immutable");
152 return;
153 }
154 if (std.mem.eql(u8, "htmx-ext-sse.js", name)) {
155 res.status = 200;
156 res.body = public_js_htmx_sse;
157 res.content_type = .JS;
158 // Cache indefinitely in the browser.
159 res.header("Cache-Control", "max-age=31536000, immutable");
160 return;
161 }
162 if (std.mem.eql(u8, "stick-to-bottom-1.0.1.js", name)) {
163 res.status = 200;
164 res.body = public_js_stick_to_bottom;
165 res.content_type = .JS;
166 // Cache indefinitely in the browser.
167 res.header("Cache-Control", "max-age=31536000, immutable");
168 return;
169 }
170 }
171
172 res.status = 404;
173 res.body = "Not found";
174 res.content_type = .TEXT;
175}
176
177pub fn getChannel(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void {
178 const channel_param = try res.arena.dupe(u8, req.param("channel").?);
179 const channel = std.Uri.percentDecodeInPlace(channel_param);
180
181 if (!ctx.channels.contains(channel)) {
182 res.status = 404;
183 res.body = "Channel does not exist";
184 res.content_type = .TEXT;
185 return;
186 }
187
188 const html_size = std.mem.replacementSize(u8, public_html_channel, "$nonce", ctx.nonce);
189 const html_with_nonce = try res.arena.alloc(u8, html_size);
190 _ = std.mem.replace(u8, public_html_channel, "$nonce", ctx.nonce, html_with_nonce);
191
192 const sanitized_channel_name = sanitize.html(res.arena, channel) catch |err| {
193 log.err("[HTTP] failed to sanitize channel name: {}: {s}", .{ err, channel });
194 res.status = 500;
195 res.body = "Internal Server Error";
196 res.content_type = .TEXT;
197 return;
198 };
199
200 const header_replace_size = std.mem.replacementSize(
201 u8,
202 html_with_nonce,
203 "$channel_name",
204 sanitized_channel_name,
205 );
206 const body_without_messages_and_sse_endpoint = try res.arena.alloc(u8, header_replace_size);
207 _ = std.mem.replace(
208 u8,
209 html_with_nonce,
210 "$channel_name",
211 sanitized_channel_name,
212 body_without_messages_and_sse_endpoint,
213 );
214
215 const url_encoded_channel_name = Url.encode(res.arena, channel) catch |err| {
216 log.err("[HTTP] failed to url encode channel name: {}: {s}", .{ err, channel });
217 res.status = 500;
218 res.body = "Internal Server Error";
219 res.content_type = .TEXT;
220 return;
221 };
222 const sanitized_url_encoded_channel_name = sanitize.html(
223 res.arena,
224 url_encoded_channel_name,
225 ) catch |err| {
226 log.err("[HTTP] failed to sanitize url encoded channel name: {}: {s}", .{ err, channel });
227 res.status = 500;
228 res.body = "Internal Server Error";
229 res.content_type = .TEXT;
230 return;
231 };
232 const sse_endpoint_replace_size = std.mem.replacementSize(
233 u8,
234 body_without_messages_and_sse_endpoint,
235 "$encoded_channel_name",
236 sanitized_url_encoded_channel_name,
237 );
238 const body_without_messages = try res.arena.alloc(u8, sse_endpoint_replace_size);
239 _ = std.mem.replace(
240 u8,
241 body_without_messages_and_sse_endpoint,
242 "$encoded_channel_name",
243 sanitized_url_encoded_channel_name,
244 body_without_messages,
245 );
246
247 // Nested SQL query because we first get the newest 100 messages, then want to order them from
248 // oldest to newest. I'm sure there might be a way to optimize this query if it turns out to be
249 // slow.
250 const sql =
251 \\SELECT * FROM (
252 \\ SELECT
253 \\ uuid,
254 \\ timestamp_ms,
255 \\ sender_nick,
256 \\ message
257 \\ FROM messages m
258 \\ WHERE recipient_type = 0
259 \\ AND recipient_id = (SELECT id FROM channels WHERE name = ?)
260 \\ ORDER BY timestamp_ms DESC
261 \\ LIMIT 100
262 \\) ORDER BY timestamp_ms ASC;
263 ;
264
265 const conn = ctx.db_pool.acquire();
266 defer ctx.db_pool.release(conn);
267 var rows = conn.rows(sql, .{channel}) catch |err| {
268 log.err("[HTTP] failed while querying messages: {}: {s}", .{ err, conn.lastError() });
269 res.status = 500;
270 res.body = "Internal Server Error";
271 res.content_type = .TEXT;
272 return;
273 };
274 defer rows.deinit();
275
276 var messages: std.ArrayListUnmanaged(u8) = .empty;
277 defer messages.deinit(res.arena);
278
279 // Track some state while printing these messages
280 var last_nick_buf: [256]u8 = undefined;
281 var last_nick: []const u8 = "";
282 var last_time: i64 = 0;
283
284 while (rows.next()) |row| {
285 const timestamp: irc.Timestamp = .{
286 .milliseconds = row.int(1),
287 };
288 const message: irc.Message = .{
289 .bytes = row.text(3),
290 .timestamp = timestamp,
291 .uuid = try uuid.urn.deserialize(row.text(0)),
292 };
293 const nick = row.text(2);
294
295 defer {
296 // Store state. We get up to 256 bytes for the nick, which should be good but either way
297 // we truncate if needed
298 const len = @min(last_nick_buf.len, nick.len);
299 @memcpy(last_nick_buf[0..len], nick);
300 last_nick = last_nick_buf[0..len];
301 last_time = timestamp.milliseconds;
302 }
303
304 var iter = message.paramIterator();
305 _ = iter.next();
306 const content = iter.next() orelse continue;
307 const san_content: sanitize.Html = .{ .bytes = content };
308
309 // We don't reprint the sender if the last message this message are from the same
310 // person. Unless enough time has elapsed (5 minutes)
311 if (std.ascii.eqlIgnoreCase(last_nick, nick) and
312 (last_time + 5 * std.time.ms_per_min) >= timestamp.milliseconds)
313 {
314 const fmt =
315 \\<div class="message"><p class="body">{s}</p></div>
316 ;
317 try messages.writer(res.arena).print(fmt, .{san_content});
318 continue;
319 }
320 const fmt =
321 \\<div class="message"><p class="nick"><b>{s}</b></p><p class="body">{s}</p></div>
322 ;
323
324 const sender_sanitized: sanitize.Html = .{ .bytes = nick };
325 try messages.writer(res.arena).print(fmt, .{ sender_sanitized, san_content });
326 }
327
328 const body_replace_size = std.mem.replacementSize(
329 u8,
330 body_without_messages,
331 "$messages",
332 messages.items,
333 );
334 const body = try res.arena.alloc(u8, body_replace_size);
335 _ = std.mem.replace(u8, body_without_messages, "$messages", messages.items, body);
336
337 res.status = 200;
338 res.body = body;
339 res.content_type = .HTML;
340}
341
342pub fn getChannels(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void {
343 _ = req;
344
345 const html_size = std.mem.replacementSize(u8, public_html_channel_list, "$nonce", ctx.nonce);
346 const html_with_nonce = try res.arena.alloc(u8, html_size);
347 _ = std.mem.replace(u8, public_html_channel_list, "$nonce", ctx.nonce, html_with_nonce);
348
349 var list: std.ArrayListUnmanaged(u8) = .empty;
350 defer list.deinit(res.arena);
351
352 for (ctx.channels.keys()) |name| {
353 const sanitized_channel_name = sanitize.html(res.arena, name) catch |err| {
354 log.err("[HTTP] failed to sanitize channel name: {}: {s}", .{ err, name });
355 res.status = 500;
356 res.body = "Internal Server Error";
357 res.content_type = .TEXT;
358 return;
359 };
360 const url_encoded_channel_name = Url.encode(res.arena, name) catch |err| {
361 log.err("[HTTP] failed to url encode channel name: {}: {s}", .{ err, name });
362 res.status = 500;
363 res.body = "Internal Server Error";
364 res.content_type = .TEXT;
365 return;
366 };
367 const sanitized_url_encoded_channel_name = sanitize.html(
368 res.arena,
369 url_encoded_channel_name,
370 ) catch |err| {
371 log.err("[HTTP] failed to sanitize url encoded channel name: {}: {s}", .{ err, name });
372 res.status = 500;
373 res.body = "Internal Server Error";
374 res.content_type = .TEXT;
375 return;
376 };
377
378 const html_item = try std.fmt.allocPrint(
379 res.arena,
380 "<li><a href=\"/channels/{s}\">{s}</a></li>",
381 .{ sanitized_url_encoded_channel_name, sanitized_channel_name },
382 );
383 try list.appendSlice(res.arena, html_item);
384 }
385
386 const replace_size = std.mem.replacementSize(u8, html_with_nonce, "$channel_list", list.items);
387 const body = try res.arena.alloc(u8, replace_size);
388 _ = std.mem.replace(u8, html_with_nonce, "$channel_list", list.items, body);
389
390 res.status = 200;
391 res.body = body;
392 res.content_type = .HTML;
393}
394
395/// We steal the fd from httpz.
396pub fn startChannelEventStream(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void {
397 const channel_param = try res.arena.dupe(u8, req.param("channel").?);
398 const channel = std.Uri.percentDecodeInPlace(channel_param);
399
400 log.err("[HTTP] Starting event stream for {s}", .{channel});
401
402 if (ctx.channels.get(channel)) |c| {
403 try prepareResponseForEventStream(res);
404
405 // Create the EventStream
406 const es = try ctx.gpa.create(EventStream);
407 es.* = .{
408 .stream = .{ .fd = res.conn.stream.handle },
409 .channel = c,
410 .write_buf = .empty,
411 .write_c = .{},
412 };
413 // add the event stream to the server. On wakeup, the server will add the stream to it's
414 // list, and the channels list
415 ctx.irc_server.wakeup_queue.push(.{ .event_stream = es });
416 return;
417 }
418
419 res.status = 404;
420 res.body = "No such channel";
421 res.content_type = .TEXT;
422}
423
424/// Vendored from httpz. This was part of self.startEventStream. We copy the part where it writes
425/// headers, but stop short of spawning a thread
426fn prepareResponseForEventStream(self: *httpz.Response) !void {
427 self.content_type = .EVENTS;
428 self.headers.add("Cache-Control", "no-cache");
429 self.headers.add("Connection", "keep-alive");
430
431 const conn = self.conn;
432 const stream = conn.stream;
433
434 const header_buf = try prepareHeader(self);
435 try stream.writeAll(header_buf);
436
437 self.disown();
438}
439
440/// Vendored from httpz. Not a public function
441fn prepareHeader(self: *httpz.Response) ![]const u8 {
442 const headers = &self.headers;
443 const names = headers.keys[0..headers.len];
444 const values = headers.values[0..headers.len];
445
446 // 220 gives us enough space to fit:
447 // 1 - The status/first line
448 // 2 - The Content-Length header or the Transfer-Encoding header.
449 // 3 - Our longest supported built-in content type (for a custom content
450 // type, it would have been set via the res.header(...) call, so would
451 // be included in `len)
452 var len: usize = 220;
453 for (names, values) |name, value| {
454 // +4 for the colon, space and trailer
455 len += name.len + value.len + 4;
456 }
457
458 var buf = try self.arena.alloc(u8, len);
459
460 var pos: usize = "HTTP/1.1 XXX \r\n".len;
461 switch (self.status) {
462 inline 100...103, 200...208, 226, 300...308, 400...418, 421...426, 428, 429, 431, 451, 500...511 => |status| @memcpy(buf[0..15], std.fmt.comptimePrint("HTTP/1.1 {d} \r\n", .{status})),
463 else => |s| {
464 const HTTP1_1 = "HTTP/1.1 ";
465 const l = HTTP1_1.len;
466 @memcpy(buf[0..l], HTTP1_1);
467 pos = l + writeInt(buf[l..], @as(u32, s));
468 @memcpy(buf[pos..][0..3], " \r\n");
469 pos += 3;
470 },
471 }
472
473 if (self.content_type) |ct| {
474 const content_type: ?[]const u8 = switch (ct) {
475 .BINARY => "Content-Type: application/octet-stream\r\n",
476 .CSS => "Content-Type: text/css; charset=UTF-8\r\n",
477 .CSV => "Content-Type: text/csv; charset=UTF-8\r\n",
478 .EOT => "Content-Type: application/vnd.ms-fontobject\r\n",
479 .EVENTS => "Content-Type: text/event-stream; charset=UTF-8\r\n",
480 .GIF => "Content-Type: image/gif\r\n",
481 .GZ => "Content-Type: application/gzip\r\n",
482 .HTML => "Content-Type: text/html; charset=UTF-8\r\n",
483 .ICO => "Content-Type: image/vnd.microsoft.icon\r\n",
484 .JPG => "Content-Type: image/jpeg\r\n",
485 .JS => "Content-Type: text/javascript; charset=UTF-8\r\n",
486 .JSON => "Content-Type: application/json\r\n",
487 .OTF => "Content-Type: font/otf\r\n",
488 .PDF => "Content-Type: application/pdf\r\n",
489 .PNG => "Content-Type: image/png\r\n",
490 .SVG => "Content-Type: image/svg+xml\r\n",
491 .TAR => "Content-Type: application/x-tar\r\n",
492 .TEXT => "Content-Type: text/plain; charset=UTF-8\r\n",
493 .TTF => "Content-Type: font/ttf\r\n",
494 .WASM => "Content-Type: application/wasm\r\n",
495 .WEBP => "Content-Type: image/webp\r\n",
496 .WOFF => "Content-Type: font/woff\r\n",
497 .WOFF2 => "Content-Type: font/woff2\r\n",
498 .XML => "Content-Type: text/xml; charset=UTF-8\r\n",
499 .UNKNOWN => null,
500 };
501 if (content_type) |value| {
502 const end = pos + value.len;
503 @memcpy(buf[pos..end], value);
504 pos = end;
505 }
506 }
507
508 if (self.keepalive == false) {
509 const CLOSE_HEADER = "Connection: Close\r\n";
510 const end = pos + CLOSE_HEADER.len;
511 @memcpy(buf[pos..end], CLOSE_HEADER);
512 pos = end;
513 }
514
515 for (names, values) |name, value| {
516 {
517 // write the name
518 const end = pos + name.len;
519 @memcpy(buf[pos..end], name);
520 pos = end;
521 buf[pos] = ':';
522 buf[pos + 1] = ' ';
523 pos += 2;
524 }
525
526 {
527 // write the value + trailer
528 const end = pos + value.len;
529 @memcpy(buf[pos..end], value);
530 pos = end;
531 buf[pos] = '\r';
532 buf[pos + 1] = '\n';
533 pos += 2;
534 }
535 }
536
537 const buffer_pos = self.buffer.pos;
538 const body_len = if (buffer_pos > 0) buffer_pos else self.body.len;
539 if (body_len > 0) {
540 const CONTENT_LENGTH = "Content-Length: ";
541 var end = pos + CONTENT_LENGTH.len;
542 @memcpy(buf[pos..end], CONTENT_LENGTH);
543 pos = end;
544
545 pos += writeInt(buf[pos..], @intCast(body_len));
546 end = pos + 4;
547 @memcpy(buf[pos..end], "\r\n\r\n");
548 return buf[0..end];
549 }
550
551 const fin = blk: {
552 // For chunked, we end with a single \r\n because the call to res.chunk()
553 // prepends a \r\n. Hence,for the first chunk, we'll have the correct \r\n\r\n
554 if (self.chunked) break :blk "Transfer-Encoding: chunked\r\n";
555 if (self.content_type == .EVENTS) break :blk "\r\n";
556 break :blk "Content-Length: 0\r\n\r\n";
557 };
558
559 const end = pos + fin.len;
560 @memcpy(buf[pos..end], fin);
561 return buf[0..end];
562}
563
564/// Vendored from httpz. Not a public function
565fn writeInt(into: []u8, value: u32) usize {
566 const small_strings = "00010203040506070809" ++
567 "10111213141516171819" ++
568 "20212223242526272829" ++
569 "30313233343536373839" ++
570 "40414243444546474849" ++
571 "50515253545556575859" ++
572 "60616263646566676869" ++
573 "70717273747576777879" ++
574 "80818283848586878889" ++
575 "90919293949596979899";
576
577 var v = value;
578 var i: usize = 10;
579 var buf: [10]u8 = undefined;
580 while (v >= 100) {
581 const digits = v % 100 * 2;
582 v /= 100;
583 i -= 2;
584 buf[i + 1] = small_strings[digits + 1];
585 buf[i] = small_strings[digits];
586 }
587
588 {
589 const digits = v * 2;
590 i -= 1;
591 buf[i] = small_strings[digits + 1];
592 if (v >= 10) {
593 i -= 1;
594 buf[i] = small_strings[digits];
595 }
596 }
597
598 const l = buf.len - i;
599 @memcpy(into[0..l], buf[i..]);
600 return l;
601}