// 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; using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend { // ReSharper disable once UnusedMember.Global // This class is instantiated dynamically in the BackendLoader. public class Dropbox : IBackend, IStreamingBackend, IRenameEnabledBackend, IFolderEnabledBackend { private static readonly string TOKEN_URL = AuthIdOptionsHelper.GetOAuthLoginUrl("dropbox", null); private readonly string m_path; private readonly DropboxHelper dbx; // ReSharper disable once UnusedMember.Global // This constructor is needed by the BackendLoader. public Dropbox() { m_path = null!; dbx = null!; } // ReSharper disable once UnusedMember.Global // This constructor is needed by the BackendLoader. public Dropbox(string url, Dictionary options) { var uri = new Utility.Uri(url); m_path = Utility.Uri.UrlDecode(uri.HostAndPath); if (m_path.Length != 0 && !m_path.StartsWith("/", StringComparison.Ordinal)) m_path = "/" + m_path; if (m_path.EndsWith("/", StringComparison.Ordinal)) m_path = m_path.Substring(0, m_path.Length - 1); var authId = AuthIdOptionsHelper.Parse(options); authId.RequireCredentials(TOKEN_URL); dbx = new DropboxHelper(authId, TimeoutOptionsHelper.Parse(options)); } /// public void Dispose() { // do nothing } /// public string DisplayName => Strings.Dropbox.DisplayName; /// public string ProtocolKey => "dropbox"; /// public bool SupportsStreaming => true; private IFileEntry ParseEntry(MetaData md) { var name = md.name; if (!md.IsFile && !string.IsNullOrEmpty(name) && !name.EndsWith("/")) name += "/"; var ife = new FileEntry(name); if (md.IsFile) { ife.IsFolder = false; ife.Size = (long)md.size; } else { ife.IsFolder = true; } try { if (!string.IsNullOrWhiteSpace(md.server_modified)) ife.LastModification = ife.LastAccess = DateTime.Parse(md.server_modified).ToUniversalTime(); } catch { } return ife; } private void ThrowFolderMissingException(DropboxException de) { if (de.errorJSON?["error"]?[".tag"]?.ToString() == "path" && de.errorJSON?["error"]?["path"]?[".tag"]?.ToString() == "not_found") throw new FolderMissingException(); } private async Task HandleListExceptions(Func> func) { try { return await func().ConfigureAwait(false); } catch (DropboxException de) { ThrowFolderMissingException(de); throw; } } public IAsyncEnumerable ListAsync(CancellationToken cancelToken) => ListAsync(null, cancelToken); private string GetAbsolutePath(string? path) { if (string.IsNullOrWhiteSpace(path)) return m_path; var p = path.Replace(Path.DirectorySeparatorChar, '/').Trim('/'); if (string.IsNullOrWhiteSpace(m_path) || m_path == "/") p = "/" + p; else p = Util.AppendDirSeparator(m_path, "/") + p; if (p == "/") p = ""; return p; } public async IAsyncEnumerable ListAsync(string? path, [EnumeratorCancellation] CancellationToken cancelToken) { var fullPath = GetAbsolutePath(path); var lfr = await HandleListExceptions(() => dbx.ListFiles(fullPath, cancelToken)).ConfigureAwait(false); foreach (var md in lfr.entries ?? []) yield return ParseEntry(md); while (lfr.has_more) { lfr = await HandleListExceptions(() => dbx.ListFilesContinue(lfr.cursor!, cancelToken)).ConfigureAwait(false); foreach (var md in lfr.entries ?? []) yield return ParseEntry(md); } } public async Task GetEntryAsync(string path, CancellationToken cancellationToken) { var fullPath = GetAbsolutePath(path); if (string.IsNullOrEmpty(fullPath)) return new FileEntry(string.Empty) { IsFolder = true }; try { var md = await dbx.GetMetadataAsync(fullPath, cancellationToken).ConfigureAwait(false); if (md == null) return null; return ParseEntry(md); } catch (Exception ex) when (ex is FolderMissingException || ex is DropboxException) { return null; } } public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) { using (var fs = File.OpenRead(filename)) await PutAsync(remotename, fs, cancelToken).ConfigureAwait(false); } public async Task GetAsync(string remotename, string filename, CancellationToken cancelToken) { using (var fs = File.Create(filename)) await GetAsync(remotename, fs, cancelToken).ConfigureAwait(false); } public async Task DeleteAsync(string remotename, CancellationToken cancelToken) { try { var path = $"{m_path}/{remotename}"; await dbx.DeleteAsync(path, cancelToken).ConfigureAwait(false); } catch (DropboxException de) { ThrowFolderMissingException(de); throw; } } public IList SupportedCommands => [ .. AuthIdOptionsHelper.GetOptions(TOKEN_URL), .. TimeoutOptionsHelper.GetOptions(), ]; public string Description { get { return Strings.Dropbox.Description; } } public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(WebApi.Dropbox.Hosts()); public Task TestAsync(CancellationToken cancelToken) => this.TestReadWritePermissionsAsync(cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { try { await dbx.CreateFolderAsync(m_path, cancelToken).ConfigureAwait(false); } catch (DropboxException de) { if (de.errorJSON?["error"]?[".tag"]?.ToString() == "path" && de.errorJSON["error"]?["path"]?[".tag"]?.ToString() == "conflict") throw new FolderAreadyExistedException(); throw; } } public async Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken) { try { string path = $"{m_path}/{remotename}"; await dbx.UploadFileAsync(path, stream, cancelToken); } catch (DropboxException de) { ThrowFolderMissingException(de); throw; } } public async Task GetAsync(string remotename, Stream stream, CancellationToken cancelToken) { try { string path = string.Format("{0}/{1}", m_path, remotename); await dbx.DownloadFileAsync(path, stream, cancelToken).ConfigureAwait(false); } catch (DropboxException de) { ThrowFolderMissingException(de); throw; } } public async Task RenameAsync(string oldname, string newname, CancellationToken cancellationToken) { try { string oldPath = $"{m_path}/{oldname}"; string newPath = $"{m_path}/{newname}"; await dbx.MoveAsync(oldPath, newPath, cancellationToken).ConfigureAwait(false); } catch (DropboxException de) { ThrowFolderMissingException(de); throw; } } } }