2025-03-27 12:19:44 +01:00
// 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 ;
2025-03-27 21:32:25 +01:00
using System.Net.Http.Json ;
2025-03-27 12:19:44 +01:00
using System.Runtime.CompilerServices ;
using System.Text ;
using System.Text.Json ;
using Duplicati.Library.Interface ;
namespace Duplicati.Library.Backend.Filen ;
/// <summary>
/// The Filen client implementation
/// </summary>
public class FilenClient : IDisposable
{
/// <summary>
/// The URLs of the Filen gateways
/// </summary>
public static IReadOnlyList < string > 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"
];
/// <summary>
/// The URLs of the Filen egest servers
/// </summary>
public static IReadOnlyList < string > 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"
];
/// <summary>
/// The URLs of the Filen ingest servers
/// </summary>
public static IReadOnlyList < string > 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"
];
/// <summary>
/// The log tag for the Filen client
/// </summary>
private static string LOGTAG = Logging . Log . LogTagFromType < FilenClient >();
/// <summary>
/// The authentication result from the initial login
/// </summary>
private sealed record FilenAuthResult
{
/// <summary>
/// The API key to use for auntehticated requests
/// </summary>
public required string ApiKey { get ; init ; }
/// <summary>
/// The account master key
/// </summary>
public required DerivedKey AccountMasterKey { get ; init ; }
/// <summary>
/// The master keys for the account
/// </summary>
public required IReadOnlyList < DerivedKey > MasterKeys { get ; init ; }
/// <summary>
/// Encrypts metadata with the latest master key
/// </summary>
/// <param name="data">The metadata to encrypt</param>
/// <returns>The encrypted metadata</returns>
public string EncryptMetadata ( string data )
=> MasterKeys . Last (). EncryptMetadata ( data );
/// <summary>
/// Decrypts metadata with the correct master key
/// </summary>
/// <param name="data">The metadata to decrypt</param>
/// <returns>The decrypted metadata</returns>
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 );
}
}
/// <summary>
/// The base url for all requests
/// </summary>
private readonly string _baseUrl ;
/// <summary>
/// The fixed chunk size for uploads
/// </summary>
private const int ChunkSize = 1024 * 1024 ; // 1 MB
/// <summary>
/// The HTTP client to use for requests
/// </summary>
private readonly HttpClient _httpClient ;
/// <summary>
/// The UUID of the root folder; null until loaded
/// </summary>
private string? _rootFolderUuid ;
/// <summary>
/// The authentication result from the initial login
/// </summary>
private FilenAuthResult _authResult ;
/// <summary>
/// The time the auth token is valid until
/// </summary>
private readonly DateTime _validUntil ;
/// <summary>
/// The cached files to avoid re-fetching
/// </summary>
private readonly Dictionary < string , FilenFileEntry > _cachedFiles = new ();
/// <summary>
/// Creates a new Filen client
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests</param>
/// <param name="authResult">The authentication result from the initial login</param>
/// <param name="baseUrl">The base url for all requests</param>
private FilenClient ( HttpClient httpClient , FilenAuthResult authResult , string baseUrl )
{
_httpClient = httpClient ;
_authResult = authResult ;
_baseUrl = baseUrl ;
_validUntil = DateTime . Now + TimeSpan . FromMinutes ( 50 );
}
/// <summary>
/// The time the client is valid
/// </summary>
public DateTime ValidUntil => _validUntil ;
2025-12-05 16:28:27 +01:00
/// <summary>
/// The API key for the client
/// </summary>
internal string ApiKey => _authResult . ApiKey ;
2025-03-27 12:19:44 +01:00
/// <summary>
/// Creates a new Filen client and authenticates
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests</param>
/// <param name="email">The email address to use for login</param>
/// <param name="password">The password to use for login</param>
/// <param name="twoFactorCode">The two-factor code to use for login</param>
2025-12-05 16:28:27 +01:00
/// <param name="apiKey">The API key to use for login</param>
2025-03-27 12:19:44 +01:00
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The authenticated Filen client</returns>
2025-12-05 16:28:27 +01:00
public static async Task < FilenClient > CreateClientAsync ( HttpClient httpClient , string email , string password , string? twoFactorCode , string? apiKey , CancellationToken cancellationToken )
2025-03-27 12:19:44 +01:00
{
var baseUrl = GatewayUrls [ Random . Shared . Next ( 0 , GatewayUrls . Count )];
2025-12-05 16:28:27 +01:00
var authResult = await AuthenticateAsync ( httpClient , baseUrl , email , password , twoFactorCode , apiKey , cancellationToken ). ConfigureAwait ( false );
2025-03-27 12:19:44 +01:00
return new FilenClient ( httpClient , authResult , baseUrl );
}
/// <summary>
2025-12-05 16:28:27 +01:00
/// Returns the authentication information for the user
2025-03-27 12:19:44 +01:00
/// </summary>
2025-12-05 16:28:27 +01:00
/// <param name="httpClient">The HTTP client to use for requests</param>
/// <param name="baseUrl">The base url for all requests</param>
/// <param name="email">The email address to use for login</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The authentication information for the user</returns>
private static async Task < AuthInfo > GetAuthInfoAsync ( HttpClient httpClient , string baseUrl , string email , CancellationToken cancellationToken )
2025-03-27 12:19:44 +01:00
{
2025-12-05 16:28:27 +01:00
var loginUrl = $"{baseUrl}/v3/auth/info" ;
using var request = new HttpRequestMessage ( HttpMethod . Post , loginUrl );
2026-01-26 15:52:40 +01:00
request . Content = JsonContent . Create ( new { email });
2025-03-27 12:19:44 +01:00
2025-12-05 16:28:27 +01:00
var response = await httpClient . SendAsync ( request , cancellationToken ). ConfigureAwait ( false );
return await ExtractDataFromResponse < AuthInfo >( response , cancellationToken ). ConfigureAwait ( false );
}
2025-03-27 12:19:44 +01:00
2025-12-05 16:28:27 +01:00
/// <summary>
/// Authenticates the user with the Filen API
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests</param>
/// <param name="baseUrl">The base url for all requests</param>
/// <param name="email">The email address to use for login</param>
/// <param name="password">The password to use for login</param>
/// <param name="twoFactorCode">The two-factor code to use for login</param>
/// <param name="apiKey">The API key to use for login</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The authentication result from the initial login</returns>
private static async Task < FilenAuthResult > 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 ))
2025-03-27 12:19:44 +01:00
{
2025-12-05 16:28:27 +01:00
try
{
var mkUrl = $"{baseUrl}/v3/user/masterKeys" ;
using var mkReq = new HttpRequestMessage ( HttpMethod . Post , mkUrl );
mkReq . Headers . Authorization =
2026-01-26 15:52:40 +01:00
new AuthenticationHeaderValue ( "Bearer" , apiKey );
2025-03-27 12:19:44 +01:00
2026-01-26 15:52:40 +01:00
mkReq . Content = JsonContent . Create ( new { masterKeys = rootKeys . MasterKey . Key });
2025-12-05 16:28:27 +01:00
using var mkResp = await httpClient . SendAsync ( mkReq , cancellationToken )
. ConfigureAwait ( false );
2025-03-27 12:19:44 +01:00
2025-12-05 16:28:27 +01:00
mkResp . EnsureSuccessStatusCode ();
2025-03-27 12:19:44 +01:00
2025-12-05 16:28:27 +01:00
var mkResult = await ExtractDataFromResponse < MasterKeysResponse >( mkResp , cancellationToken )
. ConfigureAwait ( false );
2025-03-27 12:19:44 +01:00
2026-01-26 15:52:40 +01:00
var masterKeysPlain = string . IsNullOrWhiteSpace ( mkResult . MasterKeys )
? ""
: rootKeys . MasterKey . DecryptMetadata ( mkResult . MasterKeys );
2025-03-27 12:19:44 +01:00
2025-12-05 16:28:27 +01:00
return new FilenAuthResult
{
ApiKey = apiKey ,
AccountMasterKey = rootKeys . MasterKey ,
2026-01-26 15:52:40 +01:00
MasterKeys = masterKeysPlain
. Split ( '|' , StringSplitOptions . RemoveEmptyEntries )
. Prepend ( rootKeys . MasterKey . Key )
. Distinct ()
. Select ( DerivedKey . Create )
. ToList ()
2025-12-05 16:28:27 +01:00
};
}
2026-01-26 15:52:40 +01:00
catch ( Exception ex )
2025-03-27 12:19:44 +01:00
{
2026-01-26 15:52:40 +01:00
Logging . Log . WriteWarningMessage ( LOGTAG , "ApiKeyAuthFailed" , ex , "Failed to authenticate with API key, falling back to password login" );
2025-12-05 16:28:27 +01:00
}
2025-03-27 12:19:44 +01:00
}
2025-12-05 16:28:27 +01:00
// 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 );
2026-01-26 15:52:40 +01:00
loginReq . Content = JsonContent . Create ( new
{
email ,
password = rootKeys . Password ,
twoFactorCode ,
authVersion = authInfo . AuthVersion
});
2025-12-05 16:28:27 +01:00
using var loginResp = await httpClient . SendAsync ( loginReq , cancellationToken )
. ConfigureAwait ( false );
var loginResult = await ExtractDataFromResponse < AuthResponse >( loginResp , cancellationToken )
. ConfigureAwait ( false );
2026-01-26 15:52:40 +01:00
var masterKeys = string . IsNullOrWhiteSpace ( loginResult . MasterKeys )
? ""
: rootKeys . MasterKey . DecryptMetadata ( loginResult . MasterKeys );
2025-12-05 16:28:27 +01:00
return new FilenAuthResult
{
ApiKey = loginResult . ApiKey ,
AccountMasterKey = rootKeys . MasterKey ,
2026-01-26 15:52:40 +01:00
MasterKeys = masterKeys
. Split ( '|' , StringSplitOptions . RemoveEmptyEntries )
. Prepend ( rootKeys . MasterKey . Key )
. Distinct ()
. Select ( DerivedKey . Create )
. ToList ()
2025-12-05 16:28:27 +01:00
};
}
private sealed class MasterKeysResponse
{
public string MasterKeys { get ; set ; } = "" ;
2025-03-27 12:19:44 +01:00
}
/// <summary>
/// Extracts the data from a response or throws an exception
/// </summary>
/// <typeparam name="T">The data type to extract</typeparam>
/// <param name="response">The response to extract from</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The extracted data</returns>
private static async Task < T > ExtractDataFromResponse < T >( HttpResponseMessage response , CancellationToken cancellationToken )
{
response . EnsureSuccessStatusCode ();
2026-01-26 15:52:40 +01:00
var json = await response . Content . ReadAsStringAsync ( cancellationToken ). ConfigureAwait ( false );
var result = JsonSerializer . Deserialize < FilenResponseEnvelope < T >>( json );
//var result = await response.Content.ReadFromJsonAsync<FilenResponseEnvelope<T>>(cancellationToken).ConfigureAwait(false);
2025-03-27 12:19:44 +01:00
if ( result is null )
throw new Exception ( "Failed to read response" );
if (! result . Status || ! string . IsNullOrWhiteSpace ( result . Error ))
2025-03-27 21:32:25 +01:00
throw new UserInformationException ( $"{result.Error ?? result.Message ?? " Unknown "}" , "FilenAPIError" );
2025-03-27 12:19:44 +01:00
return result . Data ;
}
/// <summary>
/// Gets the file entry for a file in a folder
/// </summary>
/// <param name="folderUuid">The UUID of the folder</param>
/// <param name="filename">The name of the file</param>
/// <param name="timeout">The timeout for the operation</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The file entry for the file, or null if not found</returns>
public async Task < FilenFileEntry ?> 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 );
}
/// <summary>
/// Lists the contents of a folder
/// </summary>
/// <param name="folderUuid">The folder UUID to list</param>
/// <param name="timeout"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async IAsyncEnumerable < FilenFileEntry > 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 );
2026-01-26 15:52:40 +01:00
request . Content = JsonContent . Create ( new { uuid = folderUuid });
2025-03-27 12:19:44 +01:00
using var response = await _httpClient . SendAsync ( request , cancellationToken ). ConfigureAwait ( false );
return await ExtractDataFromResponse < DirListData >( response , cancellationToken ). ConfigureAwait ( false );
}). ConfigureAwait ( false );
var entries = new List < FilenFileEntry >();
foreach ( var folder in result . Folders )
{
NameEntry ? decryptedName = null ;
try
{
decryptedName = JsonSerializer . Deserialize < NameEntry >( _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 < FileInfoMetadata >( _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
};
}
}
/// <summary>
/// Deletes a file from the Filen API
/// </summary>
/// <param name="fileUuid">The UUID of the file to delete</param>
/// <param name="permanent">True to permanently delete the file, false to move it to the trash</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>A task that completes when the file is deleted</returns>
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 );
2026-01-26 15:52:40 +01:00
request . Content = JsonContent . Create ( new { uuid = fileUuid });
2025-03-27 12:19:44 +01:00
var response = await _httpClient . SendAsync ( request , cancellationToken ). ConfigureAwait ( false );
await ExtractDataFromResponse < string? >( response , cancellationToken ). ConfigureAwait ( false );
var key = _cachedFiles . FirstOrDefault ( kv => kv . Value . Uuid == fileUuid ). Key ;
if ( string . IsNullOrWhiteSpace ( key ))
_cachedFiles . Clear ();
else
_cachedFiles . Remove ( key );
}
/// <summary>
/// Uploads a file to the Filen API
/// </summary>
/// <param name="stream">The stream to upload</param>
/// <param name="remoteName">The name of the file</param>
/// <param name="parentFolderUuid">The UUID of the parent folder</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>A task that completes when the file is uploaded</returns>
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 );
}
/// <summary>
/// Uploads encrypted chunks of a file to the Filen API
/// </summary>
/// <param name="inputStream">The stream to read from</param>
/// <param name="uuid">The UUID of the file</param>
/// <param name="parentUuid">The UUID of the parent folder</param>
/// <param name="fileKey">The encryption key for the file</param>
/// <param name="uploadKey">The upload key for the file</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The number of chunks uploaded</returns>
private async Task < int > 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 ;
}
2025-03-27 21:32:25 +01:00
Logging . Log . WriteVerboseMessage ( LOGTAG , "UploadingChunk" , "Uploading chunk {0}, size {1}" , chunk , bytesRead );
2025-03-27 12:19:44 +01:00
// 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 < string , string >
{
{ "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 < ChunkUploadResponse >( response , cancellationToken ). ConfigureAwait ( false );
chunk ++;
}
2025-03-27 21:32:25 +01:00
Logging . Log . WriteVerboseMessage ( LOGTAG , "UploadingChunk" , "Finished uploading {0} chunks" , chunk );
2025-03-27 12:19:44 +01:00
return chunk ;
}
/// <summary>
/// Completes a chunked upload of a file to the Filen API
/// </summary>
/// <param name="fileUuid">The UUID of the file</param>
/// <param name="remoteName">The name of the file</param>
/// <param name="size">The size of the file</param>
/// <param name="chunks">The number of chunks</param>
/// <param name="fileKey">The encryption key for the file</param>
/// <param name="uploadKey">The upload key for the file</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>A task that completes when the file is uploaded</returns>
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 );
2026-01-26 15:52:40 +01:00
request . Content = JsonContent . Create ( completeBody );
2025-03-27 12:19:44 +01:00
var response = await _httpClient . SendAsync ( request , cancellationToken ). ConfigureAwait ( false );
var res = await ExtractDataFromResponse < FileUploadResponse >( response , cancellationToken ). ConfigureAwait ( false );
if ( res . Size != size )
throw new Exception ( $"Failed to upload file, size mismatch. Expected {size}, got {res.Size}" );
}
/// <summary>
/// Downloads and decrypts a file to a stream
/// </summary>
/// <param name="file">The file to download</param>
/// <param name="outputStream">The stream to write to</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>A task that completes when the file is downloaded</returns>
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 )
{
2025-03-27 21:32:25 +01:00
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 );
2025-03-27 12:19:44 +01:00
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 ++;
}
}
/// <summary>
/// Creates a folder in the Filen API
/// </summary>
/// <param name="parentUuid">The UUID of the parent folder</param>
/// <param name="folderName">The name of the folder</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The UUID of the created folder</returns>
public async Task < string > 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 );
2026-01-26 15:52:40 +01:00
request . Content = JsonContent . Create ( body );
2025-03-27 12:19:44 +01:00
var response = await _httpClient . SendAsync ( request , cancellationToken );
var result = await ExtractDataFromResponse < CreateFolderResponse >( response , cancellationToken ). ConfigureAwait ( false );
return result . Uuid ;
}
/// <summary>
/// Gets the UUID of the user's base folder
/// </summary>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The UUID of the user's base folder</returns>
public async Task < string > 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 < CreateFolderResponse >( response , cancellationToken );
return _rootFolderUuid = result . Uuid ;
}
return _rootFolderUuid ;
}
/// <summary>
/// Resolves a folder path to a UUID
/// </summary>
/// <param name="path">The path to resolve</param>
/// <param name="timeout">The timeout for the operation</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>The UUID of the resolved folder</returns>
public async Task < string > 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 ();
}
2026-01-27 13:08:20 +01:00
/// <summary>
/// Renames a file in the Filen API
/// </summary>
/// <param name="fileUuid">The UUID of the file to rename</param>
/// <param name="newName">The new name of the file</param>
/// <param name="cancellationToken">The cancellation token to use for the operation</param>
/// <returns>A task that completes when the file is renamed</returns>
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 < string? >( response , cancellationToken ). ConfigureAwait ( false );
// Update cache
var key = _cachedFiles . FirstOrDefault ( kv => kv . Value . Uuid == fileUuid ). Key ;
if (! string . IsNullOrWhiteSpace ( key ))
_cachedFiles . Remove ( key );
}
2025-03-27 12:19:44 +01:00
}