Merge pull request #120 from jetzig-framework/anti-csrf

Closes #108: Anti-CSRF middleware
This commit is contained in:
bobf 2024-11-23 12:58:03 +00:00 committed by GitHub
commit 130a7c81a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 700 additions and 142 deletions

View File

@ -45,7 +45,7 @@ jobs:
- name: Run App Tests
run: |
cd demo
zig build jetzig:test
zig build -Denvironment=testing jetzig:test
- name: Build artifacts
if: ${{ matrix.os == 'ubuntu-latest' }}

View File

@ -109,6 +109,7 @@ pub fn build(b: *std.Build) !void {
main_tests.root_module.addImport("smtp", smtp_client_dep.module("smtp_client"));
const test_build_options = b.addOptions();
test_build_options.addOption(Environment, "environment", .testing);
test_build_options.addOption(bool, "build_static", true);
const run_main_tests = b.addRunArtifact(main_tests);
main_tests.root_module.addOptions("build_options", test_build_options);
@ -137,6 +138,11 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
"environment",
"Jetzig server environment.",
) orelse .development;
const build_static = b.option(
bool,
"build_static",
"Pre-render static routes. [default: false in development, true in testing/production]",
) orelse (environment != .development);
const jetzig_dep = b.dependency(
"jetzig",
@ -164,6 +170,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
const build_options = b.addOptions();
build_options.addOption(Environment, "environment", environment);
build_options.addOption(bool, "build_static", build_static);
jetzig_module.addOptions("build_options", build_options);
exe.root_module.addImport("jetzig", jetzig_module);
@ -253,15 +260,23 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
exe_static_routes.root_module.addImport("zmpl", zmpl_module);
const markdown_fragments_write_files = b.addWriteFiles();
const path = markdown_fragments_write_files.add("markdown_fragments.zig", try generateMarkdownFragments(b));
const path = markdown_fragments_write_files.add(
"markdown_fragments.zig",
try generateMarkdownFragments(b),
);
const markdown_fragments_module = b.createModule(.{ .root_source_file = path });
exe_static_routes.root_module.addImport("markdown_fragments", markdown_fragments_module);
const run_static_routes_cmd = b.addRunArtifact(exe_static_routes);
const static_outputs_path = run_static_routes_cmd.addOutputFileArg("static.zig");
const static_module = b.createModule(.{ .root_source_file = static_outputs_path });
exe.root_module.addImport("static", static_module);
const static_module = if (build_static)
b.createModule(.{ .root_source_file = static_outputs_path })
else
b.createModule(.{
.root_source_file = jetzig_dep.builder.path("src/jetzig/development_static.zig"),
});
exe.root_module.addImport("static", static_module);
run_static_routes_cmd.expectExitCode(0);
const run_tests_file_cmd = b.addRunArtifact(exe_routes_file);

View File

@ -7,8 +7,8 @@
.hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/25b91d030b992631d319adde1cf01baecd9f3934.tar.gz",
.hash = "12208dd5a4bf0c6c7efc4e9f37a5d8ed80d6004d5680176d1fc2114bfa593e927baf",
.url = "https://github.com/jetzig-framework/zmpl/archive/af75c8b842c3957eb97b4fc4bc49c7b2243968fa.tar.gz",
.hash = "1220ecac93d295dafd2f034a86f0979f6108d40e5ea1a39e3a2b9977c35147cac684",
},
.jetkv = .{
.url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz",

View File

@ -0,0 +1,77 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub const layout = "application";
pub const actions = .{
.before = .{jetzig.middleware.AntiCsrfMiddleware},
};
pub fn post(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
const Params = struct { spam: []const u8 };
const params = try request.expectParams(Params) orelse {
return request.fail(.unprocessable_entity);
};
try root.put("spam", params.spam);
return request.render(.created);
}
pub fn index(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}
test "post with missing token" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.POST, "/anti_csrf", .{});
try response.expectStatus(.forbidden);
}
test "post with invalid token" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.POST, "/anti_csrf", .{});
try response.expectStatus(.forbidden);
}
test "post with valid token but missing expected params" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
_ = try app.request(.GET, "/anti_csrf", .{});
const token = app.session.getT(.string, jetzig.authenticity_token_name).?;
const response = try app.request(
.POST,
"/anti_csrf",
.{ .params = .{ ._jetzig_authenticity_token = token } },
);
try response.expectStatus(.unprocessable_entity);
}
test "post with valid token and expected params" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
_ = try app.request(.GET, "/anti_csrf", .{});
const token = app.session.getT(.string, jetzig.authenticity_token_name).?;
const response = try app.request(
.POST,
"/anti_csrf",
.{ .params = .{ ._jetzig_authenticity_token = token, .spam = "Spam" } },
);
try response.expectStatus(.created);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/anti_csrf", .{});
try response.expectStatus(.ok);
}

View File

@ -0,0 +1,8 @@
<form action="/anti_csrf" method="POST">
{{context.authenticityFormElement()}}
<label>Enter spam here:</label>
<input type="text" name="spam" />
<input type="submit" value="Submit Spam" />
</form>

View File

@ -0,0 +1,5 @@
<h1>Spam Submitted Successfully</h1>
<h2>Spam:</h2>
<div>{{$.spam}}</div>

View File

