// 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);
}
}