diff options
| author | sadbeast <sadbeast@sadbeast.com> | 2024-07-16 18:16:29 -0700 | 
|---|---|---|
| committer | sadbeast <sadbeast@sadbeast.com> | 2024-10-05 16:40:55 -0700 | 
| commit | 6bd24af2ffbea91db1b10a5d5258980ce2068c7f (patch) | |
| tree | 66634833f2d45260be5fcaf9111400eda12f03cc /src/web | |
| download | teamdraft-6bd24af2ffbea91db1b10a5d5258980ce2068c7f.tar.gz teamdraft-6bd24af2ffbea91db1b10a5d5258980ce2068c7f.tar.bz2  | |
Diffstat (limited to 'src/web')
| -rw-r--r-- | src/web/Cookie.zig | 418 | ||||
| -rw-r--r-- | src/web/handler.zig | 0 | ||||
| -rw-r--r-- | src/web/middleware/Logger.zig | 51 | ||||
| -rw-r--r-- | src/web/web.zig | 241 | 
4 files changed, 710 insertions, 0 deletions
diff --git a/src/web/Cookie.zig b/src/web/Cookie.zig new file mode 100644 index 0000000..c44bbd2 --- /dev/null +++ b/src/web/Cookie.zig @@ -0,0 +1,418 @@ +const std = @import("std"); + +allocator: std.mem.Allocator, +cookie_string: []const u8, +cookies: std.StringArrayHashMap(*Cookie), +modified: bool = false, +arena: std.heap.ArenaAllocator, + +const Self = @This(); + +const SameSite = enum { strict, lax, none }; +pub const CookieOptions = struct { +    domain: []const u8 = "localhost", +    path: []const u8 = "/", +    same_site: ?SameSite = null, +    secure: bool = false, +    expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp() +    http_only: bool = false, +    max_age: ?i64 = null, +    partitioned: bool = false, +}; + +pub const Cookie = struct { +    name: []const u8, +    value: []const u8, +    domain: ?[]const u8 = null, +    path: ?[]const u8 = null, +    same_site: ?SameSite = null, +    secure: ?bool = null, +    expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp() +    http_only: ?bool = null, +    max_age: ?i64 = null, +    partitioned: ?bool = null, + +    /// Build a cookie string. +    pub fn bufPrint(self: Cookie, buf: *[4096]u8) ![]const u8 { +        var options: CookieOptions = .{}; +        inline for (std.meta.fields(CookieOptions)) |field| { +            @field(options, field.name) = @field(self, field.name) orelse @field(options, field.name); +        } + +        // secure is required if samesite is set to none +        const require_secure = if (options.same_site) |same_site| same_site == .none else false; + +        var stream = std.io.fixedBufferStream(buf); +        const writer = stream.writer(); + +        try writer.print("{s}={s}; path={s}; domain={s};", .{ +            self.name, +            self.value, +            options.path, +            options.domain, +        }); + +        if (options.same_site) |same_site| try writer.print(" SameSite={s};", .{@tagName(same_site)}); +        if (options.secure or require_secure) try writer.writeAll(" Secure;"); +        if (options.expires) |expires| try writer.print(" Expires={d};", .{std.time.timestamp() + expires}); +        if (options.max_age) |max_age| try writer.print(" Max-Age={d};", .{max_age}); +        if (options.http_only) try writer.writeAll(" HttpOnly;"); +        if (options.partitioned) try writer.writeAll(" Partitioned;"); + +        return stream.getWritten(); +    } + +    pub fn applyFlag(self: *Cookie, allocator: std.mem.Allocator, flag: Flag) !void { +        switch (flag) { +            .domain => |domain| self.domain = try allocator.dupe(u8, domain), +            .path => |path| self.path = try allocator.dupe(u8, path), +            .same_site => |same_site| self.same_site = same_site, +            .secure => |secure| self.secure = secure, +            .expires => |expires| self.expires = expires, +            .http_only => |http_only| self.http_only = http_only, +            .max_age => |max_age| self.max_age = max_age, +            .partitioned => |partitioned| self.partitioned = partitioned, +        } +    } +}; + +pub fn init(allocator: std.mem.Allocator, cookie_string: []const u8) Self { +    return .{ +        .allocator = allocator, +        .cookie_string = cookie_string, +        .cookies = std.StringArrayHashMap(*Cookie).init(allocator), +        .arena = std.heap.ArenaAllocator.init(allocator), +    }; +} + +pub fn deinit(self: *Self) void { +    var it = self.cookies.iterator(); +    while (it.next()) |item| { +        self.allocator.free(item.key_ptr.*); +        self.allocator.free(item.value_ptr.*.value); +        self.allocator.destroy(item.value_ptr.*); +    } +    self.cookies.deinit(); +    self.arena.deinit(); +} + +pub fn get(self: *Self, key: []const u8) ?*Cookie { +    return self.cookies.get(key); +} + +pub fn put(self: *Self, cookie: Cookie) !void { +    self.modified = true; + +    if (self.cookies.fetchSwapRemove(cookie.name)) |entry| { +        self.allocator.free(entry.key); +        self.allocator.free(entry.value.value); +        self.allocator.destroy(entry.value); +    } +    const key = try self.allocator.dupe(u8, cookie.name); +    const ptr = try self.allocator.create(Cookie); +    ptr.* = cookie; +    ptr.name = key; +    ptr.value = try self.allocator.dupe(u8, cookie.value); +    try self.cookies.put(key, ptr); +} + +pub const HeaderIterator = struct { +    allocator: std.mem.Allocator, +    cookies_iterator: std.StringArrayHashMap(*Cookie).Iterator, +    buf: *[4096]u8, + +    pub fn init(allocator: std.mem.Allocator, cookies: *Self, buf: *[4096]u8) HeaderIterator { +        return .{ .allocator = allocator, .cookies_iterator = cookies.cookies.iterator(), .buf = buf }; +    } + +    pub fn next(self: *HeaderIterator) !?[]const u8 { +        if (self.cookies_iterator.next()) |entry| { +            const cookie = entry.value_ptr.*; +            return try cookie.bufPrint(self.buf); +        } else { +            return null; +        } +    } +}; + +pub fn headerIterator(self: *Self, buf: *[4096]u8) HeaderIterator { +    return HeaderIterator.init(self.allocator, self, buf); +} + +// https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1 +// cookie-header = "Cookie:" OWS cookie-string OWS +// cookie-string = cookie-pair *( ";" SP cookie-pair ) +pub fn parse(self: *Self) !void { +    var key_buf = std.ArrayList(u8).init(self.allocator); +    var value_buf = std.ArrayList(u8).init(self.allocator); +    var key_terminated = false; +    var value_started = false; +    var cookie_buf = std.ArrayList(Cookie).init(self.allocator); + +    defer key_buf.deinit(); +    defer value_buf.deinit(); +    defer cookie_buf.deinit(); +    defer self.modified = false; + +    for (self.cookie_string, 0..) |char, index| { +        if (char == '=') { +            key_terminated = true; +            continue; +        } + +        if (char == ';' or index == self.cookie_string.len - 1) { +            if (char != ';') try value_buf.append(char); +            if (parseFlag(key_buf.items, value_buf.items)) |flag| { +                for (cookie_buf.items) |*cookie| try cookie.applyFlag(self.arena.allocator(), flag); +            } else { +                try cookie_buf.append(.{ +                    .name = try self.arena.allocator().dupe(u8, key_buf.items), +                    .value = try self.arena.allocator().dupe(u8, value_buf.items), +                }); +            } +            key_buf.clearAndFree(); +            value_buf.clearAndFree(); +            value_started = false; +            key_terminated = false; +            continue; +        } + +        if (!key_terminated and char == ' ') continue; + +        if (!key_terminated) { +            try key_buf.append(char); +            continue; +        } + +        if (char == ' ' and !value_started) continue; +        if (char != ' ' and !value_started) value_started = true; + +        if (key_terminated and value_started) { +            try value_buf.append(char); +            continue; +        } + +        return error.JetzigInvalidCookieHeader; +    } + +    for (cookie_buf.items) |cookie| try self.put(cookie); +} + +const Flag = union(enum) { +    domain: []const u8, +    path: []const u8, +    same_site: SameSite, +    secure: bool, +    expires: i64, +    max_age: i64, +    http_only: bool, +    partitioned: bool, +}; + +fn parseFlag(key: []const u8, value: []const u8) ?Flag { +    if (key.len > 64) return null; +    if (value.len > 64) return null; + +    var key_buf: [64]u8 = undefined; + +    const normalized_key = std.ascii.lowerString(&key_buf, strip(key)); +    const normalized_value = strip(value); + +    if (std.mem.eql(u8, normalized_key, "domain")) { +        return .{ .domain = normalized_value }; +    } else if (std.mem.eql(u8, normalized_key, "path")) { +        return .{ .path = normalized_value }; +    } else if (std.mem.eql(u8, normalized_key, "samesite")) { +        return if (std.mem.eql(u8, normalized_value, "strict")) +            .{ .same_site = .strict } +        else if (std.mem.eql(u8, normalized_value, "lax")) +            .{ .same_site = .lax } +        else +            .{ .same_site = .none }; +    } else if (std.mem.eql(u8, normalized_key, "secure")) { +        return .{ .secure = true }; +    } else if (std.mem.eql(u8, normalized_key, "httponly")) { +        return .{ .http_only = true }; +    } else if (std.mem.eql(u8, normalized_key, "partitioned")) { +        return .{ .partitioned = true }; +    } else if (std.mem.eql(u8, normalized_key, "expires")) { +        return .{ .expires = std.fmt.parseInt(i64, normalized_value, 10) catch return null }; +    } else if (std.mem.eql(u8, normalized_key, "max-age")) { +        return .{ .max_age = std.fmt.parseInt(i64, normalized_value, 10) catch return null }; +    } else { +        return null; +    } +} + +inline fn strip(input: []const u8) []const u8 { +    return std.mem.trim(u8, input, &std.ascii.whitespace); +} + +test "basic cookie string" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux;"); +    defer cookies.deinit(); +    try cookies.parse(); +    try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value); +    try std.testing.expectEqualStrings("qux", cookies.get("baz").?.value); +} + +test "empty cookie string" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, ""); +    defer cookies.deinit(); +    try cookies.parse(); +} + +test "cookie string with irregular spaces" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=   bar;     baz=        qux;"); +    defer cookies.deinit(); +    try cookies.parse(); +    try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value); +    try std.testing.expectEqualStrings("qux", cookies.get("baz").?.value); +} + +test "headerIterator" { +    const allocator = std.testing.allocator; +    var buf = std.ArrayList(u8).init(allocator); +    defer buf.deinit(); + +    const writer = buf.writer(); + +    var cookies = Self.init(allocator, "foo=bar; baz=qux;"); +    defer cookies.deinit(); +    try cookies.parse(); + +    var it_buf: [4096]u8 = undefined; +    var it = cookies.headerIterator(&it_buf); +    while (try it.next()) |*header| { +        try writer.writeAll(header.*); +        try writer.writeAll("\n"); +    } + +    try std.testing.expectEqualStrings( +        \\foo=bar; path=/; domain=localhost; +        \\baz=qux; path=/; domain=localhost; +        \\ +    , buf.items); +} + +test "modified" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    try std.testing.expect(cookies.modified == false); + +    try cookies.put(.{ .name = "quux", .value = "corge" }); +    try std.testing.expect(cookies.modified == true); +} + +test "domain=example.com" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; Domain=example.com;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expectEqualStrings(cookie.domain.?, "example.com"); +} + +test "path=/example_path" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; Path=/example_path;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expectEqualStrings(cookie.path.?, "/example_path"); +} + +test "SameSite=lax" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=lax;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expect(cookie.same_site == .lax); +} + +test "SameSite=none" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=none;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expect(cookie.same_site == .none); +} + +test "SameSite=strict" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=strict;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expect(cookie.same_site == .strict); +} + +test "Secure" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; Secure;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expect(cookie.secure.?); +} + +test "Partitioned" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; Partitioned;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expect(cookie.partitioned.?); +} + +test "Max-Age" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; Max-Age=123123123;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expect(cookie.max_age.? == 123123123); +} + +test "Expires" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux; Expires=123123123;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expect(cookie.expires.? == 123123123); +} + +test "default flags" { +    const allocator = std.testing.allocator; +    var cookies = Self.init(allocator, "foo=bar; baz=qux;"); +    defer cookies.deinit(); + +    try cookies.parse(); +    const cookie = cookies.get("foo").?; +    try std.testing.expect(cookie.domain == null); +    try std.testing.expect(cookie.path == null); +    try std.testing.expect(cookie.same_site == null); +    try std.testing.expect(cookie.secure == null); +    try std.testing.expect(cookie.expires == null); +    try std.testing.expect(cookie.http_only == null); +    try std.testing.expect(cookie.max_age == null); +    try std.testing.expect(cookie.partitioned == null); +} diff --git a/src/web/handler.zig b/src/web/handler.zig new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/web/handler.zig diff --git a/src/web/middleware/Logger.zig b/src/web/middleware/Logger.zig new file mode 100644 index 0000000..f2b1637 --- /dev/null +++ b/src/web/middleware/Logger.zig @@ -0,0 +1,51 @@ +const Logger = @This(); + +query: bool, + +// Must define an `init` method, which will accept your Config +// Alternatively, you can define a init(config: Config, mc: httpz.MiddlewareConfig) +// here mc will give you access to the server's allocator and arena +pub fn init(config: Config) !Logger { +    return .{ +        .query = config.query, +    }; +} + +// optionally you can define an "deinit" method +// pub fn deinit(self: *Logger) void { + +// } + +// Must define an `execute` method. `self` doesn't have to be `const`, but +// you're responsible for making your middleware thread-safe. +pub fn execute(self: *const Logger, req: *httpz.Request, res: *httpz.Response, executor: anytype) !void { +    const start = std.time.microTimestamp(); + +    defer { +        const elapsed = std.time.microTimestamp() - start; +        var logger = logz.logger().multiuse() +            .stringSafe("@l", "REQ") +            .stringSafe("method", @tagName(req.method)) +            .int("status", res.status) +            .string("path", req.url.path); + +        if (self.query) { +            _ = logger.string("query", req.url.query); +        } + +        logger.int("us", elapsed).log(); +    } + +    // If you don't call executor.next(), there will be no further processing of +    // the request and we'll go straight to writing the response. +    return executor.next(); +} + +// Must defined a pub config structure, even if it's empty +pub const Config = struct { +    query: bool, +}; + +const std = @import("std"); +const httpz = @import("httpz"); +const logz = @import("logz"); diff --git a/src/web/web.zig b/src/web/web.zig new file mode 100644 index 0000000..e0882a0 --- /dev/null +++ b/src/web/web.zig @@ -0,0 +1,241 @@ +pub fn start(app: *App) !void { +    const allocator = app.allocator; + +    var server = try httpz.Server(*App).init(allocator, .{ +        .address = "0.0.0.0", +        .port = 5882, +        .request = .{ +            .max_form_count = 4, +        }, +    }, app); +    defer server.deinit(); + +    const router = server.router(); +    { +        // publicly accessible +        var routes = router.group("/", .{}); + +        routes.get("/", index); +        routes.get("/about", about); +        routes.get("/invite/:code", acceptInvite); + +        routes.post("/drafts", createDraft); +        routes.get("/drafts/:id", showDraft); +        routes.post("/drafts/:id/pick", createPick); +    } + +    const http_address = try std.fmt.allocPrint(allocator, "http://{s}:{d}", .{ "0.0.0.0", 5882 }); +    logz.info().ctx("http").string("address", http_address).log(); +    allocator.free(http_address); + +    // blocks +    try server.listen(); +} + +fn index(_: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { +    var data = Data.init(res.arena); +    defer data.deinit(); + +    const dt = try zul.DateTime.fromUnix(std.time.timestamp(), .seconds); +    const tomorrow = try dt.add(1, .hours); + +    var root = try data.object(); +    try root.put("draft_time", try std.fmt.allocPrint(req.arena, "{s}T{s}", .{ tomorrow.date(), tomorrow.time() })); + +    try renderData("index", &data, req, res); +} + +fn about(_: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { +    try render("about", req, res); +} + +fn acceptInvite(ctx: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { +    var data = Data.init(res.arena); +    defer data.deinit(); + +    if (req.params.get("code")) |code| { +        var accept_invite_result = (try ctx.app.pool.row("SELECT * FROM accept_invite($1)", .{code})) orelse { +            res.status = 500; +            return; +        }; +        defer accept_invite_result.deinit() catch {}; + +        const session_id = accept_invite_result.get([]const u8, 0); +        const draft_id = accept_invite_result.get(i64, 1); +        res.header("Set-Cookie", try std.fmt.allocPrint(res.arena, "s={s}; Path=/", .{session_id})); +        res.header("Location", try std.fmt.allocPrint(res.arena, "/drafts/{}", .{draft_id})); + +        res.status = 302; +    } +} + +fn createPick(ctx: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { +    const query = try req.query(); +    if (query.get("team_id")) |team_id| { +        if (req.params.get("id")) |draft_id| { +            _ = try ctx.app.pool.exec("CALL auto_draft($1)", .{draft_id}); +            _ = try ctx.app.pool.exec("INSERT INTO picks (draft_user_id, draft_id, team_id) VALUES (current_picker_id($1), $1, $2)", .{ draft_id, team_id }); + +            res.header("Location", try std.fmt.allocPrint(res.arena, "/drafts/{s}", .{draft_id})); +            res.header("HX-Location", try std.fmt.allocPrint(res.arena, "/drafts/{s}", .{draft_id})); + +            res.status = 302; +        } +    } +} + +fn createDraft(ctx: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { +    const formData = try req.formData(); + +    if (formData.get("player1")) |player1| { +        logz.info().string("player1", player1).log(); +        const player2 = formData.get("player2") orelse "Player 2"; +        var create_draft_result: pg.QueryRow = undefined; + +        if (formData.get("draft")) |_| { +            if (formData.get("draft_time")) |draft_time| { +                logz.info().string("draft_time", draft_time).log(); +                create_draft_result = (try ctx.app.pool.row("SELECT * FROM create_draft(1, $1, $2, $3)", .{ player1, player2, draft_time })) orelse { +                    res.status = 500; +                    return; +                }; +            } +        } else { +            create_draft_result = (try ctx.app.pool.row("SELECT * FROM create_draft(1, $1, $2, null)", .{ player1, player2 })) orelse { +                res.status = 500; +                return; +            }; +        } +        defer create_draft_result.deinit() catch {}; + +        const session_id = create_draft_result.get([]const u8, 0); +        const draft_id = create_draft_result.get(i64, 1); +        res.header("Set-Cookie", try std.fmt.allocPrint(res.arena, "s={s}; Path=/", .{session_id})); +        res.header("Location", try std.fmt.allocPrint(res.arena, "/drafts/{}", .{draft_id})); + +        res.status = 302; +        return; +    } + +    var data = Data.init(res.arena); +    defer data.deinit(); +    if (formData.get("draft")) |_| { +        var root = try data.root(.object); +        try root.put("draft", true); +    } +    try renderData("index", &data, req, res); +} + +pub const Team = struct { +    team_id: i32, +    rank: ?i16, +    name: []const u8, +    picked: bool, +    pick_user: ?[]const u8, +}; + +pub const DraftInfo = struct { +    draft_id: i64, +    current_player_id: ?i64, +    current_player_name: ?[]const u8, +    draft_status_id: i16, +    round_time_remaining: ?i32, +    message: ?[]const u8, +    can_pick: bool, +}; + +fn showDraft(ctx: *RequestContext, req: *httpz.Request, res: *httpz.Response) !void { +    if (req.params.get("id")) |draft_id| { +        var draft_count = (try ctx.app.pool.row("SELECT count(1) FROM drafts WHERE draft_id = $1", .{draft_id})) orelse @panic("oh no"); +        defer draft_count.deinit() catch {}; +        if (draft_count.get(i64, 0) == 0) { +            return ctx.app.notFound(req, res); +        } + +        var data = Data.init(res.arena); +        var root = try data.root(.object); + +        _ = try ctx.app.pool.exec("CALL auto_draft($1)", .{draft_id}); + +        const picks_query = "SELECT * FROM current_draft_picks($1)"; +        var picks_result = try ctx.app.pool.query(picks_query, .{draft_id}); +        defer picks_result.deinit(); + +        var teams = try data.array(); +        while (try picks_result.next()) |row| { +            const team = try row.to(Team, .{}); +            var team_data = try data.object(); +            try team_data.put("name", team.name); +            try team_data.put("pick_user", team.pick_user); +            try team_data.put("picked", team.picked); +            try team_data.put("rank", team.rank); +            try team_data.put("id", team.team_id); +            try teams.append(team_data); +        } +        try root.put("teams", teams); + +        var draft_info: DraftInfo = undefined; +        const current_picker_query = +            \\SELECT * FROM draft_info WHERE draft_id = $1 +        ; +        var row = try ctx.app.pool.row(current_picker_query, .{draft_id}); +        if (row) |r| { +            // defer r.denit(); +            draft_info = try r.to(DraftInfo, .{}); +            var can_pick = false; +            if (draft_info.current_player_id) |current_player_id| { +                if (ctx.user) |user| { +                    can_pick = draft_info.can_pick and current_player_id == user.id; +                } +            } +            try root.put("can_pick", can_pick); +            try root.put("running", draft_info.draft_status_id == 2); +            try root.put("draft_id", draft_info.draft_id); +            try root.put("message", draft_info.message); +            try root.put("round_time_remaining", draft_info.round_time_remaining); +            try root.put("current_picker", draft_info.current_player_name); +        } + +        const code_query = +            \\SELECT code FROM draft_user_invites  +            \\ JOIN draft_users USING(draft_user_id) +            \\ WHERE draft_id = $1 +        ; +        var code_result = (try ctx.app.pool.row(code_query, .{draft_id})) orelse @panic("oh no"); +        const code = code_result.get([]const u8, 0); +        defer code_result.deinit() catch {}; +        try root.put("code", code); + +        defer data.deinit(); + +        try renderData("draft", &data, req, res); + +        try row.?.deinit(); +    } +} + +pub fn render(template_name: []const u8, req: *httpz.Request, res: *httpz.Response) !void { +    var data = Data.init(res.arena); +    defer data.deinit(); +    try renderData(template_name, &data, req, res); +} + +pub fn renderData(template_name: []const u8, data: *Data, req: *httpz.Request, res: *httpz.Response) !void { +    if (zmpl.find(template_name)) |template| { +        res.body = try template.renderWithOptions(data, .{ .layout = if (req.header("hx-request")) |_| zmpl.find("body") else zmpl.find("layout") }); +    } +} + +const App = @import("../App.zig"); +const RequestContext = @import("../RequestContext.zig"); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const config = @import("config"); + +const httpz = @import("httpz"); +const logz = @import("logz"); +const pg = @import("pg"); +const zmpl = @import("zmpl"); +const zul = @import("zul"); +const Data = zmpl.Data;  |