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 - name: Run App Tests
run: | run: |
cd demo cd demo
zig build jetzig:test zig build -Denvironment=testing jetzig:test
- name: Build artifacts - name: Build artifacts
if: ${{ matrix.os == 'ubuntu-latest' }} 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")); main_tests.root_module.addImport("smtp", smtp_client_dep.module("smtp_client"));
const test_build_options = b.addOptions(); const test_build_options = b.addOptions();
test_build_options.addOption(Environment, "environment", .testing); test_build_options.addOption(Environment, "environment", .testing);
test_build_options.addOption(bool, "build_static", true);
const run_main_tests = b.addRunArtifact(main_tests); const run_main_tests = b.addRunArtifact(main_tests);
main_tests.root_module.addOptions("build_options", test_build_options); 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", "environment",
"Jetzig server environment.", "Jetzig server environment.",
) orelse .development; ) 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( const jetzig_dep = b.dependency(
"jetzig", "jetzig",
@ -164,6 +170,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
const build_options = b.addOptions(); const build_options = b.addOptions();
build_options.addOption(Environment, "environment", environment); build_options.addOption(Environment, "environment", environment);
build_options.addOption(bool, "build_static", build_static);
jetzig_module.addOptions("build_options", build_options); jetzig_module.addOptions("build_options", build_options);
exe.root_module.addImport("jetzig", jetzig_module); 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); exe_static_routes.root_module.addImport("zmpl", zmpl_module);
const markdown_fragments_write_files = b.addWriteFiles(); 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 }); const markdown_fragments_module = b.createModule(.{ .root_source_file = path });
exe_static_routes.root_module.addImport("markdown_fragments", markdown_fragments_module); exe_static_routes.root_module.addImport("markdown_fragments", markdown_fragments_module);
const run_static_routes_cmd = b.addRunArtifact(exe_static_routes); const run_static_routes_cmd = b.addRunArtifact(exe_static_routes);
const static_outputs_path = run_static_routes_cmd.addOutputFileArg("static.zig"); const static_outputs_path = run_static_routes_cmd.addOutputFileArg("static.zig");
const static_module = b.createModule(.{ .root_source_file = static_outputs_path }); const static_module = if (build_static)
exe.root_module.addImport("static", static_module); 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); run_static_routes_cmd.expectExitCode(0);
const run_tests_file_cmd = b.addRunArtifact(exe_routes_file); const run_tests_file_cmd = b.addRunArtifact(exe_routes_file);

View File

