// 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.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Duplicati.Library.Interface;
namespace Duplicati.Library.Backend.Filen;
///
/// The Filen client implementation
///
public class FilenClient : IDisposable
{
///
/// The URLs of the Filen gateways
///
public static IReadOnlyList GatewayUrls = [
"https://gateway.filen.io",
"https://gateway.filen.net",
"https://gateway.filen-1.net",
"https://gateway.filen-2.net",
"https://gateway.filen-3.net",
"https://gateway.filen-4.net",
"https://gateway.filen-5.net",
"https://gateway.filen-6.net"
];
///
/// The URLs of the Filen egest servers
///
public static IReadOnlyList EgestUrls = [
"https://egest.filen.io",
"https://egest.filen.net",
"https://egest.filen-1.net",
"https://egest.filen-2.net",
"https://egest.filen-3.net",
"https://egest.filen-4.net",
"https://egest.filen-5.net",
"https://egest.filen-6.net"
];
///
/// The URLs of the Filen ingest servers
///
public static IReadOnlyList IngestURLs = [
"https://ingest.filen.io",
"https://ingest.filen.net",
"https://ingest.filen-1.net",
"https://ingest.filen-2.net",
"https://ingest.filen-3.net",
"https://ingest.filen-4.net",
"https://ingest.filen-5.net",
"https://ingest.filen-6.net"
];
///
/// The log tag for the Filen client
///
private static string LOGTAG = Logging.Log.LogTagFromType();
///
/// The authentication result from the initial login
///
private sealed record FilenAuthResult
{
///
/// The API key to use for auntehticated requests
///
public required string ApiKey { get; init; }
///
/// The account master key
///
public required DerivedKey AccountMasterKey { get; init; }
///
/// The master keys for the account
///
public required IReadOnlyList MasterKeys { get; init; }
///
/// Encrypts metadata with the latest master key
///
/// The metadata to encrypt
/// The encrypted metadata
public string EncryptMetadata(string data)
=> MasterKeys.Last().EncryptMetadata(data);
///
/// Decrypts metadata with the correct master key
///
/// The metadata to decrypt
/// The decrypted metadata
public string DecryptMetadata(string data)
{
Exception? firstExecption = null;
var keyIx = MasterKeys.Count;
foreach (var key in MasterKeys.Reverse())
{
try
{
return key.DecryptMetadata(data);
}
catch (Exception ex)
{
keyIx--;
firstExecption ??= ex;
Logging.Log.WriteVerboseMessage(LOGTAG, "DecryptMetadataAttemptFailed", ex, "Failed to decrypt metadata with key {0}", keyIx);
}
}
throw new Exception("Failed to decrypt metadata", firstExecption);
}
}
///
/// The base url for all requests
///
private readonly string _baseUrl;
///
/// The fixed chunk size for uploads
///
private const int ChunkSize = 1024 * 1024; // 1 MB
///
/// The HTTP client to use for requests
///
private readonly HttpClient _httpClient;
///
/// The UUID of the root folder; null until loaded
///
private string? _rootFolderUuid;
///
/// The authentication result from the initial login
///
private FilenAuthResult _authResult;
///
/// The time the auth token is valid until
///
private readonly DateTime _validUntil;
///
/// The cached files to avoid re-fetching
///
private readonly Dictionary _cachedFiles = new();
///
/// Creates a new Filen client
///
/// The HTTP client to use for requests
/// The authentication result from the initial login
/// The base url for all requests
private FilenClient(HttpClient httpClient, FilenAuthResult authResult, string baseUrl)
{
_httpClient = httpClient;
_authResult = authResult;
_baseUrl = baseUrl;
_validUntil = DateTime.Now + TimeSpan.FromMinutes(50);
}
///
/// The time the client is valid
///
public DateTime ValidUntil => _validUntil;
///
/// The API key for the client
///
internal string ApiKey => _authResult.ApiKey;
///
/// Creates a new Filen client and authenticates
///
/// The HTTP client to use for requests
/// The email address to use for login
/// The password to use for login
/// The two-factor code to use for login
/// The API key to use for login
/// The cancellation token to use for the operation
/// The authenticated Filen client
public static async Task CreateClientAsync(HttpClient httpClient, string email, string password, string? twoFactorCode, string? apiKey, CancellationToken cancellationToken)
{
var baseUrl = GatewayUrls[Random.Shared.Next(0, GatewayUrls.Count)];
var authResult = await AuthenticateAsync(httpClient, baseUrl, email, password, twoFactorCode, apiKey, cancellationToken).ConfigureAwait(false);
return new FilenClient(httpClient, authResult, baseUrl);
}
///
/// Returns the authentication information for the user
///
/// The HTTP client to use for requests
/// The base url for all requests
/// The email address to use for login
/// The cancellation token to use for the operation
/// The authentication information for the user
private static async Task GetAuthInfoAsync(HttpClient httpClient, string baseUrl, string email, CancellationToken cancellationToken)
{
var loginUrl = $"{baseUrl}/v3/auth/info";
using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl);
request.Content = JsonContent.Create(new { email });
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false);
}
///
/// Authenticates the user with the Filen API
///
/// The HTTP client to use for requests
/// The base url for all requests
/// The email address to use for login
/// The password to use for login
/// The two-factor code to use for login
/// The API key to use for login
/// The cancellation token to use for the operation
/// The authentication result from the initial login
private static async Task AuthenticateAsync(
HttpClient httpClient,
string baseUrl,
string email,
string password,
string? twoFactorCode,
string? apiKey,
CancellationToken cancellationToken)
{
// Always need authInfo to derive the account master key from password
var authInfo = await GetAuthInfoAsync(httpClient, baseUrl, email, cancellationToken)
.ConfigureAwait(false);
var rootKeys = FilenCrypto.GeneratePasswordAndMasterKeyBasedOnAuthVersion(
password, authInfo.AuthVersion, authInfo.Salt);
// 1) Fast-path: if apiKey is provided, try to use it to fetch master keys first
if (!string.IsNullOrWhiteSpace(apiKey))
{
try
{
var mkUrl = $"{baseUrl}/v3/user/masterKeys";
using var mkReq = new HttpRequestMessage(HttpMethod.Post, mkUrl);
mkReq.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
mkReq.Content = JsonContent.Create(new { masterKeys = rootKeys.MasterKey.Key });
using var mkResp = await httpClient.SendAsync(mkReq, cancellationToken)
.ConfigureAwait(false);
mkResp.EnsureSuccessStatusCode();
var mkResult = await ExtractDataFromResponse(mkResp, cancellationToken)
.ConfigureAwait(false);
var masterKeysPlain = string.IsNullOrWhiteSpace(mkResult.MasterKeys)
? ""
: rootKeys.MasterKey.DecryptMetadata(mkResult.MasterKeys);
return new FilenAuthResult
{
ApiKey = apiKey,
AccountMasterKey = rootKeys.MasterKey,
MasterKeys = masterKeysPlain
.Split('|', StringSplitOptions.RemoveEmptyEntries)
.Prepend(rootKeys.MasterKey.Key)
.Distinct()
.Select(DerivedKey.Create)
.ToList()
};
}
catch (Exception ex)
{
Logging.Log.WriteWarningMessage(LOGTAG, "ApiKeyAuthFailed", ex, "Failed to authenticate with API key, falling back to password login");
}
}
// 2) Fallback: login endpoint (requires MFA if enabled)
if (string.IsNullOrWhiteSpace(twoFactorCode))
twoFactorCode = "XXXXXX";
var loginUrl = $"{baseUrl}/v3/login";
using var loginReq = new HttpRequestMessage(HttpMethod.Post, loginUrl);
loginReq.Content = JsonContent.Create(new
{
email,
password = rootKeys.Password,
twoFactorCode,
authVersion = authInfo.AuthVersion
});
using var loginResp = await httpClient.SendAsync(loginReq, cancellationToken)
.ConfigureAwait(false);
var loginResult = await ExtractDataFromResponse(loginResp, cancellationToken)
.ConfigureAwait(false);
var masterKeys = string.IsNullOrWhiteSpace(loginResult.MasterKeys)
? ""
: rootKeys.MasterKey.DecryptMetadata(loginResult.MasterKeys);
return new FilenAuthResult
{
ApiKey = loginResult.ApiKey,
AccountMasterKey = rootKeys.MasterKey,
MasterKeys = masterKeys
.Split('|', StringSplitOptions.RemoveEmptyEntries)
.Prepend(rootKeys.MasterKey.Key)
.Distinct()
.Select(DerivedKey.Create)
.ToList()
};
}
private sealed class MasterKeysResponse
{
public string MasterKeys { get; set; } = "";
}
///
/// Extracts the data from a response or throws an exception
///
/// The data type to extract
/// The response to extract from
/// The cancellation token to use for the operation
/// The extracted data
private static async Task ExtractDataFromResponse(HttpResponseMessage response, CancellationToken cancellationToken)
{
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize>(json);
//var result = await response.Content.ReadFromJsonAsync>(cancellationToken).ConfigureAwait(false);
if (result is null)
throw new Exception("Failed to read response");
if (!result.Status || !string.IsNullOrWhiteSpace(result.Error))
throw new UserInformationException($"{result.Error ?? result.Message ?? "Unknown"}", "FilenAPIError");
return result.Data;
}
///
/// Gets the file entry for a file in a folder
///
/// The UUID of the folder
/// The name of the file
/// The timeout for the operation
/// The cancellation token to use for the operation
/// The file entry for the file, or null if not found
public async Task GetFileEntryAsync(string folderUuid, string filename, TimeSpan timeout, CancellationToken cancellationToken)
{
var f = _cachedFiles.GetValueOrDefault($"{folderUuid}:{filename}");
if (f is not null)
return f;
return await ListFolderDecryptedAsync(folderUuid, timeout, cancellationToken)
.FirstOrDefaultAsync(e => e.Name == filename, cancellationToken)
.ConfigureAwait(false);
}
///
/// Lists the contents of a folder
///
/// The folder UUID to list
///
///
///
public async IAsyncEnumerable ListFolderDecryptedAsync(string folderUuid, TimeSpan timeout, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var result = await Utility.Utility.WithTimeout(timeout, cancellationToken, async ct =>
{
var url = $"{_baseUrl}/v3/dir/content";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey);
request.Content = JsonContent.Create(new { uuid = folderUuid });
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
var entries = new List();
foreach (var folder in result.Folders)
{
NameEntry? decryptedName = null;
try
{
decryptedName = JsonSerializer.Deserialize(_authResult.DecryptMetadata(folder.EncryptedName))
?? throw new Exception("Failed to decrypt name");
}
catch (Exception ex)
{
Logging.Log.WriteVerboseMessage(LOGTAG, "SkipFolderDueToDecryptionError", ex, "Failed to decrypt folder name");
}
if (!string.IsNullOrWhiteSpace(decryptedName?.Name))
yield return new FilenFileEntry
{
Uuid = folder.UUID,
Name = decryptedName.Name,
IsFolder = true,
LastModified = DateTimeOffset.FromUnixTimeMilliseconds(folder.LastModified).UtcDateTime,
Size = 0,
Region = string.Empty,
Bucket = string.Empty,
Chunks = 0,
FileKey = string.Empty,
Version = 0
};
}
foreach (var file in result.Files)
{
FileInfoMetadata? fileInfo = null;
try
{
fileInfo = JsonSerializer.Deserialize(_authResult.DecryptMetadata(file.MetadataEncrypted))
?? throw new Exception("Failed to decrypt metadata");
}
catch (Exception ex)
{
Logging.Log.WriteVerboseMessage(LOGTAG, "SkipFileDueToDecryptionError", ex, "Failed to decrypt file name");
}
if (!string.IsNullOrWhiteSpace(fileInfo?.Name))
yield return _cachedFiles[$"{folderUuid}:{fileInfo.Name}"] = new FilenFileEntry
{
Uuid = file.Uuid,
Name = fileInfo.Name,
IsFolder = false,
Size = file.Size,
LastModified = DateTimeOffset.FromUnixTimeMilliseconds(fileInfo.LastModified).UtcDateTime,
Region = file.Region,
Bucket = file.Bucket,
Chunks = file.Chunks,
FileKey = fileInfo.Key,
Version = file.Version
};
}
}
///
/// Deletes a file from the Filen API
///
/// The UUID of the file to delete
/// True to permanently delete the file, false to move it to the trash
/// The cancellation token to use for the operation
/// A task that completes when the file is deleted
public async Task DeleteFileAsync(string fileUuid, bool permanent, CancellationToken cancellationToken)
{
var url = permanent
? $"{_baseUrl}/v3/file/delete/permanent"
: $"{_baseUrl}/v3/file/trash";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey);
request.Content = JsonContent.Create(new { uuid = fileUuid });
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false);
var key = _cachedFiles.FirstOrDefault(kv => kv.Value.Uuid == fileUuid).Key;
if (string.IsNullOrWhiteSpace(key))
_cachedFiles.Clear();
else
_cachedFiles.Remove(key);
}
///
/// Uploads a file to the Filen API
///
/// The stream to upload
/// The name of the file
/// The UUID of the parent folder
/// The cancellation token to use for the operation
/// A task that completes when the file is uploaded
public async Task UploadStreamedEncryptedFileAsync(Stream stream, string remoteName, string parentFolderUuid, CancellationToken cancellationToken)
{
var size = stream.Length;
if (stream.Length == 0)
throw new InvalidOperationException("Cannot upload empty file");
var uploadKey = FilenCrypto.GenerateRandomString(32);
var fileKey = DerivedKey.Create(FilenCrypto.GenerateRandomString(32));
var fileUuid = Guid.NewGuid().ToString();
var chunks = await UploadEncryptedChunksAsync(stream, fileUuid, parentFolderUuid, fileKey, uploadKey, cancellationToken).ConfigureAwait(false);
if (chunks == 0)
throw new Exception("Failed to upload any chunks");
await CompleteChunkedUploadAsync(fileUuid, remoteName, size, chunks, fileKey, uploadKey, cancellationToken).ConfigureAwait(false);
}
///
/// Uploads encrypted chunks of a file to the Filen API
///
/// The stream to read from
/// The UUID of the file
/// The UUID of the parent folder
/// The encryption key for the file
/// The upload key for the file
/// The cancellation token to use for the operation
/// The number of chunks uploaded
private async Task UploadEncryptedChunksAsync(Stream inputStream, string uuid, string parentUuid, DerivedKey fileKey, string uploadKey, CancellationToken cancellationToken)
{
var buffer = new byte[ChunkSize];
var chunk = 0;
while (true)
{
int bytesRead = 0;
while (bytesRead < ChunkSize)
{
int read = await inputStream.ReadAsync(buffer, bytesRead, ChunkSize - bytesRead, cancellationToken).ConfigureAwait(false);
if (read == 0) break;
bytesRead += read;
}
Logging.Log.WriteVerboseMessage(LOGTAG, "UploadingChunk", "Uploading chunk {0}, size {1}", chunk, bytesRead);
// Stream is exhausted
if (bytesRead == 0) break;
var cipherText = fileKey.EncryptData(buffer.AsSpan().Slice(0, bytesRead));
var hash = FilenCrypto.HashChunk(cipherText);
var url = $"{IngestURLs[Random.Shared.Next(0, IngestURLs.Count)]}/v3/upload";
var qp = new Dictionary
{
{ "uuid", uuid },
{ "index", chunk.ToString() },
{ "parent", parentUuid },
{ "uploadKey", uploadKey },
{ "hash", hash }
};
url += "?" + string.Join("&", qp.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey);
request.Content = new ByteArrayContent(cipherText);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var res = await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false);
chunk++;
}
Logging.Log.WriteVerboseMessage(LOGTAG, "UploadingChunk", "Finished uploading {0} chunks", chunk);
return chunk;
}
///
/// Completes a chunked upload of a file to the Filen API
///
/// The UUID of the file
/// The name of the file
/// The size of the file
/// The number of chunks
/// The encryption key for the file
/// The upload key for the file
/// The cancellation token to use for the operation
/// A task that completes when the file is uploaded
private async Task CompleteChunkedUploadAsync(string fileUuid, string remoteName, long size, int chunks, DerivedKey fileKey, string uploadKey, CancellationToken cancellationToken)
{
var encryptedName = _authResult.EncryptMetadata(JsonSerializer.Serialize(new NameEntry() { Name = remoteName }));
var nameHashed = FilenCrypto.HashFn(remoteName);
var now = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds;
var encryptedMime = fileKey.EncryptMetadata("application/octet-stream");
var encryptedFilesize = fileKey.EncryptMetadata(JsonSerializer.Serialize(size));
var encryptedMetadata = _authResult.EncryptMetadata(JsonSerializer.Serialize(new FileInfoMetadata
{
Name = remoteName,
Size = size,
Mime = "application/octet-stream",
Key = fileKey.Key,
LastModified = now,
Create = now
}));
var completeBody = new
{
uuid = fileUuid,
name = encryptedName,
nameHashed = nameHashed,
size = encryptedFilesize,
chunks = chunks,
mime = encryptedMime,
rm = FilenCrypto.GenerateRandomString(32),
metadata = encryptedMetadata,
version = 2,
uploadKey = uploadKey,
};
var url = $"{_baseUrl}/v3/upload/done";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey);
request.Content = JsonContent.Create(completeBody);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var res = await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false);
if (res.Size != size)
throw new Exception($"Failed to upload file, size mismatch. Expected {size}, got {res.Size}");
}
///
/// Downloads and decrypts a file to a stream
///
/// The file to download
/// The stream to write to
/// The cancellation token to use for the operation
/// A task that completes when the file is downloaded
public async Task DownloadAndDecryptToStreamAsync(FilenFileEntry file, Stream outputStream, CancellationToken cancellationToken)
{
var chunk = 0;
var downloaded = 0L;
var fileKey = DerivedKey.Create(file.FileKey);
var totalSize = file.Size;
while (downloaded < totalSize)
{
if (chunk >= file.Chunks)
throw new Exception($"Attempted to download more chunks than available, expected {file.Chunks}, got {chunk}. File: {file.Name}, total size: {totalSize}, downloaded: {downloaded}");
Logging.Log.WriteVerboseMessage(LOGTAG, "DownloadingChunk", "Downloading chunk {0}, size {1}", chunk, totalSize - downloaded);
var url = $"{EgestUrls[Random.Shared.Next(0, EgestUrls.Count)]}/{file.Region}/{file.Bucket}/{file.Uuid}/{chunk}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey);
var response = await _httpClient.SendAsync(request, cancellationToken);
var encrypted = await response.Content.ReadAsByteArrayAsync(cancellationToken);
var decrypted = fileKey.DecryptData(file.Version, encrypted);
await outputStream.WriteAsync(decrypted, cancellationToken);
downloaded += decrypted.Length;
chunk++;
}
}
///
/// Creates a folder in the Filen API
///
/// The UUID of the parent folder
/// The name of the folder
/// The cancellation token to use for the operation
/// The UUID of the created folder
public async Task CreateFolderAsync(string parentUuid, string folderName, CancellationToken cancellationToken)
{
var self = Guid.NewGuid().ToString();
var encryptedName = _authResult.EncryptMetadata(JsonSerializer.Serialize(new NameEntry() { Name = folderName }));
var hashedName = FilenCrypto.HashFn(folderName);
var body = new
{
uuid = self,
name = encryptedName,
nameHashed = hashedName,
parent = parentUuid
};
var url = $"{_baseUrl}/v3/dir/create";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey);
request.Content = JsonContent.Create(body);
var response = await _httpClient.SendAsync(request, cancellationToken);
var result = await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false);
return result.Uuid;
}
///
/// Gets the UUID of the user's base folder
///
/// The cancellation token to use for the operation
/// The UUID of the user's base folder
public async Task GetUserBaseFolder(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_rootFolderUuid))
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/v3/user/baseFolder");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey);
var response = await _httpClient.SendAsync(request);
var result = await ExtractDataFromResponse(response, cancellationToken);
return _rootFolderUuid = result.Uuid;
}
return _rootFolderUuid;
}
///
/// Resolves a folder path to a UUID
///
/// The path to resolve
/// The timeout for the operation
/// The cancellation token to use for the operation
/// The UUID of the resolved folder
public async Task ResolveFolderPathAsync(string path, TimeSpan timeout, CancellationToken cancellationToken)
{
var rootFolderUuid = await GetUserBaseFolder(cancellationToken);
if (string.IsNullOrWhiteSpace(path)) return rootFolderUuid;
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
var currentUuid = rootFolderUuid;
var prev = "/";
foreach (var segment in segments)
{
var match = await ListFolderDecryptedAsync(currentUuid, timeout, cancellationToken)
.FirstOrDefaultAsync(f => f.Name == segment)
.ConfigureAwait(false);
if (match == null)
throw new FolderMissingException($"Path segment '{segment}' not found under {prev} {currentUuid}");
currentUuid = match.Uuid;
prev += segment + "/";
}
return currentUuid;
}
public void Dispose()
{
_httpClient.Dispose();
}
///
/// Renames a file in the Filen API
///
/// The UUID of the file to rename
/// The new name of the file
/// The cancellation token to use for the operation
/// A task that completes when the file is renamed
public async Task RenameFileAsync(string fileUuid, string newName, CancellationToken cancellationToken)
{
var encryptedName = _authResult.EncryptMetadata(JsonSerializer.Serialize(new NameEntry() { Name = newName }));
var url = $"{_baseUrl}/v3/file/rename";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey);
request.Content = JsonContent.Create(new { uuid = fileUuid, name = encryptedName });
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false);
// Update cache
var key = _cachedFiles.FirstOrDefault(kv => kv.Value.Uuid == fileUuid).Key;
if (!string.IsNullOrWhiteSpace(key))
_cachedFiles.Remove(key);
}
}