╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
350 // mode / no session / an unknown token 351 fn sessionUser( 352 io: std.Io, 353 allocator: std.mem.Allocator, 354 arena: *std.heap.ArenaAllocator, 355 request: *std.http.Server.Request, 356 host: Host, 357 ) !?evt.User { 358 const remote = switch (host) { 359 .remote => |remote| remote, 360 .local => return null, 361 }; 362 const token = getCookieValue(request, cookie_name) orelse return null; 363 var user_id: [evt.event_id_size]u8 = undefined; 364 if (!remote.session_store.lookup(token, &user_id)) return null; 365 366 var repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 367 defer repo.deinit(io, allocator); 368 const moment = try evt.currentMoment(evt.admin_repo_opts, &repo); 369 return (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, &user_id)) orelse unreachable; 370 } 371 372 // create an issue in the repo the form's page names (base is 373 // "/repo/<owner>/<name>", or "" in local mode) and redirect to it 374 fn handleIssue( 375 io: std.Io, 376 request: *std.http.Server.Request, 377 allocator: std.mem.Allocator, 378 base: []const u8, 379 host: Host, 380 ) !void { 381 var body_buf: [256]u8 = undefined; 382 const reader = request.readerExpectNone(&body_buf); 383 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 384 defer allocator.free(body); 385 386 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 387 defer allocator.free(title); 388 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 389 defer allocator.free(tags); 390 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 391 defer allocator.free(description_crlf); 392 // form submission normalizes textarea line breaks to CRLF; store plain 393 // newlines so the text renders the same on every host 394 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 395 defer allocator.free(description); 396 397 if (!evt.Issue.fieldsValid(title, tags)) { 398 const form_location = try std.fmt.allocPrint(allocator, "{s}/issues/new", .{base}); 399 defer allocator.free(form_location); 400 try request.respond("", .{ 401 .status = .see_other, 402 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 403 }); 404 return; 405 } 406 407 var id_bytes: [evt.event_id_size]u8 = undefined; 408 io.random(&id_bytes); 409 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 410 411 var author_arena = std.heap.ArenaAllocator.init(allocator); 412 defer author_arena.deinit(); 413 414 const event = evt.EventWithId{ 415 .id = event_id_hex, 416 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 417 .author_email = if (try sessionUser(io, allocator, &author_arena, request, host)) |author| author.email else "user@haxy", 418 .event = .{ .issue = .{ 419 .title = title, 420 .description = description, 421 .tags = tags, 422 } }, 423 }; 424 425 const not_found = "repo not found"; 426 switch (host) { 427 .remote => |remote| { 428 const repo_prefix = "/repo/"; 429 if (!std.mem.startsWith(u8, base, repo_prefix)) { 430 try request.respond(not_found, .{ 431 .status = .not_found, 432 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 433 }); 434 return; 435 } 436 437 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 438 defer allocator.free(repos_dir); 439 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, base[repo_prefix.len..], false)) { 440 .ok => |p| p, 441 .invalid, .not_found => { 442 try request.respond(not_found, .{ 443 .status = .not_found, 444 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 445 }); 446 return; 447 }, 448 }; 449 defer allocator.free(repo_path); 450 451 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 452 defer repo.deinit(io, allocator); 453 try evt.consume(.xit, .{}, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{event}); 454 }, 455 .local => |src| { 456 // the local form posts to "/issue", so base is empty 457 if (base.len != 0) { 458 try request.respond(not_found, .{ 459 .status = .not_found, 460 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 461 }); 462 return; 463 } 464 switch (src.repo_kind) { 465 inline else => |repo_kind| { 466 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 467 defer any_repo.deinit(io, allocator); 468 switch (any_repo) { 469 inline else => |*repo| try evt.consume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{event}), 470 } 471 }, 472 } 473 }, 474 } 475 476 const location = try std.fmt.allocPrint(allocator, "{s}/issues/{s}", .{ base, &event_id_hex }); 477 defer allocator.free(location); 478 try request.respond("", .{ 479 .status = .see_other, 480 .extra_headers = &.{.{ .name = "location", .value = location }}, 481 }); 482 } 483 484 // split an issue url ("/repo/<owner>/<name>/issues/<id>", identity elided in 485 // local mode) into what precedes "/issues/" and the decoded id, null when it 486 // isn't one. the repo base is validated per host in updateIssue. 487 const IssueBaseParts = struct { repo_base: []const u8, id_bytes: [evt.event_id_size]u8 }; 488 fn issueBaseParts(base: []const u8) ?IssueBaseParts { 489 const issues_infix = "/issues/"; 490 const id_len = evt.event_id_size * 2; 491 if (base.len < issues_infix.len + id_len) return null; 492 var id_bytes: [evt.event_id_size]u8 = undefined; 493 _ = std.fmt.hexToBytes(&id_bytes, base[base.len - id_len ..]) catch return null; 494 const head = base[0 .. base.len - id_len]; 495 if (!std.mem.endsWith(u8, head, issues_infix)) return null; 496 return .{ .repo_base = head[0 .. head.len - issues_infix.len], .id_bytes = id_bytes }; 497 } 498 499 // re-emit the issue `parts` names with `update` applied, in the repo the 500 // host resolves the base to. error.NotFound when the base names no repo or 501 // the id no issue. 502 fn updateIssue( 503 io: std.Io, 504 allocator: std.mem.Allocator, 505 host: Host, 506 parts: IssueBaseParts, 507 update: evt.Issue.Update, 508 author_email: []const u8, 509 ) !void { 510 switch (host) { 511 .remote => |remote| { 512 const repo_prefix = "/repo/"; 513 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return error.NotFound; 514 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 515 defer allocator.free(repos_dir); 516 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { 517 .ok => |p| p, 518 .invalid, .not_found => return error.NotFound, 519 }; 520 defer allocator.free(repo_path); 521 522 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 523 defer repo.deinit(io, allocator); 524 try evt.Issue.update(.xit, .{}, io, allocator, &repo, &parts.id_bytes, update, author_email); 525 }, 526 .local => |src| { 527 // the local forms post to "/issues/<id>/...", so the repo base 528 // is empty 529 if (parts.repo_base.len != 0) return error.NotFound; 530 switch (src.repo_kind) { 531 inline else => |repo_kind| { 532 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 533 defer any_repo.deinit(io, allocator); 534 switch (any_repo) { 535 inline else => |*repo| try evt.Issue.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &parts.id_bytes, update, author_email), 536 } 537 }, 538 } 539 }, 540 } 541 } 542 543 // set the status of the issue the url names (base is 544 // "/repo/<owner>/<name>/issues/<id>", identity elided in local mode) by 545 // re-emitting its event, then redirect back to it 546 fn handleIssueStatus( 547 io: std.Io, 548 request: *std.http.Server.Request, 549 allocator: std.mem.Allocator, 550 base: []const u8, 551 host: Host, 552 status: evt.Issue.Status, 553 ) !void { 554 const not_found = "issue not found"; 555 const parts = issueBaseParts(base) orelse { 556 try request.respond(not_found, .{ 557 .status = .not_found, 558 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 559 }); 560 return; 561 }; 562 563 var author_arena = std.heap.ArenaAllocator.init(allocator); 564 defer author_arena.deinit(); 565 const author_email = if (try sessionUser(io, allocator, &author_arena, request, host)) |author| author.email else "user@haxy"; 566 567 updateIssue(io, allocator, host, parts, .{ .status = status }, author_email) catch |err| switch (err) { 568 error.NotFound => { 569 try request.respond(not_found, .{ 570 .status = .not_found, 571 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 572 }); 573 return; 574 }, 575 else => |e| return e, 576 }; 577 578 // like logout, this is a bodyless POST, so close the connection rather than 579 // letting the keep-alive path try to discard a body that isn't framed. 580 try request.respond("", .{ 581 .status = .see_other, 582 .keep_alive = false, 583 .extra_headers = &.{.{ .name = "location", .value = base }}, 584 }); 585 } 586 587 // replace the title/tags/description of the issue the url names (base is 588 // "/repo/<owner>/<name>/issues/<id>", identity elided in local mode; the 589 // form posts to "<base>/edit") by re-emitting its event with its status 590 // preserved, then redirect back to it 591 fn handleIssueEdit( 592 io: std.Io, 593 request: *std.http.Server.Request, 594 allocator: std.mem.Allocator, 595 base: []const u8, 596 host: Host, 597 ) !void { 598 var body_buf: [256]u8 = undefined; 599 const reader = request.readerExpectNone(&body_buf); 600 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 601 defer allocator.free(body); 602 603 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 604 defer allocator.free(title); 605 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 606 defer allocator.free(tags); 607 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 608 defer allocator.free(description_crlf); 609 // form submission normalizes textarea line breaks to CRLF; store plain 610 // newlines so the text renders the same on every host 611 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 612 defer allocator.free(description); 613 614 // invalid fields send the user back to the edit form 615 if (!evt.Issue.fieldsValid(title, tags)) { 616 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 617 defer allocator.free(form_location); 618 try request.respond("", .{ 619 .status = .see_other, 620 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 621 }); 622 return; 623 } 624 625 const not_found = "issue not found"; 626 const parts = issueBaseParts(base) orelse { 627 try request.respond(not_found, .{ 628 .status = .not_found, 629 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 630 }); 631 return; 632 }; 633 634 var author_arena = std.heap.ArenaAllocator.init(allocator); 635 defer author_arena.deinit(); 636 const author_email = if (try sessionUser(io, allocator, &author_arena, request, host)) |author| author.email else "user@haxy"; 637 638 updateIssue(io, allocator, host, parts, .{ .fields = .{ 639 .title = title, 640 .tags = tags, 641 .description = description, 642 } }, author_email) catch |err| switch (err) { 643 error.NotFound => { 644 try request.respond(not_found, .{ 645 .status = .not_found, 646 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 647 }); 648 return; 649 }, 650 else => |e| return e, 651 }; 652 653 try request.respond("", .{ 654 .status = .see_other, 655 .extra_headers = &.{.{ .name = "location", .value = base }}, 656 }); 657 } 658 659 fn renderIndexHtml( 660 io: std.Io, 661 allocator: std.mem.Allocator, 662 host: Host, 663 session_data: ui.Session.Data, 664 ) ![]const u8 { 665 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 666 667 var page_arena = std.heap.ArenaAllocator.init(allocator); 668 defer page_arena.deinit(); 669 670 // the remote admin repo must outlive the page build, since the session's 671 // moment reads from it. 672 var repo_maybe: ?rp.Repo(.xit, evt.admin_repo_opts) = null; 673 defer if (repo_maybe) |*repo| repo.deinit(io, allocator); 674 675 var session = switch (host) { 676 .remote => |remote| blk: { 677 // open the admin repo to read live user/repo data. 678 repo_maybe = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 679 const repo = if (repo_maybe) |*repo| repo else unreachable; 680 var session = try ui.Session.init(&page_arena, repo, session_data); 681 // give the page builders filesystem access to the on-disk repos (a 682 // sibling "repos" dir next to the admin repo) so the Repo page can 683 // read its files. 684 session.repos_dir = try std.fs.path.join(page_arena.allocator(), &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 685 break :blk session; 686 }, 687 .local => |local| ui.Session{ 688 .data = session_data, 689 .arena = &page_arena, 690 .page_arena = &page_arena, 691 .local = local, 692 }, 693 }; 694 session.io = io; 695 696 const snapshot: ui.Snapshot = .{ 697 .page = try ui.Page.init(session.page_arena, &session, session.data.current_page), 698 .session = session.data, 699 }; 700 var root = try ui.initRoot(allocator, &snapshot.page, &session); 701 defer root.deinit(allocator); 702 703 const content = try generateHtml(allocator, &root); 704 defer allocator.free(content); 705 const overlay = try generateOverlay(allocator, &root, &session); 706 defer allocator.free(overlay); 707 708 // serialize the snapshot so the wasm side can parse it back without making 709 // a second request. it's embedded raw in a <script type="application/json"> 710 // block, whose content is raw text terminated only by "</script". in json, 711 // '<' appears solely inside string values, so escaping every '<' (and '>' 712 // for good measure) to its \uXXXX form yields equivalent json that can't 713 // break out of the tag. 714 var json: std.Io.Writer.Allocating = .init(allocator); 715 defer json.deinit(); 716 try std.json.Stringify.value(snapshot, .{}, &json.writer); 717 718 var json_escaped: std.ArrayList(u8) = .empty; 719 defer json_escaped.deinit(allocator); 720 for (json.written()) |c| switch (c) { 721 '<' => try json_escaped.appendSlice(allocator, "\\u003c"), 722 '>' => try json_escaped.appendSlice(allocator, "\\u003e"), 723 else => try json_escaped.append(allocator, c), 724 }; 725 726 var out: std.ArrayList(u8) = .empty; 727 errdefer out.deinit(allocator); 728 { 729 var cursor: usize = 0; 730 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 731 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 732 .{ .needle = "{{{ HAXY_OVERLAY }}}", .replacement = overlay }, 733 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_escaped.items }, 734 }) |sub| { 735 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 736 try out.appendSlice(allocator, template[cursor..idx]); 737 try out.appendSlice(allocator, sub.replacement); 738 cursor = idx + sub.needle.len; 739 } 740 try out.appendSlice(allocator, template[cursor..]); 741 } 742 return try out.toOwnedSlice(allocator); 743 } 744 745 fn findEmbed(request_path: []const u8) ?Embed { 746 const path = if (std.mem.eql(u8, request_path, "/")) 747 "index.html" 748 else if (request_path.len > 1 and request_path[0] == '/') 749 request_path[1..] 750 else 751 return null; 752 753 for (embeds) |embed| { 754 if (std.mem.eql(u8, path, embed.path)) return embed; 755 } 756 return null; 757 } 758 759 // emits the TUI grid cells as static HTML. each web-native Scroll becomes its 760 // own absolutely-positioned, natively-scrollable <div> holding its full content, 761 // rendered recursively so nested scrolls work. 762 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { 763 const grid = root.getGrid() orelse return error.MissingGrid; 764 765 var out: std.ArrayList(u8) = .empty; 766 errdefer out.deinit(allocator); 767 768 try renderPanel(allocator, &out, root.getFocus(), grid); 769 return try out.toOwnedSlice(allocator); 770 } 771 772 // render one panel — a focus subtree and its content grid — as HTML, recursing 773 // once per web-native Scroll inside it. the root panel is the whole page; each 774 // Scroll becomes a nested, natively-scrollable div holding its full content. 775 // emits the panel's grid cells as text, leaving holes where child scrolls sit, 776 // then the child scroll divs over those holes. 777 fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid) !void { 778 // `direct` is this panel's scroll children, each drawn as an overlaid div. 779 // `excluded` is every focus id that belongs to a scroll (the scroll nodes 780 // plus their descendants, which the focus tree flattens into this panel's 781 // children): the cell hit-test below skips them so this panel doesn't claim 782 // cells that are really rendered inside a child scroll's own panel. 783 // (scrolls aren't nested in practice, so every scroll child is direct here.) 784 var direct: std.ArrayList(usize) = .empty; 785 defer direct.deinit(allocator); 786 var excluded: std.AutoHashMapUnmanaged(usize, void) = .empty; 787 defer excluded.deinit(allocator); 788 { 789 var it = focus.children.iterator(); 790 while (it.next()) |e| { 791 if (e.value_ptr.focus.scroll == null) continue; 792 try direct.append(allocator, e.key_ptr.*); 793 try excluded.put(allocator, e.key_ptr.*, {}); 794 var mit = e.value_ptr.focus.children.iterator(); 795 while (mit.next()) |m| try excluded.put(allocator, m.key_ptr.*, {}); 796 } 797 } 798 799 // the HTML element a run of cells is wrapped in, chosen from the cell's 800 // focusable kind 801 const CellTag = union(enum) { 802 span, // clickable focusable cell 803 a: []const u8, // clickable focusable link 804 plain, // non-focusable, colored 805 806 const a_prefix = "a:"; 807 // an "in-page" anchor: rendered as a real <a href> so it works with js 808 // disabled, but crossPageLink ignores it, so with wasm the click just 809 // moves focus rather than navigating. 810 const inpage_prefix = "ai:"; 811 812 fn init(str: []const u8) @This() { 813 if (std.mem.startsWith(u8, str, inpage_prefix)) { 814 return .{ .a = str[inpage_prefix.len..] }; 815 } 816 if (std.mem.startsWith(u8, str, a_prefix)) { 817 return .{ .a = str[a_prefix.len..] }; 818 } 819 return .span; 820 } 821 822 fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void { 823 var id_buf: [32]u8 = undefined; 824 switch (self) { 825 .span => { 826 try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); 827 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 828 try out.appendSlice(alloc, "\""); 829 try out.appendSlice(alloc, style_attr); 830 try out.appendSlice(alloc, ">"); 831 }, 832 .a => |href| { 833 // tabindex=-1 keeps these out of the browser's tab order 834 try out.appendSlice(alloc, "<a class=\"clickable\" tabindex=\"-1\" data-focus-id=\""); 835 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 836 try out.appendSlice(alloc, "\" href=\""); 837 try appendEscapedHtml(alloc, out, href); 838 try out.appendSlice(alloc, "\""); 839 try out.appendSlice(alloc, style_attr); 840 try out.appendSlice(alloc, ">"); 841 }, 842 .plain => { 843 try out.appendSlice(alloc, "<span"); 844 try out.appendSlice(alloc, style_attr); 845 try out.appendSlice(alloc, ">"); 846 }, 847 } 848 } 849 850 fn closeTag(self: @This()) []const u8 { 851 return switch (self) { 852 .span, .plain => "</span>", 853 .a => "</a>", 854 }; 855 } 856 }; 857 858 // emit this panel's grid as rows of text, coalescing adjacent cells that 859 // share a focus id and colors into one tag (a clickable span, a link, or a 860 // plain colored span). cells under a child scroll keep only their background. 861 for (0..grid.size.height) |y| { 862 var cur_id: ?usize = null; 863 var cur_fg: ?Grid.Color = null; 864 var cur_bg: ?Grid.Color = null; 865 var open_tag: ?CellTag = null; 866 var first = true; 867 for (0..grid.size.width) |x| { 868 const cell = grid.cells.items[try grid.cells.at(.{ y, x })]; 869 // a cell covered by a child scroll's viewport is drawn by that scroll's 870 // own div, so blank its glyph and make it non-clickable here — but keep 871 // its background, so the backdrop still shows through the scroll's 872 // transparent div instead of a bare hole. 873 const covered_by_scroll = blk: { 874 for (direct.items) |id| { 875 const r = (focus.children.get(id) orelse continue).rect; 876 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) break :blk true; 877 } 878 break :blk false; 879 }; 880 // the focusable cell at (x, y) among this panel's own focusables (skipping 881 // any that belong to a child scroll, whose cells are drawn in their own 882 // panel); none for a covered cell. 883 const cell_id = if (covered_by_scroll) null else blk: { 884 var iter = focus.children.iterator(); 885 while (iter.next()) |entry| { 886 const child = entry.value_ptr.*; 887 if (!child.focus.focusable) continue; 888 if (excluded.contains(entry.key_ptr.*)) continue; 889 const r = child.rect; 890 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 891 break :blk entry.key_ptr.*; 892 } 893 } 894 break :blk null; 895 }; 896 const fg = if (covered_by_scroll) null else cell.style.fg; 897 const bg = cell.style.bg; 898 899 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 900 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 901 902 var new_tag: ?CellTag = null; 903 if (cell_id) |id| { 904 if (focus.children.get(id)) |child| { 905 new_tag = switch (child.focus.kind) { 906 .custom => |custom| CellTag.init(custom), 907 else => .span, 908 }; 909 } 910 } 911 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 912 913 if (new_tag) |t| { 914 var style_buf: [64]u8 = undefined; 915 try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 916 } 917 918 open_tag = new_tag; 919 cur_id = cell_id; 920 cur_fg = fg; 921 cur_bg = bg; 922 first = false; 923 } 924 925 if (covered_by_scroll) { 926 try appendEscapedHtml(allocator, output, " "); 927 } else if (cell.continuation) { 928 // the wide rune to the left was emitted 2ch wide, covering this column 929 } else if (cell.rune) |rune| { 930 // a double-width rune is pinned inside a 2ch span so the 931 // fallback glyph's advance width can't shift the rest of the row 932 const wide = x + 1 < grid.size.width and grid.cells.items[try grid.cells.at(.{ y, x + 1 })].continuation; 933 if (wide) { 934 try output.appendSlice(allocator, "<span class=\"w2\">"); 935 try appendEscapedHtml(allocator, output, rune); 936 try output.appendSlice(allocator, "</span>"); 937 } else { 938 try appendEscapedHtml(allocator, output, rune); 939 } 940 } else { 941 try appendEscapedHtml(allocator, output, " "); 942 } 943 } 944 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 945 try output.append(allocator, '\n'); 946 } 947 948 // each child scroll becomes a natively-scrollable div positioned over its 949 // viewport, holding its full content rendered recursively. 950 for (direct.items) |id| { 951 const child = focus.children.get(id) orelse continue; 952 const info = child.focus.scroll orelse continue; 953 const r = child.rect; 954 // overflow only on the axes the widget scrolls; `auto` shows a bar only 955 // when that axis actually overflows. the other axis is clipped (hidden), 956 // matching how the terminal Scroll handles its non-scrolling axis. 957 const overflow = switch (info.direction) { 958 .vert => "overflow-x:hidden;overflow-y:auto", 959 .horiz => "overflow-x:auto;overflow-y:hidden", 960 .both => "overflow:auto", 961 }; 962 // the id carries a content version so JS preserves the native scroll 963 // position across re-renders of the same content but resets it when the 964 // content is replaced — e.g. selecting a different commit. the widget's 965 // scroll offset (in cells) rides along so JS can apply it when it has no 966 // preserved position, letting a wasm-side scrollToRect (the files list 967 // scrolling to its selected row on page load) reach the browser. 968 var buf: [224]u8 = undefined; 969 try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, "<div class=\"scroll\" data-scroll-id=\"{d}-{d}\" data-scroll-x=\"{d}\" data-scroll-y=\"{d}\" style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em;{s}\">", .{ id, child.focus.version, info.offset_x, info.offset_y, r.x, r.y, r.size.width, r.size.height, overflow })); 970 try renderPanel(allocator, output, child.focus, info.content); 971 try output.appendSlice(allocator, "</div>"); 972 } 973 } 974 975 // emits the form overlay — one <form> per "form:<url>" focus subtree in the 976 // widget tree, each wrapping the text inputs and submit button inside it, 977 // positioned absolutely over the matching grid cells. 978 pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget, session: *ui.Session) ![]const u8 { 979 const root_focus = root.getFocus(); 980 981 var out: std.ArrayList(u8) = .empty; 982 errdefer out.deinit(allocator); 983 984 const form_prefix = "form:"; 985 986 // Focus.children is an AutoArrayHashMap that preserves insertion order, 987 // which matches the order widgets were added in code 988 var iter = root_focus.children.iterator(); 989 while (iter.next()) |entry| { 990 const child = entry.value_ptr.*; 991 const action_url = switch (child.focus.kind) { 992 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) 993 custom[form_prefix.len..] 994 else 995 continue, 996 else => continue, 997 }; 998 999 try out.appendSlice(allocator, "<form action=\""); 1000 try appendEscapedHtml(allocator, &out, action_url); 1001 try out.appendSlice(allocator, "\" method=\"post\""); 1002 // an empty action must not POST — block it and send the input to the TUI instead 1003 if (action_url.len == 0) try out.appendSlice(allocator, " onsubmit=\"event.preventDefault();sendEnter(this);\""); 1004 try out.appendSlice(allocator, ">"); 1005 1006 // form.focus.children was populated by addChild, which flattens the 1007 // whole subtree into one map — so descendants (inputs + the submit 1008 // button) all appear here. positions on form.focus are relative to 1009 // the form, so we look up absolute rects via root_focus for layout. 1010 var inner_iter = child.focus.children.iterator(); 1011 while (inner_iter.next()) |inner_entry| { 1012 const inner_id = inner_entry.key_ptr.*; 1013 const root_child = root_focus.children.get(inner_id) orelse continue; 1014 const r = root_child.rect; 1015 switch (root_child.focus.kind) { 1016 .text_input, .text_input_password => { 1017 const ti = session.text_inputs.get(inner_id) orelse continue; 1018 const inner_left = r.x + 1; 1019 const inner_top = r.y + 1; 1020 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1021 1022 try out.appendSlice(allocator, "<input type=\""); 1023 try out.appendSlice(allocator, if (root_child.focus.kind == .text_input_password) "password" else "text"); 1024 try out.appendSlice(allocator, "\" data-focus-id=\""); 1025 var id_buf: [32]u8 = undefined; 1026 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1027 if (ti.options.name.len > 0) { 1028 try out.appendSlice(allocator, "\" name=\""); 1029 try appendEscapedHtml(allocator, &out, ti.options.name); 1030 } 1031 // never the live content as the `value`: the browser tracks user 1032 // input natively, and including the wasm-side value here would make 1033 // the overlay HTML differ on every keystroke — that would trip the 1034 // diff in _setOverlay and rebuild the <input>, eating the user's 1035 // caret. a page-constant initial value is safe. 1036 if (session.input_values.get(inner_id)) |value| { 1037 try out.appendSlice(allocator, "\" value=\""); 1038 try appendEscapedHtml(allocator, &out, value); 1039 } 1040 try out.appendSlice(allocator, "\" style=\"left:"); 1041 var pos_buf: [64]u8 = undefined; 1042 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 })); 1043 try out.appendSlice(allocator, "\">"); 1044 }, 1045 .text_area => { 1046 const ti = session.text_inputs.get(inner_id) orelse continue; 1047 const inner_left = r.x + 1; 1048 const inner_top = r.y + 1; 1049 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1050 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 1051 1052 try out.appendSlice(allocator, "<textarea data-focus-id=\""); 1053 var id_buf: [32]u8 = undefined; 1054 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1055 if (ti.options.name.len > 0) { 1056 try out.appendSlice(allocator, "\" name=\""); 1057 try appendEscapedHtml(allocator, &out, ti.options.name); 1058 } 1059 // like the input above, only a page-constant initial value 1060 // inside: the browser tracks the live value and the 1061 // overlay diff must stay stable across keystrokes 1062 try out.appendSlice(allocator, "\" style=\"left:"); 1063 var pos_buf: [128]u8 = undefined; 1064 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 })); 1065 try out.appendSlice(allocator, "\">"); 1066 if (session.input_values.get(inner_id)) |value| try appendEscapedHtml(allocator, &out, value); 1067 try out.appendSlice(allocator, "</textarea>"); 1068 }, 1069 .custom => |custom| { 1070 if (std.mem.eql(u8, "submit", custom)) { 1071 try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); 1072 var id_buf: [32]u8 = undefined; 1073 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1074 try out.appendSlice(allocator, "\" style=\"left:"); 1075 var pos_buf: [128]u8 = undefined; 1076 try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:{d}em", .{ r.x, r.y, r.size.width, r.size.height })); 1077 try out.appendSlice(allocator, "\"></button>"); 1078 } 1079 }, 1080 else => {}, 1081 } 1082 } 1083 1084 try out.appendSlice(allocator, "</form>"); 1085 } 1086 1087 return try out.toOwnedSlice(allocator); 1088 } 1089 1090 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 1091 if (a) |av| return if (b) |bv| av.eql(bv) else false; 1092 return b == null; 1093 } 1094 1095 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 1096 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 1097 // empty slice when neither is set. 1098 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 1099 if (fg == null and bg == null) return buf[0..0]; 1100 const prefix = " style=\""; 1101 @memcpy(buf[0..prefix.len], prefix); 1102 var i: usize = prefix.len; 1103 if (fg) |c| { 1104 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]; 1105 i += s.len; 1106 } 1107 if (bg) |c| { 1108 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]; 1109 i += s.len; 1110 } 1111 buf[i] = '"'; 1112 i += 1; 1113 return buf[0..i]; 1114 } 1115 1116 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 1117 for (input) |ch| { 1118 switch (ch) { 1119 '&' => try out.appendSlice(allocator, "&amp;"), 1120 '<' => try out.appendSlice(allocator, "&lt;"), 1121 '>' => try out.appendSlice(allocator, "&gt;"), 1122 '"' => try out.appendSlice(allocator, "&quot;"), 1123 '\'' => try out.appendSlice(allocator, "&#39;"), 1124 else => try out.append(allocator, ch), 1125 } 1126 } 1127 } 1128 1129 // --- helpers -------------------------------------------------------------- 1130 1131 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 1132 var iter = request.iterateHeaders(); 1133 while (iter.next()) |header| { 1134 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 1135 var pairs = std.mem.splitScalar(u8, header.value, ';'); 1136 while (pairs.next()) |pair| { 1137 const trimmed = std.mem.trim(u8, pair, " \t"); 1138 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 1139 if (std.mem.eql(u8, trimmed[0..eq], name)) { 1140 return trimmed[eq + 1 ..]; 1141 } 1142 } 1143 } 1144 return null; 1145 } 1146 1147 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 1148 var iter = std.mem.splitScalar(u8, body, '&'); 1149 while (iter.next()) |pair| { 1150 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 1151 if (std.mem.eql(u8, pair[0..eq], key)) { 1152 return try decodeFormValue(allocator, pair[eq + 1 ..]); 1153 } 1154 } 1155 return null; 1156 } 1157 1158 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 1159 var out: std.ArrayList(u8) = .empty; 1160 errdefer out.deinit(allocator); 1161 var i: usize = 0; 1162 while (i < encoded.len) : (i += 1) { 1163 switch (encoded[i]) { 1164 '+' => try out.append(allocator, ' '), 1165 '%' => { 1166 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 1167 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 1168 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 1169 try out.append(allocator, hi * 16 + lo); 1170 i += 2; 1171 }, 1172 else => try out.append(allocator, encoded[i]), 1173 } 1174 } 1175 return try out.toOwnedSlice(allocator); 1176 } 1177 1178 // disk-backed web session store. each login mints a random, opaque token 1179 // (not the user's id) that is stored as a file named by the token hex, whose 1180 // contents are the raw user_id bytes. authenticating a request is a lookup of 1181 // the cookie's token; logout deletes the file, revoking the session. there is 1182 // deliberately no expiry — a session lives until logout. 1183 pub const SessionStore = struct { 1184 io: std.Io, 1185 dir: std.Io.Dir, 1186 1187 pub const token_hex_len = evt.event_id_size * 2; 1188 1189 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 1190 try data_dir.createDirPath(io, "sessions"); 1191 const dir = try data_dir.openDir(io, "sessions", .{}); 1192 return .{ .io = io, .dir = dir }; 1193 } 1194 1195 pub fn deinit(self: SessionStore) void { 1196 self.dir.close(self.io); 1197 } 1198 1199 // mint a new session for user_id, returning the token hex to set as the 1200 // cookie value. 1201 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 1202 var token: [evt.event_id_size]u8 = undefined; 1203 self.io.random(&token); 1204 const token_hex = std.fmt.bytesToHex(token, .lower); 1205 const file = try self.dir.createFile(self.io, &token_hex, .{}); 1206 defer file.close(self.io); 1207 try file.writeStreamingAll(self.io, user_id); 1208 return token_hex; 1209 } 1210 1211 // resolve a cookie's token to its user_id. returns false (logged out) for a 1212 // missing, malformed, or unknown token. 1213 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 1214 if (!isToken(token_hex)) return false; 1215 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 1216 defer file.close(self.io); 1217 var storage: [evt.event_id_size]u8 = undefined; 1218 var file_reader = file.reader(self.io, &storage); 1219 file_reader.interface.readSliceAll(out) catch return false; 1220 return true; 1221 } 1222 1223 // revoke a session. a no-op for a missing or malformed token. 1224 pub fn remove(self: SessionStore, token_hex: []const u8) void { 1225 if (!isToken(token_hex)) return; 1226 self.dir.deleteFile(self.io, token_hex) catch {}; 1227 } 1228 1229 // a token is exactly token_hex_len hex chars. validating before using the 1230 // value as a path keeps attacker-supplied cookies from escaping the 1231 // sessions dir (e.g. via "/" or ".." bytes). 1232 fn isToken(token_hex: []const u8) bool { 1233 if (token_hex.len != token_hex_len) return false; 1234 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 1235 return true; 1236 } 1237 }; 1238