// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Net.Http.Headers; using System.Text.Json; namespace Duplicati.ShellExtension; /// /// Simple client for communicating with the Duplicati server to check folder backup status /// public sealed class DuplicatiClient : IDisposable { private readonly HttpClient _httpClient; private readonly string _baseUrl; private readonly JsonSerializerOptions _jsonOptions; private bool _disposed; private DateTime _lastCacheTime = DateTime.MinValue; private Dictionary _folderStatusCache = new(StringComparer.OrdinalIgnoreCase); private static readonly TimeSpan CacheExpiration = TimeSpan.FromSeconds(30); private readonly SemaphoreSlim _cacheLock = new(1, 1); /// /// Information about a folder's backup status /// public record FolderStatusInfo( FolderBackupStatus Status, string? BackupName, DateTime? LastBackupTime, string? BackupId ); /// /// Creates a new instance of the Duplicati client /// /// The base URL of the Duplicati server (default: http://localhost:8200) public DuplicatiClient(string? baseUrl = null) { _baseUrl = (baseUrl ?? GetServerUrl()).TrimEnd('/'); _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; } /// /// Gets the Duplicati server URL from settings or returns default /// private static string GetServerUrl() { // Try to read from registry or config file try { using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( @"Software\Duplicati\ShellExtension"); if (key != null) { var url = key.GetValue("ServerUrl") as string; if (!string.IsNullOrEmpty(url)) return url; } } catch { // Ignore registry errors } return "http://localhost:8200"; } /// /// Sets the authentication token for API requests /// /// The bearer token public void SetAuthToken(string token) { _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); } /// /// Gets the backup status for a specific folder path /// /// The full path to the folder /// Cancellation token /// The folder's backup status information public async Task GetFolderStatusAsync( string folderPath, CancellationToken cancellationToken = default) { // Normalize the path folderPath = Path.GetFullPath(folderPath).TrimEnd(Path.DirectorySeparatorChar); await _cacheLock.WaitAsync(cancellationToken); try { // Check if cache is still valid if (DateTime.UtcNow - _lastCacheTime > CacheExpiration) { await RefreshCacheAsync(cancellationToken); } // Look up the folder in the cache if (_folderStatusCache.TryGetValue(folderPath, out var status)) return status; // Check if any cached folder is a parent of this folder foreach (var kvp in _folderStatusCache) { if (folderPath.StartsWith(kvp.Key + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { return kvp.Value; } } return new FolderStatusInfo(FolderBackupStatus.NotInBackup, null, null, null); } finally { _cacheLock.Release(); } } /// /// Refreshes the folder status cache from the server /// private async Task RefreshCacheAsync(CancellationToken cancellationToken) { try { // Get folder status from the dedicated API endpoint var response = await _httpClient.GetAsync( $"{_baseUrl}/api/v1/folderstatus", cancellationToken); if (!response.IsSuccessStatusCode) { // If API not available, fall back to backup listing await RefreshCacheFromBackupsAsync(cancellationToken); return; } var content = await response.Content.ReadAsStringAsync(cancellationToken); var statusList = JsonSerializer.Deserialize(content, _jsonOptions); _folderStatusCache.Clear(); if (statusList != null) { foreach (var item in statusList) { if (!string.IsNullOrEmpty(item.Path)) { var normalizedPath = Path.GetFullPath(item.Path).TrimEnd(Path.DirectorySeparatorChar); _folderStatusCache[normalizedPath] = new FolderStatusInfo( ParseStatus(item.Status), item.BackupName, item.LastBackupTime, item.BackupId ); } } } _lastCacheTime = DateTime.UtcNow; } catch { // If we can't reach the server, keep the old cache if (_folderStatusCache.Count == 0) { _lastCacheTime = DateTime.UtcNow; } } } /// /// Fallback method to refresh cache from backup listings /// private async Task RefreshCacheFromBackupsAsync(CancellationToken cancellationToken) { try { var response = await _httpClient.GetAsync( $"{_baseUrl}/api/v1/backups", cancellationToken); if (!response.IsSuccessStatusCode) return; var content = await response.Content.ReadAsStringAsync(cancellationToken); var backups = JsonSerializer.Deserialize(content, _jsonOptions); _folderStatusCache.Clear(); if (backups != null) { foreach (var backup in backups) { if (backup.Backup?.Sources != null) { var status = DetermineBackupStatus(backup); foreach (var source in backup.Backup.Sources) { if (!string.IsNullOrEmpty(source)) { var normalizedPath = Path.GetFullPath(source).TrimEnd(Path.DirectorySeparatorChar); _folderStatusCache[normalizedPath] = new FolderStatusInfo( status, backup.Backup.Name, backup.Backup.Metadata?.TryGetValue("LastBackupDate", out var dateStr) == true ? DateTime.TryParse(dateStr, out var date) ? date : null : null, backup.Backup.ID ); } } } } } _lastCacheTime = DateTime.UtcNow; } catch { // Silently fail - server might not be running } } private static FolderBackupStatus DetermineBackupStatus(BackupInfo backup) { // Check if backup has metadata about last run if (backup.Backup?.Metadata == null) return FolderBackupStatus.NeverBackedUp; if (!backup.Backup.Metadata.TryGetValue("LastBackupDate", out var lastDateStr) || string.IsNullOrEmpty(lastDateStr)) return FolderBackupStatus.NeverBackedUp; // Check for error/warning status if (backup.Backup.Metadata.TryGetValue("LastBackupError", out var error) && !string.IsNullOrEmpty(error)) return FolderBackupStatus.BackupFailed; if (backup.Backup.Metadata.TryGetValue("LastBackupWarning", out var warning) && !string.IsNullOrEmpty(warning)) return FolderBackupStatus.BackedUpWithWarning; return FolderBackupStatus.BackedUp; } private static FolderBackupStatus ParseStatus(string? status) { return status?.ToLowerInvariant() switch { "backedup" => FolderBackupStatus.BackedUp, "warning" => FolderBackupStatus.BackedUpWithWarning, "failed" => FolderBackupStatus.BackupFailed, "inprogress" => FolderBackupStatus.BackupInProgress, "never" => FolderBackupStatus.NeverBackedUp, _ => FolderBackupStatus.NotInBackup }; } /// /// Disposes the client and releases resources /// public void Dispose() { if (!_disposed) { _httpClient.Dispose(); _cacheLock.Dispose(); _disposed = true; } } // Response DTOs for JSON deserialization private record FolderStatusResponse( string? Path, string? Status, string? BackupName, DateTime? LastBackupTime, string? BackupId ); private record BackupInfo(BackupData? Backup); private record BackupData( string? ID, string? Name, string[]? Sources, Dictionary? Metadata ); }