Phase 4: backups + import/export config
Release / build-installer (push) Has been cancelled
CI / build (pull_request) Has been cancelled

ConfigStore.SaveAsync now snapshots the previous config to
%ProgramData%\WebhookServer\backups\config-<timestamp>.json before
overwriting, retaining the last 30. Failures are silent so a
backup-write hiccup never blocks an actual save.

Three new admin pipe ops:
- list-backups: returns newest 50 entries with timestamps and sizes
- restore-backup: takes a fileName, refuses path-traversal chars,
  loads the named backup over the live config (which itself triggers
  a fresh backup of the current state via the SaveAsync hook)
- import-config: replaces the current config with a GUI-supplied
  ServerConfig, merging encrypted secrets where the GUI didn't supply
  new plaintext

GUI File menu items are wired:
- Import config: file picker -> ImportConfigAsync
- Export config: SaveFileDialog writes the current config as JSON
- Backups: dynamic submenu auto-refreshed when opened, listing
  backups with timestamp + size; click to confirm-and-restore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 09:55:03 -04:00
parent 9e6abeef74
commit 93a9c327e0
7 changed files with 226 additions and 3 deletions
@@ -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)