395 lines
18 KiB
C#
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
|
|
{
|
|
|
|
}
|
|
}
|