diff --git a/build.zig b/build.zig index 48a40c5..5498d42 100644 --- a/build.zig +++ b/build.zig @@ -39,7 +39,7 @@ pub fn build(b: *std.Build) !void { lib.root_module.addImport("zmpl", zmpl_dep.module("zmpl")); jetzig_module.addImport("zmpl", zmpl_dep.module("zmpl")); - lib.root_module.addImport("args", zig_args_dep.module("args")); + jetzig_module.addImport("args", zig_args_dep.module("args")); // This is the way to make it look nice in the zig build script // If we would do it the other way around, we would have to do diff --git a/build.zig.zon b/build.zig.zon index 3f72aa0..1bc2895 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,8 +7,8 @@ .hash = "1220e5ede084ca6b94defd466a8f8779aab151d37bf688fefb928fded6f02cde4135", }, .args = .{ - .url = "https://github.com/MasterQ32/zig-args/archive/89f18a104d9c13763b90e97d6b4ce133da8a3e2b.tar.gz", - .hash = "12203ded54c85878eea7f12744066dcb4397177395ac49a7b2aa365bf6047b623829", + .url = "https://github.com/bobf/zig-args/archive/e827c93f00e8bd95bd4b970c59593f393a6b08d5.tar.gz", + .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, diff --git a/cli/build.zig.zon b/cli/build.zig.zon index 9350709..f703560 100644 --- a/cli/build.zig.zon +++ b/cli/build.zig.zon @@ -5,8 +5,8 @@ .dependencies = .{ .args = .{ - .url = "https://github.com/MasterQ32/zig-args/archive/89f18a104d9c13763b90e97d6b4ce133da8a3e2b.tar.gz", - .hash = "12203ded54c85878eea7f12744066dcb4397177395ac49a7b2aa365bf6047b623829", + .url = "https://github.com/bobf/zig-args/archive/e827c93f00e8bd95bd4b970c59593f393a6b08d5.tar.gz", + .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, .paths = .{ diff --git a/cli/commands/generate.zig b/cli/commands/generate.zig index b6fb6e9..5480a84 100644 --- a/cli/commands/generate.zig +++ b/cli/commands/generate.zig @@ -4,12 +4,13 @@ const view = @import("generate/view.zig"); const partial = @import("generate/partial.zig"); const layout = @import("generate/layout.zig"); const middleware = @import("generate/middleware.zig"); +const secret = @import("generate/secret.zig"); const util = @import("../util.zig"); /// Command line options for the `generate` command. pub const Options = struct { pub const meta = .{ - .usage_summary = "[view|partial|layout|middleware] [options]", + .usage_summary = "[view|partial|layout|middleware|secret] [options]", .full_text = \\Generates scaffolding for views, middleware, and other objects in future. \\ @@ -44,7 +45,7 @@ pub fn run( try args.printHelp(Options, "jetzig generate", writer); return; } - var generate_type: ?enum { view, partial, layout, middleware } = null; + var generate_type: ?enum { view, partial, layout, middleware, secret } = null; var sub_args = std.ArrayList([]const u8).init(allocator); defer sub_args.deinit(); @@ -57,6 +58,8 @@ pub fn run( generate_type = .layout; } else if (generate_type == null and std.mem.eql(u8, arg, "middleware")) { generate_type = .middleware; + } else if (generate_type == null and std.mem.eql(u8, arg, "secret")) { + generate_type = .secret; } else if (generate_type == null) { std.debug.print("Unknown generator command: {s}\n", .{arg}); return error.JetzigCommandError; @@ -71,6 +74,7 @@ pub fn run( .partial => partial.run(allocator, cwd, sub_args.items), .layout => layout.run(allocator, cwd, sub_args.items), .middleware => middleware.run(allocator, cwd, sub_args.items), + .secret => secret.run(allocator, cwd, sub_args.items), }; } else { std.debug.print("Missing sub-command. Expected: [view|partial|layout|middleware]\n", .{}); diff --git a/cli/commands/generate/secret.zig b/cli/commands/generate/secret.zig new file mode 100644 index 0000000..25da07f --- /dev/null +++ b/cli/commands/generate/secret.zig @@ -0,0 +1,16 @@ +const std = @import("std"); + +/// Generate a secure random secret and output to stdout. +pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8) !void { + _ = allocator; + _ = args; + _ = cwd; + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var secret: [44]u8 = undefined; + + for (0..44) |index| { + secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)]; + } + + std.debug.print("{s}\n", .{secret}); +} diff --git a/demo/src/app/middleware/DemoMiddleware.zig b/demo/src/app/middleware/DemoMiddleware.zig index accf8b9..a6758d2 100644 --- a/demo/src/app/middleware/DemoMiddleware.zig +++ b/demo/src/app/middleware/DemoMiddleware.zig @@ -33,14 +33,17 @@ pub fn init(request: *jetzig.http.Request) !*Self { /// 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("[DemoMiddleware:afterRequest] my_custom_value: {s}", .{self.my_custom_value}); + try request.server.logger.DEBUG( + "[DemoMiddleware:afterRequest] my_custom_value: {s}", + .{self.my_custom_value}, + ); self.my_custom_value = @tagName(request.method); } /// 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( + try request.server.logger.DEBUG( "[DemoMiddleware:beforeResponse] my_custom_value: {s}, response status: {s}", .{ self.my_custom_value, @tagName(response.status_code) }, ); @@ -51,7 +54,7 @@ pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jet pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { _ = self; _ = response; - request.server.logger.debug("[DemoMiddleware:afterResponse] response completed", .{}); + try request.server.logger.DEBUG("[DemoMiddleware:afterResponse] response completed", .{}); } /// Invoked after `afterResponse` is called. Use this function to do any clean-up. diff --git a/src/jetzig.zig b/src/jetzig.zig index 91447f7..9f782a9 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -5,16 +5,19 @@ pub const zmpl = @import("zmpl").zmpl; pub const http = @import("jetzig/http.zig"); pub const loggers = @import("jetzig/loggers.zig"); pub const data = @import("jetzig/data.zig"); -pub const caches = @import("jetzig/caches.zig"); pub const views = @import("jetzig/views.zig"); pub const colors = @import("jetzig/colors.zig"); pub const middleware = @import("jetzig/middleware.zig"); pub const util = @import("jetzig/util.zig"); +pub const types = @import("jetzig/types.zig"); /// The primary interface for a Jetzig application. Create an `App` in your application's /// `src/main.zig` and call `start` to launch the application. pub const App = @import("jetzig/App.zig"); +/// Configuration options for the application server with command-line argument parsing. +pub const Environment = @import("jetzig/Environment.zig"); + /// An HTTP request which is passed to (dynamic) view functions and provides access to params, /// headers, and functions to render a response. pub const Request = http.Request; @@ -43,36 +46,11 @@ pub fn init(allocator: std.mem.Allocator) !App { const args = try std.process.argsAlloc(allocator); defer std.process.argsFree(allocator, args); - const host: []const u8 = if (args.len > 1) - try allocator.dupe(u8, args[1]) - else - try allocator.dupe(u8, "127.0.0.1"); - - // TODO: Fix this up with proper arg parsing - const port: u16 = if (args.len > 2) try std.fmt.parseInt(u16, args[2], 10) else 8080; - const use_cache: bool = args.len > 3 and std.mem.eql(u8, args[3], "--cache"); - const server_cache = switch (use_cache) { - true => caches.Cache{ .memory_cache = caches.MemoryCache.init(allocator) }, - false => caches.Cache{ .null_cache = caches.NullCache.init(allocator) }, - }; - var logger = loggers.Logger{ .development_logger = loggers.DevelopmentLogger.init(allocator) }; - const secret = try generateSecret(allocator); - logger.debug( - "Running in development mode, using auto-generated cookie encryption key:\n {s}", - .{secret}, - ); - - const server_options = http.Server.ServerOptions{ - .cache = server_cache, - .logger = logger, - .secret = secret, - }; + const environment = Environment.init(allocator); return .{ - .server_options = server_options, + .server_options = try environment.getServerOptions(), .allocator = allocator, - .host = host, - .port = port, }; } @@ -154,36 +132,3 @@ pub fn route(comptime routes: anytype) []views.Route { return &detected; } - -pub fn generateSecret(allocator: std.mem.Allocator) ![]const u8 { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - var secret: [64]u8 = undefined; - - for (0..64) |index| { - secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)]; - } - - return try allocator.dupe(u8, &secret); -} - -pub fn base64Encode(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { - const encoder = std.base64.Base64Encoder.init( - std.base64.url_safe_no_pad.alphabet_chars, - std.base64.url_safe_no_pad.pad_char, - ); - const size = encoder.calcSize(string.len); - const ptr = try allocator.alloc(u8, size); - _ = encoder.encode(ptr, string); - return ptr; -} - -pub fn base64Decode(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { - const decoder = std.base64.Base64Decoder.init( - std.base64.url_safe_no_pad.alphabet_chars, - std.base64.url_safe_no_pad.pad_char, - ); - const size = try decoder.calcSizeForSlice(string); - const ptr = try allocator.alloc(u8, size); - try decoder.decode(ptr, string); - return ptr; -} diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index c0ea75b..f21e925 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const args = @import("args"); + const jetzig = @import("../jetzig.zig"); const mime_types = @import("mime_types").mime_types; // Generated at build time. @@ -7,8 +9,6 @@ const Self = @This(); server_options: jetzig.http.Server.ServerOptions, allocator: std.mem.Allocator, -host: []const u8, -port: u16, pub fn deinit(self: Self) void { _ = self; @@ -48,30 +48,40 @@ pub fn start(self: Self, comptime_routes: []jetzig.views.Route) !void { self.allocator.destroy(route); }; + if (self.server_options.detach) { + const argv = try std.process.argsAlloc(self.allocator); + defer std.process.argsFree(self.allocator, argv); + var child_argv = std.ArrayList([]const u8).init(self.allocator); + for (argv) |arg| { + if (!std.mem.eql(u8, "-d", arg) and !std.mem.eql(u8, "--detach", arg)) { + try child_argv.append(arg); + } + } + var child = std.process.Child.init(child_argv.items, self.allocator); + try child.spawn(); + std.debug.print("Spawned child process. PID: {}. Exiting.\n", .{child.id}); + std.process.exit(0); + } + var server = jetzig.http.Server.init( self.allocator, - self.host, - self.port, self.server_options, routes.items, &mime_map, ); - defer server.deinit(); - defer self.allocator.free(self.host); - defer self.allocator.free(server.options.secret); server.listen() catch |err| { switch (err) { error.AddressInUse => { - server.logger.debug( + try server.logger.ERROR( "Socket unavailable: {s}:{} - unable to start server.\n", - .{ self.host, self.port }, + .{ self.server_options.bind, self.server_options.port }, ); return; }, else => { - server.logger.debug("Encountered error: {}\nExiting.\n", .{err}); + try server.logger.ERROR("Encountered error: {}\nExiting.\n", .{err}); return err; }, } diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig new file mode 100644 index 0000000..73f2d84 --- /dev/null +++ b/src/jetzig/Environment.zig @@ -0,0 +1,124 @@ +const std = @import("std"); + +const args = @import("args"); + +const jetzig = @import("../jetzig.zig"); + +const Environment = @This(); + +allocator: std.mem.Allocator, + +const Options = struct { + help: bool = false, + bind: []const u8 = "127.0.0.1", + port: u16 = 8080, + environment: []const u8 = "development", + log: []const u8 = "-", + @"log-error": []const u8 = "-", + @"log-level": jetzig.loggers.LogLevel = .DEBUG, + detach: bool = false, + + pub const shorthands = .{ + .h = "help", + .b = "bind", + .p = "port", + .e = "environment", + .d = "detach", + }; + + pub const wrap_len = 80; + + pub const meta = .{ + .option_docs = .{ + .bind = "IP address/hostname to bind to (default: 127.0.0.1)", + .port = "Port to listen on (default: 8080)", + .environment = "Load an environment configuration from src/app/environments/.zig", + .log = "Path to log file. Use '-' for stdout (default: -)", + .@"log-error" = + \\Optional path to separate error log file. Use '-' for stdout. If omitted, errors are logged to the location specified by the `log` option. + , + .@"log-level" = + \\Specify the minimum log level. Log events below the given level are ignored. Must be one of: TRACE, DEBUG, INFO, WARN, ERROR, FATAL (default: DEBUG) + , + .detach = + \\Run the server in the background. Must be used in conjunction with --log (default: false) + , + .help = "Print help and exit", + }, + }; +}; + +pub fn init(allocator: std.mem.Allocator) Environment { + return .{ .allocator = allocator }; +} + +/// Generate server initialization options using command line args with defaults. +pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { + const options = try args.parseForCurrentProcess(Options, self.allocator, .print); + + if (options.options.help) { + const writer = std.io.getStdErr().writer(); + try args.printHelp(Options, options.executable_name orelse "", writer); + std.process.exit(0); + } + + var logger = jetzig.loggers.Logger{ + .development_logger = jetzig.loggers.DevelopmentLogger.init( + self.allocator, + try getLogFile(options.options.log), + ), + }; + + if (options.options.detach and std.mem.eql(u8, options.options.log, "-")) { + try logger.ERROR("Must pass `--log` when using `--detach`.", .{}); + std.process.exit(1); + } + + // TODO: Generate nonce per session - do research to confirm correct best practice. + const secret_len = jetzig.http.Session.Cipher.key_length + jetzig.http.Session.Cipher.nonce_length; + const secret = try self.getSecret(&logger, secret_len); + + if (secret.len != secret_len) { + try logger.ERROR("Expected secret length: {}, found: {}.", .{ secret_len, secret.len }); + try logger.ERROR("Use `jetzig generate secret` to create a secure secret value.", .{}); + std.process.exit(1); + } + + return .{ + .logger = logger, + .secret = secret, + .bind = try self.allocator.dupe(u8, options.options.bind), + .port = options.options.port, + .detach = options.options.detach, + }; +} + +fn getLogFile(path: []const u8) !std.fs.File { + if (std.mem.eql(u8, path, "-")) return std.io.getStdOut(); + + const file = try std.fs.createFileAbsolute(path, .{ .truncate = false }); + try file.seekFromEnd(0); + return file; +} + +fn getSecret(self: Environment, logger: *jetzig.loggers.Logger, comptime len: u10) ![]const u8 { + return std.process.getEnvVarOwned(self.allocator, "JETZIG_SECRET") catch |err| { + switch (err) { + error.EnvironmentVariableNotFound => { + // TODO: Make this a failure when running in non-development mode. + const secret = try jetzig.util.generateSecret(self.allocator, len); + try logger.WARN( + "Running in development mode, using auto-generated cookie encryption key: {s}", + .{secret}, + ); + try logger.WARN( + "Run `jetzig generate secret` and set `JETZIG_SECRET` to remove this warning.", + .{}, + ); + + return secret; + }, + else => return err, + } + }; +} diff --git a/src/jetzig/caches.zig b/src/jetzig/caches.zig deleted file mode 100644 index 94a2dc4..0000000 --- a/src/jetzig/caches.zig +++ /dev/null @@ -1,30 +0,0 @@ -const std = @import("std"); - -const http = @import("http.zig"); - -pub const Result = @import("caches/Result.zig"); -pub const MemoryCache = @import("caches/MemoryCache.zig"); -pub const NullCache = @import("caches/NullCache.zig"); - -pub const Cache = union(enum) { - memory_cache: MemoryCache, - null_cache: NullCache, - - pub fn deinit(self: *Cache) void { - switch (self.*) { - inline else => |*case| case.deinit(), - } - } - - pub fn get(self: *Cache, key: []const u8) ?Result { - return switch (self.*) { - inline else => |*case| case.get(key), - }; - } - - pub fn put(self: *Cache, key: []const u8, value: http.Response) !Result { - return switch (self.*) { - inline else => |*case| case.put(key, value), - }; - } -}; diff --git a/src/jetzig/caches/Cache.zig b/src/jetzig/caches/Cache.zig deleted file mode 100644 index 0769537..0000000 --- a/src/jetzig/caches/Cache.zig +++ /dev/null @@ -1,5 +0,0 @@ -const std = @import("std"); - -pub const Result = @import("Result.zig"); -pub const MemoryCache = @import("MemoryCache.zig"); -pub const NullCache = @import("NullCache.zig"); diff --git a/src/jetzig/caches/MemoryCache.zig b/src/jetzig/caches/MemoryCache.zig deleted file mode 100644 index 1363b0e..0000000 --- a/src/jetzig/caches/MemoryCache.zig +++ /dev/null @@ -1,39 +0,0 @@ -const std = @import("std"); - -const http = @import("../http.zig"); -const Result = @import("Result.zig"); - -allocator: std.mem.Allocator, -cache: std.StringHashMap(http.Response), - -const Self = @This(); - -pub fn init(allocator: std.mem.Allocator) Self { - const cache = std.StringHashMap(http.Response).init(allocator); - - return .{ .allocator = allocator, .cache = cache }; -} - -pub fn deinit(self: *Self) void { - var iterator = self.cache.keyIterator(); - while (iterator.next()) |key| { - self.allocator.free(key.*); - } - self.cache.deinit(); -} - -pub fn get(self: *Self, key: []const u8) ?Result { - if (self.cache.get(key)) |value| { - return Result.init(self.allocator, value, true); - } else { - return null; - } -} - -pub fn put(self: *Self, key: []const u8, value: http.Response) !Result { - const key_dupe = try self.allocator.dupe(u8, key); - const value_dupe = try value.dupe(); - try self.cache.put(key_dupe, value_dupe); - - return Result.init(self.allocator, value_dupe, true); -} diff --git a/src/jetzig/caches/NullCache.zig b/src/jetzig/caches/NullCache.zig deleted file mode 100644 index 1c67aa0..0000000 --- a/src/jetzig/caches/NullCache.zig +++ /dev/null @@ -1,27 +0,0 @@ -const std = @import("std"); - -const http = @import("../http.zig"); -const Result = @import("Result.zig"); - -const Self = @This(); - -allocator: std.mem.Allocator, - -pub fn init(allocator: std.mem.Allocator) Self { - return Self{ .allocator = allocator }; -} - -pub fn deinit(self: *const Self) void { - _ = self; -} - -pub fn get(self: *const Self, key: []const u8) ?Result { - _ = key; - _ = self; - return null; -} - -pub fn put(self: *const Self, key: []const u8, value: http.Response) !Result { - _ = key; - return Result{ .value = value, .cached = false, .allocator = self.allocator }; -} diff --git a/src/jetzig/caches/Result.zig b/src/jetzig/caches/Result.zig deleted file mode 100644 index 628645b..0000000 --- a/src/jetzig/caches/Result.zig +++ /dev/null @@ -1,17 +0,0 @@ -const std = @import("std"); - -const Self = @This(); - -const jetzig = @import("../../jetzig.zig"); - -value: jetzig.http.Response, -cached: bool, -allocator: std.mem.Allocator, - -pub fn init(allocator: std.mem.Allocator, value: jetzig.http.Response, cached: bool) Self { - return .{ .allocator = allocator, .cached = cached, .value = value }; -} - -pub fn deinit(self: *const Self) void { - if (!self.cached) self.value.deinit(); -} diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index b4088de..9992bfb 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -101,7 +101,7 @@ pub fn process(self: *Self) !void { self.session.parse() catch |err| { switch (err) { error.JetzigInvalidSessionCookie => { - self.server.logger.debug("Invalid session cookie detected. Resetting session.", .{}); + try self.server.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{}); try self.session.reset(); }, else => return err, @@ -310,7 +310,9 @@ pub fn hash(self: *Self) ![]const u8 { ); } -pub fn fmtMethod(self: *Self) []const u8 { +pub fn fmtMethod(self: *Self, colorized: bool) []const u8 { + if (!colorized) return @tagName(self.method); + return switch (self.method) { .GET => jetzig.colors.cyan("GET"), .PUT => jetzig.colors.yellow("PUT"), diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index ee9dd68..c3e3bca 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -11,37 +11,32 @@ else struct {}; pub const ServerOptions = struct { - cache: jetzig.caches.Cache, logger: jetzig.loggers.Logger, + bind: []const u8, + port: u16, secret: []const u8, + detach: bool, }; allocator: std.mem.Allocator, -port: u16, -host: []const u8, -cache: jetzig.caches.Cache, logger: jetzig.loggers.Logger, options: ServerOptions, start_time: i128 = undefined, routes: []*jetzig.views.Route, mime_map: *jetzig.http.mime.MimeMap, std_net_server: std.net.Server = undefined, +initialized: bool = false, const Self = @This(); pub fn init( allocator: std.mem.Allocator, - host: []const u8, - port: u16, options: ServerOptions, routes: []*jetzig.views.Route, mime_map: *jetzig.http.mime.MimeMap, ) Self { return .{ .allocator = allocator, - .host = host, - .port = port, - .cache = options.cache, .logger = options.logger, .options = options, .routes = routes, @@ -50,15 +45,18 @@ pub fn init( } pub fn deinit(self: *Self) void { - self.std_net_server.deinit(); + if (self.initialized) self.std_net_server.deinit(); + self.allocator.free(self.options.secret); + self.allocator.free(self.options.bind); } pub fn listen(self: *Self) !void { - const address = try std.net.Address.parseIp("127.0.0.1", 8080); + const address = try std.net.Address.parseIp(self.options.bind, self.options.port); self.std_net_server = try address.listen(.{ .reuse_port = true }); - const cache_status = if (self.options.cache == .null_cache) "disabled" else "enabled"; - self.logger.debug("Listening on http://{s}:{} [cache:{s}]", .{ self.host, self.port, cache_status }); + self.initialized = true; + + try self.logger.INFO("Listening on http://{s}:{}", .{ self.options.bind, self.options.port }); try self.processRequests(); } @@ -77,7 +75,6 @@ fn processRequests(self: *Self) !void { self.processNextRequest(allocator, &std_http_server) catch |err| { if (isBadHttpError(err)) { - std.debug.print("Encountered HTTP error: {s}\n", .{@errorName(err)}); std_http_server.connection.stream.close(); continue; } else return err; @@ -113,7 +110,7 @@ fn processNextRequest(self: *Self, allocator: std.mem.Allocator, std_http_server const log_message = try self.requestLogMessage(&request); defer self.allocator.free(log_message); - self.logger.debug("{s}", .{log_message}); + try self.logger.INFO("{s}", .{log_message}); } fn renderResponse(self: *Self, request: *jetzig.http.Request) !void { @@ -210,7 +207,7 @@ fn renderView( // `return request.render(.ok)`, but the actual rendered view is stored in // `request.rendered_view`. _ = route.render(route.*, request) catch |err| { - self.logger.debug("Encountered error: {s}", .{@errorName(err)}); + try self.logger.ERROR("Encountered error: {s}", .{@errorName(err)}); if (isUnhandledError(err)) return err; if (isBadRequest(err)) return try self.renderBadRequest(request); return try self.renderInternalServerError(request, err); @@ -230,7 +227,7 @@ fn renderView( return .{ .view = rendered_view, .content = "" }; } } else { - self.logger.debug("`request.render` was not invoked. Rendering empty content.", .{}); + try self.logger.WARN("`request.render` was not invoked. Rendering empty content.", .{}); request.response_data.reset(); return .{ .view = .{ .data = request.response_data, .status_code = .no_content }, @@ -254,7 +251,7 @@ fn renderTemplateWithLayout( if (zmpl.manifest.find(prefixed_name)) |layout| { return try template.renderWithLayout(layout, view.data); } else { - self.logger.debug("Unknown layout: {s}", .{layout_name}); + try self.logger.WARN("Unknown layout: {s}", .{layout_name}); return try template.render(view.data); } } else return try template.render(view.data); @@ -299,7 +296,7 @@ fn renderInternalServerError(self: *Self, request: *jetzig.http.Request, err: an try object.put("error", request.response_data.string(@errorName(err))); const stack = @errorReturnTrace(); - if (stack) |capture| try self.logStackTrace(capture, request, object); + if (stack) |capture| try self.logStackTrace(capture, request); return .{ .view = jetzig.views.View{ .data = request.response_data, .status_code = .internal_server_error }, @@ -324,16 +321,13 @@ fn logStackTrace( self: *Self, stack: *std.builtin.StackTrace, request: *jetzig.http.Request, - object: *jetzig.data.Value, ) !void { - _ = self; - std.debug.print("\nStack Trace:\n{}", .{stack}); - var array = std.ArrayList(u8).init(request.allocator); - const writer = array.writer(); + try self.logger.ERROR("\nStack Trace:\n{}", .{stack}); + var buf = std.ArrayList(u8).init(request.allocator); + defer buf.deinit(); + const writer = buf.writer(); try stack.format("", .{}, writer); - // TODO: Generate an array of objects with stack trace in useful data structure instead of - // dumping the whole formatted backtrace as a JSON string: - try object.put("backtrace", request.response_data.string(array.items)); + try self.logger.ERROR("{s}", .{buf.items}); } fn requestLogMessage(self: *Self, request: *jetzig.http.Request) ![]const u8 { @@ -345,13 +339,16 @@ fn requestLogMessage(self: *Self, request: *jetzig.http.Request) ![]const u8 { ), }; - const formatted_duration = try jetzig.colors.duration(self.allocator, self.duration()); + const formatted_duration = if (self.logger.isColorized()) + try jetzig.colors.duration(self.allocator, self.duration()) + else + try std.fmt.allocPrint(self.allocator, "{}", .{self.duration()}); defer self.allocator.free(formatted_duration); return try std.fmt.allocPrint(self.allocator, "[{s}/{s}/{s}] {s}", .{ formatted_duration, - request.fmtMethod(), - status.format(), + request.fmtMethod(self.logger.isColorized()), + status.format(self.logger.isColorized()), request.path.path, }); } diff --git a/src/jetzig/http/Session.zig b/src/jetzig/http/Session.zig index 9dd55e3..343a4a5 100644 --- a/src/jetzig/http/Session.zig +++ b/src/jetzig/http/Session.zig @@ -3,7 +3,7 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); pub const cookie_name = "_jetzig-session"; -const Cipher = std.crypto.aead.aes_gcm.Aes256Gcm; +pub const Cipher = std.crypto.aead.aes_gcm.Aes256Gcm; allocator: std.mem.Allocator, encryption_key: ?[]const u8, @@ -99,7 +99,7 @@ fn save(self: *Self) !void { } self.encrypted = try self.encrypt(json); - const encoded = try jetzig.base64Encode(self.allocator, self.encrypted.?); + const encoded = try jetzig.util.base64Encode(self.allocator, self.encrypted.?); defer self.allocator.free(encoded); if (self.cookie) |*ptr| self.allocator.free(ptr.*.value); @@ -113,7 +113,7 @@ fn save(self: *Self) !void { fn parseSessionCookie(self: *Self, cookie_value: []const u8) !void { self.data = jetzig.data.Data.init(self.allocator); - const decoded = try jetzig.base64Decode(self.allocator, cookie_value); + const decoded = try jetzig.util.base64Decode(self.allocator, cookie_value); defer self.allocator.free(decoded); const buf = self.decrypt(decoded) catch |err| { diff --git a/src/jetzig/http/status_codes.zig b/src/jetzig/http/status_codes.zig index cc4bad6..f1e0008 100644 --- a/src/jetzig/http/status_codes.zig +++ b/src/jetzig/http/status_codes.zig @@ -73,11 +73,12 @@ pub fn StatusCodeType(comptime code: []const u8, comptime message: []const u8) t const Self = @This(); - pub fn format(self: Self) []const u8 { + pub fn format(self: Self, colorized: bool) []const u8 { _ = self; - const full_message = code ++ " " ++ message; + if (!colorized) return full_message; + if (std.mem.startsWith(u8, code, "2")) { return jetzig.colors.green(full_message); } else if (std.mem.startsWith(u8, code, "3")) { @@ -158,9 +159,9 @@ pub const TaggedStatusCode = union(StatusCode) { const Self = @This(); - pub fn format(self: Self) []const u8 { + pub fn format(self: Self, colorized: bool) []const u8 { return switch (self) { - inline else => |capture| capture.format(), + inline else => |capture| capture.format(colorized), }; } }; diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig index 53a4f6d..367678d 100644 --- a/src/jetzig/loggers.zig +++ b/src/jetzig/loggers.zig @@ -4,18 +4,56 @@ const Self = @This(); pub const DevelopmentLogger = @import("loggers/DevelopmentLogger.zig"); -const LogLevel = enum { - debug, -}; +pub const LogLevel = enum { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }; pub const Logger = union(enum) { development_logger: DevelopmentLogger, - pub fn debug(self: *Logger, comptime message: []const u8, args: anytype) void { + pub fn isColorized(self: Logger) bool { + switch (self) { + inline else => |logger| return logger.isColorized(), + } + } + + /// Log a TRACE level message to the configured logger. + pub fn TRACE(self: *const Logger, comptime message: []const u8, args: anytype) !void { switch (self.*) { - inline else => |*case| case.debug(message, args) catch |err| { - std.debug.print("{}\n", .{err}); - }, + inline else => |*logger| try logger.TRACE(message, args), + } + } + + /// Log a DEBUG level message to the configured logger. + pub fn DEBUG(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.DEBUG(message, args), + } + } + + /// Log an INFO level message to the configured logger. + pub fn INFO(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.INFO(message, args), + } + } + + /// Log a WARN level message to the configured logger. + pub fn WARN(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.WARN(message, args), + } + } + + /// Log an ERROR level message to the configured logger. + pub fn ERROR(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.ERROR(message, args), + } + } + + /// Log a FATAL level message to the configured logger. + pub fn FATAL(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.FATAL(message, args), } } }; diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index 90415e0..038ae92 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -1,19 +1,74 @@ const std = @import("std"); +const jetzig = @import("../../jetzig.zig"); + const Self = @This(); -const Timestamp = @import("../types/Timestamp.zig"); + +const Timestamp = jetzig.types.Timestamp; +const LogLevel = jetzig.loggers.LogLevel; allocator: std.mem.Allocator, +file: std.fs.File, +colorized: bool, -pub fn init(allocator: std.mem.Allocator) Self { - return .{ .allocator = allocator }; +pub fn init(allocator: std.mem.Allocator, file: std.fs.File) Self { + return .{ .allocator = allocator, .file = file, .colorized = file.isTty() }; } -pub fn debug(self: *Self, comptime message: []const u8, args: anytype) !void { +/// Return true if logger was initialized with colorization (i.e. if log file is a tty) +pub fn isColorized(self: Self) bool { + return self.colorized; +} + +/// Log a TRACE level message to the configured logger. +pub fn TRACE(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.DEBUG, message, args); +} + +/// Log a DEBUG level message to the configured logger. +pub fn DEBUG(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.DEBUG, message, args); +} + +/// Log an INFO level message to the configured logger. +pub fn INFO(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.INFO, message, args); +} + +/// Log a WARN level message to the configured logger. +pub fn WARN(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.WARN, message, args); +} + +/// Log an ERROR level message to the configured logger. +pub fn ERROR(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.ERROR, message, args); +} + +/// Log a FATAL level message to the configured logger. +pub fn FATAL(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.FATAL, message, args); +} + +pub fn log(self: Self, comptime level: LogLevel, comptime message: []const u8, args: anytype) !void { const output = try std.fmt.allocPrint(self.allocator, message, args); defer self.allocator.free(output); const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); const iso8601 = try timestamp.iso8601(); defer self.allocator.free(iso8601); - std.debug.print("[{s}] {s}\n", .{ iso8601, output }); + const writer = self.file.writer(); + const level_formatted = if (self.colorized) colorizedLogLevel(level) else @tagName(level); + try writer.print("{s: >5} [{s}] {s}\n", .{ level_formatted, iso8601, output }); + if (!self.file.isTty()) try self.file.sync(); +} + +fn colorizedLogLevel(comptime level: LogLevel) []const u8 { + return switch (level) { + .TRACE => jetzig.colors.white(@tagName(level)), + .DEBUG => jetzig.colors.cyan(@tagName(level)), + .INFO => jetzig.colors.blue(@tagName(level) ++ " "), + .WARN => jetzig.colors.yellow(@tagName(level) ++ " "), + .ERROR => jetzig.colors.red(@tagName(level)), + .FATAL => jetzig.colors.red(@tagName(level)), + }; } diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig index 453b5d0..ebedcfe 100644 --- a/src/jetzig/middleware/HtmxMiddleware.zig +++ b/src/jetzig/middleware/HtmxMiddleware.zig @@ -16,7 +16,7 @@ pub fn init(request: *jetzig.http.Request) !*Self { pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { _ = self; if (request.getHeader("HX-Target")) |target| { - request.server.logger.debug( + try request.server.logger.DEBUG( "[middleware-htmx] htmx request detected, disabling layout. (#{s})", .{target}, ); diff --git a/src/jetzig/util.zig b/src/jetzig/util.zig index 495beaa..0301b75 100644 --- a/src/jetzig/util.zig +++ b/src/jetzig/util.zig @@ -1,5 +1,6 @@ const std = @import("std"); +/// Compare two strings with case-insensitive matching. pub fn equalStringsCaseInsensitive(expected: []const u8, actual: []const u8) bool { if (expected.len != actual.len) return false; for (expected, actual) |expected_char, actual_char| { @@ -7,3 +8,39 @@ pub fn equalStringsCaseInsensitive(expected: []const u8, actual: []const u8) boo } return true; } + +/// Encode arbitrary input to Base64. +pub fn base64Encode(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { + const encoder = std.base64.Base64Encoder.init( + std.base64.url_safe_no_pad.alphabet_chars, + std.base64.url_safe_no_pad.pad_char, + ); + const size = encoder.calcSize(string.len); + const ptr = try allocator.alloc(u8, size); + _ = encoder.encode(ptr, string); + return ptr; +} + +/// Decode arbitrary input from Base64. +pub fn base64Decode(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { + const decoder = std.base64.Base64Decoder.init( + std.base64.url_safe_no_pad.alphabet_chars, + std.base64.url_safe_no_pad.pad_char, + ); + const size = try decoder.calcSizeForSlice(string); + const ptr = try allocator.alloc(u8, size); + try decoder.decode(ptr, string); + return ptr; +} + +/// Generate a secure random string of `len` characters (for cryptographic purposes). +pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const u8 { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var secret: [len]u8 = undefined; + + for (0..len) |index| { + secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)]; + } + + return try allocator.dupe(u8, &secret); +}