// 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 Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Utility; using Duplicati.Library.Utility.Options; namespace Duplicati.Library.Backend.Filen; /// /// The Filen backend /// public class FilenBackend : IStreamingBackend, IRenameEnabledBackend { /// /// The two-factor option name /// private const string TwoFactorOption = "two-factor-code"; /// /// The move to trash option name /// private const string MoveToTrashOption = "move-to-trash"; /// /// The API key option name /// private const string ApiKeyOption = "api-key"; /// /// The Filen client instance /// private FilenClient? _client; /// /// The authentication options /// private readonly AuthOptionsHelper.AuthOptions _auth; /// /// The timeout options /// private readonly TimeoutOptionsHelper.Timeouts _timeout; /// /// The path to the folder /// private readonly string _path; /// /// The two-factor code, if any /// private readonly string? _twoFactorCode; /// /// The API key, if any /// private readonly string? _apiKey; /// /// Whether to move files to the trash instead of deleting them /// private readonly bool _moveToTrash; /// /// The UUID of the folder files are stored in /// private string? _folderUuid; /// /// Constructor for reflection based loading /// public FilenBackend() { _client = null!; _auth = null!; _timeout = null!; _path = null!; } /// /// Creates a new instance of the Filen backend /// /// The connection url /// The options to use public FilenBackend(string url, Dictionary options) { var uri = new Utility.Uri(url); _path = uri.HostAndPath; _auth = AuthOptionsHelper.Parse(options, uri) .RequireCredentials(); _moveToTrash = Utility.Utility.ParseBoolOption(options, MoveToTrashOption); _twoFactorCode = options.GetValueOrDefault(TwoFactorOption); _apiKey = options.GetValueOrDefault(ApiKeyOption); _timeout = TimeoutOptionsHelper.Parse(options); } /// /// Gets a client /// /// The cancellation token to use /// A client instance private async Task GetClientAsync(CancellationToken cancellationToken) { if (_client == null || DateTime.Now > _client.ValidUntil) { _client?.Dispose(); _client = null; var httpClient = HttpClientHelper.CreateClient(); httpClient.Timeout = Timeout.InfiniteTimeSpan; _client = await FilenClient.CreateClientAsync(httpClient, _auth.Username!, _auth.Password!, _twoFactorCode, _apiKey, cancellationToken).ConfigureAwait(false); } return _client; } /// public string DisplayName => Strings.FilenBackend.DisplayName; /// public string ProtocolKey => "filen"; /// public string Description => Strings.FilenBackend.Description; /// public bool SupportsStreaming => true; /// public IList SupportedCommands => [ .. AuthOptionsHelper.GetOptions(), new CommandLineArgument(TwoFactorOption, CommandLineArgument.ArgumentType.String, Strings.FilenBackend.TwoFactorShort, Strings.FilenBackend.TwoFactorLong), new CommandLineArgument(ApiKeyOption, CommandLineArgument.ArgumentType.Password, Strings.FilenBackend.ApiKeyShort, Strings.FilenBackend.ApiKeyLong), new CommandLineArgument(MoveToTrashOption, CommandLineArgument.ArgumentType.Boolean, Strings.FilenBackend.MoveToTrashShort, Strings.FilenBackend.MoveToTrashLong), .. TimeoutOptionsHelper.GetOptions() ]; /// /// Gets an API key for the account /// /// The cancellation token to use /// The API key internal async Task GetApiKey(CancellationToken cancellationToken) => (await GetClientAsync(cancellationToken))?.ApiKey; /// /// Gets the folder uuid for the folder this backend is working in /// /// The client to use /// The cancellation token to use /// The folder UUID private async Task GetFolderUuid(FilenClient client, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_folderUuid)) return _folderUuid = await client.ResolveFolderPathAsync(_path, _timeout.ListTimeout, cancellationToken).ConfigureAwait(false); return _folderUuid; } /// public async IAsyncEnumerable ListAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { var client = await GetClientAsync(cancellationToken).ConfigureAwait(false); var folderId = await GetFolderUuid(client, cancellationToken).ConfigureAwait(false); await foreach (var entry in client.ListFolderDecryptedAsync(folderId, _timeout.ListTimeout, cancellationToken).ConfigureAwait(false)) { yield return new FileEntry(entry.Name, entry.Size, new DateTime(0), entry.LastModified) { IsFolder = entry.IsFolder }; } } /// public async Task PutAsync(string remotename, string filename, CancellationToken cancellationToken) { using var stream = File.OpenRead(filename); await PutAsync(remotename, stream, cancellationToken).ConfigureAwait(false); } /// public async Task GetAsync(string remotename, string filename, CancellationToken cancellationToken) { using var stream = File.Create(filename); await GetAsync(remotename, stream, cancellationToken).ConfigureAwait(false); } /// public async Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken) { var client = await GetClientAsync(cancelToken).ConfigureAwait(false); var folderId = await GetFolderUuid(client, cancelToken).ConfigureAwait(false); using var timeoutStream = stream.ObserveReadTimeout(_timeout.ReadWriteTimeout, false); await client.UploadStreamedEncryptedFileAsync(timeoutStream, remotename, folderId, cancelToken).ConfigureAwait(false); } /// public async Task GetAsync(string remotename, Stream stream, CancellationToken cancelToken) { var client = await GetClientAsync(cancelToken).ConfigureAwait(false); var folderId = await GetFolderUuid(client, cancelToken).ConfigureAwait(false); var file = await client.GetFileEntryAsync(folderId, remotename, _timeout.ListTimeout, cancelToken).ConfigureAwait(false); if (file == null) throw new FileMissingException($"File '{remotename}' not found."); using var timeoutStream = stream.ObserveWriteTimeout(_timeout.ReadWriteTimeout, false); await client.DownloadAndDecryptToStreamAsync(file, timeoutStream, cancelToken).ConfigureAwait(false); } /// public async Task DeleteAsync(string remotename, CancellationToken cancellationToken) { var client = await GetClientAsync(cancellationToken).ConfigureAwait(false); var folderId = await GetFolderUuid(client, cancellationToken).ConfigureAwait(false); var file = await client.GetFileEntryAsync(folderId, remotename, _timeout.ListTimeout, cancellationToken) .ConfigureAwait(false); if (file == null) throw new FileMissingException($"File '{remotename}' not found."); await Utility.Utility.WithTimeout(_timeout.ShortTimeout, cancellationToken, ct => client.DeleteFileAsync(file.Uuid, !_moveToTrash, ct)).ConfigureAwait(false); } /// public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult( FilenClient.GatewayUrls .Concat(FilenClient.EgestUrls) .Concat(FilenClient.IngestURLs) .Select(u => new System.Uri(u).Host) .Distinct() .ToArray() ); /// public Task TestAsync(CancellationToken cancellationToken) => this.TestReadWritePermissionsAsync(cancellationToken); /// public async Task CreateFolderAsync(CancellationToken cancellationToken) { var client = await GetClientAsync(cancellationToken).ConfigureAwait(false); var rootFolderId = await client.GetUserBaseFolder(cancellationToken).ConfigureAwait(false); foreach (var s in _path.Split('/')) { if (string.IsNullOrWhiteSpace(s)) continue; var entry = await client.ListFolderDecryptedAsync(rootFolderId, _timeout.ListTimeout, cancellationToken).FirstOrDefaultAsync(f => f.Name == s).ConfigureAwait(false); if (entry != null) { rootFolderId = entry.Uuid; continue; } rootFolderId = await Utility.Utility.WithTimeout(_timeout.ShortTimeout, cancellationToken, ct => client.CreateFolderAsync(rootFolderId, s, ct)).ConfigureAwait(false); } } /// public void Dispose() { _client?.Dispose(); } public async Task RenameAsync(string oldname, string newname, CancellationToken cancellationToken) { var client = await GetClientAsync(cancellationToken).ConfigureAwait(false); var folderUuid = await GetFolderUuid(client, cancellationToken).ConfigureAwait(false); var fileEntry = await client.GetFileEntryAsync(folderUuid, oldname, _timeout.ListTimeout, cancellationToken).ConfigureAwait(false); if (fileEntry == null) throw new FileMissingException($"File '{oldname}' not found."); await Utility.Utility.WithTimeout(_timeout.ShortTimeout, cancellationToken, ct => client.RenameFileAsync(fileEntry.Uuid, newname, ct)).ConfigureAwait(false); } }