╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 95 if (method == .POST) { │ │ 96 switch (host) { │ │ 97 .remote => |remote| { │ │ 98 - const PostRoute = enum { login, logout, ansi, issue, open, close, edit, resolve }; │ │ 98 + const PostRoute = enum { login, logout, ansi, issue, comment, open, close, edit, resolve };│ │ 99 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { │ │100 const suffix = "/" ++ field.name; │ │101 if (std.mem.endsWith(u8, path, suffix)) { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │105 .logout => handleLogout(request, base, remote.session_store), │ │106 .ansi => handleAnsi(io, request, allocator, base, remote.admin_repo_path, remote.session_store),│ │107 .issue => handleIssue(io, request, allocator, base, host), │ │108 + .comment => handleComment(io, request, allocator, base, host), │ │109 .open => handleIssueStatus(io, request, allocator, base, host, .open), │ │110 .close => handleIssueStatus(io, request, allocator, base, host, .closed), │ │111 .edit => handleIssueEdit(io, request, allocator, base, host), │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │115 } │ │116 }, │ │117 .local => { │ │117 - const PostRoute = enum { issue, open, close, edit, resolve }; │ │118 + const PostRoute = enum { issue, comment, open, close, edit, resolve };│ │119 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { │ │120 const suffix = "/" ++ field.name; │ │121 if (std.mem.endsWith(u8, path, suffix)) { │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │122 const base = path[0 .. path.len - suffix.len]; │ │123 return switch (@field(PostRoute, field.name)) { │ │124 .issue => handleIssue(io, request, allocator, base, host), │ │125 + .comment => handleComment(io, request, allocator, base, host), │ │126 .open => handleIssueStatus(io, request, allocator, base, host, .open), │ │127 .close => handleIssueStatus(io, request, allocator, base, host, .closed),│ │128 .edit => handleIssueEdit(io, request, allocator, base, host), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │504 } │ │505 │ │506 const location = try std.fmt.allocPrint(allocator, "{s}/issue:{s}", .{ base, &event_id_hex }); │ │507 + defer allocator.free(location); │ │508 + try request.respond("", .{ │ │509 + .status = .see_other, │ │510 + .extra_headers = &.{.{ .name = "location", .value = location }}, │ │511 + }); │ │512 +} │ │513 + │ │514 +const CommentBaseParts = struct { │ │515 + repo_base: []const u8, │ │516 + thread_id: [evt.event_id_size]u8, │ │517 + parent_id: [evt.event_id_size]u8, │ │518 +}; │ │519 + │ │520 +fn commentBaseParts(base: []const u8) ?CommentBaseParts { │ │521 + const issue_infix = "/issue:"; │ │522 + const comment_infix = "/comment:"; │ │523 + const id_len = evt.event_id_size * 2; │ │524 + const issue_at = std.mem.lastIndexOf(u8, base, issue_infix) orelse return null; │ │525 + const tail = base[issue_at + issue_infix.len ..]; │ │526 + if (tail.len < id_len) return null; │ │527 + │ │528 + var thread_id: [evt.event_id_size]u8 = undefined; │ │529 + _ = std.fmt.hexToBytes(&thread_id, tail[0..id_len]) catch return null; │ │530 + var parent_id = thread_id; │ │531 + if (tail.len != id_len) { │ │532 + if (tail.len != id_len + comment_infix.len + id_len) return null; │ │533 + if (!std.mem.eql(u8, tail[id_len .. id_len + comment_infix.len], comment_infix)) return null; │ │534 + _ = std.fmt.hexToBytes(&parent_id, tail[id_len + comment_infix.len ..]) catch return null; │ │535 + } │ │536 + return .{ .repo_base = base[0..issue_at], .thread_id = thread_id, .parent_id = parent_id }; │ │537 +} │ │538 + │ │539 +// create a reply from an issue or comment /new form, then redirect to its │ │540 +// permalink. the rest of the url identifies the thread and parent. │ │541 +fn handleComment( │ │542 + io: std.Io, │ │543 + request: *std.http.Server.Request, │ │544 + allocator: std.mem.Allocator, │ │545 + base: []const u8, │ │546 + host: Host, │ │547 +) !void { │ │548 + const not_found = "comment parent not found"; │ │549 + const parts = commentBaseParts(base) orelse { │ │550 + try request.respond(not_found, .{ │ │551 + .status = .not_found, │ │552 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │553 + }); │ │554 + return; │ │555 + }; │ │556 + │ │557 + var author_arena = std.heap.ArenaAllocator.init(allocator); │ │558 + defer author_arena.deinit(); │ │559 + const author_email = (try eventAuthorEmail(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); │ │560 + │ │561 + var body_buf: [256]u8 = undefined; │ │562 + const reader = request.readerExpectNone(&body_buf); │ │563 + const posted = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; │ │564 + defer allocator.free(posted); │ │565 + const body_crlf = (try parseFormField(allocator, posted, "body")) orelse try allocator.dupe(u8, ""); │ │566 + defer allocator.free(body_crlf); │ │567 + const body = try std.mem.replaceOwned(u8, allocator, body_crlf, "\r\n", "\n"); │ │568 + defer allocator.free(body); │ │569 + │ │570 + if (!evt.Comment.fieldsValid(body)) { │ │571 + const form_location = try std.fmt.allocPrint(allocator, "{s}/new", .{base}); │ │572 + defer allocator.free(form_location); │ │573 + try request.respond("", .{ │ │574 + .status = .see_other, │ │575 + .extra_headers = &.{.{ .name = "location", .value = form_location }}, │ │576 + }); │ │577 + return; │ │578 + } │ │579 + │ │580 + var id_bytes: [evt.event_id_size]u8 = undefined; │ │581 + io.random(&id_bytes); │ │582 + const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); │ │583 + const thread_id_hex = std.fmt.bytesToHex(parts.thread_id, .lower); │ │584 + const parent_id_hex = std.fmt.bytesToHex(parts.parent_id, .lower); │ │585 + const event = evt.EventWithId{ │ │586 + .id = event_id_hex, │ │587 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │588 + .author_email = author_email, │ │589 + .event = .{ .comment = .{ │ │590 + .thread_id = thread_id_hex, │ │591 + .parent_id = parent_id_hex, │ │592 + .body = body, │ │593 + } }, │ │594 + }; │ │595 + │ │596 + switch (host) { │ │597 + .remote => |remote| { │ │598 + const repo_prefix = "/repo/"; │ │599 + if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) { │ │600 + try request.respond(not_found, .{ │ │601 + .status = .not_found, │ │602 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │603 + }); │ │604 + return; │ │605 + } │ │606 + const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); │ │607 + defer allocator.free(repos_dir); │ │608 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) {│ │609 + .ok => |p| p, │ │610 + .invalid, .not_found => { │ │611 + try request.respond(not_found, .{ │ │612 + .status = .not_found, │ │613 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │614 + }); │ │615 + return; │ │616 + }, │ │617 + }; │ │618 + defer allocator.free(repo_path); │ │619 + │ │620 + var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); │ │621 + defer repo.deinit(io, allocator); │ │622 + try evt.consume(.xit, .{}, io, allocator, &repo, evt.events_ref, &.{event}); │ │623 + }, │ │624 + .local => |src| { │ │625 + if (parts.repo_base.len != 0) { │ │626 + try request.respond(not_found, .{ │ │627 + .status = .not_found, │ │628 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │629 + }); │ │630 + return; │ │631 + } │ │632 + switch (src.repo_kind) { │ │633 + inline else => |repo_kind| { │ │634 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); │ │635 + defer any_repo.deinit(io, allocator); │ │636 + switch (any_repo) { │ │637 + inline else => |*repo| try evt.consume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}), │ │638 + } │ │639 + }, │ │640 + } │ │641 + }, │ │642 + } │ │643 + │ │644 + const location = try std.fmt.allocPrint(allocator, "{s}/issue:{s}/comment:{s}", .{ parts.repo_base, &thread_id_hex, &event_id_hex }); │ │645 defer allocator.free(location); │ │646 try request.respond("", .{ │ │647 .status = .see_other, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