╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
801 } 802 803 // the images a browser renders safely inline. anything else downloads as 804 // opaque bytes, so an uploaded page can't run on this origin. 805 fn attachmentContentType(name: []const u8) ?[]const u8 { 806 const types = [_]struct { ext: []const u8, value: []const u8 }{ 807 .{ .ext = ".png", .value = "image/png" }, 808 .{ .ext = ".jpg", .value = "image/jpeg" }, 809 .{ .ext = ".jpeg", .value = "image/jpeg" }, 810 .{ .ext = ".gif", .value = "image/gif" }, 811 .{ .ext = ".webp", .value = "image/webp" }, 812 }; 813 for (types) |entry| { 814 if (name.len > entry.ext.len and std.ascii.eqlIgnoreCase(name[name.len - entry.ext.len ..], entry.ext)) return entry.value; 815 } 816 return null; 817 } 818 819 fn respondAttachmentNotFound(request: *std.http.Server.Request) !void { 820 try request.respond("attachment not found", .{ 821 .status = .not_found, 822 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 823 }); 824 } 825 826 fn respondAttachmentParentNotFound(request: *std.http.Server.Request) !void { 827 try request.respond("attachment parent not found", .{ 828 .status = .not_found, 829 .keep_alive = false, 830 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 831 }); 832 } 833 834 // the upload declares its file's name here, since the body is the file itself 835 const attachment_name_header = "x-attachment-name"; 836 837 const AttachParentParts = struct { 838 repo_base: []const u8, 839 id_bytes: [evt.event_id_size]u8, 840 }; 841 842 // what an attachment hangs off: the `<kind>:<id>` segment the form's page ends 843 // with, whatever kind that is 844 fn attachParentParts(base: []const u8) ?AttachParentParts { 845 const id_len = evt.event_id_size * 2; 846 if (base.len < id_len + 1) return null; 847 const head = base[0 .. base.len - id_len]; 848 if (head[head.len - 1] != ':') return null; 849 var id_bytes: [evt.event_id_size]u8 = undefined; 850 _ = std.fmt.hexToBytes(&id_bytes, base[base.len - id_len ..]) catch return null; 851 const segment_at = std.mem.lastIndexOfScalar(u8, head, '/') orelse return null; 852 return .{ .repo_base = head[0..segment_at], .id_bytes = id_bytes }; 853 } 854 855 // attach the posted file to the event the url names, then reload. the file is 856 // the whole request body, so it streams from the socket into the object store 857 // without being buffered. 858 fn handleAttach( 859 io: std.Io, 860 request: *std.http.Server.Request, 861 allocator: std.mem.Allocator, 862 base: []const u8, 863 host: Host, 864 ) !void { 865 // reading the body invalidates the request head's memory, and the url is 866 // still needed to resolve the repo afterward 867 const base_copy = try allocator.dupe(u8, base); 868 defer allocator.free(base_copy); 869 870 const parts = attachParentParts(base_copy) orelse return respondAttachmentParentNotFound(request); 871 872 var author_arena = std.heap.ArenaAllocator.init(allocator); 873 defer author_arena.deinit(); 874 const author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 875 876 const name = (try attachmentName(allocator, request)) orelse return respondUploadError(request, .bad_request, "attachment needs a name"); 877 defer allocator.free(name); 878 if (!evt.Attachment.nameValid(name)) return respondUploadError(request, .bad_request, "invalid attachment name"); 879 880 const size = request.head.content_length orelse return respondUploadError(request, .length_required, "attachments must declare a content length"); 881 if (size == 0) return respondUploadError(request, .bad_request, "attachment is empty"); 882 if (size > evt.Attachment.max_size) return respondUploadError(request, .payload_too_large, "attachment is too large"); 883 884 const parent_id_hex = std.fmt.bytesToHex(parts.id_bytes, .lower); 885 886 var body_buf: [4096]u8 = undefined; 887 const blob: evt.Blob = .{ .name = name, .size = size, .reader = try request.readerExpectContinue(&body_buf) }; 888 889 switch (host) { 890 .remote => |remote| { 891 const repo_prefix = "/repo/"; 892 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return respondAttachmentParentNotFound(request); 893 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 894 defer allocator.free(repos_dir); 895 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { 896 .ok => |p| p, 897 .invalid, .not_found => return respondAttachmentParentNotFound(request), 898 }; 899 defer allocator.free(repo_path); 900 901 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 902 defer repo.deinit(io, allocator); 903 _ = evt.Attachment.create(.xit, .{}, io, allocator, &repo, &parent_id_hex, blob, author) catch |err| switch (err) { 904 error.ParentNotFound => return respondAttachmentParentNotFound(request), 905 else => return err, 906 }; 907 }, 908 .local => |src| { 909 if (parts.repo_base.len != 0) return respondAttachmentParentNotFound(request); 910 switch (src.repo_kind) { 911 inline else => |repo_kind| { 912 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 913 defer any_repo.deinit(io, allocator); 914 switch (any_repo) { 915 inline else => |*repo| _ = evt.Attachment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &parent_id_hex, blob, author) catch |err| switch (err) { 916 error.ParentNotFound => return respondAttachmentParentNotFound(request), 917 else => return err, 918 }, 919 } 920 }, 921 } 922 }, 923 } 924 925 // the client reloads the page itself 926 try request.respond("", .{ .status = .no_content, .keep_alive = false }); 927 } 928 929 // the name the upload declares for its file, percent-encoded so it survives a 930 // header 931 fn attachmentName(allocator: std.mem.Allocator, request: *std.http.Server.Request) !?[]u8 { 932 var iter = request.iterateHeaders(); 933 while (iter.next()) |header| { 934 if (!std.ascii.eqlIgnoreCase(header.name, attachment_name_header)) continue; 935 return try decodeFormValue(allocator, header.value); 936 } 937 return null; 938 } 939 940 // an upload that failed leaves the body partly read, so the connection closes 941 fn respondUploadError(request: *std.http.Server.Request, status: std.http.Status, message: []const u8) !void { 942 try request.respond(message, .{ 943 .status = status, 944 .keep_alive = false, 945 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 946 }); 947 } 948 949 // re-emit the comment `parts` names with a new body, in the repo the host 950 // resolves the base to. 951 fn updateComment( 952 io: std.Io, 953 allocator: std.mem.Allocator, 954 host: Host, 955 parts: CommentBaseParts, 956 body: []const u8, 957 author: evt.CommitAuthor, 958 ) !void { 959 const comment_id = parts.comment_id orelse return error.NotFound; 960 const thread_id = std.fmt.bytesToHex(parts.thread_id, .lower); 961 switch (host) { 962 .remote => |remote| { 963 const repo_prefix = "/repo/"; 964 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return error.NotFound; 965 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 966 defer allocator.free(repos_dir); 967 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { 968 .ok => |p| p, 969 .invalid, .not_found => return error.NotFound, 970 }; 971 defer allocator.free(repo_path); 972 973 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 974 defer repo.deinit(io, allocator); 975 try evt.Comment.update(.xit, .{}, io, allocator, &repo, &thread_id, &comment_id, body, author); 976 }, 977 .local => |src| { 978 if (parts.repo_base.len != 0) return error.NotFound; 979 switch (src.repo_kind) { 980 inline else => |repo_kind| { 981 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 982 defer any_repo.deinit(io, allocator); 983 switch (any_repo) { 984 inline else => |*repo| try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author), 985 } 986 }, 987 } 988 }, 989 } 990 } 991 992 // edit actions share a suffix; the base identifies whether the form names an 993 // issue or comment. 994 fn handleEdit( 995 io: std.Io, 996 request: *std.http.Server.Request, 997 allocator: std.mem.Allocator, 998 base: []const u8, 999 host: Host, 1000 ) !void { 1001 if (commentBaseParts(base)) |parts| { 1002 if (parts.comment_id != null) return handleCommentEdit(io, request, allocator, base, host, parts); 1003 } 1004 return handleIssueEdit(io, request, allocator, base, host); 1005 } 1006 1007 // replace the body of the comment the url names, then redirect to its 1008 // permalink. 1009 fn handleCommentEdit( 1010 io: std.Io, 1011 request: *std.http.Server.Request, 1012 allocator: std.mem.Allocator, 1013 base: []const u8, 1014 host: Host, 1015 parts: CommentBaseParts, 1016 ) !void { 1017 var author_arena = std.heap.ArenaAllocator.init(allocator); 1018 defer author_arena.deinit(); 1019 const author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 1020 1021 var body_buf: [256]u8 = undefined; 1022 const reader = request.readerExpectNone(&body_buf); 1023 const posted = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 1024 defer allocator.free(posted); 1025 const body_crlf = (try parseFormField(allocator, posted, "body")) orelse try allocator.dupe(u8, ""); 1026 defer allocator.free(body_crlf); 1027 const body = try std.mem.replaceOwned(u8, allocator, body_crlf, "\r\n", "\n"); 1028 defer allocator.free(body); 1029 1030 if (!evt.Comment.fieldsValid(body)) { 1031 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 1032 defer allocator.free(form_location); 1033 try request.respond("", .{ 1034 .status = .see_other, 1035 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 1036 }); 1037 return; 1038 } 1039 1040 const not_found = "comment not found"; 1041 updateComment(io, allocator, host, parts, body, author) catch |err| switch (err) { 1042 error.NotFound => { 1043 try request.respond(not_found, .{ 1044 .status = .not_found, 1045 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1046 }); 1047 return; 1048 }, 1049 else => |e| return e, 1050 }; 1051 1052 try request.respond("", .{ 1053 .status = .see_other, 1054 .extra_headers = &.{.{ .name = "location", .value = base }}, 1055 }); 1056 } 1057 1058 // split an issue url ("/repo/<owner>/<name>/issue:<id>", identity elided in 1059 // local mode) into what precedes "/issue:" and the decoded id, null when it 1060 // isn't one. the repo base is validated per host in updateIssue. 1061 const IssueBaseParts = struct { repo_base: []const u8, id_bytes: [evt.event_id_size]u8 }; 1062 fn issueBaseParts(base: []const u8) ?IssueBaseParts { 1063 const issues_infix = "/issue:"; 1064 const id_len = evt.event_id_size * 2; 1065 if (base.len < issues_infix.len + id_len) return null; 1066 var id_bytes: [evt.event_id_size]u8 = undefined; 1067 _ = std.fmt.hexToBytes(&id_bytes, base[base.len - id_len ..]) catch return null; 1068 const head = base[0 .. base.len - id_len]; 1069 if (!std.mem.endsWith(u8, head, issues_infix)) return null; 1070 return .{ .repo_base = head[0 .. head.len - issues_infix.len], .id_bytes = id_bytes }; 1071 } 1072 1073 const RemoveParts = struct { 1074 repo_base: []const u8, 1075 kind: evt.EventKind, 1076 id: [evt.event_id_size]u8, 1077 }; 1078 1079 // split a remove url into the repo and event it names 1080 fn removeParts(base: []const u8) ?RemoveParts { 1081 if (attachmentRequest(base)) |attachment| { 1082 const parent = attachParentParts(attachment.repo_base) orelse return null; 1083 return .{ 1084 .repo_base = parent.repo_base, 1085 .kind = .attach, 1086 .id = attachment.id, 1087 }; 1088 } 1089 if (commentBaseParts(base)) |comment| if (comment.comment_id) |comment_id| return .{ 1090 .repo_base = comment.repo_base, 1091 .kind = .comment, 1092 .id = comment_id, 1093 }; 1094 if (issueBaseParts(base)) |issue| return .{ 1095 .repo_base = issue.repo_base, 1096 .kind = .issue, 1097 .id = issue.id_bytes, 1098 }; 1099 return null; 1100 } 1101 1102 // remove the issue, comment or attachment named by the form's url 1103 fn handleRemove( 1104 io: std.Io, 1105 request: *std.http.Server.Request, 1106 allocator: std.mem.Allocator, 1107 base: []const u8, 1108 host: Host, 1109 ) !void { 1110 const parts = removeParts(base) orelse return respondRemoveNotFound(request); 1111 1112 var author_arena = std.heap.ArenaAllocator.init(allocator); 1113 defer author_arena.deinit(); 1114 const author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 1115 1116 switch (host) { 1117 .remote => |remote| { 1118 const repo_prefix = "/repo/"; 1119 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return respondRemoveNotFound(request); 1120 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 1121 defer allocator.free(repos_dir); 1122 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { 1123 .ok => |p| p, 1124 .invalid, .not_found => return respondRemoveNotFound(request), 1125 }; 1126 defer allocator.free(repo_path); 1127 1128 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 1129 defer repo.deinit(io, allocator); 1130 evt.remove(.xit, .{}, io, allocator, &repo, &parts.id, parts.kind, author) catch |err| switch (err) { 1131 error.EventNotFound => return respondRemoveNotFound(request), 1132 else => |e| return e, 1133 }; 1134 }, 1135 .local => |src| { 1136 if (parts.repo_base.len != 0) return respondRemoveNotFound(request); 1137 switch (src.repo_kind) { 1138 inline else => |repo_kind| { 1139 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1140 defer any_repo.deinit(io, allocator); 1141 switch (any_repo) { 1142 inline else => |*repo| evt.remove(repo_kind, repo.self_repo_opts, io, allocator, repo, &parts.id, parts.kind, author) catch |err| switch (err) { 1143 error.EventNotFound => return respondRemoveNotFound(request), 1144 else => |e| return e, 1145 }, 1146 } 1147 }, 1148 } 1149 }, 1150 } 1151 1152 const id = std.fmt.bytesToHex(parts.id, .lower); 1153 const location = try std.fmt.allocPrint(allocator, "{s}/event:{s}/kind:{s}", .{ parts.repo_base, &id, @tagName(parts.kind) }); 1154 defer allocator.free(location); 1155 try request.respond("", .{ 1156 .status = .see_other, 1157 .keep_alive = false, 1158 .extra_headers = &.{.{ .name = "location", .value = location }}, 1159 }); 1160 } 1161 1162 fn respondRemoveNotFound(request: *std.http.Server.Request) !void { 1163 try request.respond("event not found", .{ 1164 .status = .not_found, 1165 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1166 }); 1167 } 1168 1169 // re-emit the issue `parts` names with `update` applied, in the repo the 1170 // host resolves the base to. error.NotFound when the base names no repo or 1171 // the id no issue. 1172 fn updateIssue( 1173 io: std.Io, 1174 allocator: std.mem.Allocator, 1175 host: Host, 1176 parts: IssueBaseParts, 1177 update: evt.Issue.Update, 1178 author: evt.CommitAuthor, 1179 ) !void { 1180 switch (host) { 1181 .remote => |remote| { 1182 const repo_prefix = "/repo/"; 1183 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return error.NotFound; 1184 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 1185 defer allocator.free(repos_dir); 1186 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { 1187 .ok => |p| p, 1188 .invalid, .not_found => return error.NotFound, 1189 }; 1190 defer allocator.free(repo_path); 1191 1192 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 1193 defer repo.deinit(io, allocator); 1194 try evt.Issue.update(.xit, .{}, io, allocator, &repo, &parts.id_bytes, update, author); 1195 }, 1196 .local => |src| { 1197 // the local forms post to "/issue:<id>/...", so the repo base 1198 // is empty 1199 if (parts.repo_base.len != 0) return error.NotFound; 1200 switch (src.repo_kind) { 1201 inline else => |repo_kind| { 1202 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1203 defer any_repo.deinit(io, allocator); 1204 switch (any_repo) { 1205 inline else => |*repo| try evt.Issue.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &parts.id_bytes, update, author), 1206 } 1207 }, 1208 } 1209 }, 1210 } 1211 } 1212 1213 // set the status of the issue the url names (base is 1214 // "/repo/<owner>/<name>/issue:<id>", identity elided in local mode) by 1215 // re-emitting its event, then redirect back to it 1216 fn handleIssueStatus( 1217 io: std.Io, 1218 request: *std.http.Server.Request, 1219 allocator: std.mem.Allocator, 1220 base: []const u8, 1221 host: Host, 1222 status: evt.Issue.Status, 1223 ) !void { 1224 const not_found = "issue not found"; 1225 const parts = issueBaseParts(base) orelse { 1226 try request.respond(not_found, .{ 1227 .status = .not_found, 1228 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1229 }); 1230 return; 1231 }; 1232 1233 var author_arena = std.heap.ArenaAllocator.init(allocator); 1234 defer author_arena.deinit(); 1235 const author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 1236 1237 updateIssue(io, allocator, host, parts, .{ .status = status }, author) catch |err| switch (err) { 1238 error.NotFound => { 1239 try request.respond(not_found, .{ 1240 .status = .not_found, 1241 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1242 }); 1243 return; 1244 }, 1245 else => |e| return e, 1246 }; 1247 1248 // like logout, this is a bodyless POST, so close the connection rather than 1249 // letting the keep-alive path try to discard a body that isn't framed. 1250 try request.respond("", .{ 1251 .status = .see_other, 1252 .keep_alive = false, 1253 .extra_headers = &.{.{ .name = "location", .value = base }}, 1254 }); 1255 } 1256 1257 // replace the title/tags/description of the issue the url names (base is 1258 // "/repo/<owner>/<name>/issue:<id>", identity elided in local mode; the 1259 // form posts to "<base>/edit") by re-emitting its event with its status 1260 // preserved, then redirect back to it 1261 fn handleIssueEdit( 1262 io: std.Io, 1263 request: *std.http.Server.Request, 1264 allocator: std.mem.Allocator, 1265 base: []const u8, 1266 host: Host, 1267 ) !void { 1268 var author_arena = std.heap.ArenaAllocator.init(allocator); 1269 defer author_arena.deinit(); 1270 const author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 1271 1272 var body_buf: [256]u8 = undefined; 1273 const reader = request.readerExpectNone(&body_buf); 1274 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 1275 defer allocator.free(body); 1276 1277 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 1278 defer allocator.free(title); 1279 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 1280 defer allocator.free(tags); 1281 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 1282 defer allocator.free(description_crlf); 1283 // form submission normalizes textarea line breaks to CRLF; store plain 1284 // newlines so the text renders the same on every host 1285 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 1286 defer allocator.free(description); 1287 1288 // invalid fields send the user back to the edit form 1289 if (!evt.Issue.fieldsValid(title, tags)) { 1290 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 1291 defer allocator.free(form_location); 1292 try request.respond("", .{ 1293 .status = .see_other, 1294 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 1295 }); 1296 return; 1297 } 1298 1299 const not_found = "issue not found"; 1300 const parts = issueBaseParts(base) orelse { 1301 try request.respond(not_found, .{ 1302 .status = .not_found, 1303 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1304 }); 1305 return; 1306 }; 1307 1308 updateIssue(io, allocator, host, parts, .{ .fields = .{ 1309 .title = title, 1310 .tags = tags, 1311 .description = description, 1312 } }, author) catch |err| switch (err) { 1313 error.NotFound => { 1314 try request.respond(not_found, .{ 1315 .status = .not_found, 1316 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1317 }); 1318 return; 1319 }, 1320 else => |e| return e, 1321 }; 1322 1323 try request.respond("", .{ 1324 .status = .see_other, 1325 .extra_headers = &.{.{ .name = "location", .value = base }}, 1326 }); 1327 } 1328 1329 // resolve the conflict of the issue the url names (base is 1330 // "/repo/<owner>/<name>/issue:<id>", identity elided in local mode; the 1331 // form posts to "<base>/resolve") 1332 fn handleIssueResolve( 1333 io: std.Io, 1334 request: *std.http.Server.Request, 1335 allocator: std.mem.Allocator, 1336 base: []const u8, 1337 host: Host, 1338 ) !void { 1339 var author_arena = std.heap.ArenaAllocator.init(allocator); 1340 defer author_arena.deinit(); 1341 const author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 1342 1343 var body_buf: [256]u8 = undefined; 1344 const reader = request.readerExpectNone(&body_buf); 1345 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 1346 defer allocator.free(body); 1347 1348 var field_arena = std.heap.ArenaAllocator.init(allocator); 1349 defer field_arena.deinit(); 1350 const aa = field_arena.allocator(); 1351 const title = try parseFormField(aa, body, "title"); 1352 const tags = try parseFormField(aa, body, "tags"); 1353 1354 // the hunk inputs post as d0, d1, ...; form submission normalizes their 1355 // textarea line breaks to CRLF 1356 var hunks: std.ArrayList([]const u8) = .empty; 1357 var hunk_index: usize = 0; 1358 while (true) : (hunk_index += 1) { 1359 const name = try std.fmt.allocPrint(aa, "d{d}", .{hunk_index}); 1360 const posted = (try parseFormField(aa, body, name)) orelse break; 1361 try hunks.append(aa, try std.mem.replaceOwned(u8, aa, posted, "\r\n", "\n")); 1362 } 1363 1364 const not_found = "issue not found"; 1365 const parts = issueBaseParts(base) orelse { 1366 try request.respond(not_found, .{ 1367 .status = .not_found, 1368 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1369 }); 1370 return; 1371 }; 1372 1373 updateIssue(io, allocator, host, parts, .{ .resolve = .{ 1374 .title = title, 1375 .tags = tags, 1376 .hunks = hunks.items, 1377 } }, author) catch |err| switch (err) { 1378 error.NotFound => { 1379 try request.respond(not_found, .{ 1380 .status = .not_found, 1381 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1382 }); 1383 return; 1384 }, 1385 // invalid fields send the user back to the resolve form 1386 error.InvalidFields => { 1387 const form_location = try std.fmt.allocPrint(allocator, "{s}/resolve", .{base}); 1388 defer allocator.free(form_location); 1389 try request.respond("", .{ 1390 .status = .see_other, 1391 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 1392 }); 1393 return; 1394 }, 1395 else => |e| return e, 1396 }; 1397 1398 try request.respond("", .{ 1399 .status = .see_other, 1400 .extra_headers = &.{.{ .name = "location", .value = base }}, 1401 }); 1402 } 1403 1404 fn handleSync( 1405 io: std.Io, 1406 request: *std.http.Server.Request, 1407 allocator: std.mem.Allocator, 1408 base: []const u8, 1409 source: ui.RepoSource, 1410 ) !void { 1411 if (base.len != 0) { 1412 try request.respond("not found", .{ 1413 .status = .not_found, 1414 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1415 }); 1416 return; 1417 } 1418 1419 var owned_failure: ?[]u8 = null; 1420 defer if (owned_failure) |message| allocator.free(message); 1421 const failure: ?[]const u8 = blk: { 1422 owned_failure = ui.Repo.Events.sync(io, allocator, source) catch |err| break :blk @errorName(err); 1423 break :blk owned_failure; 1424 }; 1425 if (failure) |message| { 1426 var encoded: std.Io.Writer.Allocating = .init(allocator); 1427 defer encoded.deinit(); 1428 try std.Uri.Component.percentEncode(&encoded.writer, message, struct { 1429 fn isUnreserved(c: u8) bool { 1430 return std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~'; 1431 } 1432 }.isUnreserved); 1433 const cookie = try std.fmt.allocPrint( 1434 allocator, 1435 sync_failure_cookie ++ "={s}; Path=/; HttpOnly; SameSite=Strict", 1436 .{encoded.written()}, 1437 ); 1438 defer allocator.free(cookie); 1439 try request.respond("", .{ 1440 .status = .see_other, 1441 .extra_headers = &.{ 1442 .{ .name = "location", .value = "/events" }, 1443 .{ .name = "set-cookie", .value = cookie }, 1444 }, 1445 }); 1446 return; 1447 } 1448 1449 try request.respond("", .{ 1450 .status = .see_other, 1451 .extra_headers = &.{ 1452 .{ .name = "location", .value = "/events" }, 1453 .{ .name = "set-cookie", .value = sync_failure_cookie ++ "=; Path=/; Max-Age=0" }, 1454 }, 1455 }); 1456 } 1457 1458 fn renderIndexHtml( 1459 io: std.Io, 1460 allocator: std.mem.Allocator, 1461 host: Host, 1462 session_data: ui.Session.Data, 1463 ) ![]const u8 { 1464 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 1465 1466 var page_arena = std.heap.ArenaAllocator.init(allocator); 1467 defer page_arena.deinit(); 1468 1469 // the remote admin repo must outlive the page build, since the session's 1470 // moment reads from it. 1471 var repo_maybe: ?rp.Repo(.xit, evt.admin_repo_opts) = null; 1472 defer if (repo_maybe) |*repo| repo.deinit(io, allocator); 1473 1474 var session = switch (host) { 1475 .remote => |remote| blk: { 1476 // open the admin repo to read live user/repo data. 1477 repo_maybe = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 1478 const repo = if (repo_maybe) |*repo| repo else unreachable; 1479 var session = try ui.Session.init(&page_arena, repo, session_data); 1480 // give the page builders filesystem access to the on-disk repos (a 1481 // sibling "repos" dir next to the admin repo) so the Repo page can 1482 // read its files. 1483 session.repos_dir = try std.fs.path.join(page_arena.allocator(), &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 1484 break :blk session; 1485 }, 1486 .local => |local| ui.Session{ 1487 .data = session_data, 1488 .arena = &page_arena, 1489 .page_arena = &page_arena, 1490 .local = local, 1491 }, 1492 }; 1493 session.io = io; 1494 1495 const snapshot: ui.Snapshot = .{ 1496 .page = try ui.Page.init(session.page_arena, &session, session.data.current_page), 1497 .session = session.data, 1498 }; 1499 var root = try ui.initRoot(allocator, &snapshot.page, &session); 1500 defer root.deinit(allocator); 1501 1502 const content = try generateHtml(allocator, &root); 1503 defer allocator.free(content); 1504 const overlay = try generateOverlay(allocator, &root, &session); 1505 defer allocator.free(overlay); 1506 1507 // serialize the snapshot so the wasm side can parse it back without making 1508 // a second request. it's embedded raw in a <script type="application/json"> 1509 // block, whose content is raw text terminated only by "</script". in json, 1510 // '<' appears solely inside string values, so escaping every '<' (and '>' 1511 // for good measure) to its \uXXXX form yields equivalent json that can't 1512 // break out of the tag. 1513 var json: std.Io.Writer.Allocating = .init(allocator); 1514 defer json.deinit(); 1515 try std.json.Stringify.value(snapshot, .{}, &json.writer); 1516 1517 var json_escaped: std.ArrayList(u8) = .empty; 1518 defer json_escaped.deinit(allocator); 1519 for (json.written()) |c| switch (c) { 1520 '<' => try json_escaped.appendSlice(allocator, "\\u003c"), 1521 '>' => try json_escaped.appendSlice(allocator, "\\u003e"), 1522 else => try json_escaped.append(allocator, c), 1523 }; 1524 1525 var out: std.ArrayList(u8) = .empty; 1526 errdefer out.deinit(allocator); 1527 { 1528 var cursor: usize = 0; 1529 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 1530 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 1531 .{ .needle = "{{{ HAXY_OVERLAY }}}", .replacement = overlay }, 1532 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_escaped.items }, 1533 }) |sub| { 1534 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 1535 try out.appendSlice(allocator, template[cursor..idx]); 1536 try out.appendSlice(allocator, sub.replacement); 1537 cursor = idx + sub.needle.len; 1538 } 1539 try out.appendSlice(allocator, template[cursor..]); 1540 } 1541 return try out.toOwnedSlice(allocator); 1542 } 1543 1544 fn findEmbed(request_path: []const u8) ?Embed { 1545 const path = if (std.mem.eql(u8, request_path, "/")) 1546 "index.html" 1547 else if (request_path.len > 1 and request_path[0] == '/') 1548 request_path[1..] 1549 else 1550 return null; 1551 1552 for (embeds) |embed| { 1553 if (std.mem.eql(u8, path, embed.path)) return embed; 1554 } 1555 return null; 1556 } 1557 1558 // emits the TUI grid cells as static HTML. each web-native Scroll becomes its 1559 // own absolutely-positioned, natively-scrollable <div> holding its full content, 1560 // rendered recursively so nested scrolls work. 1561 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { 1562 const grid = root.getGrid() orelse return error.MissingGrid; 1563 1564 var out: std.ArrayList(u8) = .empty; 1565 errdefer out.deinit(allocator); 1566 1567 try renderPanel(allocator, &out, root.getFocus(), grid); 1568 return try out.toOwnedSlice(allocator); 1569 } 1570 1571 // render one panel — a focus subtree and its content grid — as HTML, recursing 1572 // once per web-native Scroll inside it. the root panel is the whole page; each 1573 // Scroll becomes a nested, natively-scrollable div holding its full content. 1574 // emits the panel's grid cells as text, leaving holes where child scrolls sit, 1575 // then the child scroll divs over those holes. 1576 fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid) !void { 1577 // `direct` is this panel's scroll children, each drawn as an overlaid div. 1578 // `excluded` is every focus id that belongs to a scroll (the scroll nodes 1579 // plus their descendants, which the focus tree flattens into this panel's 1580 // children): the cell hit-test below skips them so this panel doesn't claim 1581 // cells that are really rendered inside a child scroll's own panel. 1582 // (scrolls aren't nested in practice, so every scroll child is direct here.) 1583 var direct: std.ArrayList(usize) = .empty; 1584 defer direct.deinit(allocator); 1585 var excluded: std.AutoHashMapUnmanaged(usize, void) = .empty; 1586 defer excluded.deinit(allocator); 1587 { 1588 var it = focus.children.iterator(); 1589 while (it.next()) |e| { 1590 if (e.value_ptr.focus.scroll == null) continue; 1591 try direct.append(allocator, e.key_ptr.*); 1592 try excluded.put(allocator, e.key_ptr.*, {}); 1593 var mit = e.value_ptr.focus.children.iterator(); 1594 while (mit.next()) |m| try excluded.put(allocator, m.key_ptr.*, {}); 1595 } 1596 } 1597 1598 // the HTML element a run of cells is wrapped in, chosen from the cell's 1599 // focusable kind 1600 const CellTag = union(enum) { 1601 span, // clickable focusable cell 1602 a: []const u8, // clickable focusable link 1603 plain, // non-focusable, colored 1604 1605 const a_prefix = "a:"; 1606 // an "in-page" anchor: rendered as a real <a href> so it works with js 1607 // disabled, but crossPageLink ignores it, so with wasm the click just 1608 // moves focus rather than navigating. 1609 const inpage_prefix = "ai:"; 1610 1611 fn init(str: []const u8) @This() { 1612 if (std.mem.startsWith(u8, str, ui.raw_link_prefix)) { 1613 return .{ .a = str[ui.raw_link_prefix.len..] }; 1614 } 1615 if (std.mem.startsWith(u8, str, inpage_prefix)) { 1616 return .{ .a = str[inpage_prefix.len..] }; 1617 } 1618 if (std.mem.startsWith(u8, str, a_prefix)) { 1619 return .{ .a = str[a_prefix.len..] }; 1620 } 1621 return .span; 1622 } 1623 1624 fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void { 1625 var id_buf: [32]u8 = undefined; 1626 switch (self) { 1627 .span => { 1628 try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); 1629 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1630 try out.appendSlice(alloc, "\""); 1631 try out.appendSlice(alloc, style_attr); 1632 try out.appendSlice(alloc, ">"); 1633 }, 1634 .a => |href| { 1635 // tabindex=-1 keeps these out of the browser's tab order 1636 try out.appendSlice(alloc, "<a class=\"clickable\" tabindex=\"-1\" data-focus-id=\""); 1637 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1638 try out.appendSlice(alloc, "\" href=\""); 1639 try appendEscapedHtml(alloc, out, href); 1640 try out.appendSlice(alloc, "\""); 1641 try out.appendSlice(alloc, style_attr); 1642 try out.appendSlice(alloc, ">"); 1643 }, 1644 .plain => { 1645 try out.appendSlice(alloc, "<span"); 1646 try out.appendSlice(alloc, style_attr); 1647 try out.appendSlice(alloc, ">"); 1648 }, 1649 } 1650 } 1651 1652 fn closeTag(self: @This()) []const u8 { 1653 return switch (self) { 1654 .span, .plain => "</span>", 1655 .a => "</a>", 1656 }; 1657 } 1658 }; 1659 1660 // emit this panel's grid as rows of text, coalescing adjacent cells that 1661 // share a focus id and colors into one tag (a clickable span, a link, or a 1662 // plain colored span). cells under a child scroll keep only their background. 1663 for (0..grid.size.height) |y| { 1664 var cur_id: ?usize = null; 1665 var cur_fg: ?Grid.Color = null; 1666 var cur_bg: ?Grid.Color = null; 1667 var open_tag: ?CellTag = null; 1668 var first = true; 1669 for (0..grid.size.width) |x| { 1670 const cell = grid.cells.items[try grid.cells.at(.{ y, x })]; 1671 // a cell covered by a child scroll's viewport is drawn by that scroll's 1672 // own div, so blank its glyph and make it non-clickable here — but keep 1673 // its background, so the backdrop still shows through the scroll's 1674 // transparent div instead of a bare hole. 1675 const covered_by_scroll = blk: { 1676 for (direct.items) |id| { 1677 const r = (focus.children.get(id) orelse continue).rect; 1678 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; 1679 } 1680 break :blk false; 1681 }; 1682 // the focusable cell at (x, y) among this panel's own focusables (skipping 1683 // any that belong to a child scroll, whose cells are drawn in their own 1684 // panel); none for a covered cell. 1685 const cell_id = if (covered_by_scroll) null else blk: { 1686 var iter = focus.children.iterator(); 1687 while (iter.next()) |entry| { 1688 const child = entry.value_ptr.*; 1689 if (!child.focus.focusable) continue; 1690 if (excluded.contains(entry.key_ptr.*)) continue; 1691 const r = child.rect; 1692 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 1693 break :blk entry.key_ptr.*; 1694 } 1695 } 1696 break :blk null; 1697 }; 1698 const fg = if (covered_by_scroll) null else cell.style.fg; 1699 const bg = cell.style.bg; 1700 1701 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 1702 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1703 1704 var new_tag: ?CellTag = null; 1705 if (cell_id) |id| { 1706 if (focus.children.get(id)) |child| { 1707 new_tag = switch (child.focus.kind) { 1708 .custom => |custom| CellTag.init(custom), 1709 else => .span, 1710 }; 1711 } 1712 } 1713 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 1714 1715 if (new_tag) |t| { 1716 var style_buf: [64]u8 = undefined; 1717 try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 1718 } 1719 1720 open_tag = new_tag; 1721 cur_id = cell_id; 1722 cur_fg = fg; 1723 cur_bg = bg; 1724 first = false; 1725 } 1726 1727 if (covered_by_scroll) { 1728 try appendEscapedHtml(allocator, output, " "); 1729 } else if (cell.continuation) { 1730 // the wide rune to the left was emitted 2ch wide, covering this column 1731 } else if (cell.rune) |rune| { 1732 // a double-width rune is pinned inside a 2ch span so the 1733 // fallback glyph's advance width can't shift the rest of the row 1734 const wide = x + 1 < grid.size.width and grid.cells.items[try grid.cells.at(.{ y, x + 1 })].continuation; 1735 if (wide) { 1736 try output.appendSlice(allocator, "<span class=\"w2\">"); 1737 try appendEscapedHtml(allocator, output, rune); 1738 try output.appendSlice(allocator, "</span>"); 1739 } else { 1740 try appendEscapedHtml(allocator, output, rune); 1741 } 1742 } else { 1743 try appendEscapedHtml(allocator, output, " "); 1744 } 1745 } 1746 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1747 try output.append(allocator, '\n'); 1748 } 1749 1750 // each child scroll becomes a natively-scrollable div positioned over its 1751 // viewport, holding its full content rendered recursively. 1752 for (direct.items) |id| { 1753 const child = focus.children.get(id) orelse continue; 1754 const info = child.focus.scroll orelse continue; 1755 const r = child.rect; 1756 // overflow only on the axes the widget scrolls; `auto` shows a bar only 1757 // when that axis actually overflows. the other axis is clipped (hidden), 1758 // matching how the terminal Scroll handles its non-scrolling axis. 1759 const overflow = switch (info.direction) { 1760 .vert => "overflow-x:hidden;overflow-y:auto", 1761 .horiz => "overflow-x:auto;overflow-y:hidden", 1762 .both => "overflow:auto", 1763 }; 1764 // the id carries a content version so JS preserves the native scroll 1765 // position across re-renders of the same content but resets it when the 1766 // content is replaced — e.g. selecting a different commit. the widget's 1767 // scroll offset (in cells) rides along so JS can apply it when it has no 1768 // preserved position, letting a wasm-side scrollToRect (the files list 1769 // scrolling to its selected row on page load) reach the browser. 1770 var buf: [224]u8 = undefined; 1771 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 })); 1772 try renderPanel(allocator, output, child.focus, info.content); 1773 try output.appendSlice(allocator, "</div>"); 1774 } 1775 } 1776 1777 // emits the form overlay — one <form> per "form:<url>" focus subtree in the 1778 // widget tree, each wrapping the text inputs and submit button inside it, 1779 // positioned absolutely over the matching grid cells. 1780 pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget, session: *ui.Session) ![]const u8 { 1781 const root_focus = root.getFocus(); 1782 1783 var out: std.ArrayList(u8) = .empty; 1784 errdefer out.deinit(allocator); 1785 1786 const form_prefix = "form:"; 1787 1788 // Focus.children is an AutoArrayHashMap that preserves insertion order, 1789 // which matches the order widgets were added in code 1790 var iter = root_focus.children.iterator(); 1791 while (iter.next()) |entry| { 1792 const child = entry.value_ptr.*; 1793 const action_url = switch (child.focus.kind) { 1794 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) 1795 custom[form_prefix.len..] 1796 else 1797 continue, 1798 else => continue, 1799 }; 1800 1801 try out.appendSlice(allocator, "<form action=\""); 1802 try appendEscapedHtml(allocator, &out, action_url); 1803 try out.appendSlice(allocator, "\" method=\"post\""); 1804 // an empty action must not POST — block it and send the input to the TUI instead 1805 if (action_url.len == 0) try out.appendSlice(allocator, " onsubmit=\"event.preventDefault();sendEnter(this);\""); 1806 try out.appendSlice(allocator, ">"); 1807 1808 // form.focus.children was populated by addChild, which flattens the 1809 // whole subtree into one map — so descendants (inputs + the submit 1810 // button) all appear here. positions on form.focus are relative to 1811 // the form, so we look up absolute rects via root_focus for layout. 1812 var inner_iter = child.focus.children.iterator(); 1813 while (inner_iter.next()) |inner_entry| { 1814 const inner_id = inner_entry.key_ptr.*; 1815 const root_child = root_focus.children.get(inner_id) orelse continue; 1816 const r = root_child.rect; 1817 switch (root_child.focus.kind) { 1818 .text_input, .text_input_password => { 1819 const ti = session.text_inputs.get(inner_id) orelse continue; 1820 const inner_left = r.x + 1; 1821 const inner_top = r.y + 1; 1822 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1823 1824 try out.appendSlice(allocator, "<input type=\""); 1825 try out.appendSlice(allocator, if (root_child.focus.kind == .text_input_password) "password" else "text"); 1826 try out.appendSlice(allocator, if (action_url.len == 0) "\" readonly data-focus-id=\"" else "\" data-focus-id=\""); 1827 var id_buf: [32]u8 = undefined; 1828 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1829 if (ti.options.name.len > 0) { 1830 try out.appendSlice(allocator, "\" name=\""); 1831 try appendEscapedHtml(allocator, &out, ti.options.name); 1832 } 1833 // never the live content as the `value`: the browser tracks user 1834 // input natively, and including the wasm-side value here would make 1835 // the overlay HTML differ on every keystroke — that would trip the 1836 // diff in _setOverlay and rebuild the <input>, eating the user's 1837 // caret. a page-constant initial value is safe. 1838 if (session.input_values.get(inner_id)) |value| { 1839 try out.appendSlice(allocator, "\" value=\""); 1840 try appendEscapedHtml(allocator, &out, value); 1841 } 1842 try out.appendSlice(allocator, "\" style=\"left:"); 1843 var pos_buf: [64]u8 = undefined; 1844 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 })); 1845 try out.appendSlice(allocator, "\">"); 1846 }, 1847 .text_area => { 1848 const ti = session.text_inputs.get(inner_id) orelse continue; 1849 const inner_left = r.x + 1; 1850 const inner_top = r.y + 1; 1851 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1852 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 1853 1854 try out.appendSlice(allocator, "<textarea data-focus-id=\""); 1855 var id_buf: [32]u8 = undefined; 1856 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1857 if (ti.options.name.len > 0) { 1858 try out.appendSlice(allocator, "\" name=\""); 1859 try appendEscapedHtml(allocator, &out, ti.options.name); 1860 } 1861 // like the input above, only a page-constant initial value 1862 // inside: the browser tracks the live value and the 1863 // overlay diff must stay stable across keystrokes 1864 try out.appendSlice(allocator, "\" style=\"left:"); 1865 var pos_buf: [128]u8 = undefined; 1866 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 })); 1867 // textarea discards the first newline immediately following 1868 // its start tag, so add one of our own so text that begins 1869 // with a newline doesn't lose it 1870 try out.appendSlice(allocator, "\">\n"); 1871 if (session.input_values.get(inner_id)) |value| try appendEscapedHtml(allocator, &out, value); 1872 try out.appendSlice(allocator, "</textarea>"); 1873 }, 1874 .custom => |custom| { 1875 if (std.mem.eql(u8, "submit", custom) or std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 1876 try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); 1877 var id_buf: [32]u8 = undefined; 1878 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1879 if (std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 1880 try out.appendSlice(allocator, "\" formaction=\""); 1881 try appendEscapedHtml(allocator, &out, custom[ui.submit_action_prefix.len..]); 1882 } 1883 try out.appendSlice(allocator, "\" style=\"left:"); 1884 var pos_buf: [128]u8 = undefined; 1885 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 })); 1886 try out.appendSlice(allocator, "\"></button>"); 1887 } 1888 }, 1889 else => {}, 1890 } 1891 } 1892 1893 try out.appendSlice(allocator, "</form>"); 1894 } 1895 1896 // a file picker isn't part of a form: it posts its file straight to the url 1897 // in its marker. it sits transparent over the button the tui drew, so 1898 // clicking that button opens the browser's own dialog. 1899 var file_iter = root_focus.children.iterator(); 1900 while (file_iter.next()) |entry| { 1901 const child = entry.value_ptr.*; 1902 const action_url = switch (child.focus.kind) { 1903 .custom => |custom| if (std.mem.startsWith(u8, custom, ui.file_input_prefix)) 1904 custom[ui.file_input_prefix.len..] 1905 else 1906 continue, 1907 else => continue, 1908 }; 1909 const r = child.rect; 1910 1911 try out.appendSlice(allocator, "<input type=\"file\" data-action=\""); 1912 try appendEscapedHtml(allocator, &out, action_url); 1913 try out.appendSlice(allocator, "\" data-focus-id=\""); 1914 var id_buf: [32]u8 = undefined; 1915 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{entry.key_ptr.*})); 1916 try out.appendSlice(allocator, "\" style=\"opacity:0;left:"); 1917 var pos_buf: [128]u8 = undefined; 1918 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 })); 1919 try out.appendSlice(allocator, "\">"); 1920 } 1921 1922 return try out.toOwnedSlice(allocator); 1923 } 1924 1925 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 1926 if (a) |av| return if (b) |bv| av.eql(bv) else false; 1927 return b == null; 1928 } 1929 1930 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 1931 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 1932 // empty slice when neither is set. 1933 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 1934 if (fg == null and bg == null) return buf[0..0]; 1935 const prefix = " style=\""; 1936 @memcpy(buf[0..prefix.len], prefix); 1937 var i: usize = prefix.len; 1938 if (fg) |c| { 1939 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]; 1940 i += s.len; 1941 } 1942 if (bg) |c| { 1943 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]; 1944 i += s.len; 1945 } 1946 buf[i] = '"'; 1947 i += 1; 1948 return buf[0..i]; 1949 } 1950 1951 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 1952 for (input) |ch| { 1953 switch (ch) { 1954 '&' => try out.appendSlice(allocator, "&amp;"), 1955 '<' => try out.appendSlice(allocator, "&lt;"), 1956 '>' => try out.appendSlice(allocator, "&gt;"), 1957 '"' => try out.appendSlice(allocator, "&quot;"), 1958 '\'' => try out.appendSlice(allocator, "&#39;"), 1959 else => try out.append(allocator, ch), 1960 } 1961 } 1962 } 1963 1964 // --- helpers -------------------------------------------------------------- 1965 1966 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 1967 var iter = request.iterateHeaders(); 1968 while (iter.next()) |header| { 1969 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 1970 var pairs = std.mem.splitScalar(u8, header.value, ';'); 1971 while (pairs.next()) |pair| { 1972 const trimmed = std.mem.trim(u8, pair, " \t"); 1973 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 1974 if (std.mem.eql(u8, trimmed[0..eq], name)) { 1975 return trimmed[eq + 1 ..]; 1976 } 1977 } 1978 } 1979 return null; 1980 } 1981 1982 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 1983 var iter = std.mem.splitScalar(u8, body, '&'); 1984 while (iter.next()) |pair| { 1985 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 1986 if (std.mem.eql(u8, pair[0..eq], key)) { 1987 return try decodeFormValue(allocator, pair[eq + 1 ..]); 1988 } 1989 } 1990 return null; 1991 } 1992 1993 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 1994 var out: std.ArrayList(u8) = .empty; 1995 errdefer out.deinit(allocator); 1996 var i: usize = 0; 1997 while (i < encoded.len) : (i += 1) { 1998 switch (encoded[i]) { 1999 '+' => try out.append(allocator, ' '), 2000 '%' => { 2001 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 2002 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 2003 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 2004 try out.append(allocator, hi * 16 + lo); 2005 i += 2; 2006 }, 2007 else => try out.append(allocator, encoded[i]), 2008 } 2009 } 2010 return try out.toOwnedSlice(allocator); 2011 } 2012 2013 // disk-backed web session store. each login mints a random, opaque token 2014 // (not the user's id) that is stored as a file named by the token hex, whose 2015 // contents are the raw user_id bytes. authenticating a request is a lookup of 2016 // the cookie's token; logout deletes the file, revoking the session. there is 2017 // deliberately no expiry — a session lives until logout. 2018 pub const SessionStore = struct { 2019 io: std.Io, 2020 dir: std.Io.Dir, 2021 2022 pub const token_hex_len = evt.event_id_size * 2; 2023 2024 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 2025 try data_dir.createDirPath(io, "sessions"); 2026 const dir = try data_dir.openDir(io, "sessions", .{}); 2027 return .{ .io = io, .dir = dir }; 2028 } 2029 2030 pub fn deinit(self: SessionStore) void { 2031 self.dir.close(self.io); 2032 } 2033 2034 // mint a new session for user_id, returning the token hex to set as the 2035 // cookie value. 2036 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 2037 var token: [evt.event_id_size]u8 = undefined; 2038 self.io.random(&token); 2039 const token_hex = std.fmt.bytesToHex(token, .lower); 2040 const file = try self.dir.createFile(self.io, &token_hex, .{}); 2041 defer file.close(self.io); 2042 try file.writeStreamingAll(self.io, user_id); 2043 return token_hex; 2044 } 2045 2046 // resolve a cookie's token to its user_id. returns false (logged out) for a 2047 // missing, malformed, or unknown token. 2048 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 2049 if (!isToken(token_hex)) return false; 2050 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 2051 defer file.close(self.io); 2052 var storage: [evt.event_id_size]u8 = undefined; 2053 var file_reader = file.reader(self.io, &storage); 2054 file_reader.interface.readSliceAll(out) catch return false; 2055 return true; 2056 } 2057 2058 // revoke a session. a no-op for a missing or malformed token. 2059 pub fn remove(self: SessionStore, token_hex: []const u8) void { 2060 if (!isToken(token_hex)) return; 2061 self.dir.deleteFile(self.io, token_hex) catch {}; 2062 } 2063 2064 // leave `token_hex`'s session for the next visitor arriving without one. 2065 // only the `try` fixture writes one, and only a debug build honors it. the 2066 // name isn't token-shaped, so it can't collide with a session file. 2067 pub fn offerAutoLogin(self: SessionStore, token_hex: *const [token_hex_len]u8) !void { 2068 const file = try self.dir.createFile(self.io, auto_login_name, .{}); 2069 defer file.close(self.io); 2070 try file.writeStreamingAll(self.io, token_hex); 2071 } 2072 2073 // claim the offered session, if one is there. the delete picks the winner 2074 // of a race, so the offer is only ever taken once. a release build never 2075 // looks, because that would be an extra syscall for every GET request. 2076 pub fn claimAutoLogin(self: SessionStore, out: *[token_hex_len]u8) bool { 2077 if (builtin.mode != .Debug) return false; 2078 { 2079 const file = self.dir.openFile(self.io, auto_login_name, .{ .mode = .read_only }) catch return false; 2080 defer file.close(self.io); 2081 var storage: [token_hex_len]u8 = undefined; 2082 var file_reader = file.reader(self.io, &storage); 2083 file_reader.interface.readSliceAll(out) catch return false; 2084 } 2085 self.dir.deleteFile(self.io, auto_login_name) catch return false; 2086 return true; 2087 } 2088 2089 const auto_login_name = "auto-login"; 2090 2091 // a token is exactly token_hex_len hex chars. validating before using the 2092 // value as a path keeps attacker-supplied cookies from escaping the 2093 // sessions dir (e.g. via "/" or ".." bytes). 2094 fn isToken(token_hex: []const u8) bool { 2095 if (token_hex.len != token_hex_len) return false; 2096 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 2097 return true; 2098 } 2099 }; 2100