╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭──────────────────────────────────────────────────────╮ │2 const xit = @import("xit"); │ │3 const rp = xit.repo; │ │4 const evt = @import("./event.zig"); │ │5 +const serve_common = @import("./serve_common.zig");│ │6 const ui = @import("./ui.zig"); │ │7 const xitui = xit.xitui; │ │8 const wgt = xitui.widget; │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │92 if (method == .POST) { │ │93 switch (host) { │ │94 .remote => |remote| { │ │94 - const PostRoute = enum { login, logout, ansi }; │ │95 + const PostRoute = enum { login, logout, ansi, issue }; │ │96 inline for (@typeInfo(PostRoute).@"enum".fields) |field| {│ │97 const suffix = "/" ++ field.name; │ │98 if (std.mem.endsWith(u8, path, suffix)) { │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │101 .login => handleLogin(io, request, allocator, base, remote.admin_repo_path, remote.session_store),│ │102 .logout => handleLogout(request, base, remote.session_store), │ │103 .ansi => handleAnsi(io, request, allocator, base, remote.admin_repo_path, remote.session_store), │ │104 + .issue => handleIssue(io, request, allocator, base, remote.admin_repo_path), │ │105 }; │ │106 } │ │107 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │197 return; │ │198 }; │ │199 │ │200 + // the embeds change with every build, so the browser must revalidate │ │201 + // rather than heuristically cache them across server restarts │ │202 try request.respond(embed.body, .{ │ │199 - .extra_headers = &.{.{ .name = "content-type", .value = embed.content_type }},│ │203 + .extra_headers = &.{ │ │204 + .{ .name = "content-type", .value = embed.content_type }, │ │205 + .{ .name = "cache-control", .value = "no-cache" }, │ │206 + }, │ │207 }); │ │208 } │ │209 │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │328 }, │ │329 }); │ │330 } │ │331 + │ │332 +// create an issue in the repo the form's page names (base is │ │333 +// "/repo/<owner>/<name>") and redirect to it │ │334 +fn handleIssue( │ │335 + io: std.Io, │ │336 + request: *std.http.Server.Request, │ │337 + allocator: std.mem.Allocator, │ │338 + base: []const u8, │ │339 + admin_repo_path: []const u8, │ │340 +) !void { │ │341 + var body_buf: [256]u8 = undefined; │ │342 + const reader = request.readerExpectNone(&body_buf); │ │343 + const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; │ │344 + defer allocator.free(body); │ │345 + │ │346 + const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); │ │347 + defer allocator.free(title); │ │348 + const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); │ │349 + defer allocator.free(tags); │ │350 + const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); │ │351 + defer allocator.free(description_crlf); │ │352 + // form submission normalizes textarea line breaks to CRLF; store plain │ │353 + // newlines so the text renders the same on every host │ │354 + const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); │ │355 + defer allocator.free(description); │ │356 + │ │357 + // a title is required, and a too-long tag would fail consumption after │ │358 + // the event is already committed; both send the user back to the form │ │359 + const valid = blk: { │ │360 + if (title.len == 0) break :blk false; │ │361 + var tag_iter = evt.Issue.tagIterator(tags); │ │362 + while (tag_iter.next()) |tag| { │ │363 + if (tag.len > evt.Issue.tag_max_len) break :blk false; │ │364 + } │ │365 + break :blk true; │ │366 + }; │ │367 + if (!valid) { │ │368 + const form_location = try std.fmt.allocPrint(allocator, "{s}/issues/new", .{base}); │ │369 + defer allocator.free(form_location); │ │370 + try request.respond("", .{ │ │371 + .status = .see_other, │ │372 + .extra_headers = &.{.{ .name = "location", .value = form_location }}, │ │373 + }); │ │374 + return; │ │375 + } │ │376 │ │377 + const not_found = "repo not found"; │ │378 + const repo_prefix = "/repo/"; │ │379 + if (!std.mem.startsWith(u8, base, repo_prefix)) { │ │380 + try request.respond(not_found, .{ │ │381 + .status = .not_found, │ │382 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │383 + }); │ │384 + return; │ │385 + } │ │386 + │ │387 + const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(admin_repo_path) orelse ".", "repos" }); │ │388 + defer allocator.free(repos_dir); │ │389 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, admin_repo_path, base[repo_prefix.len..], false)) {│ │390 + .ok => |p| p, │ │391 + .invalid, .not_found => { │ │392 + try request.respond(not_found, .{ │ │393 + .status = .not_found, │ │394 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │395 + }); │ │396 + return; │ │397 + }, │ │398 + }; │ │399 + defer allocator.free(repo_path); │ │400 + │ │401 + var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); │ │402 + defer repo.deinit(io, allocator); │ │403 + │ │404 + var id_bytes: [evt.event_id_size]u8 = undefined; │ │405 + io.random(&id_bytes); │ │406 + const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); │ │407 + │ │408 + try evt.commitAndConsume(.xit, .{}, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{.{ │ │409 + .id = event_id_hex, │ │410 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │411 + .event = .{ .issue = .{ │ │412 + .title = title, │ │413 + .description = description, │ │414 + .tags = tags, │ │415 + } }, │ │416 + }}, false); │ │417 + │ │418 + const location = try std.fmt.allocPrint(allocator, "{s}/issues/{s}", .{ base, &event_id_hex }); │ │419 + defer allocator.free(location); │ │420 + try request.respond("", .{ │ │421 + .status = .see_other, │ │422 + .extra_headers = &.{.{ .name = "location", .value = location }}, │ │423 + }); │ │424 +} │ │425 + │ │426 fn renderIndexHtml( │ │427 io: std.Io, │ │428 allocator: std.mem.Allocator, │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │787 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 })); │ │788 try out.appendSlice(allocator, "\">"); │ │789 }, │ │790 + .text_area => { │ │791 + const ti = session.text_inputs.get(inner_id) orelse continue; │ │792 + const inner_left = r.x + 1; │ │793 + const inner_top = r.y + 1; │ │794 + const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; │ │795 + const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; │ │796 + │ │797 + try out.appendSlice(allocator, "<textarea data-focus-id=\""); │ │798 + var id_buf: [32]u8 = undefined; │ │799 + try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); │ │800 + if (ti.options.name.len > 0) { │ │801 + try out.appendSlice(allocator, "\" name=\""); │ │802 + try appendEscapedHtml(allocator, &out, ti.options.name); │ │803 + } │ │804 + // like the input above, no content inside: the browser │ │805 + // tracks the value and the overlay diff must stay stable │ │806 + // across keystrokes │ │807 + try out.appendSlice(allocator, "\" style=\"left:"); │ │808 + var pos_buf: [128]u8 = undefined; │ │809 + 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 }));│ │810 + try out.appendSlice(allocator, "\"></textarea>"); │ │811 + }, │ │812 .custom => |custom| { │ │813 if (std.mem.eql(u8, "submit", custom)) { │ │814 // inside the dashed border the TUI cells draw around the button. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