// Copyright (C) 2026, 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.Text.Json; using System.Text.Json.Serialization; using Duplicati.Library.Interface; using Duplicati.Server; using Duplicati.Server.Database; using Duplicati.Server.Serialization; using Duplicati.Server.Serialization.Interface; using Duplicati.WebserverCore.Abstractions; using Duplicati.WebserverCore.Exceptions; using Microsoft.AspNetCore.Mvc; namespace Duplicati.WebserverCore.Endpoints.V1.Backup; public class BackupGet : IEndpointV1 { public record GetBackupResultDto(Dto.ScheduleDto? Schedule, Dto.BackupDto Backup, Dictionary DisplayNames); public static void Map(RouteGroupBuilder group) { group.MapGet("/backup/{id}", ([FromServices] Connection connection, [FromRoute] string id) => ExecuteGet(connection, GetBackup(connection, id))) .RequireAuthorization(); group.MapGet("/backup/{id}/files", ([FromServices] Connection connection, [FromServices] IQueueRunnerService queueRunnerService, [FromRoute] string id, [FromQuery] string? filter, [FromQuery] string? time, [FromQuery(Name = "all-versions")] bool? allVersions, [FromQuery(Name = "prefix-only")] bool? prefixOnly, [FromQuery(Name = "folder-contents")] bool? folderContents) => ExecuteGetFiles(queueRunnerService, GetBackup(connection, id), filter, time, allVersions ?? false, prefixOnly ?? false, folderContents ?? false, new Dictionary())) .RequireAuthorization(); group.MapGet("/backup/{id}/log", ([FromServices] Connection connection, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize) => ExecuteGetLog(connection, GetBackup(connection, id), offset, pagesize ?? 100)) .RequireAuthorization(); group.MapGet("/backup/{id}/remotelog", ([FromServices] Connection connection, [FromRoute] string id, [FromQuery] long? offset, [FromQuery] long? pagesize) => ExecuteGetRemotelog(connection, GetBackup(connection, id), offset, pagesize ?? 100)) .RequireAuthorization(); group.MapGet("/backup/{id}/filesets", ([FromServices] Connection connection, [FromServices] IQueueRunnerService queueRunnerService, [FromRoute] string id, [FromQuery(Name = "include-metadata")] bool? includeMetadata, [FromQuery(Name = "from-remote-only")] bool? fromRemoteOnly) => ExecuteGetFilesets(queueRunnerService, GetBackup(connection, id), includeMetadata ?? false, fromRemoteOnly ?? false)) .RequireAuthorization(); group.MapGet("/backup/{id}/export-argsonly", ([FromServices] Connection connection, [FromRoute] string id, [FromQuery(Name = "export-passwords")] bool? exportPasswords, [FromQuery] string? passphrase) => ExecuteGetExportArgsOnly(connection, GetBackup(connection, id), exportPasswords ?? false)) .RequireAuthorization(); group.MapGet("/backup/{id}/export-cmdline", ([FromServices] Connection connection, [FromRoute] string id, [FromQuery(Name = "export-passwords")] bool? exportPasswords, [FromQuery] string? passphrase) => ExecuteGetExportCmdline(connection, GetBackup(connection, id), exportPasswords ?? false)) .RequireAuthorization(); group.MapGet("/backup/{id}/export", ([FromServices] Connection connection, [FromServices] IHttpContextAccessor httpContextAccessor, [FromServices] IJWTTokenProvider jWTTokenProvider, [FromRoute] string id, [FromQuery(Name = "export-passwords")] bool? exportPasswords, [FromQuery] string? passphrase, [FromQuery] string token, CancellationToken ct) => { // Custom authorization check var singleOperationToken = jWTTokenProvider.ReadSingleOperationToken(token); if (singleOperationToken.Operation != "export") throw new UnauthorizedException("Invalid operation"); var (data, filename) = ExecuteGetExport(connection, GetBackup(connection, id), exportPasswords ?? false, passphrase); var resp = httpContextAccessor.HttpContext!.Response; resp.ContentLength = data.Length; resp.ContentType = "application/octet-stream"; resp.Headers.Append("Content-Disposition", $"attachment; filename={filename}"); resp.Body.WriteAsync(data, ct); }); group.MapGet("/backup/{id}/isdbusedelsewhere", ([FromServices] Connection connection, [FromRoute] string id) => ExecuteGetIsdbUsedElsewhere(GetBackup(connection, id))) .RequireAuthorization(); group.MapGet("/backup/{id}/isactive", ([FromServices] Connection connection, [FromServices] IQueueRunnerService queueRunnerService, [FromRoute] string id) => ExecuteGetIsActive(queueRunnerService, GetBackup(connection, id))) .RequireAuthorization(); } private static IBackup GetBackup(Connection connection, string id) => connection.GetBackup(id) ?? throw new NotFoundException("Backup not found"); private static GetBackupResultDto ExecuteGet(Connection connection, IBackup bk) { var scheduleId = connection.GetScheduleIDsFromTags(new string[] { "ID=" + bk.ID }); var schedule = scheduleId.Any() ? connection.GetSchedule(scheduleId.First()) : null; var sourcenames = SpecialFolders.GetSourceNames(bk); bk.MaskSensitiveInformation(); return new GetBackupResultDto( schedule == null ? null : new Dto.ScheduleDto() { ID = schedule.ID, Tags = schedule.Tags, Time = schedule.Time, Repeat = schedule.Repeat, LastRun = schedule.LastRun, Rule = schedule.Rule, AllowedDays = schedule.AllowedDays }, new Dto.BackupDto() { ID = bk.ID, ExternalID = bk.ExternalID, Name = bk.Name, Sources = bk.Sources, Settings = bk.Settings?.Select(x => new Dto.SettingDto() { Name = x.Name, Value = x.Value, Filter = x.Filter, Argument = x.Argument }).ToArray(), Filters = bk.Filters?.Select(x => new Dto.FilterDto() { Order = x.Order, Include = x.Include, Expression = x.Expression }).ToArray(), Metadata = bk.Metadata, Description = bk.Description, Tags = bk.Tags, TargetURL = bk.TargetURL, ConnectionStringID = bk.ConnectionStringID, DBPath = bk.DBPath, DBPathExists = File.Exists(bk.DBPath), IsTemporary = bk.IsTemporary, IsUnencryptedOrPassphraseStored = false, AdditionalTargetURLs = bk.AdditionalTargetURLs?.Select(x => new Dto.TargetUrlDto { UrlKey = x.TargetUrlKey, TargetUrl = x.TargetUrl, Mode = x.Mode, Interval = x.Interval, ConnectionStringID = x.ConnectionStringID, Options = x.Options, }).ToArray() ?? Array.Empty(), }, sourcenames ); } private static Dictionary SearchFiles(IQueueRunnerService queueRunnerService, IBackup backup, string? filter, string? timestring, bool allVersions, bool prefixOnly, bool folderContents, Dictionary extraValues) { if (string.IsNullOrWhiteSpace(timestring) && !allVersions) throw new BadRequestException("Invalid or missing time"); var time = new DateTime(); if (!allVersions) time = Library.Utility.Timeparser.ParseTimeInterval(timestring, DateTime.Now); var r = queueRunnerService.RunImmediately(Runner.CreateListTask(backup, filter == null ? null : [filter], prefixOnly, allVersions, folderContents, time)) as IListResults; if (r == null) throw new ServerErrorException("No result from list operation"); var result = new Dictionary(); foreach (var k in extraValues) result[k.Key] = k.Value; result["Filesets"] = r.Filesets; result["Files"] = r.Files // Group directories first - support either directory separator here as we may be restoring data from an alternate platform .OrderByDescending(f => (f.Path.StartsWith('/') && f.Path.EndsWith('/')) || (!f.Path.StartsWith('/') && f.Path.EndsWith('\\'))) // Sort both groups (directories and files) alphabetically .ThenBy(f => f.Path); return result; } private static Dictionary ExecuteGetFiles(IQueueRunnerService queueRunnerService, IBackup bk, string? filter, string? timestring, bool allVersions, bool prefixOnly, bool folderContents, Dictionary extraValues) => SearchFiles(queueRunnerService, bk, filter, timestring, allVersions, prefixOnly, folderContents, extraValues); private static List> ExecuteGetLog(Connection connection, IBackup bk, long? offset, long pagesize) { if (!File.Exists(bk.DBPath)) return new List>(); using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(bk.DBPath)) using (var cmd = con.CreateCommand()) return LogData.DumpTable(cmd, "LogData", "ID", offset, pagesize); } private static List> ExecuteGetRemotelog(Connection connection, IBackup bk, long? offset, long pagesize) { if (!File.Exists(bk.DBPath)) return new List>(); using (var con = Library.SQLiteHelper.SQLiteLoader.LoadConnection(bk.DBPath)) using (var cmd = con.CreateCommand()) { var dt = LogData.DumpTable(cmd, "RemoteOperation", "ID", offset, pagesize); // Unwrap raw data to a string foreach (var n in dt) try { n["Data"] = System.Text.Encoding.UTF8.GetString((byte[])n["Data"]); } catch { } return dt; } } private static IEnumerable ExecuteGetFilesets(IQueueRunnerService queueRunnerService, IBackup bk, bool includeMetadata, bool fromRemoteOnly) { var extra = new Dictionary { ["list-sets-only"] = "true" }; if (includeMetadata) extra["list-sets-only"] = "false"; if (fromRemoteOnly) extra["no-local-db"] = "true"; // Retries will hang the http request extra["number-of-retries"] = "0"; try { var r = queueRunnerService.RunImmediately(Runner.CreateTask(DuplicatiOperation.List, bk, extra)) as IListResults; if (r == null) throw new ServerErrorException("No result from list operation"); if (r.EncryptedFiles && bk.Settings.Any(x => string.Equals("--no-encryption", x.Name, StringComparison.OrdinalIgnoreCase))) throw new ServerErrorException("encrypted-storage"); return r.Filesets; } catch (FolderMissingException) { throw new ServerErrorException("folder-missing"); } } public static void RemovePasswords(IBackup backup) { backup.RemoveSensitiveInformation(); } private static Dto.ExportCommandlineDto ExecuteGetExportCmdline(Connection connection, IBackup backup, bool exportPasswords) { if (!exportPasswords) RemovePasswords(backup); return new Dto.ExportCommandlineDto(Runner.GetCommandLine(connection, Runner.CreateTask(DuplicatiOperation.Backup, backup))); } private static Dto.ExportArgsOnlyDto ExecuteGetExportArgsOnly(Connection connection, IBackup backup, bool exportPasswords) { if (!exportPasswords) RemovePasswords(backup); var parts = Runner.GetCommandLineParts(connection, Runner.CreateTask(DuplicatiOperation.Backup, backup)); return new Dto.ExportArgsOnlyDto( parts.First(), parts.Skip(1).Where(x => !x.StartsWith("--", StringComparison.Ordinal)), parts.Skip(1).Where(x => x.StartsWith("--", StringComparison.Ordinal)) ); } private static (byte[] Data, string Filename) ExecuteGetExport(Connection connection, IBackup backup, bool exportPasswords, string? passphrase) { if (!exportPasswords) RemovePasswords(backup); byte[] data = EncodeDataForExport(connection, backup, passphrase); string filename = Uri.EscapeDataString(backup.Name + "-duplicati-config.json"); if (!string.IsNullOrWhiteSpace(passphrase)) filename += ".aes"; return (data, filename); } private static byte[] EncodeDataForExport(Connection connection, IBackup bk, string? passphrase) { var ipx = connection.PrepareBackupForExport(bk); using var ms = new MemoryStream(); JsonSerializer.Serialize(ms, ipx, new JsonSerializerOptions() { WriteIndented = true, Converters = { new JsonStringEnumConverter(), new DayOfWeekStringEnumConverter() } }); if (string.IsNullOrWhiteSpace(passphrase)) return ms.ToArray(); ms.Position = 0; using var ms2 = new MemoryStream(); using (var m = new Library.Encryption.AESEncryption(passphrase, new Dictionary())) m.Encrypt(ms, ms2); return ms2.ToArray(); } private static Dto.IsDbUsedElsewhereDto ExecuteGetIsdbUsedElsewhere(IBackup bk) => new Dto.IsDbUsedElsewhereDto(Library.Main.CLIDatabaseLocator.IsDatabasePathInUse(bk.DBPath)); private static Dto.IsBackupActiveDto ExecuteGetIsActive(IQueueRunnerService queueRunnerService, IBackup bk) { var t = queueRunnerService.GetCurrentTask(); var bt = t?.BackupID; if (bt != null && bk.ID == bt) return new Dto.IsBackupActiveDto("OK", true); if (queueRunnerService.GetCurrentTasks().Any(x => x?.BackupID == null || x.BackupID == bk.ID)) return new Dto.IsBackupActiveDto("OK", true); return new Dto.IsBackupActiveDto("OK", false); } }