╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const xit = @import("xit"); 3 const rp = xit.repo; 4 const hash = xit.hash; 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 // flash cookie for surfacing the outcome of the most recent /login POST. 15 // set on the failure redirect, read and immediately expired on the next 16 // GET / so refreshing the page doesn't keep showing the error. 17 const login_failure_cookie = "haxy_login_failure"; 18 19 const Embed = struct { 20 path: []const u8, 21 content_type: []const u8, 22 body: []const u8, 23 }; 24 25 const embeds = [_]Embed{ 26 .{ .path = "index.html", .content_type = "text/html; charset=utf-8", .body = @embedFile("embed/index.html") }, 27 .{ .path = "script.js", .content_type = "text/javascript; charset=utf-8", .body = @embedFile("embed/script.js") }, 28 .{ .path = "term.ttf", .content_type = "font/ttf", .body = @embedFile("embed/term.ttf") }, 29 .{ .path = "haxy.wasm", .content_type = "application/wasm", .body = @embedFile("haxy.wasm") }, 30 }; 31 32 // what a web request is served from: the multi-user server (admin repo + 33 // login sessions) or a single local repo. 34 pub const Host = union(enum) { 35 remote: struct { 36 admin_repo_path: []const u8, 37 session_store: SessionStore, 38 }, 39 local: ui.RepoSource, 40 }; 41 42 pub fn handleConnection( 43 io: std.Io, 44 allocator: std.mem.Allocator, 45 stream: std.Io.net.Stream, 46 host: Host, 47 err: *std.Io.Writer, 48 ) !void { 49 var send_buffer = [_]u8{0} ** 4096; 50 var recv_buffer = [_]u8{0} ** 4096; 51 var conn_br = stream.reader(io, &recv_buffer); 52 var conn_bw = stream.writer(io, &send_buffer); 53 var http_server = std.http.Server.init(&conn_br.interface, &conn_bw.interface); 54 55 // serve multiple requests on a single connection so HTTP/1.1 keep-alive 56 // works — important for browser-native form POST: after the 303 the 57 // browser issues GET / on the same socket, and forcibly closing here 58 // would race the follow-up request into a "site can't be reached". 59 while (http_server.reader.state == .ready) { 60 var request = http_server.receiveHead() catch |receive_err| switch (receive_err) { 61 error.HttpConnectionClosing => break, 62 error.ReadFailed => break, 63 else => |e| return e, 64 }; 65 66 handleRequest(io, &request, allocator, host) catch |request_err| { 67 try err.print("web ui request failed: {s}\n", .{@errorName(request_err)}); 68 try err.flush(); 69 // best-effort 500. if handleRequest already started writing a 70 // response before throwing, this respond may fail too, but at 71 // that point the connection is unrecoverable anyway. 72 request.respond(@errorName(request_err), .{ 73 .status = .internal_server_error, 74 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 75 }) catch {}; 76 }; 77 } 78 } 79 80 fn handleRequest( 81 io: std.Io, 82 request: *std.http.Server.Request, 83 allocator: std.mem.Allocator, 84 host: Host, 85 ) !void { 86 const method = request.head.method; 87 const uri = try std.Uri.parseAfterScheme("", request.head.target); 88 const path = uri.path.percent_encoded; 89 90 // POST routes can be scoped by any page, so they can redirect using 91 // the base of the URL. this allows logging in to keep you on the page 92 // you were on. local mode has no accounts, so only the issue routes. 93 if (method == .POST) { 94 switch (host) { 95 .remote => |remote| { 96 const PostRoute = enum { login, logout, ansi, issue, open, close, edit }; 97 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { 98 const suffix = "/" ++ field.name; 99 if (std.mem.endsWith(u8, path, suffix)) { 100 const base = path[0 .. path.len - suffix.len]; 101 return switch (@field(PostRoute, field.name)) { 102 .login => handleLogin(io, request, allocator, base, remote.admin_repo_path, remote.session_store), 103 .logout => handleLogout(request, base, remote.session_store), 104 .ansi => handleAnsi(io, request, allocator, base, remote.admin_repo_path, remote.session_store), 105 .issue => handleIssue(io, request, allocator, base, host), 106 .open => handleIssueStatus(io, request, allocator, base, host, .open), 107 .close => handleIssueStatus(io, request, allocator, base, host, .closed), 108 .edit => handleIssueEdit(io, request, allocator, base, host), 109 }; 110 } 111 } 112 }, 113 .local => { 114 const PostRoute = enum { issue, open, close, edit }; 115 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { 116 const suffix = "/" ++ field.name; 117 if (std.mem.endsWith(u8, path, suffix)) { 118 const base = path[0 .. path.len - suffix.len]; 119 return switch (@field(PostRoute, field.name)) { 120 .issue => handleIssue(io, request, allocator, base, host), 121 .open => handleIssueStatus(io, request, allocator, base, host, .open), 122 .close => handleIssueStatus(io, request, allocator, base, host, .closed), 123 .edit => handleIssueEdit(io, request, allocator, base, host), 124 }; 125 } 126 } 127 }, 128 } 129 } 130 131 const get_or_head = method == .GET or method == .HEAD; 132 if (!get_or_head) { 133 try request.respond("method not allowed", .{ 134 .status = .method_not_allowed, 135 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 136 }); 137 return; 138 } 139 140 if (std.mem.eql(u8, path, "/favicon.ico")) { 141 try request.respond("", .{ 142 .status = .no_content, 143 .extra_headers = &.{.{ .name = "content-type", .value = "image/x-icon" }}, 144 }); 145 return; 146 } 147 148 const current_page_maybe = switch (host) { 149 .remote => ui.RoutablePage.fromUrl(path), 150 .local => ui.RoutablePage.fromUrlLocal(path), 151 }; 152 if (current_page_maybe) |current_page| { 153 // resolve the haxy_session cookie's token to a user_id via the 154 // store. user_id_buf lives on the stack for the rest of handleRequest, 155 // which is plenty for renderIndexHtml to consume. local mode has no 156 // accounts, so it is always logged out. 157 var user_id_buf: [evt.event_id_size]u8 = undefined; 158 var user_id: ?[]const u8 = null; 159 var login_failure: ?ui.Home.Auth.Login.Failure = null; 160 switch (host) { 161 .remote => |remote| { 162 user_id = blk: { 163 const token = getCookieValue(request, cookie_name) orelse break :blk null; 164 if (!remote.session_store.lookup(token, &user_id_buf)) break :blk null; 165 break :blk user_id_buf[0..evt.event_id_size]; 166 }; 167 login_failure = if (getCookieValue(request, login_failure_cookie)) |raw| 168 if (std.mem.eql(u8, raw, "unknown_user")) 169 .unknown_user 170 else if (std.mem.eql(u8, raw, "wrong_password")) 171 .wrong_password 172 else 173 null 174 else 175 null; 176 }, 177 .local => {}, 178 } 179 180 const html = renderIndexHtml(io, allocator, host, .{ 181 .user_id = user_id, 182 .login_failure = login_failure, 183 .current_page = current_page, 184 .is_local = host == .local, 185 }) catch |err| switch (err) { 186 error.NotFound => { 187 try request.respond("not found", .{ 188 .status = .not_found, 189 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 190 }); 191 return; 192 }, 193 else => |e| return e, 194 }; 195 defer allocator.free(html); 196 197 // expire the flash cookie on the way out so a refresh doesn't keep 198 // showing the failure label. 199 var headers: [2]std.http.Header = undefined; 200 headers[0] = .{ .name = "content-type", .value = "text/html; charset=utf-8" }; 201 var headers_slice: []const std.http.Header = headers[0..1]; 202 if (login_failure != null) { 203 headers[1] = .{ .name = "set-cookie", .value = login_failure_cookie ++ "=; Path=/; Max-Age=0" }; 204 headers_slice = headers[0..2]; 205 } 206 try request.respond(html, .{ .extra_headers = headers_slice }); 207 return; 208 } 209 210 const embed = findEmbed(path) orelse { 211 try request.respond("not found", .{ 212 .status = .not_found, 213 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 214 }); 215 return; 216 }; 217 218 // the embeds change with every build, so the browser must revalidate 219 // rather than heuristically cache them across server restarts 220 try request.respond(embed.body, .{ 221 .extra_headers = &.{ 222 .{ .name = "content-type", .value = embed.content_type }, 223 .{ .name = "cache-control", .value = "no-cache" }, 224 }, 225 }); 226 } 227 228 fn handleLogin( 229 io: std.Io, 230 request: *std.http.Server.Request, 231 allocator: std.mem.Allocator, 232 base: []const u8, 233 admin_repo_path: []const u8, 234 session_store: SessionStore, 235 ) !void { 236 var body_buf: [256]u8 = undefined; 237 const reader = request.readerExpectNone(&body_buf); 238 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 239 defer allocator.free(body); 240 241 const username = (try parseFormField(allocator, body, "username")) orelse try allocator.dupe(u8, ""); 242 defer allocator.free(username); 243 const password = (try parseFormField(allocator, body, "password")) orelse try allocator.dupe(u8, ""); 244 defer allocator.free(password); 245 246 const Repo = rp.Repo(.xit, evt.admin_repo_opts); 247 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 248 defer repo.deinit(io, allocator); 249 250 var arena = std.heap.ArenaAllocator.init(allocator); 251 defer arena.deinit(); 252 253 const haxy_moment = try evt.currentMoment(evt.admin_repo_opts, &repo); 254 const result = try evt.User.verifyCredentials(evt.AdminDB, evt.admin_repo_opts.hash, haxy_moment, &arena, username, password); 255 256 // on success, return to the page the login came from (base, or "/" at the 257 // root); on failure, stay on its auth tab to surface the error. 258 const success_location: []const u8 = if (base.len == 0) "/" else base; 259 const failure_location = try std.fmt.allocPrint(arena.allocator(), "{s}/auth", .{base}); 260 261 switch (result) { 262 .success => |user_id| { 263 const token = try session_store.create(&user_id); 264 var cookie_buf: [256]u8 = undefined; 265 const cookie = try std.fmt.bufPrint(&cookie_buf, cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict", .{token}); 266 try request.respond("", .{ 267 .status = .see_other, 268 .extra_headers = &.{ 269 .{ .name = "location", .value = success_location }, 270 .{ .name = "set-cookie", .value = cookie }, 271 }, 272 }); 273 }, 274 .unknown_user => { 275 try request.respond("", .{ 276 .status = .see_other, 277 .extra_headers = &.{ 278 .{ .name = "location", .value = failure_location }, 279 .{ .name = "set-cookie", .value = login_failure_cookie ++ "=unknown_user; Path=/; HttpOnly; SameSite=Strict" }, 280 }, 281 }); 282 }, 283 .wrong_password => { 284 try request.respond("", .{ 285 .status = .see_other, 286 .extra_headers = &.{ 287 .{ .name = "location", .value = failure_location }, 288 .{ .name = "set-cookie", .value = login_failure_cookie ++ "=wrong_password; Path=/; HttpOnly; SameSite=Strict" }, 289 }, 290 }); 291 }, 292 } 293 } 294 295 fn handleLogout(request: *std.http.Server.Request, base: []const u8, session_store: SessionStore) !void { 296 // revoke the session server-side so the cookie is dead for anyone holding 297 // a copy, not just this browser. 298 if (getCookieValue(request, cookie_name)) |token| session_store.remove(token); 299 // return to the page the logout came from (base, or "/" at the root). 300 const location: []const u8 = if (base.len == 0) "/" else base; 301 // close the connection instead of keeping it alive: we don't read the 302 // request body, and respond()'s keep-alive path would otherwise try to 303 // discard it — which asserts on a bodyless POST that carries no 304 // content-length, and would block on one with no framing at all. a logout 305 // is just a redirect, so the browser reconnects for the follow-up GET. 306 try request.respond("", .{ 307 .status = .see_other, 308 .keep_alive = false, 309 .extra_headers = &.{ 310 .{ .name = "location", .value = location }, 311 .{ .name = "set-cookie", .value = cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" }, 312 }, 313 }); 314 } 315 316 fn handleAnsi( 317 io: std.Io, 318 request: *std.http.Server.Request, 319 allocator: std.mem.Allocator, 320 base: []const u8, 321 admin_repo_path: []const u8, 322 session_store: SessionStore, 323 ) !void { 324 var user_id: [evt.event_id_size]u8 = undefined; 325 if (getCookieValue(request, cookie_name)) |token| { 326 if (session_store.lookup(token, &user_id)) { 327 const Repo = rp.Repo(.xit, evt.admin_repo_opts); 328 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 329 defer repo.deinit(io, allocator); 330 331 try evt.User.toggleAnsi(evt.admin_repo_opts, io, allocator, &repo, &user_id); 332 } 333 } 334 335 // return to the settings tab the toggle came from so the change is visible. 336 const location = try std.fmt.allocPrint(allocator, "{s}/settings", .{base}); 337 defer allocator.free(location); 338 339 // like logout, this is a bodyless POST, so close the connection rather than 340 // letting the keep-alive path try to discard a body that isn't framed. 341 try request.respond("", .{ 342 .status = .see_other, 343 .keep_alive = false, 344 .extra_headers = &.{ 345 .{ .name = "location", .value = location }, 346 }, 347 }); 348 } 349 350 // create an issue in the repo the form's page names (base is 351 // "/repo/<owner>/<name>", or "" in local mode) and redirect to it 352 fn handleIssue( 353 io: std.Io, 354 request: *std.http.Server.Request, 355 allocator: std.mem.Allocator, 356 base: []const u8, 357 host: Host, 358 ) !void { 359 var body_buf: [256]u8 = undefined; 360 const reader = request.readerExpectNone(&body_buf); 361 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 362 defer allocator.free(body); 363 364 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 365 defer allocator.free(title); 366 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 367 defer allocator.free(tags); 368 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 369 defer allocator.free(description_crlf); 370 // form submission normalizes textarea line breaks to CRLF; store plain 371 // newlines so the text renders the same on every host 372 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 373 defer allocator.free(description); 374 375 if (!issueFieldsValid(title, tags)) { 376 const form_location = try std.fmt.allocPrint(allocator, "{s}/issues/new", .{base}); 377 defer allocator.free(form_location); 378 try request.respond("", .{ 379 .status = .see_other, 380 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 381 }); 382 return; 383 } 384 385 var id_bytes: [evt.event_id_size]u8 = undefined; 386 io.random(&id_bytes); 387 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 388 389 const event = evt.EventWithId{ 390 .id = event_id_hex, 391 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 392 .event = .{ .issue = .{ 393 .title = title, 394 .description = description, 395 .tags = tags, 396 } }, 397 }; 398 399 const not_found = "repo not found"; 400 switch (host) { 401 .remote => |remote| { 402 const repo_prefix = "/repo/"; 403 if (!std.mem.startsWith(u8, base, repo_prefix)) { 404 try request.respond(not_found, .{ 405 .status = .not_found, 406 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 407 }); 408 return; 409 } 410 411 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 412 defer allocator.free(repos_dir); 413 const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, base[repo_prefix.len..], false)) { 414 .ok => |p| p, 415 .invalid, .not_found => { 416 try request.respond(not_found, .{ 417 .status = .not_found, 418 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 419 }); 420 return; 421 }, 422 }; 423 defer allocator.free(repo_path); 424 425 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 426 defer repo.deinit(io, allocator); 427 try evt.commitAndConsume(.xit, .{}, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{event}); 428 }, 429 .local => |src| { 430 // the local form posts to "/issue", so base is empty 431 if (base.len != 0) { 432 try request.respond(not_found, .{ 433 .status = .not_found, 434 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 435 }); 436 return; 437 } 438 switch (src.repo_kind) { 439 inline else => |repo_kind| { 440 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 441 defer any_repo.deinit(io, allocator); 442 switch (any_repo) { 443 inline else => |*repo| try evt.commitAndConsume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{event}), 444 } 445 }, 446 } 447 }, 448 } 449 450 const location = try std.fmt.allocPrint(allocator, "{s}/issues/{s}", .{ base, &event_id_hex }); 451 defer allocator.free(location); 452 try request.respond("", .{ 453 .status = .see_other, 454 .extra_headers = &.{.{ .name = "location", .value = location }}, 455 }); 456 } 457 458 // a title is required, and a too-long tag would fail consumption after the 459 // event is already committed 460 fn issueFieldsValid(title: []const u8, tags: []const u8) bool { 461 if (title.len == 0) return false; 462 var tag_iter = evt.Issue.tagIterator(tags); 463 while (tag_iter.next()) |tag| { 464 if (tag.len > evt.Issue.tag_max_len) return false; 465 } 466 return true; 467 } 468 469 // split an issue url ("/repo/<owner>/<name>/issues/<id>", identity elided in 470 // local mode) into what precedes "/issues/" and the decoded id, null when it 471 // isn't one. the repo base is validated per host in updateIssue. 472 const IssueBaseParts = struct { repo_base: []const u8, id_bytes: [evt.event_id_size]u8 }; 473 fn issueBaseParts(base: []const u8) ?IssueBaseParts { 474 const issues_infix = "/issues/"; 475 const id_len = evt.event_id_size * 2; 476 if (base.len < issues_infix.len + id_len) return null; 477 var id_bytes: [evt.event_id_size]u8 = undefined; 478 _ = std.fmt.hexToBytes(&id_bytes, base[base.len - id_len ..]) catch return null; 479 const head = base[0 .. base.len - id_len]; 480 if (!std.mem.endsWith(u8, head, issues_infix)) return null; 481 return .{ .repo_base = head[0 .. head.len - issues_infix.len], .id_bytes = id_bytes }; 482 } 483 484 // what an update route replaces on an issue: its status, or its content. 485 const IssueUpdate = union(enum) { 486 status: evt.Issue.Status, 487 fields: struct { title: []const u8, tags: []const u8, description: []const u8 }, 488 }; 489 490 // re-emit the issue `parts` names with `update` applied, in the repo the 491 // host resolves the base to. error.NotFound when the base names no repo or 492 // the id no issue. 493 fn updateIssue( 494 io: std.Io, 495 allocator: std.mem.Allocator, 496 host: Host, 497 parts: IssueBaseParts, 498 update: IssueUpdate, 499 ) !void { 500 switch (host) { 501 .remote => |remote| { 502 const repo_prefix = "/repo/"; 503 if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return error.NotFound; 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, parts.repo_base[repo_prefix.len..], false)) { 507 .ok => |p| p, 508 .invalid, .not_found => return error.NotFound, 509 }; 510 defer allocator.free(repo_path); 511 512 var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); 513 defer repo.deinit(io, allocator); 514 try updateIssueInRepo(.xit, .{}, io, allocator, &repo, &parts.id_bytes, update); 515 }, 516 .local => |src| { 517 // the local forms post to "/issues/<id>/...", so the repo base 518 // is empty 519 if (parts.repo_base.len != 0) return error.NotFound; 520 switch (src.repo_kind) { 521 inline else => |repo_kind| { 522 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 523 defer any_repo.deinit(io, allocator); 524 switch (any_repo) { 525 inline else => |*repo| try updateIssueInRepo(repo_kind, repo.self_repo_opts, io, allocator, repo, &parts.id_bytes, update), 526 } 527 }, 528 } 529 }, 530 } 531 } 532 533 fn updateIssueInRepo( 534 comptime repo_kind: rp.RepoKind, 535 comptime repo_opts: rp.RepoOpts(repo_kind), 536 io: std.Io, 537 allocator: std.mem.Allocator, 538 repo: *rp.Repo(repo_kind, repo_opts), 539 id_bytes: *const [evt.event_id_size]u8, 540 update: IssueUpdate, 541 ) !void { 542 var arena = std.heap.ArenaAllocator.init(allocator); 543 defer arena.deinit(); 544 545 const issue = (try readRepoIssue(repo_kind, repo_opts, io, allocator, &arena, repo, id_bytes)) orelse return error.NotFound; 546 547 var updated = issue; 548 switch (update) { 549 .status => |status| { 550 if (issue.status == status) return; 551 updated.status = status; 552 }, 553 .fields => |fields| { 554 updated.title = fields.title; 555 updated.tags = fields.tags; 556 updated.description = fields.description; 557 }, 558 } 559 560 try evt.commitAndConsume(repo_kind, repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{.{ 561 .id = std.fmt.bytesToHex(id_bytes.*, .lower), 562 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 563 .event = .{ .issue = updated }, 564 }}); 565 } 566 567 // the issue `id_bytes` names in the repo's consumed event db (the repo's own 568 // db for xit; the side db next to a git repo), or null 569 fn readRepoIssue( 570 comptime repo_kind: rp.RepoKind, 571 comptime repo_opts: rp.RepoOpts(repo_kind), 572 io: std.Io, 573 allocator: std.mem.Allocator, 574 arena: *std.heap.ArenaAllocator, 575 repo: *rp.Repo(repo_kind, repo_opts), 576 id_bytes: *const [evt.event_id_size]u8, 577 ) !?evt.Issue { 578 const DB = evt.EventDB(repo_opts.hash); 579 var event_db_maybe: ?evt.LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try evt.LocalEventDB(repo_opts.hash).openReadOnly(io, allocator, repo.core.repo_dir) else null; 580 defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); 581 const moment = (if (event_db_maybe) |*event_db| 582 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 583 else if (repo_kind == .git) 584 return null 585 else 586 evt.currentMoment(repo_opts, repo)) catch return null; 587 const event_id_to_issue_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue"))) orelse return null; 588 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 589 const issue_cursor = (try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, id_bytes))) orelse return null; 590 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 591 return try evt.read(evt.Issue, DB, repo_opts.hash, arena, issue_map); 592 } 593 594 // set the status of the issue the url names (base is 595 // "/repo/<owner>/<name>/issues/<id>", identity elided in local mode) by 596 // re-emitting its event, then redirect back to it 597 fn handleIssueStatus( 598 io: std.Io, 599 request: *std.http.Server.Request, 600 allocator: std.mem.Allocator, 601 base: []const u8, 602 host: Host, 603 status: evt.Issue.Status, 604 ) !void { 605 const not_found = "issue not found"; 606 const parts = issueBaseParts(base) orelse { 607 try request.respond(not_found, .{ 608 .status = .not_found, 609 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 610 }); 611 return; 612 }; 613 614 updateIssue(io, allocator, host, parts, .{ .status = status }) catch |err| switch (err) { 615 error.NotFound => { 616 try request.respond(not_found, .{ 617 .status = .not_found, 618 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 619 }); 620 return; 621 }, 622 else => |e| return e, 623 }; 624 625 // like logout, this is a bodyless POST, so close the connection rather than 626 // letting the keep-alive path try to discard a body that isn't framed. 627 try request.respond("", .{ 628 .status = .see_other, 629 .keep_alive = false, 630 .extra_headers = &.{.{ .name = "location", .value = base }}, 631 }); 632 } 633 634 // replace the title/tags/description of the issue the url names (base is 635 // "/repo/<owner>/<name>/issues/<id>", identity elided in local mode; the 636 // form posts to "<base>/edit") by re-emitting its event with its status 637 // preserved, then redirect back to it 638 fn handleIssueEdit( 639 io: std.Io, 640 request: *std.http.Server.Request, 641 allocator: std.mem.Allocator, 642 base: []const u8, 643 host: Host, 644 ) !void { 645 var body_buf: [256]u8 = undefined; 646 const reader = request.readerExpectNone(&body_buf); 647 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 648 defer allocator.free(body); 649 650 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 651 defer allocator.free(title); 652 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 653 defer allocator.free(tags); 654 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 655 defer allocator.free(description_crlf); 656 // form submission normalizes textarea line breaks to CRLF; store plain 657 // newlines so the text renders the same on every host 658 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 659 defer allocator.free(description); 660 661 // invalid fields send the user back to the edit form 662 if (!issueFieldsValid(title, tags)) { 663 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 664 defer allocator.free(form_location); 665 try request.respond("", .{ 666 .status = .see_other, 667 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 668 }); 669 return; 670 } 671 672 const not_found = "issue not found"; 673 const parts = issueBaseParts(base) orelse { 674 try request.respond(not_found, .{ 675 .status = .not_found, 676 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 677 }); 678 return; 679 }; 680 681 updateIssue(io, allocator, host, parts, .{ .fields = .{ 682 .title = title, 683 .tags = tags, 684 .description = description, 685 } }) catch |err| switch (err) { 686 error.NotFound => { 687 try request.respond(not_found, .{ 688 .status = .not_found, 689 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 690 }); 691 return; 692 }, 693 else => |e| return e, 694 }; 695 696 try request.respond("", .{ 697 .status = .see_other, 698 .extra_headers = &.{.{ .name = "location", .value = base }}, 699 }); 700 } 701 702 fn renderIndexHtml( 703 io: std.Io, 704 allocator: std.mem.Allocator, 705 host: Host, 706 session_data: ui.Session.Data, 707 ) ![]const u8 { 708 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 709 710 var page_arena = std.heap.ArenaAllocator.init(allocator); 711 defer page_arena.deinit(); 712 713 // the remote admin repo must outlive the page build, since the session's 714 // moment reads from it. 715 var repo_maybe: ?rp.Repo(.xit, evt.admin_repo_opts) = null; 716 defer if (repo_maybe) |*repo| repo.deinit(io, allocator); 717 718 var session = switch (host) { 719 .remote => |remote| blk: { 720 // open the admin repo to read live user/repo data. 721 repo_maybe = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 722 const repo = if (repo_maybe) |*repo| repo else unreachable; 723 var session = try ui.Session.init(&page_arena, repo, session_data); 724 // give the page builders filesystem access to the on-disk repos (a 725 // sibling "repos" dir next to the admin repo) so the Repo page can 726 // read its files. 727 session.repos_dir = try std.fs.path.join(page_arena.allocator(), &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 728 break :blk session; 729 }, 730 .local => |local| ui.Session{ 731 .data = session_data, 732 .arena = &page_arena, 733 .page_arena = &page_arena, 734 .local = local, 735 }, 736 }; 737 session.io = io; 738 739 const snapshot: ui.Snapshot = .{ 740 .page = try ui.Page.init(session.page_arena, &session, session.data.current_page), 741 .session = session.data, 742 }; 743 var root = try ui.initRoot(allocator, &snapshot.page, &session); 744 defer root.deinit(allocator); 745 746 const content = try generateHtml(allocator, &root); 747 defer allocator.free(content); 748 const overlay = try generateOverlay(allocator, &root, &session); 749 defer allocator.free(overlay); 750 751 // serialize the snapshot so the wasm side can parse it back without making 752 // a second request. it's embedded raw in a <script type="application/json"> 753 // block, whose content is raw text terminated only by "</script". in json, 754 // '<' appears solely inside string values, so escaping every '<' (and '>' 755 // for good measure) to its \uXXXX form yields equivalent json that can't 756 // break out of the tag. 757 var json: std.Io.Writer.Allocating = .init(allocator); 758 defer json.deinit(); 759 try std.json.Stringify.value(snapshot, .{}, &json.writer); 760 761 var json_escaped: std.ArrayList(u8) = .empty; 762 defer json_escaped.deinit(allocator); 763 for (json.written()) |c| switch (c) { 764 '<' => try json_escaped.appendSlice(allocator, "\\u003c"), 765 '>' => try json_escaped.appendSlice(allocator, "\\u003e"), 766 else => try json_escaped.append(allocator, c), 767 }; 768 769 var out: std.ArrayList(u8) = .empty; 770 errdefer out.deinit(allocator); 771 { 772 var cursor: usize = 0; 773 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 774 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 775 .{ .needle = "{{{ HAXY_OVERLAY }}}", .replacement = overlay }, 776 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_escaped.items }, 777 }) |sub| { 778 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 779 try out.appendSlice(allocator, template[cursor..idx]); 780 try out.appendSlice(allocator, sub.replacement); 781 cursor = idx + sub.needle.len; 782 } 783 try out.appendSlice(allocator, template[cursor..]); 784 } 785 return try out.toOwnedSlice(allocator); 786 } 787 788 fn findEmbed(request_path: []const u8) ?Embed { 789 const path = if (std.mem.eql(u8, request_path, "/")) 790 "index.html" 791 else if (request_path.len > 1 and request_path[0] == '/') 792 request_path[1..] 793 else 794 return null; 795 796 for (embeds) |embed| { 797 if (std.mem.eql(u8, path, embed.path)) return embed; 798 } 799 return null; 800 } 801 802 // emits the TUI grid cells as static HTML. each web-native Scroll becomes its 803 // own absolutely-positioned, natively-scrollable <div> holding its full content, 804 // rendered recursively so nested scrolls work. 805 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { 806 const grid = root.getGrid() orelse return error.MissingGrid; 807 808 var out: std.ArrayList(u8) = .empty; 809 errdefer out.deinit(allocator); 810 811 try renderPanel(allocator, &out, root.getFocus(), grid); 812 return try out.toOwnedSlice(allocator); 813 } 814 815 // render one panel — a focus subtree and its content grid — as HTML, recursing 816 // once per web-native Scroll inside it. the root panel is the whole page; each 817 // Scroll becomes a nested, natively-scrollable div holding its full content. 818 // emits the panel's grid cells as text, leaving holes where child scrolls sit, 819 // then the child scroll divs over those holes. 820 fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid) !void { 821 // `direct` is this panel's scroll children, each drawn as an overlaid div. 822 // `excluded` is every focus id that belongs to a scroll (the scroll nodes 823 // plus their descendants, which the focus tree flattens into this panel's 824 // children): the cell hit-test below skips them so this panel doesn't claim 825 // cells that are really rendered inside a child scroll's own panel. 826 // (scrolls aren't nested in practice, so every scroll child is direct here.) 827 var direct: std.ArrayList(usize) = .empty; 828 defer direct.deinit(allocator); 829 var excluded: std.AutoHashMapUnmanaged(usize, void) = .empty; 830 defer excluded.deinit(allocator); 831 { 832 var it = focus.children.iterator(); 833 while (it.next()) |e| { 834 if (e.value_ptr.focus.scroll == null) continue; 835 try direct.append(allocator, e.key_ptr.*); 836 try excluded.put(allocator, e.key_ptr.*, {}); 837 var mit = e.value_ptr.focus.children.iterator(); 838 while (mit.next()) |m| try excluded.put(allocator, m.key_ptr.*, {}); 839 } 840 } 841 842 // the HTML element a run of cells is wrapped in, chosen from the cell's 843 // focusable kind 844 const CellTag = union(enum) { 845 span, // clickable focusable cell 846 a: []const u8, // clickable focusable link 847 plain, // non-focusable, colored 848 849 const a_prefix = "a:"; 850 // an "in-page" anchor: rendered as a real <a href> so it works with js 851 // disabled, but crossPageLink ignores it, so with wasm the click just 852 // moves focus rather than navigating. 853 const inpage_prefix = "ai:"; 854 855 fn init(str: []const u8) @This() { 856 if (std.mem.startsWith(u8, str, inpage_prefix)) { 857 return .{ .a = str[inpage_prefix.len..] }; 858 } 859 if (std.mem.startsWith(u8, str, a_prefix)) { 860 return .{ .a = str[a_prefix.len..] }; 861 } 862 return .span; 863 } 864 865 fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void { 866 var id_buf: [32]u8 = undefined; 867 switch (self) { 868 .span => { 869 try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); 870 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 871 try out.appendSlice(alloc, "\""); 872 try out.appendSlice(alloc, style_attr); 873 try out.appendSlice(alloc, ">"); 874 }, 875 .a => |href| { 876 // tabindex=-1 keeps these out of the browser's tab order 877 try out.appendSlice(alloc, "<a class=\"clickable\" tabindex=\"-1\" data-focus-id=\""); 878 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 879 try out.appendSlice(alloc, "\" href=\""); 880 try appendEscapedHtml(alloc, out, href); 881 try out.appendSlice(alloc, "\""); 882 try out.appendSlice(alloc, style_attr); 883 try out.appendSlice(alloc, ">"); 884 }, 885 .plain => { 886 try out.appendSlice(alloc, "<span"); 887 try out.appendSlice(alloc, style_attr); 888 try out.appendSlice(alloc, ">"); 889 }, 890 } 891 } 892 893 fn closeTag(self: @This()) []const u8 { 894 return switch (self) { 895 .span, .plain => "</span>", 896 .a => "</a>", 897 }; 898 } 899 }; 900 901 // emit this panel's grid as rows of text, coalescing adjacent cells that 902 // share a focus id and colors into one tag (a clickable span, a link, or a 903 // plain colored span). cells under a child scroll keep only their background. 904 for (0..grid.size.height) |y| { 905 var cur_id: ?usize = null; 906 var cur_fg: ?Grid.Color = null; 907 var cur_bg: ?Grid.Color = null; 908 var open_tag: ?CellTag = null; 909 var first = true; 910 for (0..grid.size.width) |x| { 911 const cell = grid.cells.items[try grid.cells.at(.{ y, x })]; 912 // a cell covered by a child scroll's viewport is drawn by that scroll's 913 // own div, so blank its glyph and make it non-clickable here — but keep 914 // its background, so the backdrop still shows through the scroll's 915 // transparent div instead of a bare hole. 916 const covered_by_scroll = blk: { 917 for (direct.items) |id| { 918 const r = (focus.children.get(id) orelse continue).rect; 919 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; 920 } 921 break :blk false; 922 }; 923 // the focusable cell at (x, y) among this panel's own focusables (skipping 924 // any that belong to a child scroll, whose cells are drawn in their own 925 // panel); none for a covered cell. 926 const cell_id = if (covered_by_scroll) null else blk: { 927 var iter = focus.children.iterator(); 928 while (iter.next()) |entry| { 929 const child = entry.value_ptr.*; 930 if (!child.focus.focusable) continue; 931 if (excluded.contains(entry.key_ptr.*)) continue; 932 const r = child.rect; 933 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 934 break :blk entry.key_ptr.*; 935 } 936 } 937 break :blk null; 938 }; 939 const fg = if (covered_by_scroll) null else cell.style.fg; 940 const bg = cell.style.bg; 941 942 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 943 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 944 945 var new_tag: ?CellTag = null; 946 if (cell_id) |id| { 947 if (focus.children.get(id)) |child| { 948 new_tag = switch (child.focus.kind) { 949 .custom => |custom| CellTag.init(custom), 950 else => .span, 951 }; 952 } 953 } 954 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 955 956 if (new_tag) |t| { 957 var style_buf: [64]u8 = undefined; 958 try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 959 } 960 961 open_tag = new_tag; 962 cur_id = cell_id; 963 cur_fg = fg; 964 cur_bg = bg; 965 first = false; 966 } 967 968 if (covered_by_scroll) { 969 try appendEscapedHtml(allocator, output, " "); 970 } else if (cell.continuation) { 971 // the wide rune to the left was emitted 2ch wide, covering this column 972 } else if (cell.rune) |rune| { 973 // a double-width rune is pinned inside a 2ch span so the 974 // fallback glyph's advance width can't shift the rest of the row 975 const wide = x + 1 < grid.size.width and grid.cells.items[try grid.cells.at(.{ y, x + 1 })].continuation; 976 if (wide) { 977 try output.appendSlice(allocator, "<span class=\"w2\">"); 978 try appendEscapedHtml(allocator, output, rune); 979 try output.appendSlice(allocator, "</span>"); 980 } else { 981 try appendEscapedHtml(allocator, output, rune); 982 } 983 } else { 984 try appendEscapedHtml(allocator, output, " "); 985 } 986 } 987 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 988 try output.append(allocator, '\n'); 989 } 990 991 // each child scroll becomes a natively-scrollable div positioned over its 992 // viewport, holding its full content rendered recursively. 993 for (direct.items) |id| { 994 const child = focus.children.get(id) orelse continue; 995 const info = child.focus.scroll orelse continue; 996 const r = child.rect; 997 // overflow only on the axes the widget scrolls; `auto` shows a bar only 998 // when that axis actually overflows. the other axis is clipped (hidden), 999 // matching how the terminal Scroll handles its non-scrolling axis. 1000 const overflow = switch (info.direction) { 1001 .vert => "overflow-x:hidden;overflow-y:auto", 1002 .horiz => "overflow-x:auto;overflow-y:hidden", 1003 .both => "overflow:auto", 1004 }; 1005 // the id carries a content version so JS preserves the native scroll 1006 // position across re-renders of the same content but resets it when the 1007 // content is replaced — e.g. selecting a different commit. the widget's 1008 // scroll offset (in cells) rides along so JS can apply it when it has no 1009 // preserved position, letting a wasm-side scrollToRect (the files list 1010 // scrolling to its selected row on page load) reach the browser. 1011 var buf: [224]u8 = undefined; 1012 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 })); 1013 try renderPanel(allocator, output, child.focus, info.content); 1014 try output.appendSlice(allocator, "</div>"); 1015 } 1016 } 1017 1018 // emits the form overlay — one <form> per "form:<url>" focus subtree in the 1019 // widget tree, each wrapping the text inputs and submit button inside it, 1020 // positioned absolutely over the matching grid cells. 1021 pub fn generateOverlay(allocator: std.mem.Allocator, root: *ui.Widget, session: *ui.Session) ![]const u8 { 1022 const root_focus = root.getFocus(); 1023 1024 var out: std.ArrayList(u8) = .empty; 1025 errdefer out.deinit(allocator); 1026 1027 const form_prefix = "form:"; 1028 1029 // Focus.children is an AutoArrayHashMap that preserves insertion order, 1030 // which matches the order widgets were added in code 1031 var iter = root_focus.children.iterator(); 1032 while (iter.next()) |entry| { 1033 const child = entry.value_ptr.*; 1034 const action_url = switch (child.focus.kind) { 1035 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) 1036 custom[form_prefix.len..] 1037 else 1038 continue, 1039 else => continue, 1040 }; 1041 1042 try out.appendSlice(allocator, "<form action=\""); 1043 try appendEscapedHtml(allocator, &out, action_url); 1044 try out.appendSlice(allocator, "\" method=\"post\""); 1045 // an empty action must not POST — block it and send the input to the TUI instead 1046 if (action_url.len == 0) try out.appendSlice(allocator, " onsubmit=\"event.preventDefault();sendEnter(this);\""); 1047 try out.appendSlice(allocator, ">"); 1048 1049 // form.focus.children was populated by addChild, which flattens the 1050 // whole subtree into one map — so descendants (inputs + the submit 1051 // button) all appear here. positions on form.focus are relative to 1052 // the form, so we look up absolute rects via root_focus for layout. 1053 var inner_iter = child.focus.children.iterator(); 1054 while (inner_iter.next()) |inner_entry| { 1055 const inner_id = inner_entry.key_ptr.*; 1056 const root_child = root_focus.children.get(inner_id) orelse continue; 1057 const r = root_child.rect; 1058 switch (root_child.focus.kind) { 1059 .text_input, .text_input_password => { 1060 const ti = session.text_inputs.get(inner_id) orelse continue; 1061 const inner_left = r.x + 1; 1062 const inner_top = r.y + 1; 1063 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1064 1065 try out.appendSlice(allocator, "<input type=\""); 1066 try out.appendSlice(allocator, if (root_child.focus.kind == .text_input_password) "password" else "text"); 1067 try out.appendSlice(allocator, "\" data-focus-id=\""); 1068 var id_buf: [32]u8 = undefined; 1069 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1070 if (ti.options.name.len > 0) { 1071 try out.appendSlice(allocator, "\" name=\""); 1072 try appendEscapedHtml(allocator, &out, ti.options.name); 1073 } 1074 // never the live content as the `value`: the browser tracks user 1075 // input natively, and including the wasm-side value here would make 1076 // the overlay HTML differ on every keystroke — that would trip the 1077 // diff in _setOverlay and rebuild the <input>, eating the user's 1078 // caret. a page-constant initial value is safe. 1079 if (session.input_values.get(inner_id)) |value| { 1080 try out.appendSlice(allocator, "\" value=\""); 1081 try appendEscapedHtml(allocator, &out, value); 1082 } 1083 try out.appendSlice(allocator, "\" style=\"left:"); 1084 var pos_buf: [64]u8 = undefined; 1085 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 })); 1086 try out.appendSlice(allocator, "\">"); 1087 }, 1088 .text_area => { 1089 const ti = session.text_inputs.get(inner_id) orelse continue; 1090 const inner_left = r.x + 1; 1091 const inner_top = r.y + 1; 1092 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1093 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 1094 1095 try out.appendSlice(allocator, "<textarea data-focus-id=\""); 1096 var id_buf: [32]u8 = undefined; 1097 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1098 if (ti.options.name.len > 0) { 1099 try out.appendSlice(allocator, "\" name=\""); 1100 try appendEscapedHtml(allocator, &out, ti.options.name); 1101 } 1102 // like the input above, only a page-constant initial value 1103 // inside: the browser tracks the live value and the 1104 // overlay diff must stay stable across keystrokes 1105 try out.appendSlice(allocator, "\" style=\"left:"); 1106 var pos_buf: [128]u8 = undefined; 1107 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 })); 1108 try out.appendSlice(allocator, "\">"); 1109 if (session.input_values.get(inner_id)) |value| try appendEscapedHtml(allocator, &out, value); 1110 try out.appendSlice(allocator, "</textarea>"); 1111 }, 1112 .custom => |custom| { 1113 if (std.mem.eql(u8, "submit", custom)) { 1114 try out.appendSlice(allocator, "<button type=\"submit\" data-focus-id=\""); 1115 var id_buf: [32]u8 = undefined; 1116 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{inner_id})); 1117 try out.appendSlice(allocator, "\" style=\"left:"); 1118 var pos_buf: [128]u8 = undefined; 1119 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 })); 1120 try out.appendSlice(allocator, "\"></button>"); 1121 } 1122 }, 1123 else => {}, 1124 } 1125 } 1126 1127 try out.appendSlice(allocator, "</form>"); 1128 } 1129 1130 return try out.toOwnedSlice(allocator); 1131 } 1132 1133 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 1134 if (a) |av| return if (b) |bv| av.eql(bv) else false; 1135 return b == null; 1136 } 1137 1138 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 1139 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 1140 // empty slice when neither is set. 1141 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 1142 if (fg == null and bg == null) return buf[0..0]; 1143 const prefix = " style=\""; 1144 @memcpy(buf[0..prefix.len], prefix); 1145 var i: usize = prefix.len; 1146 if (fg) |c| { 1147 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]; 1148 i += s.len; 1149 } 1150 if (bg) |c| { 1151 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]; 1152 i += s.len; 1153 } 1154 buf[i] = '"'; 1155 i += 1; 1156 return buf[0..i]; 1157 } 1158 1159 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 1160 for (input) |ch| { 1161 switch (ch) { 1162 '&' => try out.appendSlice(allocator, "&amp;"), 1163 '<' => try out.appendSlice(allocator, "&lt;"), 1164 '>' => try out.appendSlice(allocator, "&gt;"), 1165 '"' => try out.appendSlice(allocator, "&quot;"), 1166 '\'' => try out.appendSlice(allocator, "&#39;"), 1167 else => try out.append(allocator, ch), 1168 } 1169 } 1170 } 1171 1172 // --- helpers -------------------------------------------------------------- 1173 1174 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 1175 var iter = request.iterateHeaders(); 1176 while (iter.next()) |header| { 1177 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 1178 var pairs = std.mem.splitScalar(u8, header.value, ';'); 1179 while (pairs.next()) |pair| { 1180 const trimmed = std.mem.trim(u8, pair, " \t"); 1181 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 1182 if (std.mem.eql(u8, trimmed[0..eq], name)) { 1183 return trimmed[eq + 1 ..]; 1184 } 1185 } 1186 } 1187 return null; 1188 } 1189 1190 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 1191 var iter = std.mem.splitScalar(u8, body, '&'); 1192 while (iter.next()) |pair| { 1193 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 1194 if (std.mem.eql(u8, pair[0..eq], key)) { 1195 return try decodeFormValue(allocator, pair[eq + 1 ..]); 1196 } 1197 } 1198 return null; 1199 } 1200 1201 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 1202 var out: std.ArrayList(u8) = .empty; 1203 errdefer out.deinit(allocator); 1204 var i: usize = 0; 1205 while (i < encoded.len) : (i += 1) { 1206 switch (encoded[i]) { 1207 '+' => try out.append(allocator, ' '), 1208 '%' => { 1209 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 1210 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 1211 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 1212 try out.append(allocator, hi * 16 + lo); 1213 i += 2; 1214 }, 1215 else => try out.append(allocator, encoded[i]), 1216 } 1217 } 1218 return try out.toOwnedSlice(allocator); 1219 } 1220 1221 // disk-backed web session store. each login mints a random, opaque token 1222 // (not the user's id) that is stored as a file named by the token hex, whose 1223 // contents are the raw user_id bytes. authenticating a request is a lookup of 1224 // the cookie's token; logout deletes the file, revoking the session. there is 1225 // deliberately no expiry — a session lives until logout. 1226 pub const SessionStore = struct { 1227 io: std.Io, 1228 dir: std.Io.Dir, 1229 1230 pub const token_hex_len = evt.event_id_size * 2; 1231 1232 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 1233 try data_dir.createDirPath(io, "sessions"); 1234 const dir = try data_dir.openDir(io, "sessions", .{}); 1235 return .{ .io = io, .dir = dir }; 1236 } 1237 1238 pub fn deinit(self: SessionStore) void { 1239 self.dir.close(self.io); 1240 } 1241 1242 // mint a new session for user_id, returning the token hex to set as the 1243 // cookie value. 1244 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 1245 var token: [evt.event_id_size]u8 = undefined; 1246 self.io.random(&token); 1247 const token_hex = std.fmt.bytesToHex(token, .lower); 1248 const file = try self.dir.createFile(self.io, &token_hex, .{}); 1249 defer file.close(self.io); 1250 try file.writeStreamingAll(self.io, user_id); 1251 return token_hex; 1252 } 1253 1254 // resolve a cookie's token to its user_id. returns false (logged out) for a 1255 // missing, malformed, or unknown token. 1256 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 1257 if (!isToken(token_hex)) return false; 1258 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 1259 defer file.close(self.io); 1260 var storage: [evt.event_id_size]u8 = undefined; 1261 var file_reader = file.reader(self.io, &storage); 1262 file_reader.interface.readSliceAll(out) catch return false; 1263 return true; 1264 } 1265 1266 // revoke a session. a no-op for a missing or malformed token. 1267 pub fn remove(self: SessionStore, token_hex: []const u8) void { 1268 if (!isToken(token_hex)) return; 1269 self.dir.deleteFile(self.io, token_hex) catch {}; 1270 } 1271 1272 // a token is exactly token_hex_len hex chars. validating before using the 1273 // value as a path keeps attacker-supplied cookies from escaping the 1274 // sessions dir (e.g. via "/" or ".." bytes). 1275 fn isToken(token_hex: []const u8) bool { 1276 if (token_hex.len != token_hex_len) return false; 1277 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 1278 return true; 1279 } 1280 }; 1281