Files
duplicati/Duplicati/Library/Backend/Dropbox/DropboxHelper.cs
T

395 lines
18 KiB
C#

// Copyright (C) 2026, 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.Utility;
using Duplicati.Library.Utility.Options;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace Duplicati.Library.Backend
{
public class DropboxHelper : OAuthHelperHttpClient
{
private const int DROPBOX_MAX_CHUNK_UPLOAD = 10 * 1024 * 1024; // 10 MB max upload
private const string API_ARG_HEADER = "DROPBOX-API-arg";
private readonly TimeoutOptionsHelper.Timeouts m_timeouts;
public DropboxHelper(AuthIdOptionsHelper.AuthIdOptions authId, TimeoutOptionsHelper.Timeouts timeouts)
: base(authId.AuthId, "dropbox", authId.OAuthUrl)
{
m_timeouts = timeouts;
base.AutoAuthHeader = true;
// Pre 2022 tokens are direct Dropbox tokens (no ':')
// Post 2022-02-21 tokens are regular authid tokens (with a ':')
base.AccessTokenOnly = !authId.AuthId!.Contains(":");
}
public async Task<ListFolderResult> ListFiles(string path, CancellationToken cancelToken)
{
return await Utility.Utility.WithTimeout(m_timeouts.ListTimeout, cancelToken,
async ct =>
{
using var req = await CreateRequestAsync(WebApi.Dropbox.ListFilesUrl(), HttpMethod.Post, ct).ConfigureAwait(false);
req.Content = JsonContent.Create(new PathArg { path = path });
using var resp = await GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
return await resp.Content.ReadFromJsonAsync<ListFolderResult>(ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize ListFolderResult");
}).ConfigureAwait(false);
}
public async Task<ListFolderResult> ListFilesContinue(string cursor, CancellationToken cancelToken)
{
return await Utility.Utility.WithTimeout(m_timeouts.ListTimeout, cancelToken,
async ct =>
{
using var req = await CreateRequestAsync(WebApi.Dropbox.ListFilesContinueUrl(), HttpMethod.Post, ct).ConfigureAwait(false);
req.Content = JsonContent.Create(new ListFolderContinueArg() { cursor = cursor });
using var resp = await GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
return await resp.Content.ReadFromJsonAsync<ListFolderResult>(ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize ListFolderResult");
}).ConfigureAwait(false);
}
public async Task<FolderMetadata> CreateFolderAsync(string path, CancellationToken cancellationToken)
{
return await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken,
async ct =>
{
using var req = await CreateRequestAsync(WebApi.Dropbox.CreateFolderUrl(), HttpMethod.Post, ct).ConfigureAwait(false);
req.Content = JsonContent.Create(new PathArg() { path = path });
using var resp = await GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
return await resp.Content.ReadFromJsonAsync<FolderMetadata>(ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize FolderMetadata");
}).ConfigureAwait(false);
}
public async Task MoveAsync(string fromPath, string toPath, CancellationToken cancellationToken)
{
await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken,
async ct =>
{
using var req = await CreateRequestAsync(WebApi.Dropbox.MoveUrl(), HttpMethod.Post, ct).ConfigureAwait(false);
req.Content = JsonContent.Create(new { from_path = fromPath, to_path = toPath });
using var resp = await GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
// We don't need the result, just ensure success
return await resp.Content.ReadFromJsonAsync<object>(ct).ConfigureAwait(false);
}).ConfigureAwait(false);
}
public async Task<MetaData?> GetMetadataAsync(string path, CancellationToken cancellationToken)
{
return await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken,
async ct =>
{
using var req = await CreateRequestAsync(WebApi.Dropbox.GetMetadataUrl(), HttpMethod.Post, ct).ConfigureAwait(false);
req.Content = JsonContent.Create(new PathArg { path = path });
using var resp = await GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
return await resp.Content.ReadFromJsonAsync<MetaData>(ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize MetaData");
}).ConfigureAwait(false);
}
private async Task<HttpRequestMessage> CreateChunkRequestAsync<T>(string url, T arg, CancellationToken cancelToken)
{
var req = await CreateRequestAsync(url, HttpMethod.Post, cancelToken).ConfigureAwait(false);
req.Headers.Add(API_ARG_HEADER, System.Text.Json.JsonSerializer.Serialize(arg));
req.Options.Set(FileRequestOption, true);
return req;
}
private async Task<TRes> UploadChunk<TRes>(Task<HttpRequestMessage> reqTask, Stream stream, long offset, long chunksize, CancellationToken cancelToken)
{
using var req = await reqTask.ConfigureAwait(false);
using var ls = new ReadLimitLengthStream(stream, offset, Math.Min(chunksize, stream.Length - offset));
using var ts = ls.ObserveWriteTimeout(m_timeouts.ReadWriteTimeout);
req.Content = new StreamContent(ts);
req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
using var resp = await GetResponseAsync(req, HttpCompletionOption.ResponseContentRead, cancelToken).ConfigureAwait(false);
if (typeof(TRes) == typeof(object))
return default!;
return await resp.Content.ReadFromJsonAsync<TRes>(cancelToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize UploadSessionStartResult");
}
public async Task<FileMetaData> UploadFileAsync(String path, Stream stream, CancellationToken cancelToken)
{
// start a session
var chunksize = (int)Math.Min(DROPBOX_MAX_CHUNK_UPLOAD, stream.Length);
var uploadStartArgs = await UploadChunk<UploadSessionStartResult>(
CreateChunkRequestAsync(WebApi.Dropbox.UploadSessionStartUrl(), new UploadSessionStartArg(), cancelToken),
stream, 0, chunksize, cancelToken);
var position = chunksize;
// keep appending until finished
// 1) read into buffer
while (position < stream.Length)
{
var remaining = stream.Length - position;
// start an append request
var usaa = new UploadSessionAppendArg();
usaa.cursor.session_id = uploadStartArgs.session_id;
usaa.cursor.offset = (ulong)position;
usaa.close = remaining < DROPBOX_MAX_CHUNK_UPLOAD;
await UploadChunk<object>(
CreateChunkRequestAsync(WebApi.Dropbox.UploadSessionAppendUrl(), usaa, cancelToken),
stream, position, chunksize, cancelToken);
position += chunksize;
}
// finish session and commit
var usfa = new UploadSessionFinishArg();
usfa.cursor.session_id = uploadStartArgs.session_id;
usfa.cursor.offset = (ulong)stream.Length;
usfa.commit.path = path;
using var commitReq = await CreateChunkRequestAsync(WebApi.Dropbox.UploadSessionFinishUrl(), usfa, cancelToken).ConfigureAwait(false);
commitReq.Options.Set(FileRequestOption, true);
commitReq.Content = new ByteArrayContent(Array.Empty<byte>());
commitReq.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
return await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken,
async ct =>
{
using var resp = await GetResponseAsync(commitReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
return await resp.Content.ReadFromJsonAsync<FileMetaData>(ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize FileMetaData");
}
).ConfigureAwait(false);
}
public async Task DownloadFileAsync(string path, Stream fs, CancellationToken cancelToken)
{
var req = await CreateRequestAsync(WebApi.Dropbox.DownloadFilesUrl(), HttpMethod.Post, cancelToken).ConfigureAwait(false);
req.Options.Set(FileRequestOption, true);
req.Headers.Add(API_ARG_HEADER, System.Text.Json.JsonSerializer.Serialize(new PathArg { path = path }));
using (var response = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, ct => GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)).ConfigureAwait(false))
{
using (var rs = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, ct => response.Content.ReadAsStreamAsync(ct)))
using (var ts = rs.ObserveReadTimeout(m_timeouts.ReadWriteTimeout))
await Utility.Utility.CopyStreamAsync(ts, fs, cancelToken).ConfigureAwait(false);
}
}
public async Task DeleteAsync(string path, CancellationToken cancelToken)
{
await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, async ct =>
{
using var req = await CreateRequestAsync(WebApi.Dropbox.DeleteUrl(), HttpMethod.Post, ct).ConfigureAwait(false);
req.Options.Set(FileRequestOption, true);
req.Content = JsonContent.Create(new PathArg { path = path });
using var response = await GetResponseAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false);
}).ConfigureAwait(false);
}
private static readonly HttpRequestOptionsKey<bool> FileRequestOption = new HttpRequestOptionsKey<bool>("FileRequestOption");
public override async Task AttemptParseAndThrowExceptionAsync(Exception ex, HttpResponseMessage? response, CancellationToken cancellationToken)
{
string json = string.Empty;
if (response != null)
{
try
{
json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
catch
{
// If reading fails, continue with empty string
}
var isFileRequest = response.RequestMessage?.Options.TryGetValue(FileRequestOption, out var fileRequest) == true && fileRequest;
// Map HTTP status codes
switch (response.StatusCode)
{
case HttpStatusCode.NotFound:
case HttpStatusCode.Conflict:
if (isFileRequest)
throw new Interface.FileMissingException(json);
else
throw new Interface.FolderMissingException(json);
case HttpStatusCode.Unauthorized:
throw new Interface.UserInformationException(
Strings.Dropbox.AuthorizationFailure(json, OAuthLoginUrl),
"OAuthLoginError",
ex);
case (HttpStatusCode)429: // Too Many Requests
case (HttpStatusCode)507: // Insufficient Storage
throw new Interface.UserInformationException(
Strings.Dropbox.OverQuotaError(string.IsNullOrWhiteSpace(json) ? ex.Message : json),
"DropboxOverQuotaError",
ex);
}
}
JsonNode? errJson = null;
try
{
if (!string.IsNullOrWhiteSpace(json))
errJson = JsonNode.Parse(json);
}
catch
{
// Parsing failed
}
if (errJson != null)
throw new DropboxException(ex) { errorJSON = errJson };
else if (response != null)
throw new InvalidDataException($"Non-json response (code: {(int)response.StatusCode}, message: {response.ReasonPhrase}): {json}", ex);
else
throw new InvalidDataException($"Non-json response: {json}", ex);
}
}
public class DropboxException : Exception
{
public DropboxException(Exception innerException)
: base("Dropbox API error", innerException)
{
}
public JsonNode? errorJSON { get; set; }
}
public class PathArg
{
public string? path { get; set; }
}
public class FolderMetadata : MetaData
{
}
public class UploadSessionStartArg
{
// ReSharper disable once UnusedMember.Global
// This is serialized into JSON and provided in the Dropbox request header.
// A value of false indicates that the session should not be closed.
public static bool close => false;
}
public class UploadSessionAppendArg
{
public UploadSessionAppendArg()
{
cursor = new UploadSessionCursor();
}
public UploadSessionCursor cursor { get; set; }
public bool close { get; set; }
}
public class UploadSessionFinishArg
{
public UploadSessionFinishArg()
{
cursor = new UploadSessionCursor();
commit = new CommitInfo();
}
public UploadSessionCursor cursor { get; set; }
public CommitInfo commit { get; set; }
}
public class UploadSessionCursor
{
public string? session_id { get; set; }
public ulong offset { get; set; }
}
public class CommitInfo
{
public CommitInfo()
{
mode = "overwrite";
autorename = false;
mute = true;
}
public string? path { get; set; }
public string mode { get; set; }
public bool autorename { get; set; }
public bool mute { get; set; }
}
public class UploadSessionStartResult
{
public string? session_id { get; set; }
}
public class ListFolderResult
{
public MetaData[]? entries { get; set; }
public string? cursor { get; set; }
public bool has_more { get; set; }
}
public class ListFolderContinueArg
{
public string? cursor { get; set; }
}
public class MetaData
{
[JsonPropertyName(".tag")]
public string? tag { get; set; }
public string? name { get; set; }
public string? server_modified { get; set; }
public ulong size { get; set; }
public bool IsFile => tag == "file";
// While this is unused, the Dropbox API v2 documentation does not
// declare this to be optional.
// ReSharper disable once UnusedMember.Global
public string? id { get; set; }
// While this is unused, the Dropbox API v2 documentation does not
// declare this to be optional.
// ReSharper disable once UnusedMember.Global
public string? rev { get; set; }
}
public class FileMetaData : MetaData
{
}
}