╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
456 .{ .name = "set-cookie", .value = cookie_name ++ "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" }, 457 }, 458 }); 459 } 460 461 fn handleAnsi( 462 io: std.Io, 463 request: *std.http.Server.Request, 464 allocator: std.mem.Allocator, 465 base: []const u8, 466 admin_repo_path: []const u8, 467 session_store: SessionStore, 468 ) !void { 469 // the toggle re-emits the user's own event, so it takes a logged-in user 470 var user_id: [evt.event_id_size]u8 = undefined; 471 const token = getCookieValue(request, cookie_name) orelse return respondLoginRequired(request); 472 if (!session_store.lookup(token, &user_id)) return respondLoginRequired(request); 473 474 { 475 const Repo = rp.Repo(.xit, evt.admin_repo_opts); 476 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 477 defer repo.deinit(io, allocator); 478 479 try evt.User.toggleAnsi(evt.admin_repo_opts, io, allocator, &repo, &user_id); 480 } 481 482 // return to the settings tab the toggle came from so the change is visible. 483 const location = try std.fmt.allocPrint(allocator, "{s}/settings", .{base}); 484 defer allocator.free(location); 485 486 // like logout, this is a bodyless POST, so close the connection rather than 487 // letting the keep-alive path try to discard a body that isn't framed. 488 try request.respond("", .{ 489 .status = .see_other, 490 .keep_alive = false, 491 .extra_headers = &.{ 492 .{ .name = "location", .value = location }, 493 }, 494 }); 495 } 496 497 const RequestAuthor = struct { 498 author: evt.CommitAuthor, 499 user_id: ?[evt.event_id_size]u8 = null, 500 }; 501 502 // the author and user id for an event created by this request, or null when the 503 // request may not create one. local mode has no accounts, so it has no user id. 504 fn eventAuthor( 505 io: std.Io, 506 allocator: std.mem.Allocator, 507 arena: *std.heap.ArenaAllocator, 508 request: *std.http.Server.Request, 509 host: Host, 510 ) !?RequestAuthor { 511 const remote = switch (host) { 512 .remote => |remote| remote, 513 .local => |src| return .{ .author = try ui.localAuthor(src, io, allocator, arena) }, 514 }; 515 const token = getCookieValue(request, cookie_name) orelse return null; 516 var user_id: [evt.event_id_size]u8 = undefined; 517 if (!remote.session_store.lookup(token, &user_id)) return null; 518 519 var repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 520 defer repo.deinit(io, allocator); 521 const moment = try evt.currentMoment(evt.admin_repo_opts, &repo); 522 const user = (try evt.User.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, arena, &user_id)) orelse return null; 523 return .{ 524 .author = .{ .name = user.event.name, .email = user.event.email }, 525 .user_id = user_id, 526 }; 527 } 528 529 // refuse a write from a request with no logged-in user. the body goes unread, 530 // so the connection closes. 531 fn respondLoginRequired(request: *std.http.Server.Request) !void { 532 try request.respond("log in to make changes", .{ 533 .status = .forbidden, 534 .keep_alive = false, 535 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 536 }); 537 } 538 539 // new actions share a suffix; the base identifies whether the form creates a 540 // thread or comment. 541 fn handleNew( 542 io: std.Io, 543 request: *std.http.Server.Request, 544 allocator: std.mem.Allocator, 545 base: []const u8, 546 host: Host, 547 ) !void { 548 if (commentBaseParts(base) != null) return handleCommentNew(io, request, allocator, base, host); 549 550 const issues_suffix = "/issues"; 551 if (std.mem.endsWith(u8, base, issues_suffix)) 552 return handleThreadNew(io, request, allocator, base[0 .. base.len - issues_suffix.len], host, .issue); 553 const discussions_suffix = "/discussions"; 554 if (std.mem.endsWith(u8, base, discussions_suffix)) 555 return handleThreadNew(io, request, allocator, base[0 .. base.len - discussions_suffix.len], host, .discuss); 556 const patches_suffix = "/patches"; 557 if (std.mem.endsWith(u8, base, patches_suffix)) 558 return handleThreadNew(io, request, allocator, base[0 .. base.len - patches_suffix.len], host, .patch); 559 560 try request.respond("new event target not found", .{ 561 .status = .not_found, 562 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 563 }); 564 } 565 566 // create a thread in the repo the form's page names and redirect to it. 567 fn handleThreadNew( 568 io: std.Io, 569 request: *std.http.Server.Request, 570 allocator: std.mem.Allocator, 571 base: []const u8, 572 host: Host, 573 kind: evt.EventKind, 574 ) !void { 575 var author_arena = std.heap.ArenaAllocator.init(allocator); 576 defer author_arena.deinit(); 577 const request_author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 578 const author = request_author.author; 579 const body = try readFormBody(request, allocator); 580 defer allocator.free(body); 581 582 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 583 defer allocator.free(title); 584 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 585 defer allocator.free(tags); 586 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 587 defer allocator.free(description_crlf); 588 // form submission normalizes textarea line breaks to CRLF; store plain 589 // newlines so the text renders the same on every host 590 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 591 defer allocator.free(description); 592 const target_branch = (try parseFormField(allocator, body, "target_branch")) orelse try allocator.dupe(u8, ""); 593 defer allocator.free(target_branch); 594 595 const valid = switch (kind) { 596 .issue => evt.Issue.fieldsValid(title, tags), 597 .patch => evt.Patch.fieldsValid(title, tags), 598 .discuss => evt.Discussion.fieldsValid(title, tags), 599 else => unreachable, 600 }; 601 if (!valid) { 602 const list_name: []const u8 = switch (kind) { 603 .issue => "issues", 604 .patch => "patches", 605 .discuss => "discussions", 606 else => unreachable, 607 }; 608 const form_location = try std.fmt.allocPrint(allocator, "{s}/{s}/new", .{ base, list_name }); 609 defer allocator.free(form_location); 610 if (!evt.titleValid(title)) { 611 return respondThreadFormFailure(request, allocator, host, form_location, requiredTitleFeedback(kind, title, tags, description, target_branch)); 612 } 613 try request.respond("", .{ 614 .status = .see_other, 615 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 616 }); 617 return; 618 } 619 620 var id_bytes: [evt.event_id_size]u8 = undefined; 621 io.random(&id_bytes); 622 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 623 624 if (kind == .patch) { 625 const remote = switch (host) { 626 .remote => |remote| remote, 627 .local => return respondRemoveNotFound(request), 628 }; 629 const user_id = request_author.user_id orelse return respondLoginRequired(request); 630 const request_repo = (try requestRepoSource(io, allocator, host, base)) orelse return respondRemoveNotFound(request); 631 defer request_repo.deinit(allocator); 632 if (!try request_repo.source.hasBranch(io, allocator, target_branch)) { 633 const form_location = try std.fmt.allocPrint(allocator, "{s}/patches/new", .{base}); 634 defer allocator.free(form_location); 635 return respondThreadFormFailure(request, allocator, host, form_location, invalidTargetBranchFeedback(title, tags, description, target_branch)); 636 } 637 const repo_id = evt.parseEventId(std.fs.path.basename(request_repo.source.path)) catch return respondRemoveNotFound(request); 638 var admin_repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 639 defer admin_repo.deinit(io, allocator); 640 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 641 defer allocator.free(repos_dir); 642 const fork_path = try fork.create(.{}, io, allocator, repos_dir, &admin_repo, .{ 643 .id = event_id_hex, 644 .user_id = user_id, 645 .repo_id = repo_id, 646 .title = title, 647 .description = description, 648 .tags = tags, 649 .target_branch = target_branch, 650 .author = author, 651 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 652 }); 653 defer allocator.free(fork_path); 654 const location = try std.fmt.allocPrint(allocator, "{s}/patch:{s}", .{ base, &event_id_hex }); 655 defer allocator.free(location); 656 try request.respond("", .{ .status = .see_other, .extra_headers = &.{.{ .name = "location", .value = location }} }); 657 return; 658 } 659 660 const event = evt.EventWithId{ 661 .id = event_id_hex, 662 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 663 .author = author, 664 .event = switch (kind) { 665 .issue => .{ .issue = .{ .title = title, .description = description, .tags = tags } }, 666 .patch => unreachable, 667 .discuss => .{ .discuss = .{ .title = title, .description = description, .tags = tags } }, 668 else => unreachable, 669 }, 670 }; 671 672 const not_found = "repo not found"; 673 const request_repo = (try requestRepoSource(io, allocator, host, 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 defer request_repo.deinit(allocator); 681 const source = request_repo.source; 682 switch (source.repo_kind) { 683 inline else => |repo_kind| { 684 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); 685 defer any_repo.deinit(io, allocator); 686 switch (any_repo) { 687 inline else => |*repo| try evt.consume(.repo, repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{event}), 688 } 689 }, 690 } 691 692 const location = try std.fmt.allocPrint(allocator, "{s}/{s}:{s}", .{ base, @tagName(kind), &event_id_hex }); 693 defer allocator.free(location); 694 try request.respond("", .{ 695 .status = .see_other, 696 .extra_headers = &.{.{ .name = "location", .value = location }}, 697 }); 698 } 699 700 const CommentBaseParts = struct { 701 repo_base: []const u8, 702 thread_kind: evt.EventKind, 703 thread_id: [evt.event_id_size]u8, 704 comment_id: ?[evt.event_id_size]u8, 705 }; 706 707 fn commentBaseParts(base: []const u8) ?CommentBaseParts { 708 const route = ui.RoutablePage.fromUrl(base) orelse ui.RoutablePage.fromUrlLocal(base) orelse return null; 709 const identity, const thread_kind, const thread_hex, const comment_hex = switch (route) { 710 .repo_issues => |*issue| .{ issue.name.slice(), evt.EventKind.issue, issue.selected.slice(), issue.comment.slice() }, 711 .repo_patches => |*patch| .{ patch.name.slice(), evt.EventKind.patch, patch.selected.slice(), patch.comment.slice() }, 712 .repo_discussions => |*discussion| .{ discussion.name.slice(), evt.EventKind.discuss, discussion.selected.slice(), discussion.comment.slice() }, 713 else => return null, 714 }; 715 const thread_id = evt.parseEventId(thread_hex) catch return null; 716 const comment_id = if (comment_hex.len == 0) null else evt.parseEventId(comment_hex) catch return null; 717 const repo_base_len = if (identity.len == 0) 0 else "/repo/".len + identity.len; 718 return .{ .repo_base = base[0..repo_base_len], .thread_kind = thread_kind, .thread_id = thread_id, .comment_id = comment_id }; 719 } 720 721 fn handlePatchPublish( 722 io: std.Io, 723 request: *std.http.Server.Request, 724 allocator: std.mem.Allocator, 725 base: []const u8, 726 host: Host, 727 ) !void { 728 const parts = commentBaseParts(base) orelse return respondRemoveNotFound(request); 729 if (parts.thread_kind != .patch or parts.comment_id != null) return respondRemoveNotFound(request); 730 const remote = switch (host) { 731 .remote => |remote| remote, 732 .local => return respondRemoveNotFound(request), 733 }; 734 var author_arena = std.heap.ArenaAllocator.init(allocator); 735 defer author_arena.deinit(); 736 const request_author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 737 const user_id = request_author.user_id orelse return respondLoginRequired(request); 738 const author = request_author.author; 739 740 const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse return respondRemoveNotFound(request); 741 defer request_repo.deinit(allocator); 742 const repo_id = evt.parseEventId(std.fs.path.basename(request_repo.source.path)) catch return respondRemoveNotFound(request); 743 var target_repo = try rp.Repo(.xit, .{}).open(io, allocator, request_repo.source.localInitOpts()); 744 defer target_repo.deinit(io, allocator); 745 var admin_repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 746 defer admin_repo.deinit(io, allocator); 747 const id = std.fmt.bytesToHex(parts.thread_id, .lower); 748 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 749 defer allocator.free(repos_dir); 750 const fork_path = try fork.forkPath(allocator, repos_dir, &id); 751 defer allocator.free(fork_path); 752 try pch.publish(.{}, io, allocator, &admin_repo, &target_repo, fork_path, .{ 753 .id = id, 754 .user_id = user_id, 755 .repo_id = repo_id, 756 .author = author, 757 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 758 }); 759 try request.respond("", .{ 760 .status = .see_other, 761 .extra_headers = &.{.{ .name = "location", .value = base }}, 762 }); 763 } 764 765 fn handlePatchMerge( 766 io: std.Io, 767 request: *std.http.Server.Request, 768 allocator: std.mem.Allocator, 769 base: []const u8, 770 host: Host, 771 revision: pch.MergeRevision, 772 ) !void { 773 const parts = commentBaseParts(base) orelse return respondRemoveNotFound(request); 774 if (parts.thread_kind != .patch or parts.comment_id != null) return respondRemoveNotFound(request); 775 const remote = switch (host) { 776 .remote => |remote| remote, 777 .local => return respondRemoveNotFound(request), 778 }; 779 var author_arena = std.heap.ArenaAllocator.init(allocator); 780 defer author_arena.deinit(); 781 const author = ((try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request)).author; 782 783 const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse return respondRemoveNotFound(request); 784 defer request_repo.deinit(allocator); 785 var target_repo = try rp.Repo(.xit, .{}).open(io, allocator, request_repo.source.localInitOpts()); 786 defer target_repo.deinit(io, allocator); 787 var admin_repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 788 defer admin_repo.deinit(io, allocator); 789 790 const id = std.fmt.bytesToHex(parts.thread_id, .lower); 791 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 792 defer allocator.free(repos_dir); 793 try pch.mergeAndRemoveFork(.{}, io, allocator, repos_dir, &admin_repo, &target_repo, .{ 794 .id = id, 795 .revision = revision, 796 .author = author, 797 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 798 }); 799 800 const location = try std.fmt.allocPrint(allocator, "{s}/patch:{s}", .{ parts.repo_base, &id }); 801 defer allocator.free(location); 802 try request.respond("", .{ 803 .status = .see_other, 804 .keep_alive = false, 805 .extra_headers = &.{.{ .name = "location", .value = location }}, 806 }); 807 } 808 809 // create a reply and redirect to its permalink. 810 fn handleCommentNew( 811 io: std.Io, 812 request: *std.http.Server.Request, 813 allocator: std.mem.Allocator, 814 base: []const u8, 815 host: Host, 816 ) !void { 817 const not_found = "comment parent not found"; 818 const parts = commentBaseParts(base) orelse { 819 try request.respond(not_found, .{ 820 .status = .not_found, 821 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 822 }); 823 return; 824 }; 825 826 var author_arena = std.heap.ArenaAllocator.init(allocator); 827 defer author_arena.deinit(); 828 const author = ((try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request)).author; 829 830 const form_body = try readFormBody(request, allocator); 831 defer allocator.free(form_body); 832 const body_crlf = (try parseFormField(allocator, form_body, "body")) orelse try allocator.dupe(u8, ""); 833 defer allocator.free(body_crlf); 834 const body = try std.mem.replaceOwned(u8, allocator, body_crlf, "\r\n", "\n"); 835 defer allocator.free(body); 836 837 if (!evt.Comment.fieldsValid(body)) { 838 const form_location = try std.fmt.allocPrint(allocator, "{s}/new", .{base}); 839 defer allocator.free(form_location); 840 try request.respond("", .{ 841 .status = .see_other, 842 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 843 }); 844 return; 845 } 846 847 const thread_id_hex = std.fmt.bytesToHex(parts.thread_id, .lower); 848 const parent_id_hex = std.fmt.bytesToHex(parts.comment_id orelse parts.thread_id, .lower); 849 850 const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse { 851 try request.respond(not_found, .{ 852 .status = .not_found, 853 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 854 }); 855 return; 856 }; 857 defer request_repo.deinit(allocator); 858 const source = request_repo.source; 859 var event_id_hex: [evt.event_id_size * 2]u8 = undefined; 860 switch (source.repo_kind) { 861 inline else => |repo_kind| { 862 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); 863 defer any_repo.deinit(io, allocator); 864 switch (any_repo) { 865 inline else => |*repo| event_id_hex = try evt.Comment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id_hex, &parent_id_hex, body, author), 866 } 867 }, 868 } 869 870 const location = try std.fmt.allocPrint(allocator, "{s}/{s}:{s}/comment:{s}", .{ parts.repo_base, @tagName(parts.thread_kind), &thread_id_hex, &event_id_hex }); 871 defer allocator.free(location); 872 try request.respond("", .{ 873 .status = .see_other, 874 .extra_headers = &.{.{ .name = "location", .value = location }}, 875 }); 876 } 877 878 const AttachmentRequest = struct { 879 repo_base: []const u8, 880 id: [evt.event_id_size]u8, 881 }; 882 883 // an attachment url is its repo's base and the attachment's event id 884 fn attachmentRequest(path: []const u8) ?AttachmentRequest { 885 const infix = "/attachment:"; 886 const at = std.mem.lastIndexOf(u8, path, infix) orelse return null; 887 const tail = path[at + infix.len ..]; 888 const id = evt.parseEventId(tail) catch return null; 889 return .{ .repo_base = path[0..at], .id = id }; 890 } 891 892 // serve an attachment's bytes from the repo its url names 893 fn serveAttachment( 894 io: std.Io, 895 request: *std.http.Server.Request, 896 allocator: std.mem.Allocator, 897 attachment: AttachmentRequest, 898 host: Host, 899 ) !void { 900 const request_repo = (try requestRepoSource(io, allocator, host, attachment.repo_base)) orelse return respondAttachmentNotFound(request); 901 defer request_repo.deinit(allocator); 902 const source = request_repo.source; 903 switch (source.repo_kind) { 904 inline else => |repo_kind| { 905 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); 906 defer any_repo.deinit(io, allocator); 907 switch (any_repo) { 908 inline else => |*repo| try sendAttachment(repo_kind, repo.self_repo_opts, io, request, allocator, repo, &attachment.id), 909 } 910 }, 911 } 912 } 913 914 fn sendAttachment( 915 comptime repo_kind: rp.RepoKind, 916 comptime repo_opts: rp.RepoOpts(repo_kind), 917 io: std.Io, 918 request: *std.http.Server.Request, 919 allocator: std.mem.Allocator, 920 repo: *rp.Repo(repo_kind, repo_opts), 921 id: *const [evt.event_id_size]u8, 922 ) !void { 923 var arena = std.heap.ArenaAllocator.init(allocator); 924 defer arena.deinit(); 925 926 const DB = evt.EventDB(repo_opts.hash); 927 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; 928 defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); 929 const haxy_moment = (if (event_db_maybe) |*event_db| 930 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 931 else if (repo_kind == .git) 932 return respondAttachmentNotFound(request) 933 else 934 evt.currentMoment(repo_opts, repo)) catch return respondAttachmentNotFound(request); 935 936 const record = (try evt.Attachment.readById(DB, repo_opts.hash, haxy_moment, &arena, id)) orelse return respondAttachmentNotFound(request); 937 if (record.removed) return respondAttachmentNotFound(request); 938 if (record.blob_oid.len != hash.hexLen(repo_opts.hash)) return respondAttachmentNotFound(request); 939 const blob_oid: *const [hash.hexLen(repo_opts.hash)]u8 = @ptrCast(record.blob_oid.ptr); 940 941 var moment = repo.core.latestMoment() catch return respondAttachmentNotFound(request); 942 const state = rp.Repo(repo_kind, repo_opts).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; 943 var object_reader = obj.ObjectReader(repo_kind, repo_opts).init(state, io, allocator, blob_oid) catch return respondAttachmentNotFound(request); 944 defer object_reader.deinit(); 945 if (object_reader.header().kind != .blob) return respondAttachmentNotFound(request); 946 947 const content_type = attachmentContentType(record.name); 948 const encoded_name = try ui.urlEncodeRef(allocator, record.name); 949 defer allocator.free(encoded_name); 950 const disposition = try std.fmt.allocPrint(allocator, "{s}; filename*=UTF-8''{s}", .{ if (content_type == null) "attachment" else "inline", encoded_name }); 951 defer allocator.free(disposition); 952 953 var send_buf: [8192]u8 = undefined; 954 var body = try request.respondStreaming(&send_buf, .{ 955 .content_length = object_reader.header().size, 956 .respond_options = .{ 957 .extra_headers = &.{ 958 .{ .name = "content-type", .value = content_type orelse "application/octet-stream" }, 959 .{ .name = "content-disposition", .value = disposition }, 960 // uploaded bytes are never sniffed into a type that could run 961 // as script on this origin 962 .{ .name = "x-content-type-options", .value = "nosniff" }, 963 }, 964 }, 965 }); 966 _ = try object_reader.interface.streamRemaining(&body.writer); 967 try body.end(); 968 } 969 970 // the images a browser renders safely inline. anything else downloads as 971 // opaque bytes, so an uploaded page can't run on this origin. 972 fn attachmentContentType(name: []const u8) ?[]const u8 { 973 const types = [_]struct { ext: []const u8, value: []const u8 }{ 974 .{ .ext = ".png", .value = "image/png" }, 975 .{ .ext = ".jpg", .value = "image/jpeg" }, 976 .{ .ext = ".jpeg", .value = "image/jpeg" }, 977 .{ .ext = ".gif", .value = "image/gif" }, 978 .{ .ext = ".webp", .value = "image/webp" }, 979 }; 980 for (types) |entry| { 981 if (name.len > entry.ext.len and std.ascii.eqlIgnoreCase(name[name.len - entry.ext.len ..], entry.ext)) return entry.value; 982 } 983 return null; 984 } 985 986 fn respondAttachmentNotFound(request: *std.http.Server.Request) !void { 987 try request.respond("attachment not found", .{ 988 .status = .not_found, 989 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 990 }); 991 } 992 993 fn respondAttachmentParentNotFound(request: *std.http.Server.Request) !void { 994 try request.respond("attachment parent not found", .{ 995 .status = .not_found, 996 .keep_alive = false, 997 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 998 }); 999 } 1000 1001 // the upload declares its file's name here, since the body is the file itself 1002 const attachment_name_header = "x-attachment-name"; 1003 1004 const AttachParentParts = struct { 1005 repo_base: []const u8, 1006 id_bytes: [evt.event_id_size]u8, 1007 }; 1008 1009 // what an attachment hangs off: the `<kind>:<id>` segment the form's page ends 1010 // with, whatever kind that is 1011 fn attachParentParts(base: []const u8) ?AttachParentParts { 1012 const segment_at = std.mem.lastIndexOfScalar(u8, base, '/') orelse return null; 1013 const segment = base[segment_at + 1 ..]; 1014 const colon_at = std.mem.lastIndexOfScalar(u8, segment, ':') orelse return null; 1015 const id_bytes = evt.parseEventId(segment[colon_at + 1 ..]) catch return null; 1016 return .{ .repo_base = base[0..segment_at], .id_bytes = id_bytes }; 1017 } 1018 1019 // attach the uploaded file to the event the url names, then reload. the file is 1020 // the whole request body, so it streams from the socket into the object store 1021 // without being buffered. 1022 fn handleAttach( 1023 io: std.Io, 1024 request: *std.http.Server.Request, 1025 allocator: std.mem.Allocator, 1026 base: []const u8, 1027 host: Host, 1028 ) !void { 1029 const parts = attachParentParts(base) orelse return respondAttachmentParentNotFound(request); 1030 1031 var author_arena = std.heap.ArenaAllocator.init(allocator); 1032 defer author_arena.deinit(); 1033 const author = ((try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request)).author; 1034 1035 const name = (try attachmentName(allocator, request)) orelse return respondUploadError(request, .bad_request, "attachment needs a name"); 1036 defer allocator.free(name); 1037 if (!evt.Attachment.nameValid(name)) return respondUploadError(request, .bad_request, "invalid attachment name"); 1038 1039 const size = request.head.content_length orelse return respondUploadError(request, .length_required, "attachments must declare a content length"); 1040 if (size == 0) return respondUploadError(request, .bad_request, "attachment is empty"); 1041 if (size > evt.Attachment.max_size) return respondUploadError(request, .payload_too_large, "attachment is too large"); 1042 1043 const parent_id_hex = std.fmt.bytesToHex(parts.id_bytes, .lower); 1044 1045 var body_buf: [4096]u8 = undefined; 1046 const blob: evt.Blob = .{ .name = name, .size = size, .reader = try request.readerExpectContinue(&body_buf) }; 1047 1048 const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse return respondAttachmentParentNotFound(request); 1049 defer request_repo.deinit(allocator); 1050 const source = request_repo.source; 1051 switch (source.repo_kind) { 1052 inline else => |repo_kind| { 1053 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); 1054 defer any_repo.deinit(io, allocator); 1055 switch (any_repo) { 1056 inline else => |*repo| _ = evt.Attachment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &parent_id_hex, blob, author) catch |err| switch (err) { 1057 error.ParentNotFound => return respondAttachmentParentNotFound(request), 1058 else => return err, 1059 }, 1060 } 1061 }, 1062 } 1063 1064 // the client reloads the page itself 1065 try request.respond("", .{ .status = .no_content, .keep_alive = false }); 1066 } 1067 1068 // the name the upload declares for its file, percent-encoded so it survives a 1069 // header 1070 fn attachmentName(allocator: std.mem.Allocator, request: *std.http.Server.Request) !?[]u8 { 1071 var iter = request.iterateHeaders(); 1072 while (iter.next()) |header| { 1073 if (!std.ascii.eqlIgnoreCase(header.name, attachment_name_header)) continue; 1074 return try decodeFormValue(allocator, header.value); 1075 } 1076 return null; 1077 } 1078 1079 // an upload that failed leaves the body partly read, so the connection closes 1080 fn respondUploadError(request: *std.http.Server.Request, status: std.http.Status, message: []const u8) !void { 1081 try request.respond(message, .{ 1082 .status = status, 1083 .keep_alive = false, 1084 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1085 }); 1086 } 1087 1088 // re-emit the comment `parts` names with a new body, in the repo the host 1089 // resolves the base to. 1090 fn updateComment( 1091 io: std.Io, 1092 allocator: std.mem.Allocator, 1093 host: Host, 1094 parts: CommentBaseParts, 1095 body: []const u8, 1096 author: evt.CommitAuthor, 1097 ) !void { 1098 const comment_id = parts.comment_id orelse return error.NotFound; 1099 const thread_id = std.fmt.bytesToHex(parts.thread_id, .lower); 1100 const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse return error.NotFound; 1101 defer request_repo.deinit(allocator); 1102 const source = request_repo.source; 1103 switch (source.repo_kind) { 1104 inline else => |repo_kind| { 1105 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); 1106 defer any_repo.deinit(io, allocator); 1107 switch (any_repo) { 1108 inline else => |*repo| try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author), 1109 } 1110 }, 1111 } 1112 } 1113 1114 // edit actions share a suffix; the base identifies the event being edited. 1115 fn handleEdit( 1116 io: std.Io, 1117 request: *std.http.Server.Request, 1118 allocator: std.mem.Allocator, 1119 base: []const u8, 1120 host: Host, 1121 ) !void { 1122 const parts = commentBaseParts(base) orelse return respondRemoveNotFound(request); 1123 if (parts.comment_id != null) return handleCommentEdit(io, request, allocator, base, host, parts); 1124 return handleThreadEdit(io, request, allocator, base, host, parts); 1125 } 1126 1127 // replace the body of the comment the url names, then redirect to its 1128 // permalink. 1129 fn handleCommentEdit( 1130 io: std.Io, 1131 request: *std.http.Server.Request, 1132 allocator: std.mem.Allocator, 1133 base: []const u8, 1134 host: Host, 1135 parts: CommentBaseParts, 1136 ) !void { 1137 var author_arena = std.heap.ArenaAllocator.init(allocator); 1138 defer author_arena.deinit(); 1139 const author = ((try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request)).author; 1140 1141 const form_body = try readFormBody(request, allocator); 1142 defer allocator.free(form_body); 1143 const body_crlf = (try parseFormField(allocator, form_body, "body")) orelse try allocator.dupe(u8, ""); 1144 defer allocator.free(body_crlf); 1145 const body = try std.mem.replaceOwned(u8, allocator, body_crlf, "\r\n", "\n"); 1146 defer allocator.free(body); 1147 1148 if (!evt.Comment.fieldsValid(body)) { 1149 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 1150 defer allocator.free(form_location); 1151 try request.respond("", .{ 1152 .status = .see_other, 1153 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 1154 }); 1155 return; 1156 } 1157 1158 const not_found = "comment not found"; 1159 updateComment(io, allocator, host, parts, body, author) catch |err| switch (err) { 1160 error.NotFound => { 1161 try request.respond(not_found, .{ 1162 .status = .not_found, 1163 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1164 }); 1165 return; 1166 }, 1167 else => |e| return e, 1168 }; 1169 1170 try request.respond("", .{ 1171 .status = .see_other, 1172 .extra_headers = &.{.{ .name = "location", .value = base }}, 1173 }); 1174 } 1175 1176 fn updateThread( 1177 comptime Event: type, 1178 io: std.Io, 1179 allocator: std.mem.Allocator, 1180 host: Host, 1181 repo_base: []const u8, 1182 id: *const [evt.event_id_size]u8, 1183 update: Event.Update, 1184 author: evt.CommitAuthor, 1185 ) !void { 1186 const request_repo = (try requestRepoSource(io, allocator, host, repo_base)) orelse return error.NotFound; 1187 defer request_repo.deinit(allocator); 1188 const source = request_repo.source; 1189 switch (source.repo_kind) { 1190 inline else => |repo_kind| { 1191 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); 1192 defer any_repo.deinit(io, allocator); 1193 switch (any_repo) { 1194 inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, id, update, author), 1195 } 1196 }, 1197 } 1198 } 1199 1200 const RemoveParts = struct { 1201 repo_base: []const u8, 1202 kind: evt.EventKind, 1203 id: [evt.event_id_size]u8, 1204 }; 1205 1206 // split a remove url into the repo and event it names 1207 fn removeParts(base: []const u8) ?RemoveParts { 1208 if (attachmentRequest(base)) |attachment| { 1209 const parent = attachParentParts(attachment.repo_base) orelse return null; 1210 return .{ 1211 .repo_base = parent.repo_base, 1212 .kind = .attach, 1213 .id = attachment.id, 1214 }; 1215 } 1216 if (commentBaseParts(base)) |thread| return .{ 1217 .repo_base = thread.repo_base, 1218 .kind = if (thread.comment_id != null) .comment else thread.thread_kind, 1219 .id = thread.comment_id orelse thread.thread_id, 1220 }; 1221 return null; 1222 } 1223 1224 // remove the event named by the form's url 1225 fn handleRemove( 1226 io: std.Io, 1227 request: *std.http.Server.Request, 1228 allocator: std.mem.Allocator, 1229 base: []const u8, 1230 host: Host, 1231 ) !void { 1232 const parts = removeParts(base) orelse return respondRemoveNotFound(request); 1233 1234 var author_arena = std.heap.ArenaAllocator.init(allocator); 1235 defer author_arena.deinit(); 1236 const request_author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 1237 const author = request_author.author; 1238 1239 const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse return respondRemoveNotFound(request); 1240 defer request_repo.deinit(allocator); 1241 const source = request_repo.source; 1242 1243 if (parts.kind == .patch) switch (host) { 1244 .remote => |remote| { 1245 const user_id = request_author.user_id orelse return respondLoginRequired(request); 1246 1247 var admin_repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 1248 defer admin_repo.deinit(io, allocator); 1249 const moment = try evt.currentMoment(evt.admin_repo_opts, &admin_repo); 1250 const record = try evt.Fork.readById(evt.AdminDB, evt.admin_repo_opts.hash, moment, &author_arena, &parts.id); 1251 if (record) |fork_record| { 1252 const repo_id = evt.parseEventId(std.fs.path.basename(source.path)) catch return respondRemoveNotFound(request); 1253 if (!fork_record.removed and fork_record.event.stage == .draft and std.mem.eql(u8, fork_record.event.repo_id, &repo_id)) { 1254 const id = std.fmt.bytesToHex(parts.id, .lower); 1255 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 1256 defer allocator.free(repos_dir); 1257 try fork.remove(io, allocator, repos_dir, &admin_repo, &id, &user_id, author); 1258 1259 const location = try std.fmt.allocPrint(allocator, "{s}/patches/drafts", .{parts.repo_base}); 1260 defer allocator.free(location); 1261 try request.respond("", .{ 1262 .status = .see_other, 1263 .keep_alive = false, 1264 .extra_headers = &.{.{ .name = "location", .value = location }}, 1265 }); 1266 return; 1267 } 1268 } 1269 }, 1270 .local => {}, 1271 }; 1272 1273 switch (source.repo_kind) { 1274 inline else => |repo_kind| { 1275 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); 1276 defer any_repo.deinit(io, allocator); 1277 switch (any_repo) { 1278 inline else => |*repo| evt.remove(.repo, repo_kind, repo.self_repo_opts, io, allocator, repo, &parts.id, parts.kind, author) catch |err| switch (err) { 1279 error.EventNotFound => return respondRemoveNotFound(request), 1280 else => |e| return e, 1281 }, 1282 } 1283 }, 1284 } 1285 1286 const id = std.fmt.bytesToHex(parts.id, .lower); 1287 const location = try std.fmt.allocPrint(allocator, "{s}/event:{s}/kind:{s}", .{ parts.repo_base, &id, @tagName(parts.kind) }); 1288 defer allocator.free(location); 1289 try request.respond("", .{ 1290 .status = .see_other, 1291 .keep_alive = false, 1292 .extra_headers = &.{.{ .name = "location", .value = location }}, 1293 }); 1294 } 1295 1296 fn respondRemoveNotFound(request: *std.http.Server.Request) !void { 1297 try request.respond("event not found", .{ 1298 .status = .not_found, 1299 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1300 }); 1301 } 1302 1303 fn updateDiscussion( 1304 io: std.Io, 1305 allocator: std.mem.Allocator, 1306 host: Host, 1307 repo_base: []const u8, 1308 id: *const [evt.event_id_size]u8, 1309 title: []const u8, 1310 tags: []const u8, 1311 description: []const u8, 1312 author: evt.CommitAuthor, 1313 ) !void { 1314 const request_repo = (try requestRepoSource(io, allocator, host, repo_base)) orelse return error.NotFound; 1315 defer request_repo.deinit(allocator); 1316 const source = request_repo.source; 1317 switch (source.repo_kind) { 1318 inline else => |repo_kind| { 1319 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); 1320 defer any_repo.deinit(io, allocator); 1321 switch (any_repo) { 1322 inline else => |*repo| try evt.Discussion.update(repo_kind, repo.self_repo_opts, io, allocator, repo, id, title, tags, description, author), 1323 } 1324 }, 1325 } 1326 } 1327 1328 // set the status of the issue the url names (base is 1329 // "/repo/<owner>/<name>/issue:<id>", identity elided in local mode) by 1330 // re-emitting its event, then redirect back to it 1331 fn handleThreadStatus( 1332 io: std.Io, 1333 request: *std.http.Server.Request, 1334 allocator: std.mem.Allocator, 1335 base: []const u8, 1336 host: Host, 1337 open: bool, 1338 ) !void { 1339 const not_found = "thread not found"; 1340 const parts = commentBaseParts(base) orelse { 1341 try request.respond(not_found, .{ 1342 .status = .not_found, 1343 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1344 }); 1345 return; 1346 }; 1347 1348 var author_arena = std.heap.ArenaAllocator.init(allocator); 1349 defer author_arena.deinit(); 1350 const author = ((try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request)).author; 1351 1352 (switch (parts.thread_kind) { 1353 .issue => updateThread(evt.Issue, io, allocator, host, parts.repo_base, &parts.thread_id, .{ .status = if (open) .open else .closed }, author), 1354 .patch => updateThread(evt.Patch, io, allocator, host, parts.repo_base, &parts.thread_id, .{ .status = if (open) .open else .closed }, author), 1355 else => return respondRemoveNotFound(request), 1356 }) catch |err| switch (err) { 1357 error.NotFound => { 1358 try request.respond(not_found, .{ 1359 .status = .not_found, 1360 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1361 }); 1362 return; 1363 }, 1364 else => |e| return e, 1365 }; 1366 1367 // like logout, this is a bodyless POST, so close the connection rather than 1368 // letting the keep-alive path try to discard a body that isn't framed. 1369 try request.respond("", .{ 1370 .status = .see_other, 1371 .keep_alive = false, 1372 .extra_headers = &.{.{ .name = "location", .value = base }}, 1373 }); 1374 } 1375 1376 // replace a thread's editable fields. 1377 fn handleThreadEdit( 1378 io: std.Io, 1379 request: *std.http.Server.Request, 1380 allocator: std.mem.Allocator, 1381 base: []const u8, 1382 host: Host, 1383 parts: CommentBaseParts, 1384 ) !void { 1385 var author_arena = std.heap.ArenaAllocator.init(allocator); 1386 defer author_arena.deinit(); 1387 const request_author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); 1388 const author = request_author.author; 1389 1390 const body = try readFormBody(request, allocator); 1391 defer allocator.free(body); 1392 1393 const title = (try parseFormField(allocator, body, "title")) orelse try allocator.dupe(u8, ""); 1394 defer allocator.free(title); 1395 const tags = (try parseFormField(allocator, body, "tags")) orelse try allocator.dupe(u8, ""); 1396 defer allocator.free(tags); 1397 const description_crlf = (try parseFormField(allocator, body, "description")) orelse try allocator.dupe(u8, ""); 1398 defer allocator.free(description_crlf); 1399 // form submission normalizes textarea line breaks to CRLF; store plain 1400 // newlines so the text renders the same on every host 1401 const description = try std.mem.replaceOwned(u8, allocator, description_crlf, "\r\n", "\n"); 1402 defer allocator.free(description); 1403 const target_branch = (try parseFormField(allocator, body, "target_branch")) orelse try allocator.dupe(u8, ""); 1404 defer allocator.free(target_branch); 1405 1406 // invalid fields send the user back to the edit form 1407 const valid = switch (parts.thread_kind) { 1408 .issue => evt.Issue.fieldsValid(title, tags), 1409 .patch => evt.Patch.fieldsValid(title, tags), 1410 .discuss => evt.Discussion.fieldsValid(title, tags), 1411 else => unreachable, 1412 }; 1413 if (!valid) { 1414 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 1415 defer allocator.free(form_location); 1416 if (!evt.titleValid(title)) { 1417 return respondThreadFormFailure(request, allocator, host, form_location, requiredTitleFeedback(parts.thread_kind, title, tags, description, target_branch)); 1418 } 1419 try request.respond("", .{ 1420 .status = .see_other, 1421 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 1422 }); 1423 return; 1424 } 1425 1426 const draft_user_id = if (parts.thread_kind == .patch) request_author.user_id else null; 1427 if (draft_user_id) |user_id| { 1428 const remote = switch (host) { 1429 .remote => |remote| remote, 1430 .local => unreachable, 1431 }; 1432 const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse return respondRemoveNotFound(request); 1433 defer request_repo.deinit(allocator); 1434 if (!try request_repo.source.hasBranch(io, allocator, target_branch)) { 1435 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); 1436 defer allocator.free(form_location); 1437 return respondThreadFormFailure(request, allocator, host, form_location, invalidTargetBranchFeedback(title, tags, description, target_branch)); 1438 } 1439 const repo_id = evt.parseEventId(std.fs.path.basename(request_repo.source.path)) catch return respondRemoveNotFound(request); 1440 var admin_repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 1441 defer admin_repo.deinit(io, allocator); 1442 const id = std.fmt.bytesToHex(parts.thread_id, .lower); 1443 const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 1444 defer allocator.free(repos_dir); 1445 const fork_path = try fork.forkPath(allocator, repos_dir, &id); 1446 defer allocator.free(fork_path); 1447 if (try pch.editDraft(.{}, io, allocator, &admin_repo, fork_path, .{ 1448 .id = id, 1449 .user_id = user_id, 1450 .repo_id = repo_id, 1451 .title = title, 1452 .tags = tags, 1453 .description = description, 1454 .target_branch = target_branch, 1455 .author = author, 1456 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 1457 })) { 1458 try request.respond("", .{ 1459 .status = .see_other, 1460 .extra_headers = &.{.{ .name = "location", .value = base }}, 1461 }); 1462 return; 1463 } 1464 } 1465 1466 const not_found = switch (parts.thread_kind) { 1467 .issue => "issue not found", 1468 .patch => "patch not found", 1469 .discuss => "discussion not found", 1470 else => unreachable, 1471 }; 1472 (switch (parts.thread_kind) { 1473 .issue => updateThread(evt.Issue, io, allocator, host, parts.repo_base, &parts.thread_id, .{ .fields = .{ 1474 .title = title, 1475 .tags = tags, 1476 .description = description, 1477 } }, author), 1478 .patch => updateThread(evt.Patch, io, allocator, host, parts.repo_base, &parts.thread_id, .{ .fields = .{ 1479 .title = title, 1480 .tags = tags, 1481 .description = description, 1482 .target_branch = target_branch, 1483 } }, author), 1484 .discuss => updateDiscussion(io, allocator, host, parts.repo_base, &parts.thread_id, title, tags, description, author), 1485 else => unreachable, 1486 }) catch |err| switch (err) { 1487 error.NotFound => { 1488 try request.respond(not_found, .{ 1489 .status = .not_found, 1490 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1491 }); 1492 return; 1493 }, 1494 else => |e| return e, 1495 }; 1496 1497 try request.respond("", .{ 1498 .status = .see_other, 1499 .extra_headers = &.{.{ .name = "location", .value = base }}, 1500 }); 1501 } 1502 1503 // resolve every conflicted field of the thread the url names 1504 fn handleThreadResolve( 1505 io: std.Io, 1506 request: *std.http.Server.Request, 1507 allocator: std.mem.Allocator, 1508 base: []const u8, 1509 host: Host, 1510 ) !void { 1511 var author_arena = std.heap.ArenaAllocator.init(allocator); 1512 defer author_arena.deinit(); 1513 const author = ((try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request)).author; 1514 1515 const body = try readFormBody(request, allocator); 1516 defer allocator.free(body); 1517 1518 var field_arena = std.heap.ArenaAllocator.init(allocator); 1519 defer field_arena.deinit(); 1520 const aa = field_arena.allocator(); 1521 const title = try parseFormField(aa, body, "title"); 1522 const tags = try parseFormField(aa, body, "tags"); 1523 1524 // the hunk inputs are submitted as d0, d1, ...; form submission normalizes their 1525 // textarea line breaks to CRLF 1526 var hunks: std.ArrayList([]const u8) = .empty; 1527 var hunk_index: usize = 0; 1528 while (true) : (hunk_index += 1) { 1529 const name = try std.fmt.allocPrint(aa, "d{d}", .{hunk_index}); 1530 const submitted = (try parseFormField(aa, body, name)) orelse break; 1531 try hunks.append(aa, try std.mem.replaceOwned(u8, aa, submitted, "\r\n", "\n")); 1532 } 1533 1534 const parts = commentBaseParts(base) orelse { 1535 try request.respond("thread not found", .{ 1536 .status = .not_found, 1537 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1538 }); 1539 return; 1540 }; 1541 if (parts.comment_id != null or (parts.thread_kind != .issue and parts.thread_kind != .patch)) { 1542 try request.respond("thread not found", .{ 1543 .status = .not_found, 1544 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1545 }); 1546 return; 1547 } 1548 const route = ui.RoutablePage.fromUrl(base) orelse ui.RoutablePage.fromUrlLocal(base) orelse { 1549 try request.respond("thread not found", .{ 1550 .status = .not_found, 1551 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1552 }); 1553 return; 1554 }; 1555 const theirs: []const u8 = switch (route) { 1556 .repo_patches => |*patch| patch.theirs.slice(), 1557 .repo_issues => "", 1558 else => unreachable, 1559 }; 1560 const not_found = if (parts.thread_kind == .patch) "patch not found" else "issue not found"; 1561 1562 (switch (parts.thread_kind) { 1563 .issue => updateThread(evt.Issue, io, allocator, host, parts.repo_base, &parts.thread_id, .{ .resolve = .{ 1564 .title = title, 1565 .tags = tags, 1566 .hunks = hunks.items, 1567 } }, author), 1568 .patch => updateThread(evt.Patch, io, allocator, host, parts.repo_base, &parts.thread_id, .{ .resolve = .{ 1569 .title = title, 1570 .tags = tags, 1571 .hunks = hunks.items, 1572 .theirs = theirs, 1573 } }, author), 1574 else => unreachable, 1575 }) catch |err| switch (err) { 1576 error.NotFound => { 1577 try request.respond(not_found, .{ 1578 .status = .not_found, 1579 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1580 }); 1581 return; 1582 }, 1583 // invalid fields send the user back to the resolve form 1584 error.InvalidFields => { 1585 const form_location = try std.fmt.allocPrint(allocator, "{s}/resolve", .{base}); 1586 defer allocator.free(form_location); 1587 try request.respond("", .{ 1588 .status = .see_other, 1589 .extra_headers = &.{.{ .name = "location", .value = form_location }}, 1590 }); 1591 return; 1592 }, 1593 else => |e| return e, 1594 }; 1595 1596 try request.respond("", .{ 1597 .status = .see_other, 1598 .extra_headers = &.{.{ .name = "location", .value = base }}, 1599 }); 1600 } 1601 1602 fn handleSync( 1603 io: std.Io, 1604 request: *std.http.Server.Request, 1605 allocator: std.mem.Allocator, 1606 base: []const u8, 1607 source: ui.RepoSource, 1608 ) !void { 1609 if (base.len != 0) { 1610 try request.respond("not found", .{ 1611 .status = .not_found, 1612 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, 1613 }); 1614 return; 1615 } 1616 1617 var owned_failure: ?[]u8 = null; 1618 defer if (owned_failure) |message| allocator.free(message); 1619 const failure: ?[]const u8 = blk: { 1620 owned_failure = ui.Repo.Events.sync(io, allocator, source) catch |err| break :blk @errorName(err); 1621 break :blk owned_failure; 1622 }; 1623 if (failure) |message| { 1624 var encoded: std.Io.Writer.Allocating = .init(allocator); 1625 defer encoded.deinit(); 1626 try std.Uri.Component.percentEncode(&encoded.writer, message, struct { 1627 fn isUnreserved(c: u8) bool { 1628 return std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~'; 1629 } 1630 }.isUnreserved); 1631 const cookie = try std.fmt.allocPrint( 1632 allocator, 1633 local_flash_cookie ++ "=" ++ local_sync_failure_prefix ++ "{s}; Path=/; HttpOnly; SameSite=Strict", 1634 .{encoded.written()}, 1635 ); 1636 defer allocator.free(cookie); 1637 try request.respond("", .{ 1638 .status = .see_other, 1639 .extra_headers = &.{ 1640 .{ .name = "location", .value = "/events" }, 1641 .{ .name = "set-cookie", .value = cookie }, 1642 }, 1643 }); 1644 return; 1645 } 1646 1647 try request.respond("", .{ 1648 .status = .see_other, 1649 .extra_headers = &.{ 1650 .{ .name = "location", .value = "/events" }, 1651 .{ .name = "set-cookie", .value = local_flash_cookie ++ "=; Path=/; Max-Age=0" }, 1652 }, 1653 }); 1654 } 1655 1656 fn renderIndexHtml( 1657 io: std.Io, 1658 allocator: std.mem.Allocator, 1659 host: Host, 1660 session_data: ui.Session.Data, 1661 ) ![]const u8 { 1662 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 1663 1664 var page_arena = std.heap.ArenaAllocator.init(allocator); 1665 defer page_arena.deinit(); 1666 1667 // the remote admin repo must outlive the page build, since the session's 1668 // moment reads from it. 1669 var repo_maybe: ?rp.Repo(.xit, evt.admin_repo_opts) = null; 1670 defer if (repo_maybe) |*repo| repo.deinit(io, allocator); 1671 1672 var session = switch (host) { 1673 .remote => |remote| blk: { 1674 // open the admin repo to read live user/repo data. 1675 repo_maybe = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); 1676 const repo = if (repo_maybe) |*repo| repo else unreachable; 1677 var session = try ui.Session.init(&page_arena, repo, session_data); 1678 // give the page builders filesystem access to the on-disk repos (a 1679 // sibling "repos" dir next to the admin repo) so the Repo page can 1680 // read its files. 1681 session.repos_dir = try std.fs.path.join(page_arena.allocator(), &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); 1682 break :blk session; 1683 }, 1684 .local => |local| ui.Session{ 1685 .data = session_data, 1686 .arena = &page_arena, 1687 .page_arena = &page_arena, 1688 .local = local, 1689 }, 1690 }; 1691 session.io = io; 1692 1693 var page = try ui.Page.init(session.page_arena, &session, session.data.current_page); 1694 var root = try ui.initRoot(allocator, &page, &session); 1695 defer root.deinit(allocator); 1696 1697 const snapshot: ui.Snapshot = .{ .page = page, .session = session.data }; 1698 1699 const content = try generateHtml(allocator, &root, &session); 1700 defer allocator.free(content); 1701 1702 // serialize the snapshot so the wasm side can parse it back without making 1703 // a second request. it's embedded raw in a <script type="application/json"> 1704 // block, whose content is raw text terminated only by "</script". in json, 1705 // '<' appears solely inside string values, so escaping every '<' (and '>' 1706 // for good measure) to its \uXXXX form yields equivalent json that can't 1707 // break out of the tag. 1708 var json: std.Io.Writer.Allocating = .init(allocator); 1709 defer json.deinit(); 1710 try std.json.Stringify.value(snapshot, .{}, &json.writer); 1711 1712 var json_escaped: std.ArrayList(u8) = .empty; 1713 defer json_escaped.deinit(allocator); 1714 for (json.written()) |c| switch (c) { 1715 '<' => try json_escaped.appendSlice(allocator, "\\u003c"), 1716 '>' => try json_escaped.appendSlice(allocator, "\\u003e"), 1717 else => try json_escaped.append(allocator, c), 1718 }; 1719 1720 var out: std.ArrayList(u8) = .empty; 1721 errdefer out.deinit(allocator); 1722 { 1723 var cursor: usize = 0; 1724 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 1725 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 1726 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_escaped.items }, 1727 }) |sub| { 1728 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 1729 try out.appendSlice(allocator, template[cursor..idx]); 1730 try out.appendSlice(allocator, sub.replacement); 1731 cursor = idx + sub.needle.len; 1732 } 1733 try out.appendSlice(allocator, template[cursor..]); 1734 } 1735 return try out.toOwnedSlice(allocator); 1736 } 1737 1738 fn findEmbed(request_path: []const u8) ?Embed { 1739 const path = if (std.mem.eql(u8, request_path, "/")) 1740 "index.html" 1741 else if (request_path.len > 1 and request_path[0] == '/') 1742 request_path[1..] 1743 else 1744 return null; 1745 1746 for (embeds) |embed| { 1747 if (std.mem.eql(u8, path, embed.path)) return embed; 1748 } 1749 return null; 1750 } 1751 1752 // emits the TUI grid cells as static HTML. each web-native Scroll becomes its 1753 // own absolutely-positioned, natively-scrollable <div> holding its full content, 1754 // rendered recursively so nested scrolls work. 1755 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget, session: *ui.Session) ![]const u8 { 1756 const grid = root.getGrid() orelse return error.MissingGrid; 1757 const root_focus = root.getFocus(); 1758 1759 var out: std.ArrayList(u8) = .empty; 1760 errdefer out.deinit(allocator); 1761 1762 switch (root.*) { 1763 .background => |*background| { 1764 if (background.art.getGrid()) |art| { 1765 var position_buf: [64]u8 = undefined; 1766 try out.appendSlice(allocator, try std.fmt.bufPrint(&position_buf, "<div class=\"ansi-art\" aria-hidden=\"true\" style=\"left:{d}ch\">", .{grid.size.width -| art.size.width})); 1767 try renderPanel(allocator, &out, background.art.getFocus(), art, session, root_focus); 1768 try out.appendSlice(allocator, "</div>"); 1769 } 1770 }, 1771 else => {}, 1772 } 1773 try out.appendSlice(allocator, "<div class=\"grid-content\" data-key=\"panel:root\">"); 1774 try renderPanel(allocator, &out, root_focus, grid, session, root_focus); 1775 try out.appendSlice(allocator, "</div>"); 1776 try renderForms(allocator, &out, root_focus); 1777 return try out.toOwnedSlice(allocator); 1778 } 1779 1780 // render one panel — a focus subtree and its content grid — as HTML, recursing 1781 // once per web-native Scroll inside it. the root panel is the whole page; each 1782 // Scroll becomes a nested, natively-scrollable div holding its full content. 1783 // emits the panel's grid cells as text, leaving holes where child scrolls sit, 1784 // then the child scroll divs over those holes. 1785 fn renderPanel(allocator: std.mem.Allocator, output: *std.ArrayList(u8), focus: *Focus, grid: Grid, session: *ui.Session, root_focus: *Focus) !void { 1786 // `direct` is this panel's scroll children, each drawn as an overlaid div. 1787 // `excluded` is every focus id that belongs to a scroll (the scroll nodes 1788 // plus their descendants, which the focus tree flattens into this panel's 1789 // children): the cell hit-test below skips them so this panel doesn't claim 1790 // cells that are really rendered inside a child scroll's own panel. 1791 // (scrolls aren't nested in practice, so every scroll child is direct here.) 1792 var direct: std.ArrayList(usize) = .empty; 1793 defer direct.deinit(allocator); 1794 var excluded: std.AutoHashMapUnmanaged(usize, void) = .empty; 1795 defer excluded.deinit(allocator); 1796 { 1797 var it = focus.children.iterator(); 1798 while (it.next()) |e| { 1799 if (e.value_ptr.focus.scroll == null) continue; 1800 try direct.append(allocator, e.key_ptr.*); 1801 try excluded.put(allocator, e.key_ptr.*, {}); 1802 var mit = e.value_ptr.focus.children.iterator(); 1803 while (mit.next()) |m| try excluded.put(allocator, m.key_ptr.*, {}); 1804 } 1805 } 1806 1807 // the HTML element a run of cells is wrapped in, chosen from the cell's 1808 // focusable kind 1809 const CellTag = union(enum) { 1810 span, // clickable focusable cell 1811 a: []const u8, // clickable focusable link 1812 plain, // non-focusable, colored 1813 1814 const a_prefix = "a:"; 1815 // an "in-page" anchor: rendered as a real <a href> so it works with js 1816 // disabled, but wasm handles it without navigating. 1817 fn init(str: []const u8) @This() { 1818 if (std.mem.startsWith(u8, str, ui.raw_link_prefix)) { 1819 return .{ .a = str[ui.raw_link_prefix.len..] }; 1820 } 1821 if (std.mem.startsWith(u8, str, ui.in_page_link_prefix)) { 1822 return .{ .a = str[ui.in_page_link_prefix.len..] }; 1823 } 1824 if (std.mem.startsWith(u8, str, a_prefix)) { 1825 return .{ .a = str[a_prefix.len..] }; 1826 } 1827 return .span; 1828 } 1829 1830 fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void { 1831 var id_buf: [32]u8 = undefined; 1832 switch (self) { 1833 .span => { 1834 try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); 1835 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1836 try out.appendSlice(alloc, "\""); 1837 try out.appendSlice(alloc, style_attr); 1838 try out.appendSlice(alloc, ">"); 1839 }, 1840 .a => |href| { 1841 // tabindex=-1 keeps these out of the browser's tab order 1842 try out.appendSlice(alloc, "<a class=\"clickable\" tabindex=\"-1\" data-focus-id=\""); 1843 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1844 try out.appendSlice(alloc, "\" href=\""); 1845 try appendEscapedHtml(alloc, out, href); 1846 try out.appendSlice(alloc, "\""); 1847 try out.appendSlice(alloc, style_attr); 1848 try out.appendSlice(alloc, ">"); 1849 }, 1850 .plain => { 1851 try out.appendSlice(alloc, "<span"); 1852 try out.appendSlice(alloc, style_attr); 1853 try out.appendSlice(alloc, ">"); 1854 }, 1855 } 1856 } 1857 1858 fn closeTag(self: @This()) []const u8 { 1859 return switch (self) { 1860 .span, .plain => "</span>", 1861 .a => "</a>", 1862 }; 1863 } 1864 }; 1865 1866 // the cells are the volatile part of a panel. js replaces this one subtree 1867 // wholesale while retaining the panel's native controls and scroll shells. 1868 try output.appendSlice(allocator, "<div class=\"grid-cells\">"); 1869 1870 // emit this panel's grid as rows of text, coalescing adjacent cells that 1871 // share a focus id and colors into one tag (a clickable span, a link, or a 1872 // plain colored span). cells under a child scroll keep only their background. 1873 for (0..grid.size.height) |y| { 1874 var cur_id: ?usize = null; 1875 var cur_fg: ?Grid.Color = null; 1876 var cur_bg: ?Grid.Color = null; 1877 var open_tag: ?CellTag = null; 1878 var first = true; 1879 for (0..grid.size.width) |x| { 1880 const cell = (try grid.cell(x, y)).*; 1881 // a cell covered by a child scroll's viewport is drawn by that scroll's 1882 // own div, so blank its glyph and make it non-clickable here — but keep 1883 // its background, so the backdrop still shows through the scroll's 1884 // transparent div instead of a bare hole. 1885 const covered_by_scroll = blk: { 1886 for (direct.items) |id| { 1887 const r = (focus.children.get(id) orelse continue).rect; 1888 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; 1889 } 1890 break :blk false; 1891 }; 1892 // the focusable cell at (x, y) among this panel's own focusables (skipping 1893 // any that belong to a child scroll, whose cells are drawn in their own 1894 // panel); none for a covered cell. 1895 const cell_id = if (covered_by_scroll) null else blk: { 1896 var iter = focus.children.iterator(); 1897 while (iter.next()) |entry| { 1898 const child = entry.value_ptr.*; 1899 if (child.focus.mode != .all) continue; 1900 if (excluded.contains(entry.key_ptr.*)) continue; 1901 const r = child.rect; 1902 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 1903 break :blk entry.key_ptr.*; 1904 } 1905 } 1906 break :blk null; 1907 }; 1908 const fg = if (covered_by_scroll) null else cell.style.fg; 1909 const bg = cell.style.bg; 1910 1911 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 1912 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1913 1914 var new_tag: ?CellTag = null; 1915 if (cell_id) |id| { 1916 if (focus.children.get(id)) |child| { 1917 new_tag = switch (child.focus.kind) { 1918 .custom => |custom| CellTag.init(custom), 1919 else => .span, 1920 }; 1921 } 1922 } 1923 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 1924 1925 if (new_tag) |t| { 1926 var style_buf: [64]u8 = undefined; 1927 try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 1928 } 1929 1930 open_tag = new_tag; 1931 cur_id = cell_id; 1932 cur_fg = fg; 1933 cur_bg = bg; 1934 first = false; 1935 } 1936 1937 if (covered_by_scroll) { 1938 try appendEscapedHtml(allocator, output, " "); 1939 } else if (cell.continuation) { 1940 // the wide rune to the left was emitted 2ch wide, covering this column 1941 } else if (cell.rune) |rune| { 1942 var encoded: [4]u8 = undefined; 1943 const encoded_len = try std.unicode.utf8Encode(rune, &encoded); 1944 const rune_text = encoded[0..encoded_len]; 1945 // a double-width rune is pinned inside a 2ch span so the 1946 // fallback glyph's advance width can't shift the rest of the row 1947 const wide = x + 1 < grid.size.width and (try grid.cell(x + 1, y)).continuation; 1948 if (wide) { 1949 try output.appendSlice(allocator, "<span class=\"w2\">"); 1950 try appendEscapedHtml(allocator, output, rune_text); 1951 try output.appendSlice(allocator, "</span>"); 1952 } else { 1953 try appendEscapedHtml(allocator, output, rune_text); 1954 } 1955 } else { 1956 try appendEscapedHtml(allocator, output, " "); 1957 } 1958 } 1959 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1960 try output.append(allocator, '\n'); 1961 } 1962 try output.appendSlice(allocator, "</div>"); 1963 1964 // native controls live in their panel, beside the replaceable cells. their 1965 // stable keys let JS update layout without recreating their DOM nodes, and 1966 // being actual descendants of a scroll gives them native movement/clipping. 1967 try renderPanelControls(allocator, output, focus, session, root_focus, &excluded); 1968 1969 // each child scroll becomes a natively-scrollable div positioned over its 1970 // viewport, holding its full content rendered recursively. 1971 for (direct.items) |id| { 1972 const child = focus.children.get(id) orelse continue; 1973 const info = child.focus.scroll orelse continue; 1974 const r = child.rect; 1975 // overflow only on the axes the widget scrolls; `auto` shows a bar only 1976 // when that axis actually overflows. the other axis is clipped (hidden), 1977 // matching how the terminal Scroll handles its non-scrolling axis. 1978 const overflow = switch (info.direction) { 1979 .vert => "overflow-x:hidden;overflow-y:auto", 1980 .horiz => "overflow-x:auto;overflow-y:hidden", 1981 .both => "overflow:auto", 1982 }; 1983 // the id carries a content version so JS preserves the native scroll 1984 // position across re-renders of the same content but resets it when the 1985 // content is replaced — e.g. selecting a different commit. the widget's 1986 // scroll offset (in cells) rides along so JS can apply it when it has no 1987 // preserved position, letting a wasm-side scrollToRect (the files list 1988 // scrolling to its selected row on page load) reach the browser. 1989 var buf: [256]u8 = undefined; 1990 try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, "<div class=\"scroll\" data-key=\"scroll:{d}\" data-scroll-id=\"{d}-{d}\" data-scroll-direction=\"{s}\" data-scroll-x=\"{d}\" data-scroll-y=\"{d}\" style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em;{s}\">", .{ id, id, child.focus.version, @tagName(info.direction), info.offset_x, info.offset_y, r.x, r.y, r.size.width, r.size.height, overflow })); 1991 try renderPanel(allocator, output, child.focus, info.content, session, root_focus); 1992 try output.appendSlice(allocator, "</div>"); 1993 } 1994 } 1995 1996 const form_prefix = "form:"; 1997 const form_id_prefix = "haxy-form-"; 1998 1999 fn formAction(focus: *const Focus) ?[]const u8 { 2000 return switch (focus.kind) { 2001 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) custom[form_prefix.len..] else null, 2002 else => null, 2003 }; 2004 } 2005 2006 // find the form ancestor of a flattened focus-tree child. html's `form` 2007 // attribute then associates the control with that form without requiring the 2008 // form and control to share a DOM parent. 2009 fn formOwner(root_focus: *Focus, focus_id: usize) ?usize { 2010 var id = focus_id; 2011 while (root_focus.children.get(id)) |child| { 2012 const parent_id = child.parent_id; 2013 if (parent_id == root_focus.id) return if (formAction(root_focus) != null) root_focus.id else null; 2014 const parent = root_focus.children.get(parent_id) orelse return null; 2015 if (formAction(parent.focus) != null) return parent_id; 2016 id = parent_id; 2017 } 2018 return null; 2019 } 2020 2021 fn appendFormAttribute(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus, focus_id: usize) !void { 2022 const form_id = formOwner(root_focus, focus_id) orelse return; 2023 var buf: [64]u8 = undefined; 2024 try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, " form=\"" ++ form_id_prefix ++ "{d}\"", .{form_id})); 2025 } 2026 2027 fn renderPanelControls( 2028 allocator: std.mem.Allocator, 2029 output: *std.ArrayList(u8), 2030 focus: *Focus, 2031 session: *ui.Session, 2032 root_focus: *Focus, 2033 excluded: *const std.AutoHashMapUnmanaged(usize, void), 2034 ) !void { 2035 var iter = focus.children.iterator(); 2036 while (iter.next()) |entry| { 2037 const id = entry.key_ptr.*; 2038 if (excluded.contains(id)) continue; 2039 const child = entry.value_ptr.*; 2040 const r = child.rect; 2041 2042 switch (child.focus.kind) { 2043 .text_input, .text_input_password, .text_area => { 2044 const ti = session.text_inputs.get(id) orelse continue; 2045 2046 const inner_left = r.x + 1; 2047 const inner_top = r.y + 1; 2048 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 2049 const preserve = !ti.options.read_only; 2050 2051 if (child.focus.kind == .text_area) { 2052 const value = try ti.text(allocator); 2053 defer allocator.free(value); 2054 var id_buf: [128]u8 = undefined; 2055 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<textarea data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); 2056 if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); 2057 try appendFormAttribute(allocator, output, root_focus, id); 2058 if (ti.options.name.len > 0) { 2059 try output.appendSlice(allocator, " name=\""); 2060 try appendEscapedHtml(allocator, output, ti.options.name); 2061 try output.append(allocator, '"'); 2062 } 2063 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 2064 var pos_buf: [128]u8 = undefined; 2065 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\">\n", .{ inner_left, inner_top, inner_width, inner_height })); 2066 try appendEscapedHtml(allocator, output, value); 2067 try output.appendSlice(allocator, "</textarea>"); 2068 continue; 2069 } 2070 2071 var id_buf: [160]u8 = undefined; 2072 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<input type=\"{s}\" data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ if (child.focus.kind == .text_input_password) "password" else "text", id, id })); 2073 if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); 2074 try appendFormAttribute(allocator, output, root_focus, id); 2075 if (ti.options.name.len > 0) { 2076 try output.appendSlice(allocator, " name=\""); 2077 try appendEscapedHtml(allocator, output, ti.options.name); 2078 try output.append(allocator, '"'); 2079 } 2080 if (child.focus.kind != .text_input_password) { 2081 const value = try ti.text(allocator); 2082 defer allocator.free(value); 2083 try output.appendSlice(allocator, " value=\""); 2084 try appendEscapedHtml(allocator, output, value); 2085 try output.append(allocator, '"'); 2086 } 2087 var pos_buf: [96]u8 = undefined; 2088 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:1em\">", .{ inner_left, inner_top, inner_width })); 2089 }, 2090 .custom => |custom| { 2091 if (std.mem.eql(u8, custom, "submit") or std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 2092 if (formOwner(root_focus, id) == null) continue; 2093 var id_buf: [128]u8 = undefined; 2094 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<button type=\"submit\" data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); 2095 try appendFormAttribute(allocator, output, root_focus, id); 2096 if (std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 2097 try output.appendSlice(allocator, " formaction=\""); 2098 try appendEscapedHtml(allocator, output, custom[ui.submit_action_prefix.len..]); 2099 try output.append(allocator, '"'); 2100 } 2101 var pos_buf: [128]u8 = undefined; 2102 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\"></button>", .{ r.x, r.y, r.size.width, r.size.height })); 2103 } else if (std.mem.startsWith(u8, custom, ui.file_input_prefix)) { 2104 try output.appendSlice(allocator, "<input type=\"file\" data-preserve-value data-action=\""); 2105 try appendEscapedHtml(allocator, output, custom[ui.file_input_prefix.len..]); 2106 var id_buf: [128]u8 = undefined; 2107 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "\" data-key=\"control:{d}\" data-focus-id=\"{d}\" style=\"opacity:0;left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\">", .{ id, id, r.x, r.y, r.size.width, r.size.height })); 2108 } 2109 }, 2110 else => {}, 2111 } 2112 } 2113 } 2114 2115 // emit hidden form owners beside the rendered panels. their controls point 2116 // back here with the standard HTML `form` attribute. 2117 fn renderForms(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus) !void { 2118 // the focus child map preserves insertion order, which matches the order 2119 // widgets were added in code 2120 var iter = root_focus.children.iterator(); 2121 while (iter.next()) |entry| { 2122 const child = entry.value_ptr.*; 2123 const action_url = formAction(child.focus) orelse continue; 2124 2125 try output.appendSlice(allocator, "<form hidden data-key=\"form:"); 2126 var id_buf: [96]u8 = undefined; 2127 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}\" id=\"" ++ form_id_prefix ++ "{d}\" action=\"", .{ entry.key_ptr.*, entry.key_ptr.* })); 2128 try appendEscapedHtml(allocator, output, action_url); 2129 try output.appendSlice(allocator, "\" method=\"post\"></form>"); 2130 } 2131 } 2132 2133 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 2134 if (a) |av| return if (b) |bv| av.eql(bv) else false; 2135 return b == null; 2136 } 2137 2138 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 2139 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 2140 // empty slice when neither is set. 2141 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 2142 if (fg == null and bg == null) return buf[0..0]; 2143 const prefix = " style=\""; 2144 @memcpy(buf[0..prefix.len], prefix); 2145 var i: usize = prefix.len; 2146 if (fg) |c| { 2147 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]; 2148 i += s.len; 2149 } 2150 if (bg) |c| { 2151 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]; 2152 i += s.len; 2153 } 2154 buf[i] = '"'; 2155 i += 1; 2156 return buf[0..i]; 2157 } 2158 2159 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 2160 for (input) |ch| { 2161 switch (ch) { 2162 '&' => try out.appendSlice(allocator, "&amp;"), 2163 '<' => try out.appendSlice(allocator, "&lt;"), 2164 '>' => try out.appendSlice(allocator, "&gt;"), 2165 '"' => try out.appendSlice(allocator, "&quot;"), 2166 '\'' => try out.appendSlice(allocator, "&#39;"), 2167 else => try out.append(allocator, ch), 2168 } 2169 } 2170 } 2171 2172 // --- helpers -------------------------------------------------------------- 2173 2174 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 2175 var iter = request.iterateHeaders(); 2176 while (iter.next()) |header| { 2177 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 2178 var pairs = std.mem.splitScalar(u8, header.value, ';'); 2179 while (pairs.next()) |pair| { 2180 const trimmed = std.mem.trim(u8, pair, " \t"); 2181 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 2182 if (std.mem.eql(u8, trimmed[0..eq], name)) { 2183 return trimmed[eq + 1 ..]; 2184 } 2185 } 2186 } 2187 return null; 2188 } 2189 2190 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 2191 var iter = std.mem.splitScalar(u8, body, '&'); 2192 while (iter.next()) |pair| { 2193 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 2194 if (std.mem.eql(u8, pair[0..eq], key)) { 2195 return try decodeFormValue(allocator, pair[eq + 1 ..]); 2196 } 2197 } 2198 return null; 2199 } 2200 2201 fn readFormBody(request: *std.http.Server.Request, allocator: std.mem.Allocator) ![]u8 { 2202 var buffer: [256]u8 = undefined; 2203 return request.readerExpectNone(&buffer).allocRemaining(allocator, .limited(65536)); 2204 } 2205 2206 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 2207 var out: std.ArrayList(u8) = .empty; 2208 errdefer out.deinit(allocator); 2209 var i: usize = 0; 2210 while (i < encoded.len) : (i += 1) { 2211 switch (encoded[i]) { 2212 '+' => try out.append(allocator, ' '), 2213 '%' => { 2214 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 2215 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 2216 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 2217 try out.append(allocator, hi * 16 + lo); 2218 i += 2; 2219 }, 2220 else => try out.append(allocator, encoded[i]), 2221 } 2222 } 2223 return try out.toOwnedSlice(allocator); 2224 } 2225 2226 // disk-backed web session store. each login mints a random, opaque token 2227 // (not the user's id) that is stored as a file named by the token hex, whose 2228 // contents are the raw user_id bytes. authenticating a request is a lookup of 2229 // the cookie's token; logout deletes the file, revoking the session. there is 2230 // deliberately no expiry — a session lives until logout. form failures use 2231 // files in the flash subdirectory that the redirected GET consumes. the 2232 // directory is cleared whenever the server starts. 2233 pub const SessionStore = struct { 2234 io: std.Io, 2235 dir: std.Io.Dir, 2236 flash_dir: std.Io.Dir, 2237 auto_login: ?[token_hex_len]u8, 2238 2239 pub const token_hex_len = evt.event_id_size * 2; 2240 2241 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 2242 try data_dir.createDirPath(io, "sessions"); 2243 const dir = try data_dir.openDir(io, "sessions", .{}); 2244 errdefer dir.close(io); 2245 try dir.deleteTree(io, "flash"); 2246 try dir.createDirPath(io, "flash"); 2247 const flash_dir = try dir.openDir(io, "flash", .{}); 2248 errdefer flash_dir.close(io); 2249 const auto_login = blk: { 2250 const file = dir.openFile(io, auto_login_name, .{ .mode = .read_only }) catch break :blk null; 2251 defer file.close(io); 2252 var token: [token_hex_len]u8 = undefined; 2253 var storage: [token_hex_len]u8 = undefined; 2254 var reader = file.reader(io, &storage); 2255 reader.interface.readSliceAll(&token) catch break :blk null; 2256 break :blk token; 2257 }; 2258 return .{ .io = io, .dir = dir, .flash_dir = flash_dir, .auto_login = auto_login }; 2259 } 2260 2261 pub fn deinit(self: SessionStore) void { 2262 self.flash_dir.close(self.io); 2263 self.dir.close(self.io); 2264 } 2265 2266 // mint a new session for user_id, returning the token hex to set as the 2267 // cookie value. 2268 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 2269 var token: [evt.event_id_size]u8 = undefined; 2270 self.io.random(&token); 2271 const token_hex = std.fmt.bytesToHex(token, .lower); 2272 const file = try self.dir.createFile(self.io, &token_hex, .{}); 2273 defer file.close(self.io); 2274 try file.writeStreamingAll(self.io, user_id); 2275 return token_hex; 2276 } 2277 2278 fn createFlash(self: SessionStore, allocator: std.mem.Allocator, value: ui.Session.FormFeedback) ![token_hex_len]u8 { 2279 var token: [evt.event_id_size]u8 = undefined; 2280 self.io.random(&token); 2281 const token_hex = std.fmt.bytesToHex(token, .lower); 2282 const file = try self.flash_dir.createFile(self.io, &token_hex, .{}); 2283 errdefer self.flash_dir.deleteFile(self.io, &token_hex) catch {}; 2284 defer file.close(self.io); 2285 2286 var json: std.Io.Writer.Allocating = .init(allocator); 2287 defer json.deinit(); 2288 try std.json.Stringify.value(value, .{}, &json.writer); 2289 try file.writeStreamingAll(self.io, json.written()); 2290 return token_hex; 2291 } 2292 2293 fn takeFlash(self: SessionStore, allocator: std.mem.Allocator, token_hex: []const u8) ?ui.Session.FormFeedback { 2294 if (!isToken(token_hex)) return null; 2295 const file = self.flash_dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return null; 2296 defer self.flash_dir.deleteFile(self.io, token_hex) catch {}; 2297 defer file.close(self.io); 2298 2299 var storage: [4096]u8 = undefined; 2300 var reader = file.reader(self.io, &storage); 2301 const bytes = reader.interface.allocRemaining(allocator, .limited(512 * 1024)) catch return null; 2302 return std.json.parseFromSliceLeaky(ui.Session.FormFeedback, allocator, bytes, .{}) catch null; 2303 } 2304 2305 // resolve a cookie's token to its user_id. returns false (logged out) for a 2306 // missing, malformed, or unknown token. 2307 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 2308 if (!isToken(token_hex)) return false; 2309 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 2310 defer file.close(self.io); 2311 var storage: [evt.event_id_size]u8 = undefined; 2312 var file_reader = file.reader(self.io, &storage); 2313 file_reader.interface.readSliceAll(out) catch return false; 2314 return true; 2315 } 2316 2317 // revoke a session. a no-op for a missing or malformed token. 2318 pub fn remove(self: SessionStore, token_hex: []const u8) void { 2319 if (!isToken(token_hex)) return; 2320 self.dir.deleteFile(self.io, token_hex) catch {}; 2321 } 2322 2323 // leave `token_hex`'s session for visitors arriving without one. only the 2324 // `try` fixture writes one. the name can't collide with a session token. 2325 pub fn offerAutoLogin(self: SessionStore, token_hex: *const [token_hex_len]u8) !void { 2326 const file = try self.dir.createFile(self.io, auto_login_name, .{}); 2327 defer file.close(self.io); 2328 try file.writeStreamingAll(self.io, token_hex); 2329 } 2330 2331 pub fn autoLogin(self: SessionStore, out: *[token_hex_len]u8) bool { 2332 out.* = self.auto_login orelse return false; 2333 return true; 2334 } 2335 2336 const auto_login_name = "auto-login"; 2337 2338 // a token is exactly token_hex_len hex chars. validating before using the 2339 // value as a path keeps attacker-supplied cookies from escaping the 2340 // sessions dir (e.g. via "/" or ".." bytes). 2341 fn isToken(token_hex: []const u8) bool { 2342 if (token_hex.len != token_hex_len) return false; 2343 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 2344 return true; 2345 } 2346 }; 2347