Compare commits

..

11 Commits

Author SHA1 Message Date
Bob Farrell
1feb18fb74 Add note to README about Zig compatibility 2025-04-26 18:14:50 +01:00
Bob Farrell
86d82026ab Fix incorrect email address format 2025-04-21 14:46:53 +01:00
bobf
01d0862043
Merge pull request #190 from PotatoesMaster/htmx-middleware-vary-header
HtmxMiddleware: add Vary header to prevent caching issues
2025-04-20 15:41:26 +01:00
Emanuel Guével
95d3f10bc9 HtmxMiddleware: add Vary header to prevent caching issues
See https://htmx.org/docs/#caching
2025-04-20 16:06:55 +02:00
Bob Farrell
dee5701b4a Never sync stdout/stderr
Detecting if stdout/stderr is tty seems to cause issues when running in
Docker (in particular with fly.io)
2025-04-19 21:41:27 +01:00
Bob Farrell
b7c3c0045a Fix missing @tagName after nameCast changes 2025-04-19 20:16:31 +01:00
bobf
6d9fa8bff4
Merge pull request #189 from jetzig-framework/zig-alignment-changes
Use Alignment arg instead of integer
2025-04-19 19:57:34 +01:00
bobf
8171ab5b5d
Merge pull request #188 from uzyn/custom-session-name
Overriding of default session cookie name
2025-04-17 07:01:16 +01:00
Bob Farrell
aae9fd182b Use Server.env.vars to get session cookie name 2025-04-16 19:29:44 +01:00
U-Zyn Chua
4c568f2606
Minor typo fix 2025-04-16 15:59:45 +08:00
U-Zyn Chua
94cf122847
Allow setting of custom session cookie name. 2025-04-16 15:59:18 +08:00
12 changed files with 134 additions and 56 deletions

View File

