authorization, end to end tests, and fix config processing

This commit is contained in:
Emil Lerch 2025-04-01 12:02:39 -07:00
parent 0b810b0604
commit 02c37f086f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 116 additions and 63 deletions

View file

@ -15,9 +15,28 @@ pub fn main() !u8 {
const args = try parseArgs(allocator);
var parsed_config = try loadConfig(allocator, args.config_path);
const file = try std.fs.cwd().openFile(args.config_path, .{});
defer file.close();
const max_size = 1024 * 1024; // 1MB max config size
const content = try file.readToEndAlloc(allocator, max_size);
defer allocator.free(content);
const parsed_config = try parseConfig(allocator, content, try detectFileType(args.config_path));
defer parsed_config.deinit();
var config = parsed_config.value;
for (config.watchers) |watcher|
std.log.debug("Watching folder {s} for paths matching pattern '{s}'", .{ watcher.folder, watcher.path_pattern });
const api_key = std.process.getEnvVarOwned(allocator, "ST_EVENTS_AUTH") catch |err|
switch (err) {
error.EnvironmentVariableNotFound => {
std.log.err("ST_EVENTS_AUTH not set. Please set this variable and re-run", .{});
return 2;
},
else => return err,
};
defer allocator.free(api_key);
if (args.syncthing_url) |url| {
config.syncthing_url = url;
@ -26,12 +45,15 @@ pub fn main() !u8 {
const stdout = std.io.getStdOut().writer();
try stdout.print("Monitoring Syncthing events at {s}\n", .{config.syncthing_url});
var last_id: ?i64 = null;
while (true) {
var arena_alloc = std.heap.ArenaAllocator.init(allocator);
defer arena_alloc.deinit();
const arena = arena_alloc.allocator();
var poller = try EventPoller.init(arena, config);
var poller = try EventPoller.init(arena, api_key, config);
defer last_id = poller.last_id;
poller.last_id = last_id;
const events = poller.poll() catch |err| switch (err) {
error.Unauthorized => {
std.log.err("Not authorized to use syncthing. Please set ST_EVENTS_AUTH environment variable and try again", .{});
@ -50,7 +72,7 @@ pub fn main() !u8 {
for (events) |event| {
for (config.watchers) |watcher| {
if (watcher.matches(event.folder, event.path)) {
try stdout.print("Match found for {s}/{s}, executing command\n", .{ event.folder, event.path });
try stdout.print("Match found for folder {s}, path {s}, executing command\n\t{s}\n", .{ event.folder, event.path, watcher.command });
try lib.executeCommand(allocator, watcher.command, event);
}
}
@ -91,17 +113,6 @@ fn parseArgs(allocator: std.mem.Allocator) !Args {
return args;
}
fn loadConfig(allocator: std.mem.Allocator, path: []const u8) !std.json.Parsed(Config) {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const max_size = 1024 * 1024; // 1MB max config size
const content = try file.readToEndAlloc(allocator, max_size);
defer allocator.free(content);
return try parseConfig(allocator, content, try detectFileType(path));
}
const FileType = enum {
json,
zon,
@ -136,6 +147,9 @@ fn printUsage() void {
\\ --url <url> Override Syncthing URL from config
\\ --help Show this help message
\\
\\ST_EVENTS_AUTH environment variable must contain the auth token for
\\syncthing. This can be found in the syncthing UI by clicking Actions,
\\then settings, and copying the API Key variable
;
std.debug.print(usage, .{});
}

View file

@ -5,14 +5,7 @@ pub const Config = struct {
syncthing_url: []const u8 = "http://localhost:8384",
max_retries: usize = std.math.maxInt(usize),
retry_delay_ms: u32 = 1000,
watchers: []Watcher,
pub fn deinit(self: *Config, allocator: std.mem.Allocator) void {
for (self.watchers) |*watcher| {
watcher.deinit(allocator);
}
allocator.free(self.watchers);
}
watchers: []*Watcher,
};
pub const Watcher = struct {
@ -21,51 +14,45 @@ pub const Watcher = struct {
command: []const u8,
compiled_pattern: ?mvzr.Regex = null,
pub fn init(allocator: std.mem.Allocator, folder: []const u8, path_pattern: []const u8, command: []const u8) !Watcher {
var watcher = Watcher{
.folder = try allocator.dupe(u8, folder),
.path_pattern = try allocator.dupe(u8, path_pattern),
.command = try allocator.dupe(u8, command),
.compiled_pattern = null,
};
watcher.compiled_pattern = mvzr.compile(path_pattern);
return watcher;
}
pub fn deinit(self: *Watcher, allocator: std.mem.Allocator) void {
allocator.free(self.folder);
allocator.free(self.path_pattern);
allocator.free(self.command);
}
pub fn matches(self: *const Watcher, folder: []const u8, path: []const u8) bool {
pub fn matches(self: *Watcher, folder: []const u8, path: []const u8) bool {
if (!std.mem.eql(u8, folder, self.folder)) {
return false;
}
if (self.compiled_pattern) |pattern| {
return pattern.match(path) != null;
std.log.debug(
"Watcher match on folder {s}. Checking path {s} against pattern {s}",
.{ folder, path, self.path_pattern },
);
self.compiled_pattern = self.compiled_pattern orelse mvzr.compile(self.path_pattern);
if (self.compiled_pattern == null) {
std.log.err("watcher path_pattern failed to compile and will never match: {s}", .{self.path_pattern});
}
if (self.compiled_pattern) |pattern|
return pattern.isMatch(path);
return false;
}
};
pub const SyncthingEvent = struct {
id: i64,
type: []const u8,
data_type: []const u8,
folder: []const u8,
path: []const u8,
time: []const u8,
pub fn fromJson(allocator: std.mem.Allocator, value: std.json.Value) !SyncthingEvent {
const data = value.object.get("data").?.object;
return SyncthingEvent{
.id = value.object.get("id").?.integer,
.type = try allocator.dupe(u8, value.object.get("type").?.string),
.folder = try allocator.dupe(u8, value.object.get("folder").?.string),
.path = try allocator.dupe(u8, value.object.get("path").?.string),
.time = value.object.get("time").?.string,
.data_type = try allocator.dupe(u8, data.get("type").?.string),
.folder = try allocator.dupe(u8, data.get("folder").?.string),
.path = try allocator.dupe(u8, data.get("item").?.string),
};
}
pub fn deinit(self: *SyncthingEvent, allocator: std.mem.Allocator) void {
allocator.free(self.type);
allocator.free(self.data_type);
allocator.free(self.time);
allocator.free(self.folder);
allocator.free(self.path);
}
@ -75,12 +62,14 @@ pub const EventPoller = struct {
allocator: std.mem.Allocator,
config: Config,
last_id: ?i64,
api_key: []u8,
pub fn init(allocator: std.mem.Allocator, config: Config) !EventPoller {
pub fn init(allocator: std.mem.Allocator, api_key: []u8, config: Config) !EventPoller {
return .{
.allocator = allocator,
.config = config,
.last_id = null,
.api_key = api_key,
};
}
@ -91,6 +80,9 @@ pub const EventPoller = struct {
const aa = arena.allocator();
try client.initDefaultProxies(aa);
var auth_buf: [1024]u8 = undefined;
const auth = try std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{self.api_key});
var retry_count: usize = 0;
while (retry_count < self.config.max_retries) : (retry_count += 1) {
var url_buf: [1024]u8 = undefined;
@ -109,6 +101,9 @@ pub const EventPoller = struct {
const response = client.fetch(.{
.location = .{ .url = url },
.response_storage = .{ .dynamic = &al },
.headers = .{
.authorization = .{ .override = auth },
},
}) catch |err| {
std.log.err("HTTP request failed: {s}", .{@errorName(err)});
if (retry_count + 1 < self.config.max_retries) {
@ -131,6 +126,7 @@ pub const EventPoller = struct {
var events = std.ArrayList(SyncthingEvent).init(self.allocator);
errdefer events.deinit();
std.log.debug("Got event response:\n{s}", .{al.items});
const parsed = try std.json.parseFromSliceLeaky(std.json.Value, aa, al.items, .{});
const array = parsed.array;
@ -173,10 +169,12 @@ fn expandCommandVariables(allocator: std.mem.Allocator, command: []const u8, eve
const var_name = command[i + 2 .. j];
if (std.mem.eql(u8, var_name, "path")) {
try result.appendSlice(event.path);
} else if (std.mem.eql(u8, var_name, "id")) {
try std.fmt.format(result.writer(), "{d}", .{event.id});
} else if (std.mem.eql(u8, var_name, "folder")) {
try result.appendSlice(event.folder);
} else if (std.mem.eql(u8, var_name, "type")) {
try result.appendSlice(event.type);
} else if (std.mem.eql(u8, var_name, "data_type")) {
try result.appendSlice(event.data_type);
}
i = j + 1;
continue;
@ -224,8 +222,11 @@ test "event parsing" {
\\{
\\ "id": 123,
\\ "type": "ItemFinished",
\\ "data": {
\\ "folder": "default",
\\ "path": "test.txt"
\\ "item": "test.txt",
\\ "type": "file"
\\ }
\\}
;
@ -236,7 +237,7 @@ test "event parsing" {
defer event.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(i64, 123), event.id);
try std.testing.expectEqualStrings("ItemFinished", event.type);
try std.testing.expectEqualStrings("file", event.data_type);
try std.testing.expectEqualStrings("default", event.folder);
try std.testing.expectEqualStrings("test.txt", event.path);
}
@ -244,32 +245,70 @@ test "event parsing" {
test "command variable expansion" {
const event = SyncthingEvent{
.id = 1,
.type = "ItemFinished",
.data_type = "file",
.folder = "photos",
.path = "vacation.jpg",
};
const command = "convert ${path} -resize 800x600 thumb_${folder}_${type}.jpg";
const command = "convert ${path} -resize 800x600 thumb_${folder}_${id}.jpg";
const expanded = try expandCommandVariables(std.testing.allocator, command, event);
defer std.testing.allocator.free(expanded);
try std.testing.expectEqualStrings(
"convert vacation.jpg -resize 800x600 thumb_photos_ItemFinished.jpg",
"convert vacation.jpg -resize 800x600 thumb_photos_1.jpg",
expanded,
);
}
test "watcher pattern matching" {
var watcher = try Watcher.init(
std.testing.allocator,
"photos",
".*\\.jpe?g$",
"echo ${path}",
);
defer watcher.deinit(std.testing.allocator);
var watcher = Watcher{
.folder = "photos",
.path_pattern = ".*\\.jpe?g$",
.command = "echo ${path}",
};
try std.testing.expect(watcher.matches("photos", "test.jpg"));
try std.testing.expect(watcher.matches("photos", "test.jpeg"));
try std.testing.expect(!watcher.matches("photos", "test.png"));
try std.testing.expect(!watcher.matches("documents", "test.jpg"));
}
test "end to end config / event" {
const config_json =
\\{
\\ "syncthing_url": "http://test:8384",
\\ "max_retries": 3,
\\ "retry_delay_ms": 2000,
\\ "watchers": [
\\ {
\\ "folder": "default",
\\ "path_pattern": ".*\\.txt$",
\\ "command": "echo ${path}"
\\ }
\\ ]
\\}
;
const parsed = try std.json.parseFromSlice(Config, std.testing.allocator, config_json, .{});
defer parsed.deinit();
const config = parsed.value;
const event_json =
\\{
\\ "id": 123,
\\ "type": "ItemFinished",
\\ "data": {
\\ "folder": "default",
\\ "item": "blah/test.txt",
\\ "type": "file"
\\ }
\\}
;
var parsed_event = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, event_json, .{});
defer parsed_event.deinit();
var event = try SyncthingEvent.fromJson(std.testing.allocator, parsed_event.value);
defer event.deinit(std.testing.allocator);
try std.testing.expect(config.watchers[0].matches(event.folder, event.path));
}