╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
248 admin_repo_path: []const u8, 249 session_store: SessionStore, 250 ) !void { 251 var body_buf: [256]u8 = undefined; 252 const reader = request.readerExpectNone(&body_buf); 253 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 254 defer allocator.free(body); 255 256 const username = (try parseFormField(allocator, body, "username")) orelse try allocator.dupe(u8, ""); 257 defer allocator.free(username); 258 const password = (try parseFormField(allocator, body, "password")) orelse try allocator.dupe(u8, ""); 259 defer allocator.free(password); 260 261 const Repo = rp.Repo(.xit, evt.admin_repo_opts); 262 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 263 defer repo.deinit(io, allocator); 264 265 var arena = std.heap.ArenaAllocator.init(allocator); 266 defer arena.deinit(); 267 268 const haxy_moment = try evt.currentMoment(evt.admin_repo_opts, &repo); 269 const result = try evt.User.verifyCredentials(evt.AdminDB, evt.admin_repo_opts.hash, haxy_moment, &arena, username, password); 270 271 // on success, return to the page the login came from (base, or "/" at the 272 // root); on failure, stay on its auth tab to surface the error. 273 const success_location: []const u8 = if (base.len == 0) "/" else base; 274 const failure_location = try std.fmt.allocPrint(arena.allocator(), "{s}/auth", .{base}); 275 276 switch (result) { 277 .success => |user_id| { 278 const token = try session_store.create(&user_id); 279 var cookie_buf: [256]u8 = undefined; 280 const cookie = try std.fmt.bufPrint(&cookie_buf, cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict", .{token}); 281 try request.respond("", .{ 282 .status = .see_other, 283 .extra_headers = &.{ 284 .{ .name = "location", .value = success_location }, 285 .{ .name = "set-cookie", .value = cookie }, 286 }, 287 }); 288 }, 289 .unknown_user => { 290 try request.respond("", .{ 291 .status = .see_other, 292 .extra_headers = &.{ 293 .{ .name = "location", .value = failure_location }, 294 .{ .name = "set-cookie", .value = login_failure_cookie ++ "=unknown_user; Path=/; HttpOnly; SameSite=Strict" }, 295 }, 296 }); 297 }, 298 .wrong_password => { 299 try request.respond("", .{ 300 .status = .see_other, 301 .extra_headers = &.{ 302 .{ .name = "location", .value = failure_location }, 303 .{ .name = "set-cookie", .value = login_failure_cookie ++ "=wrong_password; Path=/; HttpOnly; SameSite=Strict" }, 304 }, 305 }); 306 }, 307 } 308 } 309 310 fn handleLogout(request: *std.http.Server.Request, base: []const u8, session_store: SessionStore) !void { 311 // revoke the session server-side so the cookie is dead for anyone holding 312 // a copy, not just this browser. 313 if (getCookieValue(request, cookie_name)) |token| session_store.remove(token); 314 // return to the page the logout came from (base, or "/" at the root). 315 const location: []const u8 = if (base.len == 0) "/" else base; 316 // close the connection instead of keeping it alive: we don't read the 317 // request body, and respond()'s keep-alive path would otherwise try to 318 // discard it — which asserts on a bodyless POST that carries no 319 // content-length, and would block on one with no framing at all. a logout 320 // is just a redirect, so the browser reconnects for the follow-up GET. 321 try request.respond("", .{ 322 .status = .see_other, 323 .keep_alive = false, 324 .extra_headers = &.{ 325 .{ .name = "location", .value = location }, 326 .{ .name = "set-cookie", .value = cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" }, 327 }, 328 }); 329 } 330 331 fn handleAnsi( 332 io: std.Io, 333 request: *std.http.Server.Request, 334 allocator: std.mem.Allocator, 335 base: []const u8, 336 admin_repo_path: []const u8, 337 session_store: SessionStore, 338 ) !void { 339 var user_id: [evt.event_id_size]u8 = undefined; 340 if (getCookieValue(request, cookie_name)) |token| { 341 if (session_store.lookup(token, &user_id)) { 342 const Repo = rp.Repo(.xit, evt.admin_repo_opts); 343 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 344 defer repo.deinit(io, allocator); 345 346 try evt.User.toggleAnsi(evt.admin_repo_opts, io, allocator, &repo, &user_id); 347 } 348 } 349 350 // return to the settings tab the toggle came from so the change is visible. 351 const location = try std.fmt.allocPrint(allocator, "{s}/settings", .{base}); 352 defer allocator.free(location); 353 354 // like logout, this is a bodyless POST, so close the connection rather than 355 // letting the keep-alive path try to discard a body that isn't framed. 356 try request.respond("", .{ 357 .status = .see_other, 358 .keep_alive = false, 359 .extra_headers = &.{ 360 .{ .name = "location", .value = location }, 361 }, 362 }); 363 } 364 365 fn renderIndexHtml( 366 io: std.Io, 367 allocator: std.mem.Allocator, 368 admin_repo_path: []const u8, 369 session_data: ui.Session.Data, 370 ) ![]const u8 { 371 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 372 373 // open the admin repo to read live user/repo data. 374 const Repo = rp.Repo(.xit, evt.admin_repo_opts); 375 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 376 defer repo.deinit(io, allocator); 377 var page_arena = std.heap.ArenaAllocator.init(allocator); 378 defer page_arena.deinit(); 379 380 var session = try ui.Session.init(&page_arena, &repo, session_data); 381 382 const snapshot: ui.Snapshot = .{ 383 .page = try ui.Page.init(session.page_arena, session.haxy_moment orelse unreachable, session.data.current_page), 384 .session = session.data, 385 }; 386 var root = try ui.initRoot(allocator, &snapshot.page, &session); 387 defer root.deinit(allocator); 388 389 const content = try generateHtml(allocator, &root); 390 defer allocator.free(content); 391 const overlay = try generateOverlay(allocator, &root); 392 defer allocator.free(overlay); 393 394 // serialize the snapshot so the wasm side can parse it back without 395 // making a second request. base64-encoded so the json can be embedded 396 // safely inside the host html. 397 var json: std.Io.Writer.Allocating = .init(allocator); 398 defer json.deinit(); 399 try std.json.Stringify.value(snapshot, .{}, &json.writer); 400 401 const b64 = std.base64.standard.Encoder; 402 const json_b64 = try allocator.alloc(u8, b64.calcSize(json.written().len)); 403 defer allocator.free(json_b64); 404 _ = b64.encode(json_b64, json.written()); 405 406 var out: std.ArrayList(u8) = .empty; 407 errdefer out.deinit(allocator); 408 { 409 var cursor: usize = 0; 410 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 411 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 412 .{ .needle = "{{{ HAXY_OVERLAY }}}", .replacement = overlay }, 413 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_b64 }, 414 }) |sub| { 415 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 416 try out.appendSlice(allocator, template[cursor..idx]); 417 try out.appendSlice(allocator, sub.replacement); 418 cursor = idx + sub.needle.len; 419 } 420 try out.appendSlice(allocator, template[cursor..]); 421 } 422 return try out.toOwnedSlice(allocator); 423 } 424 425 fn findEmbed(request_path: []const u8) ?Embed { 426 const path = if (std.mem.eql(u8, request_path, "/")) 427 "index.html" 428 else if (request_path.len > 1 and request_path[0] == '/') 429 request_path[1..] 430 else 431 return null; 432 433 for (embeds) |embed| { 434 if (std.mem.eql(u8, path, embed.path)) return embed; 435 } 436 return null; 437 } 438 439 fn logError(err: *std.Io.Writer, comptime fmt: []const u8, args: anytype) void { 440 err.print(fmt, args) catch return; 441 err.flush() catch {}; 442 } 443 444 // emits the TUI grid cells as static HTML 445 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { 446 const grid = root.getGrid() orelse return error.MissingGrid; 447 const root_focus = root.getFocus(); 448 449 const Tag = union(enum) { 450 span, // clickable focusable cell 451 a: []const u8, // clickable focusable link 452 plain, // non-focusable, colored 453 454 const a_prefix = "a:"; 455 456 fn init(str: []const u8) @This() { 457 if (std.mem.startsWith(u8, str, a_prefix)) { 458 return .{ .a = str[a_prefix.len..] }; 459 } 460 return .span; 461 } 462 463 fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void { 464 var id_buf: [32]u8 = undefined; 465 switch (self) { 466 .span => { 467 try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); 468 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 469 try out.appendSlice(alloc, "\""); 470 try out.appendSlice(alloc, style_attr); 471 try out.appendSlice(alloc, ">"); 472 }, 473 .a => |href| { 474 try out.appendSlice(alloc, "<a class=\"clickable\" data-focus-id=\""); 475 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 476 try out.appendSlice(alloc, "\" href=\""); 477 try appendEscapedHtml(alloc, out, href); 478 try out.appendSlice(alloc, "\""); 479 try out.appendSlice(alloc, style_attr); 480 try out.appendSlice(alloc, ">"); 481 }, 482 .plain => { 483 try out.appendSlice(alloc, "<span"); 484 try out.appendSlice(alloc, style_attr); 485 try out.appendSlice(alloc, ">"); 486 }, 487 } 488 } 489 490 fn closeTag(self: @This()) []const u8 { 491 return switch (self) { 492 .span, .plain => "</span>", 493 .a => "</a>", 494 }; 495 } 496 }; 497 498 var out: std.ArrayList(u8) = .empty; 499 errdefer out.deinit(allocator); 500 501 for (0..grid.size.height) |y| { 502 var cur_id: ?usize = null; 503 var cur_fg: ?Grid.Color = null; 504 var cur_bg: ?Grid.Color = null; 505 var open_tag: ?Tag = null; 506 var first = true; 507 for (0..grid.size.width) |x| { 508 const cell = grid.cells.items[try grid.cells.at(.{ y, x })]; 509 const cell_id = cellFocusId(root_focus, x, y); 510 const fg = cell.style.fg; 511 const bg = cell.style.bg; 512 513 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 514 if (open_tag) |t| try out.appendSlice(allocator, t.closeTag()); 515 516 var new_tag: ?Tag = null; 517 if (cell_id) |id| { 518 if (root_focus.children.get(id)) |child| { 519 new_tag = switch (child.focus.kind) { 520 .custom => |custom| Tag.init(custom), 521 else => .span, 522 }; 523 } 524 } 525 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 526 527 if (new_tag) |t| { 528 var style_buf: [64]u8 = undefined; 529 try t.writeOpenTag(allocator, &out, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 530 } 531 532 open_tag = new_tag; 533 cur_id = cell_id; 534 cur_fg = fg; 535 cur_bg = bg; 536 first = false; 537 } 538 539 try appendEscapedHtml(allocator, &out, cell.rune orelse " "); 540 } 541 if (open_tag) |t| try out.appendSlice(allocator, t.closeTag()); 542 try out.append(allocator, '\n'); 543 } 544 545 return try out.toOwnedSlice(allocator); 546 } 547 548 // emits the form overlay — one <form> per "form:<url>" focus subtree in the 549 // widget tree, each wrapping the text inputs and submit button inside it, 550 // positioned absolutely over the matching grid cells. 551 pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { 552 const root_focus = root.getFocus(); 553 554 var out: std.ArrayList(u8) = .empty; 555 errdefer out.deinit(allocator); 556 557 const form_prefix = "form:"; 558 559 // Focus.children is an AutoArrayHashMap that preserves insertion order, 560 // which matches the order widgets were added in code 561 var iter = root_focus.children.iterator(); 562 while (iter.next()) |entry| { 563 const child = entry.value_ptr.*; 564 const action_url = switch (child.focus.kind) { 565 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) 566 custom[form_prefix.len..] 567 else 568 continue, 569 else => continue, 570 }; 571 572 try out.appendSlice(allocator, "<form action=\""); 573 try appendEscapedHtml(allocator, &out, action_url); 574 try out.appendSlice(allocator, "\" method=\"post\""); 575 // an empty action must not POST — block it and send the input to the TUI instead 576 if (action_url.len == 0) try out.appendSlice(allocator, " onsubmit=\"event.preventDefault();sendEnter(this);\""); 577 try out.appendSlice(allocator, ">"); 578 579 // form.focus.children was populated by addChild, which flattens the 580 // whole subtree into one map — so descendants (inputs + the submit 581 // button) all appear here. positions on form.focus are relative to 582 // the form, so we look up absolute rects via root_focus for layout. 583 var inner_iter = child.focus.children.iterator(); 584 while (inner_iter.next()) |inner_entry| { 585 const inner_id = inner_entry.key_ptr.*; 586 const root_child = root_focus.children.get(inner_id) orelse continue; 587 const r = root_child.rect; 588 switch (root_child.focus.kind) { 589 .text_input, .text_input_password => { 590 const ti: *wgt.TextInput(ui.Widget) = @fieldParentPtr("focus", root_child.focus); 591 const inner_left = r.x + 1; 592 const inner_top = r.y + 1; 593 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 594 595 try out.appendSlice(allocator, "<input type=\""); 596 try out.appendSlice(allocator, if (root_child.focus.kind == .text_input_password) "password" else "text"); 597 try out.appendSlice(allocator, "\" data-focus-id=\""); 598 var id_buf: [32]u8 = undefined; 599 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 600 if (ti.options.name.len > 0) { 601 try out.appendSlice(allocator, "\" name=\""); 602 try appendEscapedHtml(allocator, &out, ti.options.name); 603 } 604 // intentionally no `value` attribute: the browser tracks user input 605 // natively, and including the wasm-side value here would make the 606 // overlay HTML differ on every keystroke — that would trip the diff 607 // in _setOverlay and rebuild the <input>, eating the user's caret. 608 try out.appendSlice(allocator, "\" style=\"left:"); 609 var pos_buf: [64]u8 = undefined; 610 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 })); 611 try out.appendSlice(allocator, "\">"); 612 }, 613 .custom => |custom| { 614 if (std.mem.eql(u8, "submit", custom)) { 615 // inside the dashed border the TUI cells draw around the button. 616 const inner_left = r.x + 1; 617 const inner_top = r.y + 1; 618 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 619 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 620 try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); 621 var id_buf: [32]u8 = undefined; 622 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 623 try out.appendSlice(allocator, "\" style=\"left:"); 624 var pos_buf: [128]u8 = undefined; 625 try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:{d}em", .{ inner_left, inner_top, inner_width, inner_height })); 626 try out.appendSlice(allocator, "\"></button>"); 627 } 628 }, 629 else => {}, 630 } 631 } 632 633 try out.appendSlice(allocator, "</form>"); 634 } 635 636 return try out.toOwnedSlice(allocator); 637 } 638 639 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 640 if (a) |av| return if (b) |bv| av.eql(bv) else false; 641 return b == null; 642 } 643 644 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 645 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 646 // empty slice when neither is set. 647 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 648 if (fg == null and bg == null) return buf[0..0]; 649 const prefix = " style=\""; 650 @memcpy(buf[0..prefix.len], prefix); 651 var i: usize = prefix.len; 652 if (fg) |c| { 653 const s = std.fmt.bufPrint(buf[i..], "color:#{x:0>2}{x:0>2}{x:0>2};", .{ c.r, c.g, c.b }) catch return buf[0..0]; 654 i += s.len; 655 } 656 if (bg) |c| { 657 const s = std.fmt.bufPrint(buf[i..], "background-color:#{x:0>2}{x:0>2}{x:0>2};", .{ c.r, c.g, c.b }) catch return buf[0..0]; 658 i += s.len; 659 } 660 buf[i] = '"'; 661 i += 1; 662 return buf[0..i]; 663 } 664 665 fn cellFocusId(focus: *Focus, x: usize, y: usize) ?usize { 666 var iter = focus.children.iterator(); 667 while (iter.next()) |entry| { 668 const child = entry.value_ptr.*; 669 if (!child.focus.focusable) continue; 670 const r = child.rect; 671 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 672 return entry.key_ptr.*; 673 } 674 } 675 return null; 676 } 677 678 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 679 for (input) |ch| { 680 switch (ch) { 681 '&' => try out.appendSlice(allocator, "&amp;"), 682 '<' => try out.appendSlice(allocator, "&lt;"), 683 '>' => try out.appendSlice(allocator, "&gt;"), 684 '"' => try out.appendSlice(allocator, "&quot;"), 685 '\'' => try out.appendSlice(allocator, "&#39;"), 686 else => try out.append(allocator, ch), 687 } 688 } 689 } 690 691 // --- helpers -------------------------------------------------------------- 692 693 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 694 var iter = request.iterateHeaders(); 695 while (iter.next()) |header| { 696 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 697 var pairs = std.mem.splitScalar(u8, header.value, ';'); 698 while (pairs.next()) |pair| { 699 const trimmed = std.mem.trim(u8, pair, " \t"); 700 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 701 if (std.mem.eql(u8, trimmed[0..eq], name)) { 702 return trimmed[eq + 1 ..]; 703 } 704 } 705 } 706 return null; 707 } 708 709 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 710 var iter = std.mem.splitScalar(u8, body, '&'); 711 while (iter.next()) |pair| { 712 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 713 if (std.mem.eql(u8, pair[0..eq], key)) { 714 return try decodeFormValue(allocator, pair[eq + 1 ..]); 715 } 716 } 717 return null; 718 } 719 720 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 721 var out: std.ArrayList(u8) = .empty; 722 errdefer out.deinit(allocator); 723 var i: usize = 0; 724 while (i < encoded.len) : (i += 1) { 725 switch (encoded[i]) { 726 '+' => try out.append(allocator, ' '), 727 '%' => { 728 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 729 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 730 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 731 try out.append(allocator, hi * 16 + lo); 732 i += 2; 733 }, 734 else => try out.append(allocator, encoded[i]), 735 } 736 } 737 return try out.toOwnedSlice(allocator); 738 } 739 740 // disk-backed web session store. each login mints a random, opaque token 741 // (not the user's id) that is stored as a file named by the token hex, whose 742 // contents are the raw user_id bytes. authenticating a request is a lookup of 743 // the cookie's token; logout deletes the file, revoking the session. there is 744 // deliberately no expiry — a session lives until logout. 745 pub const SessionStore = struct { 746 io: std.Io, 747 dir: std.Io.Dir, 748 749 pub const token_hex_len = evt.event_id_size * 2; 750 751 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 752 try data_dir.createDirPath(io, "sessions"); 753 const dir = try data_dir.openDir(io, "sessions", .{}); 754 return .{ .io = io, .dir = dir }; 755 } 756 757 pub fn deinit(self: SessionStore) void { 758 self.dir.close(self.io); 759 } 760 761 // mint a new session for user_id, returning the token hex to set as the 762 // cookie value. 763 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 764 var token: [evt.event_id_size]u8 = undefined; 765 self.io.random(&token); 766 const token_hex = std.fmt.bytesToHex(token, .lower); 767 const file = try self.dir.createFile(self.io, &token_hex, .{}); 768 defer file.close(self.io); 769 try file.writeStreamingAll(self.io, user_id); 770 return token_hex; 771 } 772 773 // resolve a cookie's token to its user_id. returns false (logged out) for a 774 // missing, malformed, or unknown token. 775 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 776 if (!isToken(token_hex)) return false; 777 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 778 defer file.close(self.io); 779 var storage: [evt.event_id_size]u8 = undefined; 780 var file_reader = file.reader(self.io, &storage); 781 file_reader.interface.readSliceAll(out) catch return false; 782 return true; 783 } 784 785 // revoke a session. a no-op for a missing or malformed token. 786 pub fn remove(self: SessionStore, token_hex: []const u8) void { 787 if (!isToken(token_hex)) return; 788 self.dir.deleteFile(self.io, token_hex) catch {}; 789 } 790 791 // a token is exactly token_hex_len hex chars. validating before using the 792 // value as a path keeps attacker-supplied cookies from escaping the 793 // sessions dir (e.g. via "/" or ".." bytes). 794 fn isToken(token_hex: []const u8) bool { 795 if (token_hex.len != token_hex_len) return false; 796 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 797 return true; 798 } 799 }; 800