diff --git a/cli/cli.zig b/cli/cli.zig index 38ffb32..5db9282 100644 --- a/cli/cli.zig +++ b/cli/cli.zig @@ -9,6 +9,7 @@ pub const routes = @import("commands/routes.zig"); pub const bundle = @import("commands/bundle.zig"); pub const tests = @import("commands/tests.zig"); pub const database = @import("commands/database.zig"); +pub const auth = @import("commands/auth.zig"); pub const Environment = enum { development, testing, production }; @@ -47,6 +48,7 @@ const Verb = union(enum) { bundle: bundle.Options, @"test": tests.Options, database: database.Options, + auth: auth.Options, g: generate.Options, s: server.Options, r: routes.Options, @@ -87,6 +89,7 @@ pub fn main() !void { \\ routes List all routes in your app. \\ bundle Create a deployment bundle. \\ database Manage the application's database. + \\ auth Utilities for Jetzig authentication. \\ test Run app tests. \\ \\ Pass --help to any command for more information, e.g. `jetzig init --help` @@ -156,6 +159,13 @@ fn run(allocator: std.mem.Allocator, options: args.ParseArgsResult(Options, Verb OptionsType, options, ), + .auth => |opts| auth.run( + allocator, + opts, + writer, + OptionsType, + options, + ), }; } } diff --git a/cli/commands/auth.zig b/cli/commands/auth.zig new file mode 100644 index 0000000..9da3dd0 --- /dev/null +++ b/cli/commands/auth.zig @@ -0,0 +1,79 @@ +const std = @import("std"); +const args = @import("args"); + +/// Command line options for the `update` command. +pub const Options = struct { + pub const meta = .{ + .usage_summary = "[password]", + .full_text = + \\Generates a password + \\Example: + \\ + \\ jetzig update + \\ jetzig update web + , + .option_docs = .{ + .path = "Set the output path relative to the current directory (default: current directory)", + }, + }; +}; + +/// Run the `jetzig database` command. +pub fn run( + allocator: std.mem.Allocator, + options: Options, + writer: anytype, + T: type, + main_options: T, +) !void { + _ = options; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Action = enum { password }; + const map = std.StaticStringMap(Action).initComptime(.{ + .{ "password", .password }, + }); + + const action = if (main_options.positionals.len > 0) + map.get(main_options.positionals[0]) + else + null; + const sub_args: []const []const u8 = if (main_options.positionals.len > 1) + main_options.positionals[1..] + else + &.{}; + + return if (main_options.options.help and action == null) blk: { + try args.printHelp(Options, "jetzig database", writer); + break :blk {}; + } else if (action == null) blk: { + const available_help = try std.mem.join(alloc, "|", map.keys()); + std.debug.print("Missing sub-command. Expected: [{s}]\n", .{available_help}); + break :blk error.JetzigCommandError; + } else if (action) |capture| + switch (capture) { + .password => blk: { + if (sub_args.len < 1) { + std.debug.print("Missing argument. Expected a password paramater.\n", .{}); + break :blk error.JetzigCommandError; + } else { + const hash = try hashPassword(alloc, sub_args[0]); + try std.io.getStdOut().writer().print("Password hash: {s}\n", .{hash}); + } + }, + }; +} + +pub fn hashPassword(allocator: std.mem.Allocator, password: []const u8) ![]const u8 { + const buf = try allocator.alloc(u8, 128); + return try std.crypto.pwhash.argon2.strHash( + password, + .{ + .allocator = allocator, + .params = .{ .t = 3, .m = 32, .p = 4 }, + }, + buf, + ); +} diff --git a/cli/commands/database.zig b/cli/commands/database.zig index fbf8807..bad2e94 100644 --- a/cli/commands/database.zig +++ b/cli/commands/database.zig @@ -28,7 +28,7 @@ pub const Options = struct { }; }; -/// Run the `jetzig generate` command. +/// Run the `jetzig database` command. pub fn run( allocator: std.mem.Allocator, options: Options, diff --git a/cli/commands/generate/middleware.zig b/cli/commands/generate/middleware.zig index b9d12b2..5e0d92b 100644 --- a/cli/commands/generate/middleware.zig +++ b/cli/commands/generate/middleware.zig @@ -78,25 +78,32 @@ const middleware_content = \\ return middleware; \\} \\ - \\/// Invoked immediately after the request head has been processed, before relevant view function - \\/// is processed. This gives you access to request headers but not the request body. - \\pub fn beforeRequest(self: *Self, request: *jetzig.http.Request) !void { + \\/// Invoked immediately after the request is received but before it has started processing. + \\/// Any calls to `request.render` or `request.redirect` will prevent further processing of the + \\/// request, including any other middleware in the chain. + \\pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { \\ request.server.logger.debug("[middleware] my_custom_value: {s}", .{self.my_custom_value}); \\ self.my_custom_value = @tagName(request.method); \\} \\ - \\/// Invoked immediately after the request has finished responding. Provides full access to the - \\/// response as well as the request. - \\pub fn afterRequest(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { + \\/// Invoked immediately before the response renders to the client. + \\/// The response can be modified here if needed. + \\pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { \\ request.server.logger.debug( \\ "[middleware] my_custom_value: {s}, response status: {s}", \\ .{ self.my_custom_value, @tagName(response.status_code) }, \\ ); \\} \\ - \\/// Invoked after `afterRequest` is called, use this function to do any clean-up. + \\/// Invoked immediately after the response has been finalized and sent to the client. + \\/// Response data can be accessed for logging, but any modifications will have no impact. + \\pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) void { + \\ request.allocator.destroy(self); + \\} + \\ + \\/// Invoked after `afterResponse` 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. + \\/// freed before the next request starts processing. \\pub fn deinit(self: *Self, request: *jetzig.http.Request) void { \\ request.allocator.destroy(self); \\} diff --git a/cli/commands/generate/secret.zig b/cli/commands/generate/secret.zig index 9866f34..4c3ef5b 100644 --- a/cli/commands/generate/secret.zig +++ b/cli/commands/generate/secret.zig @@ -14,11 +14,12 @@ pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, he _ = args; _ = cwd; const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - var secret: [128]u8 = undefined; + const len = 128; + var secret: [len]u8 = undefined; - for (0..128) |index| { - secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)]; + for (0..len) |index| { + secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len - 1)]; } - std.debug.print("{s}\n", .{secret}); + try std.io.getStdOut().writer().print("{s}\n", .{secret}); } diff --git a/src/compile_static_routes.zig b/src/compile_static_routes.zig index cbbbca4..0ac298e 100644 --- a/src/compile_static_routes.zig +++ b/src/compile_static_routes.zig @@ -12,6 +12,10 @@ pub const database_lazy_connect = true; pub const jetzig_options = struct { pub const Schema = @import("Schema"); + pub const middleware: []const type = if (@hasDecl(@import("main").jetzig_options, "middleware")) + @import("main").jetzig_options.middleware + else + &.{}; }; pub fn main() !void { diff --git a/src/jetzig.zig b/src/jetzig.zig index 32f4b57..ec95a65 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -21,6 +21,7 @@ pub const kv = @import("jetzig/kv.zig"); 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 DateTime = jetcommon.types.DateTime; pub const Time = jetcommon.types.Time; diff --git a/src/jetzig/auth.zig b/src/jetzig/auth.zig new file mode 100644 index 0000000..8e1fa96 --- /dev/null +++ b/src/jetzig/auth.zig @@ -0,0 +1,50 @@ +const std = @import("std"); + +const jetzig = @import("../jetzig.zig"); + +pub const IdType = enum { string, integer }; + +pub fn getUserId(comptime id_type: IdType, request: *jetzig.Request) !?switch (id_type) { + .integer => i128, + .string => []const u8, +} { + const session = try request.session(); + + return session.getT(std.enums.nameCast(jetzig.data.ValueType, id_type), "_jetzig_user_id"); +} + +pub fn signIn(request: *jetzig.Request, user_id: anytype) !void { + var session = try request.session(); + try session.put("_jetzig_user_id", user_id); +} + +pub fn verifyPassword( + allocator: std.mem.Allocator, + hash: []const u8, + password: []const u8, +) !bool { + const verify_error = std.crypto.pwhash.argon2.strVerify( + hash, + password, + .{ .allocator = allocator }, + ); + + return if (verify_error) + true + else |err| switch (err) { + error.AuthenticationFailed, error.PasswordVerificationFailed => false, + else => err, + }; +} + +pub fn hashPassword(allocator: std.mem.Allocator, password: []const u8) ![]const u8 { + const buf = try allocator.alloc(u8, 128); + return try std.crypto.pwhash.argon2.strHash( + password, + .{ + .allocator = allocator, + .params = .{ .t = 3, .m = 32, .p = 4 }, + }, + buf, + ); +} diff --git a/src/jetzig/data.zig b/src/jetzig/data.zig index d9b5d17..c97c39d 100644 --- a/src/jetzig/data.zig +++ b/src/jetzig/data.zig @@ -12,3 +12,4 @@ pub const Boolean = zmpl.Data.Boolean; pub const String = zmpl.Data.String; pub const Object = zmpl.Data.Object; pub const Array = zmpl.Data.Array; +pub const ValueType = zmpl.Data.ValueType; diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 5745096..0b046b3 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -35,9 +35,11 @@ layout: ?[]const u8 = null, layout_disabled: bool = false, rendered: bool = false, redirected: bool = false, +failed: bool = false, redirect_state: ?RedirectState = null, middleware_rendered: ?struct { name: []const u8, action: []const u8 } = null, middleware_rendered_during_response: bool = false, +middleware_data: jetzig.http.middleware.MiddlewareData = undefined, rendered_multiple: bool = false, rendered_view: ?jetzig.views.View = null, start_time: i128, @@ -173,7 +175,7 @@ pub fn respond(self: *Request) !void { /// Render a response. This function can only be called once per request (repeat calls will /// trigger an error). pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View { - if (self.rendered) self.rendered_multiple = true; + if (self.rendered or self.failed) self.rendered_multiple = true; self.rendered = true; if (self.response_started) self.middleware_rendered_during_response = true; @@ -181,6 +183,18 @@ pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode) return self.rendered_view.?; } +/// Render an error. This function can only be called once per request (repeat calls will +/// trigger an error). +pub fn fail(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View { + if (self.rendered or self.redirected) self.rendered_multiple = true; + + self.rendered = true; + self.failed = true; + if (self.response_started) self.middleware_rendered_during_response = true; + self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; + return self.rendered_view.?; +} + /// Issue a redirect to a new location. /// ```zig /// return request.redirect("https://www.example.com/", .moved_permanently); @@ -194,7 +208,7 @@ pub fn redirect( location: []const u8, redirect_status: enum { moved_permanently, found }, ) jetzig.views.View { - if (self.rendered) self.rendered_multiple = true; + if (self.rendered or self.failed) self.rendered_multiple = true; self.rendered = true; self.redirected = true; @@ -209,6 +223,19 @@ pub fn redirect( return .{ .data = self.response_data, .status_code = status_code }; } +pub fn middleware( + self: *const Request, + comptime name: jetzig.http.middleware.Enum, +) jetzig.http.middleware.Type(name) { + inline for (jetzig.http.middleware.middlewares, 0..) |T, index| { + if (@hasDecl(T, "middleware_name") and std.mem.eql(u8, @tagName(name), T.middleware_name)) { + const middleware_data = self.middleware_data.get(index); + return @as(*jetzig.http.middleware.Type(name), @ptrCast(@alignCast(middleware_data))).*; + } + } + unreachable; +} + const RedirectState = struct { location: []const u8, status_code: jetzig.http.status_codes.StatusCode }; pub fn renderRedirect(self: *Request, state: RedirectState) !void { diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index dfb738d..991805a 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -153,10 +153,10 @@ pub fn processNextRequest( try self.renderResponse(&request); try request.response.headers.append("Content-Type", response.content_type); try jetzig.http.middleware.beforeResponse(&middleware_data, &request); - jetzig.http.middleware.deinit(&middleware_data, &request); - try jetzig.http.middleware.afterResponse(&middleware_data, &request); try request.respond(); + try jetzig.http.middleware.afterResponse(&middleware_data, &request); + jetzig.http.middleware.deinit(&middleware_data, &request); } try self.logger.logRequest(&request); @@ -233,7 +233,7 @@ fn renderHTML( return request.setResponse(rendered_error, .{}); }; - return if (request.redirected or request.dynamic_assigned_template != null) + return if (request.redirected or request.failed or request.dynamic_assigned_template != null) request.setResponse(rendered, .{}) else request.setResponse(try self.renderNotFound(request), .{}); @@ -302,6 +302,14 @@ fn renderView( return try self.renderInternalServerError(request, err); }; + if (request.failed) { + const view: jetzig.views.View = request.rendered_view orelse .{ + .data = request.response_data, + .status_code = .internal_server_error, + }; + return try self.renderError(request, view.status_code); + } + const template: ?zmpl.Template = if (request.dynamic_assigned_template) |request_template| zmpl.findPrefixed("views", request_template) orelse maybe_template else diff --git a/src/jetzig/http/Session.zig b/src/jetzig/http/Session.zig index 178df99..40607e3 100644 --- a/src/jetzig/http/Session.zig +++ b/src/jetzig/http/Session.zig @@ -53,8 +53,8 @@ pub fn deinit(self: *Self) void { } /// Get a value from the session. -pub fn get(self: *Self, key: []const u8) !?*jetzig.data.Value { - if (self.state != .parsed) return error.UnparsedSessionCookie; +pub fn get(self: *Self, key: []const u8) ?*jetzig.data.Value { + std.debug.assert(self.state == .parsed); return switch (self.data.value.?.*) { .object => self.data.value.?.object.get(key), @@ -62,8 +62,22 @@ pub fn get(self: *Self, key: []const u8) !?*jetzig.data.Value { }; } +/// Get a typed value from the session. +pub fn getT( + self: *Self, + comptime T: jetzig.data.ValueType, + key: []const u8, +) @TypeOf(self.data.value.?.object.getT(T, key)) { + std.debug.assert(self.state == .parsed); + + return switch (self.data.value.?.*) { + .object => self.data.value.?.object.getT(T, key), + else => unreachable, + }; +} + /// Put a value into the session. -pub fn put(self: *Self, key: []const u8, value: *jetzig.data.Value) !void { +pub fn put(self: *Self, key: []const u8, value: anytype) !void { if (self.state != .parsed) return error.UnparsedSessionCookie; switch (self.data.value.?.*) { diff --git a/src/jetzig/http/middleware.zig b/src/jetzig/http/middleware.zig index bead30f..a6feace 100644 --- a/src/jetzig/http/middleware.zig +++ b/src/jetzig/http/middleware.zig @@ -1,9 +1,52 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); -const middlewares: []const type = jetzig.config.get([]const type, "middleware"); +pub const middlewares: []const type = jetzig.config.get([]const type, "middleware"); +pub const MiddlewareData = std.BoundedArray(?*anyopaque, middlewares.len); +pub const Enum = MiddlewareEnum(); -const MiddlewareData = std.BoundedArray(?*anyopaque, middlewares.len); +fn MiddlewareEnum() type { + comptime { + var size: usize = 0; + for (middlewares) |middleware_type| { + if (@hasDecl(middleware_type, "middleware_name")) size += 1; + } + var fields: [size]std.builtin.Type.EnumField = undefined; + var index: usize = 0; + for (middlewares) |middleware_type| { + if (@hasDecl(middleware_type, "middleware_name")) { + fields[index] = .{ .name = middleware_type.middleware_name, .value = index }; + index += 1; + } + } + return @Type(.{ + .@"enum" = .{ + .tag_type = std.math.IntFittingRange(0, if (size == 0) 0 else size - 1), + .fields = &fields, + .decls = &.{}, + .is_exhaustive = true, + }, + }); + } +} + +pub fn Type(comptime name: MiddlewareEnum()) type { + comptime { + for (middlewares) |middleware_type| { + if (@hasDecl( + middleware_type, + "middleware_name", + ) and std.mem.eql( + u8, + middleware_type.middleware_name, + @tagName(name), + )) { + return middleware_type; + } + } + unreachable; + } +} pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData { var middleware_data = MiddlewareData.init(0) catch unreachable; @@ -35,6 +78,7 @@ pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData { } } + request.middleware_data = middleware_data; return middleware_data; } diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index 821452b..b8cd69a 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -12,6 +12,7 @@ stdout_colorized: bool, stderr_colorized: bool, level: LogLevel, log_queue: *jetzig.loggers.LogQueue, +mutex: *std.Thread.Mutex, /// Initialize a new Development Logger. pub fn init( @@ -19,12 +20,14 @@ pub fn init( level: LogLevel, log_queue: *jetzig.loggers.LogQueue, ) DevelopmentLogger { + const mutex = allocator.create(std.Thread.Mutex) catch unreachable; return .{ .allocator = allocator, .level = level, .log_queue = log_queue, .stdout_colorized = log_queue.stdout_is_tty, .stderr_colorized = log_queue.stderr_is_tty, + .mutex = mutex, }; } @@ -105,6 +108,9 @@ pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) } pub fn logSql(self: *const DevelopmentLogger, event: jetzig.jetquery.events.Event) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + // XXX: This function does not make any effort to prevent log messages clobbering each other // from multiple threads. JSON logger etc. write in one call and the logger's mutex prevents // clobbering, but this is not the case here.