╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 const xit = @import("xit"); 4 const rp = xit.repo; 5 const evt = @import("./event.zig"); 6 const serve_common = @import("./serve_common.zig"); 7 const ui = @import("./ui.zig"); 8 const xitui = xit.xitui; 9 const wgt = xitui.widget; 10 const Focus = xitui.focus.Focus; 11 const Grid = xitui.grid.Grid; 12 13 const cookie_name = "haxy_session"; 14 // the session cookie a login or a claimed auto-login sets; both must scope 15 // it the same way. 16 const session_cookie_fmt = cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict"; 17 // flash cookie for surfacing the outcome of the most recent /login POST. 18 // set on the failure redirect, read and immediately expired on the next 19 // GET / so refreshing the page doesn't keep showing the error. 20 const login_failure_cookie = "haxy_login_failure"; 21 const sync_failure_cookie = "haxy_sync_failure"; 22 23 const Embed = struct { 24 path: []const u8, 25 content_type: []const u8, 26 body: []const u8, 27 }; 28 29 const embeds = [_]Embed{ 30 .{ .path = "index.html", .content_type = "text/html; charset=utf-8", .body = @embedFile("embed/index.html") }, 31 .{ .path = "script.js", .content_type = "text/javascript; charset=utf-8", .body = @embedFile("embed/script.js") }, 32 .{ .path = "term.ttf", .content_type = "font/ttf", .body = @embedFile("embed/term.ttf") }, 33 .{ .path = "haxy.wasm", .content_type = "application/wasm", .body = @embedFile("haxy.wasm") }, 34 }; 35 36 // what a web request is served from: the multi-user server (admin repo + 37 // login sessions) or a single local repo. 38 pub const Host = union(enum) { 39 remote: struct { 40 admin_repo_path: []const u8, 41 session_store: SessionStore, 42 clone_http_port: ?u16, 43 clone_ssh_port: ?u16, 44 }, 45 local: ui.RepoSource, 46 }; 47 48 pub fn handleConnection( 49 io: std.Io, 50 allocator: std.mem.Allocator, 51 stream: std.Io.net.Stream, 52 host: Host, 53 err: *std.Io.Writer, 54 ) !void { 55 var send_buffer = [_]u8{0} ** 4096; 56 var recv_buffer = [_]u8{0} ** 4096; 57 var conn_br = stream.reader(io, &recv_buffer); 58 var conn_bw = stream.writer(io, &send_buffer); 59 var http_server = std.http.Server.init(&conn_br.interface, &conn_bw.interface); 60 61 // serve multiple requests on a single connection so HTTP/1.1 keep-alive 62 // works — important for browser-native form POST: after the 303 the 63 // browser issues GET / on the same socket, and forcibly closing here 64 // would race the follow-up request into a "site can't be reached". 65 while (http_server.reader.state == .ready) { 66 var request = http_server.receiveHead() catch |receive_err| switch (receive_err) { 67 error.HttpConnectionClosing => break, 68 error.ReadFailed => break, 69 else => |e| return e, 70 }; 71 72 handleRequest(io, &request, allocator, host) catch |request_err| { 73 serve_common.logError(io, err, "web ui request failed: {s}\n", .{@errorName(request_err)}); 74 // best-effort 500. if handleRequest already started writing a 75 // response before throwing, this respond may fail too, but at 76 // that point the connection is unrecoverable anyway. 77 request.respond(@errorName(request_err), .{ 78 .status = .internal_server_error, 79 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 80 }) catch {}; 81 }; 82 } 83 } 84 85 fn handleRequest( 86 io: std.Io, 87 request: *std.http.Server.Request, 88 allocator: std.mem.Allocator, 89 host: Host, 90 ) !void { 91 const method = request.head.method; 92 const uri = try std.Uri.parseAfterScheme("", request.head.target); 93 const path = uri.path.percent_encoded; 94 95 // POST routes can be scoped by any page, so they can redirect using 96 // the base of the URL. this allows logging in to keep you on the page 97 // you were on. local mode has no accounts, so only the repo routes. 98 if (method == .POST) { 99 switch (host) { 100 .remote => |remote| { 101 const PostRoute = enum { login, logout, ansi, new, edit, open, close, resolve }; 102 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { 103 const suffix = "/" ++ field.name; 104 if (std.mem.endsWith(u8, path, suffix)) { 105 const base = path[0 .. path.len - suffix.len]; 106 return switch (@field(PostRoute, field.name)) { 107 .login => handleLogin(io, request, allocator, base, remote.admin_repo_path, remote.session_store), 108 .logout => handleLogout(request, base, remote.session_store), 109 .ansi => handleAnsi(io, request, allocator, base, remote.admin_repo_path, remote.session_store), 110 .new => handleNew(io, request, allocator, base, host), 111 .edit => handleEdit(io, request, allocator, base, host), 112 .open => handleIssueStatus(io, request, allocator, base, host, .open), 113 .close => handleIssueStatus(io, request, allocator, base, host, .closed), 114 .resolve => handleIssueResolve(io, request, allocator, base, host), 115 }; 116 } 117 } 118 }, 119 .local => |local| { 120 const PostRoute = enum { new, edit, open, close, resolve, sync }; 121 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { 122 const suffix = "/" ++ field.name; 123 if (std.mem.endsWith(u8, path, suffix)) { 124 const base = path[0 .. path.len - suffix.len]; 125 return switch (@field(PostRoute, field.name)) { 126 .new => handleNew(io, request, allocator, base, host), 127 .edit => handleEdit(io, request, allocator, base, host), 128 .open => handleIssueStatus(io, request, allocator, base, host, .open), 129 .close => handleIssueStatus(io, request, allocator, base, host, .closed), 130 .resolve => handleIssueResolve(io, request, allocator, base, host), 131 .sync => handleSync(io, request, allocator, base, local), 132 }; 133 } 134 } 135 }, 136 } 137 } 138 139 const get_or_head = method == .GET or method == .HEAD; 140 if (!get_or_head) { 141 try request.respond("method not allowed", .{ 142 .status = .method_not_allowed, 143 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 144 }); 145 return; 146 } 147 148 if (std.mem.eql(u8, path, "/favicon.ico")) { 149 try request.respond("", .{ 150 .status = .no_content, 151 .extra_headers = &.{.{ .name = "content-type", .value = "image/x-icon" }}, 152 }); 153 return; 154 } 155 156 const current_page_maybe = switch (host) { 157 .remote => ui.RoutablePage.fromUrl(path), 158 .local => ui.RoutablePage.fromUrlLocal(path), 159 }; 160 if (current_page_maybe) |current_page| { 161 // resolve the haxy_session cookie's token to a user_id via the 162 // store. user_id_buf lives on the stack for the rest of handleRequest, 163 // which is plenty for renderIndexHtml to consume. local mode has no 164 // accounts, so it is always logged out. 165 var user_id_buf: [evt.event_id_size]u8 = undefined; 166 var user_id: ?[]const u8 = null; 167 var login_failure: ?ui.Home.Auth.Login.Failure = null; 168 var sync_failure: ?[]const u8 = null; 169 var sync_failure_allocated: ?[]u8 = null; 170 defer if (sync_failure_allocated) |value| allocator.free(value); 171 // backs the cookie a claimed auto-login sets 172 var cookie_buf: [256]u8 = undefined; 173 var session_cookie: ?[]const u8 = null; 174 switch (host) { 175 .remote => |remote| { 176 user_id = blk: { 177 const token = getCookieValue(request, cookie_name) orelse break :blk null; 178 if (!remote.session_store.lookup(token, &user_id_buf)) break :blk null; 179 break :blk user_id_buf[0..evt.event_id_size]; 180 }; 181 // whoever seeded the store may have left a session to claim 182 if (user_id == null) { 183 var token: [SessionStore.token_hex_len]u8 = undefined; 184 if (remote.session_store.claimAutoLogin(&token) and remote.session_store.lookup(&token, &user_id_buf)) { 185 session_cookie = try std.fmt.bufPrint(&cookie_buf, session_cookie_fmt, .{token}); 186 user_id = user_id_buf[0..evt.event_id_size]; 187 } 188 } 189 login_failure = if (getCookieValue(request, login_failure_cookie)) |raw| 190 if (std.mem.eql(u8, raw, "unknown_user")) 191 .unknown_user 192 else if (std.mem.eql(u8, raw, "wrong_password")) 193 .wrong_password 194 else 195 null 196 else 197 null; 198 }, 199 .local => if (getCookieValue(request, sync_failure_cookie)) |raw| { 200 const value = try allocator.dupe(u8, raw); 201 sync_failure_allocated = value; 202 sync_failure = std.Uri.percentDecodeInPlace(value); 203 }, 204 } 205 206 const clone_http_port: ?u16, const clone_ssh_port: ?u16 = switch (host) { 207 .remote => |remote| .{ remote.clone_http_port, remote.clone_ssh_port }, 208 .local => .{ null, null }, 209 }; 210 const html = renderIndexHtml(io, allocator, host, .{ 211 .user_id = user_id, 212 .login_failure = login_failure, 213 .sync_failure = sync_failure, 214 .current_page = current_page, 215 .is_local = host == .local, 216 .clone_http_port = clone_http_port, 217 .clone_ssh_port = clone_ssh_port, 218 }) catch |err| switch (err) { 219 error.NotFound => { 220 try request.respond("not found", .{ 221 .status = .not_found, 222 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 223 }); 224 return; 225 }, 226 else => |e| return e, 227 }; 228 defer allocator.free(html); 229 230 var header_buf: [4]std.http.Header = undefined; 231 var headers: std.ArrayList(std.http.Header) = .initBuffer(&header_buf); 232 headers.appendAssumeCapacity(.{ .name = "content-type", .value = "text/html; charset=utf-8" }); 233 // expire the flash cookie on the way out so a refresh doesn't keep 234 // showing the failure label 235 if (login_failure != null) headers.appendAssumeCapacity(.{ .name = "set-cookie", .value = login_failure_cookie ++ "=; Path=/; Max-Age=0" }); 236 if (sync_failure != null) headers.appendAssumeCapacity(.{ .name = "set-cookie", .value = sync_failure_cookie ++ "=; Path=/; Max-Age=0" }); 237 if (session_cookie) |cookie| headers.appendAssumeCapacity(.{ .name = "set-cookie", .value = cookie }); 238 try request.respond(html, .{ .extra_headers = headers.items }); 239 return; 240 } 241 242 const embed = findEmbed(path) orelse { 243 try request.respond("not found", .{ 244 .status = .not_found, 245 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 246 }); 247 return; 248 }; 249 250 // the embeds change with every build, so the browser must revalidate 251 // rather than heuristically cache them across server restarts 252 try request.respond(embed.body, .{ 253 .extra_headers = &.{ 254 .{ .name = "content-type", .value = embed.content_type }, 255 .{ .name = "cache-control", .value = "no-cache" }, 256 }, 257 }); 258 } 259 260 fn handleLogin( 261 io: std.Io, 262 request: *std.http.Server.Request, 263 allocator: std.mem.Allocator, 264 base: []const u8, 265 admin_repo_path: []const u8, 266 session_store: SessionStore, 267 ) !void { 268 var body_buf: [256]u8 = undefined; 269 const reader = request.readerExpectNone(&body_buf); 270 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 271 defer allocator.free(body); 272 273 const username = (try parseFormField(allocator, body, "username")) orelse try allocator.dupe(u8, ""); 274 defer allocator.free(username); 275 const password = (try parseFormField(allocator, body, "password")) orelse try allocator.dupe(u8, ""); 276 defer allocator.free(password); 277 278 const Repo = rp.Repo(.xit, evt.admin_repo_opts); 279 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 280 defer repo.deinit(io, allocator); 281 282 var arena = std.heap.ArenaAllocator.init(allocator); 283 defer arena.deinit(); 284 285 const haxy_moment = try evt.currentMoment(evt.admin_repo_opts, &repo); 286 const result = try evt.User.verifyCredentials(evt.AdminDB, evt.admin_repo_opts.hash, haxy_moment, &arena, username, password); 287 288 // on success, return to the page the login came from (base, or "/" at the 289 // root); on failure, stay on its auth tab to surface the error. 290 const success_location: []const u8 = if (base.len == 0) "/" else base; 291 const failure_location = try std.fmt.allocPrint(arena.allocator(), "{s}/auth", .{base}); 292 293 switch (result) { 294 .success => |user_id| { 295 const token = try session_store.create(&user_id); 296 var cookie_buf: [256]u8 = undefined; 297 const cookie = try std.fmt.bufPrint(&cookie_buf, session_cookie_fmt, .{token}); 298 try request.respond("", .{ 299 .status = .see_other, 300 .extra_headers = &.{ 301 .{ .name = "location", .value = success_location }, 302 .{ .name = "set-cookie", .value = cookie }, 303 }, 304 }); 305 }, 306 .unknown_user => { 307 try request.respond("", .{ 308 .status = .see_other, 309 .extra_headers = &.{ 310 .{ .name = "location", .value = failure_location }, 311 .{ .name = "set-cookie", .value = login_failure_cookie ++ "=unknown_user; Path=/; HttpOnly; SameSite=Strict" }, 312 }, 313 }); 314 }, 315 .wrong_password => { 316 try request.respond("", .{ 317 .status = .see_other, 318 .extra_headers = &.{ 319 .{ .name = "location", .value = failure_location }, 320 .{ .name = "set-cookie", .value = login_failure_cookie ++ "=wrong_password; Path=/; HttpOnly; SameSite=Strict" }, 321 }, 322 }); 323 }, 324 } 325 } 326 327 fn handleLogout(request: *std.http.Server.Request, base: []const u8, session_store: SessionStore) !void { 328 // revoke the session server-side so the cookie is dead for anyone holding 329 // a copy, not just this browser. 330 if (getCookieValue(request, cookie_name)) |token| session_store.remove(token); 331 // return to the page the logout came from (base, or "/" at the root). 332 const location: []const u8 = if (base.len == 0) "/" else base; 333 // close the connection instead of keeping it alive: we don't read the 334 // request body, and respond()'s keep-alive path would otherwise try to 335 // discard it — which asserts on a bodyless POST that carries no 336 // content-length, and would block on one with no framing at all. a logout 337 // is just a redirect, so the browser reconnects for the follow-up GET. 338 try request.respond("", .{ 339 .status = .see_other, 340 .keep_alive = false, 341 .extra_headers = &.{ 342 .{ .name = "location", .value = location }, 343 .{ .name = "set-cookie", .value = cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" }, 344 }, 345 }); 346 } 347 348 fn handleAnsi( 349 io: std.Io, 350 request: *std.http.Server.Request, 351 allocator: std.mem.Allocator, 352 base: []const u8, 353 admin_repo_path: []const u8, 354 session_store: SessionStore, 355 ) !void { 356 // the toggle re-emits the user's own event, so it takes a logged-in user 357 var user_id: [evt.event_id_size]u8 = undefined; 358 const token = getCookieValue(request, cookie_name) orelse return respondLoginRequired(request); 359 if (!session_store.lookup(token, &user_id)) return respondLoginRequired(request); 360 361 { 362 const Repo = rp.Repo(.xit, evt.admin_repo_opts); 363 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 364 defer repo.deinit(io, allocator); 365 366 try evt.User.toggleAnsi(evt.admin_repo_opts, io, allocator, &repo, &user_id); 367 } 368 369 // return to the settings tab the toggle came from so the change is visible. 370 const location = try std.fmt.allocPrint(allocator, "{s}/settings", .{base}); 371 defer allocator.free(location); 372 373 // like logout, this is a bodyless POST, so close the connection rather than 374 // letting the keep-alive path try to discard a body that isn't framed. 375 try request.respond("", .{ 376 .status = .see_other, 377 .keep_alive = false, 378 .extra_headers = &.{ 379 .{ .name = "location", .value = location }, 380 }, 381 }); 382 } 383 384 // the author for an event created by this request, or null when the request 385 // may not create one. local mode has no accounts, so it authors anonymously. 386 fn eventAuthorEmail( 387 io: std.Io, 388 allocator: std.mem.Allocator, 389 arena: *std.heap.ArenaAllocator, 390 request: *std.http.Server.Request, 391 host: Host, 392 ) !?[]const u8 { 393 const remote = switch (host) { 394 .remote => |remote| remote, 395 .local => return "user@haxy", // TODO: try the repo's git config user.email first 396 }; 397 const token = getCookieValue(request, cookie_name) orelse return null; 398 var user_id: [evt.event_id_size]u8 = undefined; 399 if (!remote.session_store.lookup(token, &user_id)) return null; 400 401 var repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 402 defer repo.deinit(io, allocator); 403 const moment = try evt.currentMoment(evt.admin_repo_opts, &repo); 404 const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, &user_id)) orelse return null; 405 return user.event.email; 406 } 407 408 // refuse a write from a request with no logged-in user. the body goes unread, 409 // so the connection closes. 410 fn respondLoginRequired(request: *std.http.Server.Request) !void { 411 try request.respond("log in to make changes", .{ 412 .status = .forbidden, 413 .keep_alive = false, 414 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 415 }); 416 } 417 418 // new actions share a suffix; the base identifies whether the form creates an 419 // issue or comment. 420 fn handleNew( 421 io: std.Io, 422 request: *std.http.Server.Request, 423 allocator: std.mem.Allocator, 424 base: []const u8, 425 host: Host, 426 ) !void { 427 if (commentBaseParts(base) != null) return handleCommentNew(io, request, allocator, base, host); 428 429 const issues_suffix = "/issues"; 430 if (std.mem.endsWith(u8, base, issues_suffix)) 431 return handleIssueNew(io, request, allocator, base[0 .. base.len - issues_suffix.len], host); 432 433 try request.respond("new event target not found", .{ 434 .status = .not_found, 435 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 436 }); 437 } 438 439 // create an issue in the repo the form's page names and redirect to it. 440 fn handleIssueNew( 441 io: std.Io, 442 request: *std.http.Server.Request, 443 allocator: std.mem.Allocator, 444 base: []const u8, 445 host: Host, 446 ) !void { 447 var author_arena = std.heap.ArenaAllocator.init(allocator); 448 defer author_arena.deinit(); 449 const author_email = (try eventAuthorEmail(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 450 451 var body_buf: [256]u8 = undefined; 452 const reader = request.readerExpectNone(&body_buf); 453 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 454 defer allocator.free(body); 455 456 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 457 defer allocator.free(title); 458 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 459 defer allocator.free(tags); 460 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 461 defer allocator.free(description_crlf); 462 // form submission normalizes textarea line breaks to CRLF; store plain 463 // newlines so the text renders the same on every host 464 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 465 defer allocator.free(description); 466 467 if (!evt.Issue.fieldsValid(title, tags)) { 468 const form_location = try std.fmt.allocPrint(allocator, "{s}/issues/new", .{base}); 469 defer allocator.free(form_location); 470 try request.respond("", .{ 471 .status = .see_other, 472 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 473 }); 474 return; 475 } 476 477 var id_bytes: [evt.event_id_size]u8 = undefined; 478 io.random(&id_bytes); 479 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 480 481 const event = evt.EventWithId{ 482 .id = event_id_hex, 483 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 484 .author_email = author_email, 485 .event = .{ .issue = .{ 486 .title = title, 487 .description = description, 488 .tags = tags, 489 } }, 490 }; 491 492 const not_found = "repo not found"; 493 switch (host) { 494 .remote => |remote| { 495 const repo_prefix = "/repo/"; 496 if (!std.mem.startsWith(u8, base, repo_prefix)) { 497 try request.respond(not_found, .{ 498 .status = .not_found, 499 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 500 }); 501 return; 502 } 503 504 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 505 defer allocator.free(repos_dir); 506 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, base[repo_prefix.len..], false)) { 507 .ok => |p| p, 508 .invalid, .not_found => { 509 try request.respond(not_found, .{ 510 .status = .not_found, 511 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 512 }); 513 return; 514 }, 515 }; 516 defer allocator.free(repo_path); 517 518 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 519 defer repo.deinit(io, allocator); 520 try evt.consume(.xit, .{}, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{event}); 521 }, 522 .local => |src| { 523 // local routes elide the repo base 524 if (base.len != 0) { 525 try request.respond(not_found, .{ 526 .status = .not_found, 527 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 528 }); 529 return; 530 } 531 switch (src.repo_kind) { 532 inline else => |repo_kind| { 533 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 534 defer any_repo.deinit(io, allocator); 535 switch (any_repo) { 536 inline else => |*repo| try evt.consume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{event}), 537 } 538 }, 539 } 540 }, 541 } 542 543 const location = try std.fmt.allocPrint(allocator, "{s}/issue:{s}", .{ base, &event_id_hex }); 544 defer allocator.free(location); 545 try request.respond("", .{ 546 .status = .see_other, 547 .extra_headers = &.{.{ .name = "location", .value = location }}, 548 }); 549 } 550 551 const CommentBaseParts = struct { 552 repo_base: []const u8, 553 thread_id: [evt.event_id_size]u8, 554 comment_id: ?[evt.event_id_size]u8, 555 }; 556 557 fn commentBaseParts(base: []const u8) ?CommentBaseParts { 558 const issue_infix = "/issue:"; 559 const comment_infix = "/comment:"; 560 const id_len = evt.event_id_size * 2; 561 const issue_at = std.mem.lastIndexOf(u8, base, issue_infix) orelse return null; 562 const tail = base[issue_at + issue_infix.len ..]; 563 if (tail.len < id_len) return null; 564 565 var thread_id: [evt.event_id_size]u8 = undefined; 566 _ = std.fmt.hexToBytes(&thread_id, tail[0..id_len]) catch return null; 567 var comment_id: ?[evt.event_id_size]u8 = null; 568 if (tail.len != id_len) { 569 if (tail.len != id_len + comment_infix.len + id_len) return null; 570 if (!std.mem.eql(u8, tail[id_len .. id_len + comment_infix.len], comment_infix)) return null; 571 var id: [evt.event_id_size]u8 = undefined; 572 _ = std.fmt.hexToBytes(&id, tail[id_len + comment_infix.len ..]) catch return null; 573 comment_id = id; 574 } 575 return .{ .repo_base = base[0..issue_at], .thread_id = thread_id, .comment_id = comment_id }; 576 } 577 578 // create a reply from an issue or comment /new form, then redirect to its 579 // permalink. the rest of the url identifies the thread and parent. 580 fn handleCommentNew( 581 io: std.Io, 582 request: *std.http.Server.Request, 583 allocator: std.mem.Allocator, 584 base: []const u8, 585 host: Host, 586 ) !void { 587 const not_found = "comment parent not found"; 588 const parts = commentBaseParts(base) orelse { 589 try request.respond(not_found, .{ 590 .status = .not_found, 591 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 592 }); 593 return; 594 }; 595 596 var author_arena = std.heap.ArenaAllocator.init(allocator); 597 defer author_arena.deinit(); 598 const author_email = (try eventAuthorEmail(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 599 600 var body_buf: [256]u8 = undefined; 601 const reader = request.readerExpectNone(&body_buf); 602 const posted = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 603 defer allocator.free(posted); 604 const body_crlf = (try parseFormField(allocator, posted, "body")) orelse try allocator.dupe(u8, ""); 605 defer allocator.free(body_crlf); 606 const body = try std.mem.replaceOwned(u8, allocator, body_crlf, "\r\n", "\n"); 607 defer allocator.free(body); 608 609 if (!evt.Comment.fieldsValid(body)) { 610 const form_location = try std.fmt.allocPrint(allocator, "{s}/new", .{base}); 611 defer allocator.free(form_location); 612 try request.respond("", .{ 613 .status = .see_other, 614 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 615 }); 616 return; 617 } 618 619 const thread_id_hex = std.fmt.bytesToHex(parts.thread_id, .lower); 620 const parent_id_hex = std.fmt.bytesToHex(parts.comment_id orelse parts.thread_id, .lower); 621 622 var event_id_hex: [evt.event_id_size * 2]u8 = undefined; 623 switch (host) { 624 .remote => |remote| { 625 const repo_prefix = "/repo/"; 626 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) { 627 try request.respond(not_found, .{ 628 .status = .not_found, 629 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 630 }); 631 return; 632 } 633 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 634 defer allocator.free(repos_dir); 635 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { 636 .ok => |p| p, 637 .invalid, .not_found => { 638 try request.respond(not_found, .{ 639 .status = .not_found, 640 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 641 }); 642 return; 643 }, 644 }; 645 defer allocator.free(repo_path); 646 647 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 648 defer repo.deinit(io, allocator); 649 event_id_hex = try evt.Comment.create(.xit, .{}, io, allocator, &repo, &thread_id_hex, &parent_id_hex, body, author_email); 650 }, 651 .local => |src| { 652 if (parts.repo_base.len != 0) { 653 try request.respond(not_found, .{ 654 .status = .not_found, 655 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 656 }); 657 return; 658 } 659 switch (src.repo_kind) { 660 inline else => |repo_kind| { 661 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 662 defer any_repo.deinit(io, allocator); 663 switch (any_repo) { 664 inline else => |*repo| event_id_hex = try evt.Comment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id_hex, &parent_id_hex, body, author_email), 665 } 666 }, 667 } 668 }, 669 } 670 671 const location = try std.fmt.allocPrint(allocator, "{s}/issue:{s}/comment:{s}", .{ parts.repo_base, &thread_id_hex, &event_id_hex }); 672 defer allocator.free(location); 673 try request.respond("", .{ 674 .status = .see_other, 675 .extra_headers = &.{.{ .name = "location", .value = location }}, 676 }); 677 } 678 679 // re-emit the comment `parts` names with a new body, in the repo the host 680 // resolves the base to. 681 fn updateComment( 682 io: std.Io, 683 allocator: std.mem.Allocator, 684 host: Host, 685 parts: CommentBaseParts, 686 body: []const u8, 687 author_email: []const u8, 688 ) !void { 689 const comment_id = parts.comment_id orelse return error.NotFound; 690 const thread_id = std.fmt.bytesToHex(parts.thread_id, .lower); 691 switch (host) { 692 .remote => |remote| { 693 const repo_prefix = "/repo/"; 694 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return error.NotFound; 695 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 696 defer allocator.free(repos_dir); 697 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { 698 .ok => |p| p, 699 .invalid, .not_found => return error.NotFound, 700 }; 701 defer allocator.free(repo_path); 702 703 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 704 defer repo.deinit(io, allocator); 705 try evt.Comment.update(.xit, .{}, io, allocator, &repo, &thread_id, &comment_id, body, author_email); 706 }, 707 .local => |src| { 708 if (parts.repo_base.len != 0) return error.NotFound; 709 switch (src.repo_kind) { 710 inline else => |repo_kind| { 711 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 712 defer any_repo.deinit(io, allocator); 713 switch (any_repo) { 714 inline else => |*repo| try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author_email), 715 } 716 }, 717 } 718 }, 719 } 720 } 721 722 // edit actions share a suffix; the base identifies whether the form names an 723 // issue or comment. 724 fn handleEdit( 725 io: std.Io, 726 request: *std.http.Server.Request, 727 allocator: std.mem.Allocator, 728 base: []const u8, 729 host: Host, 730 ) !void { 731 if (commentBaseParts(base)) |parts| { 732 if (parts.comment_id != null) return handleCommentEdit(io, request, allocator, base, host, parts); 733 } 734 return handleIssueEdit(io, request, allocator, base, host); 735 } 736 737 // replace the body of the comment the url names, then redirect to its 738 // permalink. 739 fn handleCommentEdit( 740 io: std.Io, 741 request: *std.http.Server.Request, 742 allocator: std.mem.Allocator, 743 base: []const u8, 744 host: Host, 745 parts: CommentBaseParts, 746 ) !void { 747 var author_arena = std.heap.ArenaAllocator.init(allocator); 748 defer author_arena.deinit(); 749 const author_email = (try eventAuthorEmail(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 750 751 var body_buf: [256]u8 = undefined; 752 const reader = request.readerExpectNone(&body_buf); 753 const posted = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 754 defer allocator.free(posted); 755 const body_crlf = (try parseFormField(allocator, posted, "body")) orelse try allocator.dupe(u8, ""); 756 defer allocator.free(body_crlf); 757 const body = try std.mem.replaceOwned(u8, allocator, body_crlf, "\r\n", "\n"); 758 defer allocator.free(body); 759 760 if (!evt.Comment.fieldsValid(body)) { 761 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 762 defer allocator.free(form_location); 763 try request.respond("", .{ 764 .status = .see_other, 765 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 766 }); 767 return; 768 } 769 770 const not_found = "comment not found"; 771 updateComment(io, allocator, host, parts, body, author_email) catch |err| switch (err) { 772 error.NotFound => { 773 try request.respond(not_found, .{ 774 .status = .not_found, 775 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 776 }); 777 return; 778 }, 779 else => |e| return e, 780 }; 781 782 try request.respond("", .{ 783 .status = .see_other, 784 .extra_headers = &.{.{ .name = "location", .value = base }}, 785 }); 786 } 787 788 // split an issue url ("/repo/<owner>/<name>/issue:<id>", identity elided in 789 // local mode) into what precedes "/issue:" and the decoded id, null when it 790 // isn't one. the repo base is validated per host in updateIssue. 791 const IssueBaseParts = struct { repo_base: []const u8, id_bytes: [evt.event_id_size]u8 }; 792 fn issueBaseParts(base: []const u8) ?IssueBaseParts { 793 const issues_infix = "/issue:"; 794 const id_len = evt.event_id_size * 2; 795 if (base.len < issues_infix.len + id_len) return null; 796 var id_bytes: [evt.event_id_size]u8 = undefined; 797 _ = std.fmt.hexToBytes(&id_bytes, base[base.len - id_len ..]) catch return null; 798 const head = base[0 .. base.len - id_len]; 799 if (!std.mem.endsWith(u8, head, issues_infix)) return null; 800 return .{ .repo_base = head[0 .. head.len - issues_infix.len], .id_bytes = id_bytes }; 801 } 802 803 // re-emit the issue `parts` names with `update` applied, in the repo the 804 // host resolves the base to. error.NotFound when the base names no repo or 805 // the id no issue. 806 fn updateIssue( 807 io: std.Io, 808 allocator: std.mem.Allocator, 809 host: Host, 810 parts: IssueBaseParts, 811 update: evt.Issue.Update, 812 author_email: []const u8, 813 ) !void { 814 switch (host) { 815 .remote => |remote| { 816 const repo_prefix = "/repo/"; 817 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return error.NotFound; 818 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 819 defer allocator.free(repos_dir); 820 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { 821 .ok => |p| p, 822 .invalid, .not_found => return error.NotFound, 823 }; 824 defer allocator.free(repo_path); 825 826 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 827 defer repo.deinit(io, allocator); 828 try evt.Issue.update(.xit, .{}, io, allocator, &repo, &parts.id_bytes, update, author_email); 829 }, 830 .local => |src| { 831 // the local forms post to "/issue:<id>/...", so the repo base 832 // is empty 833 if (parts.repo_base.len != 0) return error.NotFound; 834 switch (src.repo_kind) { 835 inline else => |repo_kind| { 836 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 837 defer any_repo.deinit(io, allocator); 838 switch (any_repo) { 839 inline else => |*repo| try evt.Issue.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &parts.id_bytes, update, author_email), 840 } 841 }, 842 } 843 }, 844 } 845 } 846 847 // set the status of the issue the url names (base is 848 // "/repo/<owner>/<name>/issue:<id>", identity elided in local mode) by 849 // re-emitting its event, then redirect back to it 850 fn handleIssueStatus( 851 io: std.Io, 852 request: *std.http.Server.Request, 853 allocator: std.mem.Allocator, 854 base: []const u8, 855 host: Host, 856 status: evt.Issue.Status, 857 ) !void { 858 const not_found = "issue not found"; 859 const parts = issueBaseParts(base) orelse { 860 try request.respond(not_found, .{ 861 .status = .not_found, 862 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 863 }); 864 return; 865 }; 866 867 var author_arena = std.heap.ArenaAllocator.init(allocator); 868 defer author_arena.deinit(); 869 const author_email = (try eventAuthorEmail(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 870 871 updateIssue(io, allocator, host, parts, .{ .status = status }, author_email) catch |err| switch (err) { 872 error.NotFound => { 873 try request.respond(not_found, .{ 874 .status = .not_found, 875 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 876 }); 877 return; 878 }, 879 else => |e| return e, 880 }; 881 882 // like logout, this is a bodyless POST, so close the connection rather than 883 // letting the keep-alive path try to discard a body that isn't framed. 884 try request.respond("", .{ 885 .status = .see_other, 886 .keep_alive = false, 887 .extra_headers = &.{.{ .name = "location", .value = base }}, 888 }); 889 } 890 891 // replace the title/tags/description of the issue the url names (base is 892 // "/repo/<owner>/<name>/issue:<id>", identity elided in local mode; the 893 // form posts to "<base>/edit") by re-emitting its event with its status 894 // preserved, then redirect back to it 895 fn handleIssueEdit( 896 io: std.Io, 897 request: *std.http.Server.Request, 898 allocator: std.mem.Allocator, 899 base: []const u8, 900 host: Host, 901 ) !void { 902 var author_arena = std.heap.ArenaAllocator.init(allocator); 903 defer author_arena.deinit(); 904 const author_email = (try eventAuthorEmail(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 905 906 var body_buf: [256]u8 = undefined; 907 const reader = request.readerExpectNone(&body_buf); 908 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 909 defer allocator.free(body); 910 911 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 912 defer allocator.free(title); 913 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 914 defer allocator.free(tags); 915 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 916 defer allocator.free(description_crlf); 917 // form submission normalizes textarea line breaks to CRLF; store plain 918 // newlines so the text renders the same on every host 919 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 920 defer allocator.free(description); 921 922 // invalid fields send the user back to the edit form 923 if (!evt.Issue.fieldsValid(title, tags)) { 924 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 925 defer allocator.free(form_location); 926 try request.respond("", .{ 927 .status = .see_other, 928 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 929 }); 930 return; 931 } 932 933 const not_found = "issue not found"; 934 const parts = issueBaseParts(base) orelse { 935 try request.respond(not_found, .{ 936 .status = .not_found, 937 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 938 }); 939 return; 940 }; 941 942 updateIssue(io, allocator, host, parts, .{ .fields = .{ 943 .title = title, 944 .tags = tags, 945 .description = description, 946 } }, author_email) catch |err| switch (err) { 947 error.NotFound => { 948 try request.respond(not_found, .{ 949 .status = .not_found, 950 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 951 }); 952 return; 953 }, 954 else => |e| return e, 955 }; 956 957 try request.respond("", .{ 958 .status = .see_other, 959 .extra_headers = &.{.{ .name = "location", .value = base }}, 960 }); 961 } 962 963 // resolve the conflict of the issue the url names (base is 964 // "/repo/<owner>/<name>/issue:<id>", identity elided in local mode; the 965 // form posts to "<base>/resolve") 966 fn handleIssueResolve( 967 io: std.Io, 968 request: *std.http.Server.Request, 969 allocator: std.mem.Allocator, 970 base: []const u8, 971 host: Host, 972 ) !void { 973 var author_arena = std.heap.ArenaAllocator.init(allocator); 974 defer author_arena.deinit(); 975 const author_email = (try eventAuthorEmail(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 976 977 var body_buf: [256]u8 = undefined; 978 const reader = request.readerExpectNone(&body_buf); 979 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 980 defer allocator.free(body); 981 982 var field_arena = std.heap.ArenaAllocator.init(allocator); 983 defer field_arena.deinit(); 984 const aa = field_arena.allocator(); 985 const title = try parseFormField(aa, body, "title"); 986 const tags = try parseFormField(aa, body, "tags"); 987 988 // the hunk inputs post as d0, d1, ...; form submission normalizes their 989 // textarea line breaks to CRLF 990 var hunks: std.ArrayList([]const u8) = .empty; 991 var hunk_index: usize = 0; 992 while (true) : (hunk_index += 1) { 993 const name = try std.fmt.allocPrint(aa, "d{d}", .{hunk_index}); 994 const posted = (try parseFormField(aa, body, name)) orelse break; 995 try hunks.append(aa, try std.mem.replaceOwned(u8, aa, posted, "\r\n", "\n")); 996 } 997 998 const not_found = "issue not found"; 999 const parts = issueBaseParts(base) orelse { 1000 try request.respond(not_found, .{ 1001 .status = .not_found, 1002 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1003 }); 1004 return; 1005 }; 1006 1007 updateIssue(io, allocator, host, parts, .{ .resolve = .{ 1008 .title = title, 1009 .tags = tags, 1010 .hunks = hunks.items, 1011 } }, author_email) catch |err| switch (err) { 1012 error.NotFound => { 1013 try request.respond(not_found, .{ 1014 .status = .not_found, 1015 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1016 }); 1017 return; 1018 }, 1019 // invalid fields send the user back to the resolve form 1020 error.InvalidFields => { 1021 const form_location = try std.fmt.allocPrint(allocator, "{s}/resolve", .{base}); 1022 defer allocator.free(form_location); 1023 try request.respond("", .{ 1024 .status = .see_other, 1025 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 1026 }); 1027 return; 1028 }, 1029 else => |e| return e, 1030 }; 1031 1032 try request.respond("", .{ 1033 .status = .see_other, 1034 .extra_headers = &.{.{ .name = "location", .value = base }}, 1035 }); 1036 } 1037 1038 fn handleSync( 1039 io: std.Io, 1040 request: *std.http.Server.Request, 1041 allocator: std.mem.Allocator, 1042 base: []const u8, 1043 source: ui.RepoSource, 1044 ) !void { 1045 if (base.len != 0) { 1046 try request.respond("not found", .{ 1047 .status = .not_found, 1048 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1049 }); 1050 return; 1051 } 1052 1053 var owned_failure: ?[]u8 = null; 1054 defer if (owned_failure) |message| allocator.free(message); 1055 const failure: ?[]const u8 = blk: { 1056 owned_failure = ui.Repo.Events.sync(io, allocator, source) catch |err| break :blk @errorName(err); 1057 break :blk owned_failure; 1058 }; 1059 if (failure) |message| { 1060 var encoded: std.Io.Writer.Allocating = .init(allocator); 1061 defer encoded.deinit(); 1062 try std.Uri.Component.percentEncode(&encoded.writer, message, struct { 1063 fn isUnreserved(c: u8) bool { 1064 return std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~'; 1065 } 1066 }.isUnreserved); 1067 const cookie = try std.fmt.allocPrint( 1068 allocator, 1069 sync_failure_cookie ++ "={s}; Path=/; HttpOnly; SameSite=Strict", 1070 .{encoded.written()}, 1071 ); 1072 defer allocator.free(cookie); 1073 try request.respond("", .{ 1074 .status = .see_other, 1075 .extra_headers = &.{ 1076 .{ .name = "location", .value = "/events" }, 1077 .{ .name = "set-cookie", .value = cookie }, 1078 }, 1079 }); 1080 return; 1081 } 1082 1083 try request.respond("", .{ 1084 .status = .see_other, 1085 .extra_headers = &.{ 1086 .{ .name = "location", .value = "/events" }, 1087 .{ .name = "set-cookie", .value = sync_failure_cookie ++ "=; Path=/; Max-Age=0" }, 1088 }, 1089 }); 1090 } 1091 1092 fn renderIndexHtml( 1093 io: std.Io, 1094 allocator: std.mem.Allocator, 1095 host: Host, 1096 session_data: ui.Session.Data, 1097 ) ![]const u8 { 1098 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 1099 1100 var page_arena = std.heap.ArenaAllocator.init(allocator); 1101 defer page_arena.deinit(); 1102 1103 // the remote admin repo must outlive the page build, since the session's 1104 // moment reads from it. 1105 var repo_maybe: ?rp.Repo(.xit, evt.admin_repo_opts) = null; 1106 defer if (repo_maybe) |*repo| repo.deinit(io, allocator); 1107 1108 var session = switch (host) { 1109 .remote => |remote| blk: { 1110 // open the admin repo to read live user/repo data. 1111 repo_maybe = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 1112 const repo = if (repo_maybe) |*repo| repo else unreachable; 1113 var session = try ui.Session.init(&page_arena, repo, session_data); 1114 // give the page builders filesystem access to the on-disk repos (a 1115 // sibling "repos" dir next to the admin repo) so the Repo page can 1116 // read its files. 1117 session.repos_dir = try std.fs.path.join(page_arena.allocator(), &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 1118 break :blk session; 1119 }, 1120 .local => |local| ui.Session{ 1121 .data = session_data, 1122 .arena = &page_arena, 1123 .page_arena = &page_arena, 1124 .local = local, 1125 }, 1126 }; 1127 session.io = io; 1128 1129 const snapshot: ui.Snapshot = .{ 1130 .page = try ui.Page.init(session.page_arena, &session, session.data.current_page), 1131 .session = session.data, 1132 }; 1133 var root = try ui.initRoot(allocator, &snapshot.page, &session); 1134 defer root.deinit(allocator); 1135 1136 const content = try generateHtml(allocator, &root); 1137 defer allocator.free(content); 1138 const overlay = try generateOverlay(allocator, &root, &session); 1139 defer allocator.free(overlay); 1140 1141 // serialize the snapshot so the wasm side can parse it back without making 1142 // a second request. it's embedded raw in a <script type="application/json"> 1143 // block, whose content is raw text terminated only by "</script". in json, 1144 // '<' appears solely inside string values, so escaping every '<' (and '>' 1145 // for good measure) to its \uXXXX form yields equivalent json that can't 1146 // break out of the tag. 1147 var json: std.Io.Writer.Allocating = .init(allocator); 1148 defer json.deinit(); 1149 try std.json.Stringify.value(snapshot, .{}, &json.writer); 1150 1151 var json_escaped: std.ArrayList(u8) = .empty; 1152 defer json_escaped.deinit(allocator); 1153 for (json.written()) |c| switch (c) { 1154 '<' => try json_escaped.appendSlice(allocator, "\\u003c"), 1155 '>' => try json_escaped.appendSlice(allocator, "\\u003e"), 1156 else => try json_escaped.append(allocator, c), 1157 }; 1158 1159 var out: std.ArrayList(u8) = .empty; 1160 errdefer out.deinit(allocator); 1161 { 1162 var cursor: usize = 0; 1163 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 1164 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 1165 .{ .needle = "{{{ HAXY_OVERLAY }}}", .replacement = overlay }, 1166 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_escaped.items }, 1167 }) |sub| { 1168 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 1169 try out.appendSlice(allocator, template[cursor..idx]); 1170 try out.appendSlice(allocator, sub.replacement); 1171 cursor = idx + sub.needle.len; 1172 } 1173 try out.appendSlice(allocator, template[cursor..]); 1174 } 1175 return try out.toOwnedSlice(allocator); 1176 } 1177 1178 fn findEmbed(request_path: []const u8) ?Embed { 1179 const path = if (std.mem.eql(u8, request_path, "/")) 1180 "index.html" 1181 else if (request_path.len > 1 and request_path[0] == '/') 1182 request_path[1..] 1183 else 1184 return null; 1185 1186 for (embeds) |embed| { 1187 if (std.mem.eql(u8, path, embed.path)) return embed; 1188 } 1189 return null; 1190 } 1191 1192 // emits the TUI grid cells as static HTML. each web-native Scroll becomes its 1193 // own absolutely-positioned, natively-scrollable <div> holding its full content, 1194 // rendered recursively so nested scrolls work. 1195 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { 1196 const grid = root.getGrid() orelse return error.MissingGrid; 1197 1198 var out: std.ArrayList(u8) = .empty; 1199 errdefer out.deinit(allocator); 1200 1201 try renderPanel(allocator, &out, root.getFocus(), grid); 1202 return try out.toOwnedSlice(allocator); 1203 } 1204 1205 // render one panel — a focus subtree and its content grid — as HTML, recursing 1206 // once per web-native Scroll inside it. the root panel is the whole page; each 1207 // Scroll becomes a nested, natively-scrollable div holding its full content. 1208 // emits the panel's grid cells as text, leaving holes where child scrolls sit, 1209 // then the child scroll divs over those holes. 1210 fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid) !void { 1211 // `direct` is this panel's scroll children, each drawn as an overlaid div. 1212 // `excluded` is every focus id that belongs to a scroll (the scroll nodes 1213 // plus their descendants, which the focus tree flattens into this panel's 1214 // children): the cell hit-test below skips them so this panel doesn't claim 1215 // cells that are really rendered inside a child scroll's own panel. 1216 // (scrolls aren't nested in practice, so every scroll child is direct here.) 1217 var direct: std.ArrayList(usize) = .empty; 1218 defer direct.deinit(allocator); 1219 var excluded: std.AutoHashMapUnmanaged(usize, void) = .empty; 1220 defer excluded.deinit(allocator); 1221 { 1222 var it = focus.children.iterator(); 1223 while (it.next()) |e| { 1224 if (e.value_ptr.focus.scroll == null) continue; 1225 try direct.append(allocator, e.key_ptr.*); 1226 try excluded.put(allocator, e.key_ptr.*, {}); 1227 var mit = e.value_ptr.focus.children.iterator(); 1228 while (mit.next()) |m| try excluded.put(allocator, m.key_ptr.*, {}); 1229 } 1230 } 1231 1232 // the HTML element a run of cells is wrapped in, chosen from the cell's 1233 // focusable kind 1234 const CellTag = union(enum) { 1235 span, // clickable focusable cell 1236 a: []const u8, // clickable focusable link 1237 plain, // non-focusable, colored 1238 1239 const a_prefix = "a:"; 1240 // an "in-page" anchor: rendered as a real <a href> so it works with js 1241 // disabled, but crossPageLink ignores it, so with wasm the click just 1242 // moves focus rather than navigating. 1243 const inpage_prefix = "ai:"; 1244 1245 fn init(str: []const u8) @This() { 1246 if (std.mem.startsWith(u8, str, inpage_prefix)) { 1247 return .{ .a = str[inpage_prefix.len..] }; 1248 } 1249 if (std.mem.startsWith(u8, str, a_prefix)) { 1250 return .{ .a = str[a_prefix.len..] }; 1251 } 1252 return .span; 1253 } 1254 1255 fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void { 1256 var id_buf: [32]u8 = undefined; 1257 switch (self) { 1258 .span => { 1259 try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); 1260 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1261 try out.appendSlice(alloc, "\""); 1262 try out.appendSlice(alloc, style_attr); 1263 try out.appendSlice(alloc, ">"); 1264 }, 1265 .a => |href| { 1266 // tabindex=-1 keeps these out of the browser's tab order 1267 try out.appendSlice(alloc, "<a class=\"clickable\" tabindex=\"-1\" data-focus-id=\""); 1268 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1269 try out.appendSlice(alloc, "\" href=\""); 1270 try appendEscapedHtml(alloc, out, href); 1271 try out.appendSlice(alloc, "\""); 1272 try out.appendSlice(alloc, style_attr); 1273 try out.appendSlice(alloc, ">"); 1274 }, 1275 .plain => { 1276 try out.appendSlice(alloc, "<span"); 1277 try out.appendSlice(alloc, style_attr); 1278 try out.appendSlice(alloc, ">"); 1279 }, 1280 } 1281 } 1282 1283 fn closeTag(self: @This()) []const u8 { 1284 return switch (self) { 1285 .span, .plain => "</span>", 1286 .a => "</a>", 1287 }; 1288 } 1289 }; 1290 1291 // emit this panel's grid as rows of text, coalescing adjacent cells that 1292 // share a focus id and colors into one tag (a clickable span, a link, or a 1293 // plain colored span). cells under a child scroll keep only their background. 1294 for (0..grid.size.height) |y| { 1295 var cur_id: ?usize = null; 1296 var cur_fg: ?Grid.Color = null; 1297 var cur_bg: ?Grid.Color = null; 1298 var open_tag: ?CellTag = null; 1299 var first = true; 1300 for (0..grid.size.width) |x| { 1301 const cell = grid.cells.items[try grid.cells.at(.{ y, x })]; 1302 // a cell covered by a child scroll's viewport is drawn by that scroll's 1303 // own div, so blank its glyph and make it non-clickable here — but keep 1304 // its background, so the backdrop still shows through the scroll's 1305 // transparent div instead of a bare hole. 1306 const covered_by_scroll = blk: { 1307 for (direct.items) |id| { 1308 const r = (focus.children.get(id) orelse continue).rect; 1309 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; 1310 } 1311 break :blk false; 1312 }; 1313 // the focusable cell at (x, y) among this panel's own focusables (skipping 1314 // any that belong to a child scroll, whose cells are drawn in their own 1315 // panel); none for a covered cell. 1316 const cell_id = if (covered_by_scroll) null else blk: { 1317 var iter = focus.children.iterator(); 1318 while (iter.next()) |entry| { 1319 const child = entry.value_ptr.*; 1320 if (!child.focus.focusable) continue; 1321 if (excluded.contains(entry.key_ptr.*)) continue; 1322 const r = child.rect; 1323 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 1324 break :blk entry.key_ptr.*; 1325 } 1326 } 1327 break :blk null; 1328 }; 1329 const fg = if (covered_by_scroll) null else cell.style.fg; 1330 const bg = cell.style.bg; 1331 1332 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 1333 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1334 1335 var new_tag: ?CellTag = null; 1336 if (cell_id) |id| { 1337 if (focus.children.get(id)) |child| { 1338 new_tag = switch (child.focus.kind) { 1339 .custom => |custom| CellTag.init(custom), 1340 else => .span, 1341 }; 1342 } 1343 } 1344 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 1345 1346 if (new_tag) |t| { 1347 var style_buf: [64]u8 = undefined; 1348 try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 1349 } 1350 1351 open_tag = new_tag; 1352 cur_id = cell_id; 1353 cur_fg = fg; 1354 cur_bg = bg; 1355 first = false; 1356 } 1357 1358 if (covered_by_scroll) { 1359 try appendEscapedHtml(allocator, output, " "); 1360 } else if (cell.continuation) { 1361 // the wide rune to the left was emitted 2ch wide, covering this column 1362 } else if (cell.rune) |rune| { 1363 // a double-width rune is pinned inside a 2ch span so the 1364 // fallback glyph's advance width can't shift the rest of the row 1365 const wide = x + 1 < grid.size.width and grid.cells.items[try grid.cells.at(.{ y, x + 1 })].continuation; 1366 if (wide) { 1367 try output.appendSlice(allocator, "<span class=\"w2\">"); 1368 try appendEscapedHtml(allocator, output, rune); 1369 try output.appendSlice(allocator, "</span>"); 1370 } else { 1371 try appendEscapedHtml(allocator, output, rune); 1372 } 1373 } else { 1374 try appendEscapedHtml(allocator, output, " "); 1375 } 1376 } 1377 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1378 try output.append(allocator, '\n'); 1379 } 1380 1381 // each child scroll becomes a natively-scrollable div positioned over its 1382 // viewport, holding its full content rendered recursively. 1383 for (direct.items) |id| { 1384 const child = focus.children.get(id) orelse continue; 1385 const info = child.focus.scroll orelse continue; 1386 const r = child.rect; 1387 // overflow only on the axes the widget scrolls; `auto` shows a bar only 1388 // when that axis actually overflows. the other axis is clipped (hidden), 1389 // matching how the terminal Scroll handles its non-scrolling axis. 1390 const overflow = switch (info.direction) { 1391 .vert => "overflow-x:hidden;overflow-y:auto", 1392 .horiz => "overflow-x:auto;overflow-y:hidden", 1393 .both => "overflow:auto", 1394 }; 1395 // the id carries a content version so JS preserves the native scroll 1396 // position across re-renders of the same content but resets it when the 1397 // content is replaced — e.g. selecting a different commit. the widget's 1398 // scroll offset (in cells) rides along so JS can apply it when it has no 1399 // preserved position, letting a wasm-side scrollToRect (the files list 1400 // scrolling to its selected row on page load) reach the browser. 1401 var buf: [224]u8 = undefined; 1402 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 })); 1403 try renderPanel(allocator, output, child.focus, info.content); 1404 try output.appendSlice(allocator, "</div>"); 1405 } 1406 } 1407 1408 // emits the form overlay — one <form> per "form:<url>" focus subtree in the 1409 // widget tree, each wrapping the text inputs and submit button inside it, 1410 // positioned absolutely over the matching grid cells. 1411 pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget, session: *ui.Session) ![]const u8 { 1412 const root_focus = root.getFocus(); 1413 1414 var out: std.ArrayList(u8) = .empty; 1415 errdefer out.deinit(allocator); 1416 1417 const form_prefix = "form:"; 1418 1419 // Focus.children is an AutoArrayHashMap that preserves insertion order, 1420 // which matches the order widgets were added in code 1421 var iter = root_focus.children.iterator(); 1422 while (iter.next()) |entry| { 1423 const child = entry.value_ptr.*; 1424 const action_url = switch (child.focus.kind) { 1425 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) 1426 custom[form_prefix.len..] 1427 else 1428 continue, 1429 else => continue, 1430 }; 1431 1432 try out.appendSlice(allocator, "<form action=\""); 1433 try appendEscapedHtml(allocator, &out, action_url); 1434 try out.appendSlice(allocator, "\" method=\"post\""); 1435 // an empty action must not POST — block it and send the input to the TUI instead 1436 if (action_url.len == 0) try out.appendSlice(allocator, " onsubmit=\"event.preventDefault();sendEnter(this);\""); 1437 try out.appendSlice(allocator, ">"); 1438 1439 // form.focus.children was populated by addChild, which flattens the 1440 // whole subtree into one map — so descendants (inputs + the submit 1441 // button) all appear here. positions on form.focus are relative to 1442 // the form, so we look up absolute rects via root_focus for layout. 1443 var inner_iter = child.focus.children.iterator(); 1444 while (inner_iter.next()) |inner_entry| { 1445 const inner_id = inner_entry.key_ptr.*; 1446 const root_child = root_focus.children.get(inner_id) orelse continue; 1447 const r = root_child.rect; 1448 switch (root_child.focus.kind) { 1449 .text_input, .text_input_password => { 1450 const ti = session.text_inputs.get(inner_id) orelse continue; 1451 const inner_left = r.x + 1; 1452 const inner_top = r.y + 1; 1453 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1454 1455 try out.appendSlice(allocator, "<input type=\""); 1456 try out.appendSlice(allocator, if (root_child.focus.kind == .text_input_password) "password" else "text"); 1457 try out.appendSlice(allocator, if (action_url.len == 0) "\" readonly data-focus-id=\"" else "\" data-focus-id=\""); 1458 var id_buf: [32]u8 = undefined; 1459 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1460 if (ti.options.name.len > 0) { 1461 try out.appendSlice(allocator, "\" name=\""); 1462 try appendEscapedHtml(allocator, &out, ti.options.name); 1463 } 1464 // never the live content as the `value`: the browser tracks user 1465 // input natively, and including the wasm-side value here would make 1466 // the overlay HTML differ on every keystroke — that would trip the 1467 // diff in _setOverlay and rebuild the <input>, eating the user's 1468 // caret. a page-constant initial value is safe. 1469 if (session.input_values.get(inner_id)) |value| { 1470 try out.appendSlice(allocator, "\" value=\""); 1471 try appendEscapedHtml(allocator, &out, value); 1472 } 1473 try out.appendSlice(allocator, "\" style=\"left:"); 1474 var pos_buf: [64]u8 = undefined; 1475 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 })); 1476 try out.appendSlice(allocator, "\">"); 1477 }, 1478 .text_area => { 1479 const ti = session.text_inputs.get(inner_id) orelse continue; 1480 const inner_left = r.x + 1; 1481 const inner_top = r.y + 1; 1482 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1483 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 1484 1485 try out.appendSlice(allocator, "<textarea data-focus-id=\""); 1486 var id_buf: [32]u8 = undefined; 1487 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1488 if (ti.options.name.len > 0) { 1489 try out.appendSlice(allocator, "\" name=\""); 1490 try appendEscapedHtml(allocator, &out, ti.options.name); 1491 } 1492 // like the input above, only a page-constant initial value 1493 // inside: the browser tracks the live value and the 1494 // overlay diff must stay stable across keystrokes 1495 try out.appendSlice(allocator, "\" style=\"left:"); 1496 var pos_buf: [128]u8 = undefined; 1497 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 })); 1498 // textarea discards the first newline immediately following 1499 // its start tag, so add one of our own so text that begins 1500 // with a newline doesn't lose it 1501 try out.appendSlice(allocator, "\">\n"); 1502 if (session.input_values.get(inner_id)) |value| try appendEscapedHtml(allocator, &out, value); 1503 try out.appendSlice(allocator, "</textarea>"); 1504 }, 1505 .custom => |custom| { 1506 if (std.mem.eql(u8, "submit", custom)) { 1507 try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); 1508 var id_buf: [32]u8 = undefined; 1509 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1510 try out.appendSlice(allocator, "\" style=\"left:"); 1511 var pos_buf: [128]u8 = undefined; 1512 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 })); 1513 try out.appendSlice(allocator, "\"></button>"); 1514 } 1515 }, 1516 else => {}, 1517 } 1518 } 1519 1520 try out.appendSlice(allocator, "</form>"); 1521 } 1522 1523 return try out.toOwnedSlice(allocator); 1524 } 1525 1526 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 1527 if (a) |av| return if (b) |bv| av.eql(bv) else false; 1528 return b == null; 1529 } 1530 1531 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 1532 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 1533 // empty slice when neither is set. 1534 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 1535 if (fg == null and bg == null) return buf[0..0]; 1536 const prefix = " style=\""; 1537 @memcpy(buf[0..prefix.len], prefix); 1538 var i: usize = prefix.len; 1539 if (fg) |c| { 1540 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]; 1541 i += s.len; 1542 } 1543 if (bg) |c| { 1544 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]; 1545 i += s.len; 1546 } 1547 buf[i] = '"'; 1548 i += 1; 1549 return buf[0..i]; 1550 } 1551 1552 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 1553 for (input) |ch| { 1554 switch (ch) { 1555 '&' => try out.appendSlice(allocator, "&amp;"), 1556 '<' => try out.appendSlice(allocator, "&lt;"), 1557 '>' => try out.appendSlice(allocator, "&gt;"), 1558 '"' => try out.appendSlice(allocator, "&quot;"), 1559 '\'' => try out.appendSlice(allocator, "&#39;"), 1560 else => try out.append(allocator, ch), 1561 } 1562 } 1563 } 1564 1565 // --- helpers -------------------------------------------------------------- 1566 1567 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 1568 var iter = request.iterateHeaders(); 1569 while (iter.next()) |header| { 1570 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 1571 var pairs = std.mem.splitScalar(u8, header.value, ';'); 1572 while (pairs.next()) |pair| { 1573 const trimmed = std.mem.trim(u8, pair, " \t"); 1574 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 1575 if (std.mem.eql(u8, trimmed[0..eq], name)) { 1576 return trimmed[eq + 1 ..]; 1577 } 1578 } 1579 } 1580 return null; 1581 } 1582 1583 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 1584 var iter = std.mem.splitScalar(u8, body, '&'); 1585 while (iter.next()) |pair| { 1586 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 1587 if (std.mem.eql(u8, pair[0..eq], key)) { 1588 return try decodeFormValue(allocator, pair[eq + 1 ..]); 1589 } 1590 } 1591 return null; 1592 } 1593 1594 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 1595 var out: std.ArrayList(u8) = .empty; 1596 errdefer out.deinit(allocator); 1597 var i: usize = 0; 1598 while (i < encoded.len) : (i += 1) { 1599 switch (encoded[i]) { 1600 '+' => try out.append(allocator, ' '), 1601 '%' => { 1602 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 1603 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 1604 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 1605 try out.append(allocator, hi * 16 + lo); 1606 i += 2; 1607 }, 1608 else => try out.append(allocator, encoded[i]), 1609 } 1610 } 1611 return try out.toOwnedSlice(allocator); 1612 } 1613 1614 // disk-backed web session store. each login mints a random, opaque token 1615 // (not the user's id) that is stored as a file named by the token hex, whose 1616 // contents are the raw user_id bytes. authenticating a request is a lookup of 1617 // the cookie's token; logout deletes the file, revoking the session. there is 1618 // deliberately no expiry — a session lives until logout. 1619 pub const SessionStore = struct { 1620 io: std.Io, 1621 dir: std.Io.Dir, 1622 1623 pub const token_hex_len = evt.event_id_size * 2; 1624 1625 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 1626 try data_dir.createDirPath(io, "sessions"); 1627 const dir = try data_dir.openDir(io, "sessions", .{}); 1628 return .{ .io = io, .dir = dir }; 1629 } 1630 1631 pub fn deinit(self: SessionStore) void { 1632 self.dir.close(self.io); 1633 } 1634 1635 // mint a new session for user_id, returning the token hex to set as the 1636 // cookie value. 1637 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 1638 var token: [evt.event_id_size]u8 = undefined; 1639 self.io.random(&token); 1640 const token_hex = std.fmt.bytesToHex(token, .lower); 1641 const file = try self.dir.createFile(self.io, &token_hex, .{}); 1642 defer file.close(self.io); 1643 try file.writeStreamingAll(self.io, user_id); 1644 return token_hex; 1645 } 1646 1647 // resolve a cookie's token to its user_id. returns false (logged out) for a 1648 // missing, malformed, or unknown token. 1649 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 1650 if (!isToken(token_hex)) return false; 1651 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 1652 defer file.close(self.io); 1653 var storage: [evt.event_id_size]u8 = undefined; 1654 var file_reader = file.reader(self.io, &storage); 1655 file_reader.interface.readSliceAll(out) catch return false; 1656 return true; 1657 } 1658 1659 // revoke a session. a no-op for a missing or malformed token. 1660 pub fn remove(self: SessionStore, token_hex: []const u8) void { 1661 if (!isToken(token_hex)) return; 1662 self.dir.deleteFile(self.io, token_hex) catch {}; 1663 } 1664 1665 // leave `token_hex`'s session for the next visitor arriving without one. 1666 // only the `try` fixture writes one, and only a debug build honors it. the 1667 // name isn't token-shaped, so it can't collide with a session file. 1668 pub fn offerAutoLogin(self: SessionStore, token_hex: *const [token_hex_len]u8) !void { 1669 const file = try self.dir.createFile(self.io, auto_login_name, .{}); 1670 defer file.close(self.io); 1671 try file.writeStreamingAll(self.io, token_hex); 1672 } 1673 1674 // claim the offered session, if one is there. the delete picks the winner 1675 // of a race, so the offer is only ever taken once. a release build never 1676 // looks, because that would be an extra syscall for every GET request. 1677 pub fn claimAutoLogin(self: SessionStore, out: *[token_hex_len]u8) bool { 1678 if (builtin.mode != .Debug) return false; 1679 { 1680 const file = self.dir.openFile(self.io, auto_login_name, .{ .mode = .read_only }) catch return false; 1681 defer file.close(self.io); 1682 var storage: [token_hex_len]u8 = undefined; 1683 var file_reader = file.reader(self.io, &storage); 1684 file_reader.interface.readSliceAll(out) catch return false; 1685 } 1686 self.dir.deleteFile(self.io, auto_login_name) catch return false; 1687 return true; 1688 } 1689 1690 const auto_login_name = "auto-login"; 1691 1692 // a token is exactly token_hex_len hex chars. validating before using the 1693 // value as a path keeps attacker-supplied cookies from escaping the 1694 // sessions dir (e.g. via "/" or ".." bytes). 1695 fn isToken(token_hex: []const u8) bool { 1696 if (token_hex.len != token_hex_len) return false; 1697 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 1698 return true; 1699 } 1700 }; 1701