// 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; using Duplicati.Server.Serialization; using Duplicati.Server.Serialization.Interface; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; namespace Duplicati.Server.Database { public class Backup : IBackup { public Backup() { this.ID = null; } internal void LoadChildren(Connection con) { if (this.IsTemporary) { this.Sources = new string[0]; this.Settings = new ISetting[0]; this.Filters = new IFilter[0]; this.Metadata = new Dictionary(); } else { var id = long.Parse(this.ID); this.Sources = con.GetSources(id); this.Settings = con.GetSettings(id); this.Filters = con.GetFilters(id); this.Metadata = con.GetMetadata(id); this.AdditionalTargetURLs = con.GetBackupTargetUrls(id); } } public void SetDBPath(string path) => this.DBPath = path; /// /// The backup ID /// public string ID { get; set; } /// /// The external ID for tracking the backup, or null if not set /// public string ExternalID { get; set; } /// /// The backup name /// public string Name { get; set; } /// /// The backup description /// public string Description { get; set; } /// /// The backup tags /// public string[] Tags { get; set; } /// /// The backup target url /// public string TargetURL { get; set; } /// /// The path to the local database /// public string DBPath { get; internal set; } /// /// The connection string ID, or -1 if not used /// public long ConnectionStringID { get; set; } = -1; /// /// The operation this backup performs when it runs. /// public OperationType OperationType { get; set; } = OperationType.Backup; /// /// The backup source folders and files /// public string[] Sources { get; set; } /// /// The backup settings /// public ISetting[] Settings { get; set; } /// /// The filters applied to the source files /// public IFilter[] Filters { get; set; } /// /// The backup metadata /// public IDictionary Metadata { get; set; } /// /// Additional target URLs for remote synchronization /// These are used by RemoteSynchronizationModule and are separate from the primary TargetURL /// public IEnumerable AdditionalTargetURLs { get; set; } = new List(); /// /// Gets a value indicating if this instance is not persisted to the database /// public bool IsTemporary { get { return ID != null && ID.IndexOf("-", StringComparison.Ordinal) > 0; } } /// /// Sanitizes the backup TargetUrl from any fields in the PasswordFieldNames list. /// private void SanitizeTargetUrl() { var url = new Duplicati.Library.Utility.Uri(this.TargetURL); NameValueCollection filteredParameters = new NameValueCollection(); if (url.Query != null) { // We cannot use url.QueryParameters since it contains decoded parameter values, which // breaks assumptions made by the decode_uri function in AppUtils.js. Since we are simply // removing password parameters, we will leave the parameters as they are in the target URL. filteredParameters = Library.Utility.Uri.ParseQueryString(url.Query, false); foreach (var field in Connection.PasswordFieldNames) filteredParameters.Remove(field); } url = url.SetQuery(Duplicati.Library.Utility.Uri.BuildUriQuery(filteredParameters)); this.TargetURL = url.ToString(); } /// /// Sanitizes the settings from any fields in the PasswordFieldNames list. /// private void SanitizeSettings() { this.Settings = this.Settings.Where((setting) => !Connection.PasswordFieldNames.Contains(setting.Name)).ToArray(); } /// /// Sanitizes the sources from any fields in the PasswordFieldNames list. /// private void SanitizeSources() { if (this.Sources == null) return; for (int i = 0; i < this.Sources.Length; i++) { if (SourceMasking.IsSpecialSource(this.Sources[i])) { var urlString = SourceMasking.ExtractUrl(this.Sources[i]); var url = new Library.Utility.Uri(urlString); if (url.Query != null) { var filteredParameters = Library.Utility.Uri.ParseQueryString(url.Query, false); foreach (var field in Connection.PasswordFieldNames) filteredParameters.Remove(field); url = url.SetQuery(Library.Utility.Uri.BuildUriQuery(filteredParameters)); this.Sources[i] = SourceMasking.ReplaceUrl(this.Sources[i], url.ToString()); } } } } /// public void RemoveSensitiveInformation() { SanitizeTargetUrl(); SanitizeSettings(); SanitizeSources(); SanitizeAdditionalTargetUrls(); } /// /// Sanitizes the additional target URLs from any fields in the PasswordFieldNames list. /// private void SanitizeAdditionalTargetUrls() { if (AdditionalTargetURLs == null) return; foreach (var target in AdditionalTargetURLs) { if (target == null || string.IsNullOrEmpty(target.TargetUrl)) continue; var url = new Duplicati.Library.Utility.Uri(target.TargetUrl); var filteredParameters = url.QueryParameters; foreach (var field in Connection.PasswordFieldNames) filteredParameters.Remove(field); url = url.SetQuery(Duplicati.Library.Utility.Uri.BuildUriQuery(filteredParameters)); target.TargetUrl = url.ToString(); } } /// public void MaskSensitiveInformation() { var protectedNames = Connection.PasswordFieldNames; TargetURL = QuerystringMasking.Mask(TargetURL, protectedNames); Sources = SourceMasking.MaskSources(Sources, protectedNames); // Mask additional target URLs if (AdditionalTargetURLs != null) foreach (var target in AdditionalTargetURLs) if (target != null) target.TargetUrl = QuerystringMasking.Mask(target.TargetUrl, protectedNames); foreach (var setting in this.Settings) if (protectedNames.Contains(setting.Name)) setting.Value = Connection.PASSWORD_PLACEHOLDER; } /// public void UnmaskSensitiveInformation(IBackup previous, IReadOnlyDictionary connectionStrings) { if (previous == null) throw new ArgumentNullException(nameof(previous)); // If there is a connection string ID, and it is different from the previous one, // the user has changed the connection strings source var constr = ConnectionStringID > 0 && ConnectionStringID != previous.ConnectionStringID ? connectionStrings.GetValueOrDefault(previous.ConnectionStringID) : previous.TargetURL; TargetURL = QuerystringMasking.Unmask(TargetURL, [constr, previous.TargetURL]); Sources = SourceMasking.UnmaskSources(Sources, previous.Sources); // Unmask additional target URLs var prevAdditionalTargets = previous.AdditionalTargetURLs?.ToList(); if (AdditionalTargetURLs != null && prevAdditionalTargets != null) { var prevTargets = prevAdditionalTargets.ToDictionary( x => x.TargetUrlKey, x => x, StringComparer.OrdinalIgnoreCase); foreach (var target in AdditionalTargetURLs) { if (target != null && Connection.UrlContainsPasswordPlaceholder(target.TargetUrl)) { if (prevTargets.TryGetValue(target.TargetUrlKey, out var prevValue)) { var prevTarget = target.ConnectionStringID > 0 && target.ConnectionStringID != prevValue.ConnectionStringID ? connectionStrings.GetValueOrDefault(prevValue.ConnectionStringID) : prevValue.TargetUrl; target.TargetUrl = QuerystringMasking.Unmask(target.TargetUrl, [prevTarget, prevValue.TargetUrl]); } else if (target.ConnectionStringID > 0) { target.TargetUrl = QuerystringMasking.Unmask(target.TargetUrl, connectionStrings.GetValueOrDefault(target.ConnectionStringID)); } else throw new InvalidOperationException($"Cannot unmask target URL with key '{target.TargetUrlKey}' because it did not exist in the previous configuration."); } } } var prevSettings = previous.Settings.ToDictionary(x => x.Name, x => x.Value, StringComparer.OrdinalIgnoreCase); foreach (var setting in this.Settings) { if (Connection.IsPasswordPlaceholder(setting.Value)) { if (prevSettings.TryGetValue(setting.Name, out var prevValue)) setting.Value = prevValue; else throw new InvalidOperationException($"Cannot unmask setting '{setting.Name}' because it did not exist in the previous configuration."); } } } public Backup Clone() { return new Backup() { ID = this.ID, ExternalID = this.ExternalID, Name = this.Name, Description = this.Description, Tags = (string[])this.Tags?.Clone() ?? [], TargetURL = this.TargetURL, ConnectionStringID = this.ConnectionStringID, OperationType = this.OperationType, DBPath = this.DBPath, Sources = (string[])this.Sources?.Clone() ?? [], Settings = this.Settings?.Select(s => new Setting { Name = s.Name, Value = s.Value, Filter = s.Filter }).ToArray() ?? [], Filters = this.Filters?.Select(f => new Filter { Order = f.Order, Include = f.Include, Expression = f.Expression }).ToArray() ?? [], Metadata = new Dictionary(this.Metadata), AdditionalTargetURLs = this.AdditionalTargetURLs?.Select(t => new TargetUrlEntry { ID = t.ID, BackupID = t.BackupID, TargetUrlKey = t.TargetUrlKey, TargetUrl = t.TargetUrl, Mode = t.Mode, Interval = t.Interval, Options = t.Options?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), CreatedAt = t.CreatedAt, UpdatedAt = t.UpdatedAt }).Cast().ToList() ?? new List() }; } } }