mirror of
https://github.com/jetzig-framework/jetzig.git
synced 2025-05-14 14:06:08 +00:00
Merge pull request #120 from jetzig-framework/anti-csrf
Closes #108: Anti-CSRF middleware
This commit is contained in:
commit
130a7c81a8
2
.github/workflows/CI.yml
vendored
2
.github/workflows/CI.yml
vendored
@ -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' }}
|
||||||
|
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"));
|
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);
|
||||||
|
@ -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",
|
||||||
|
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
|
/// 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.
|
||||||
|
@ -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")) {
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
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 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",
|
||||||
};
|
};
|
||||||
|
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 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");
|
||||||
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
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.
|
/// 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);
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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()`).
|
||||||
|
Loading…
x
Reference in New Issue
Block a user