Files
duplicati/Duplicati/Library/Backend/Box/BoxBackend.cs
T

319 lines
13 KiB
C#

// 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;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Logging;
using Newtonsoft.Json;
using Uri = Duplicati.Library.Utility.Uri;
namespace Duplicati.Library.Backend.Box
{
public class BoxBackend : IStreamingBackend
{
private static readonly string LOGTAG = Log.LogTagFromType<BoxBackend>();
private const string AUTHID_OPTION = "authid";
private const string REALLY_DELETE_OPTION = "box-delete-from-trash";
private const string BOX_API_URL = "https://api.box.com/2.0";
private const string BOX_UPLOAD_URL = "https://upload.box.com/api/2.0/files";
private const int PAGE_SIZE = 200;
private readonly BoxHelper _oAuthHelper;
private readonly string _path;
private readonly bool _deleteFromTrash;
private string _currentFolder;
private readonly Dictionary<string, string> _fileCache = new();
private class BoxHelper : OAuthHelperHttpClient
{
public BoxHelper(string authid)
: base(authid, "box.com")
{
AutoAuthHeader = true;
}
public override void AttemptParseAndThrowException(Exception ex, HttpResponseMessage responseContext = null)
{
if (ex is not HttpRequestException || responseContext == null)
return;
if (ex is HttpRequestException && responseContext is { StatusCode: HttpStatusCode.TooManyRequests })
throw new TooManyRequestException(responseContext.Headers.RetryAfter);
using var stream = responseContext.Content.ReadAsStream();
using var reader = new StreamReader(stream);
var rawData = reader.ReadToEnd();
var errorResponse = JsonConvert.DeserializeObject<ErrorResponse>(rawData);
throw new UserInformationException($"Box.com ErrorResponse: {errorResponse.Status} - {errorResponse.Code}: {errorResponse.Message}", "box.com");
}
}
public BoxBackend()
{
}
public BoxBackend(string url, Dictionary<string, string> options)
{
var uri = new Uri(url);
_path = Util.AppendDirSeparator(uri.HostAndPath, "/");
string authid = null;
if (options.TryGetValue(AUTHID_OPTION, out var option))
authid = option;
_deleteFromTrash = Utility.Utility.ParseBoolOption(options, REALLY_DELETE_OPTION);
_oAuthHelper = new BoxHelper(authid);
}
private async Task<string> GetCurrentFolderWithCacheAsync(CancellationToken cancelToken)
{
if (_currentFolder == null)
await GetCurrentFolderAsync(false, cancelToken).ConfigureAwait(false);
return _currentFolder;
}
private async Task GetCurrentFolderAsync(bool create, CancellationToken cancelToken)
{
var parentid = "0";
foreach (var p in _path.Split(["/"], StringSplitOptions.RemoveEmptyEntries))
{
var el = (MiniFolder)await PagedFileListResponse(parentid, true, cancelToken).FirstOrDefaultAsync(x => x.Name == p, cancellationToken: cancelToken).ConfigureAwait(false);
if (el == null)
{
if (!create)
throw new FolderMissingException();
el = await _oAuthHelper.PostAndGetJsonDataAsync<ListFolderResponse>(
$"{BOX_API_URL}/folders",
new CreateItemRequest
{
Name = p, Parent = new IDReference { ID = parentid }
},
cancelToken
).ConfigureAwait(false);
}
parentid = el.ID;
}
_currentFolder = parentid;
}
private async Task<string> GetFileIdAsync(string name, CancellationToken cancelToken)
{
if (_fileCache.TryGetValue(name, out var async))
return async;
// Make sure we enumerate this, otherwise the m_filecache is empty.
var currentFolder = await GetCurrentFolderWithCacheAsync(cancelToken).ConfigureAwait(false);
await PagedFileListResponse(currentFolder, false, cancelToken).LastOrDefaultAsync(cancellationToken: cancelToken).ConfigureAwait(false);
if (_fileCache.TryGetValue(name, out var idAsync))
return idAsync;
throw new FileMissingException();
}
private async IAsyncEnumerable<FileEntity> PagedFileListResponse(string parentid, bool onlyfolders, [EnumeratorCancellation] CancellationToken cancelToken)
{
var offset = 0;
var done = false;
if (!onlyfolders)
_fileCache.Clear();
do
{
var resp = await _oAuthHelper.GetJsonDataAsync<ShortListResponse>($"{BOX_API_URL}/folders/{parentid}/items?limit={PAGE_SIZE}&offset={offset}&fields=name,size,modified_at", cancelToken).ConfigureAwait(false);
if (resp.Entries == null || resp.Entries.Length == 0)
break;
foreach (var f in resp.Entries)
{
if (onlyfolders && f.Type != "folder")
{
done = true;
break;
}
if (!onlyfolders && f.Type == "file")
_fileCache[f.Name] = f.ID;
yield return f;
}
offset = offset + PAGE_SIZE;
if (offset >= resp.TotalCount)
break;
} while (!done);
}
public async Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken)
{
var currentFolder = await GetCurrentFolderWithCacheAsync(cancelToken).ConfigureAwait(false);
var createreq = new CreateItemRequest
{
Name = remotename,
Parent = new IDReference
{
ID = currentFolder
}
};
if (_fileCache.Count == 0)
await PagedFileListResponse(currentFolder, false, cancelToken).LastOrDefaultAsync(cancelToken).ConfigureAwait(false);
var existing = _fileCache.ContainsKey(remotename);
var multipartForm = new MultipartFormDataContent();
try
{
string url;
if (existing)
url = $"{BOX_UPLOAD_URL}/{_fileCache[remotename]}/content";
else
{
url = $"{BOX_UPLOAD_URL}/content";
multipartForm.Add(JsonContent.Create(createreq),"attributes");
}
multipartForm.Add(new StreamContent(stream), "file", remotename);
var res = (await _oAuthHelper.PostMultipartAndGetJsonDataAsync<FileList>(url, cancelToken, multipartForm)).Entries.First();
_fileCache[remotename] = res.ID;
}
catch
{
_fileCache.Clear();
throw;
}
}
public async Task GetAsync(string remotename, Stream stream, CancellationToken cancelToken)
{
var fileId = await GetFileIdAsync(remotename, cancelToken).ConfigureAwait(false);
using var request = _oAuthHelper.CreateRequest($"{BOX_API_URL}/files/{fileId}/content");
using var resp = await _oAuthHelper.GetResponseAsync(request, cancelToken).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
await using var responseStream = await resp.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false);
await Utility.Utility.CopyStreamAsync(responseStream, stream, cancelToken).ConfigureAwait(false);
}
public async IAsyncEnumerable<IFileEntry> ListAsync([EnumeratorCancellation] CancellationToken cancelToken)
{
var currentFolder = await GetCurrentFolderWithCacheAsync(cancelToken).ConfigureAwait(false);
await foreach (var n in PagedFileListResponse(currentFolder, false, cancelToken).ConfigureAwait(false))
yield return new FileEntry(n.Name, n.Size, n.ModifiedAt, n.ModifiedAt) { IsFolder = n.Type == "folder" };
}
public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken)
{
await using FileStream fs = File.OpenRead(filename);
await PutAsync(remotename, fs, cancelToken).ConfigureAwait(false);
}
public async Task GetAsync(string remotename, string filename, CancellationToken cancelToken)
{
await using FileStream fs = File.Create(filename);
await GetAsync(remotename, fs, cancelToken).ConfigureAwait(false);
}
public async Task DeleteAsync(string remotename, CancellationToken cancelToken)
{
var fileId = await GetFileIdAsync(remotename, cancelToken).ConfigureAwait(false);
try
{
using (var request = _oAuthHelper.CreateRequest($"{BOX_API_URL}/files/{fileId}","DELETE"))
using (var r = await _oAuthHelper.GetResponseAsync(request, cancelToken).ConfigureAwait(false))
{
}
if (_deleteFromTrash)
{
using var request = _oAuthHelper.CreateRequest($"{BOX_API_URL}/files/{fileId}/trash","DELETE");
using (var r = await _oAuthHelper
.GetResponseAsync(request, cancelToken).ConfigureAwait(false))
{
}
}
}
catch
{
_fileCache.Clear();
throw;
}
}
public Task TestAsync(CancellationToken cancelToken)
=> this.TestListAsync(cancelToken);
public Task CreateFolderAsync(CancellationToken cancellationToken)
{
return GetCurrentFolderAsync(true, cancellationToken);
}
public string DisplayName => Strings.Box.DisplayName;
public string ProtocolKey => "box";
public IList<ICommandLineArgument> SupportedCommands =>
new List<ICommandLineArgument>([
new CommandLineArgument(AUTHID_OPTION, CommandLineArgument.ArgumentType.Password, Strings.Box.AuthidShort, Strings.Box.AuthidLong(OAuthHelper.OAUTH_LOGIN_URL("box.com"))),
new CommandLineArgument(REALLY_DELETE_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.Box.ReallydeleteShort, Strings.Box.ReallydeleteLong)
]);
public string Description => Strings.Box.Description;
public Task<string[]> GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] {
new System.Uri(BOX_API_URL).Host,
new System.Uri(BOX_UPLOAD_URL).Host
});
public void Dispose()
{
}
}
}