this repo has no description
at main 21 kB view raw
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}