@ -6,6 +6,8 @@ _Jetzig_ is a web framework written in 100% pure [Zig](https://ziglang.org) :liz
Official website: [jetzig.dev](https://www.jetzig.dev/)
Please note that _Jetzig_'s `main` branch aims to be compatible with the latest [Zig nightly master build](https://ziglang.org/download/) and older versions of _Zig_ are not supported.
_Jetzig_ aims to provide a rich set of user-friendly tools for building modern web applications quickly. See the checklist below.
Join us on Discord ! [https://discord.gg/eufqssz7X6](https://discord.gg/eufqssz7X6).

View File

@ -7,4 +7,4 @@
<input type="submit" value="Submit Spam" />
</form>
<div>Try clearing `_jetzig_session` cookie before clicking "Submit Spam"</div>
<div>Try clearing `_jetzig-session` cookie before clicking "Submit Spam"</div>

View File

@ -196,15 +196,15 @@ pub fn init(parent_allocator: std.mem.Allocator, env_options: EnvironmentOptions
const env_file = std.fs.cwd().openFile(options.options.@"env-file", .{}) catch |err|
switch (err) {
error.FileNotFound => null,
else => return err,
};
error.FileNotFound => null,
else => return err,
};
const vars = try Vars.init(allocator, env_file);
var launch_logger = LaunchLogger{
.stdout = stdout,
.stderr = stderr,
.stdout = stdout.file,
.stderr = stderr.file,
.silent = env_options.silent,
};
@ -213,8 +213,8 @@ pub fn init(parent_allocator: std.mem.Allocator, env_options: EnvironmentOptions
.development_logger = jetzig.loggers.DevelopmentLogger.init(
allocator,
resolveLogLevel(options.options.@"log-level", jetzig.environment),
stdout,
stderr,
stdout.file,
stderr.file,
),
},
.production => jetzig.loggers.Logger{
@ -304,23 +304,26 @@ pub fn deinit(self: Environment) void {
self.parent_allocator.destroy(self.arena);
}
fn getLogFile(stream: enum { stdout, stderr }, options: Options) !std.fs.File {
fn getLogFile(stream: enum { stdout, stderr }, options: Options) !jetzig.loggers.LogFile {
const path = switch (stream) {
.stdout => options.log,
.stderr => options.@"log-error",
};
if (std.mem.eql(u8, path, "-")) return switch (stream) {
.stdout => std.io.getStdOut(),
.stdout => .{ .file = std.io.getStdOut(), .sync = false },
.stderr => if (std.mem.eql(u8, options.log, "-"))
std.io.getStdErr()
.{ .file = std.io.getStdErr(), .sync = false }
else
try std.fs.createFileAbsolute(options.log, .{ .truncate = false }),
.{
.file = try std.fs.createFileAbsolute(options.log, .{ .truncate = false }),
.sync = true,
},
};
const file = try std.fs.createFileAbsolute(path, .{ .truncate = false });
try file.seekFromEnd(0);
return file;
return .{ .file = file, .sync = true };
}
fn getSecret(

View File

@ -14,7 +14,7 @@ pub fn getUserId(comptime id_type: IdType, request: *jetzig.Request) !?switch (i
} {
const session = try request.session();
return session.getT(@field(jetzig.data.ValueType, id_type), "_jetzig_user_id");
return session.getT(@field(jetzig.data.ValueType, @tagName(id_type)), "_jetzig_user_id");
}
pub fn signIn(request: *jetzig.Request, user_id: anytype) !void {

View File

@ -497,12 +497,14 @@ pub fn cookies(self: *Request) !*jetzig.http.Cookies {
/// `jetzig.http.Session`.
pub fn session(self: *Request) !*jetzig.http.Session {
if (self._session) |capture| return capture;
const cookie_name = self.server.env.vars.get("JETZIG_SESSION_COOKIE") orelse
jetzig.http.Session.default_cookie_name;
const local_session = try self.allocator.create(jetzig.http.Session);
local_session.* = jetzig.http.Session.init(
self.allocator,
try self.cookies(),
self.server.env.secret,
.{ .cookie_name = cookie_name },
);
local_session.parse() catch |err| {
switch (err) {

View File

@ -2,12 +2,12 @@ const std = @import("std");
const jetzig = @import("../../jetzig.zig");
pub const cookie_name = "_jetzig-session";
pub const Cipher = std.crypto.aead.chacha_poly.XChaCha20Poly1305;
allocator: std.mem.Allocator,
encryption_key: []const u8,
cookies: *jetzig.http.Cookies,
cookie_name: []const u8,
initialized: bool = false,
data: jetzig.data.Data,
@ -15,22 +15,30 @@ state: enum { parsed, pending } = .pending,
const Self = @This();
pub const default_cookie_name = "_jetzig-session";
pub const Options = struct {
cookie_name: []const u8 = default_cookie_name,
};
pub fn init(
allocator: std.mem.Allocator,
cookies: *jetzig.http.Cookies,
encryption_key: []const u8,
options: Options,
) Self {
return .{
.allocator = allocator,
.data = jetzig.data.Data.init(allocator),
.cookies = cookies,
.cookie_name = options.cookie_name,
.encryption_key = encryption_key,
};
}
/// Parse session cookie.
pub fn parse(self: *Self) !void {
if (self.cookies.get(cookie_name)) |cookie| {
if (self.cookies.get(self.cookie_name)) |cookie| {
try self.parseSessionCookie(cookie.value);
} else {
try self.reset();
@ -111,7 +119,7 @@ fn save(self: *Self) !void {
defer self.allocator.free(encrypted);
const encoded = try jetzig.util.base64Encode(self.allocator, encrypted);
defer self.allocator.free(encoded);
try self.cookies.put(.{ .name = cookie_name, .value = encoded });
try self.cookies.put(.{ .name = self.cookie_name, .value = encoded });
}
fn parseSessionCookie(self: *Self, cookie_value: []const u8) !void {
@ -180,7 +188,7 @@ test "put and get session key/value" {
try cookies.parse();
const secret: [Cipher.key_length]u8 = [_]u8{0x69} ** Cipher.key_length;
var session = Self.init(allocator, &cookies, &secret);
var session = Self.init(allocator, &cookies, &secret, .{});
defer session.deinit();
var data = jetzig.data.Data.init(allocator);
@ -199,7 +207,7 @@ test "remove session key/value" {
try cookies.parse();
const secret: [Cipher.key_length]u8 = [_]u8{0x69} ** Cipher.key_length;
var session = Self.init(allocator, &cookies, &secret);
var session = Self.init(allocator, &cookies, &secret, .{});
defer session.deinit();
var data = jetzig.data.Data.init(allocator);
@ -224,7 +232,7 @@ test "get value from parsed/decrypted cookie" {
try cookies.parse();
const secret: [Cipher.key_length]u8 = [_]u8{0x69} ** Cipher.key_length;
var session = Self.init(allocator, &cookies, &secret);
var session = Self.init(allocator, &cookies, &secret, .{});
defer session.deinit();
try session.parse();
@ -233,17 +241,32 @@ test "get value from parsed/decrypted cookie" {
}
test "invalid cookie value - too short" {
const allocator = std.testing.allocator;
var cookies = jetzig.http.Cookies.init(allocator, "_jetzig-session=abc");
defer cookies.deinit();
try cookies.parse();
const secret: [Cipher.key_length]u8 = [_]u8{0x69} ** Cipher.key_length;
var session = Self.init(allocator, &cookies, &secret, .{});
defer session.deinit();
try std.testing.expectError(error.JetzigInvalidSessionCookie, session.parse());
}
test "custom session cookie name" {
const allocator = std.testing.allocator;
var cookies = jetzig.http.Cookies.init(
allocator,
"_jetzig-session=abc",
"custom-cookie-name=fPCFwZHvPDT-XCVcsQUSspDLchS3tRuJDqPpB2v3127VXpRP_bPcPLgpHK6RiVkfcP1bMtU",
);
defer cookies.deinit();
try cookies.parse();
const secret: [Cipher.key_length]u8 = [_]u8{0x69} ** Cipher.key_length;
var session = Self.init(allocator, &cookies, &secret);
var session = Self.init(allocator, &cookies, &secret, .{ .cookie_name = "custom-cookie-name" });
defer session.deinit();
try std.testing.expectError(error.JetzigInvalidSessionCookie, session.parse());
try session.parse();
var value = (session.get("foo")).?;
try std.testing.expectEqualStrings("bar", try value.toString());
}

View File

@ -12,6 +12,11 @@ pub const NullLogger = @import("loggers/NullLogger.zig");
pub const LogQueue = @import("loggers/LogQueue.zig");
pub const LogFile = struct {
file: std.fs.File,
sync: bool = false,
};
pub const LogLevel = enum(u4) { TRACE, DEBUG, INFO, WARN, ERROR, FATAL };
pub const LogFormat = enum { development, production, json, null };

View File

@ -70,7 +70,11 @@ pub fn deinit(self: *LogQueue) void {
}
/// Set the stdout and stderr outputs. Must be called before `print`.
pub fn setFiles(self: *LogQueue, stdout_file: std.fs.File, stderr_file: std.fs.File) !void {
pub fn setFiles(
self: *LogQueue,
stdout_file: jetzig.loggers.LogFile,
stderr_file: jetzig.loggers.LogFile,
) !void {
self.writer = Writer{
.queue = self,
.mutex = std.Thread.Mutex{},
@ -80,11 +84,11 @@ pub fn setFiles(self: *LogQueue, stdout_file: std.fs.File, stderr_file: std.fs.F
.stderr_file = stderr_file,
.queue = self,
};
self.stdout_is_tty = stdout_file.isTty();
self.stderr_is_tty = stderr_file.isTty();
self.stdout_is_tty = stdout_file.file.isTty();
self.stderr_is_tty = stderr_file.file.isTty();
self.stdout_colorize = std.io.tty.detectConfig(stdout_file) != .no_color;
self.stderr_colorize = std.io.tty.detectConfig(stderr_file) != .no_color;
self.stdout_colorize = std.io.tty.detectConfig(stdout_file.file) != .no_color;
self.stderr_colorize = std.io.tty.detectConfig(stderr_file.file) != .no_color;
self.state = .ready;
}
@ -147,8 +151,8 @@ pub const Writer = struct {
/// Reader for `LogQueue`. Reads log events from the queue and writes them to the designated
/// target (stdout or stderr).
pub const Reader = struct {
stdout_file: std.fs.File,
stderr_file: std.fs.File,
stdout_file: jetzig.loggers.LogFile,
stderr_file: jetzig.loggers.LogFile,
queue: *LogQueue,
pub const PublishOptions = struct {
@ -160,8 +164,8 @@ pub const Reader = struct {
pub fn publish(self: *Reader, options: PublishOptions) !void {
std.debug.assert(self.queue.state == .ready);
const stdout_writer = self.stdout_file.writer();
const stderr_writer = self.stderr_file.writer();
const stdout_writer = self.stdout_file.file.writer();
const stderr_writer = self.stderr_file.file.writer();
while (true) {
self.queue.condition_mutex.lock();
@ -181,13 +185,13 @@ pub const Reader = struct {
.stdout => {
stdout_written = true;
if (builtin.os.tag == .windows) {
file = self.stdout_file;
file = self.stdout_file.file;
}
},
.stderr => {
stderr_written = true;
if (builtin.os.tag == .windows) {
file = self.stderr_file;
file = self.stderr_file.file;
}
},
}
@ -220,8 +224,8 @@ pub const Reader = struct {
}
}
if (stdout_written and !self.queue.stdout_is_tty) try self.stdout_file.sync();
if (stderr_written and !self.queue.stderr_is_tty) try self.stderr_file.sync();
if (stdout_written and self.stdout_file.sync) try self.stdout_file.file.sync();
if (stderr_written and self.stderr_file.sync) try self.stderr_file.file.sync();
if (options.oneshot) break;
}
@ -289,7 +293,7 @@ test "print to stdout and stderr" {
const stderr = try tmp_dir.dir.createFile("stderr.log", .{ .read = true });
defer stderr.close();
try log_queue.setFiles(stdout, stderr);
try log_queue.setFiles(.{ .file = stdout }, .{ .file = stderr });
try log_queue.print("foo {s}\n", .{"bar"}, .stdout);
try log_queue.print("baz {s}\n", .{"qux"}, .stderr);
try log_queue.print("quux {s}\n", .{"corge"}, .stdout);
@ -333,7 +337,7 @@ test "long messages" {
const stderr = try tmp_dir.dir.createFile("stderr.log", .{ .read = true });
defer stderr.close();
try log_queue.setFiles(stdout, stderr);
try log_queue.setFiles(.{ .file = stdout }, .{ .file = stderr });
try log_queue.print("foo" ** buffer_size, .{}, .stdout);
try log_queue.reader.publish(.{ .oneshot = true });

View File

@ -165,7 +165,7 @@ test "HTML part only" {
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: <Bob> user@example.com
\\From: Bob <user@example.com>
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
@ -201,7 +201,7 @@ test "text part only" {
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: <Bob> user@example.com
\\From: Bob <user@example.com>
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
@ -238,7 +238,7 @@ test "HTML and text parts" {
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: <Bob> user@example.com
\\From: Bob <user@example.com>
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
@ -262,6 +262,41 @@ test "HTML and text parts" {
try std.testing.expectEqualStrings(expected, actual);
}
test "default email address name" {
const mail = Mail{
.allocator = std.testing.allocator,
.env = undefined,
.config = .{},
.boundary = 123456789,
.params = .{
.from = .{ .email = "user@example.com" },
.to = &.{.{ .email = "user@example.com" }},
.subject = "Test subject",
.text = "Hello",
},
};
const actual = try generateData(mail);
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: user@example.com <user@example.com>
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
\\--=_alternative_123456789
\\Content-Type: text/plain; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\Hello
\\
\\.
\\
, "\n", "\r\n");
defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, actual);
}
test "long content encoding" {
const mail = Mail{
.allocator = std.testing.allocator,
@ -281,7 +316,7 @@ test "long content encoding" {
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: <Bob> user@example.com
\\From: Bob <user@example.com>
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
@ -332,7 +367,7 @@ test "non-latin alphabet encoding" {
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: <Bob> user@example.com
\\From: Bob <user@example.com>
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"

View File

@ -22,7 +22,7 @@ pub const Address = struct {
email: []const u8,
pub fn format(address: Address, _: anytype, _: anytype, writer: anytype) !void {
try writer.print("<{?s}> {s}", .{ address.name, address.email });
try writer.print("{s} <{s}>", .{ address.name orelse address.email, address.email });
}
};

View File

@ -19,17 +19,21 @@ pub fn afterRequest(request: *jetzig.http.Request) !void {
/// If a redirect was issued during request processing, reset any response data, set response
/// status to `200 OK` and replace the `Location` header with a `HX-Redirect` header.
/// Add Vary response header to prevent caching the page without layout for requests not coming
/// from htmx.
pub fn beforeResponse(request: *jetzig.http.Request, response: *jetzig.http.Response) !void {
switch (response.status_code) {
.moved_permanently, .found => {},
else => return,
}
if (request.headers.get("HX-Request") == null) return;
if (response.headers.get("Location")) |location| {
response.status_code = .ok;
request.response_data.reset();
try response.headers.append("HX-Redirect", location);
switch (response.status_code) {
.moved_permanently, .found => {
if (response.headers.get("Location")) |location| {
response.status_code = .ok;
request.response_data.reset();
try response.headers.append("HX-Redirect", location);
}
},
else => {
try response.headers.append("Vary", "HX-Request");
},
}
}

View File

@ -55,7 +55,7 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
try cookies.parse();
const session = try alloc.create(jetzig.http.Session);
session.* = jetzig.http.Session.init(alloc, cookies, jetzig.testing.secret);
session.* = jetzig.http.Session.init(alloc, cookies, jetzig.testing.secret, .{});
app.* = App{
.arena = arena,
@ -237,7 +237,7 @@ 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);
local_session.* = jetzig.http.Session.init(allocator, self.cookies, jetzig.testing.secret, .{});
try local_session.parse();
self.session = local_session;