mirror of
https://github.com/jetzig-framework/jetzig.git
synced 2025-07-01 05:26:07 +00:00
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:
parent
d887cd5bd8
commit
6e6f1bec1b
2
.github/workflows/CI.yml
vendored
2
.github/workflows/CI.yml
vendored
@ -45,7 +45,7 @@ jobs:
|
||||
- name: Run App Tests
|
||||
run: |
|
||||
cd demo
|
||||
zig build jetzig:test
|
||||
zig build -Denvironment=testing jetzig:test
|
||||
|
||||
- name: Build artifacts
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
|
21
build.zig
21
build.zig
@ -109,6 +109,7 @@ pub fn build(b: *std.Build) !void {
|
||||
main_tests.root_module.addImport("smtp", smtp_client_dep.module("smtp_client"));
|
||||
const test_build_options = b.addOptions();
|
||||
test_build_options.addOption(Environment, "environment", .testing);
|
||||
test_build_options.addOption(bool, "build_static", true);
|
||||
const run_main_tests = b.addRunArtifact(main_tests);
|
||||
main_tests.root_module.addOptions("build_options", test_build_options);
|
||||
|
||||
@ -137,6 +138,11 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
|
||||
"environment",
|
||||
"Jetzig server environment.",
|
||||
) orelse .development;
|
||||
const build_static = b.option(
|
||||
bool,
|
||||
"build_static",
|
||||
"Pre-render static routes. [default: false in development, true in testing/production]",
|
||||
) orelse (environment != .development);
|
||||
|
||||
const jetzig_dep = b.dependency(
|
||||
"jetzig",
|
||||
@ -164,6 +170,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
|
||||
|
||||
const build_options = b.addOptions();
|
||||
build_options.addOption(Environment, "environment", environment);
|
||||
build_options.addOption(bool, "build_static", build_static);
|
||||
jetzig_module.addOptions("build_options", build_options);
|
||||
|
||||
exe.root_module.addImport("jetzig", jetzig_module);
|
||||
@ -253,15 +260,23 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
|
||||
exe_static_routes.root_module.addImport("zmpl", zmpl_module);
|
||||
|
||||
const markdown_fragments_write_files = b.addWriteFiles();
|
||||
const path = markdown_fragments_write_files.add("markdown_fragments.zig", try generateMarkdownFragments(b));
|
||||
const path = markdown_fragments_write_files.add(
|
||||
"markdown_fragments.zig",
|
||||
try generateMarkdownFragments(b),
|
||||
);
|
||||
const markdown_fragments_module = b.createModule(.{ .root_source_file = path });
|
||||
exe_static_routes.root_module.addImport("markdown_fragments", markdown_fragments_module);
|
||||
|
||||
const run_static_routes_cmd = b.addRunArtifact(exe_static_routes);
|
||||
const static_outputs_path = run_static_routes_cmd.addOutputFileArg("static.zig");
|
||||
const static_module = b.createModule(.{ .root_source_file = static_outputs_path });
|
||||
exe.root_module.addImport("static", static_module);
|
||||
const static_module = if (build_static)
|
||||
b.createModule(.{ .root_source_file = static_outputs_path })
|
||||
else
|
||||
b.createModule(.{
|
||||
.root_source_file = jetzig_dep.builder.path("src/jetzig/development_static.zig"),
|
||||
});
|
||||
|
||||
exe.root_module.addImport("static", static_module);
|
||||
run_static_routes_cmd.expectExitCode(0);
|
||||
|
||||
const run_tests_file_cmd = b.addRunArtifact(exe_routes_file);
|
||||
|
@ -7,8 +7,8 @@
|
||||
.hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163",
|
||||
},
|
||||
.zmpl = .{
|
||||
.url = "https://github.com/jetzig-framework/zmpl/archive/25b91d030b992631d319adde1cf01baecd9f3934.tar.gz",
|
||||
.hash = "12208dd5a4bf0c6c7efc4e9f37a5d8ed80d6004d5680176d1fc2114bfa593e927baf",
|
||||
.url = "https://github.com/jetzig-framework/zmpl/archive/af75c8b842c3957eb97b4fc4bc49c7b2243968fa.tar.gz",
|
||||
.hash = "1220ecac93d295dafd2f034a86f0979f6108d40e5ea1a39e3a2b9977c35147cac684",
|
||||
},
|
||||
.jetkv = .{
|
||||
.url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz",
|
||||
|
77
demo/src/app/views/anti_csrf.zig
Normal file
77
demo/src/app/views/anti_csrf.zig
Normal 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);
|
||||
}
|
8
demo/src/app/views/anti_csrf/index.zmpl
Normal file
8
demo/src/app/views/anti_csrf/index.zmpl
Normal 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>
|
5
demo/src/app/views/anti_csrf/post.zmpl
Normal file
5
demo/src/app/views/anti_csrf/post.zmpl
Normal file
@ -0,0 +1,5 @@
|
||||
<h1>Spam Submitted Successfully</h1>
|
||||
|
||||
<h2>Spam:</h2>
|
||||
|
||||
<div>{{$.spam}}</div>
|
@ -12,6 +12,8 @@ pub const jetzig_options = struct {
|
||||
/// Middleware chain. Add any custom middleware here, or use middleware provided in
|
||||
/// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`).
|
||||
pub const middleware: []const type = &.{
|
||||
// jetzig.middleware.AuthMiddleware,
|
||||
// jetzig.middleware.AntiCsrfMiddleware,
|
||||
// jetzig.middleware.HtmxMiddleware,
|
||||
// jetzig.middleware.CompressionMiddleware,
|
||||
// @import("app/middleware/DemoMiddleware.zig"),
|
||||
@ -79,13 +81,16 @@ pub const jetzig_options = struct {
|
||||
pub const Schema = @import("Schema");
|
||||
|
||||
/// HTTP cookie configuration
|
||||
pub const cookies: jetzig.http.Cookies.CookieOptions = .{
|
||||
.domain = switch (jetzig.environment) {
|
||||
.development => "localhost",
|
||||
.testing => "localhost",
|
||||
.production => "www.example.com",
|
||||
},
|
||||
pub const cookies: jetzig.http.Cookies.CookieOptions = switch (jetzig.environment) {
|
||||
.development, .testing => .{
|
||||
.domain = "localhost",
|
||||
.path = "/",
|
||||
},
|
||||
.production => .{
|
||||
.same_site = true,
|
||||
.secure = true,
|
||||
.http_only = true,
|
||||
},
|
||||
};
|
||||
|
||||
/// Key-value store options. Set backend to `.file` to use a file-based store.
|
||||
|
@ -280,6 +280,8 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
|
||||
\\ .static = {4s},
|
||||
\\ .uri_path = "{5s}",
|
||||
\\ .template = "{6s}",
|
||||
\\ .before_callbacks = jetzig.callbacks.beforeCallbacks(@import("{7s}")),
|
||||
\\ .after_callbacks = jetzig.callbacks.afterCallbacks(@import("{7s}")),
|
||||
\\ .layout = if (@hasDecl(@import("{7s}"), "layout")) @import("{7s}").layout else null,
|
||||
\\ .json_params = &[_][]const u8 {{ {8s} }},
|
||||
\\ .formats = if (@hasDecl(@import("{7s}"), "formats")) @import("{7s}").formats else null,
|
||||
@ -389,7 +391,7 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
|
||||
|
||||
for (capture.args, 0..) |arg, arg_index| {
|
||||
if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) {
|
||||
capture.static = true;
|
||||
capture.static = jetzig.build_options.build_static;
|
||||
capture.legacy = arg_index + 1 < capture.args.len;
|
||||
try static_routes.append(capture.*);
|
||||
} else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) {
|
||||
|
@ -13,7 +13,7 @@ pub fn main() !void {
|
||||
|
||||
log("Jetzig Routes:", .{});
|
||||
|
||||
const environment = jetzig.Environment.init(allocator, .{ .silent = true });
|
||||
const environment = try jetzig.Environment.init(allocator, .{ .silent = true });
|
||||
const initHook: ?*const fn (*jetzig.App) anyerror!void = if (@hasDecl(app, "init")) app.init else null;
|
||||
|
||||
inline for (routes.routes) |route| max_uri_path_len = @max(route.uri_path.len + 5, max_uri_path_len);
|
||||
@ -44,7 +44,7 @@ pub fn main() !void {
|
||||
}
|
||||
|
||||
var jetzig_app = jetzig.App{
|
||||
.environment = environment,
|
||||
.env = environment,
|
||||
.allocator = allocator,
|
||||
.custom_routes = std.ArrayList(jetzig.views.Route).init(allocator),
|
||||
.initHook = initHook,
|
||||
|
@ -147,7 +147,7 @@ fn renderMarkdown(
|
||||
|
||||
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
|
||||
view.data.content = .{ .data = content };
|
||||
return try layout.render(view.data);
|
||||
return try layout.render(view.data, jetzig.TemplateContext, .{}, .{});
|
||||
} else {
|
||||
std.debug.print("Unknown layout: {s}\n", .{layout_name});
|
||||
return content;
|
||||
@ -170,13 +170,18 @@ fn renderZmplTemplate(
|
||||
defer allocator.free(prefixed_name);
|
||||
|
||||
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
|
||||
return try template.renderWithOptions(view.data, .{ .layout = layout });
|
||||
return try template.render(
|
||||
view.data,
|
||||
jetzig.TemplateContext,
|
||||
.{},
|
||||
.{ .layout = layout },
|
||||
);
|
||||
} else {
|
||||
std.debug.print("Unknown layout: {s}\n", .{layout_name});
|
||||
return try allocator.dupe(u8, "");
|
||||
}
|
||||
} else {
|
||||
return try template.render(view.data);
|
||||
return try template.render(view.data, jetzig.TemplateContext, .{}, .{});
|
||||
}
|
||||
} else return null;
|
||||
}
|
||||
|
@ -22,11 +22,15 @@ pub const database = @import("jetzig/database.zig");
|
||||
pub const testing = @import("jetzig/testing.zig");
|
||||
pub const config = @import("jetzig/config.zig");
|
||||
pub const auth = @import("jetzig/auth.zig");
|
||||
pub const callbacks = @import("jetzig/callbacks.zig");
|
||||
pub const TemplateContext = @import("jetzig/TemplateContext.zig");
|
||||
|
||||
pub const DateTime = jetcommon.types.DateTime;
|
||||
pub const Time = jetcommon.types.Time;
|
||||
pub const Date = jetcommon.types.Date;
|
||||
|
||||
pub const authenticity_token_name = config.get([]const u8, "authenticity_token_name");
|
||||
|
||||
pub const build_options = @import("build_options");
|
||||
pub const environment = std.enums.nameCast(Environment.EnvironmentName, build_options.environment);
|
||||
|
||||
@ -46,6 +50,9 @@ pub const Request = http.Request;
|
||||
/// requests.
|
||||
pub const StaticRequest = http.StaticRequest;
|
||||
|
||||
/// An HTTP response generated during request processing.
|
||||
pub const Response = http.Response;
|
||||
|
||||
/// Generic, JSON-compatible data type. Provides `Value` which in turn provides `Object`,
|
||||
/// `Array`, `String`, `Integer`, `Float`, `Boolean`, and `NullType`.
|
||||
pub const Data = data.Data;
|
||||
@ -78,7 +85,8 @@ pub const Logger = loggers.Logger;
|
||||
|
||||
pub const root = @import("root");
|
||||
pub const Global = if (@hasDecl(root, "Global")) root.Global else DefaultGlobal;
|
||||
pub const DefaultGlobal = struct { __jetzig_default: bool };
|
||||
pub const DefaultGlobal = struct { comptime __jetzig_default: bool = true };
|
||||
pub const default_global = DefaultGlobal{};
|
||||
|
||||
pub const initHook: ?*const fn (*App) anyerror!void = if (@hasDecl(root, "init")) root.init else null;
|
||||
|
||||
|
@ -17,8 +17,8 @@ pub fn deinit(self: *const App) void {
|
||||
@constCast(self).custom_routes.deinit();
|
||||
}
|
||||
|
||||
// Not used yet, but allows us to add new options to `start()` without breaking
|
||||
// backward-compatibility.
|
||||
/// Specify a global value accessible as `request.server.global`.
|
||||
/// Must specify type by defining `pub const Global` in your app's `src/main.zig`.
|
||||
const AppOptions = struct {
|
||||
global: *anyopaque = undefined,
|
||||
};
|
||||
@ -228,6 +228,8 @@ pub fn createRoutes(
|
||||
.template = const_route.template,
|
||||
.json_params = const_route.json_params,
|
||||
.formats = const_route.formats,
|
||||
.before_callbacks = const_route.before_callbacks,
|
||||
.after_callbacks = const_route.after_callbacks,
|
||||
};
|
||||
|
||||
try var_route.initParams(allocator);
|
||||
|
25
src/jetzig/TemplateContext.zig
Normal file
25
src/jetzig/TemplateContext.zig
Normal 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
112
src/jetzig/callbacks.zig
Normal 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;
|
||||
}
|
||||
}
|
@ -17,6 +17,9 @@ pub const jobs = @import("jobs.zig");
|
||||
pub const mail = @import("mail.zig");
|
||||
pub const kv = @import("kv.zig");
|
||||
pub const db = @import("database.zig");
|
||||
pub const Environment = @import("Environment.zig");
|
||||
pub const environment = std.enums.nameCast(Environment.EnvironmentName, build_options.environment);
|
||||
pub const build_options = @import("build_options");
|
||||
|
||||
const root = @import("root");
|
||||
|
||||
@ -149,11 +152,26 @@ pub const smtp: mail.SMTPConfig = .{
|
||||
};
|
||||
|
||||
/// HTTP cookie configuration
|
||||
pub const cookies: http.Cookies.CookieOptions = .{
|
||||
pub const cookies: http.Cookies.CookieOptions = switch (environment) {
|
||||
.development, .testing => .{
|
||||
.domain = "localhost",
|
||||
.path = "/",
|
||||
},
|
||||
.production => .{
|
||||
.secure = true,
|
||||
.http_only = true,
|
||||
.same_site = true,
|
||||
.path = "/",
|
||||
},
|
||||
};
|
||||
|
||||
/// Override the default anti-CSRF authenticity token name that is stored in the encrypted
|
||||
/// session. This value is also used by `context.authenticityFormElement()` to render an HTML
|
||||
/// element: the element's `name` attribute is set to this value.
|
||||
pub const authenticity_token_name: []const u8 = "_jetzig_authenticity_token";
|
||||
|
||||
/// When using `AuthMiddleware`, set this value to override the default JetQuery model name that
|
||||
/// maps the users table.
|
||||
pub const auth: @import("auth.zig").AuthOptions = .{
|
||||
.user_model = "User",
|
||||
};
|
||||
|
12
src/jetzig/development_static.zig
Normal file
12
src/jetzig/development_static.zig
Normal 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,
|
||||
};
|
@ -1,9 +1,14 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
pub const build_options = @import("build_options");
|
||||
|
||||
pub const Server = @import("http/Server.zig");
|
||||
pub const Request = @import("http/Request.zig");
|
||||
pub const StaticRequest = @import("http/StaticRequest.zig");
|
||||
pub const StaticRequest = if (build_options.environment == .development)
|
||||
Request
|
||||
else
|
||||
@import("http/StaticRequest.zig");
|
||||
pub const Response = @import("http/Response.zig");
|
||||
pub const Session = @import("http/Session.zig");
|
||||
pub const Cookies = @import("http/Cookies.zig");
|
||||
|
@ -8,18 +8,18 @@ cookies: std.StringArrayHashMap(*Cookie),
|
||||
modified: bool = false,
|
||||
arena: std.heap.ArenaAllocator,
|
||||
|
||||
const Self = @This();
|
||||
const Cookies = @This();
|
||||
|
||||
const SameSite = enum { strict, lax, none };
|
||||
pub const CookieOptions = struct {
|
||||
domain: []const u8 = "localhost",
|
||||
domain: ?[]const u8 = "localhost",
|
||||
path: []const u8 = "/",
|
||||
same_site: ?SameSite = null,
|
||||
secure: bool = false,
|
||||
expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp()
|
||||
http_only: bool = false,
|
||||
max_age: ?i64 = null,
|
||||
partitioned: bool = false,
|
||||
same_site: ?SameSite = null,
|
||||
expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp()
|
||||
max_age: ?i64 = null,
|
||||
};
|
||||
|
||||
const cookie_options = jetzig.config.get(CookieOptions, "cookies");
|
||||
@ -27,43 +27,50 @@ const cookie_options = jetzig.config.get(CookieOptions, "cookies");
|
||||
pub const Cookie = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
domain: ?[]const u8 = null,
|
||||
path: ?[]const u8 = null,
|
||||
same_site: ?SameSite = null,
|
||||
secure: ?bool = null,
|
||||
expires: ?i64 = null, // if used, set to time in seconds to be added to std.time.timestamp()
|
||||
http_only: ?bool = null,
|
||||
max_age: ?i64 = null,
|
||||
partitioned: ?bool = null,
|
||||
secure: bool = cookie_options.secure,
|
||||
http_only: bool = cookie_options.http_only,
|
||||
partitioned: bool = cookie_options.partitioned,
|
||||
domain: ?[]const u8 = cookie_options.domain,
|
||||
path: ?[]const u8 = cookie_options.path,
|
||||
same_site: ?SameSite = cookie_options.same_site,
|
||||
// if used, set to time in seconds to be added to std.time.timestamp()
|
||||
expires: ?i64 = cookie_options.expires,
|
||||
max_age: ?i64 = cookie_options.max_age,
|
||||
|
||||
/// Build a cookie string.
|
||||
pub fn bufPrint(self: Cookie, buf: *[4096]u8) ![]const u8 {
|
||||
var options = cookie_options;
|
||||
inline for (std.meta.fields(CookieOptions)) |field| {
|
||||
@field(options, field.name) = @field(self, field.name) orelse @field(cookie_options, field.name);
|
||||
}
|
||||
|
||||
// secure is required if samesite is set to none
|
||||
const require_secure = if (options.same_site) |same_site| same_site == .none else false;
|
||||
|
||||
var stream = std.io.fixedBufferStream(buf);
|
||||
const writer = stream.writer();
|
||||
try writer.print("{}", .{self});
|
||||
return stream.getWritten();
|
||||
}
|
||||
|
||||
try writer.print("{s}={s}; path={s}; domain={s};", .{
|
||||
/// Build a cookie string.
|
||||
pub fn format(self: Cookie, _: anytype, _: anytype, writer: anytype) !void {
|
||||
// secure is required if samesite is set to none
|
||||
const require_secure = if (self.same_site) |same_site| same_site == .none else false;
|
||||
|
||||
try writer.print("{s}={s}; path={s};", .{
|
||||
self.name,
|
||||
self.value,
|
||||
options.path,
|
||||
options.domain,
|
||||
self.path orelse "/",
|
||||
});
|
||||
|
||||
if (options.same_site) |same_site| try writer.print(" SameSite={s};", .{@tagName(same_site)});
|
||||
if (options.secure or require_secure) try writer.writeAll(" Secure;");
|
||||
if (options.expires) |expires| try writer.print(" Expires={d};", .{std.time.timestamp() + expires});
|
||||
if (options.max_age) |max_age| try writer.print(" Max-Age={d};", .{max_age});
|
||||
if (options.http_only) try writer.writeAll(" HttpOnly;");
|
||||
if (options.partitioned) try writer.writeAll(" Partitioned;");
|
||||
|
||||
return stream.getWritten();
|
||||
if (self.domain) |domain| try writer.print(" domain={s};", .{domain});
|
||||
if (self.same_site) |same_site| try writer.print(
|
||||
" SameSite={s};",
|
||||
.{@tagName(same_site)},
|
||||
);
|
||||
if (self.secure or require_secure) try writer.writeAll(" Secure;");
|
||||
if (self.expires) |expires| {
|
||||
const seconds = std.time.timestamp() + expires;
|
||||
const timestamp = try jetzig.jetcommon.DateTime.fromUnix(seconds, .seconds);
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#expiresdate
|
||||
try timestamp.strftime(writer, " Expires=%a, %d %h %Y %H:%M:%S GMT;");
|
||||
}
|
||||
if (self.max_age) |max_age| try writer.print(" Max-Age={d};", .{max_age});
|
||||
if (self.http_only) try writer.writeAll(" HttpOnly;");
|
||||
if (self.partitioned) try writer.writeAll(" Partitioned;");
|
||||
}
|
||||
|
||||
pub fn applyFlag(self: *Cookie, allocator: std.mem.Allocator, flag: Flag) !void {
|
||||
@ -80,7 +87,7 @@ pub const Cookie = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, cookie_string: []const u8) Self {
|
||||
pub fn init(allocator: std.mem.Allocator, cookie_string: []const u8) Cookies {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.cookie_string = cookie_string,
|
||||
@ -89,7 +96,7 @@ pub fn init(allocator: std.mem.Allocator, cookie_string: []const u8) Self {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
pub fn deinit(self: *Cookies) void {
|
||||
var it = self.cookies.iterator();
|
||||
while (it.next()) |item| {
|
||||
self.allocator.free(item.key_ptr.*);
|
||||
@ -100,11 +107,11 @@ pub fn deinit(self: *Self) void {
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
pub fn get(self: *Self, key: []const u8) ?*Cookie {
|
||||
pub fn get(self: *Cookies, key: []const u8) ?*Cookie {
|
||||
return self.cookies.get(key);
|
||||
}
|
||||
|
||||
pub fn put(self: *Self, cookie: Cookie) !void {
|
||||
pub fn put(self: *Cookies, cookie: Cookie) !void {
|
||||
self.modified = true;
|
||||
|
||||
if (self.cookies.fetchSwapRemove(cookie.name)) |entry| {
|
||||
@ -125,8 +132,12 @@ pub const HeaderIterator = struct {
|
||||
cookies_iterator: std.StringArrayHashMap(*Cookie).Iterator,
|
||||
buf: *[4096]u8,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, cookies: *Self, buf: *[4096]u8) HeaderIterator {
|
||||
return .{ .allocator = allocator, .cookies_iterator = cookies.cookies.iterator(), .buf = buf };
|
||||
pub fn init(allocator: std.mem.Allocator, cookies: *Cookies, buf: *[4096]u8) HeaderIterator {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.cookies_iterator = cookies.cookies.iterator(),
|
||||
.buf = buf,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn next(self: *HeaderIterator) !?[]const u8 {
|
||||
@ -139,14 +150,14 @@ pub const HeaderIterator = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub fn headerIterator(self: *Self, buf: *[4096]u8) HeaderIterator {
|
||||
pub fn headerIterator(self: *Cookies, buf: *[4096]u8) HeaderIterator {
|
||||
return HeaderIterator.init(self.allocator, self, buf);
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1
|
||||
// cookie-header = "Cookie:" OWS cookie-string OWS
|
||||
// cookie-string = cookie-pair *( ";" SP cookie-pair )
|
||||
pub fn parse(self: *Self) !void {
|
||||
pub fn parse(self: *Cookies) !void {
|
||||
var key_buf = std.ArrayList(u8).init(self.allocator);
|
||||
var value_buf = std.ArrayList(u8).init(self.allocator);
|
||||
var key_terminated = false;
|
||||
@ -202,6 +213,13 @@ pub fn parse(self: *Self) !void {
|
||||
for (cookie_buf.items) |cookie| try self.put(cookie);
|
||||
}
|
||||
|
||||
pub fn format(self: Cookies, _: anytype, _: anytype, writer: anytype) !void {
|
||||
var it = self.cookies.iterator();
|
||||
while (it.next()) |entry| {
|
||||
try writer.print("{}; ", .{entry.value_ptr.*});
|
||||
}
|
||||
}
|
||||
|
||||
const Flag = union(enum) {
|
||||
domain: []const u8,
|
||||
path: []const u8,
|
||||
@ -250,7 +268,7 @@ fn parseFlag(key: []const u8, value: []const u8) ?Flag {
|
||||
|
||||
test "basic cookie string" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux;");
|
||||
defer cookies.deinit();
|
||||
try cookies.parse();
|
||||
try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value);
|
||||
@ -259,14 +277,14 @@ test "basic cookie string" {
|
||||
|
||||
test "empty cookie string" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "");
|
||||
var cookies = Cookies.init(allocator, "");
|
||||
defer cookies.deinit();
|
||||
try cookies.parse();
|
||||
}
|
||||
|
||||
test "cookie string with irregular spaces" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo= bar; baz= qux;");
|
||||
var cookies = Cookies.init(allocator, "foo= bar; baz= qux;");
|
||||
defer cookies.deinit();
|
||||
try cookies.parse();
|
||||
try std.testing.expectEqualStrings("bar", cookies.get("foo").?.value);
|
||||
@ -280,7 +298,7 @@ test "headerIterator" {
|
||||
|
||||
const writer = buf.writer();
|
||||
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux;");
|
||||
defer cookies.deinit();
|
||||
try cookies.parse();
|
||||
|
||||
@ -300,7 +318,7 @@ test "headerIterator" {
|
||||
|
||||
test "modified" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
@ -312,7 +330,7 @@ test "modified" {
|
||||
|
||||
test "domain=example.com" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; Domain=example.com;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Domain=example.com;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
@ -322,7 +340,7 @@ test "domain=example.com" {
|
||||
|
||||
test "path=/example_path" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; Path=/example_path;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Path=/example_path;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
@ -332,7 +350,7 @@ test "path=/example_path" {
|
||||
|
||||
test "SameSite=lax" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=lax;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; SameSite=lax;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
@ -342,7 +360,7 @@ test "SameSite=lax" {
|
||||
|
||||
test "SameSite=none" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=none;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; SameSite=none;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
@ -352,7 +370,7 @@ test "SameSite=none" {
|
||||
|
||||
test "SameSite=strict" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; SameSite=strict;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; SameSite=strict;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
@ -362,27 +380,27 @@ test "SameSite=strict" {
|
||||
|
||||
test "Secure" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; Secure;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Secure;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
const cookie = cookies.get("foo").?;
|
||||
try std.testing.expect(cookie.secure.?);
|
||||
try std.testing.expect(cookie.secure);
|
||||
}
|
||||
|
||||
test "Partitioned" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; Partitioned;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Partitioned;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
const cookie = cookies.get("foo").?;
|
||||
try std.testing.expect(cookie.partitioned.?);
|
||||
try std.testing.expect(cookie.partitioned);
|
||||
}
|
||||
|
||||
test "Max-Age" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; Max-Age=123123123;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Max-Age=123123123;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
@ -392,7 +410,7 @@ test "Max-Age" {
|
||||
|
||||
test "Expires" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux; Expires=123123123;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux; Expires=123123123;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
@ -402,17 +420,17 @@ test "Expires" {
|
||||
|
||||
test "default flags" {
|
||||
const allocator = std.testing.allocator;
|
||||
var cookies = Self.init(allocator, "foo=bar; baz=qux;");
|
||||
var cookies = Cookies.init(allocator, "foo=bar; baz=qux;");
|
||||
defer cookies.deinit();
|
||||
|
||||
try cookies.parse();
|
||||
const cookie = cookies.get("foo").?;
|
||||
try std.testing.expect(cookie.domain == null);
|
||||
try std.testing.expect(cookie.path == null);
|
||||
try std.testing.expect(cookie.secure == false);
|
||||
try std.testing.expect(cookie.partitioned == false);
|
||||
try std.testing.expect(cookie.http_only == false);
|
||||
try std.testing.expect(cookie.same_site == null);
|
||||
try std.testing.expect(cookie.secure == null);
|
||||
try std.testing.expectEqualStrings(cookie.domain.?, "localhost");
|
||||
try std.testing.expectEqualStrings(cookie.path.?, "/");
|
||||
try std.testing.expect(cookie.expires == null);
|
||||
try std.testing.expect(cookie.http_only == null);
|
||||
try std.testing.expect(cookie.max_age == null);
|
||||
try std.testing.expect(cookie.partitioned == null);
|
||||
}
|
||||
|
@ -46,10 +46,9 @@ pub fn getAll(self: Headers, name: []const u8) []const []const u8 {
|
||||
var headers = std.ArrayList([]const u8).init(self.allocator);
|
||||
|
||||
for (self.httpz_headers.keys, 0..) |key, index| {
|
||||
var buf: [max_bytes_header_name]u8 = undefined;
|
||||
const lower = std.ascii.lowerString(&buf, name);
|
||||
|
||||
if (std.mem.eql(u8, lower, key)) headers.append(self.httpz_headers.values[index]) catch @panic("OOM");
|
||||
if (std.ascii.eqlIgnoreCase(name, key)) {
|
||||
headers.append(self.httpz_headers.values[index]) catch @panic("OOM");
|
||||
}
|
||||
}
|
||||
return headers.toOwnedSlice() catch @panic("OOM");
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ const default_content_type = "text/html";
|
||||
pub const Method = enum { DELETE, GET, PATCH, POST, HEAD, PUT, CONNECT, OPTIONS, TRACE };
|
||||
pub const Modifier = enum { edit, new };
|
||||
pub const Format = enum { HTML, JSON, UNKNOWN };
|
||||
pub const Protocol = enum { http, https };
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
path: jetzig.http.Path,
|
||||
@ -34,6 +35,8 @@ response_started: bool = false,
|
||||
dynamic_assigned_template: ?[]const u8 = null,
|
||||
layout: ?[]const u8 = null,
|
||||
layout_disabled: bool = false,
|
||||
// TODO: Squash rendered/redirected/failed into
|
||||
// `state: enum { initial, rendered, redirected, failed }`
|
||||
rendered: bool = false,
|
||||
redirected: bool = false,
|
||||
failed: bool = false,
|
||||
@ -249,7 +252,10 @@ pub fn middleware(
|
||||
unreachable;
|
||||
}
|
||||
|
||||
const RedirectState = struct { location: []const u8, status_code: jetzig.http.status_codes.StatusCode };
|
||||
const RedirectState = struct {
|
||||
location: []const u8,
|
||||
status_code: jetzig.http.status_codes.StatusCode,
|
||||
};
|
||||
|
||||
pub fn renderRedirect(self: *Request, state: RedirectState) !void {
|
||||
self.response_data.reset();
|
||||
@ -275,7 +281,12 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void {
|
||||
.HTML, .UNKNOWN => if (maybe_template) |template| blk: {
|
||||
try view.data.addConst("jetzig_view", view.data.string("internal"));
|
||||
try view.data.addConst("jetzig_action", view.data.string(@tagName(state.status_code)));
|
||||
break :blk try template.render(self.response_data);
|
||||
break :blk try template.render(
|
||||
self.response_data,
|
||||
jetzig.TemplateContext,
|
||||
.{ .request = self },
|
||||
.{},
|
||||
);
|
||||
} else try std.fmt.allocPrint(self.allocator, "Redirecting to {s}", .{state.location}),
|
||||
.JSON => blk: {
|
||||
break :blk try std.json.stringifyAlloc(
|
||||
@ -486,6 +497,31 @@ pub fn session(self: *Request) !*jetzig.http.Session {
|
||||
return local_session;
|
||||
}
|
||||
|
||||
/// Return the anti-CSRF token cookie value. If no cookie exist, create it.
|
||||
pub fn authenticityToken(self: *Request) ![]const u8 {
|
||||
var local_session = try self.session();
|
||||
|
||||
return local_session.getT(.string, jetzig.authenticity_token_name) orelse blk: {
|
||||
const token = try jetzig.util.generateSecret(self.allocator, 32);
|
||||
try local_session.put(jetzig.authenticity_token_name, token);
|
||||
break :blk local_session.getT(.string, jetzig.authenticity_token_name).?;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn resourceId(self: *const Request) ![]const u8 {
|
||||
return self.path.resource_id;
|
||||
}
|
||||
|
||||
/// Return the protocol used to serve the current request by detecting the `X-Forwarded-Proto`
|
||||
/// header.
|
||||
pub fn protocol(self: *const Request) Protocol {
|
||||
return if (self.headers.get("x-forwarded-proto")) |x_forwarded_proto|
|
||||
if (std.ascii.eqlIgnoreCase(x_forwarded_proto, "https")) .https else .http
|
||||
else
|
||||
// TODO: Extend login when we support serving HTTPS directly.
|
||||
.http;
|
||||
}
|
||||
|
||||
/// Create a new Job. Receives a job name which must resolve to `src/app/jobs/<name>.zig`
|
||||
/// Call `Job.put(...)` to set job params.
|
||||
/// Call `Job.background()` to run the job outside of the request/response flow.
|
||||
|
@ -14,7 +14,6 @@ custom_routes: []jetzig.views.Route,
|
||||
job_definitions: []const jetzig.JobDefinition,
|
||||
mailer_definitions: []const jetzig.MailerDefinition,
|
||||
mime_map: *jetzig.http.mime.MimeMap,
|
||||
std_net_server: std.net.Server = undefined,
|
||||
initialized: bool = false,
|
||||
store: *jetzig.kv.Store,
|
||||
job_queue: *jetzig.kv.Store,
|
||||
@ -57,7 +56,6 @@ pub fn init(
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Server) void {
|
||||
if (self.initialized) self.std_net_server.deinit();
|
||||
self.allocator.free(self.env.secret);
|
||||
self.allocator.free(self.env.bind);
|
||||
}
|
||||
@ -187,21 +185,45 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void {
|
||||
} else unreachable; // In future a MiddlewareRoute might provide a render function etc.
|
||||
}
|
||||
|
||||
const route = self.matchCustomRoute(request) orelse try self.matchRoute(request, false);
|
||||
const maybe_route = self.matchCustomRoute(request) orelse try self.matchRoute(request, false);
|
||||
|
||||
if (route) |capture| {
|
||||
if (!capture.validateFormat(request)) {
|
||||
if (maybe_route) |route| {
|
||||
if (!route.validateFormat(request)) {
|
||||
return request.setResponse(try self.renderNotFound(request), .{});
|
||||
}
|
||||
}
|
||||
|
||||
switch (request.requestFormat()) {
|
||||
.HTML => try self.renderHTML(request, route),
|
||||
.JSON => try self.renderJSON(request, route),
|
||||
.UNKNOWN => try self.renderHTML(request, route),
|
||||
if (maybe_route) |route| {
|
||||
for (route.before_callbacks) |callback| {
|
||||
try callback(request, route);
|
||||
if (request.rendered_view) |view| {
|
||||
if (request.failed) {
|
||||
request.setResponse(try self.renderError(request, view.status_code), .{});
|
||||
} else if (request.rendered) {
|
||||
// TODO: Allow callbacks to set content
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.redirect_state) |state| {
|
||||
try request.renderRedirect(state);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.redirect_state) |state| return try request.renderRedirect(state);
|
||||
switch (request.requestFormat()) {
|
||||
.HTML => try self.renderHTML(request, maybe_route),
|
||||
.JSON => try self.renderJSON(request, maybe_route),
|
||||
.UNKNOWN => try self.renderHTML(request, maybe_route),
|
||||
}
|
||||
|
||||
if (maybe_route) |route| {
|
||||
for (route.after_callbacks) |callback| {
|
||||
try callback(request, request.response, route);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.redirect_state) |state| try request.renderRedirect(state);
|
||||
}
|
||||
|
||||
fn renderStatic(resource: StaticResource, request: *jetzig.http.Request) !void {
|
||||
@ -355,6 +377,8 @@ fn renderTemplateWithLayout(
|
||||
) ![]const u8 {
|
||||
try addTemplateConstants(view, route);
|
||||
|
||||
const template_context = jetzig.TemplateContext{ .request = request };
|
||||
|
||||
if (request.getLayout(route)) |layout_name| {
|
||||
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/
|
||||
const prefixed_name = try std.mem.concat(
|
||||
@ -365,23 +389,37 @@ fn renderTemplateWithLayout(
|
||||
defer self.allocator.free(prefixed_name);
|
||||
|
||||
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
|
||||
return try template.renderWithOptions(view.data, .{ .layout = layout });
|
||||
return try template.render(
|
||||
view.data,
|
||||
jetzig.TemplateContext,
|
||||
template_context,
|
||||
.{ .layout = layout },
|
||||
);
|
||||
} else {
|
||||
try self.logger.WARN("Unknown layout: {s}", .{layout_name});
|
||||
return try template.render(view.data);
|
||||
return try template.render(
|
||||
view.data,
|
||||
jetzig.TemplateContext,
|
||||
template_context,
|
||||
.{},
|
||||
);
|
||||
}
|
||||
} else return try template.render(view.data);
|
||||
} else return try template.render(
|
||||
view.data,
|
||||
jetzig.TemplateContext,
|
||||
template_context,
|
||||
.{},
|
||||
);
|
||||
}
|
||||
|
||||
fn addTemplateConstants(view: jetzig.views.View, route: jetzig.views.Route) !void {
|
||||
try view.data.addConst("jetzig_view", view.data.string(route.view_name));
|
||||
|
||||
const action = switch (route.action) {
|
||||
.custom => route.name,
|
||||
else => |tag| @tagName(tag),
|
||||
};
|
||||
|
||||
try view.data.addConst("jetzig_action", view.data.string(action));
|
||||
try view.data.addConst("jetzig_view", view.data.string(route.view_name));
|
||||
}
|
||||
|
||||
fn isBadRequest(err: anyerror) bool {
|
||||
@ -481,7 +519,15 @@ fn renderErrorView(
|
||||
.HTML, .UNKNOWN => {
|
||||
if (zmpl.findPrefixed("views", route.template)) |template| {
|
||||
try addTemplateConstants(view, route.*);
|
||||
return .{ .view = view, .content = try template.render(request.response_data) };
|
||||
return .{
|
||||
.view = view,
|
||||
.content = try template.render(
|
||||
request.response_data,
|
||||
jetzig.TemplateContext,
|
||||
.{ .request = request },
|
||||
.{},
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
.JSON => return .{ .view = view, .content = try request.response_data.toJson() },
|
||||
@ -573,19 +619,26 @@ fn matchMiddlewareRoute(request: *const jetzig.http.Request) ?jetzig.middleware.
|
||||
fn matchRoute(self: *Server, request: *jetzig.http.Request, static: bool) !?jetzig.views.Route {
|
||||
for (self.routes) |route| {
|
||||
// .index routes always take precedence.
|
||||
if (route.static == static and route.action == .index and try request.match(route.*)) {
|
||||
return route.*;
|
||||
if (route.action == .index and try request.match(route.*)) {
|
||||
if (!jetzig.build_options.build_static) return route.*;
|
||||
if (route.static == static) return route.*;
|
||||
}
|
||||
}
|
||||
|
||||
for (self.routes) |route| {
|
||||
if (route.static == static and try request.match(route.*)) return route.*;
|
||||
if (try request.match(route.*)) {
|
||||
if (!jetzig.build_options.build_static) return route.*;
|
||||
if (route.static == static) return route.*;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const StaticResource = struct { content: []const u8, mime_type: []const u8 = "application/octet-stream" };
|
||||
const StaticResource = struct {
|
||||
content: []const u8,
|
||||
mime_type: []const u8 = "application/octet-stream",
|
||||
};
|
||||
|
||||
fn matchStaticResource(self: *Server, request: *jetzig.http.Request) !?StaticResource {
|
||||
// TODO: Map public and static routes at launch to avoid accessing the file system when
|
||||
@ -682,7 +735,7 @@ fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8
|
||||
}
|
||||
|
||||
pub fn decodeStaticParams(self: *Server) !void {
|
||||
if (!@hasDecl(jetzig.root, "static")) return;
|
||||
if (comptime !@hasDecl(jetzig.root, "static")) return;
|
||||
|
||||
// Store decoded static params (i.e. declared in views) for faster comparison at request time.
|
||||
var decoded = std.ArrayList(*jetzig.data.Value).init(self.allocator);
|
||||
|
@ -134,7 +134,7 @@ fn defaultHtml(
|
||||
try data.addConst("jetzig_view", data.string(""));
|
||||
try data.addConst("jetzig_action", data.string(""));
|
||||
return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template|
|
||||
try template.render(&data)
|
||||
try template.render(&data, jetzig.TemplateContext, .{}, .{})
|
||||
else
|
||||
null;
|
||||
}
|
||||
@ -152,7 +152,7 @@ fn defaultText(
|
||||
try data.addConst("jetzig_view", data.string(""));
|
||||
try data.addConst("jetzig_action", data.string(""));
|
||||
return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template|
|
||||
try template.render(&data)
|
||||
try template.render(&data, jetzig.TemplateContext, .{}, .{})
|
||||
else
|
||||
null;
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ const jetzig = @import("../jetzig.zig");
|
||||
pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig");
|
||||
pub const CompressionMiddleware = @import("middleware/CompressionMiddleware.zig");
|
||||
pub const AuthMiddleware = @import("middleware/AuthMiddleware.zig");
|
||||
pub const AntiCsrfMiddleware = @import("middleware/AntiCsrfMiddleware.zig");
|
||||
|
||||
const RouteOptions = struct {
|
||||
content: ?[]const u8 = null,
|
||||
|
73
src/jetzig/middleware/AntiCsrfMiddleware.zig
Normal file
73
src/jetzig/middleware/AntiCsrfMiddleware.zig
Normal 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);
|
||||
}
|
||||
}
|
@ -11,11 +11,11 @@ const user_model = jetzig.config.get(jetzig.auth.AuthOptions, "auth").user_model
|
||||
/// they can also be modified.
|
||||
user: ?@TypeOf(jetzig.database.Query(user_model).find(0)).ResultType,
|
||||
|
||||
const Self = @This();
|
||||
const AuthMiddleware = @This();
|
||||
|
||||
/// Initialize middleware.
|
||||
pub fn init(request: *jetzig.http.Request) !*Self {
|
||||
const middleware = try request.allocator.create(Self);
|
||||
pub fn init(request: *jetzig.http.Request) !*AuthMiddleware {
|
||||
const middleware = try request.allocator.create(AuthMiddleware);
|
||||
middleware.* = .{ .user = null };
|
||||
return middleware;
|
||||
}
|
||||
@ -31,8 +31,11 @@ const map = std.StaticStringMap(void).initComptime(.{
|
||||
///
|
||||
/// User ID is accessible from a request:
|
||||
/// ```zig
|
||||
///
|
||||
pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void {
|
||||
/// if (request.middleware(.auth).user) |user| {
|
||||
/// try request.server.log(.DEBUG, "{}", .{user.id});
|
||||
/// }
|
||||
/// ```
|
||||
pub fn afterRequest(self: *AuthMiddleware, request: *jetzig.http.Request) !void {
|
||||
if (request.path.extension) |extension| {
|
||||
if (map.get(extension) == null) return;
|
||||
}
|
||||
@ -47,6 +50,6 @@ pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void {
|
||||
/// Invoked after `afterRequest` is called, use this function to do any clean-up.
|
||||
/// Note that `request.allocator` is an arena allocator, so any allocations are automatically
|
||||
/// done before the next request starts processing.
|
||||
pub fn deinit(self: *Self, request: *jetzig.http.Request) void {
|
||||
pub fn deinit(self: *AuthMiddleware, request: *jetzig.http.Request) void {
|
||||
request.allocator.destroy(self);
|
||||
}
|
||||
|
@ -15,6 +15,8 @@ multipart_boundary: ?[]const u8 = null,
|
||||
logger: jetzig.loggers.Logger,
|
||||
server: Server,
|
||||
repo: *jetzig.database.Repo,
|
||||
cookies: *jetzig.http.Cookies,
|
||||
session: *jetzig.http.Session,
|
||||
|
||||
const Server = struct { logger: jetzig.loggers.Logger };
|
||||
|
||||
@ -47,6 +49,13 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
|
||||
const app = try alloc.create(App);
|
||||
const repo = try alloc.create(jetzig.database.Repo);
|
||||
|
||||
const cookies = try alloc.create(jetzig.http.Cookies);
|
||||
cookies.* = jetzig.http.Cookies.init(alloc, "");
|
||||
try cookies.parse();
|
||||
|
||||
const session = try alloc.create(jetzig.http.Session);
|
||||
session.* = jetzig.http.Session.init(alloc, cookies, jetzig.testing.secret);
|
||||
|
||||
app.* = App{
|
||||
.arena = arena,
|
||||
.allocator = allocator,
|
||||
@ -57,6 +66,8 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
|
||||
.logger = logger,
|
||||
.server = .{ .logger = logger },
|
||||
.repo = repo,
|
||||
.cookies = cookies,
|
||||
.session = session,
|
||||
};
|
||||
|
||||
repo.* = try jetzig.database.repo(alloc, app.*);
|
||||
@ -149,30 +160,61 @@ pub fn request(
|
||||
try server.decodeStaticParams();
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
var httpz_request = try stubbedRequest(allocator, &buf, method, path, self.multipart_boundary, options);
|
||||
var httpz_request = try stubbedRequest(
|
||||
allocator,
|
||||
&buf,
|
||||
method,
|
||||
path,
|
||||
self.multipart_boundary,
|
||||
options,
|
||||
self.cookies,
|
||||
);
|
||||
var httpz_response = try stubbedResponse(allocator);
|
||||
|
||||
try server.processNextRequest(&httpz_request, &httpz_response);
|
||||
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(self.arena.allocator());
|
||||
for (0..httpz_response.headers.len) |index| {
|
||||
try headers.append(.{
|
||||
.name = try self.arena.allocator().dupe(u8, httpz_response.headers.keys[index]),
|
||||
.value = try self.arena.allocator().dupe(u8, httpz_response.headers.values[index]),
|
||||
});
|
||||
|
||||
{
|
||||
const cookies = try allocator.create(jetzig.http.Cookies);
|
||||
cookies.* = jetzig.http.Cookies.init(allocator, "");
|
||||
try cookies.parse();
|
||||
self.cookies = cookies;
|
||||
}
|
||||
|
||||
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(allocator);
|
||||
for (0..httpz_response.headers.len) |index| {
|
||||
const key = httpz_response.headers.keys[index];
|
||||
const value = httpz_response.headers.values[index];
|
||||
|
||||
try headers.append(.{
|
||||
.name = try allocator.dupe(u8, key),
|
||||
.value = try allocator.dupe(u8, httpz_response.headers.values[index]),
|
||||
});
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(key, "set-cookie")) {
|
||||
// FIXME: We only expect one set-cookie header at the moment.
|
||||
const cookies = try allocator.create(jetzig.http.Cookies);
|
||||
cookies.* = jetzig.http.Cookies.init(allocator, value);
|
||||
self.cookies = cookies;
|
||||
try self.cookies.parse();
|
||||
}
|
||||
}
|
||||
|
||||
var data = jetzig.data.Data.init(allocator);
|
||||
defer data.deinit();
|
||||
|
||||
var jobs = std.ArrayList(jetzig.testing.TestResponse.Job).init(self.arena.allocator());
|
||||
var jobs = std.ArrayList(jetzig.testing.TestResponse.Job).init(allocator);
|
||||
while (try self.job_queue.popFirst(&data, "__jetzig_jobs")) |value| {
|
||||
if (value.getT(.string, "__jetzig_job_name")) |job_name| try jobs.append(.{
|
||||
.name = try self.arena.allocator().dupe(u8, job_name),
|
||||
.name = try allocator.dupe(u8, job_name),
|
||||
});
|
||||
}
|
||||
|
||||
try self.initSession();
|
||||
|
||||
return .{
|
||||
.allocator = self.arena.allocator(),
|
||||
.allocator = allocator,
|
||||
.status = httpz_response.status,
|
||||
.body = try self.arena.allocator().dupe(u8, httpz_response.body),
|
||||
.body = try allocator.dupe(u8, httpz_response.body),
|
||||
.headers = try headers.toOwnedSlice(),
|
||||
.jobs = try jobs.toOwnedSlice(),
|
||||
};
|
||||
@ -189,6 +231,16 @@ pub fn params(self: App, args: anytype) []Param {
|
||||
return array.toOwnedSlice() catch @panic("OOM");
|
||||
}
|
||||
|
||||
pub fn initSession(self: *App) !void {
|
||||
const allocator = self.arena.allocator();
|
||||
|
||||
var local_session = try allocator.create(jetzig.http.Session);
|
||||
local_session.* = jetzig.http.Session.init(allocator, self.cookies, jetzig.testing.secret);
|
||||
try local_session.parse();
|
||||
|
||||
self.session = local_session;
|
||||
}
|
||||
|
||||
/// Encode an arbitrary struct to a JSON string for use as a request body.
|
||||
pub fn json(self: App, args: anytype) []const u8 {
|
||||
const allocator = self.arena.allocator();
|
||||
@ -245,15 +297,29 @@ fn stubbedRequest(
|
||||
comptime path: []const u8,
|
||||
multipart_boundary: ?[]const u8,
|
||||
options: RequestOptions,
|
||||
maybe_cookies: ?*const jetzig.http.Cookies,
|
||||
) !httpz.Request {
|
||||
// TODO: Use httpz.testing
|
||||
var request_headers = try keyValue(allocator, 32);
|
||||
for (options.headers) |header| request_headers.add(header.name, header.value);
|
||||
|
||||
if (maybe_cookies) |cookies| {
|
||||
var cookie_buf = std.ArrayList(u8).init(allocator);
|
||||
const cookie_writer = cookie_buf.writer();
|
||||
try cookie_writer.print("{}", .{cookies});
|
||||
const cookie = try cookie_buf.toOwnedSlice();
|
||||
request_headers.add("cookie", cookie);
|
||||
}
|
||||
|
||||
if (options.json != null) {
|
||||
request_headers.add("accept", "application/json");
|
||||
request_headers.add("content-type", "application/json");
|
||||
} else if (multipart_boundary) |boundary| {
|
||||
const header = try std.mem.concat(allocator, u8, &.{ "multipart/form-data; boundary=", boundary });
|
||||
const header = try std.mem.concat(
|
||||
allocator,
|
||||
u8,
|
||||
&.{ "multipart/form-data; boundary=", boundary },
|
||||
);
|
||||
request_headers.add("content-type", header);
|
||||
}
|
||||
|
||||
@ -358,7 +424,10 @@ fn buildOptions(allocator: std.mem.Allocator, app: *const App, args: anytype) !R
|
||||
}
|
||||
|
||||
return .{
|
||||
.headers = if (@hasField(@TypeOf(args), "headers")) try buildHeaders(allocator, args.headers) else &.{},
|
||||
.headers = if (@hasField(@TypeOf(args), "headers"))
|
||||
try buildHeaders(allocator, args.headers)
|
||||
else
|
||||
&.{},
|
||||
.json = if (@hasField(@TypeOf(args), "json")) app.json(args.json) else null,
|
||||
.params = if (@hasField(@TypeOf(args), "params")) app.params(args.params) else null,
|
||||
.body = if (@hasField(@TypeOf(args), "body")) args.body else null,
|
||||
@ -368,7 +437,12 @@ fn buildOptions(allocator: std.mem.Allocator, app: *const App, args: anytype) !R
|
||||
fn buildHeaders(allocator: std.mem.Allocator, args: anytype) ![]const jetzig.testing.TestResponse.Header {
|
||||
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(allocator);
|
||||
inline for (std.meta.fields(@TypeOf(args))) |field| {
|
||||
try headers.append(jetzig.testing.TestResponse.Header{ .name = field.name, .value = @field(args, field.name) });
|
||||
try headers.append(
|
||||
jetzig.testing.TestResponse.Header{
|
||||
.name = field.name,
|
||||
.value = @field(args, field.name),
|
||||
},
|
||||
);
|
||||
}
|
||||
return try headers.toOwnedSlice();
|
||||
}
|
||||
|
@ -67,25 +67,25 @@ pub inline fn unquote(input: []const u8) []const u8 {
|
||||
}
|
||||
|
||||
/// Generate a secure random string of `len` characters (for cryptographic purposes).
|
||||
pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const u8 {
|
||||
pub fn generateSecret(allocator: std.mem.Allocator, len: u10) ![]const u8 {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
var secret: [len]u8 = undefined;
|
||||
const secret = try allocator.alloc(u8, len);
|
||||
|
||||
for (0..len) |index| {
|
||||
secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)];
|
||||
secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len - 1)];
|
||||
}
|
||||
|
||||
return try allocator.dupe(u8, &secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
pub fn generateRandomString(buf: []u8) []const u8 {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
for (0..buf.len) |index| {
|
||||
buf[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)];
|
||||
buf[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len - 1)];
|
||||
}
|
||||
|
||||
return buf;
|
||||
return buf[0..];
|
||||
}
|
||||
|
||||
/// Calculate a duration from a given start time (in nanoseconds) to the current time.
|
||||
|
@ -56,6 +56,8 @@ json_params: []const []const u8,
|
||||
params: std.ArrayList(*jetzig.data.Data) = undefined,
|
||||
id: []const u8,
|
||||
formats: ?Formats = null,
|
||||
before_callbacks: []const jetzig.callbacks.BeforeCallback = &.{},
|
||||
after_callbacks: []const jetzig.callbacks.AfterCallback = &.{},
|
||||
|
||||
/// Initializes a route's static params on server launch. Converts static params (JSON strings)
|
||||
/// to `jetzig.data.Data` values. Memory is owned by caller (`App.start()`).
|
||||
|
Loading…
x
Reference in New Issue
Block a user