diff --git a/Duplicati/Library/Backend/Box/BoxBackend.cs b/Duplicati/Library/Backend/Box/BoxBackend.cs index ec1c21cde..5ecbe7ce8 100644 --- a/Duplicati/Library/Backend/Box/BoxBackend.cs +++ b/Duplicati/Library/Backend/Box/BoxBackend.cs @@ -19,20 +19,13 @@ // 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 Duplicati.Library.Utility.Options; using Newtonsoft.Json; using Uri = Duplicati.Library.Utility.Uri; @@ -40,8 +33,7 @@ namespace Duplicati.Library.Backend.Box { public class BoxBackend : IStreamingBackend { - private static readonly string LOGTAG = Log.LogTagFromType(); - + private static readonly string TOKEN_URL = OAuthHelper.OAUTH_LOGIN_URL("box.com"); private const string AUTHID_OPTION = "authid"; private const string REALLY_DELETE_OPTION = "box-delete-from-trash"; @@ -54,24 +46,21 @@ namespace Duplicati.Library.Backend.Box private readonly string _path; private readonly bool _deleteFromTrash; - private string _currentFolder; + private string? _currentFolder; private readonly Dictionary _fileCache = new(); + private readonly TimeoutOptionsHelper.Timeouts _timeouts; private class BoxHelper : OAuthHelperHttpClient { - public BoxHelper(string authid) + private readonly TimeoutOptionsHelper.Timeouts _timeouts; + public BoxHelper(string authid, TimeoutOptionsHelper.Timeouts timeouts) : base(authid, "box.com") { AutoAuthHeader = true; + _timeouts = timeouts; } - 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) + public override async Task AttemptParseAndThrowExceptionAsync(Exception ex, HttpResponseMessage responseContext, CancellationToken cancellationToken) { if (ex is not HttpRequestException || responseContext == null) return; @@ -81,66 +70,74 @@ namespace Duplicati.Library.Backend.Box 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); + var rawData = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, ct => reader.ReadToEndAsync(ct)).ConfigureAwait(false); + var errorResponse = JsonConvert.DeserializeObject(rawData) + ?? new ErrorResponse { Status = (int)responseContext.StatusCode, Code = "Unknown", Message = rawData }; throw new UserInformationException($"Box.com ErrorResponse: {errorResponse.Status} - {errorResponse.Code}: {errorResponse.Message}", "box.com"); } } public BoxBackend() { + _oAuthHelper = null!; + _path = null!; + _timeouts = null!; } - public BoxBackend(string url, Dictionary options) + 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; + var authid = AuthIdOptionsHelper.Parse(options) + .RequireCredentials(TOKEN_URL) + .AuthId!; _deleteFromTrash = Utility.Utility.ParseBoolOption(options, REALLY_DELETE_OPTION); + _timeouts = TimeoutOptionsHelper.Parse(options); + + _oAuthHelper = new BoxHelper(authid, _timeouts); - _oAuthHelper = new BoxHelper(authid); } private async Task GetCurrentFolderWithCacheAsync(CancellationToken cancelToken) { if (_currentFolder == null) - await GetCurrentFolderAsync(false, cancelToken).ConfigureAwait(false); + return await GetCurrentFolderAsync(false, cancelToken).ConfigureAwait(false); return _currentFolder; } - private async Task GetCurrentFolderAsync(bool create, CancellationToken cancelToken) + 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); + 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( + el = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => _oAuthHelper.PostAndGetJsonDataAsync( $"{BOX_API_URL}/folders", new CreateItemRequest { Name = p, Parent = new IDReference { ID = parentid } }, - cancelToken - ).ConfigureAwait(false); + ct + )).ConfigureAwait(false); } parentid = el.ID; + if (string.IsNullOrWhiteSpace(parentid)) + throw new InvalidDataException($"Invalid folder ID for {p} in {_path}"); } - _currentFolder = parentid; + return _currentFolder = parentid; } private async Task GetFileIdAsync(string name, CancellationToken cancelToken) @@ -168,13 +165,16 @@ namespace Duplicati.Library.Backend.Box 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); + var resp = await Utility.Utility.WithTimeout(_timeouts.ListTimeout, cancelToken, ct => _oAuthHelper.GetJsonDataAsync($"{BOX_API_URL}/folders/{parentid}/items?limit={PAGE_SIZE}&offset={offset}&fields=name,size,modified_at", ct)).ConfigureAwait(false); if (resp.Entries == null || resp.Entries.Length == 0) break; foreach (var f in resp.Entries) { + if (string.IsNullOrWhiteSpace(f.Name) || string.IsNullOrWhiteSpace(f.ID)) + continue; + if (onlyfolders && f.Type != "folder") { done = true; @@ -226,9 +226,12 @@ namespace Duplicati.Library.Backend.Box multipartForm.Add(JsonContent.Create(createreq), "attributes"); } - multipartForm.Add(new StreamContent(stream), "file", remotename); + using var timeoutStream = stream.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false); + multipartForm.Add(new StreamContent(timeoutStream), "file", remotename); - var res = (await _oAuthHelper.PostMultipartAndGetJsonDataAsync(url, cancelToken, multipartForm)).Entries.First(); + var res = (await _oAuthHelper.PostMultipartAndGetJsonDataAsync(url, cancelToken, multipartForm)).Entries?.FirstOrDefault(); + if (res == null || string.IsNullOrWhiteSpace(res.ID)) + throw new InvalidDataException("No file ID returned after upload"); _fileCache[remotename] = res.ID; } catch @@ -241,11 +244,12 @@ namespace Duplicati.Library.Backend.Box 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); + using var request = await _oAuthHelper.CreateRequestAsync($"{BOX_API_URL}/files/{fileId}/content", HttpMethod.Get, cancelToken).ConfigureAwait(false); + using var resp = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => _oAuthHelper.GetResponseAsync(request, HttpCompletionOption.ResponseHeadersRead, ct)).ConfigureAwait(false); resp.EnsureSuccessStatusCode(); - await using var responseStream = await resp.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false); - await Utility.Utility.CopyStreamAsync(responseStream, stream, cancelToken).ConfigureAwait(false); + await using var responseStream = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => resp.Content.ReadAsStreamAsync(ct)).ConfigureAwait(false); + using var ts = responseStream.ObserveReadTimeout(_timeouts.ReadWriteTimeout); + await Utility.Utility.CopyStreamAsync(ts, stream, cancelToken).ConfigureAwait(false); } public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) @@ -272,16 +276,15 @@ namespace Duplicati.Library.Backend.Box 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)) + using (var request = await _oAuthHelper.CreateRequestAsync($"{BOX_API_URL}/files/{fileId}", HttpMethod.Delete, cancelToken).ConfigureAwait(false)) + using (var r = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => _oAuthHelper.GetResponseAsync(request, HttpCompletionOption.ResponseContentRead, ct)).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)) + using (var request = await _oAuthHelper.CreateRequestAsync($"{BOX_API_URL}/files/{fileId}/trash", HttpMethod.Delete, cancelToken).ConfigureAwait(false)) + using (var r = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, ct => _oAuthHelper.GetResponseAsync(request, HttpCompletionOption.ResponseContentRead, ct)).ConfigureAwait(false)) { } } @@ -306,17 +309,18 @@ namespace Duplicati.Library.Backend.Box 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) - ]); + [ + .. AuthIdOptionsHelper.GetOptions(TOKEN_URL), + new CommandLineArgument(REALLY_DELETE_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.Box.ReallydeleteShort, Strings.Box.ReallydeleteLong), + .. TimeoutOptionsHelper.GetOptions() + ]; 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 - }); + }.Distinct().WhereNotNullOrWhiteSpace().ToArray()); public void Dispose() { diff --git a/Duplicati/Library/Backend/Box/Model/CreateItemRequest.cs b/Duplicati/Library/Backend/Box/Model/CreateItemRequest.cs index b84ecd32b..c37a29db3 100644 --- a/Duplicati/Library/Backend/Box/Model/CreateItemRequest.cs +++ b/Duplicati/Library/Backend/Box/Model/CreateItemRequest.cs @@ -4,6 +4,6 @@ namespace Duplicati.Library.Backend.Box; public class CreateItemRequest { - [JsonProperty("name")] public string Name { get; set; } - [JsonProperty("parent")] public IDReference Parent { get; set; } + [JsonProperty("name")] public string? Name { get; set; } + [JsonProperty("parent")] public IDReference? Parent { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/ErrorResponse.cs b/Duplicati/Library/Backend/Box/Model/ErrorResponse.cs index 627780c8f..888974aed 100644 --- a/Duplicati/Library/Backend/Box/Model/ErrorResponse.cs +++ b/Duplicati/Library/Backend/Box/Model/ErrorResponse.cs @@ -4,10 +4,10 @@ namespace Duplicati.Library.Backend.Box; public class ErrorResponse { - [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("type")] public string? Type { get; set; } [JsonProperty("status")] public int Status { get; set; } - [JsonProperty("code")] public string Code { get; set; } - [JsonProperty("help_url")] public string HelpUrl { get; set; } - [JsonProperty("message")] public string Message { get; set; } - [JsonProperty("request_id")] public string RequestId { get; set; } + [JsonProperty("code")] public string? Code { get; set; } + [JsonProperty("help_url")] public string? HelpUrl { get; set; } + [JsonProperty("message")] public string? Message { get; set; } + [JsonProperty("request_id")] public string? RequestId { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/FileEntity.cs b/Duplicati/Library/Backend/Box/Model/FileEntity.cs index 55e222724..c55b2b15a 100644 --- a/Duplicati/Library/Backend/Box/Model/FileEntity.cs +++ b/Duplicati/Library/Backend/Box/Model/FileEntity.cs @@ -1,4 +1,3 @@ -using System; using Newtonsoft.Json; namespace Duplicati.Library.Backend.Box; @@ -10,7 +9,7 @@ public class FileEntity : MiniFolder Size = -1; } - [JsonProperty("sha1")] public string SHA1 { get; set; } + [JsonProperty("sha1")] public string? SHA1 { get; set; } [JsonProperty("size", NullValueHandling = NullValueHandling.Ignore)] public long Size { get; set; } diff --git a/Duplicati/Library/Backend/Box/Model/FileList.cs b/Duplicati/Library/Backend/Box/Model/FileList.cs index 999aab376..5fb514cc9 100644 --- a/Duplicati/Library/Backend/Box/Model/FileList.cs +++ b/Duplicati/Library/Backend/Box/Model/FileList.cs @@ -5,7 +5,7 @@ namespace Duplicati.Library.Backend.Box; public class FileList { [JsonProperty("total_count")] public long TotalCount { get; set; } - [JsonProperty("entries")] public FileEntity[] Entries { get; set; } + [JsonProperty("entries")] public FileEntity[]? Entries { get; set; } [JsonProperty("offset")] public long Offset { get; set; } [JsonProperty("limit")] public long Limit { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/FolderList.cs b/Duplicati/Library/Backend/Box/Model/FolderList.cs index 028188322..778c7091c 100644 --- a/Duplicati/Library/Backend/Box/Model/FolderList.cs +++ b/Duplicati/Library/Backend/Box/Model/FolderList.cs @@ -5,5 +5,5 @@ namespace Duplicati.Library.Backend.Box; public class FolderList { [JsonProperty("total_count")] public long TotalCount { get; set; } - [JsonProperty("entries")] public MiniFolder[] Entries { get; set; } + [JsonProperty("entries")] public MiniFolder[]? Entries { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/IDReference.cs b/Duplicati/Library/Backend/Box/Model/IDReference.cs index 29ba6cf14..f0e0410c3 100644 --- a/Duplicati/Library/Backend/Box/Model/IDReference.cs +++ b/Duplicati/Library/Backend/Box/Model/IDReference.cs @@ -4,5 +4,5 @@ namespace Duplicati.Library.Backend.Box; public class IDReference { - [JsonProperty("id")] public string ID { get; set; } + [JsonProperty("id")] public string? ID { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/ListFolderResponse.cs b/Duplicati/Library/Backend/Box/Model/ListFolderResponse.cs index 395bdaaba..6231d2e17 100644 --- a/Duplicati/Library/Backend/Box/Model/ListFolderResponse.cs +++ b/Duplicati/Library/Backend/Box/Model/ListFolderResponse.cs @@ -7,15 +7,15 @@ public class ListFolderResponse : MiniFolder { [JsonProperty("created_at")] public DateTime CreatedAt { get; set; } [JsonProperty("modified_at")] public DateTime ModifiedAt { get; set; } - [JsonProperty("description")] public string Description { get; set; } + [JsonProperty("description")] public string? Description { get; set; } [JsonProperty("size")] public long Size { get; set; } - [JsonProperty("path_collection")] public FolderList PathCollection { get; set; } - [JsonProperty("created_by")] public MiniUser CreatedBy { get; set; } - [JsonProperty("modified_by")] public MiniUser ModifiedBy { get; set; } - [JsonProperty("owned_by")] public MiniUser OwnedBy { get; set; } - [JsonProperty("shared_link")] public MiniUser SharedLink { get; set; } - [JsonProperty("folder_upload_email")] public UploadEmail FolderUploadEmail { get; set; } - [JsonProperty("parent")] public MiniFolder Parent { get; set; } - [JsonProperty("item_status")] public string ItemStatus { get; set; } - [JsonProperty("item_collection")] public FileList ItemCollection { get; set; } + [JsonProperty("path_collection")] public FolderList? PathCollection { get; set; } + [JsonProperty("created_by")] public MiniUser? CreatedBy { get; set; } + [JsonProperty("modified_by")] public MiniUser? ModifiedBy { get; set; } + [JsonProperty("owned_by")] public MiniUser? OwnedBy { get; set; } + [JsonProperty("shared_link")] public MiniUser? SharedLink { get; set; } + [JsonProperty("folder_upload_email")] public UploadEmail? FolderUploadEmail { get; set; } + [JsonProperty("parent")] public MiniFolder? Parent { get; set; } + [JsonProperty("item_status")] public string? ItemStatus { get; set; } + [JsonProperty("item_collection")] public FileList? ItemCollection { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/MiniFolder.cs b/Duplicati/Library/Backend/Box/Model/MiniFolder.cs index 26363b542..5d5f41029 100644 --- a/Duplicati/Library/Backend/Box/Model/MiniFolder.cs +++ b/Duplicati/Library/Backend/Box/Model/MiniFolder.cs @@ -4,8 +4,8 @@ namespace Duplicati.Library.Backend.Box; public class MiniFolder : IDReference { - [JsonProperty("type")] public string Type { get; set; } - [JsonProperty("name")] public string Name { get; set; } - [JsonProperty("etag")] public string ETag { get; set; } - [JsonProperty("sequence_id")] public string SequenceID { get; set; } + [JsonProperty("type")] public string? Type { get; set; } + [JsonProperty("name")] public string? Name { get; set; } + [JsonProperty("etag")] public string? ETag { get; set; } + [JsonProperty("sequence_id")] public string? SequenceID { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/MiniUser.cs b/Duplicati/Library/Backend/Box/Model/MiniUser.cs index cc3af66ef..0f96851fc 100644 --- a/Duplicati/Library/Backend/Box/Model/MiniUser.cs +++ b/Duplicati/Library/Backend/Box/Model/MiniUser.cs @@ -4,7 +4,7 @@ namespace Duplicati.Library.Backend.Box; public class MiniUser : IDReference { - [JsonProperty("type")] public string Type { get; set; } - [JsonProperty("name")] public string Name { get; set; } - [JsonProperty("login")] public string Login { get; set; } + [JsonProperty("type")] public string? Type { get; set; } + [JsonProperty("name")] public string? Name { get; set; } + [JsonProperty("login")] public string? Login { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/OrderEntry.cs b/Duplicati/Library/Backend/Box/Model/OrderEntry.cs index 5a573fb9c..16e778ce5 100644 --- a/Duplicati/Library/Backend/Box/Model/OrderEntry.cs +++ b/Duplicati/Library/Backend/Box/Model/OrderEntry.cs @@ -4,6 +4,6 @@ namespace Duplicati.Library.Backend.Box; public class OrderEntry { - [JsonProperty("by")] public string By { get; set; } - [JsonProperty("direction")] public string Direction { get; set; } + [JsonProperty("by")] public string? By { get; set; } + [JsonProperty("direction")] public string? Direction { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/ShortListResponse.cs b/Duplicati/Library/Backend/Box/Model/ShortListResponse.cs index 4f8db34fd..9e67c8bc5 100644 --- a/Duplicati/Library/Backend/Box/Model/ShortListResponse.cs +++ b/Duplicati/Library/Backend/Box/Model/ShortListResponse.cs @@ -4,5 +4,5 @@ namespace Duplicati.Library.Backend.Box; public class ShortListResponse : FileList { - [JsonProperty("order")] public OrderEntry[] Order { get; set; } + [JsonProperty("order")] public OrderEntry[]? Order { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/Box/Model/UploadEmail.cs b/Duplicati/Library/Backend/Box/Model/UploadEmail.cs index de24408d9..2cf749f09 100644 --- a/Duplicati/Library/Backend/Box/Model/UploadEmail.cs +++ b/Duplicati/Library/Backend/Box/Model/UploadEmail.cs @@ -4,6 +4,6 @@ namespace Duplicati.Library.Backend.Box; public class UploadEmail { - [JsonProperty("access")] public string Access { get; set; } - [JsonProperty("email")] public string Email { get; set; } + [JsonProperty("access")] public string? Access { get; set; } + [JsonProperty("email")] public string? Email { get; set; } } \ No newline at end of file diff --git a/Duplicati/Library/Backend/OAuthHelper/JSONWebHelperHttpClient.cs b/Duplicati/Library/Backend/OAuthHelper/JSONWebHelperHttpClient.cs index 214a6122d..325bcbcb3 100644 --- a/Duplicati/Library/Backend/OAuthHelper/JSONWebHelperHttpClient.cs +++ b/Duplicati/Library/Backend/OAuthHelper/JSONWebHelperHttpClient.cs @@ -46,23 +46,20 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) /// Useragent string building method /// protected string UserAgent => $"Duplicati v{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"; - public event Action CreateSetupHelper; /// /// Centralized method to prepare a request with the given URL and method setting useragent /// /// Url /// Method - public virtual HttpRequestMessage CreateRequest(string url, string method = null) + public virtual Task CreateRequestAsync(string url, HttpMethod method, CancellationToken cancelToken) { - HttpRequestMessage request = new HttpRequestMessage(string.IsNullOrEmpty(method) ? HttpMethod.Get : new HttpMethod(method), url); + var request = new HttpRequestMessage(method, url); request.Headers.Add("User-Agent", UserAgent); - CreateSetupHelper?.Invoke(request); - - return request; + return Task.FromResult(request); } - + /// /// Performs a multipart post and parses the response as JSON /// @@ -71,13 +68,13 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) /// Token to cancel the operation. /// The multipart items. /// The return type parameter. - public virtual async Task PostMultipartAndGetJsonDataAsync(string url, CancellationToken cancellationToken, MultipartContent parts) + public virtual async Task PostMultipartAndGetJsonDataAsync(string url, CancellationToken cancellationToken, MultipartContent parts) { using var response = await PostMultipartAsync(url, cancellationToken, parts).ConfigureAwait(false); - return ReadJsonResponse(response); + return await ReadJsonResponseAsync(response, cancellationToken).ConfigureAwait(false); } - + /// /// Performs a multipart post /// @@ -87,11 +84,11 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) /// The multipart items. protected virtual async Task PostMultipartAsync(string url, CancellationToken cancellationToken, MultipartContent parts) { - using var req = CreateRequest(url, "POST"); + using var req = await CreateRequestAsync(url, HttpMethod.Post, cancellationToken).ConfigureAwait(false); req.Content = parts; return await _httpClient.SendAsync(req, cancellationToken).ConfigureAwait(false); } - + /// /// Execute Get request and return response and deserializes JSON response into the given type /// @@ -101,7 +98,7 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) /// Destination Type protected virtual async Task GetJsonDataAsync(string url, CancellationToken cancellationToken, Action setup = null) { - using var req = CreateRequest(url); + using var req = await CreateRequestAsync(url, HttpMethod.Get, cancellationToken).ConfigureAwait(false); if (setup != null) setup(req); @@ -132,7 +129,7 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) } ).ConfigureAwait(false); } - + /// /// Executes a web request and json-deserializes the results as the specified type /// @@ -142,7 +139,7 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) /// The type of data to return. public virtual async Task GetJsonDataAsync(string url, CancellationToken cancellationToken) { - + return await GetJsonDataAsync( url, cancellationToken, @@ -161,37 +158,10 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) /// Destination Type protected virtual async Task ReadJsonResponseAsync(HttpRequestMessage req, CancellationToken cancellationToken) { - using var resp = await GetResponseAsync(req, cancellationToken).ConfigureAwait(false); + using var resp = await GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); return await ReadJsonResponseAsync(resp, cancellationToken).ConfigureAwait(false); } - /// - /// Read the JSON response from the server and deserialize it into the given type - /// - /// Response object - /// Type to cast to - /// - /// Exception when failing to deserialize the JSON to the Type - protected virtual T ReadJsonResponse(HttpResponseMessage response) - { - using var rs = response.Content.ReadAsStream(); - using var ps = new StreamPeekReader(rs); - try - { - using var tr = new StreamReader(ps); - using var jr = new JsonTextReader(tr); - return new JsonSerializer().Deserialize(jr); - } - catch (Exception ex) - { - // If we get invalid JSON, report the peek value - if (ex is JsonReaderException) - throw new IOException($"Invalid JSON data: \"{ps.PeekData()}\"", ex); - // Otherwise, we have no additional help to offer - throw; - } - } - /// /// Read the JSON response from the server and deserialize it into the given type asynchronously /// @@ -224,18 +194,9 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) /// Use this method to register an exception handler, /// which can throw another, more meaningful exception /// - public virtual async Task AttemptParseAndThrowExceptionAsync(Exception ex, HttpResponseMessage responseContext = null, CancellationToken cancellationToken = default) + public virtual Task AttemptParseAndThrowExceptionAsync(Exception ex, HttpResponseMessage responseContext, CancellationToken cancellationToken) { - - } - - /// - /// Use this method to register an exception handler, - /// which can throw another, more meaningful exception - /// - public virtual void AttemptParseAndThrowException(Exception ex, HttpResponseMessage responseContext = null) - { - + return Task.CompletedTask; } /// @@ -244,19 +205,19 @@ public class JsonWebHelperHttpClient(HttpClient httpClient) /// Request object /// Cancellation Token /// - public async Task GetResponseAsync(HttpRequestMessage req, CancellationToken cancellationToken) + public async Task GetResponseAsync(HttpRequestMessage req, HttpCompletionOption httpCompletionOption, CancellationToken cancellationToken) { HttpResponseMessage response = null; try { - response = await _httpClient.SendAsync(req, cancellationToken).ConfigureAwait(false); - + response = await _httpClient.SendAsync(req, httpCompletionOption, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); return response; } catch (Exception ex) { - AttemptParseAndThrowException(ex, response); + await AttemptParseAndThrowExceptionAsync(ex, response, cancellationToken).ConfigureAwait(false); throw; } } diff --git a/Duplicati/Library/Backend/OAuthHelper/OAuthHelperHttpClient.cs b/Duplicati/Library/Backend/OAuthHelper/OAuthHelperHttpClient.cs index 8f35628f0..622c819ed 100644 --- a/Duplicati/Library/Backend/OAuthHelper/OAuthHelperHttpClient.cs +++ b/Duplicati/Library/Backend/OAuthHelper/OAuthHelperHttpClient.cs @@ -36,14 +36,22 @@ public class OAuthHelperHttpClient : JsonWebHelperHttpClient private string _Authid; private DateTime _mTokenExpires = DateTime.UtcNow; private string OAuthLoginUrl { get; } - + /// /// Timeout for authentication requests /// - private const int AUTHENTICATION_TIMEOUT_SECONDS = 25; + private static readonly TimeSpan AUTHENTICATION_TIMEOUT = TimeSpan.FromSeconds(25); + /// + /// Maximum number of retries for authorization + /// private const int MAX_AUTHORIZATION_RETRIES = 5; + /// + /// URL to use for OAuth login + /// + /// The name of the module + /// The URL to use for OAuth login private static string OAUTH_LOGIN_URL(string modulename) { var u = new Utility.Uri(OAuthContextSettings.ServerURL); @@ -84,108 +92,105 @@ public class OAuthHelperHttpClient : JsonWebHelperHttpClient Strings.OAuthHelper.MissingAuthID(OAuthLoginUrl), "MissingAuthID"); } - private HttpRequestMessage CreateRequest(string url, string method, bool noAuthorization, CancellationToken cancellationToken = default) + private async Task CreateRequestAsync(string url, HttpMethod method, bool noAuthorization, CancellationToken cancellationToken) { - var request = new HttpRequestMessage(string.IsNullOrEmpty(method) ? HttpMethod.Get : new HttpMethod(method), url); + var request = new HttpRequestMessage(method, url); request.Headers.Add("User-Agent", UserAgent); - if (!noAuthorization && AutoAuthHeader && !string.Equals(OAuthContextSettings.ServerURL, url)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", - GetAccessTokenAsync(cancellationToken).Await()); + if (!noAuthorization && AutoAuthHeader && !string.Equals(OAuthContextSettings.ServerURL, url)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", + await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false)); return request; } - public override HttpRequestMessage CreateRequest(string url, string method = null) - { - return CreateRequest(url, method, false); - } - - private async Task GetAccessTokenAsync(CancellationToken cancellationToken) - { - if (AccessTokenOnly) - return _Authid; + public override Task CreateRequestAsync(string url, HttpMethod method, CancellationToken cancellationToken) + => CreateRequestAsync(url, method, false, cancellationToken); - if (_Token == null || _mTokenExpires < DateTime.UtcNow) + private async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + if (AccessTokenOnly) + return _Authid; + + if (_Token == null || _mTokenExpires < DateTime.UtcNow) + { + var retries = 0; + + while (true) { - var retries = 0; - - while (true) + HttpResponseMessage response = null; + try { - HttpResponseMessage response = null; - try - { - using var timeoutToken = new CancellationTokenSource(); - timeoutToken.CancelAfter(TimeSpan.FromSeconds(AUTHENTICATION_TIMEOUT_SECONDS)); - using var combinedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken.Token, cancellationToken); - - using var request = CreateRequest(OAuthContextSettings.ServerURL, "GET", false, combinedCancellationToken.Token); + using var request = await CreateRequestAsync(OAuthContextSettings.ServerURL, HttpMethod.Get, false, cancellationToken).ConfigureAwait(false); + return await Utility.Utility.WithTimeout(AUTHENTICATION_TIMEOUT, cancellationToken, async ct => + { request.Headers.TryAddWithoutValidation("X-AuthID", _Authid); - response = await _httpClient.SendAsync(request, combinedCancellationToken.Token).ConfigureAwait(false); + response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var res = await ReadJsonResponseAsync(response, combinedCancellationToken.Token).ConfigureAwait(false); + var res = await ReadJsonResponseAsync(response, ct).ConfigureAwait(false); _mTokenExpires = DateTime.UtcNow.AddSeconds(res.expires - 30); if (AutoV2 && !string.IsNullOrWhiteSpace(res.v2_authid)) _Authid = res.v2_authid; return _Token = res.access_token; - } - catch (Exception ex) - { - var clientError = false; - - try - { - // Only retry once on client errors - if (ex is HttpRequestException { StatusCode: not null } exception) - { - var sc = (int)exception.StatusCode; - clientError = sc is >= 400 and <= 499; - } - } - catch - { - // ignored - } - - if (response != null && response.Headers.Contains("X-Reason")) - { - var msg = response.Headers.GetValues("X-Reason").FirstOrDefault(); - - if (string.IsNullOrWhiteSpace(msg)) - msg = response.StatusCode.ToString(); - - if (response.StatusCode == HttpStatusCode.ServiceUnavailable) - { - string errorKey = msg == response.StatusCode.ToString() - ? "OAuthOverQuotaError" - : "OAuthLoginError"; - - string errorMessage = errorKey == "OAuthOverQuotaError" - ? Strings.OAuthHelper.OverQuotaError - : Strings.OAuthHelper.AuthorizationFailure(msg, OAuthLoginUrl); - - throw new Interface.UserInformationException(errorMessage, errorKey, - errorKey == "OAuthLoginError" ? ex : null); - } - } - - if (retries >= (clientError ? 1 : MAX_AUTHORIZATION_RETRIES)) - { - await AttemptParseAndThrowExceptionAsync(ex, response, cancellationToken).ConfigureAwait(false); - throw; - } - - Thread.Sleep(TimeSpan.FromSeconds(Math.Pow(2, retries))); - retries++; - } - finally - { - response?.Dispose(); - } - + }).ConfigureAwait(false); } - } + catch (Exception ex) + { + var clientError = false; - return _Token; + try + { + // Only retry once on client errors + if (ex is HttpRequestException { StatusCode: not null } exception) + { + var sc = (int)exception.StatusCode; + clientError = sc is >= 400 and <= 499; + } + } + catch + { + // ignored + } + + if (response != null && response.Headers.Contains("X-Reason")) + { + var msg = response.Headers.GetValues("X-Reason").FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(msg)) + msg = response.StatusCode.ToString(); + + if (response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + string errorKey = msg == response.StatusCode.ToString() + ? "OAuthOverQuotaError" + : "OAuthLoginError"; + + string errorMessage = errorKey == "OAuthOverQuotaError" + ? Strings.OAuthHelper.OverQuotaError + : Strings.OAuthHelper.AuthorizationFailure(msg, OAuthLoginUrl); + + throw new Interface.UserInformationException(errorMessage, errorKey, + errorKey == "OAuthLoginError" ? ex : null); + } + } + + if (retries >= (clientError ? 1 : MAX_AUTHORIZATION_RETRIES)) + { + await AttemptParseAndThrowExceptionAsync(ex, response, cancellationToken).ConfigureAwait(false); + throw; + } + + Thread.Sleep(TimeSpan.FromSeconds(Math.Pow(2, retries))); + retries++; + } + finally + { + response?.Dispose(); + } + + } + } + + return _Token; } } \ No newline at end of file