╭ 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 ui = @import("./ui.zig"); 7 const xitui = xit.xitui; 8 const wgt = xitui.widget; 9 const Focus = xitui.focus.Focus; 10 11 const cookie_name = "haxy_user"; 12 // flash cookie for surfacing the outcome of the most recent /login POST. 13 // set on the failure redirect, read and immediately expired on the next 14 // GET / so refreshing the page doesn't keep showing the error. 15 const login_failure_cookie = "haxy_login_failure"; 16 17 const Embed = struct { 18 path: []const u8, 19 content_type: []const u8, 20 body: []const u8, 21 }; 22 23 const embeds = [_]Embed{ 24 .{ .path = "index.html", .content_type = "text/html; charset=utf-8", .body = @embedFile("embed/index.html") }, 25 .{ .path = "script.js", .content_type = "text/javascript; charset=utf-8", .body = @embedFile("embed/script.js") }, 26 .{ .path = "term.ttf", .content_type = "font/ttf", .body = @embedFile("embed/term.ttf") }, 27 .{ .path = "haxy.wasm", .content_type = "application/wasm", .body = @embedFile("embed/haxy.wasm") }, 28 }; 29 30 pub fn run( 31 io: std.Io, 32 allocator: std.mem.Allocator, 33 net_server: *std.Io.net.Server, 34 tasks: *std.Io.Group, 35 admin_repo_path: []const u8, 36 err: *std.Io.Writer, 37 ) void { 38 const Listener = struct { 39 io: std.Io, 40 allocator: std.mem.Allocator, 41 net_server: *std.Io.net.Server, 42 tasks: *std.Io.Group, 43 admin_repo_path: []const u8, 44 err: *std.Io.Writer, 45 46 fn run(ctx: @This()) void { 47 while (true) { 48 const stream = ctx.net_server.accept(ctx.io) catch |accept_err| { 49 if (accept_err == error.Canceled) return; 50 logError(ctx.err, "web ui accept failed: {s}\n", .{@errorName(accept_err)}); 51 continue; 52 }; 53 54 const Connection = struct { 55 io: std.Io, 56 allocator: std.mem.Allocator, 57 stream: std.Io.net.Stream, 58 admin_repo_path: []const u8, 59 err: *std.Io.Writer, 60 61 fn run(conn: @This()) void { 62 defer conn.stream.close(conn.io); 63 handleConnection(conn.io, conn.allocator, conn.stream, conn.admin_repo_path, conn.err) catch |request_err| { 64 logError(conn.err, "web ui request failed: {s}\n", .{@errorName(request_err)}); 65 }; 66 } 67 }; 68 69 ctx.tasks.async(ctx.io, Connection.run, .{Connection{ 70 .io = ctx.io, 71 .allocator = ctx.allocator, 72 .stream = stream, 73 .admin_repo_path = ctx.admin_repo_path, 74 .err = ctx.err, 75 }}); 76 } 77 } 78 }; 79 80 tasks.async(io, Listener.run, .{Listener{ 81 .io = io, 82 .allocator = allocator, 83 .net_server = net_server, 84 .tasks = tasks, 85 .admin_repo_path = admin_repo_path, 86 .err = err, 87 }}); 88 } 89 90 fn handleConnection( 91 io: std.Io, 92 allocator: std.mem.Allocator, 93 stream: std.Io.net.Stream, 94 admin_repo_path: []const u8, 95 err: *std.Io.Writer, 96 ) !void { 97 var send_buffer = [_]u8{0} ** 4096; 98 var recv_buffer = [_]u8{0} ** 4096; 99 var conn_br = stream.reader(io, &recv_buffer); 100 var conn_bw = stream.writer(io, &send_buffer); 101 var http_server = std.http.Server.init(&conn_br.interface, &conn_bw.interface); 102 103 while (http_server.reader.state == .ready) { 104 var request = http_server.receiveHead() catch |receive_err| switch (receive_err) { 105 error.HttpConnectionClosing => break, 106 error.ReadFailed => break, 107 else => |e| return e, 108 }; 109 110 handleRequest(io, &http_server, &request, allocator, admin_repo_path) catch |request_err| { 111 try err.print("web ui request failed: {s}\n", .{@errorName(request_err)}); 112 try err.flush(); 113 if (http_server.reader.state == .received_head) { 114 http_server.reader.state = .ready; 115 } 116 try writeSimpleResponse(&http_server, 500, "Internal Server Error", "text/plain", @errorName(request_err)); 117 }; 118 try http_server.out.flush(); 119 break; 120 } 121 } 122 123 fn handleRequest( 124 io: std.Io, 125 http_server: *std.http.Server, 126 request: *std.http.Server.Request, 127 allocator: std.mem.Allocator, 128 admin_repo_path: []const u8, 129 ) !void { 130 const method = request.head.method; 131 const uri = try std.Uri.parseAfterScheme("", request.head.target); 132 const path = uri.path.percent_encoded; 133 134 if (method == .POST and std.mem.eql(u8, path, "/login")) { 135 return handleLogin(io, http_server, request, allocator, admin_repo_path); 136 } 137 if (method == .POST and std.mem.eql(u8, path, "/logout")) { 138 return handleLogout(http_server, request); 139 } 140 141 const get_or_head = method == .GET or method == .HEAD; 142 if (!get_or_head) { 143 if (http_server.reader.state == .received_head) { 144 http_server.reader.state = .ready; 145 } 146 try writeSimpleResponse(http_server, 405, "Method Not Allowed", "text/plain", "method not allowed"); 147 return; 148 } 149 150 if (std.mem.eql(u8, path, "/favicon.ico")) { 151 if (http_server.reader.state == .received_head) { 152 http_server.reader.state = .ready; 153 } 154 try writeStaticResponse(http_server, 204, "No Content", "image/x-icon", "", true); 155 return; 156 } 157 158 // for the index page we need to consult the session cookie before 159 // consuming the request, so we don't dispatch through findEmbed here. 160 if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/index.html")) { 161 const user_id_hex = getCookieValue(request, cookie_name); 162 const login_failure: ?ui.Home.Auth.Login.Failure = 163 if (getCookieValue(request, login_failure_cookie)) |raw| 164 if (std.mem.eql(u8, raw, "unknown_user")) 165 .unknown_user 166 else if (std.mem.eql(u8, raw, "wrong_password")) 167 .wrong_password 168 else 169 null 170 else 171 null; 172 if (http_server.reader.state == .received_head) { 173 http_server.reader.state = .ready; 174 } 175 const html = try renderIndexHtml(io, allocator, admin_repo_path, user_id_hex, login_failure); 176 defer allocator.free(html); 177 // expire the flash cookie on the way out — it's been consumed by 178 // this render and shouldn't survive a refresh. 179 const clear_flash: []const u8 = if (login_failure != null) 180 "Set-Cookie: " ++ login_failure_cookie ++ "=; Path=/; Max-Age=0\r\n" 181 else 182 ""; 183 try http_server.out.print( 184 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n{s}Content-Length: {d}\r\n\r\n", 185 .{ clear_flash, html.len }, 186 ); 187 if (method != .HEAD) try http_server.out.writeAll(html); 188 return; 189 } 190 191 const embed = findEmbed(path) orelse { 192 if (http_server.reader.state == .received_head) { 193 http_server.reader.state = .ready; 194 } 195 try writeSimpleResponse(http_server, 404, "Not Found", "text/plain", "not found"); 196 return; 197 }; 198 199 if (http_server.reader.state == .received_head) { 200 http_server.reader.state = .ready; 201 } 202 try writeStaticResponse(http_server, 200, "OK", embed.content_type, embed.body, method == .HEAD); 203 } 204 205 fn handleLogin( 206 io: std.Io, 207 http_server: *std.http.Server, 208 request: *std.http.Server.Request, 209 allocator: std.mem.Allocator, 210 admin_repo_path: []const u8, 211 ) !void { 212 var body_buf: [256]u8 = undefined; 213 const reader = request.readerExpectNone(&body_buf); 214 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; 215 defer allocator.free(body); 216 217 const username = (try parseFormField(allocator, body, "username")) orelse try allocator.dupe(u8, ""); 218 defer allocator.free(username); 219 const password = (try parseFormField(allocator, body, "password")) orelse try allocator.dupe(u8, ""); 220 defer allocator.free(password); 221 222 const repo_opts: rp.RepoOpts(.xit) = .{}; 223 const Repo = rp.Repo(.xit, repo_opts); 224 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 225 defer repo.deinit(io, allocator); 226 227 var arena = std.heap.ArenaAllocator.init(allocator); 228 defer arena.deinit(); 229 230 const haxy_moment = try openHaxyMoment(repo_opts, &repo); 231 const result = try evt.User.verifyCredentials(Repo.DB, repo_opts.hash, haxy_moment, &arena, username, password); 232 233 switch (result) { 234 .success => |user_id| { 235 const hex = std.fmt.bytesToHex(user_id, .lower); 236 try http_server.out.print( 237 "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict\r\nContent-Length: 0\r\n\r\n", 238 .{hex}, 239 ); 240 }, 241 .unknown_user => { 242 try http_server.out.print( 243 "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ login_failure_cookie ++ "=unknown_user; Path=/; HttpOnly; SameSite=Strict\r\nContent-Length: 0\r\n\r\n", 244 .{}, 245 ); 246 }, 247 .wrong_password => { 248 try http_server.out.print( 249 "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ login_failure_cookie ++ "=wrong_password; Path=/; HttpOnly; SameSite=Strict\r\nContent-Length: 0\r\n\r\n", 250 .{}, 251 ); 252 }, 253 } 254 } 255 256 fn handleLogout(http_server: *std.http.Server, request: *std.http.Server.Request) !void { 257 // we don't need the body, but the http server wants the request consumed 258 // before the next one (and before we write the response). 259 var sink_buf: [64]u8 = undefined; 260 _ = request.readerExpectNone(&sink_buf).discardRemaining() catch {}; 261 262 try http_server.out.print( 263 "HTTP/1.1 303 See Other\r\nLocation: /\r\nSet-Cookie: " ++ cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0\r\nContent-Length: 0\r\n\r\n", 264 .{}, 265 ); 266 } 267 268 fn renderIndexHtml( 269 io: std.Io, 270 allocator: std.mem.Allocator, 271 admin_repo_path: []const u8, 272 user_id_hex_opt: ?[]const u8, 273 login_failure: ?ui.Home.Auth.Login.Failure, 274 ) ![]const u8 { 275 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 276 277 // open the admin repo to read live user/repo data. 278 const repo_opts: rp.RepoOpts(.xit) = .{}; 279 const Repo = rp.Repo(.xit, repo_opts); 280 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 281 defer repo.deinit(io, allocator); 282 var page_arena = std.heap.ArenaAllocator.init(allocator); 283 defer page_arena.deinit(); 284 285 var session: ui.Session = .{}; 286 if (user_id_hex_opt) |hex| { 287 if (decodeHexAlloc(page_arena.allocator(), hex)) |bytes| { 288 session.data.user_id = bytes; 289 } else |_| {} 290 } 291 session.data.login_failure = login_failure; 292 293 const snapshot: ui.Snapshot = .{ 294 .page = .{ .home = try .init(repo_opts, &page_arena, &repo, &session) }, 295 .session = session.data, 296 }; 297 var root = try ui.initRoot(allocator, &snapshot.page, &session); 298 defer root.deinit(allocator); 299 300 const content = try generateHtml(allocator, &root, &session); 301 defer allocator.free(content); 302 303 // serialize the snapshot so the wasm side can parse it back without 304 // making a second request. base64-encoded so the json can be embedded 305 // safely inside the host html. 306 var json: std.Io.Writer.Allocating = .init(allocator); 307 defer json.deinit(); 308 try std.json.Stringify.value(snapshot, .{}, &json.writer); 309 310 const b64 = std.base64.standard.Encoder; 311 const json_b64 = try allocator.alloc(u8, b64.calcSize(json.written().len)); 312 defer allocator.free(json_b64); 313 _ = b64.encode(json_b64, json.written()); 314 315 var out: std.ArrayList(u8) = .empty; 316 errdefer out.deinit(allocator); 317 { 318 var cursor: usize = 0; 319 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 320 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 321 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_b64 }, 322 }) |sub| { 323 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 324 try out.appendSlice(allocator, template[cursor..idx]); 325 try out.appendSlice(allocator, sub.replacement); 326 cursor = idx + sub.needle.len; 327 } 328 try out.appendSlice(allocator, template[cursor..]); 329 } 330 return try out.toOwnedSlice(allocator); 331 } 332 333 fn findEmbed(request_path: []const u8) ?Embed { 334 const path = if (std.mem.eql(u8, request_path, "/")) 335 "index.html" 336 else if (request_path.len > 1 and request_path[0] == '/') 337 request_path[1..] 338 else 339 return null; 340 341 for (embeds) |embed| { 342 if (std.mem.eql(u8, path, embed.path)) return embed; 343 } 344 return null; 345 } 346 347 fn writeSimpleResponse( 348 http_server: *std.http.Server, 349 code: u16, 350 message: []const u8, 351 content_type: []const u8, 352 body: []const u8, 353 ) !void { 354 try http_server.out.print( 355 "HTTP/1.1 {d} {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n{s}", 356 .{ code, message, content_type, body.len, body }, 357 ); 358 } 359 360 fn writeStaticResponse( 361 http_server: *std.http.Server, 362 code: u16, 363 message: []const u8, 364 content_type: []const u8, 365 body: []const u8, 366 head_only: bool, 367 ) !void { 368 try http_server.out.print( 369 "HTTP/1.1 {d} {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n", 370 .{ code, message, content_type, body.len }, 371 ); 372 if (!head_only) { 373 try http_server.out.writeAll(body); 374 } 375 } 376 377 fn logError(err: *std.Io.Writer, comptime fmt: []const u8, args: anytype) void { 378 err.print(fmt, args) catch return; 379 err.flush() catch {}; 380 } 381 382 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget, session: *const ui.Session) ![]const u8 { 383 const grid = root.getGrid() orelse return error.MissingGrid; 384 const root_focus = root.getFocus(); 385 386 var out: std.ArrayList(u8) = .empty; 387 errdefer out.deinit(allocator); 388 389 // determines the form route for any submit_button on this page. only one 390 // is visible at a time: login when logged out, logout when logged in. 391 const submit_url: []const u8 = if (session.data.user_id != null) "/logout" else "/login"; 392 393 // collect the text inputs in deterministic top-to-bottom, left-to-right 394 // order. root_focus.children is a hash map so iterating it directly would 395 // give arbitrary tab order between username / password. 396 const InputEntry = struct { 397 focus_id: usize, 398 x: usize, 399 y: usize, 400 width: usize, 401 is_password: bool, 402 ti: *wgt.TextInput(ui.Widget), 403 }; 404 var inputs: std.ArrayList(InputEntry) = .empty; 405 defer inputs.deinit(allocator); 406 var focus_iter = root_focus.children.iterator(); 407 while (focus_iter.next()) |entry| { 408 const child = entry.value_ptr.*; 409 const is_password = switch (child.focus.kind) { 410 .text_input => false, 411 .text_input_password => true, 412 else => continue, 413 }; 414 try inputs.append(allocator, .{ 415 .focus_id = entry.key_ptr.*, 416 .x = child.rect.x, 417 .y = child.rect.y, 418 .width = child.rect.size.width, 419 .is_password = is_password, 420 .ti = @fieldParentPtr("focus", child.focus), 421 }); 422 } 423 std.mem.sort(InputEntry, inputs.items, {}, struct { 424 fn lt(_: void, a: InputEntry, b: InputEntry) bool { 425 if (a.y != b.y) return a.y < b.y; 426 return a.x < b.x; 427 } 428 }.lt); 429 430 // emit the overlay inputs FIRST so they precede any submit-button span 431 // in DOM order; with the submit button carrying tabindex="0", that puts 432 // it at the end of the natural tab order — username -> password -> button. 433 for (inputs.items) |entry| { 434 const inner_left = entry.x + 1; 435 const inner_top = entry.y + 1; 436 const inner_width = if (entry.width > 2) entry.width - 2 else 0; 437 438 try out.appendSlice(allocator, "<input type=\""); 439 try out.appendSlice(allocator, if (entry.is_password) "password" else "text"); 440 try out.appendSlice(allocator, "\" data-focus-id=\""); 441 var id_buf: [32]u8 = undefined; 442 try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{entry.focus_id})); 443 if (entry.ti.options.name.len > 0) { 444 try out.appendSlice(allocator, "\" name=\""); 445 try appendEscapedHtml(allocator, &out, entry.ti.options.name); 446 } 447 // mark the wasm-focused element so JS can mirror it onto DOM focus 448 // — otherwise moving focus via arrow keys leaves the TUI border 449 // showing focus but the underlying <input> unfocused. 450 if (entry.focus_id == root_focus.grandchild_id) { 451 try out.appendSlice(allocator, "\" data-focused=\"true"); 452 } 453 try out.appendSlice(allocator, "\" value=\""); 454 var value_buf: std.ArrayList(u8) = .empty; 455 defer value_buf.deinit(allocator); 456 for (entry.ti.content.items) |cp| { 457 try value_buf.appendSlice(allocator, cp); 458 } 459 try appendEscapedHtml(allocator, &out, value_buf.items); 460 try out.appendSlice(allocator, "\" style=\"left:"); 461 var pos_buf: [64]u8 = undefined; 462 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 })); 463 try out.appendSlice(allocator, "\">"); 464 } 465 466 for (0..grid.size.height) |y| { 467 // wrap runs of cells belonging to a focusable widget in a span tagged 468 // with that widget's focus id. CSS paints them with a pointer cursor; 469 // JS reads the id on click and dispatches focus directly. 470 var current_id: ?usize = null; 471 for (0..grid.size.width) |x| { 472 const cell_id = cellFocusId(root_focus, x, y); 473 if (cell_id != current_id) { 474 if (current_id != null) try out.appendSlice(allocator, "</span>"); 475 if (cell_id) |id| { 476 const kind = if (root_focus.children.get(id)) |entry| entry.focus.kind else .container; 477 const focused_attr: []const u8 = if (id == root_focus.grandchild_id) " data-focused=\"true\"" else ""; 478 var buf: [256]u8 = undefined; 479 // tabindex="0" on submit buttons puts them in the browser's 480 // natural Tab order alongside the inputs above. 481 const tag = if (kind == .submit_button) 482 try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\" data-action=\"submit\" data-url=\"{s}\" tabindex=\"0\"{s}>", .{ id, submit_url, focused_attr }) 483 else 484 try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{d}\"{s}>", .{ id, focused_attr }); 485 try out.appendSlice(allocator, tag); 486 } 487 current_id = cell_id; 488 } 489 const rune = grid.cells.items[try grid.cells.at(.{ y, x })].rune orelse " "; 490 try appendEscapedHtml(allocator, &out, rune); 491 } 492 if (current_id != null) try out.appendSlice(allocator, "</span>"); 493 try out.append(allocator, '\n'); 494 } 495 496 return try out.toOwnedSlice(allocator); 497 } 498 499 fn cellFocusId(focus: *Focus, x: usize, y: usize) ?usize { 500 var iter = focus.children.iterator(); 501 while (iter.next()) |entry| { 502 const child = entry.value_ptr.*; 503 if (!child.focus.focusable) continue; 504 const r = child.rect; 505 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 506 return entry.key_ptr.*; 507 } 508 } 509 return null; 510 } 511 512 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 513 for (input) |ch| { 514 switch (ch) { 515 '&' => try out.appendSlice(allocator, "&amp;"), 516 '<' => try out.appendSlice(allocator, "&lt;"), 517 '>' => try out.appendSlice(allocator, "&gt;"), 518 '"' => try out.appendSlice(allocator, "&quot;"), 519 '\'' => try out.appendSlice(allocator, "&#39;"), 520 else => try out.append(allocator, ch), 521 } 522 } 523 } 524 525 // --- helpers -------------------------------------------------------------- 526 527 fn openHaxyMoment( 528 comptime repo_opts: rp.RepoOpts(.xit), 529 repo: *rp.Repo(.xit, repo_opts), 530 ) !rp.Repo(.xit, repo_opts).DB.HashMap(.read_only) { 531 const DB = rp.Repo(.xit, repo_opts).DB; 532 const history = try DB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); 533 const moment_cursor = try history.getCursor(-1) orelse return error.NotFound; 534 const moment = try DB.HashMap(.read_only).init(moment_cursor); 535 const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy-last-object-id")) orelse return error.NotFound; 536 var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; 537 _ = try last_object_id_cursor.readBytes(&last_object_id); 538 const haxy_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy")) orelse return error.NotFound; 539 const haxy = try DB.ArrayList(.read_only).init(haxy_cursor); 540 const haxy_moments_cursor = try haxy.getCursor(-1) orelse return error.NotFound; 541 const haxy_moments = try DB.HashMap(.read_only).init(haxy_moments_cursor); 542 const haxy_moment_cursor = try haxy_moments.getCursor(hash.bytesToInt(repo_opts.hash, &last_object_id)) orelse return error.NotFound; 543 return try DB.HashMap(.read_only).init(haxy_moment_cursor); 544 } 545 546 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 547 var iter = request.iterateHeaders(); 548 while (iter.next()) |header| { 549 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 550 var pairs = std.mem.splitScalar(u8, header.value, ';'); 551 while (pairs.next()) |pair| { 552 const trimmed = std.mem.trim(u8, pair, " \t"); 553 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 554 if (std.mem.eql(u8, trimmed[0..eq], name)) { 555 return trimmed[eq + 1 ..]; 556 } 557 } 558 } 559 return null; 560 } 561 562 fn decodeHexAlloc(allocator: std.mem.Allocator, hex: []const u8) ![]u8 { 563 if (hex.len % 2 != 0) return error.InvalidHex; 564 const bytes = try allocator.alloc(u8, hex.len / 2); 565 errdefer allocator.free(bytes); 566 _ = std.fmt.hexToBytes(bytes, hex) catch return error.InvalidHex; 567 return bytes; 568 } 569 570 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 571 var iter = std.mem.splitScalar(u8, body, '&'); 572 while (iter.next()) |pair| { 573 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 574 if (std.mem.eql(u8, pair[0..eq], key)) { 575 return try decodeFormValue(allocator, pair[eq + 1 ..]); 576 } 577 } 578 return null; 579 } 580 581 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 582 var out: std.ArrayList(u8) = .empty; 583 errdefer out.deinit(allocator); 584 var i: usize = 0; 585 while (i < encoded.len) : (i += 1) { 586 switch (encoded[i]) { 587 '+' => try out.append(allocator, ' '), 588 '%' => { 589 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 590 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 591 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 592 try out.append(allocator, hi * 16 + lo); 593 i += 2; 594 }, 595 else => try out.append(allocator, encoded[i]), 596 } 597 } 598 return try out.toOwnedSlice(allocator); 599 } 600