const std = @import("std"); const sqlite = @import("sqlite"); const httpz = @import("httpz"); const uuid = @import("uuid"); const xev = @import("xev"); const Allocator = std.mem.Allocator; const log = @import("log.zig"); const irc = @import("irc.zig"); const IrcServer = @import("Server.zig"); const sanitize = @import("sanitize.zig"); const Url = @import("Url.zig"); const public_html_index = @embedFile("public/html/index.html"); const public_html_channel = @embedFile("public/html/channel.html"); const public_html_channel_list = @embedFile("public/html/channel-list.html"); const public_css_reset = @embedFile("public/css/reset.css"); const public_css_style = @embedFile("public/css/style.css"); const public_js_htmx = @embedFile("public/js/htmx-2.0.4.js"); const public_js_htmx_sse = @embedFile("public/js/htmx-ext-sse.js"); const public_js_stick_to_bottom = @embedFile("public/js/stick-to-bottom.js"); pub const Server = struct { gpa: std.mem.Allocator, channels: *std.StringArrayHashMapUnmanaged(*irc.Channel), db_pool: *sqlite.Pool, csp_nonce: []const u8 = undefined, nonce: []u8 = undefined, irc_server: *IrcServer, pub fn hasChannel(self: *const Server, channel: []const u8) bool { for (self.channels.keys()) |c| { if (std.mem.eql(u8, c[1..], channel)) return true; } return false; } /// Generates a 256-bit, base64-encoded nonce and adds a Content-Security-Policy header. fn addContentSecurityPolicy( self: *Server, action: httpz.Action(*Server), req: *httpz.Request, res: *httpz.Response, ) !void { _ = action; _ = req; // TODO: Use static buffers, we know the lenghts of everything here, so we should be // able to skip all the allocations here. const nonce_buf = try res.arena.alloc(u8, 32); std.crypto.random.bytes(nonce_buf); const encoder: std.base64.Base64Encoder = .init( std.base64.standard.alphabet_chars, std.base64.standard.pad_char, ); const b64_len = encoder.calcSize(nonce_buf.len); self.nonce = try res.arena.alloc(u8, b64_len); _ = encoder.encode(self.nonce, nonce_buf); const header = try std.fmt.allocPrint( res.arena, "script-src 'nonce-{s}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none';", .{self.nonce}, ); res.header("Content-Security-Policy", header); } pub fn dispatch( self: *Server, action: httpz.Action(*Server), req: *httpz.Request, res: *httpz.Response, ) !void { try self.addContentSecurityPolicy(action, req, res); try action(self, req, res); } }; pub const EventStream = struct { stream: xev.TCP, channel: *irc.Channel, write_buf: std.ArrayListUnmanaged(u8), /// EventStream owns it's own completion. We do this because we can only ever write to the /// stream. We have full control over the lifetime of this completion. For other IRC /// connections, we could have an inflight write and receive a connection closed on our read /// call. This makes managing the lifetime difficult. For EventStream, we will only error out on /// the write - and then we can dispose of the connection write_c: xev.Completion, pub fn print( self: *EventStream, gpa: std.mem.Allocator, comptime format: []const u8, args: anytype, ) std.mem.Allocator.Error!void { return self.write_buf.writer(gpa).print(format, args); } pub fn writeAll(self: *EventStream, gpa: Allocator, buf: []const u8) Allocator.Error!void { return self.write_buf.appendSlice(gpa, buf); } }; pub fn getIndex(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void { _ = req; const html_size = std.mem.replacementSize(u8, public_html_index, "$nonce", ctx.nonce); const html_with_nonce = try res.arena.alloc(u8, html_size); _ = std.mem.replace(u8, public_html_index, "$nonce", ctx.nonce, html_with_nonce); res.status = 200; res.body = html_with_nonce; res.content_type = .HTML; } pub fn getAsset(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void { _ = ctx; const asset_type = req.param("type").?; const name = req.param("name").?; if (std.mem.eql(u8, asset_type, "css")) { if (std.mem.eql(u8, "reset.css", name)) { res.status = 200; res.body = public_css_reset; res.content_type = .CSS; // Cache indefinitely in the browser. res.header("Cache-Control", "max-age=31536000, immutable"); return; } if (std.mem.eql(u8, "style-1.0.0.css", name)) { res.status = 200; res.body = public_css_style; res.content_type = .CSS; // Cache indefinitely in the browser. res.header("Cache-Control", "max-age=31536000, immutable"); return; } } if (std.mem.eql(u8, asset_type, "js")) { if (std.mem.eql(u8, "htmx-2.0.4.js", name)) { res.status = 200; res.body = public_js_htmx; res.content_type = .JS; // Cache indefinitely in the browser. res.header("Cache-Control", "max-age=31536000, immutable"); return; } if (std.mem.eql(u8, "htmx-ext-sse.js", name)) { res.status = 200; res.body = public_js_htmx_sse; res.content_type = .JS; // Cache indefinitely in the browser. res.header("Cache-Control", "max-age=31536000, immutable"); return; } if (std.mem.eql(u8, "stick-to-bottom-1.0.1.js", name)) { res.status = 200; res.body = public_js_stick_to_bottom; res.content_type = .JS; // Cache indefinitely in the browser. res.header("Cache-Control", "max-age=31536000, immutable"); return; } } res.status = 404; res.body = "Not found"; res.content_type = .TEXT; } pub fn getChannel(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void { const channel_param = try res.arena.dupe(u8, req.param("channel").?); const channel = std.Uri.percentDecodeInPlace(channel_param); if (!ctx.channels.contains(channel)) { res.status = 404; res.body = "Channel does not exist"; res.content_type = .TEXT; return; } const html_size = std.mem.replacementSize(u8, public_html_channel, "$nonce", ctx.nonce); const html_with_nonce = try res.arena.alloc(u8, html_size); _ = std.mem.replace(u8, public_html_channel, "$nonce", ctx.nonce, html_with_nonce); const sanitized_channel_name = sanitize.html(res.arena, channel) catch |err| { log.err("[HTTP] failed to sanitize channel name: {}: {s}", .{ err, channel }); res.status = 500; res.body = "Internal Server Error"; res.content_type = .TEXT; return; }; const header_replace_size = std.mem.replacementSize( u8, html_with_nonce, "$channel_name", sanitized_channel_name, ); const body_without_messages_and_sse_endpoint = try res.arena.alloc(u8, header_replace_size); _ = std.mem.replace( u8, html_with_nonce, "$channel_name", sanitized_channel_name, body_without_messages_and_sse_endpoint, ); const url_encoded_channel_name = Url.encode(res.arena, channel) catch |err| { log.err("[HTTP] failed to url encode channel name: {}: {s}", .{ err, channel }); res.status = 500; res.body = "Internal Server Error"; res.content_type = .TEXT; return; }; const sanitized_url_encoded_channel_name = sanitize.html( res.arena, url_encoded_channel_name, ) catch |err| { log.err("[HTTP] failed to sanitize url encoded channel name: {}: {s}", .{ err, channel }); res.status = 500; res.body = "Internal Server Error"; res.content_type = .TEXT; return; }; const sse_endpoint_replace_size = std.mem.replacementSize( u8, body_without_messages_and_sse_endpoint, "$encoded_channel_name", sanitized_url_encoded_channel_name, ); const body_without_messages = try res.arena.alloc(u8, sse_endpoint_replace_size); _ = std.mem.replace( u8, body_without_messages_and_sse_endpoint, "$encoded_channel_name", sanitized_url_encoded_channel_name, body_without_messages, ); // Nested SQL query because we first get the newest 100 messages, then want to order them from // oldest to newest. I'm sure there might be a way to optimize this query if it turns out to be // slow. const sql = \\SELECT * FROM ( \\ SELECT \\ uuid, \\ timestamp_ms, \\ sender_nick, \\ message \\ FROM messages m \\ WHERE recipient_type = 0 \\ AND recipient_id = (SELECT id FROM channels WHERE name = ?) \\ ORDER BY timestamp_ms DESC \\ LIMIT 100 \\) ORDER BY timestamp_ms ASC; ; const conn = ctx.db_pool.acquire(); defer ctx.db_pool.release(conn); var rows = conn.rows(sql, .{channel}) catch |err| { log.err("[HTTP] failed while querying messages: {}: {s}", .{ err, conn.lastError() }); res.status = 500; res.body = "Internal Server Error"; res.content_type = .TEXT; return; }; defer rows.deinit(); var messages: std.ArrayListUnmanaged(u8) = .empty; defer messages.deinit(res.arena); // Track some state while printing these messages var last_nick_buf: [256]u8 = undefined; var last_nick: []const u8 = ""; var last_time: i64 = 0; while (rows.next()) |row| { const timestamp: irc.Timestamp = .{ .milliseconds = row.int(1), }; const message: irc.Message = .{ .bytes = row.text(3), .timestamp = timestamp, .uuid = try uuid.urn.deserialize(row.text(0)), }; const nick = row.text(2); defer { // Store state. We get up to 256 bytes for the nick, which should be good but either way // we truncate if needed const len = @min(last_nick_buf.len, nick.len); @memcpy(last_nick_buf[0..len], nick); last_nick = last_nick_buf[0..len]; last_time = timestamp.milliseconds; } var iter = message.paramIterator(); _ = iter.next(); const content = iter.next() orelse continue; const san_content: sanitize.Html = .{ .bytes = content }; // We don't reprint the sender if the last message this message are from the same // person. Unless enough time has elapsed (5 minutes) if (std.ascii.eqlIgnoreCase(last_nick, nick) and (last_time + 5 * std.time.ms_per_min) >= timestamp.milliseconds) { const fmt = \\
; try messages.writer(res.arena).print(fmt, .{san_content}); continue; } const fmt = \\ ; const sender_sanitized: sanitize.Html = .{ .bytes = nick }; try messages.writer(res.arena).print(fmt, .{ sender_sanitized, san_content }); } const body_replace_size = std.mem.replacementSize( u8, body_without_messages, "$messages", messages.items, ); const body = try res.arena.alloc(u8, body_replace_size); _ = std.mem.replace(u8, body_without_messages, "$messages", messages.items, body); res.status = 200; res.body = body; res.content_type = .HTML; } pub fn getChannels(ctx: *Server, req: *httpz.Request, res: *httpz.Response) !void { _ = req; const html_size = std.mem.replacementSize(u8, public_html_channel_list, "$nonce", ctx.nonce); const html_with_nonce = try res.arena.alloc(u8, html_size); _ = std.mem.replace(u8, public_html_channel_list, "$nonce", ctx.nonce, html_with_nonce); var list: std.ArrayListUnmanaged(u8) = .empty; defer list.deinit(res.arena); for (ctx.channels.keys()) |name| { const sanitized_channel_name = sanitize.html(res.arena, name) catch |err| { log.err("[HTTP] failed to sanitize channel name: {}: {s}", .{ err, name }); res.status = 500; res.body = "Internal Server Error"; res.content_type = .TEXT; return; }; const url_encoded_channel_name = Url.encode(res.arena, name) catch |err| { log.err("[HTTP] failed to url encode channel name: {}: {s}", .{ err, name }); res.status = 500; res.body = "Internal Server Error"; res.content_type = .TEXT; return; }; const sanitized_url_encoded_channel_name = sanitize.html( res.arena, url_encoded_channel_name, ) catch |err| { log.err("[HTTP] failed to sanitize url encoded channel name: {}: {s}", .{ err, name }); res.status = 500; res.body = "Internal Server Error"; res.content_type = .TEXT; return; }; const html_item = try std.fmt.allocPrint( res.arena, "