// 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 Duplicati.Library.Utility; 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(); 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 _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) { AttemptParseAndThrowExceptionAsync(ex, responseContext).Await(); } public override async Task AttemptParseAndThrowExceptionAsync(Exception ex, HttpResponseMessage responseContext = null, CancellationToken cancellationToken = default) { if (ex is not HttpRequestException || responseContext == null) return; if (responseContext is { StatusCode: HttpStatusCode.TooManyRequests }) throw new TooManyRequestException(responseContext.Headers.RetryAfter); await using var stream = await responseContext.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var reader = new StreamReader(stream); var rawData = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); var errorResponse = JsonConvert.DeserializeObject(rawData); throw new UserInformationException($"Box.com ErrorResponse: {errorResponse.Status} - {errorResponse.Code}: {errorResponse.Message}", "box.com"); } } public BoxBackend() { } public BoxBackend(string url, Dictionary 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 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( $"{BOX_API_URL}/folders", new CreateItemRequest { Name = p, Parent = new IDReference { ID = parentid } }, cancelToken ).ConfigureAwait(false); } parentid = el.ID; } _currentFolder = parentid; } private async Task 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 PagedFileListResponse(string parentid, bool onlyfolders, [EnumeratorCancellation] CancellationToken cancelToken) { var offset = 0; var done = false; if (!onlyfolders) _fileCache.Clear(); do { var resp = await _oAuthHelper.GetJsonDataAsync($"{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(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 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 SupportedCommands => new List([ 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 GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(new[] { new System.Uri(BOX_API_URL).Host, new System.Uri(BOX_UPLOAD_URL).Host }); public void Dispose() { } } }