Something went wrong. Try again.
Uploads documents to Paperless-ngx from the command line or from a Send to Paperless item in Nautilus.
Something went wrong. Try again.
22 kB · 524 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Where the Paperless server is and how to sign in to it, read from a ZON//! file://!//! .{//! .url = "https://paperless.example.com",//! .token = "0123456789abcdef0123456789abcdef01234567",//! }//!//! or, to keep the token out of the configuration -- in a file a secrets//! manager writes, say -- with `token_file` in place of `token`://!//! .{//! .url = "https://paperless.example.com",//! .token_file = "/run/user/1000/secrets/paperless-token",//! }//!//! `$NAME` and `${NAME}` in `token_file` are replaced with that environment//! variable, since secrets managers hand out paths such as//! `/run/user/$UID/secrets/paperless-token` or//! `${XDG_RUNTIME_DIR}/agenix/paperless-token` and expect a shell to expand//! them. `UID`, which shells set without exporting, is the process's real//! user ID when the environment does not have it. A relative `token_file` is//! relative to the directory the configuration file is in. Whitespace around//! the token in it, such as a final newline, is ignored.//!//! The file is found by `locate`: a path given outright, else//! `paperless-upload/config.zon` in the user's configuration directory as//! known-folders finds it -- `$XDG_CONFIG_HOME`, else `~/.config`, on Linux.//! The environment is used only to find the files; the server and the token//! are never taken from it.
const Config = @This();
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;const known_folders = @import("known_folders");
/// The server's base URL, with any path prefix it is mounted under. A/// trailing slash is removed.url: []const u8,/// An API token, from the user's profile in the Paperless web interface:/// as the configuration file gave it, or as read from its `token_file`.token: []const u8,
/// The configuration file as it is written: one of `token` and/// `token_file`, and not both.pub const File = struct { url: []const u8, token: ?[]const u8 = null, /// A file holding the token and nothing else. token_file: ?[]const u8 = null,};
/// The name of the file within the configuration directory.pub const relative_path = "paperless-upload" ++ std.fs.path.sep_str ++ "config.zon";
/// What the configuration file is found from.pub const Locate = struct { /// `--config PATH`: used as it is, and nothing else is tried. explicit: ?[]const u8 = null, /// The environment the configuration directory is found from: /// `XDG_CONFIG_HOME` and `HOME`, on Linux. environ: *const std.process.Environ.Map,};
/// The path of the configuration file, allocated in `arena`, or null when/// there is neither a path given nor a configuration directory to look in.pub fn locate(io: Io, arena: Allocator, where: Locate) known_folders.Error!?[]const u8 { if (where.explicit) |path| return path; const dir = try known_folders.getPath(io, arena, where.environ, .local_configuration) orelse return null; return try std.fs.path.join(arena, &.{ dir, relative_path });}
pub const LoadError = error{ /// No path was given and there is no home directory to look in. NoConfigPath, /// The file could not be opened or read. ReadFailed, /// The file is not a valid configuration. InvalidConfig, WriteFailed,} || known_folders.Error;
/// Reads and parses the configuration file, and the token file if it names/// one. Everything returned is allocated in `arena`. On failure a sentence or/// two saying what went wrong, fit to show a person, is written to `report`./// Whichever file holds the token is warned about, in `report`, if others can/// read it; it is loaded all the same.pub fn load( io: Io, gpa: Allocator, arena: Allocator, where: Locate, report: *Io.Writer,) LoadError!Config { const path = try locate(io, arena, where) orelse { try report.writeAll("no configuration file: there is no configuration directory (is HOME set?), and no path was given\n"); return error.NoConfigPath; };
const source = Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(64 * 1024), .of(u8), 0) catch |err| { switch (err) { error.FileNotFound => try report.print( \\{s}: no such file; create it with \\ \\.{{ \\ .url = "https://paperless.example.com", \\ .token = "<API token from your Paperless profile>", \\}} \\ \\or with .token_file = "<path to a file holding the token>" in place of .token \\ , .{path}), else => try report.print("{s}: cannot read: {t}\n", .{ path, err }), } return error.ReadFailed; };
const file = try parse(gpa, arena, source, path, report); const token = if (file.token) |token| token: { try warnIfReadable(io, path, report); break :token token; } else try readTokenFile(io, arena, where.environ, path, file.token_file.?, report);
return .{ .url = file.url, .token = token };}
/// The token in `token_file`, which has its variables expanded from/// `environ` and is relative to the directory of the configuration file at/// `config_path` unless it is absolute.fn readTokenFile( io: Io, arena: Allocator, environ: *const std.process.Environ.Map, config_path: []const u8, token_file: []const u8, report: *Io.Writer,) LoadError![]const u8 { var name: []const u8 = undefined; const expanded = expand(arena, environ, token_file, &name) catch |err| { switch (err) { error.OutOfMemory => |e| return e, error.UnsetVariable => try report.print("{s}: token_file {s}: ${s} is not set\n", .{ config_path, token_file, name }), error.UnterminatedVariable => try report.print("{s}: token_file {s}: ${{ without a closing }}\n", .{ config_path, token_file }), } return error.InvalidConfig; }; const path = if (std.fs.path.isAbsolute(expanded)) expanded else try std.fs.path.join(arena, &.{ std.fs.path.dirname(config_path) orelse ".", expanded });
const contents = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(4096)) catch |err| { try report.print("{s}: cannot read the token file {s}: {t}\n", .{ config_path, path, err }); return error.ReadFailed; }; const token = std.mem.trim(u8, contents, " \t\r\n"); if (token.len == 0) { try report.print("{s}: the token file is empty\n", .{path}); return error.InvalidConfig; } if (std.mem.findAny(u8, token, " \t\r\n") != null) { try report.print("{s}: the token file holds more than a token\n", .{path}); return error.InvalidConfig; } try warnIfReadable(io, path, report); return token;}
pub const ExpandError = error{ /// A variable named in the string is not in the environment. UnsetVariable, /// A `${` has no `}` after it. UnterminatedVariable,} || Allocator.Error;
/// `s` with each `$NAME` and `${NAME}` replaced by that variable from/// `environ`, allocated in `arena`. A `$` followed by neither a name nor `{`/// is kept as it is. `UID` is the process's real user ID when `environ` has/// no `UID`, since shells set it without exporting it. On/// `error.UnsetVariable` the variable's name is left in `unset`.pub fn expand( arena: Allocator, environ: *const std.process.Environ.Map, s: []const u8, unset: *[]const u8,) ExpandError![]const u8 { if (std.mem.findScalar(u8, s, '$') == null) return s;
var out: std.ArrayList(u8) = .empty; var i: usize = 0; while (i < s.len) { if (s[i] != '$' or i + 1 == s.len) { try out.append(arena, s[i]); i += 1; continue; } const name, const end = if (s[i + 1] == '{') braced: { const close = std.mem.findScalarPos(u8, s, i + 2, '}') orelse return error.UnterminatedVariable; break :braced .{ s[i + 2 .. close], close + 1 }; } else bare: { var j = i + 1; while (j < s.len and (std.ascii.isAlphanumeric(s[j]) or s[j] == '_')) j += 1; break :bare .{ s[i + 1 .. j], j }; }; if (name.len == 0 and s[i + 1] != '{') { try out.append(arena, '$'); i += 1; continue; } if (environ.get(name)) |value| { try out.appendSlice(arena, value); } else if (std.mem.eql(u8, name, "UID") and realUid() != null) { try out.print(arena, "{d}", .{realUid().?}); } else { unset.* = name; return error.UnsetVariable; } i = end; } return out.items;}
/// The process's real user ID, where there is such a thing.fn realUid() ?u32 { const builtin = @import("builtin"); if (builtin.link_libc and @hasDecl(std.c, "getuid")) return @intCast(std.c.getuid()); if (builtin.os.tag == .linux) return std.os.linux.getuid(); return null;}
fn warnIfReadable(io: Io, path: []const u8, report: *Io.Writer) Io.Writer.Error!void { if (groupOrWorldReadable(io, path)) { try report.print("warning: {s} holds an API token and can be read by other users; chmod 600 it\n", .{path}); }}
/// Parses configuration source, reporting problems against `path`. The/// result has exactly one of `token` and `token_file`.pub fn parse( gpa: Allocator, arena: Allocator, source: [:0]const u8, path: []const u8, report: *Io.Writer,) LoadError!File { var diagnostics: std.zon.parse.Diagnostics = undefined; var config = std.zon.parse.fromSlice(File, .{ .gpa = gpa, .arena = arena, .source = source, .diagnostics = &diagnostics, }) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.ParseZon => { try report.print("{f}", .{diagnostics.fmt(path)}); return error.InvalidConfig; }, };
config.url = std.mem.trimEnd(u8, config.url, "/"); if (!std.mem.startsWith(u8, config.url, "http://") and !std.mem.startsWith(u8, config.url, "https://")) { try report.print("{s}: url must start with http:// or https://\n", .{path}); return error.InvalidConfig; } if (config.token != null and config.token_file != null) { try report.print("{s}: give token or token_file, not both\n", .{path}); return error.InvalidConfig; } if (config.token == null and config.token_file == null) { try report.print("{s}: give the API token, as token or as token_file\n", .{path}); return error.InvalidConfig; } if (config.token) |token| if (token.len == 0) { try report.print("{s}: token is empty\n", .{path}); return error.InvalidConfig; }; if (config.token_file) |token_file| if (token_file.len == 0) { try report.print("{s}: token_file is empty\n", .{path}); return error.InvalidConfig; }; return config;}
fn groupOrWorldReadable(io: Io, path: []const u8) bool { if (!@hasDecl(Io.File.Permissions, "toMode")) return false; const stat = Io.Dir.cwd().statFile(io, path, .{}) catch return false; return stat.permissions.toMode() & 0o044 != 0;}
const testing = std.testing;
test "parse a good file" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit();
const config = try parse(testing.allocator, arena.allocator(), \\.{ \\ .url = "https://paperless.example.com/", \\ .token = "abc", \\} , "config.zon", &report.writer); try testing.expectEqualStrings("https://paperless.example.com", config.url); try testing.expectEqualStrings("abc", config.token.?); try testing.expectEqualStrings("", report.written());}
test "parse refuses both token and token_file, and neither" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit();
try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "https://p.example.com", .token = "abc", .token_file = "t" } , "config.zon", &report.writer)); try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "https://p.example.com" } , "config.zon", &report.writer));}
test "load reads a token file relative to the configuration file" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "token", .data = "abc123\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "config.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"token\" }", }); const path = try std.fs.path.join(arena.allocator(), &.{ ".zig-cache", "tmp", &tmp.sub_path, "config.zon" });
const config = try load(testing.io, testing.allocator, arena.allocator(), .{ .explicit = path, .environ = &environ }, &report.writer); try testing.expectEqualStrings("abc123", config.token);}
test "load refuses a token file that is missing, empty or not a token" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "empty", .data = "\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "two", .data = "abc def\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "missing.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"nothing\" }" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "empty.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"empty\" }" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "two.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"two\" }" });
const a = arena.allocator(); const dir = try std.fs.path.join(a, &.{ ".zig-cache", "tmp", &tmp.sub_path }); try testing.expectError(error.ReadFailed, load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "missing.zon" }), .environ = &environ }, &report.writer)); try testing.expectError(error.InvalidConfig, load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "empty.zon" }), .environ = &environ }, &report.writer)); try testing.expectError(error.InvalidConfig, load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "two.zon" }), .environ = &environ }, &report.writer));}
test expand { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); try environ.put("XDG_RUNTIME_DIR", "/run/user/1000"); try environ.put("UID", "1000"); try environ.put("A_1", "x"); var unset: []const u8 = undefined;
try testing.expectEqualStrings("/run/user/1000/secrets/t", try expand(a, &environ, "/run/user/$UID/secrets/t", &unset)); try testing.expectEqualStrings("/run/user/1000/agenix/t", try expand(a, &environ, "${XDG_RUNTIME_DIR}/agenix/t", &unset)); try testing.expectEqualStrings("x.y", try expand(a, &environ, "$A_1.y", &unset)); try testing.expectEqualStrings("xx", try expand(a, &environ, "${A_1}$A_1", &unset)); try testing.expectEqualStrings("a$/b$", try expand(a, &environ, "a$/b$", &unset)); try testing.expectEqualStrings("plain", try expand(a, &environ, "plain", &unset));
try testing.expectError(error.UnsetVariable, expand(a, &environ, "/x/$NOPE/y", &unset)); try testing.expectEqualStrings("NOPE", unset); try testing.expectError(error.UnsetVariable, expand(a, &environ, "${}", &unset)); try testing.expectError(error.UnterminatedVariable, expand(a, &environ, "${XDG_RUNTIME_DIR/t", &unset));}
test "expand takes UID from the process when the environment lacks it" { if (realUid() == null) return error.SkipZigTest; var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); var unset: []const u8 = undefined;
const expected = try std.fmt.allocPrint(arena.allocator(), "/run/user/{d}/t", .{realUid().?}); try testing.expectEqualStrings(expected, try expand(arena.allocator(), &environ, "/run/user/$UID/t", &unset));}
test "load expands variables in token_file" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit(); var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); try environ.put("NAME", "token");
try tmp.dir.writeFile(testing.io, .{ .sub_path = "token", .data = "abc123\n" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "config.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"${NAME}\" }", }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "unset.zon", .data = ".{ .url = \"https://p.example.com\", .token_file = \"$NOPE\" }", }); const a = arena.allocator(); const dir = try std.fs.path.join(a, &.{ ".zig-cache", "tmp", &tmp.sub_path });
const config = try load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "config.zon" }), .environ = &environ }, &report.writer); try testing.expectEqualStrings("abc123", config.token);
try testing.expectError(error.InvalidConfig, load(testing.io, testing.allocator, a, .{ .explicit = try std.fs.path.join(a, &.{ dir, "unset.zon" }), .environ = &environ }, &report.writer)); try testing.expect(std.mem.find(u8, report.written(), "$NOPE is not set") != null);}
test "parse refuses a missing field" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit();
try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .token = "abc" } , "config.zon", &report.writer)); try testing.expect(std.mem.find(u8, report.written(), "config.zon:1:") != null); try testing.expect(std.mem.find(u8, report.written(), "url") != null);}
test "parse refuses an unknown field" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit();
try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "https://p.example.com", .token = "abc", .tokne = "x" } , "config.zon", &report.writer)); try testing.expect(std.mem.find(u8, report.written(), "tokne") != null);}
test "parse refuses a url that is not http" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit();
try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "paperless.example.com", .token = "abc" } , "config.zon", &report.writer));}
test "parse refuses an empty token" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit();
try testing.expectError(error.InvalidConfig, parse(testing.allocator, arena.allocator(), \\.{ .url = "https://p.example.com", .token = "" } , "config.zon", &report.writer));}
test locate { if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator();
var empty: std.process.Environ.Map = .init(testing.allocator); defer empty.deinit(); var home: std.process.Environ.Map = .init(testing.allocator); defer home.deinit(); try home.put("HOME", "/home/u"); var xdg: std.process.Environ.Map = .init(testing.allocator); defer xdg.deinit(); try xdg.put("HOME", "/home/u"); try xdg.put("XDG_CONFIG_HOME", "/xdg");
try testing.expectEqualStrings("/etc/x.zon", (try locate(testing.io, a, .{ .explicit = "/etc/x.zon", .environ = &xdg })).?); try testing.expectEqualStrings("/xdg/paperless-upload/config.zon", (try locate(testing.io, a, .{ .environ = &xdg })).?); try testing.expectEqualStrings("/home/u/.config/paperless-upload/config.zon", (try locate(testing.io, a, .{ .environ = &home })).?); try testing.expectEqual(null, try locate(testing.io, a, .{ .environ = &empty }));}
test "load reports a missing file" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var report: Io.Writer.Allocating = .init(testing.allocator); defer report.deinit();
var environ: std.process.Environ.Map = .init(testing.allocator); defer environ.deinit(); try testing.expectError(error.ReadFailed, load( testing.io, testing.allocator, arena.allocator(), .{ .explicit = "/nonexistent/paperless-upload/config.zon", .environ = &environ }, &report.writer, )); try testing.expect(std.mem.find(u8, report.written(), "no such file") != null);}