using System.Net;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Duplicati.Library.Interface;
using Duplicati.Library.Logging;
using Duplicati.Library.Utility;
using Duplicati.Library.Utility.Options;
using Jose;
using NetUri = System.Uri;
namespace Duplicati.Proprietary.Office365;
///
/// Implements the API helper class for Microsoft Graph API access
///
internal class APIHelper : IDisposable
{
///
/// The log tag for this class
///
private static readonly string LOGTAG = Log.LogTagFromType();
///
/// The default page size for API requests
///
internal const int GENERAL_PAGE_SIZE = 100;
///
/// The default page size for chat API requests
///
internal const int CHATS_PAGE_SIZE = 50;
///
/// The HTTP client used for API requests
///
private readonly HttpClient _httpClient;
///
/// The cached Graph access token
///
private string? _graphAccessToken;
///
/// The authentication values
///
private readonly AuthOptionsHelper.AuthOptions _authOptions;
///
/// The tenant ID for the API requests
///
private readonly string _tenantId;
///
/// The base URL for Graph API requests
///
private readonly string _graphBaseUrl;
///
/// The base URL for Graph API requests
///
public string GraphBaseUrl => _graphBaseUrl;
///
/// The path to the certificate file
///
private readonly string? _certificatePath;
///
/// The password for the certificate file
///
private readonly string? _certificatePassword;
///
/// The scope for the API requests
///
private readonly string _scope;
///
/// The timeout options for the backend
///
private readonly TimeoutOptionsHelper.Timeouts _timeouts;
///
/// Initializes a new instance of the class.
///
/// The HTTP client to use for API requests
/// The authentication options
/// The tenant ID
/// The base URL for Graph API requests
/// The timeout options
/// The path to the certificate file
/// The password for the certificate file
/// The scope for the API requests
private APIHelper(HttpClient httpClient, AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl, TimeoutOptionsHelper.Timeouts timeouts, string? certificatePath, string? certificatePassword, string scope)
{
_httpClient = httpClient;
_authOptions = authOptions;
_tenantId = tenantId;
_graphBaseUrl = graphBaseUrl;
_timeouts = timeouts;
_certificatePath = certificatePath;
_certificatePassword = certificatePassword;
_scope = scope;
}
///
/// Creates a new instance of the class.
///
/// The authentication options
/// The tenant ID
/// The base URL for Graph API requests
/// The timeout options
/// The path to the certificate file
/// The password for the certificate file
/// The scope for the API requests
/// A new instance of the class
public static APIHelper Create(AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl, TimeoutOptionsHelper.Timeouts timeouts, string? certificatePath = null, string? certificatePassword = null, string scope = "https://graph.microsoft.com/.default")
{
var handler = new HttpClientHandler
{
UseCookies = false
};
var httpClient = HttpClientHelper.CreateClient(handler);
httpClient.Timeout = Timeout.InfiniteTimeSpan;
httpClient.DefaultRequestHeaders.Add("User-Agent", $"Duplicati/{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}");
return new APIHelper(httpClient, authOptions, tenantId, graphBaseUrl, timeouts, certificatePath, certificatePassword, scope);
}
///
/// Gets the authentication header for the API requests.
///
/// Whether to refresh the access token
/// The cancellation token
/// The authentication header
public async Task GetAuthenticationHeaderAsync(bool refresh, CancellationToken cancellationToken)
{
var accessToken = await AcquireAccessTokenAsync(refresh, cancellationToken).ConfigureAwait(false);
return new AuthenticationHeaderValue("Bearer", accessToken);
}
///
/// Acquires an access token for the API requests.
///
/// Whether to refresh the access token
/// The cancellation token
/// The access token
public async Task AcquireAccessTokenAsync(bool refresh, CancellationToken cancellationToken)
{
if (!refresh && !string.IsNullOrWhiteSpace(_graphAccessToken))
return _graphAccessToken;
var tokenEndpoint = new NetUri($"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token");
var parameters = new Dictionary
{
["grant_type"] = "client_credentials",
["client_id"] = _authOptions.Username!,
["scope"] = _scope
};
if (!string.IsNullOrWhiteSpace(_certificatePath))
{
if (!File.Exists(_certificatePath))
throw new UserInformationException($"Certificate file not found: {_certificatePath}", "CertificateFileNotFound");
try
{
var cert = X509CertificateLoader.LoadPkcs12FromFile(_certificatePath, _certificatePassword);
var rsa = cert.GetRSAPrivateKey();
if (rsa == null)
throw new UserInformationException("Certificate does not contain a private key", "CertificateNoPrivateKey");
var payload = new Dictionary
{
{ "aud", tokenEndpoint.ToString() },
{ "exp", DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds() },
{ "iss", _authOptions.Username! },
{ "jti", Guid.NewGuid().ToString() },
{ "nbf", DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeSeconds() },
{ "sub", _authOptions.Username! }
};
var extraHeaders = new Dictionary
{
{ "x5t", Convert.ToBase64String(cert.GetCertHash()) }
};
var clientAssertion = JWT.Encode(payload, rsa, JwsAlgorithm.RS256, extraHeaders);
parameters["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
parameters["client_assertion"] = clientAssertion;
}
catch (Exception ex)
{
throw new UserInformationException($"Failed to load certificate: {ex.Message}", "CertificateLoadFailed", ex);
}
}
else
{
parameters["client_secret"] = _authOptions.Password!;
}
using var content = new FormUrlEncodedContent(parameters);
using var client = HttpClientHelper.CreateClient();
using var response = await client.PostAsync(tokenEndpoint, content, cancellationToken).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(response, cancellationToken).ConfigureAwait(false);
var token = await ParseResponseJson(response, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(token?.AccessToken))
throw new UserInformationException(Strings.MissingAccessToken, "MissingAccessTokenInResponse");
return token.AccessToken;
}
///
/// Parses the response JSON.
///
/// The type to deserialize to
/// The HTTP response message
/// The cancellation token
/// The deserialized object
public static async Task ParseResponseJson(HttpResponseMessage response, CancellationToken cancellationToken)
{
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
try
{
return JsonSerializer.Deserialize(content) ?? throw new JsonException("Deserialized object is null");
}
catch (JsonException ex)
{
throw new UserInformationException($"Failed to parse response JSON: {ex.Message}, JSON: {content}", "FailedToParseResponseJson", ex);
}
}
///
/// Ensures that the response from the Office API is successful.
///
/// The HTTP response message
/// The cancellation token
/// An awaitable task
public static async Task EnsureOfficeApiSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
return;
string? responseBody = null;
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(1));
responseBody = response.Content == null
? null
: await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
}
catch
{
// Ignore failures when reading the response body; we'll throw with status code regardless.
}
var message = TryExtractOfficeApiErrorMessage(responseBody)
?? responseBody
?? response.ReasonPhrase
?? "Request failed.";
// Ensure the status code is both in the message (human readable) and on the exception (machine readable).
var statusCode = response.StatusCode;
var detailedMessage = $"{message} (HTTP {(int)statusCode} {statusCode}) - Request URI: {response.RequestMessage?.RequestUri}";
if (response?.Headers?.TryGetValues("Location", out var location) == true)
detailedMessage += $" - Location: {location.First()}";
throw new HttpRequestException(detailedMessage, inner: null, statusCode);
}
///
/// Attempts to extract the error message from the Office API response.
///
/// The response body
/// The extracted error message, or null if none could be found
private static string? TryExtractOfficeApiErrorMessage(string? responseBody)
{
if (string.IsNullOrWhiteSpace(responseBody))
return null;
// Fast filter: if it doesn't look like JSON, don't attempt to parse.
var trimmed = responseBody.AsSpan().TrimStart();
if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
return null;
try
{
using var doc = JsonDocument.Parse(responseBody);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return null;
if (doc.RootElement.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object)
{
if (error.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String)
{
var message = messageElement.GetString();
return string.IsNullOrWhiteSpace(message) ? null : message;
}
}
}
catch (JsonException)
{
// Not JSON (or invalid JSON) - fall back to entire response body.
}
return null;
}
///
/// Gets a single page of Graph API results.
///
/// The type of items in the page
/// The URL to fetch the page from
/// A callback to determine if a request should be retried
/// The cancellation token
/// >The page of results
public async Task?> GetGraphPageAsync(string url, Func? shouldRetry, CancellationToken ct)
{
async Task requestFactory(CancellationToken ct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url));
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return req;
}
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, _timeouts.ListTimeout, ct).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
return await ParseResponseJson>(resp, ct).ConfigureAwait(false)
?? new GraphPage();
}
///
/// Gets all Graph API results from a paged endpoint as an asynchronous enumerable.
///
/// The type of items in the page
/// The initial URL to fetch results from
/// The cancellation token
/// An asynchronous enumerable of all items
public IAsyncEnumerable GetAllGraphItemsAsync(string initialUrl, CancellationToken ct)
=> GetAllGraphItemsAsync(initialUrl, null, ct);
///
/// Gets all Graph API results from a paged endpoint as an asynchronous enumerable.
///
/// The type of items in the page
/// The initial URL to fetch results from
/// A callback to determine if a request should be retried
/// The cancellation token
/// An asynchronous enumerable of all items
public async IAsyncEnumerable GetAllGraphItemsAsync(string initialUrl, Func? shouldRetry, [EnumeratorCancellation] CancellationToken ct)
{
var next = initialUrl;
while (!string.IsNullOrWhiteSpace(next))
{
ct.ThrowIfCancellationRequested();
var page = await GetGraphPageAsync(next, shouldRetry, ct).ConfigureAwait(false);
// If page is null, the shouldRetry callback indicated we should treat this as empty results
// (e.g., 503 when OneDrive is not provisioned for a user)
if (page == null)
yield break;
foreach (var item in page.Value)
yield return item;
next = page.NextLink;
}
}
///
/// Gets a single Graph API item.
///
/// The graph item to get
/// The URL to fetch
/// The cancellation token
/// >The graph item
public async Task GetGraphItemAsync(string url, CancellationToken ct)
{
var select = GraphSelectBuilder.BuildSelect();
async Task requestFactory(CancellationToken cancellationToken)
{
var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url));
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return req;
}
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
return await ParseResponseJson(resp, ct).ConfigureAwait(false)
?? throw new UserInformationException("Failed to parse Graph item response.", nameof(SourceProvider));
}
///
/// Posts a Graph API item.
///
/// The type of the item to return
/// The URL to post to
/// The stream to post
/// The cancellation token
/// The posted item
public async Task PostGraphItemStreamAsync(string url, Stream stream, string contentType, CancellationToken ct)
{
async Task requestFactory(CancellationToken cancellationToken)
{
var req = new HttpRequestMessage(HttpMethod.Post, new NetUri(url));
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
stream.Position = 0;
req.Content = new StreamContent(stream);
req.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
return req;
}
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
return await ParseResponseJson(resp, ct).ConfigureAwait(false);
}
///
/// Posts a Graph API item.
///
/// The type of the item to return
/// The URL to post to
/// The content to post
/// The cancellation token
/// The posted item
public async Task PostGraphItemAsync(string url, HttpContent content, CancellationToken ct)
{
#if DEBUG
if (content is StreamContent)
throw new InvalidOperationException("Use PostGraphItemStreamAsync for stream content.");
#endif
async Task requestFactory(CancellationToken cancellationToken)
{
var req = new HttpRequestMessage(HttpMethod.Post, new NetUri(url));
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Content = content;
return req;
}
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
return await ParseResponseJson(resp, ct).ConfigureAwait(false);
}
///
/// Posts a Graph API item and ignores the response body.
///
/// The URL to post to
/// The content to post
/// The cancellation token
/// An awaitable task
public async Task PostGraphItemNoResponseAsync(string url, HttpContent content, CancellationToken ct)
{
async Task requestFactory(CancellationToken cancellationToken)
{
var req = new HttpRequestMessage(HttpMethod.Post, new NetUri(url));
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Content = content;
return req;
}
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
}
///
/// Patches a Graph API item.
///
/// The URL to patch
/// The content to patch
/// The cancellation token
/// An awaitable task
public async Task PatchGraphItemAsync(string url, HttpContent content, CancellationToken ct)
{
async Task requestFactory(CancellationToken cancellationToken)
{
var req = new HttpRequestMessage(HttpMethod.Patch, new NetUri(url));
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Content = content;
return req;
}
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
}
///
/// Returns the response from the Graph API as a stream.
/// This method will buffer the response in memory.
///
/// The URL to fetch
/// The Accept header value
/// The Prefer header value
/// The cancellation token
/// The response stream
public Task GetGraphItemAsStreamAsync(string url, string accept, string prefer, CancellationToken ct)
=> GetGraphItemAsStreamAsync(url, accept, prefer, null, ct);
///
/// Returns the response from the Graph API as a stream.
/// This method will buffer the response in memory.
///
/// The URL to fetch
/// The Accept header value
/// The cancellation token
/// The response stream
public Task GetGraphItemAsStreamAsync(string url, string accept, CancellationToken ct)
=> GetGraphItemAsStreamAsync(url, accept, null, null, ct);
///
/// Returns the response from the Graph API as a stream.
/// This method will buffer the response in memory.
///
/// The URL to fetch
/// The Accept header value
/// The Prefer header value
/// A callback to determine if a request should be retried
/// The cancellation token
/// The response stream
private async Task GetGraphItemAsStreamAsync(
string url,
string accept,
string? prefer,
Func? shouldRetry,
CancellationToken ct)
{
async Task requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url));
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
if (!string.IsNullOrWhiteSpace(prefer))
req.Headers.TryAddWithoutValidation("Prefer", prefer);
return req;
}
using var resp = await SendWithRetryAsync(
requestFactory,
HttpCompletionOption.ResponseHeadersRead,
shouldRetry,
_timeouts.ShortTimeout,
ct).ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.NotFound)
return Stream.Null;
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
// Return a stream the caller can own safely after HttpResponseMessage disposal.
var ms = new MemoryStream();
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
using var timeoutStream = stream.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false);
await timeoutStream.CopyToAsync(ms, ct).ConfigureAwait(false);
ms.Position = 0;
return ms;
}
///
/// Returns the response from the Graph API as a stream.
///
/// The URL to fetch
/// The Accept header value
/// The cancellation token
/// The response stream
public async Task GetGraphResponseAsRealStreamAsync(string url, string accept, CancellationToken ct)
{
async Task requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url));
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
return req;
}
using var resp = await SendWithRetryAsync(
requestFactory,
HttpCompletionOption.ResponseHeadersRead,
null,
_timeouts.ShortTimeout,
ct).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
if (resp.Content.Headers.ContentLength == 0)
return Stream.Null;
// Return a stream the caller can own safely after HttpResponseMessage disposal.
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
using var timeoutStream = stream.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false);
// The caller expects a FileStream-like stream, so we need to buffer to a temp file.
// We could wrap this in a stream that exposes the Length property, which would avoid the temp file.
var tempStream = TempFileStream.Create();
await timeoutStream.CopyToAsync(tempStream, ct).ConfigureAwait(false);
tempStream.Position = 0;
// Return the stream, will delete on dispose.
return tempStream;
}
///
/// Gets the HTTP client, acquiring an access token if needed.
///
/// The cancellation token
/// The HTTP client
private async Task GetHttpClientAsync(CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_graphAccessToken))
_graphAccessToken = await AcquireAccessTokenAsync(false, ct).ConfigureAwait(false);
return _httpClient;
}
///
/// Sends an HTTP request with retry logic for transient failures, using short timeouts.
///
/// The factory to create the HTTP request
/// The cancellation token
/// >The HTTP response message
public async Task SendWithRetryShortAsync(
Func> requestFactory,
CancellationToken ct
)
=> await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false);
///
/// Sends an HTTP request with retry logic for transient failures.
///
/// The factory to create the HTTP request
/// The HTTP completion option
/// A callback to determine if a request should be retried
/// The timeout for the request
/// The cancellation token
/// >The HTTP response message
public async Task SendWithRetryAsync(
Func> requestFactory,
HttpCompletionOption completionOption,
Func? shouldRetry,
TimeSpan? timeout,
CancellationToken ct
)
{
int maxRetries = 4;
var attempt = 0;
var isReAuthAttempt = false;
var client = await GetHttpClientAsync(ct).ConfigureAwait(false);
while (true)
{
ct.ThrowIfCancellationRequested();
HttpResponseMessage? resp = null;
try
{
using var request = await requestFactory(ct).ConfigureAwait(false);
if (timeout.HasValue)
{
resp = await Utility.WithTimeout(timeout.Value, ct, c => client.SendAsync(request, completionOption, c)).ConfigureAwait(false);
}
else
{
resp = await client.SendAsync(request, completionOption, ct).ConfigureAwait(false);
}
}
catch (TimeoutException)
{
// Ignore and retry
}
if (resp != null)
{
if (resp.IsSuccessStatusCode)
return resp;
if (shouldRetry?.Invoke(resp) == false)
return resp;
// Retryable status codes:
// 429: TooManyRequests
// 503: ServiceUnavailable
// 502: BadGateway
// 504: GatewayTimeout
switch (resp.StatusCode)
{
case HttpStatusCode.TooManyRequests:
case HttpStatusCode.ServiceUnavailable:
case HttpStatusCode.BadGateway:
case HttpStatusCode.GatewayTimeout:
break;
case HttpStatusCode.Unauthorized:
_graphAccessToken = null; // force re-auth
if (isReAuthAttempt)
return resp; // already tried re-auth
isReAuthAttempt = true;
break;
default:
return resp;
}
}
attempt++;
if (attempt > maxRetries)
{
if (resp != null)
return resp; // let EnsureOfficeApiSuccessAsync throw with details
throw new TimeoutException($"Request timed out after {maxRetries} attempts");
}
if (resp != null)
Log.WriteRetryMessage(LOGTAG, "Office365APIRetry", null, $"Request failed with status code {(int)resp.StatusCode} {resp.StatusCode}. Retrying attempt {attempt} of {maxRetries}.");
else
Log.WriteRetryMessage(LOGTAG, "Office365APITimeout", null, $"Request timed out. Retrying attempt {attempt} of {maxRetries}.");
// Respect Retry-After if present, else exponential backoff.
TimeSpan delay;
if (resp?.Headers.RetryAfter?.Delta is TimeSpan ra)
{
delay = ra;
}
else
{
var baseSeconds = Math.Min(60, Math.Pow(2, attempt)); // cap at 60s
var jitterMs = Random.Shared.Next(0, 500);
delay = TimeSpan.FromSeconds(baseSeconds) + TimeSpan.FromMilliseconds(jitterMs);
}
resp?.Dispose();
await Task.Delay(delay, ct).ConfigureAwait(false);
}
}
///
/// Uploads a stream to an upload session.
///
/// The upload session URL
/// The content stream
/// The cancellation token
/// An awaitable task
public async Task UploadFileToSessionAsync(string uploadUrl, Stream contentStream, CancellationToken ct)
{
// 320 KiB is the required multiple for Graph API
const int ChunkSize = 320 * 1024 * 10; // 3.2 MB chunks
var buffer = new byte[ChunkSize];
long totalLength = contentStream.Length;
long position = 0;
while (position < totalLength)
{
var bytesRead = await contentStream.ReadAsync(buffer, 0, ChunkSize, ct).ConfigureAwait(false);
if (bytesRead == 0) break;
var rangeHeader = $"bytes {position}-{position + bytesRead - 1}/{totalLength}";
HttpRequestMessage requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Put, new NetUri(uploadUrl));
var ms = new MemoryStream(buffer, 0, bytesRead);
var timeoutStream = ms.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false);
req.Content = new StreamContent(timeoutStream);
req.Content.Headers.ContentLength = bytesRead;
req.Content.Headers.ContentRange = ContentRangeHeaderValue.Parse(rangeHeader);
return req;
}
using var resp = await SendWithRetryAsync(
ct => Task.FromResult(requestFactory(ct)),
HttpCompletionOption.ResponseHeadersRead,
null,
null,
ct).ConfigureAwait(false);
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
position += bytesRead;
}
}
///
public void Dispose()
{
_httpClient.Dispose();
_graphAccessToken = null;
}
}