@ -7,8 +7,8 @@
.hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163", .hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163",
}, },
.zmpl = .{ .zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/25b91d030b992631d319adde1cf01baecd9f3934.tar.gz", .url = "https://github.com/jetzig-framework/zmpl/archive/af75c8b842c3957eb97b4fc4bc49c7b2243968fa.tar.gz",
.hash = "12208dd5a4bf0c6c7efc4e9f37a5d8ed80d6004d5680176d1fc2114bfa593e927baf", .hash = "1220ecac93d295dafd2f034a86f0979f6108d40e5ea1a39e3a2b9977c35147cac684",
}, },
.jetkv = .{ .jetkv = .{
.url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz", .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 /// Middleware chain. Add any custom middleware here, or use middleware provided in
/// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`). /// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`).
pub const middleware: []const type = &.{ pub const middleware: []const type = &.{
// jetzig.middleware.AuthMiddleware,
// jetzig.middleware.AntiCsrfMiddleware,
// jetzig.middleware.HtmxMiddleware, // jetzig.middleware.HtmxMiddleware,
// jetzig.middleware.CompressionMiddleware, // jetzig.middleware.CompressionMiddleware,
// @import("app/middleware/DemoMiddleware.zig"), // @import("app/middleware/DemoMiddleware.zig"),
@ -79,13 +81,16 @@ pub const jetzig_options = struct {
pub const Schema = @import("Schema"); pub const Schema = @import("Schema");
/// HTTP cookie configuration /// HTTP cookie configuration
pub const cookies: jetzig.http.Cookies.CookieOptions = .{ pub const cookies: jetzig.http.Cookies.CookieOptions = switch (jetzig.environment) {
.domain = switch (jetzig.environment) { .development, .testing => .{
.development => "localhost", .domain = "localhost",
.testing => "localhost",
.production => "www.example.com",
},
.path = "/", .path = "/",
},
.production => .{
.same_site = true,
.secure = true,
.http_only = true,
},
}; };
/// Key-value store options. Set backend to `.file` to use a file-based store. /// 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}, \\ .static = {4s},
\\ .uri_path = "{5s}", \\ .uri_path = "{5s}",
\\ .template = "{6s}", \\ .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, \\ .layout = if (@hasDecl(@import("{7s}"), "layout")) @import("{7s}").layout else null,
\\ .json_params = &[_][]const u8 {{ {8s} }}, \\ .json_params = &[_][]const u8 {{ {8s} }},
\\ .formats = if (@hasDecl(@import("{7s}"), "formats")) @import("{7s}").formats else null, \\ .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| { for (capture.args, 0..) |arg, arg_index| {
if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) { 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; capture.legacy = arg_index + 1 < capture.args.len;
try static_routes.append(capture.*); try static_routes.append(capture.*);
} else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) { } else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) {

View File

@ -13,7 +13,7 @@ pub fn main() !void {
log("Jetzig Routes:", .{}); 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; 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); 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{ var jetzig_app = jetzig.App{
.environment = environment, .env = environment,
.allocator = allocator, .allocator = allocator,
.custom_routes = std.ArrayList(jetzig.views.Route).init(allocator), .custom_routes = std.ArrayList(jetzig.views.Route).init(allocator),
.initHook = initHook, .initHook = initHook,

View File

@ -147,7 +147,7 @@ fn renderMarkdown(
if (zmpl.findPrefixed("views", prefixed_name)) |layout| { if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
view.data.content = .{ .data = content }; view.data.content = .{ .data = content };
return try layout.render(view.data); return try layout.render(view.data, jetzig.TemplateContext, .{}, .{});
} else { } else {
std.debug.print("Unknown layout: {s}\n", .{layout_name}); std.debug.print("Unknown layout: {s}\n", .{layout_name});
return content; return content;
@ -170,13 +170,18 @@ fn renderZmplTemplate(
defer allocator.free(prefixed_name); defer allocator.free(prefixed_name);
if (zmpl.findPrefixed("views", prefixed_name)) |layout| { 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 { } else {
std.debug.print("Unknown layout: {s}\n", .{layout_name}); std.debug.print("Unknown layout: {s}\n", .{layout_name});
return try allocator.dupe(u8, ""); return try allocator.dupe(u8, "");
} }
} else { } else {
return try template.render(view.data); return try template.render(view.data, jetzig.TemplateContext, .{}, .{});
} }
} else return null; } 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 testing = @import("jetzig/testing.zig");
pub const config = @import("jetzig/config.zig"); pub const config = @import("jetzig/config.zig");
pub const auth = @import("jetzig/auth.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 DateTime = jetcommon.types.DateTime;
pub const Time = jetcommon.types.Time; pub const Time = jetcommon.types.Time;
pub const Date = jetcommon.types.Date; 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 build_options = @import("build_options");
pub const environment = std.enums.nameCast(Environment.EnvironmentName, build_options.environment); pub const environment = std.enums.nameCast(Environment.EnvironmentName, build_options.environment);
@ -46,6 +50,9 @@ pub const Request = http.Request;
/// requests. /// requests.
pub const StaticRequest = http.StaticRequest; 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`, /// Generic, JSON-compatible data type. Provides `Value` which in turn provides `Object`,
/// `Array`, `String`, `Integer`, `Float`, `Boolean`, and `NullType`. /// `Array`, `String`, `Integer`, `Float`, `Boolean`, and `NullType`.
pub const Data = data.Data; pub const Data = data.Data;
@ -78,7 +85,8 @@ pub const Logger = loggers.Logger;
pub const root = @import("root"); pub const root = @import("root");
pub const Global = if (@hasDecl(root, "Global")) root.Global else DefaultGlobal; 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; 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(); @constCast(self).custom_routes.deinit();
} }
// Not used yet, but allows us to add new options to `start()` without breaking /// Specify a global value accessible as `request.server.global`.
// backward-compatibility. /// Must specify type by defining `pub const Global` in your app's `src/main.zig`.
const AppOptions = struct { const AppOptions = struct {
global: *anyopaque = undefined, global: *anyopaque = undefined,
}; };
@ -228,6 +228,8 @@ pub fn createRoutes(
.template = const_route.template, .template = const_route.template,
.json_params = const_route.json_params, .json_params = const_route.json_params,
.formats = const_route.formats, .formats = const_route.formats,
.before_callbacks = const_route.before_callbacks,
.after_callbacks = const_route.after_callbacks,
}; };
try var_route.initParams(allocator); 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 mail = @import("mail.zig");
pub const kv = @import("kv.zig"); pub const kv = @import("kv.zig");
pub const db = @import("database.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"); const root = @import("root");
@ -149,11 +152,26 @@ pub const smtp: mail.SMTPConfig = .{
}; };
/// HTTP cookie configuration /// HTTP cookie configuration
pub const cookies: http.Cookies.CookieOptions = .{ pub const cookies: http.Cookies.CookieOptions = switch (environment) {
.development, .testing => .{
.domain = "localhost", .domain = "localhost",
.path = "/", .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 = .{ pub const auth: @import("auth.zig").AuthOptions = .{
.user_model = "User", .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 std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
pub const build_options = @import("build_options");
pub const Server = @import("http/Server.zig"); pub const Server = @import("http/Server.zig");
pub const Request = @import("http/Request.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 Response = @import("http/Response.zig");
pub const Session = @import("http/Session.zig"); pub const Session = @import("http/Session.zig");
pub const Cookies = @import("http/Cookies.zig"); pub const Cookies = @import("http/Cookies.zig");

View File

@ -8,18 +8,18 @@ cookies: std.StringArrayHashMap(*Cookie),
modified: bool = false, modified: bool = false,
arena: std.heap.ArenaAllocator, arena: std.heap.ArenaAllocator,
const Self = @This(); const Cookies = @This();
const SameSite = enum { strict, lax, none }; const SameSite = enum { strict, lax, none };
pub const CookieOptions = struct { pub const CookieOptions = struct {
domain: []const u8 = "localhost", domain: ?[]const u8 = "localhost",
path: []const u8 = "/", path: []const u8 = "/",
same_site: ?SameSite = null,
secure: bool = false, secure: bool = false,
expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp()
http_only: bool = false, http_only: bool = false,
max_age: ?i64 = null,
partitioned: bool = false, 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"); const cookie_options = jetzig.config.get(CookieOptions, "cookies");
@ -27,43 +27,50 @@ const cookie_options = jetzig.config.get(CookieOptions, "cookies");
pub const Cookie = struct { pub const Cookie = struct {
name: []const u8, name: []const u8,
value: []const u8, value: []const u8,
domain: ?[]const u8 = null, secure: bool = cookie_options.secure,
path: ?[]const u8 = null, http_only: bool = cookie_options.http_only,
same_site: ?SameSite = null, partitioned: bool = cookie_options.partitioned,
secure: ?bool = null, domain: ?[]const u8 = cookie_options.domain,
expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp() path: ?[]const u8 = cookie_options.path,
http_only: ?bool = null, same_site: ?SameSite = cookie_options.same_site,
max_age: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp()
partitioned: ?bool = null, expires: ?i64 = cookie_options.expires,
max_age: ?i64 = cookie_options.max_age,
/// Build a cookie string. /// Build a cookie string.
pub fn bufPrint(self: Cookie, buf: *[4096]u8) ![]const u8 { 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); var stream = std.io.fixedBufferStream(buf);
const writer = stream.writer(); 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.name,
self.value, self.value,
options.path, self.path orelse "/",
options.domain,
}); });
if (options.same_site) |same_site| try writer.print(" SameSite={s};", .{@tagName(same_site)}); if (self.domain) |domain| try writer.print(" domain={s};", .{domain});
if (options.secure or require_secure) try writer.writeAll(" Secure;"); if (self.same_site) |same_site| try writer.print(
if (options.expires) |expires| try writer.print(" Expires={d};", .{std.time.timestamp() + expires}); " SameSite={s};",
if (options.max_age) |max_age| try writer.print(" Max-Age={d};", .{max_age}); .{@tagName(same_site)},
if (options.http_only) try writer.writeAll(" HttpOnly;"); );
if (options.partitioned) try writer.writeAll(" Partitioned;"); if (self.secure or require_secure) try writer.writeAll(" Secure;");
if (self.expires) |expires| {
return stream.getWritten(); 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 { 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 .{ return .{
.allocator = allocator, .allocator = allocator,
.cookie_string = cookie_string, .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(); var it = self.cookies.iterator();
while (it.next()) |item| { while (it.next()) |item| {
self.allocator.free(item.key_ptr.*); self.allocator.free(item.key_ptr.*);
@ -100,11 +107,11 @@ pub fn deinit(self: *Self) void {
self.arena.deinit(); 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); return self.cookies.get(key);
} }
pub fn put(self: *Self, cookie: Cookie) !void { pub fn put(self: *Cookies, cookie: Cookie) !void {
self.modified = true; self.modified = true;
if (self.cookies.fetchSwapRemove(cookie.name)) |entry| { if (self.cookies.fetchSwapRemove(cookie.name)) |entry| {
@ -125,8 +132,12 @@ pub const HeaderIterator = struct {
cookies_iterator: std.StringArrayHashMap(*Cookie).Iterator, cookies_iterator: std.StringArrayHashMap(*Cookie).Iterator,
buf: *[4096]u8, buf: *[4096]u8,
pub fn init(allocator: std.mem.Allocator, cookies: *Self, buf: *[4096]u8) HeaderIterator { pub fn init(allocator: std.mem.Allocator, cookies: *Cookies, buf: *[4096]u8) HeaderIterator {
return .{ .allocator = allocator, .cookies_iterator = cookies.cookies.iterator(), .buf = buf }; return .{
.allocator = allocator,
.cookies_iterator = cookies.cookies.iterator(),
.buf = buf,
};
} }
pub fn next(self: *HeaderIterator) !?[]const u8 { 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); return HeaderIterator.init(self.allocator, self, buf);
} }
// https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1 // https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1
// cookie-header = "Cookie:" OWS cookie-string OWS // cookie-header = "Cookie:" OWS cookie-string OWS
// cookie-string = cookie-pair *( ";" SP cookie-pair ) // 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 key_buf = std.ArrayList(u8).init(self.allocator);
var value_buf = std.ArrayList(u8).init(self.allocator); var value_buf = std.ArrayList(u8).init(self.allocator);
var key_terminated = false; var key_terminated = false;
@ -202,6 +213,13 @@ pub fn parse(self: *Self) !void {
for (cookie_buf.items) |cookie| try self.put(cookie); 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) { const Flag = union(enum) {
domain: []const u8, domain: []const u8,
path: []const u8, path: []const u8,
@ -250,7 +268,7 @@ fn parseFlag(key: []const u8, value: []const u8) ?Flag {
test "basic cookie string" { test "basic cookie string" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value); try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value);
@ -259,14 +277,14 @@ test "basic cookie string" {
test "empty cookie string" { test "empty cookie string" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var cookies = Self.init(allocator, ""); var cookies = Cookies.init(allocator, "");
defer cookies.deinit(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
} }
test "cookie string with irregular spaces" { test "cookie string with irregular spaces" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value); try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value);
@ -280,7 +298,7 @@ test "headerIterator" {
const writer = buf.writer(); 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -300,7 +318,7 @@ test "headerIterator" {
test "modified" { test "modified" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -312,7 +330,7 @@ test "modified" {
test "domain=example.com" { test "domain=example.com" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -322,7 +340,7 @@ test "domain=example.com" {
test "path=/example_path" { test "path=/example_path" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -332,7 +350,7 @@ test "path=/example_path" {
test "SameSite=lax" { test "SameSite=lax" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -342,7 +360,7 @@ test "SameSite=lax" {
test "SameSite=none" { test "SameSite=none" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -352,7 +370,7 @@ test "SameSite=none" {
test "SameSite=strict" { test "SameSite=strict" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -362,27 +380,27 @@ test "SameSite=strict" {
test "Secure" { test "Secure" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
const cookie = cookies.get("foo").?; const cookie = cookies.get("foo").?;
try std.testing.expect(cookie.secure.?); try std.testing.expect(cookie.secure);
} }
test "Partitioned" { test "Partitioned" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
const cookie = cookies.get("foo").?; const cookie = cookies.get("foo").?;
try std.testing.expect(cookie.partitioned.?); try std.testing.expect(cookie.partitioned);
} }
test "Max-Age" { test "Max-Age" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -392,7 +410,7 @@ test "Max-Age" {
test "Expires" { test "Expires" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
@ -402,17 +420,17 @@ test "Expires" {
test "default flags" { test "default flags" {
const allocator = std.testing.allocator; 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(); defer cookies.deinit();
try cookies.parse(); try cookies.parse();
const cookie = cookies.get("foo").?; const cookie = cookies.get("foo").?;
try std.testing.expect(cookie.domain == null); try std.testing.expect(cookie.secure == false);
try std.testing.expect(cookie.path == null); 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.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.expires == null);
try std.testing.expect(cookie.http_only == null);
try std.testing.expect(cookie.max_age == 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); var headers = std.ArrayList([]const u8).init(self.allocator);
for (self.httpz_headers.keys, 0..) |key, index| { for (self.httpz_headers.keys, 0..) |key, index| {
var buf: [max_bytes_header_name]u8 = undefined; if (std.ascii.eqlIgnoreCase(name, key)) {
const lower = std.ascii.lowerString(&buf, name); headers.append(self.httpz_headers.values[index]) catch @panic("OOM");
}
if (std.mem.eql(u8, lower, key)) headers.append(self.httpz_headers.values[index]) catch @panic("OOM");
} }
return headers.toOwnedSlice() 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 Method = enum { DELETE, GET, PATCH, POST, HEAD, PUT, CONNECT, OPTIONS, TRACE };
pub const Modifier = enum { edit, new }; pub const Modifier = enum { edit, new };
pub const Format = enum { HTML, JSON, UNKNOWN }; pub const Format = enum { HTML, JSON, UNKNOWN };
pub const Protocol = enum { http, https };
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
path: jetzig.http.Path, path: jetzig.http.Path,
@ -34,6 +35,8 @@ response_started: bool = false,
dynamic_assigned_template: ?[]const u8 = null, dynamic_assigned_template: ?[]const u8 = null,
layout: ?[]const u8 = null, layout: ?[]const u8 = null,
layout_disabled: bool = false, layout_disabled: bool = false,
// TODO: Squash rendered/redirected/failed into
// `state: enum { initial, rendered, redirected, failed }`
rendered: bool = false, rendered: bool = false,
redirected: bool = false, redirected: bool = false,
failed: bool = false, failed: bool = false,
@ -249,7 +252,10 @@ pub fn middleware(
unreachable; 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 { pub fn renderRedirect(self: *Request, state: RedirectState) !void {
self.response_data.reset(); self.response_data.reset();
@ -275,7 +281,12 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void {
.HTML, .UNKNOWN => if (maybe_template) |template| blk: { .HTML, .UNKNOWN => if (maybe_template) |template| blk: {
try view.data.addConst("jetzig_view", view.data.string("internal")); try view.data.addConst("jetzig_view", view.data.string("internal"));
try view.data.addConst("jetzig_action", view.data.string(@tagName(state.status_code))); 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}), } else try std.fmt.allocPrint(self.allocator, "Redirecting to {s}", .{state.location}),
.JSON => blk: { .JSON => blk: {
break :blk try std.json.stringifyAlloc( break :blk try std.json.stringifyAlloc(
@ -486,6 +497,31 @@ pub fn session(self: *Request) !*jetzig.http.Session {
return local_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` /// 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.put(...)` to set job params.
/// Call `Job.background()` to run the job outside of the request/response flow. /// 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, job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition, mailer_definitions: []const jetzig.MailerDefinition,
mime_map: *jetzig.http.mime.MimeMap, mime_map: *jetzig.http.mime.MimeMap,
std_net_server: std.net.Server = undefined,
initialized: bool = false, initialized: bool = false,
store: *jetzig.kv.Store, store: *jetzig.kv.Store,
job_queue: *jetzig.kv.Store, job_queue: *jetzig.kv.Store,
@ -57,7 +56,6 @@ pub fn init(
} }
pub fn deinit(self: *Server) void { 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.secret);
self.allocator.free(self.env.bind); 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. } 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 (maybe_route) |route| {
if (!capture.validateFormat(request)) { if (!route.validateFormat(request)) {
return request.setResponse(try self.renderNotFound(request), .{}); return request.setResponse(try self.renderNotFound(request), .{});
} }
} }
switch (request.requestFormat()) { if (maybe_route) |route| {
.HTML => try self.renderHTML(request, route), for (route.before_callbacks) |callback| {
.JSON => try self.renderJSON(request, route), try callback(request, route);
.UNKNOWN => try self.renderHTML(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 { fn renderStatic(resource: StaticResource, request: *jetzig.http.Request) !void {
@ -355,6 +377,8 @@ fn renderTemplateWithLayout(
) ![]const u8 { ) ![]const u8 {
try addTemplateConstants(view, route); try addTemplateConstants(view, route);
const template_context = jetzig.TemplateContext{ .request = request };
if (request.getLayout(route)) |layout_name| { if (request.getLayout(route)) |layout_name| {
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/ // TODO: Allow user to configure layouts directory other than src/app/views/layouts/
const prefixed_name = try std.mem.concat( const prefixed_name = try std.mem.concat(
@ -365,23 +389,37 @@ fn renderTemplateWithLayout(
defer self.allocator.free(prefixed_name); defer self.allocator.free(prefixed_name);
if (zmpl.findPrefixed("views", prefixed_name)) |layout| { 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 { } else {
try self.logger.WARN("Unknown layout: {s}", .{layout_name}); 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 { 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) { const action = switch (route.action) {
.custom => route.name, .custom => route.name,
else => |tag| @tagName(tag), else => |tag| @tagName(tag),
}; };
try view.data.addConst("jetzig_action", view.data.string(action)); 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 { fn isBadRequest(err: anyerror) bool {
@ -481,7 +519,15 @@ fn renderErrorView(
.HTML, .UNKNOWN => { .HTML, .UNKNOWN => {
if (zmpl.findPrefixed("views", route.template)) |template| { if (zmpl.findPrefixed("views", route.template)) |template| {
try addTemplateConstants(view, route.*); 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() }, .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 { fn matchRoute(self: *Server, request: *jetzig.http.Request, static: bool) !?jetzig.views.Route {
for (self.routes) |route| { for (self.routes) |route| {
// .index routes always take precedence. // .index routes always take precedence.
if (route.static == static and route.action == .index and try request.match(route.*)) { if (route.action == .index and try request.match(route.*)) {
return route.*; if (!jetzig.build_options.build_static) return route.*;
if (route.static == static) return route.*;
} }
} }
for (self.routes) |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; 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 { fn matchStaticResource(self: *Server, request: *jetzig.http.Request) !?StaticResource {
// TODO: Map public and static routes at launch to avoid accessing the file system when // 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 { 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. // 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); 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_view", data.string(""));
try data.addConst("jetzig_action", data.string("")); try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template| return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template|
try template.render(&data) try template.render(&data, jetzig.TemplateContext, .{}, .{})
else else
null; null;
} }
@ -152,7 +152,7 @@ fn defaultText(
try data.addConst("jetzig_view", data.string("")); try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string("")); try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template| return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template|
try template.render(&data) try template.render(&data, jetzig.TemplateContext, .{}, .{})
else else
null; null;
} }

View File

@ -4,6 +4,7 @@ const jetzig = @import("../jetzig.zig");
pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig"); pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig");
pub const CompressionMiddleware = @import("middleware/CompressionMiddleware.zig"); pub const CompressionMiddleware = @import("middleware/CompressionMiddleware.zig");
pub const AuthMiddleware = @import("middleware/AuthMiddleware.zig"); pub const AuthMiddleware = @import("middleware/AuthMiddleware.zig");
pub const AntiCsrfMiddleware = @import("middleware/AntiCsrfMiddleware.zig");
const RouteOptions = struct { const RouteOptions = struct {
content: ?[]const u8 = null, 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. /// they can also be modified.
user: ?@TypeOf(jetzig.database.Query(user_model).find(0)).ResultType, user: ?@TypeOf(jetzig.database.Query(user_model).find(0)).ResultType,
const Self = @This(); const AuthMiddleware = @This();
/// Initialize middleware. /// Initialize middleware.
pub fn init(request: *jetzig.http.Request) !*Self { pub fn init(request: *jetzig.http.Request) !*AuthMiddleware {
const middleware = try request.allocator.create(Self); const middleware = try request.allocator.create(AuthMiddleware);
middleware.* = .{ .user = null }; middleware.* = .{ .user = null };
return middleware; return middleware;
} }
@ -31,8 +31,11 @@ const map = std.StaticStringMap(void).initComptime(.{
/// ///
/// User ID is accessible from a request: /// User ID is accessible from a request:
/// ```zig /// ```zig
/// /// if (request.middleware(.auth).user) |user| {
pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { /// try request.server.log(.DEBUG, "{}", .{user.id});
/// }
/// ```
pub fn afterRequest(self: *AuthMiddleware, request: *jetzig.http.Request) !void {
if (request.path.extension) |extension| { if (request.path.extension) |extension| {
if (map.get(extension) == null) return; 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. /// 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 /// Note that `request.allocator` is an arena allocator, so any allocations are automatically
/// done before the next request starts processing. /// 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); request.allocator.destroy(self);
} }

View File

@ -15,6 +15,8 @@ multipart_boundary: ?[]const u8 = null,
logger: jetzig.loggers.Logger, logger: jetzig.loggers.Logger,
server: Server, server: Server,
repo: *jetzig.database.Repo, repo: *jetzig.database.Repo,
cookies: *jetzig.http.Cookies,
session: *jetzig.http.Session,
const Server = struct { logger: jetzig.loggers.Logger }; 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 app = try alloc.create(App);
const repo = try alloc.create(jetzig.database.Repo); 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{ app.* = App{
.arena = arena, .arena = arena,
.allocator = allocator, .allocator = allocator,
@ -57,6 +66,8 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
.logger = logger, .logger = logger,
.server = .{ .logger = logger }, .server = .{ .logger = logger },
.repo = repo, .repo = repo,
.cookies = cookies,
.session = session,
}; };
repo.* = try jetzig.database.repo(alloc, app.*); repo.* = try jetzig.database.repo(alloc, app.*);
@ -149,30 +160,61 @@ pub fn request(
try server.decodeStaticParams(); try server.decodeStaticParams();
var buf: [1024]u8 = undefined; 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); var httpz_response = try stubbedResponse(allocator);
try server.processNextRequest(&httpz_request, &httpz_response); 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(.{ const cookies = try allocator.create(jetzig.http.Cookies);
.name = try self.arena.allocator().dupe(u8, httpz_response.headers.keys[index]), cookies.* = jetzig.http.Cookies.init(allocator, "");
.value = try self.arena.allocator().dupe(u8, httpz_response.headers.values[index]), 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); var data = jetzig.data.Data.init(allocator);
defer data.deinit(); 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| { while (try self.job_queue.popFirst(&data, "__jetzig_jobs")) |value| {
if (value.getT(.string, "__jetzig_job_name")) |job_name| try jobs.append(.{ 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 .{ return .{
.allocator = self.arena.allocator(), .allocator = allocator,
.status = httpz_response.status, .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(), .headers = try headers.toOwnedSlice(),
.jobs = try jobs.toOwnedSlice(), .jobs = try jobs.toOwnedSlice(),
}; };
@ -189,6 +231,16 @@ pub fn params(self: App, args: anytype) []Param {
return array.toOwnedSlice() catch @panic("OOM"); 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. /// Encode an arbitrary struct to a JSON string for use as a request body.
pub fn json(self: App, args: anytype) []const u8 { pub fn json(self: App, args: anytype) []const u8 {
const allocator = self.arena.allocator(); const allocator = self.arena.allocator();
@ -245,15 +297,29 @@ fn stubbedRequest(
comptime path: []const u8, comptime path: []const u8,
multipart_boundary: ?[]const u8, multipart_boundary: ?[]const u8,
options: RequestOptions, options: RequestOptions,
maybe_cookies: ?*const jetzig.http.Cookies,
) !httpz.Request { ) !httpz.Request {
// TODO: Use httpz.testing // TODO: Use httpz.testing
var request_headers = try keyValue(allocator, 32); var request_headers = try keyValue(allocator, 32);
for (options.headers) |header| request_headers.add(header.name, header.value); 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) { if (options.json != null) {
request_headers.add("accept", "application/json"); request_headers.add("accept", "application/json");
request_headers.add("content-type", "application/json"); request_headers.add("content-type", "application/json");
} else if (multipart_boundary) |boundary| { } 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); request_headers.add("content-type", header);
} }
@ -358,7 +424,10 @@ fn buildOptions(allocator: std.mem.Allocator, app: *const App, args: anytype) !R
} }
return .{ 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, .json = if (@hasField(@TypeOf(args), "json")) app.json(args.json) else null,
.params = if (@hasField(@TypeOf(args), "params")) app.params(args.params) else null, .params = if (@hasField(@TypeOf(args), "params")) app.params(args.params) else null,
.body = if (@hasField(@TypeOf(args), "body")) args.body 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 { fn buildHeaders(allocator: std.mem.Allocator, args: anytype) ![]const jetzig.testing.TestResponse.Header {
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(allocator); var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(allocator);
inline for (std.meta.fields(@TypeOf(args))) |field| { 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(); 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). /// 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"; const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var secret: [len]u8 = undefined; const secret = try allocator.alloc(u8, len);
for (0..len) |index| { 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 { pub fn generateRandomString(buf: []u8) []const u8 {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
for (0..buf.len) |index| { 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. /// 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, params: std.ArrayList(*jetzig.data.Data) = undefined,
id: []const u8, id: []const u8,
formats: ?Formats = null, 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) /// 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()`). /// to `jetzig.data.Data` values. Memory is owned by caller (`App.start()`).