@ -12,6 +12,8 @@ pub const jetzig_options = struct {
/// Middleware chain. Add any custom middleware here, or use middleware provided in
/// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`).
pub const middleware: []const type = &.{
// jetzig.middleware.AuthMiddleware,
// jetzig.middleware.AntiCsrfMiddleware,
// jetzig.middleware.HtmxMiddleware,
// jetzig.middleware.CompressionMiddleware,
// @import("app/middleware/DemoMiddleware.zig"),
@ -79,13 +81,16 @@ pub const jetzig_options = struct {
pub const Schema = @import("Schema");
/// HTTP cookie configuration
pub const cookies: jetzig.http.Cookies.CookieOptions = .{
.domain = switch (jetzig.environment) {
.development => "localhost",
.testing => "localhost",
.production => "www.example.com",
},
pub const cookies: jetzig.http.Cookies.CookieOptions = switch (jetzig.environment) {
.development, .testing => .{
.domain = "localhost",
.path = "/",
},
.production => .{
.same_site = true,
.secure = true,
.http_only = true,
},
};
/// Key-value store options. Set backend to `.file` to use a file-based store.

View File

@ -280,6 +280,8 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
\\ .static = {4s},
\\ .uri_path = "{5s}",
\\ .template = "{6s}",
\\ .before_callbacks = jetzig.callbacks.beforeCallbacks(@import("{7s}")),
\\ .after_callbacks = jetzig.callbacks.afterCallbacks(@import("{7s}")),
\\ .layout = if (@hasDecl(@import("{7s}"), "layout")) @import("{7s}").layout else null,
\\ .json_params = &[_][]const u8 {{ {8s} }},
\\ .formats = if (@hasDecl(@import("{7s}"), "formats")) @import("{7s}").formats else null,
@ -389,7 +391,7 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
for (capture.args, 0..) |arg, arg_index| {
if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) {
capture.static = true;
capture.static = jetzig.build_options.build_static;
capture.legacy = arg_index + 1 < capture.args.len;
try static_routes.append(capture.*);
} else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) {

View File

@ -13,7 +13,7 @@ pub fn main() !void {
log("Jetzig Routes:", .{});
const environment = jetzig.Environment.init(allocator, .{ .silent = true });
const environment = try jetzig.Environment.init(allocator, .{ .silent = true });
const initHook: ?*const fn (*jetzig.App) anyerror!void = if (@hasDecl(app, "init")) app.init else null;
inline for (routes.routes) |route| max_uri_path_len = @max(route.uri_path.len + 5, max_uri_path_len);
@ -44,7 +44,7 @@ pub fn main() !void {
}
var jetzig_app = jetzig.App{
.environment = environment,
.env = environment,
.allocator = allocator,
.custom_routes = std.ArrayList(jetzig.views.Route).init(allocator),
.initHook = initHook,

View File

@ -147,7 +147,7 @@ fn renderMarkdown(
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
view.data.content = .{ .data = content };
return try layout.render(view.data);
return try layout.render(view.data, jetzig.TemplateContext, .{}, .{});
} else {
std.debug.print("Unknown layout: {s}\n", .{layout_name});
return content;
@ -170,13 +170,18 @@ fn renderZmplTemplate(
defer allocator.free(prefixed_name);
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
return try template.renderWithOptions(view.data, .{ .layout = layout });
return try template.render(
view.data,
jetzig.TemplateContext,
.{},
.{ .layout = layout },
);
} else {
std.debug.print("Unknown layout: {s}\n", .{layout_name});
return try allocator.dupe(u8, "");
}
} else {
return try template.render(view.data);
return try template.render(view.data, jetzig.TemplateContext, .{}, .{});
}
} else return null;
}

View File

@ -22,11 +22,15 @@ pub const database = @import("jetzig/database.zig");
pub const testing = @import("jetzig/testing.zig");
pub const config = @import("jetzig/config.zig");
pub const auth = @import("jetzig/auth.zig");
pub const callbacks = @import("jetzig/callbacks.zig");
pub const TemplateContext = @import("jetzig/TemplateContext.zig");
pub const DateTime = jetcommon.types.DateTime;
pub const Time = jetcommon.types.Time;
pub const Date = jetcommon.types.Date;
pub const authenticity_token_name = config.get([]const u8, "authenticity_token_name");
pub const build_options = @import("build_options");
pub const environment = std.enums.nameCast(Environment.EnvironmentName, build_options.environment);
@ -46,6 +50,9 @@ pub const Request = http.Request;
/// requests.
pub const StaticRequest = http.StaticRequest;
/// An HTTP response generated during request processing.
pub const Response = http.Response;
/// Generic, JSON-compatible data type. Provides `Value` which in turn provides `Object`,
/// `Array`, `String`, `Integer`, `Float`, `Boolean`, and `NullType`.
pub const Data = data.Data;
@ -78,7 +85,8 @@ pub const Logger = loggers.Logger;
pub const root = @import("root");
pub const Global = if (@hasDecl(root, "Global")) root.Global else DefaultGlobal;
pub const DefaultGlobal = struct { __jetzig_default: bool };
pub const DefaultGlobal = struct { comptime __jetzig_default: bool = true };
pub const default_global = DefaultGlobal{};
pub const initHook: ?*const fn (*App) anyerror!void = if (@hasDecl(root, "init")) root.init else null;

View File

@ -17,8 +17,8 @@ pub fn deinit(self: *const App) void {
@constCast(self).custom_routes.deinit();
}
// Not used yet, but allows us to add new options to `start()` without breaking
// backward-compatibility.
/// Specify a global value accessible as `request.server.global`.
/// Must specify type by defining `pub const Global` in your app's `src/main.zig`.
const AppOptions = struct {
global: *anyopaque = undefined,
};
@ -228,6 +228,8 @@ pub fn createRoutes(
.template = const_route.template,
.json_params = const_route.json_params,
.formats = const_route.formats,
.before_callbacks = const_route.before_callbacks,
.after_callbacks = const_route.after_callbacks,
};
try var_route.initParams(allocator);

View File

@ -0,0 +1,25 @@
const std = @import("std");
pub const http = @import("http.zig");
pub const config = @import("config.zig");
/// Context available in every Zmpl template as `context`.
pub const TemplateContext = @This();
request: ?*http.Request = null,
pub fn authenticityToken(self: TemplateContext) !?[]const u8 {
return if (self.request) |request|
try request.authenticityToken()
else
null;
}
pub fn authenticityFormElement(self: TemplateContext) !?[]const u8 {
return if (self.request) |request| blk: {
const token = try request.authenticityToken();
break :blk try std.fmt.allocPrint(request.allocator,
\\<input type="hidden" name="{s}" value="{s}" />
, .{ config.get([]const u8, "authenticity_token_name"), token });
} else null;
}

112
src/jetzig/callbacks.zig Normal file
View File

@ -0,0 +1,112 @@
const std = @import("std");
const jetzig = @import("../jetzig.zig");
pub const BeforeCallback = *const fn (
*jetzig.http.Request,
jetzig.views.Route,
) anyerror!void;
pub const AfterCallback = *const fn (
*jetzig.http.Request,
*jetzig.http.Response,
jetzig.views.Route,
) anyerror!void;
pub const Context = enum { before, after };
pub fn beforeCallbacks(view: type) []const BeforeCallback {
comptime {
return buildCallbacks(.before, view);
}
}
pub fn afterCallbacks(view: type) []const AfterCallback {
comptime {
return buildCallbacks(.after, view);
}
}
fn buildCallbacks(comptime context: Context, view: type) switch (context) {
.before => []const BeforeCallback,
.after => []const AfterCallback,
} {
comptime {
if (!@hasDecl(view, "actions")) return &.{};
if (!@hasField(@TypeOf(view.actions), @tagName(context))) return &.{};
var size: usize = 0;
for (@field(view.actions, @tagName(context))) |module| {
if (isCallback(context, module)) {
size += 1;
} else {
@compileError(std.fmt.comptimePrint(
"`{0s}` callbacks must be either a function `{1s}` or a type that defines " ++
"`pub const {0s}Render`. Found: `{2s}`",
.{
@tagName(context),
switch (context) {
.before => @typeName(BeforeCallback),
.after => @typeName(AfterCallback),
},
if (@TypeOf(module) == type)
@typeName(module)
else
@typeName(@TypeOf(&module)),
},
));
}
}
var callbacks: [size]switch (context) {
.before => BeforeCallback,
.after => AfterCallback,
} = undefined;
var index: usize = 0;
for (@field(view.actions, @tagName(context))) |module| {
if (!isCallback(context, module)) continue;
callbacks[index] = if (@TypeOf(module) == type)
@field(module, @tagName(context) ++ "Render")
else
&module;
index += 1;
}
const final = callbacks;
return &final;
}
}
fn isCallback(comptime context: Context, comptime module: anytype) bool {
comptime {
if (@typeInfo(@TypeOf(module)) == .@"fn") {
const expected = switch (context) {
.before => BeforeCallback,
.after => AfterCallback,
};
const info = @typeInfo(@TypeOf(module)).@"fn";
const actual_params = info.params;
const expected_params = @typeInfo(@typeInfo(expected).pointer.child).@"fn".params;
if (actual_params.len != expected_params.len) return false;
for (actual_params, expected_params) |actual_param, expected_param| {
if (actual_param.type != expected_param.type) return false;
}
if (@typeInfo(info.return_type.?) != .error_union) return false;
if (@typeInfo(info.return_type.?).error_union.payload != void) return false;
return true;
}
return if (@TypeOf(module) == type and @hasDecl(module, @tagName(context) ++ "Render"))
true
else
false;
}
}

View File

@ -17,6 +17,9 @@ pub const jobs = @import("jobs.zig");
pub const mail = @import("mail.zig");
pub const kv = @import("kv.zig");
pub const db = @import("database.zig");
pub const Environment = @import("Environment.zig");
pub const environment = std.enums.nameCast(Environment.EnvironmentName, build_options.environment);
pub const build_options = @import("build_options");
const root = @import("root");
@ -149,11 +152,26 @@ pub const smtp: mail.SMTPConfig = .{
};
/// HTTP cookie configuration
pub const cookies: http.Cookies.CookieOptions = .{
pub const cookies: http.Cookies.CookieOptions = switch (environment) {
.development, .testing => .{
.domain = "localhost",
.path = "/",
},
.production => .{
.secure = true,
.http_only = true,
.same_site = true,
.path = "/",
},
};
/// Override the default anti-CSRF authenticity token name that is stored in the encrypted
/// session. This value is also used by `context.authenticityFormElement()` to render an HTML
/// element: the element's `name` attribute is set to this value.
pub const authenticity_token_name: []const u8 = "_jetzig_authenticity_token";
/// When using `AuthMiddleware`, set this value to override the default JetQuery model name that
/// maps the users table.
pub const auth: @import("auth.zig").AuthOptions = .{
.user_model = "User",
};

View File

@ -0,0 +1,12 @@
pub const compiled = [_]Compiled{};
const StaticOutput = struct {
json: ?[]const u8 = null,
html: ?[]const u8 = null,
params: ?[]const u8,
};
const Compiled = struct {
route_id: []const u8,
output: StaticOutput,
};

View File

@ -1,9 +1,14 @@
const std = @import("std");
const builtin = @import("builtin");
pub const build_options = @import("build_options");
pub const Server = @import("http/Server.zig");
pub const Request = @import("http/Request.zig");
pub const StaticRequest = @import("http/StaticRequest.zig");
pub const StaticRequest = if (build_options.environment == .development)
Request
else
@import("http/StaticRequest.zig");
pub const Response = @import("http/Response.zig");
pub const Session = @import("http/Session.zig");
pub const Cookies = @import("http/Cookies.zig");

View File

@ -8,18 +8,18 @@ cookies: std.StringArrayHashMap(*Cookie),
modified: bool = false,
arena: std.heap.ArenaAllocator,
const Self = @This();
const Cookies = @This();
const SameSite = enum { strict, lax, none };
pub const CookieOptions = struct {
domain: []const u8 = "localhost",
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,
same_site: ?SameSite = null,
expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp()
max_age: ?i64 = null,
};
const cookie_options = jetzig.config.get(CookieOptions, "cookies");
@ -27,43 +27,50 @@ const cookie_options = jetzig.config.get(CookieOptions, "cookies");
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,
secure: bool = cookie_options.secure,
http_only: bool = cookie_options.http_only,
partitioned: bool = cookie_options.partitioned,
domain: ?[]const u8 = cookie_options.domain,
path: ?[]const u8 = cookie_options.path,
same_site: ?SameSite = cookie_options.same_site,
// if used, set to time in seconds to be added to std.time.timestamp()
expires: ?i64 = cookie_options.expires,
max_age: ?i64 = cookie_options.max_age,
/// Build a cookie string.
pub fn bufPrint(self: Cookie, buf: *[4096]u8) ![]const u8 {
var options = cookie_options;
inline for (std.meta.fields(CookieOptions)) |field| {
@field(options, field.name) = @field(self, field.name) orelse @field(cookie_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("{}", .{self});
return stream.getWritten();
}
try writer.print("{s}={s}; path={s}; domain={s};", .{
/// Build a cookie string.
pub fn format(self: Cookie, _: anytype, _: anytype, writer: anytype) !void {
// secure is required if samesite is set to none
const require_secure = if (self.same_site) |same_site| same_site == .none else false;
try writer.print("{s}={s}; path={s};", .{
self.name,
self.value,
options.path,
options.domain,
self.path orelse "/",
});
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();
if (self.domain) |domain| try writer.print(" domain={s};", .{domain});
if (self.same_site) |same_site| try writer.print(
" SameSite={s};",
.{@tagName(same_site)},
);
if (self.secure or require_secure) try writer.writeAll(" Secure;");
if (self.expires) |expires| {
const seconds = std.time.timestamp() + expires;
const timestamp = try jetzig.jetcommon.DateTime.fromUnix(seconds, .seconds);
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#expiresdate
try timestamp.strftime(writer, " Expires=%a, %d %h %Y %H:%M:%S GMT;");
}
if (self.max_age) |max_age| try writer.print(" Max-Age={d};", .{max_age});
if (self.http_only) try writer.writeAll(" HttpOnly;");
if (self.partitioned) try writer.writeAll(" Partitioned;");
}
pub fn applyFlag(self: *Cookie, allocator: std.mem.Allocator, flag: Flag) !void {
@ -80,7 +87,7 @@ pub const Cookie = struct {
}
};
pub fn init(allocator: std.mem.Allocator, cookie_string: []const u8) Self {
pub fn init(allocator: std.mem.Allocator, cookie_string: []const u8) Cookies {
return .{
.allocator = allocator,
.cookie_string = cookie_string,
@ -89,7 +96,7 @@ pub fn init(allocator: std.mem.Allocator, cookie_string: []const u8) Self {
};
}
pub fn deinit(self: *Self) void {
pub fn deinit(self: *Cookies) void {
var it = self.cookies.iterator();
while (it.next()) |item| {
self.allocator.free(item.key_ptr.*);
@ -100,11 +107,11 @@ pub fn deinit(self: *Self) void {
self.arena.deinit();
}
pub fn get(self: *Self, key: []const u8) ?*Cookie {
pub fn get(self: *Cookies, key: []const u8) ?*Cookie {
return self.cookies.get(key);
}
pub fn put(self: *Self, cookie: Cookie) !void {
pub fn put(self: *Cookies, cookie: Cookie) !void {
self.modified = true;
if (self.cookies.fetchSwapRemove(cookie.name)) |entry| {
@ -125,8 +132,12 @@ pub const HeaderIterator = struct {
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 init(allocator: std.mem.Allocator, cookies: *Cookies, buf: *[4096]u8) HeaderIterator {
return .{
.allocator = allocator,
.cookies_iterator = cookies.cookies.iterator(),
.buf = buf,
};
}
pub fn next(self: *HeaderIterator) !?[]const u8 {
@ -139,14 +150,14 @@ pub const HeaderIterator = struct {
}
};
pub fn headerIterator(self: *Self, buf: *[4096]u8) HeaderIterator {
pub fn headerIterator(self: *Cookies, 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 {
pub fn parse(self: *Cookies) !void {
var key_buf = std.ArrayList(u8).init(self.allocator);
var value_buf = std.ArrayList(u8).init(self.allocator);
var key_terminated = false;
@ -202,6 +213,13 @@ pub fn parse(self: *Self) !void {
for (cookie_buf.items) |cookie| try self.put(cookie);
}
pub fn format(self: Cookies, _: anytype, _: anytype, writer: anytype) !void {
var it = self.cookies.iterator();
while (it.next()) |entry| {
try writer.print("{}; ", .{entry.value_ptr.*});
}
}
const Flag = union(enum) {
domain: []const u8,
path: []const u8,
@ -250,7 +268,7 @@ fn parseFlag(key: []const u8, value: []const u8) ?Flag {
test "basic cookie string" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux;");
defer cookies.deinit();
try cookies.parse();
try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value);
@ -259,14 +277,14 @@ test "basic cookie string" {
test "empty cookie string" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "");
var cookies = Cookies.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;");
var cookies = Cookies.init(allocator, "foo= bar; baz= qux;");
defer cookies.deinit();
try cookies.parse();
try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value);
@ -280,7 +298,7 @@ test "headerIterator" {
const writer = buf.writer();
var cookies = Self.init(allocator, "foo=bar; baz=qux;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux;");
defer cookies.deinit();
try cookies.parse();
@ -300,7 +318,7 @@ test "headerIterator" {
test "modified" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux;");
defer cookies.deinit();
try cookies.parse();
@ -312,7 +330,7 @@ test "modified" {
test "domain=example.com" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux; Domain=example.com;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Domain=example.com;");
defer cookies.deinit();
try cookies.parse();
@ -322,7 +340,7 @@ test "domain=example.com" {
test "path=/example_path" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux; Path=/example_path;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Path=/example_path;");
defer cookies.deinit();
try cookies.parse();
@ -332,7 +350,7 @@ test "path=/example_path" {
test "SameSite=lax" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=lax;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; SameSite=lax;");
defer cookies.deinit();
try cookies.parse();
@ -342,7 +360,7 @@ test "SameSite=lax" {
test "SameSite=none" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=none;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; SameSite=none;");
defer cookies.deinit();
try cookies.parse();
@ -352,7 +370,7 @@ test "SameSite=none" {
test "SameSite=strict" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=strict;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; SameSite=strict;");
defer cookies.deinit();
try cookies.parse();
@ -362,27 +380,27 @@ test "SameSite=strict" {
test "Secure" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux; Secure;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Secure;");
defer cookies.deinit();
try cookies.parse();
const cookie = cookies.get("foo").?;
try std.testing.expect(cookie.secure.?);
try std.testing.expect(cookie.secure);
}
test "Partitioned" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux; Partitioned;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Partitioned;");
defer cookies.deinit();
try cookies.parse();
const cookie = cookies.get("foo").?;
try std.testing.expect(cookie.partitioned.?);
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;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Max-Age=123123123;");
defer cookies.deinit();
try cookies.parse();
@ -392,7 +410,7 @@ test "Max-Age" {
test "Expires" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux; Expires=123123123;");
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Expires=123123123;");
defer cookies.deinit();
try cookies.parse();
@ -402,17 +420,17 @@ test "Expires" {
test "default flags" {
const allocator = std.testing.allocator;
var cookies = Self.init(allocator, "foo=bar; baz=qux;");
var cookies = Cookies.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.secure == false);
try std.testing.expect(cookie.partitioned == false);
try std.testing.expect(cookie.http_only == false);
try std.testing.expect(cookie.same_site == null);
try std.testing.expect(cookie.secure == null);
try std.testing.expectEqualStrings(cookie.domain.?, "localhost");
try std.testing.expectEqualStrings(cookie.path.?, "/");
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);
}

View File

@ -46,10 +46,9 @@ pub fn getAll(self: Headers, name: []const u8) []const []const u8 {
var headers = std.ArrayList([]const u8).init(self.allocator);
for (self.httpz_headers.keys, 0..) |key, index| {
var buf: [max_bytes_header_name]u8 = undefined;
const lower = std.ascii.lowerString(&buf, name);
if (std.mem.eql(u8, lower, key)) headers.append(self.httpz_headers.values[index]) catch @panic("OOM");
if (std.ascii.eqlIgnoreCase(name, key)) {
headers.append(self.httpz_headers.values[index]) catch @panic("OOM");
}
}
return headers.toOwnedSlice() catch @panic("OOM");
}

View File

@ -10,6 +10,7 @@ const default_content_type = "text/html";
pub const Method = enum { DELETE, GET, PATCH, POST, HEAD, PUT, CONNECT, OPTIONS, TRACE };
pub const Modifier = enum { edit, new };
pub const Format = enum { HTML, JSON, UNKNOWN };
pub const Protocol = enum { http, https };
allocator: std.mem.Allocator,
path: jetzig.http.Path,
@ -34,6 +35,8 @@ response_started: bool = false,
dynamic_assigned_template: ?[]const u8 = null,
layout: ?[]const u8 = null,
layout_disabled: bool = false,
// TODO: Squash rendered/redirected/failed into
// `state: enum { initial, rendered, redirected, failed }`
rendered: bool = false,
redirected: bool = false,
failed: bool = false,
@ -249,7 +252,10 @@ pub fn middleware(
unreachable;
}
const RedirectState = struct { location: []const u8, status_code: jetzig.http.status_codes.StatusCode };
const RedirectState = struct {
location: []const u8,
status_code: jetzig.http.status_codes.StatusCode,
};
pub fn renderRedirect(self: *Request, state: RedirectState) !void {
self.response_data.reset();
@ -275,7 +281,12 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void {
.HTML, .UNKNOWN => if (maybe_template) |template| blk: {
try view.data.addConst("jetzig_view", view.data.string("internal"));
try view.data.addConst("jetzig_action", view.data.string(@tagName(state.status_code)));
break :blk try template.render(self.response_data);
break :blk try template.render(
self.response_data,
jetzig.TemplateContext,
.{ .request = self },
.{},
);
} else try std.fmt.allocPrint(self.allocator, "Redirecting to {s}", .{state.location}),
.JSON => blk: {
break :blk try std.json.stringifyAlloc(
@ -486,6 +497,31 @@ pub fn session(self: *Request) !*jetzig.http.Session {
return local_session;
}
/// Return the anti-CSRF token cookie value. If no cookie exist, create it.
pub fn authenticityToken(self: *Request) ![]const u8 {
var local_session = try self.session();
return local_session.getT(.string, jetzig.authenticity_token_name) orelse blk: {
const token = try jetzig.util.generateSecret(self.allocator, 32);
try local_session.put(jetzig.authenticity_token_name, token);
break :blk local_session.getT(.string, jetzig.authenticity_token_name).?;
};
}
pub fn resourceId(self: *const Request) ![]const u8 {
return self.path.resource_id;
}
/// Return the protocol used to serve the current request by detecting the `X-Forwarded-Proto`
/// header.
pub fn protocol(self: *const Request) Protocol {
return if (self.headers.get("x-forwarded-proto")) |x_forwarded_proto|
if (std.ascii.eqlIgnoreCase(x_forwarded_proto, "https")) .https else .http
else
// TODO: Extend login when we support serving HTTPS directly.
.http;
}
/// Create a new Job. Receives a job name which must resolve to `src/app/jobs/<name>.zig`
/// Call `Job.put(...)` to set job params.
/// Call `Job.background()` to run the job outside of the request/response flow.

View File

@ -14,7 +14,6 @@ custom_routes: []jetzig.views.Route,
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
mime_map: *jetzig.http.mime.MimeMap,
std_net_server: std.net.Server = undefined,
initialized: bool = false,
store: *jetzig.kv.Store,
job_queue: *jetzig.kv.Store,
@ -57,7 +56,6 @@ pub fn init(
}
pub fn deinit(self: *Server) void {
if (self.initialized) self.std_net_server.deinit();
self.allocator.free(self.env.secret);
self.allocator.free(self.env.bind);
}
@ -187,21 +185,45 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void {
} else unreachable; // In future a MiddlewareRoute might provide a render function etc.
}
const route = self.matchCustomRoute(request) orelse try self.matchRoute(request, false);
const maybe_route = self.matchCustomRoute(request) orelse try self.matchRoute(request, false);
if (route) |capture| {
if (!capture.validateFormat(request)) {
if (maybe_route) |route| {
if (!route.validateFormat(request)) {
return request.setResponse(try self.renderNotFound(request), .{});
}
}
switch (request.requestFormat()) {
.HTML => try self.renderHTML(request, route),
.JSON => try self.renderJSON(request, route),
.UNKNOWN => try self.renderHTML(request, route),
if (maybe_route) |route| {
for (route.before_callbacks) |callback| {
try callback(request, route);
if (request.rendered_view) |view| {
if (request.failed) {
request.setResponse(try self.renderError(request, view.status_code), .{});
} else if (request.rendered) {
// TODO: Allow callbacks to set content
}
return;
}
if (request.redirect_state) |state| {
try request.renderRedirect(state);
return;
}
}
}
if (request.redirect_state) |state| return try request.renderRedirect(state);
switch (request.requestFormat()) {
.HTML => try self.renderHTML(request, maybe_route),
.JSON => try self.renderJSON(request, maybe_route),
.UNKNOWN => try self.renderHTML(request, maybe_route),
}
if (maybe_route) |route| {
for (route.after_callbacks) |callback| {
try callback(request, request.response, route);
}
}
if (request.redirect_state) |state| try request.renderRedirect(state);
}
fn renderStatic(resource: StaticResource, request: *jetzig.http.Request) !void {
@ -355,6 +377,8 @@ fn renderTemplateWithLayout(
) ![]const u8 {
try addTemplateConstants(view, route);
const template_context = jetzig.TemplateContext{ .request = request };
if (request.getLayout(route)) |layout_name| {
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/
const prefixed_name = try std.mem.concat(
@ -365,23 +389,37 @@ fn renderTemplateWithLayout(
defer self.allocator.free(prefixed_name);
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
return try template.renderWithOptions(view.data, .{ .layout = layout });
return try template.render(
view.data,
jetzig.TemplateContext,
template_context,
.{ .layout = layout },
);
} else {
try self.logger.WARN("Unknown layout: {s}", .{layout_name});
return try template.render(view.data);
return try template.render(
view.data,
jetzig.TemplateContext,
template_context,
.{},
);
}
} else return try template.render(view.data);
} else return try template.render(
view.data,
jetzig.TemplateContext,
template_context,
.{},
);
}
fn addTemplateConstants(view: jetzig.views.View, route: jetzig.views.Route) !void {
try view.data.addConst("jetzig_view", view.data.string(route.view_name));
const action = switch (route.action) {
.custom => route.name,
else => |tag| @tagName(tag),
};
try view.data.addConst("jetzig_action", view.data.string(action));
try view.data.addConst("jetzig_view", view.data.string(route.view_name));
}
fn isBadRequest(err: anyerror) bool {
@ -481,7 +519,15 @@ fn renderErrorView(
.HTML, .UNKNOWN => {
if (zmpl.findPrefixed("views", route.template)) |template| {
try addTemplateConstants(view, route.*);
return .{ .view = view, .content = try template.render(request.response_data) };
return .{
.view = view,
.content = try template.render(
request.response_data,
jetzig.TemplateContext,
.{ .request = request },
.{},
),
};
}
},
.JSON => return .{ .view = view, .content = try request.response_data.toJson() },
@ -573,19 +619,26 @@ fn matchMiddlewareRoute(request: *const jetzig.http.Request) ?jetzig.middleware.
fn matchRoute(self: *Server, request: *jetzig.http.Request, static: bool) !?jetzig.views.Route {
for (self.routes) |route| {
// .index routes always take precedence.
if (route.static == static and route.action == .index and try request.match(route.*)) {
return route.*;
if (route.action == .index and try request.match(route.*)) {
if (!jetzig.build_options.build_static) return route.*;
if (route.static == static) return route.*;
}
}
for (self.routes) |route| {
if (route.static == static and try request.match(route.*)) return route.*;
if (try request.match(route.*)) {
if (!jetzig.build_options.build_static) return route.*;
if (route.static == static) return route.*;
}
}
return null;
}
const StaticResource = struct { content: []const u8, mime_type: []const u8 = "application/octet-stream" };
const StaticResource = struct {
content: []const u8,
mime_type: []const u8 = "application/octet-stream",
};
fn matchStaticResource(self: *Server, request: *jetzig.http.Request) !?StaticResource {
// TODO: Map public and static routes at launch to avoid accessing the file system when
@ -682,7 +735,7 @@ fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8
}
pub fn decodeStaticParams(self: *Server) !void {
if (!@hasDecl(jetzig.root, "static")) return;
if (comptime !@hasDecl(jetzig.root, "static")) return;
// Store decoded static params (i.e. declared in views) for faster comparison at request time.
var decoded = std.ArrayList(*jetzig.data.Value).init(self.allocator);

View File

@ -134,7 +134,7 @@ fn defaultHtml(
try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template|
try template.render(&data)
try template.render(&data, jetzig.TemplateContext, .{}, .{})
else
null;
}
@ -152,7 +152,7 @@ fn defaultText(
try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template|
try template.render(&data)
try template.render(&data, jetzig.TemplateContext, .{}, .{})
else
null;
}

View File

@ -4,6 +4,7 @@ const jetzig = @import("../jetzig.zig");
pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig");
pub const CompressionMiddleware = @import("middleware/CompressionMiddleware.zig");
pub const AuthMiddleware = @import("middleware/AuthMiddleware.zig");
pub const AntiCsrfMiddleware = @import("middleware/AntiCsrfMiddleware.zig");
const RouteOptions = struct {
content: ?[]const u8 = null,

View File

@ -0,0 +1,73 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
pub const middleware_name = "anti_csrf";
const TokenParams = @Type(.{
.@"struct" = .{
.layout = .auto,
.is_tuple = false,
.decls = &.{},
.fields = &.{.{
.name = jetzig.authenticity_token_name ++ "",
.type = []const u8,
.is_comptime = false,
.default_value = null,
.alignment = @alignOf([]const u8),
}},
},
});
pub fn afterRequest(request: *jetzig.http.Request) !void {
try verifyCsrfToken(request);
}
pub fn beforeRender(request: *jetzig.http.Request, route: jetzig.views.Route) !void {
_ = route;
try verifyCsrfToken(request);
}
fn logFailure(request: *jetzig.http.Request) !void {
_ = request.fail(.forbidden);
try request.server.logger.DEBUG("Anti-CSRF token validation failed. Request aborted.", .{});
}
fn verifyCsrfToken(request: *jetzig.http.Request) !void {
switch (request.method) {
.DELETE, .PATCH, .PUT, .POST => {},
else => return,
}
switch (request.requestFormat()) {
.HTML, .UNKNOWN => {},
// We do not authenticate JSON requests. Users must implement their own authentication
// system or disable JSON endpoints that should be protected.
.JSON => return,
}
const session = try request.session();
if (session.getT(.string, jetzig.authenticity_token_name)) |token| {
const params = try request.expectParams(TokenParams) orelse {
return logFailure(request);
};
if (token.len != 32 or @field(params, jetzig.authenticity_token_name).len != 32) {
return try logFailure(request);
}
var actual: [32]u8 = undefined;
var expected: [32]u8 = undefined;
@memcpy(&actual, token[0..32]);
@memcpy(&expected, @field(params, jetzig.authenticity_token_name)[0..32]);
const valid = std.crypto.timing_safe.eql([32]u8, expected, actual);
if (!valid) {
return try logFailure(request);
}
} else {
return try logFailure(request);
}
}

View File

@ -11,11 +11,11 @@ const user_model = jetzig.config.get(jetzig.auth.AuthOptions, "auth").user_model
/// they can also be modified.
user: ?@TypeOf(jetzig.database.Query(user_model).find(0)).ResultType,
const Self = @This();
const AuthMiddleware = @This();
/// Initialize middleware.
pub fn init(request: *jetzig.http.Request) !*Self {
const middleware = try request.allocator.create(Self);
pub fn init(request: *jetzig.http.Request) !*AuthMiddleware {
const middleware = try request.allocator.create(AuthMiddleware);
middleware.* = .{ .user = null };
return middleware;
}
@ -31,8 +31,11 @@ const map = std.StaticStringMap(void).initComptime(.{
///
/// User ID is accessible from a request:
/// ```zig
///
pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void {
/// if (request.middleware(.auth).user) |user| {
/// try request.server.log(.DEBUG, "{}", .{user.id});
/// }
/// ```
pub fn afterRequest(self: *AuthMiddleware, request: *jetzig.http.Request) !void {
if (request.path.extension) |extension| {
if (map.get(extension) == null) return;
}
@ -47,6 +50,6 @@ pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void {
/// Invoked after `afterRequest` is called, use this function to do any clean-up.
/// Note that `request.allocator` is an arena allocator, so any allocations are automatically
/// done before the next request starts processing.
pub fn deinit(self: *Self, request: *jetzig.http.Request) void {
pub fn deinit(self: *AuthMiddleware, request: *jetzig.http.Request) void {
request.allocator.destroy(self);
}

View File

@ -15,6 +15,8 @@ multipart_boundary: ?[]const u8 = null,
logger: jetzig.loggers.Logger,
server: Server,
repo: *jetzig.database.Repo,
cookies: *jetzig.http.Cookies,
session: *jetzig.http.Session,
const Server = struct { logger: jetzig.loggers.Logger };
@ -47,6 +49,13 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
const app = try alloc.create(App);
const repo = try alloc.create(jetzig.database.Repo);
const cookies = try alloc.create(jetzig.http.Cookies);
cookies.* = jetzig.http.Cookies.init(alloc, "");
try cookies.parse();
const session = try alloc.create(jetzig.http.Session);
session.* = jetzig.http.Session.init(alloc, cookies, jetzig.testing.secret);
app.* = App{
.arena = arena,
.allocator = allocator,
@ -57,6 +66,8 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
.logger = logger,
.server = .{ .logger = logger },
.repo = repo,
.cookies = cookies,
.session = session,
};
repo.* = try jetzig.database.repo(alloc, app.*);
@ -149,30 +160,61 @@ pub fn request(
try server.decodeStaticParams();
var buf: [1024]u8 = undefined;
var httpz_request = try stubbedRequest(allocator, &buf, method, path, self.multipart_boundary, options);
var httpz_request = try stubbedRequest(
allocator,
&buf,
method,
path,
self.multipart_boundary,
options,
self.cookies,
);
var httpz_response = try stubbedResponse(allocator);
try server.processNextRequest(&httpz_request, &httpz_response);
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(self.arena.allocator());
for (0..httpz_response.headers.len) |index| {
try headers.append(.{
.name = try self.arena.allocator().dupe(u8, httpz_response.headers.keys[index]),
.value = try self.arena.allocator().dupe(u8, httpz_response.headers.values[index]),
});
{
const cookies = try allocator.create(jetzig.http.Cookies);
cookies.* = jetzig.http.Cookies.init(allocator, "");
try cookies.parse();
self.cookies = cookies;
}
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(allocator);
for (0..httpz_response.headers.len) |index| {
const key = httpz_response.headers.keys[index];
const value = httpz_response.headers.values[index];
try headers.append(.{
.name = try allocator.dupe(u8, key),
.value = try allocator.dupe(u8, httpz_response.headers.values[index]),
});
if (std.ascii.eqlIgnoreCase(key, "set-cookie")) {
// FIXME: We only expect one set-cookie header at the moment.
const cookies = try allocator.create(jetzig.http.Cookies);
cookies.* = jetzig.http.Cookies.init(allocator, value);
self.cookies = cookies;
try self.cookies.parse();
}
}
var data = jetzig.data.Data.init(allocator);
defer data.deinit();
var jobs = std.ArrayList(jetzig.testing.TestResponse.Job).init(self.arena.allocator());
var jobs = std.ArrayList(jetzig.testing.TestResponse.Job).init(allocator);
while (try self.job_queue.popFirst(&data, "__jetzig_jobs")) |value| {
if (value.getT(.string, "__jetzig_job_name")) |job_name| try jobs.append(.{
.name = try self.arena.allocator().dupe(u8, job_name),
.name = try allocator.dupe(u8, job_name),
});
}
try self.initSession();
return .{
.allocator = self.arena.allocator(),
.allocator = allocator,
.status = httpz_response.status,
.body = try self.arena.allocator().dupe(u8, httpz_response.body),
.body = try allocator.dupe(u8, httpz_response.body),
.headers = try headers.toOwnedSlice(),
.jobs = try jobs.toOwnedSlice(),
};
@ -189,6 +231,16 @@ pub fn params(self: App, args: anytype) []Param {
return array.toOwnedSlice() catch @panic("OOM");
}
pub fn initSession(self: *App) !void {
const allocator = self.arena.allocator();
var local_session = try allocator.create(jetzig.http.Session);
local_session.* = jetzig.http.Session.init(allocator, self.cookies, jetzig.testing.secret);
try local_session.parse();
self.session = local_session;
}
/// Encode an arbitrary struct to a JSON string for use as a request body.
pub fn json(self: App, args: anytype) []const u8 {
const allocator = self.arena.allocator();
@ -245,15 +297,29 @@ fn stubbedRequest(
comptime path: []const u8,
multipart_boundary: ?[]const u8,
options: RequestOptions,
maybe_cookies: ?*const jetzig.http.Cookies,
) !httpz.Request {
// TODO: Use httpz.testing
var request_headers = try keyValue(allocator, 32);
for (options.headers) |header| request_headers.add(header.name, header.value);
if (maybe_cookies) |cookies| {
var cookie_buf = std.ArrayList(u8).init(allocator);
const cookie_writer = cookie_buf.writer();
try cookie_writer.print("{}", .{cookies});
const cookie = try cookie_buf.toOwnedSlice();
request_headers.add("cookie", cookie);
}
if (options.json != null) {
request_headers.add("accept", "application/json");
request_headers.add("content-type", "application/json");
} else if (multipart_boundary) |boundary| {
const header = try std.mem.concat(allocator, u8, &.{ "multipart/form-data; boundary=", boundary });
const header = try std.mem.concat(
allocator,
u8,
&.{ "multipart/form-data; boundary=", boundary },
);
request_headers.add("content-type", header);
}
@ -358,7 +424,10 @@ fn buildOptions(allocator: std.mem.Allocator, app: *const App, args: anytype) !R
}
return .{
.headers = if (@hasField(@TypeOf(args), "headers")) try buildHeaders(allocator, args.headers) else &.{},
.headers = if (@hasField(@TypeOf(args), "headers"))
try buildHeaders(allocator, args.headers)
else
&.{},
.json = if (@hasField(@TypeOf(args), "json")) app.json(args.json) else null,
.params = if (@hasField(@TypeOf(args), "params")) app.params(args.params) else null,
.body = if (@hasField(@TypeOf(args), "body")) args.body else null,
@ -368,7 +437,12 @@ fn buildOptions(allocator: std.mem.Allocator, app: *const App, args: anytype) !R
fn buildHeaders(allocator: std.mem.Allocator, args: anytype) ![]const jetzig.testing.TestResponse.Header {
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(allocator);
inline for (std.meta.fields(@TypeOf(args))) |field| {
try headers.append(jetzig.testing.TestResponse.Header{ .name = field.name, .value = @field(args, field.name) });
try headers.append(
jetzig.testing.TestResponse.Header{
.name = field.name,
.value = @field(args, field.name),
},
);
}
return try headers.toOwnedSlice();
}

View File

@ -67,25 +67,25 @@ pub inline fn unquote(input: []const u8) []const u8 {
}
/// Generate a secure random string of `len` characters (for cryptographic purposes).
pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const u8 {
pub fn generateSecret(allocator: std.mem.Allocator, len: u10) ![]const u8 {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var secret: [len]u8 = undefined;
const secret = try allocator.alloc(u8, len);
for (0..len) |index| {
secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)];
secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len - 1)];
}
return try allocator.dupe(u8, &secret);
return secret;
}
pub fn generateRandomString(buf: []u8) []const u8 {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
for (0..buf.len) |index| {
buf[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)];
buf[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len - 1)];
}
return buf;
return buf[0..];
}
/// Calculate a duration from a given start time (in nanoseconds) to the current time.

View File

@ -56,6 +56,8 @@ json_params: []const []const u8,
params: std.ArrayList(*jetzig.data.Data) = undefined,
id: []const u8,
formats: ?Formats = null,
before_callbacks: []const jetzig.callbacks.BeforeCallback = &.{},
after_callbacks: []const jetzig.callbacks.AfterCallback = &.{},
/// Initializes a route's static params on server launch. Converts static params (JSON strings)
/// to `jetzig.data.Data` values. Memory is owned by caller (`App.start()`).