From 02c37f086f62f30f5ed59b7cc3062918c1931372 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 1 Apr 2025 12:02:39 -0700 Subject: [PATCH] authorization, end to end tests, and fix config processing --- src/main.zig | 42 ++++++++++------ src/root.zig | 137 +++++++++++++++++++++++++++++++++------------------ 2 files changed, 116 insertions(+), 63 deletions(-) diff --git a/src/main.zig b/src/main.zig index c996acf..e44bcd5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 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, .{}); } diff --git a/src/root.zig b/src/root.zig index 7c81bed..5df936e 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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", - \\ "folder": "default", - \\ "path": "test.txt" + \\ "data": { + \\ "folder": "default", + \\ "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)); +}