╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const xit = @import("xit"); 3 const rp = xit.repo; 4 const hash = xit.hash; 5 const evt = @import("./event.zig"); 6 const ui = @import("./ui.zig"); 7 const xitui = xit.xitui; 8 const wgt = xitui.widget; 9 const Focus = xitui.focus.Focus; 10 11 const cookie_name = "haxy_user"; 12 13 const Embed = struct { 14 path: []const u8, 15 content_type: []const u8, 16 body: []const u8, 17 }; 18 19 const embeds = [_]Embed{ 20 .{ .path = "index.html", .content_type = "text/html; charset=utf-8", .body = @embedFile("embed/index.html") }, 21 .{ .path = "script.js", .content_type = "text/javascript; charset=utf-8", .body = @embedFile("embed/script.js") }, 22 .{ .path = "term.ttf", .content_type = "font/ttf", .body = @embedFile("embed/term.ttf") }, 23 .{ .path = "haxy.wasm", .content_type = "application/wasm", .body = @embedFile("embed/haxy.wasm") }, 24 }; 25 26 pub fn run( 27 io: std.Io, 28 allocator: std.mem.Allocator, 29 net_server: *std.Io.net.Server, 30 tasks: *std.Io.Group, 31 admin_repo_path: []const u8, 32 err: *std.Io.Writer, 33 ) void { 34 const Listener = struct { 35 io: std.Io, 36 allocator: std.mem.Allocator, 37 net_server: *std.Io.net.Server, 38 tasks: *std.Io.Group, 39 admin_repo_path: []const u8, 40 err: *std.Io.Writer, 41 42 fn run(ctx: @This()) void { 43 while (true) { 44 const stream = ctx.net_server.accept(ctx.io) catch |accept_err| { 45 if (accept_err == error.Canceled) return; 46 logError(ctx.err, "web ui accept failed: {s}\n", .{@errorName(accept_err)}); 47 continue; 48 }; 49 50 const Connection = struct { 51 io: std.Io, 52 allocator: std.mem.Allocator, 53 stream: std.Io.net.Stream, 54 admin_repo_path: []const u8, 55 err: *std.Io.Writer, 56 57 fn run(conn: @This()) void { 58 defer conn.stream.close(conn.io); 59 handleConnection(conn.io, conn.allocator, conn.stream, conn.admin_repo_path, conn.err) catch |request_err| { 60 logError(conn.err, "web ui request failed: {s}\n", .{@errorName(request_err)}); 61 }; 62 } 63 }; 64 65 ctx.tasks.async(ctx.io, Connection.run, .{Connection{ 66 .io = ctx.io, 67 .allocator = ctx.allocator, 68 .stream = stream, 69 .admin_repo_path = ctx.admin_repo_path, 70 .err = ctx.err, 71 }}); 72 } 73 } 74 }; 75 76 tasks.async(io, Listener.run, .{Listener{ 77 .io = io, 78 .allocator = allocator, 79 .net_server = net_server, 80 .tasks = tasks, 81 .admin_repo_path = admin_repo_path, 82 .err = err, 83 }}); 84 } 85 86 fn handleConnection( 87 io: std.Io, 88 allocator: std.mem.Allocator, 89 stream: std.Io.net.Stream, 90 admin_repo_path: []const u8, 91 err: *std.Io.Writer, 92 ) !void { 93 var send_buffer = [_]u8{0} ** 4096; 94 var recv_buffer = [_]u8{0} ** 4096; 95 var conn_br = stream.reader(io, &recv_buffer); 96 var conn_bw = stream.writer(io, &send_buffer); 97 var http_server = std.http.Server.init(&conn_br.interface, &conn_bw.interface); 98 99 while (http_server.reader.state == .ready) { 100 var request = http_server.receiveHead() catch |receive_err| switch (receive_err) { 101 error.HttpConnectionClosing => break, 102 error.ReadFailed => break, 103 else => |e| return e, 104 }; 105 106 handleRequest(io, &http_server, &request, allocator, admin_repo_path) catch |request_err| { 107 try err.print("web ui request failed: {s}\n", .{@errorName(request_err)}); 108 try err.flush(); 109 if (http_server.reader.state == .received_head) { 110 http_server.reader.state = .ready; 111 } 112 try writeSimpleResponse(&http_server, 500, "Internal Server Error", "text/plain", @errorName(request_err)); 113 }; 114 try http_server.out.flush(); 115 break; 116 } 117 } 118 119 fn handleRequest( 120 io: std.Io, 121 http_server: *std.http.Server, 122 request: *std.http.Server.Request, 123 allocator: std.mem.Allocator, 124 admin_repo_path: []const u8, 125 ) !void { 126 const method = request.head.method; 127 const uri = try std.Uri.parseAfterScheme("", request.head.target); 128 const path = uri.path.percent_encoded; 129 130 if (method == .POST and std.mem.eql(u8, path, "/login")) { 131 return handleLogin(io, http_server, request, allocator, admin_repo_path); 132 } 133 if (method == .POST and std.mem.eql(u8, path, "/logout")) { 134 return handleLogout(http_server, request); 135 } 136 137 const get_or_head = method == .GET or method == .HEAD; 138 if (!get_or_head) { 139 if (http_server.reader.state == .received_head) { 140 http_server.reader.state = .ready; 141 } 142 try writeSimpleResponse(http_server, 405, "Method Not Allowed", "text/plain", "method not allowed"); 143 return; 144 } 145 146 if (std.mem.eql(u8, path, "/favicon.ico")) { 147 if (http_server.reader.state == .received_head) { 148 http_server.reader.state = .ready; 149 } 150 try writeStaticResponse(http_server, 204, "No Content", "image/x-icon", "", true); 151 return; 152 } 153 154 // for the index page we need to consult the session cookie before 155 // consuming the request, so we don't dispatch through findEmbed here. 156 if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/index.html")) { 157 const user_id_hex = getCookieValue(request, cookie_name); 158 if (http_server.reader.state == .received_head) { 159 http_server.reader.state = .ready; 160 } 161 const html = try renderIndexHtml(io, allocator, admin_repo_path, user_id_hex); 162 defer allocator.free(html); 163 try writeStaticResponse(http_server, 200, "OK", "text/html; charset=utf-8", html, method == .HEAD); 164 return; 165 } 166 167 const embed = findEmbed(path) orelse { 168 if (http_server.reader.state == .received_head) { 169 http_server.reader.state = .ready; 170 } 171 try writeSimpleResponse(http_server, 404, "Not Found", "text/plain", "not found"); 172 return; 173 }; 174 175 if (http_server.reader.state == .received_head) { 176 http_server.reader.state = .ready; 177 } 178 try writeStaticResponse(http_server, 200, "OK", embed.content_type, embed.body, method == .HEAD); 179 } 180 181 fn handleLogin( 182 io: std.Io, 183 http_server: *std.http.Server, 184 request: *std.http.Server.Request, 185 allocator: std.mem.Allocator, 186 admin_repo_path: []const u8, 187 ) !void { 188 var body_buf: [256]u8 = undefined; 189 const reader = request.readerExpectNone(&body_buf); 190 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 191 defer allocator.free(body); 192 193 const username = (try parseFormField(allocator, body, "username")) orelse try allocator.dupe(u8, ""); 194 defer allocator.free(username); 195 const password = (try parseFormField(allocator, body, "password")) orelse try allocator.dupe(u8, ""); 196 defer allocator.free(password); 197 198 const repo_opts: rp.RepoOpts(.xit) = .{}; 199 const Repo = rp.Repo(.xit, repo_opts); 200 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 201 defer repo.deinit(io, allocator); 202 203 var arena = std.heap.ArenaAllocator.init(allocator); 204 defer arena.deinit(); 205 206 const haxy_moment = try openHaxyMoment(repo_opts, &repo); 207 const result = try evt.User.verifyCredentials(Repo.DB, repo_opts.hash, haxy_moment, &arena, username, password); 208 209 switch (result) { 210 .success => |user_id| { 211 const hex = std.fmt.bytesToHex(user_id, .lower); 212 try http_server.out.print( 213 "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict\r\nContent-Length: 0\r\n\r\n", 214 .{hex}, 215 ); 216 }, 217 .unknown_user, .wrong_password => { 218 try http_server.out.print( 219 "HTTP/1.1 303 See Other\r\nLocation: /\r\nContent-Length: 0\r\n\r\n", 220 .{}, 221 ); 222 }, 223 } 224 } 225 226 fn handleLogout(http_server: *std.http.Server, request: *std.http.Server.Request) !void { 227 // we don't need the body, but the http server wants the request consumed 228 // before the next one (and before we write the response). 229 var sink_buf: [64]u8 = undefined; 230 _ = request.readerExpectNone(&sink_buf).discardRemaining() catch {}; 231 232 try http_server.out.print( 233 "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0\r\nContent-Length: 0\r\n\r\n", 234 .{}, 235 ); 236 } 237 238 fn renderIndexHtml( 239 io: std.Io, 240 allocator: std.mem.Allocator, 241 admin_repo_path: []const u8, 242 user_id_hex_opt: ?[]const u8, 243 ) ![]const u8 { 244 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 245 246 // open the admin repo to read live user/repo data. 247 const repo_opts: rp.RepoOpts(.xit) = .{}; 248 const Repo = rp.Repo(.xit, repo_opts); 249 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 250 defer repo.deinit(io, allocator); 251 var page_arena = std.heap.ArenaAllocator.init(allocator); 252 defer page_arena.deinit(); 253 254 var session: ui.Session = .{}; 255 if (user_id_hex_opt) |hex| { 256 if (decodeHexAlloc(page_arena.allocator(), hex)) |bytes| { 257 session.user_id = bytes; 258 } else |_| {} 259 } 260 261 const page: ui.Page = .{ .home = try .init(repo_opts, &page_arena, &repo, &session) }; 262 var root = try ui.initRoot(allocator, &page, &session); 263 defer root.deinit(allocator); 264 265 const content = try generateHtml(allocator, &root, &session); 266 defer allocator.free(content); 267 268 // serialize the page so the wasm side can parse it back without making 269 // a second request. base64-encoded so the json can be embedded in html. 270 var json: std.Io.Writer.Allocating = .init(allocator); 271 defer json.deinit(); 272 try std.json.Stringify.value(page, .{}, &json.writer); 273 274 const b64 = std.base64.standard.Encoder; 275 const json_b64 = try allocator.alloc(u8, b64.calcSize(json.written().len)); 276 defer allocator.free(json_b64); 277 _ = b64.encode(json_b64, json.written()); 278 279 var out: std.ArrayList(u8) = .empty; 280 errdefer out.deinit(allocator); 281 { 282 var cursor: usize = 0; 283 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 284 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 285 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_b64 }, 286 }) |sub| { 287 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 288 try out.appendSlice(allocator, template[cursor..idx]); 289 try out.appendSlice(allocator, sub.replacement); 290 cursor = idx + sub.needle.len; 291 } 292 try out.appendSlice(allocator, template[cursor..]); 293 } 294 return try out.toOwnedSlice(allocator); 295 } 296 297 fn findEmbed(request_path: []const u8) ?Embed { 298 const path = if (std.mem.eql(u8, request_path, "/")) 299 "index.html" 300 else if (request_path.len > 1 and request_path[0] == '/') 301 request_path[1..] 302 else 303 return null; 304 305 for (embeds) |embed| { 306 if (std.mem.eql(u8, path, embed.path)) return embed; 307 } 308 return null; 309 } 310 311 fn writeSimpleResponse( 312 http_server: *std.http.Server, 313 code: u16, 314 message: []const u8, 315 content_type: []const u8, 316 body: []const u8, 317 ) !void { 318 try http_server.out.print( 319 "HTTP/1.1 {d} {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n{s}", 320 .{ code, message, content_type, body.len, body }, 321 ); 322 } 323 324 fn writeStaticResponse( 325 http_server: *std.http.Server, 326 code: u16, 327 message: []const u8, 328 content_type: []const u8, 329 body: []const u8, 330 head_only: bool, 331 ) !void { 332 try http_server.out.print( 333 "HTTP/1.1 {d} {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n", 334 .{ code, message, content_type, body.len }, 335 ); 336 if (!head_only) { 337 try http_server.out.writeAll(body); 338 } 339 } 340 341 fn logError(err: *std.Io.Writer, comptime fmt: []const u8, args: anytype) void { 342 err.print(fmt, args) catch return; 343 err.flush() catch {}; 344 } 345 346 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget, session: *const ui.Session) ![]const u8 { 347 const grid = root.getGrid() orelse return error.MissingGrid; 348 const root_focus = root.getFocus(); 349 350 var out: std.ArrayList(u8) = .empty; 351 errdefer out.deinit(allocator); 352 353 // determines the form route for any submit_button on this page. only one 354 // is visible at a time: login when logged out, logout when logged in. 355 const submit_url: []const u8 = if (session.user_id != null) "/logout" else "/login"; 356 357 // collect the text inputs in deterministic top-to-bottom, left-to-right 358 // order. root_focus.children is a hash map so iterating it directly would 359 // give arbitrary tab order between username / password. 360 const InputEntry = struct { 361 focus_id: usize, 362 x: usize, 363 y: usize, 364 width: usize, 365 is_password: bool, 366 ti: *wgt.TextInput(ui.Widget), 367 }; 368 var inputs: std.ArrayList(InputEntry) = .empty; 369 defer inputs.deinit(allocator); 370 var focus_iter = root_focus.children.iterator(); 371 while (focus_iter.next()) |entry| { 372 const child = entry.value_ptr.*; 373 const is_password = switch (child.focus.kind) { 374 .text_input => false, 375 .text_input_password => true, 376 else => continue, 377 }; 378 try inputs.append(allocator, .{ 379 .focus_id = entry.key_ptr.*, 380 .x = child.rect.x, 381 .y = child.rect.y, 382 .width = child.rect.size.width, 383 .is_password = is_password, 384 .ti = @fieldParentPtr("focus", child.focus), 385 }); 386 } 387 std.mem.sort(InputEntry, inputs.items, {}, struct { 388 fn lt(_: void, a: InputEntry, b: InputEntry) bool { 389 if (a.y != b.y) return a.y < b.y; 390 return a.x < b.x; 391 } 392 }.lt); 393 394 // emit the overlay inputs FIRST so they precede any submit-button span 395 // in DOM order; with the submit button carrying tabindex="0", that puts 396 // it at the end of the natural tab order — username -> password -> button. 397 for (inputs.items) |entry| { 398 const inner_left = entry.x + 1; 399 const inner_top = entry.y + 1; 400 const inner_width = if (entry.width > 2) entry.width - 2 else 0; 401 402 try out.appendSlice(allocator, "<input type=\""); 403 try out.appendSlice(allocator, if (entry.is_password) "password" else "text"); 404 try out.appendSlice(allocator, "\" data-focus-id=\""); 405 var id_buf: [32]u8 = undefined; 406 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{entry.focus_id})); 407 if (entry.ti.options.name.len > 0) { 408 try out.appendSlice(allocator, "\" name=\""); 409 try appendEscapedHtml(allocator, &out, entry.ti.options.name); 410 } 411 try out.appendSlice(allocator, "\" value=\""); 412 var value_buf: std.ArrayList(u8) = .empty; 413 defer value_buf.deinit(allocator); 414 for (entry.ti.content.items) |cp| { 415 try value_buf.appendSlice(allocator, cp); 416 } 417 try appendEscapedHtml(allocator, &out, value_buf.items); 418 try out.appendSlice(allocator, "\" style=\"left:"); 419 var pos_buf: [64]u8 = undefined; 420 try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:1em", .{ inner_left, inner_top, inner_width })); 421 try out.appendSlice(allocator, "\">"); 422 } 423 424 for (0..grid.size.height) |y| { 425 // wrap runs of cells belonging to a focusable widget in a span tagged 426 // with that widget's focus id. CSS paints them with a pointer cursor; 427 // JS reads the id on click and dispatches focus directly. 428 var current_id: ?usize = null; 429 for (0..grid.size.width) |x| { 430 const cell_id = cellFocusId(root_focus, x, y); 431 if (cell_id != current_id) { 432 if (current_id != null) try out.appendSlice(allocator, "</span>"); 433 if (cell_id) |id| { 434 const kind = if (root_focus.children.get(id)) |entry| entry.focus.kind else .container; 435 var buf: [256]u8 = undefined; 436 // tabindex="0" on submit buttons puts them in the browser's 437 // natural Tab order alongside the inputs above. 438 const tag = if (kind == .submit_button) 439 try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\" data-action=\"submit\" data-url=\"{s}\" tabindex=\"0\">", .{ id, submit_url }) 440 else 441 try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\">", .{id}); 442 try out.appendSlice(allocator, tag); 443 } 444 current_id = cell_id; 445 } 446 const rune = grid.cells.items[try grid.cells.at(.{ y, x })].rune orelse " "; 447 try appendEscapedHtml(allocator, &out, rune); 448 } 449 if (current_id != null) try out.appendSlice(allocator, "</span>"); 450 try out.append(allocator, '\n'); 451 } 452 453 return try out.toOwnedSlice(allocator); 454 } 455 456 fn cellFocusId(focus: *Focus, x: usize, y: usize) ?usize { 457 var iter = focus.children.iterator(); 458 while (iter.next()) |entry| { 459 const child = entry.value_ptr.*; 460 if (!child.focus.focusable) continue; 461 const r = child.rect; 462 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 463 return entry.key_ptr.*; 464 } 465 } 466 return null; 467 } 468 469 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 470 for (input) |ch| { 471 switch (ch) { 472 '&' => try out.appendSlice(allocator, "&amp;"), 473 '<' => try out.appendSlice(allocator, "&lt;"), 474 '>' => try out.appendSlice(allocator, "&gt;"), 475 '"' => try out.appendSlice(allocator, "&quot;"), 476 '\'' => try out.appendSlice(allocator, "&#39;"), 477 else => try out.append(allocator, ch), 478 } 479 } 480 } 481 482 // --- helpers -------------------------------------------------------------- 483 484 fn openHaxyMoment( 485 comptime repo_opts: rp.RepoOpts(.xit), 486 repo: *rp.Repo(.xit, repo_opts), 487 ) !rp.Repo(.xit, repo_opts).DB.HashMap(.read_only) { 488 const DB = rp.Repo(.xit, repo_opts).DB; 489 const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 490 const moment_cursor = try history.getCursor(-1) orelse return error.NotFound; 491 const moment = try DB.HashMap(.read_only).init(moment_cursor); 492 const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy-last-object-id")) orelse return error.NotFound; 493 var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; 494 _ = try last_object_id_cursor.readBytes(&last_object_id); 495 const haxy_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy")) orelse return error.NotFound; 496 const haxy = try DB.ArrayList(.read_only).init(haxy_cursor); 497 const haxy_moments_cursor = try haxy.getCursor(-1) orelse return error.NotFound; 498 const haxy_moments = try DB.HashMap(.read_only).init(haxy_moments_cursor); 499 const haxy_moment_cursor = try haxy_moments.getCursor(hash.bytesToInt(repo_opts.hash, &last_object_id)) orelse return error.NotFound; 500 return try DB.HashMap(.read_only).init(haxy_moment_cursor); 501 } 502 503 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 504 var iter = request.iterateHeaders(); 505 while (iter.next()) |header| { 506 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 507 var pairs = std.mem.splitScalar(u8, header.value, ';'); 508 while (pairs.next()) |pair| { 509 const trimmed = std.mem.trim(u8, pair, " \t"); 510 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 511 if (std.mem.eql(u8, trimmed[0..eq], name)) { 512 return trimmed[eq + 1 ..]; 513 } 514 } 515 } 516 return null; 517 } 518 519 fn decodeHexAlloc(allocator: std.mem.Allocator, hex: []const u8) ![]u8 { 520 if (hex.len % 2 != 0) return error.InvalidHex; 521 const bytes = try allocator.alloc(u8, hex.len / 2); 522 errdefer allocator.free(bytes); 523 _ = std.fmt.hexToBytes(bytes, hex) catch return error.InvalidHex; 524 return bytes; 525 } 526 527 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 528 var iter = std.mem.splitScalar(u8, body, '&'); 529 while (iter.next()) |pair| { 530 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 531 if (std.mem.eql(u8, pair[0..eq], key)) { 532 return try decodeFormValue(allocator, pair[eq + 1 ..]); 533 } 534 } 535 return null; 536 } 537 538 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 539 var out: std.ArrayList(u8) = .empty; 540 errdefer out.deinit(allocator); 541 var i: usize = 0; 542 while (i < encoded.len) : (i += 1) { 543 switch (encoded[i]) { 544 '+' => try out.append(allocator, ' '), 545 '%' => { 546 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 547 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 548 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 549 try out.append(allocator, hi * 16 + lo); 550 i += 2; 551 }, 552 else => try out.append(allocator, encoded[i]), 553 } 554 } 555 return try out.toOwnedSlice(allocator); 556 } 557