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