Closes #108: Anti-CSRF middleware

Add to middleware in app's `src/main.zig`:

```zig
pub const jetzig_options = struct {
    pub const middleware: []const type = &.{
        jetzig.middleware.AntiCsrfMiddleware,
    };
};
```

CSRF token available in Zmpl templates:

```
{{context.authenticityToken()}}
```
or render a hidden form element:
```
{{context.authenticityFormElement()}}
```

The following HTML requests are rejected (403 Forbidden) if the
submitted query param does not match the value stored in the encrypted
session (added automatically when the token is generated for a template
value):

* POST
* PUT
* PATCH
* DELETE

JSON requests are not impacted - users should either disable JSON
endpoints or implement a different authentication method to protect
them.
This commit is contained in:
Bob Farrell 2024-11-21 22:09:15 +00:00
parent d887cd5bd8
commit 6e6f1bec1b
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", .path = "/",
.production => "www.example.com", },
.production => .{
.same_site = true,
.secure = true,
.http_only = true,
}, },
.path = "/",
}; };
/// 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) {
.domain = "localhost", .development, .testing => .{
.path = "/", .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 = .{ 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()`).