diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs index 2c9b37f..5ca1c4e 100644 --- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs +++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs @@ -23,6 +23,21 @@ public static class AdminOps public const string BindHttps = "bind-https"; public const string RestartListener = "restart-listener"; public const string Ping = "ping"; + public const string ListBackups = "list-backups"; + public const string RestoreBackup = "restore-backup"; + public const string ImportConfig = "import-config"; +} + +public sealed class BackupEntry +{ + public string FileName { get; set; } = ""; + public DateTimeOffset SavedAt { get; set; } + public long SizeBytes { get; set; } +} + +public sealed class RestoreBackupArgs +{ + public string FileName { get; set; } = ""; } public sealed class AdminRequest diff --git a/src/WebhookServer.Core/Storage/ConfigStore.cs b/src/WebhookServer.Core/Storage/ConfigStore.cs index 7e8b9a4..2f6daa3 100644 --- a/src/WebhookServer.Core/Storage/ConfigStore.cs +++ b/src/WebhookServer.Core/Storage/ConfigStore.cs @@ -38,6 +38,25 @@ public sealed class ConfigStore var dir = System.IO.Path.GetDirectoryName(Path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + // Snapshot the previous config (if any) into the backups folder before + // overwriting. Cheap insurance against typos in the GUI. + if (File.Exists(Path) && !string.IsNullOrEmpty(dir)) + { + try + { + var backupsDir = System.IO.Path.Combine(dir, "backups"); + Directory.CreateDirectory(backupsDir); + var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var backupPath = System.IO.Path.Combine(backupsDir, $"config-{stamp}.json"); + File.Copy(Path, backupPath, overwrite: false); + PruneBackups(backupsDir, retain: 30); + } + catch + { + // Backup is best-effort; don't fail the save if it can't write. + } + } + var tmp = Path + ".tmp"; await using (var fs = File.Create(tmp)) { @@ -49,6 +68,17 @@ public sealed class ConfigStore File.Move(tmp, Path, overwrite: true); } + private static void PruneBackups(string backupsDir, int retain) + { + var stale = new DirectoryInfo(backupsDir).GetFiles("config-*.json") + .OrderByDescending(f => f.Name) + .Skip(retain); + foreach (var f in stale) + { + try { f.Delete(); } catch { } + } + } + public static void ClearPlaintexts(ServerConfig config) { foreach (var ep in config.Endpoints) diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index 6dcf6eb..a2ff565 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -27,9 +27,26 @@ - - - + + + + + + + diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs index c825e1e..de2a239 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml.cs +++ b/src/WebhookServer.Gui/MainWindow.xaml.cs @@ -52,4 +52,10 @@ public partial class MainWindow : Window if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null)) vm.EditEndpointCommand.Execute(null); } + + private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel vm) + await vm.RefreshBackupsCommand.ExecuteAsync(null); + } } diff --git a/src/WebhookServer.Gui/Services/AdminPipeClient.cs b/src/WebhookServer.Gui/Services/AdminPipeClient.cs index cc56d04..4ce804e 100644 --- a/src/WebhookServer.Gui/Services/AdminPipeClient.cs +++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs @@ -86,4 +86,18 @@ public sealed class AdminPipeClient var lst = resp.Data.Value.GetProperty("lines").Deserialize>(AdminProtocol.JsonOptions); return lst ?? new List(); } + + public async Task> ListBackupsAsync(CancellationToken ct = default) + { + var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false); + if (!resp.Ok || resp.Data is null) return new List(); + var lst = resp.Data.Value.GetProperty("backups").Deserialize>(AdminProtocol.JsonOptions); + return lst ?? new List(); + } + + public Task RestoreBackupAsync(string fileName, CancellationToken ct = default) => + InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct); + + public Task ImportConfigAsync(ServerConfig config, CancellationToken ct = default) => + InvokeAsync(AdminOps.ImportConfig, config, ct); } diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index 297fd91..69b8d11 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -175,6 +175,92 @@ public sealed partial class MainViewModel : ObservableObject } } + [ObservableProperty] private System.Collections.ObjectModel.ObservableCollection _backups = new(); + + [RelayCommand] + private async Task RefreshBackupsAsync() + { + try + { + var list = await _client.ListBackupsAsync().ConfigureAwait(false); + Application.Current.Dispatcher.Invoke(() => + { + Backups.Clear(); + foreach (var b in list) Backups.Add(b); + }); + } + catch { /* ignore - backup listing isn't critical */ } + } + + [RelayCommand] + private async Task RestoreBackupAsync(BackupEntry? entry) + { + if (entry is null) return; + var ok = MessageBox.Show( + $"Restore configuration from {entry.FileName} ({entry.SavedAt:yyyy-MM-dd HH:mm})?\n\nA backup of the current config will be saved first.", + "Restore backup", + MessageBoxButton.OKCancel, + MessageBoxImage.Question); + if (ok != MessageBoxResult.OK) return; + try + { + await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false); + await RefreshAsync().ConfigureAwait(false); + } + catch (Exception ex) { ShowError("Restore failed", ex); } + } + + [RelayCommand] + private async Task ExportConfigAsync() + { + try + { + var snap = await _client.GetConfigAsync().ConfigureAwait(false); + if (snap is null) { ShowError("Export failed", new InvalidOperationException("Service did not return a config.")); return; } + + var dlg = new Microsoft.Win32.SaveFileDialog + { + FileName = $"webhook-server-config-{DateTime.Now:yyyyMMdd-HHmmss}.json", + DefaultExt = ".json", + Filter = "JSON config (*.json)|*.json", + }; + if (dlg.ShowDialog() != true) return; + + var json = System.Text.Json.JsonSerializer.Serialize(snap, WebhookServer.Core.Storage.ConfigJson.Pretty); + await System.IO.File.WriteAllTextAsync(dlg.FileName, json).ConfigureAwait(false); + } + catch (Exception ex) { ShowError("Export failed", ex); } + } + + [RelayCommand] + private async Task ImportConfigAsync() + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + Filter = "JSON config (*.json)|*.json", + CheckFileExists = true, + }; + if (dlg.ShowDialog() != true) return; + + try + { + var json = await System.IO.File.ReadAllTextAsync(dlg.FileName).ConfigureAwait(false); + var cfg = System.Text.Json.JsonSerializer.Deserialize(json, WebhookServer.Core.Storage.ConfigJson.Pretty); + if (cfg is null) throw new InvalidOperationException("File did not contain a valid config."); + + var ok = MessageBox.Show( + $"Replace the current configuration with {dlg.FileName}?\n\nA backup of the current config will be saved first.", + "Import config", + MessageBoxButton.OKCancel, + MessageBoxImage.Warning); + if (ok != MessageBoxResult.OK) return; + + await _client.ImportConfigAsync(cfg).ConfigureAwait(false); + await RefreshAsync().ConfigureAwait(false); + } + catch (Exception ex) { ShowError("Import failed", ex); } + } + [RelayCommand] private async Task RestartServiceAsync() { diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs index 54fff23..811b0a1 100644 --- a/src/WebhookServer.Service/AdminPipeServer.cs +++ b/src/WebhookServer.Service/AdminPipeServer.cs @@ -202,11 +202,66 @@ internal sealed class AdminPipeServer : BackgroundService return AdminResponse.Success(new { lines }); } + case AdminOps.ListBackups: + { + var entries = ListBackups(); + return AdminResponse.Success(new { backups = entries }); + } + + case AdminOps.RestoreBackup: + { + var args = DeserializeData(request) ?? throw new ArgumentException("missing fileName"); + var restored = await RestoreBackupAsync(args.FileName, ct).ConfigureAwait(false); + _logger.LogInformation("Restored config from backup {File}", args.FileName); + return AdminResponse.Success(SafeSnapshotForWire(restored)); + } + + case AdminOps.ImportConfig: + { + var incoming = DeserializeData(request) ?? throw new ArgumentException("missing config payload"); + MergeWithExistingSecrets(incoming, _state.Snapshot()); + await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false); + _logger.LogInformation("Config imported ({Count} endpoints)", incoming.Endpoints.Count); + return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot())); + } + default: return AdminResponse.Failure($"unknown op '{request.Op}'"); } } + private static List ListBackups() + { + var dir = Path.Combine(ServicePaths.DataRoot, "backups"); + if (!Directory.Exists(dir)) return new List(); + return new DirectoryInfo(dir).GetFiles("config-*.json") + .OrderByDescending(f => f.Name) + .Take(50) + .Select(f => new BackupEntry + { + FileName = f.Name, + SavedAt = f.LastWriteTimeUtc, + SizeBytes = f.Length, + }) + .ToList(); + } + + private async Task RestoreBackupAsync(string fileName, CancellationToken ct) + { + // Refuse anything that tries to escape the backups directory. + if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new ArgumentException("invalid file name"); + var backupPath = Path.Combine(ServicePaths.DataRoot, "backups", fileName); + if (!File.Exists(backupPath)) + throw new FileNotFoundException("backup not found", fileName); + + await using var fs = File.OpenRead(backupPath); + var cfg = await JsonSerializer.DeserializeAsync(fs, ConfigJson.Pretty, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("backup file was empty"); + await _state.ReplaceAsync(cfg, ct).ConfigureAwait(false); + return _state.Snapshot(); + } + private ServerConfig CloneSnapshotForEdit() { // Round-trip via JSON to avoid sharing references with the live snapshot.