// 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.Runtime.CompilerServices; using System.Text; using System.Text.Json; using Duplicati.Library.Interface; using Duplicati.Library.Utility; using Duplicati.Library.Utility.Options; namespace Duplicati.Library.Backend { /// /// FileJump backend implementation for Duplicati. /// public class Filejump : IBackend, IStreamingBackend { /// /// Name of the API URL option. /// private const string API_URL_OPTION = "api-url"; /// /// Name of the page size option. /// private const string PAGE_SIZE_OPTION = "page-size"; /// /// Name of the soft delete option. /// private const string SOFT_DELETE_OPTION = "soft-delete"; /// /// Name of the API token option. /// private const string API_TOKEN_OPTION = "api-token"; /// /// Default value for the soft delete option. /// private const string SOFT_DELETE_DEFAULT = "false"; /// /// Default API URL for filejump. /// private const string DEFAULT_API_URL = "https://drive.filejump.com/api/v1/"; /// /// The expiration time for the token. /// private static readonly TimeSpan TOKEN_EXPIRATION = TimeSpan.FromHours(1); /// /// Default page size for filejump API requests. /// private const int DEFAULT_PAGE_SIZE = 1000; /// /// Type of folder in filejump API. /// private const string FOLDER_TYPE = "folder"; /// /// The path to the filejump folder. /// private readonly string m_path; /// /// The expiration time of the token. /// private DateTime m_tokenExpiration; /// /// The HTTP client used to communicate with the filejump API. /// private HttpClient? m_client; /// /// The ID of the target folder. /// private long? m_targetPathId; /// /// The API URL for filejump. /// private readonly string m_apiUrl; /// /// The page size for filejump API requests. /// private readonly int m_pageSize; /// /// The soft delete option for filejump API requests. /// private readonly bool m_softDelete = false; /// /// The JSON serializer options used for deserializing API responses. /// private readonly JsonSerializerOptions m_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; /// /// The timeout options for filejump API requests. /// private readonly TimeoutOptionsHelper.Timeouts m_timeoutOptions; /// /// The authentication options for filejump API requests. /// private readonly AuthOptionsHelper.AuthOptions m_authOptions; /// /// The API token for filejump authentication. /// private readonly string? m_api_token; /// /// The cache for file entry IDs. /// private Dictionary? m_fileEntryIdCache; public Filejump() { m_path = null!; m_apiUrl = null!; m_timeoutOptions = null!; m_authOptions = null!; } public Filejump(string url, Dictionary options) { var u = new Utility.Uri(url); m_path = u.HostAndPath.Trim('/'); if (string.IsNullOrWhiteSpace(m_path)) m_path = "/"; else m_path = $"/{m_path}/"; m_api_token = options.GetValueOrDefault(API_TOKEN_OPTION); m_authOptions = AuthOptionsHelper.Parse(options, u); if (string.IsNullOrWhiteSpace(m_api_token) && (!m_authOptions.HasUsername || !m_authOptions.HasPassword)) throw new ArgumentException("Either an API token, or username/password are required for filejump authentication."); m_timeoutOptions = TimeoutOptionsHelper.Parse(options); m_apiUrl = options.GetValueOrDefault(API_URL_OPTION) ?? ""; if (string.IsNullOrWhiteSpace(m_apiUrl)) m_apiUrl = DEFAULT_API_URL; m_pageSize = Utility.Utility.ParseIntOption(options, PAGE_SIZE_OPTION, DEFAULT_PAGE_SIZE); m_softDelete = Utility.Utility.ParseBoolOption(options, SOFT_DELETE_OPTION); m_client = HttpClientHelper.CreateClient(); m_client.Timeout = Timeout.InfiniteTimeSpan; m_client.BaseAddress = new System.Uri(DEFAULT_API_URL); } /// public string ProtocolKey => "filejump"; /// public string DisplayName => Strings.Filejump.DisplayName; /// public string Description => Strings.Filejump.Description; /// public IList SupportedCommands => [ new CommandLineArgument(API_TOKEN_OPTION, CommandLineArgument.ArgumentType.Password, Strings.Filejump.DescriptionApitokenShort, Strings.Filejump.DescriptionApitokenLong, null), .. AuthOptionsHelper.GetOptions(), new CommandLineArgument(API_URL_OPTION, CommandLineArgument.ArgumentType.String, Strings.Filejump.DescriptionApiurlShort, Strings.Filejump.DescriptionApiurlLong, DEFAULT_API_URL), new CommandLineArgument(PAGE_SIZE_OPTION, CommandLineArgument.ArgumentType.Integer, Strings.Filejump.DescriptionPagesizeShort, Strings.Filejump.DescriptionPagesizeLong, DEFAULT_PAGE_SIZE.ToString()), new CommandLineArgument(SOFT_DELETE_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.Filejump.DescriptionSoftDeleteShort, Strings.Filejump.DescriptionSoftDeleteLong, SOFT_DELETE_DEFAULT), .. TimeoutOptionsHelper.GetOptions() ]; /// /// Creates the http client or returns a cached one if it is still valid. /// /// The cancellation token /// >The http client private async Task GetClient(CancellationToken cancelToken) { if (m_client != null && m_tokenExpiration > DateTime.UtcNow.AddMinutes(-5)) return m_client; m_client?.Dispose(); m_client = null; var client = HttpClientHelper.CreateClient(); client.Timeout = Timeout.InfiniteTimeSpan; client.BaseAddress = new System.Uri(m_apiUrl); if (!string.IsNullOrWhiteSpace(m_api_token)) { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", m_api_token); m_tokenExpiration = DateTime.UtcNow.Add(TOKEN_EXPIRATION); return m_client = client; } var data = new { email = m_authOptions.Username, password = m_authOptions.Password, token_name = $"Duplicati v{AutoUpdater.UpdaterManager.SelfVersion.Version}" }; var content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage(HttpMethod.Post, "auth/login") { Content = content }; request.Headers.Add("Accept", "application/json"); var response = await Utility.Utility.WithTimeout(m_timeoutOptions.ShortTimeout, cancelToken, ct => client.SendAsync(request, ct)); var body = await response.Content.ReadAsStringAsync(cancelToken); var result = JsonSerializer.Deserialize(body, m_jsonOptions); if (result == null || !string.Equals(result.Status, "success", StringComparison.OrdinalIgnoreCase)) throw new UserInformationException($"Failed to authenticate: {result?.Message}", "LoginFailed"); if (!string.IsNullOrWhiteSpace(result?.User?.Banned_At)) throw new UserInformationException($"User is banned: {result.User.Banned_At}", "UserBanned"); response.EnsureSuccessStatusCode(); var token = result?.User?.Access_Token ?? string.Empty; if (string.IsNullOrWhiteSpace(token)) throw new InvalidOperationException("Failed to get access token"); m_tokenExpiration = DateTime.UtcNow.Add(TOKEN_EXPIRATION); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return m_client = client; } /// public async Task PutAsync(string remotename, string filename, CancellationToken token) { using var fs = File.OpenRead(filename); await PutAsync(remotename, fs, token); } /// public async Task PutAsync(string remotename, Stream stream, CancellationToken token) { var client = await GetClient(token); var folderId = await GetTargetPathIdAsync(m_path, token); if (folderId == null && !string.IsNullOrWhiteSpace(m_path)) throw new FolderMissingException($"Folder not found: {m_path}"); // Filejump does not support overwrite, so we need to delete the previous version if it exists var cache = await GetCacheTable(token); cache.TryGetValue(remotename, out var _); await DeleteAsync(remotename, token); var content = new MultipartFormDataContent(); using var timeoutStream = stream.ObserveReadTimeout(m_timeoutOptions.ReadWriteTimeout); var fileContent = new StreamContent(timeoutStream); content.Add(fileContent, "file", remotename); if (folderId.HasValue) content.Add(new StringContent(folderId.Value.ToString()), "parentId"); using var request = new HttpRequestMessage(HttpMethod.Post, "uploads") { Content = content }; request.Headers.Add("Accept", "application/json"); var response = await client.SendAsync(request, token); var body = await response.Content.ReadAsStringAsync(token); var result = JsonSerializer.Deserialize(body, m_jsonOptions); if (result == null || !string.Equals(result.Status, "success", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Failed to upload file: {result?.Message}"); if (string.IsNullOrWhiteSpace(result?.FileEntry?.Id.ToString())) throw new InvalidOperationException($"Failed to upload file: {result?.Message}"); await EnsureSuccessStatusCode(response); cache[result.FileEntry.Name] = new(result.FileEntry.Id, result.FileEntry.Url); } /// public async Task GetAsync(string remotename, string filename, CancellationToken token) { using var fs = new FileStream(filename, FileMode.Create, FileAccess.Write); await GetAsync(remotename, fs, token); } /// public async Task GetAsync(string remotename, Stream stream, CancellationToken token) { var client = await GetClient(token); var entry = await FindFileEntryIdAsync(remotename, token); if (entry == null) throw new FileMissingException($"File not found: {remotename}"); var url = entry.Url; if (!url.StartsWith("/") && !url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) url = "/" + url; var response = await Utility.Utility.WithTimeout(m_timeoutOptions.ShortTimeout, token, ct => client.SendAsync(new HttpRequestMessage(HttpMethod.Get, url), HttpCompletionOption.ResponseHeadersRead, ct)); response.EnsureSuccessStatusCode(); using var timeoutStream = stream.ObserveWriteTimeout(m_timeoutOptions.ReadWriteTimeout); await response.Content.CopyToAsync(timeoutStream, token); } /// /// Parses the date time from the string returned by the API (ISO-8601 format). /// /// The timestamp to parse /// >The parsed date time private static DateTime ParseDateTime(string timestamp) { if (DateTime.TryParseExact( timestamp, "yyyy-MM-ddTHH:mm:ss.ffffffZ", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var result )) return result; return new DateTime(0); } /// /// Gets the folder ID for the given path. The path is split into parts and each part is searched for in the parent folder. /// /// The path to the folder /// The cancellation token /// >The ID of the folder, or null if not found private async Task GetTargetPathIdAsync(string folderPath, CancellationToken token) { if (m_targetPathId.HasValue) return m_targetPathId.Value; if (string.IsNullOrWhiteSpace(folderPath) || folderPath == "/") return null; var pathParts = folderPath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); long? currentParentId = null; foreach (var part in pathParts) { currentParentId = await GetFolderIdAsync(currentParentId, part, token); if (currentParentId == null) throw new FolderMissingException($"Folder not found: {part}"); } return m_targetPathId = currentParentId; } /// /// Gets the folder ID for the given folder. /// /// The ID of the parent folder /// The name of the folder /// The cancellation token /// >The ID of the folder, or null if not found private async Task GetFolderIdAsync(long? parentFolderId, string folderName, CancellationToken token) { var client = await GetClient(token); int page = 1; var currentParentId = parentFolderId; while (true) { // Note: In principle, we can add /&query={folderName}" to the URL, but it is super slow var url = $"drive/file-entries?page={page}&perPage={m_pageSize}&parentIds={currentParentId}&type={FOLDER_TYPE}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add("Accept", "application/json"); var response = await Utility.Utility.WithTimeout(m_timeoutOptions.ListTimeout, token, ct => client.SendAsync(request, ct)); await EnsureSuccessStatusCode(response); var body = await response.Content.ReadAsStringAsync(token); var entries = JsonSerializer.Deserialize(body, m_jsonOptions); if (entries?.Data == null || entries.Data.Count == 0) break; foreach (var entry in entries.Data) if (string.Equals(entry.Type, FOLDER_TYPE, StringComparison.OrdinalIgnoreCase) && entry.Name == folderName && string.IsNullOrWhiteSpace(entry.Deleted_At) && ((entry.Parent_Id.HasValue && entry.Parent_Id.Value == currentParentId) || (entry.Parent_Id == null && currentParentId == null))) return entry.Id; if (entries.Data.Count < entries.Per_Page || entries.Next_Page == null) break; page++; } return null; } /// /// Gets the cache table for file entries. It first checks if the cache is already populated, and if not, it lists all entries to populate the cache. /// /// The cancellation token /// >The cache table private async Task> GetCacheTable(CancellationToken token) { if (m_fileEntryIdCache != null) return m_fileEntryIdCache; await foreach (var _ in ListAsync(token)) { // The list method populates the cache } return m_fileEntryIdCache!; } /// public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken token) { var client = await GetClient(token); var folderId = await GetTargetPathIdAsync(m_path, token); int page = 1; m_fileEntryIdCache = null; var cache = new Dictionary(); while (true) { using var request = new HttpRequestMessage(HttpMethod.Get, $"drive/file-entries?page={page}&perPage={m_pageSize}&parentIds={folderId}"); request.Headers.Add("Accept", "application/json"); var response = await Utility.Utility.WithTimeout(m_timeoutOptions.ListTimeout, token, ct => client.SendAsync(request, ct)); await EnsureSuccessStatusCode(response); var body = await response.Content.ReadAsStringAsync(token); var entries = JsonSerializer.Deserialize(body, m_jsonOptions); if (entries?.Data == null || entries.Data.Count == 0) break; foreach (var entry in entries.Data) { // Ignore soft deleted files if (!string.IsNullOrWhiteSpace(entry.Deleted_At)) continue; if (entry.Type == "file") { cache[entry.Name] = new(entry.Id, entry.Url); var e = new Common.IO.FileEntry( entry.Name, entry.File_Size ); e.LastModification = ParseDateTime(entry.Updated_At); e.Created = ParseDateTime(entry.Created_At); e.IsFolder = string.Equals(entry.Type, FOLDER_TYPE, StringComparison.OrdinalIgnoreCase); yield return e; } } if (entries.Data.Count < entries.Per_Page || entries.Next_Page == null) break; page++; } m_fileEntryIdCache = cache; } /// public async Task DeleteAsync(string remotename, CancellationToken token) { var client = await GetClient(token); var entry = await FindFileEntryIdAsync(remotename, token); if (entry == null) return; var data = new { entryIds = new[] { entry.Id }, deleteForever = !m_softDelete }; var json = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage(HttpMethod.Post, "file-entries/delete") { Content = json }; request.Headers.Add("Accept", "application/json"); var response = await Utility.Utility.WithTimeout(m_timeoutOptions.ShortTimeout, token, ct => client.SendAsync(request, ct)); await EnsureSuccessStatusCode(response); } /// public async Task CreateFolderAsync(CancellationToken token) { var client = await GetClient(token); if (m_client == null || m_timeoutOptions == null) throw new InvalidOperationException("Backend not initialized correctly"); long? currentParentId = null; var pathParts = m_path.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); var newFolder = false; foreach (var part in pathParts) { var foundId = newFolder ? null : await GetFolderIdAsync(currentParentId, part, token); if (foundId != null) { currentParentId = foundId; continue; } var data = new { name = part, parentId = currentParentId }; var content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage(HttpMethod.Post, "folders") { Content = content }; request.Headers.Add("Accept", "application/json"); var response = await Utility.Utility.WithTimeout(m_timeoutOptions.ShortTimeout, token, ct => m_client.Send(request, ct)); await EnsureSuccessStatusCode(response); var body = await response.Content.ReadAsStringAsync(token); var result = JsonSerializer.Deserialize(body, m_jsonOptions); currentParentId = result?.Folder?.Id; if (!string.Equals(result?.Status, "success", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Failed to create folder: {part}, {result?.Message}"); if (currentParentId == null || currentParentId.Value == 0) throw new InvalidOperationException($"Failed to create folder: {part}"); newFolder = true; } m_targetPathId = currentParentId; } /// public Task TestAsync(CancellationToken token) => this.TestReadWritePermissionsAsync(token); /// public Task GetDNSNamesAsync(CancellationToken token) => Task.FromResult([new System.Uri(m_apiUrl).Host]); /// public void Dispose() { m_client?.Dispose(); } /// /// Locates the file entry ID for the given name. It first checks the cache, and if not found, it lists all entries to populate the cache. /// /// /// /// private async Task FindFileEntryIdAsync(string name, CancellationToken token) { if (m_fileEntryIdCache != null && m_fileEntryIdCache.TryGetValue(name, out var entry)) return entry; m_fileEntryIdCache = null; var cache = await GetCacheTable(token); return cache.TryGetValue(name, out entry) ? entry : null; } /// /// Ensures that the HTTP response was successful. /// Attempts to deserialize the response body to get the error message. /// /// The HTTP response to check /// An awaitable task private static async Task EnsureSuccessStatusCode(HttpResponseMessage response) { if (response.IsSuccessStatusCode) return; var body = await response.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (result == null || !string.IsNullOrWhiteSpace(result.Message)) throw new InvalidOperationException($"Request failed: {result?.Message}"); await EnsureSuccessStatusCode(response); } /// /// Entry for the file entry cache. /// /// The ID of the file entry /// The (relative) URL of the file entry private sealed record CachedFileEntry(long Id, string Url); /// /// Response envelope for paginated list responses. /// private class PagedListResponse { /// /// The page this response is /// public int Current_Page { get; set; } /// /// The entries in this page /// public List Data { get; set; } = new List(); /// /// The requested number of entries per page /// public int Per_Page { get; set; } /// /// The next page, or null if there are no more pages /// public int? Next_Page { get; set; } } /// /// A file entry in the filejump API. /// private class FileEntry { /// /// The ID of the file entry. /// public long Id { get; set; } /// /// The name of the file entry. /// public string Name { get; set; } = string.Empty; /// /// The type of the file entry (file or folder). /// public string Type { get; set; } = string.Empty; /// /// The URL of the file entry. /// public string Url { get; set; } = string.Empty; /// /// The size of the file entry in bytes. /// public long File_Size { get; set; } /// /// The time the entry was deleted /// public string Deleted_At { get; set; } = string.Empty; /// /// The time the entry was created /// public string Created_At { get; set; } = string.Empty; /// /// The time the entry was last updated /// public string Updated_At { get; set; } = string.Empty; /// /// The ID of the parent folder, or null if it is in the root folder. /// public long? Parent_Id { get; set; } } /// /// Response envelope for API responses. /// private class ResponseEnvelope { /// /// The error message, if any. /// public string? Message { get; set; } /// /// The status of the response. /// public string? Status { get; set; } } /// /// Response envelope for file entry responses. /// private class FileEntryOuter : ResponseEnvelope { /// /// The file entry returned by the API. /// public FileEntry? FileEntry { get; set; } } /// /// Response envelope for folder entry responses. /// private class FolderEntryOuter : ResponseEnvelope { /// /// The folder entry returned by the API. /// public FolderEntry? Folder { get; set; } } /// /// A folder entry in the filejump API. /// private class FolderEntry { /// /// The ID of the folder entry. /// public long Id { get; set; } /// /// The name of the folder entry. /// public string Name { get; set; } = string.Empty; /// /// The ID of the parent folder, or null if it is in the root folder. /// public string Type { get; set; } = string.Empty; } /// /// Response envelope for authentication responses. /// private class AuthResponseOuter : ResponseEnvelope { public AuthResponseUser? User { get; set; } } /// /// User information returned by the authentication response. /// private class AuthResponseUser { /// /// The email of the user. /// public string Email { get; set; } = string.Empty; /// /// The name of the user /// public string Name { get; set; } = string.Empty; /// /// The returned access token. /// public string Access_Token { get; set; } = string.Empty; /// /// The time the user was banned, if applicable. /// public string Banned_At { get; set; } = string.Empty; } } }