diff --git a/src/Config.zig b/src/Config.zig index 30692516..928b0952 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -177,6 +177,13 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 { }; } +pub fn cookiesFile(self: *const Config) ?[]const u8 { + return switch (self.mode) { + inline .serve, .fetch, .mcp => |opts| opts.common.cookies_file, + else => null, + }; +} + pub fn cdpTimeout(self: *const Config) usize { return switch (self.mode) { .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, @@ -310,6 +317,7 @@ pub const Common = struct { user_agent_suffix: ?[]const u8 = null, user_agent: ?[]const u8 = null, http_cache_dir: ?[]const u8 = null, + cookies_file: ?[]const u8 = null, web_bot_auth_key_file: ?[]const u8 = null, web_bot_auth_keyid: ?[]const u8 = null, @@ -465,6 +473,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Path to a directory to use as a Filesystem Cache for network resources. \\ Omitting this will result is no caching. \\ Defaults to no caching. + \\ + \\--cookies-file + \\ Path to a JSON file for cookie persistence. Cookies are loaded + \\ from this file at startup and saved back on exit. + \\ Format: [{name, value, domain, path, expires, secure, httpOnly}] + \\ Defaults to no persistence. ; // MAX_HELP_LEN| @@ -1185,6 +1199,15 @@ fn parseCommonArg( return true; } + if (std.mem.eql(u8, "--cookies-file", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--cookies-file" }); + return error.InvalidArgument; + }; + common.cookies_file = try allocator.dupe(u8, str); + return true; + } + return false; } diff --git a/src/cookies.zig b/src/cookies.zig new file mode 100644 index 00000000..9d6eb475 --- /dev/null +++ b/src/cookies.zig @@ -0,0 +1,120 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Cookie = @import("browser/webapi/storage/Cookie.zig"); +const log = @import("log.zig"); + +/// Load cookies from a JSON file into the cookie jar. +/// The file format is an array of objects with: name, value, domain, path, +/// expires (optional, float), secure (optional, bool), httpOnly (optional, bool). +/// This matches the CDP Network.Cookie format used by Puppeteer and Playwright. +pub fn loadFromFile(jar: *Cookie.Jar, path: []const u8) !void { + const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { + error.FileNotFound => return, // No file yet, nothing to load + else => { + log.err(.app, "failed to open cookies file", .{ .path = path, .err = err }); + return err; + }, + }; + defer file.close(); + + const content = file.readToEndAlloc(jar.allocator, 1024 * 1024) catch |err| { + log.err(.app, "failed to read cookies file", .{ .path = path, .err = err }); + return err; + }; + defer jar.allocator.free(content); + + const parsed = std.json.parseFromSlice([]const JsonCookie, jar.allocator, content, .{ + .ignore_unknown_fields = true, + }) catch |err| { + log.err(.app, "failed to parse cookies JSON", .{ .path = path, .err = err }); + return err; + }; + defer parsed.deinit(); + + var loaded: usize = 0; + for (parsed.value) |jc| { + var arena = std.heap.ArenaAllocator.init(jar.allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + + const cookie = Cookie{ + .arena = arena, + .name = try a.dupe(u8, jc.name), + .value = try a.dupe(u8, jc.value), + .domain = try a.dupe(u8, jc.domain), + .path = try a.dupe(u8, jc.path orelse "/"), + .expires = jc.expires, + .secure = jc.secure orelse false, + .http_only = jc.httpOnly orelse false, + .same_site = .none, + }; + + jar.add(cookie, std.time.timestamp()) catch |err| { + cookie.deinit(); + log.warn(.app, "skipping cookie", .{ .name = jc.name, .err = err }); + continue; + }; + loaded += 1; + } + + log.info(.app, "loaded cookies from file", .{ .path = path, .count = loaded }); +} + +/// Save all cookies from the jar to a JSON file. +pub fn saveToFile(jar: *Cookie.Jar, path: []const u8) !void { + jar.removeExpired(null); + + var file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + + var bw = std.io.bufferedWriter(file.writer()); + const writer = bw.writer(); + + try writer.writeByte('['); + for (jar.cookies.items, 0..) |c, i| { + if (i > 0) try writer.writeByte(','); + try writer.writeAll("\n "); + try std.json.stringify(JsonCookie{ + .name = c.name, + .value = c.value, + .domain = c.domain, + .path = c.path, + .expires = c.expires, + .secure = c.secure, + .httpOnly = c.http_only, + }, .{}, writer); + } + if (jar.cookies.items.len > 0) { + try writer.writeByte('\n'); + } + try writer.writeAll("]\n"); + try bw.flush(); + + log.info(.app, "saved cookies to file", .{ .path = path, .count = jar.cookies.items.len }); +} + +const JsonCookie = struct { + name: []const u8, + value: []const u8, + domain: []const u8, + path: ?[]const u8 = "/", + expires: ?f64 = null, + secure: ?bool = null, + httpOnly: ?bool = null, +}; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index b0356e93..64308f91 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -40,6 +40,7 @@ pub const forms = @import("browser/forms.zig"); pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); pub const mcp = @import("mcp.zig"); +pub const cookies = @import("cookies.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); @@ -66,6 +67,19 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { defer browser.deinit(); var session = try browser.newSession(notification); + + // Load cookies from file if --cookies-file was specified, save on exit. + if (app.config.cookiesFile()) |cookies_path| { + cookies.loadFromFile(&session.cookie_jar, cookies_path) catch |err| { + log.err(.app, "cookie load error", .{ .err = err }); + }; + defer { + cookies.saveToFile(&session.cookie_jar, cookies_path) catch |err| { + log.err(.app, "cookie save error", .{ .err = err }); + }; + } + } + const page = try session.createPage(); // // Comment this out to get a profile of the JS code in v8/profile.json.