mirror of
https://github.com/duplicati/duplicati.git
synced 2026-05-06 07:16:38 -04:00
Implemented Restore feature and refactored a bit.
Added a free tier for the Office 365 module.
This commit is contained in:
@@ -45,9 +45,10 @@ public interface IRestoreDestinationProvider : IDisposable
|
||||
/// <summary>
|
||||
/// Finalizes the restore destination provider
|
||||
/// </summary>
|
||||
/// <param name="progressCallback">A callback to report progress</param>
|
||||
/// <param name="cancel">The cancellation token</param>
|
||||
/// <returns>An awaitable task</returns>
|
||||
Task Finalize(CancellationToken cancel);
|
||||
Task Finalize(Action<double>? progressCallback, CancellationToken cancel);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the folder at the given path if it does not exist
|
||||
|
||||
@@ -162,7 +162,10 @@ namespace Duplicati.Library.Main
|
||||
.RunAsync(paths, backendManager, filter, restoreDestination)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await restoreDestination.Finalize(result.TaskControl.ProgressToken).ConfigureAwait(false);
|
||||
await restoreDestination.Finalize((pg) =>
|
||||
{
|
||||
result.OperationProgressUpdater.UpdateProgress((float)pg);
|
||||
}, result.TaskControl.ProgressToken).ConfigureAwait(false);
|
||||
result.OperationProgressUpdater.UpdatePhase(OperationPhase.Restore_Complete);
|
||||
result.EndTime = DateTime.UtcNow;
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ public class FileRestoreDestinationProvider(string mountedPath) : IRestoreDestin
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task Finalize(CancellationToken cancel)
|
||||
public Task Finalize(Action<double>? progressCallback, CancellationToken cancel)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+18
-8
@@ -2,23 +2,33 @@
|
||||
|
||||
using Duplicati.Library.AutoUpdater;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.LicenseChecker;
|
||||
|
||||
namespace Duplicati.Proprietary.LoaderHelper;
|
||||
namespace Duplicati.Proprietary.LicenseChecker;
|
||||
|
||||
internal static class LicenseHelper
|
||||
public static class LicenseHelper
|
||||
{
|
||||
public static LicenseData? LicenseData => licenseData.Value;
|
||||
|
||||
public static bool HasOffice365Feature => HasFeature(DuplicatiLicenseFeatures.Office365);
|
||||
public static int AvailableOffice365FeatureSeats => GetFeatureSeats(DuplicatiLicenseFeatures.Office365);
|
||||
|
||||
private static bool HasFeature(string feature)
|
||||
private static Dictionary<string, int> UnlicensedSeats = new Dictionary<string, int>
|
||||
{
|
||||
{ DuplicatiLicenseFeatures.Office365, 5 },
|
||||
};
|
||||
|
||||
private static int GetDefaultSeats(string feature)
|
||||
=> UnlicensedSeats.GetValueOrDefault(feature, 0);
|
||||
|
||||
private static int GetFeatureSeats(string feature)
|
||||
{
|
||||
var data = LicenseData;
|
||||
if (data == null)
|
||||
return false;
|
||||
return GetDefaultSeats(feature);
|
||||
|
||||
return data.Features.ContainsKey(feature);
|
||||
if (!int.TryParse(data.Features.GetValueOrDefault(feature, "0"), out int seats))
|
||||
return GetDefaultSeats(feature);
|
||||
|
||||
return seats;
|
||||
}
|
||||
|
||||
private static Lazy<LicenseData?> licenseData = new Lazy<LicenseData?>(() =>
|
||||
@@ -39,7 +49,7 @@ internal static class LicenseHelper
|
||||
return null;
|
||||
|
||||
using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
return LicenseChecker.LicenseChecker.ObtainLicenseAsync(key, ct.Token).Await();
|
||||
return LicenseChecker.ObtainLicenseAsync(key, ct.Token).Await();
|
||||
});
|
||||
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.LicenseChecker;
|
||||
|
||||
namespace Duplicati.Proprietary.LoaderHelper;
|
||||
|
||||
@@ -14,7 +14,7 @@ public static class Configuration
|
||||
|
||||
private static Lazy<string[]> LicensedAPIExtensionsLazy = new(() =>
|
||||
new string?[] {
|
||||
LicenseHelper.HasOffice365Feature ? "office365" : null
|
||||
LicenseHelper.AvailableOffice365FeatureSeats > 0 ? "office365" : null
|
||||
}
|
||||
.WhereNotNull()
|
||||
.ToArray()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.LicenseChecker;
|
||||
|
||||
namespace Duplicati.Proprietary.LoaderHelper;
|
||||
|
||||
@@ -34,7 +35,7 @@ public static class RestoreDestinationProviderModules
|
||||
/// </summary>
|
||||
private static readonly Lazy<IReadOnlyList<IRestoreDestinationProviderModule>> LicensedRestoreDestinationProvidersLazy = new(() =>
|
||||
new IRestoreDestinationProviderModule?[] {
|
||||
LicenseHelper.HasOffice365Feature ? new Office365.RestoreProvider() : null
|
||||
LicenseHelper.AvailableOffice365FeatureSeats > 0 ? new Office365.RestoreProvider() : null
|
||||
}
|
||||
.WhereNotNull()
|
||||
.ToList()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.LicenseChecker;
|
||||
|
||||
namespace Duplicati.Proprietary.LoaderHelper;
|
||||
|
||||
@@ -34,7 +35,7 @@ public static class SourceProviderModules
|
||||
/// </summary>
|
||||
private static Lazy<IReadOnlyList<ISourceProviderModule>> LicensedSourceProvidersLazy = new(() =>
|
||||
new ISourceProviderModule?[] {
|
||||
LicenseHelper.HasOffice365Feature ? new Office365.SourceProvider() : null
|
||||
LicenseHelper.AvailableOffice365FeatureSeats > 0 ? new Office365.SourceProvider() : null
|
||||
}
|
||||
.WhereNotNull()
|
||||
.ToList()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Utility;
|
||||
using Duplicati.Proprietary.LicenseChecker;
|
||||
|
||||
namespace Duplicati.Proprietary.LoaderHelper;
|
||||
|
||||
@@ -34,7 +35,7 @@ public static class WebModules
|
||||
/// </summary>
|
||||
private static Lazy<IReadOnlyList<IWebModule>> LicensedWebModulesLazy = new(() =>
|
||||
new IWebModule?[] {
|
||||
LicenseHelper.HasOffice365Feature ? new Office365.WebModule() : null
|
||||
LicenseHelper.AvailableOffice365FeatureSeats > 0 ? new Office365.WebModule() : null
|
||||
}
|
||||
.WhereNotNull()
|
||||
.ToList()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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;
|
||||
@@ -14,6 +17,10 @@ namespace Duplicati.Proprietary.Office365;
|
||||
/// </summary>
|
||||
internal class APIHelper : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The log tag for this class
|
||||
/// </summary>
|
||||
private static readonly string LOGTAG = Log.LogTagFromType<APIHelper>();
|
||||
/// <summary>
|
||||
/// The HTTP client used for API requests
|
||||
/// </summary>
|
||||
@@ -39,6 +46,21 @@ internal class APIHelper : IDisposable
|
||||
/// </summary>
|
||||
public string GraphBaseUrl => _graphBaseUrl;
|
||||
|
||||
/// <summary>
|
||||
/// The path to the certificate file
|
||||
/// </summary>
|
||||
private readonly string? _certificatePath;
|
||||
|
||||
/// <summary>
|
||||
/// The password for the certificate file
|
||||
/// </summary>
|
||||
private readonly string? _certificatePassword;
|
||||
|
||||
/// <summary>
|
||||
/// The timeout options for the backend
|
||||
/// </summary>
|
||||
private readonly TimeoutOptionsHelper.Timeouts _timeouts;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="APIHelper"/> class.
|
||||
/// </summary>
|
||||
@@ -46,12 +68,18 @@ internal class APIHelper : IDisposable
|
||||
/// <param name="authOptions">The authentication options</param>
|
||||
/// <param name="tenantId">The tenant ID</param>
|
||||
/// <param name="graphBaseUrl">The base URL for Graph API requests</param>
|
||||
private APIHelper(HttpClient httpClient, AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl)
|
||||
/// <param name="timeouts">The timeout options</param>
|
||||
/// <param name="certificatePath">The path to the certificate file</param>
|
||||
/// <param name="certificatePassword">The password for the certificate file</param>
|
||||
private APIHelper(HttpClient httpClient, AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl, TimeoutOptionsHelper.Timeouts timeouts, string? certificatePath, string? certificatePassword)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_authOptions = authOptions;
|
||||
_tenantId = tenantId;
|
||||
_graphBaseUrl = graphBaseUrl;
|
||||
_timeouts = timeouts;
|
||||
_certificatePath = certificatePath;
|
||||
_certificatePassword = certificatePassword;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,8 +88,11 @@ internal class APIHelper : IDisposable
|
||||
/// <param name="authOptions">The authentication options</param>
|
||||
/// <param name="tenantId">The tenant ID</param>
|
||||
/// <param name="graphBaseUrl">The base URL for Graph API requests</param>
|
||||
/// <param name="timeouts">The timeout options</param>
|
||||
/// <param name="certificatePath">The path to the certificate file</param>
|
||||
/// <param name="certificatePassword">The password for the certificate file</param>
|
||||
/// <returns>A new instance of the <see cref="APIHelper"/> class</returns>
|
||||
public static APIHelper Create(AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl)
|
||||
public static APIHelper Create(AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl, TimeoutOptionsHelper.Timeouts timeouts, string? certificatePath = null, string? certificatePassword = null)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
@@ -72,7 +103,7 @@ internal class APIHelper : IDisposable
|
||||
httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
||||
httpClient.DefaultRequestHeaders.Add("User-Agent", $"Duplicati/{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}");
|
||||
|
||||
return new APIHelper(httpClient, authOptions, tenantId, graphBaseUrl);
|
||||
return new APIHelper(httpClient, authOptions, tenantId, graphBaseUrl, timeouts, certificatePath, certificatePassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -99,13 +130,56 @@ internal class APIHelper : IDisposable
|
||||
return _graphAccessToken;
|
||||
|
||||
var tokenEndpoint = new NetUri($"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token");
|
||||
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = _authOptions.Username!,
|
||||
["client_secret"] = _authOptions.Password!,
|
||||
["scope"] = $"{_graphBaseUrl.TrimEnd('/')}/.default"
|
||||
});
|
||||
};
|
||||
|
||||
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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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);
|
||||
@@ -236,7 +310,7 @@ internal class APIHelper : IDisposable
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, ct).ConfigureAwait(false);
|
||||
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, _timeouts.ListTimeout, ct).ConfigureAwait(false);
|
||||
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
return await ParseResponseJson<GraphPage<T>>(resp, ct).ConfigureAwait(false)
|
||||
@@ -302,13 +376,161 @@ internal class APIHelper : IDisposable
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, ct).ConfigureAwait(false);
|
||||
using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false);
|
||||
await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
return await ParseResponseJson<T>(resp, ct).ConfigureAwait(false)
|
||||
?? throw new UserInformationException("Failed to parse Graph item response.", nameof(SourceProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts a Graph API item.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the item to return</typeparam>
|
||||
/// <param name="url">The URL to post to</param>
|
||||
/// <param name="content">The content to post</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>The posted item</returns>
|
||||
public async Task<T> PostGraphItemAsync<T>(string url, HttpContent content, CancellationToken ct)
|
||||
{
|
||||
async Task<HttpRequestMessage> 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<T>(resp, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts a Graph API item and ignores the response body.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to post to</param>
|
||||
/// <param name="content">The content to post</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>An awaitable task</returns>
|
||||
public async Task PostGraphItemNoResponseAsync(string url, HttpContent content, CancellationToken ct)
|
||||
{
|
||||
async Task<HttpRequestMessage> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patches a Graph API item.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to patch</param>
|
||||
/// <param name="content">The content to patch</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>An awaitable task</returns>
|
||||
public async Task PatchGraphItemAsync(string url, HttpContent content, CancellationToken ct)
|
||||
{
|
||||
async Task<HttpRequestMessage> 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);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns the response from the Graph API as a stream.
|
||||
/// This method will buffer the response in memory.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to fetch</param>
|
||||
/// <param name="accept">The Accept header value</param>
|
||||
/// <param name="prefer">The Prefer header value</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>The response stream</returns>
|
||||
public Task<Stream> GetGraphItemAsStreamAsync(string url, string accept, string prefer, CancellationToken ct)
|
||||
=> GetGraphItemAsStreamAsync(url, accept, prefer, null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the response from the Graph API as a stream.
|
||||
/// This method will buffer the response in memory.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to fetch</param>
|
||||
/// <param name="accept">The Accept header value</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>The response stream</returns>
|
||||
public Task<Stream> GetGraphItemAsStreamAsync(string url, string accept, CancellationToken ct)
|
||||
=> GetGraphItemAsStreamAsync(url, accept, null, null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the response from the Graph API as a stream.
|
||||
/// This method will buffer the response in memory.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to fetch</param>
|
||||
/// <param name="accept">The Accept header value</param>
|
||||
/// <param name="prefer">The Prefer header value</param>
|
||||
/// <param name="shouldRetry">A callback to determine if a request should be retried</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>The response stream</returns>
|
||||
private async Task<Stream> GetGraphItemAsStreamAsync(
|
||||
string url,
|
||||
string accept,
|
||||
string? prefer,
|
||||
Func<HttpResponseMessage, bool?>? shouldRetry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
async Task<HttpRequestMessage> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the response from the Graph API as a stream.
|
||||
/// </summary>
|
||||
@@ -316,38 +538,42 @@ internal class APIHelper : IDisposable
|
||||
/// <param name="accept">The Accept header value</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>The response stream</returns>
|
||||
public Task<Stream> GetGraphAsStreamAsync(string url, string accept, CancellationToken ct)
|
||||
=> GetGraphAsStreamAsync(url, accept, null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the response from the Graph API as a stream.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to fetch</param>
|
||||
/// <param name="accept">The Accept header value</param>
|
||||
/// <param name="shouldRetry">A callback to determine if a request should be retried</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>The response stream</returns>
|
||||
private async Task<Stream> GetGraphAsStreamAsync(string url, string accept, Func<HttpResponseMessage, bool?>? shouldRetry, CancellationToken ct)
|
||||
public async Task<Stream> GetGraphResponseAsRealStreamAsync(string url, string accept, CancellationToken ct)
|
||||
{
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url));
|
||||
req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
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, shouldRetry, ct).ConfigureAwait(false);
|
||||
if (resp.StatusCode == HttpStatusCode.NotFound)
|
||||
return Stream.Null;
|
||||
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.
|
||||
var ms = new MemoryStream();
|
||||
await resp.Content.CopyToAsync(ms, ct).ConfigureAwait(false);
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -363,22 +589,36 @@ internal class APIHelper : IDisposable
|
||||
return _httpClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request with retry logic for transient failures, using short timeouts.
|
||||
/// </summary>
|
||||
/// <param name="requestFactory">The factory to create the HTTP request</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>>The HTTP response message</returns>
|
||||
public async Task<HttpResponseMessage> SendWithRetryShortAsync(
|
||||
Func<CancellationToken, Task<HttpRequestMessage>> requestFactory,
|
||||
CancellationToken ct
|
||||
)
|
||||
=> await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request with retry logic for transient failures.
|
||||
/// </summary>
|
||||
/// <param name="requestFactory">The factory to create the HTTP request</param>
|
||||
/// <param name="completionOption">The HTTP completion option</param>
|
||||
/// <param name="shouldRetry">A callback to determine if a request should be retried</param>
|
||||
/// <param name="timeout">The timeout for the request</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <param name="maxRetries">The maximum number of retries</param>
|
||||
/// <returns>>The HTTP response message</returns>
|
||||
public async Task<HttpResponseMessage> SendWithRetryAsync(
|
||||
Func<CancellationToken, Task<HttpRequestMessage>> requestFactory,
|
||||
HttpCompletionOption completionOption,
|
||||
Func<HttpResponseMessage, bool?>? shouldRetry,
|
||||
CancellationToken ct,
|
||||
int maxRetries = 4)
|
||||
TimeSpan? timeout,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
int maxRetries = 4;
|
||||
var attempt = 0;
|
||||
var isReAuthAttempt = false;
|
||||
var client = await GetHttpClientAsync(ct).ConfigureAwait(false);
|
||||
@@ -387,45 +627,73 @@ internal class APIHelper : IDisposable
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
using var request = await requestFactory(ct).ConfigureAwait(false);
|
||||
var resp = await client.SendAsync(request, completionOption, ct).ConfigureAwait(false);
|
||||
|
||||
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)
|
||||
HttpResponseMessage? resp = null;
|
||||
try
|
||||
{
|
||||
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:
|
||||
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)
|
||||
return resp; // let EnsureOfficeApiSuccessAsync throw with details
|
||||
{
|
||||
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)
|
||||
if (resp?.Headers.RetryAfter?.Delta is TimeSpan ra)
|
||||
{
|
||||
delay = ra;
|
||||
}
|
||||
@@ -436,11 +704,58 @@ internal class APIHelper : IDisposable
|
||||
delay = TimeSpan.FromSeconds(baseSeconds) + TimeSpan.FromMilliseconds(jitterMs);
|
||||
}
|
||||
|
||||
resp.Dispose();
|
||||
resp?.Dispose();
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a stream to an upload session.
|
||||
/// </summary>
|
||||
/// <param name="uploadUrl">The upload session URL</param>
|
||||
/// <param name="contentStream">The content stream</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns>An awaitable task</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -125,6 +125,79 @@ internal sealed class GraphMessage
|
||||
|
||||
[JsonPropertyName("hasAttachments")]
|
||||
public bool? HasAttachments { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public GraphBody? Body { get; set; }
|
||||
|
||||
[JsonPropertyName("sender")]
|
||||
public GraphRecipient? Sender { get; set; }
|
||||
|
||||
[JsonPropertyName("ccRecipients")]
|
||||
public List<GraphRecipient>? CcRecipients { get; set; }
|
||||
|
||||
[JsonPropertyName("bccRecipients")]
|
||||
public List<GraphRecipient>? BccRecipients { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphBody
|
||||
{
|
||||
[JsonPropertyName("contentType")]
|
||||
public string? ContentType { get; set; } // "Text" or "HTML"
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphAttachment
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("@odata.type")]
|
||||
public string? ODataType { get; set; } // "#microsoft.graph.fileAttachment"
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public int? Size { get; set; }
|
||||
|
||||
[JsonPropertyName("isInline")]
|
||||
public bool? IsInline { get; set; }
|
||||
|
||||
[JsonPropertyName("contentId")]
|
||||
public string? ContentId { get; set; }
|
||||
|
||||
// For fileAttachment
|
||||
[JsonPropertyName("contentBytes")]
|
||||
public string? ContentBytes { get; set; } // Base64
|
||||
}
|
||||
|
||||
internal sealed class GraphAttachmentItem
|
||||
{
|
||||
[JsonPropertyName("attachmentType")]
|
||||
public string? AttachmentType { get; set; } // "file"
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long? Size { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphUploadSession
|
||||
{
|
||||
[JsonPropertyName("uploadUrl")]
|
||||
public string? UploadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("expirationDateTime")]
|
||||
public DateTimeOffset? ExpirationDateTime { get; set; }
|
||||
|
||||
[JsonPropertyName("nextExpectedRanges")]
|
||||
public List<string>? NextExpectedRanges { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphRecipient
|
||||
@@ -187,6 +260,18 @@ internal sealed class GraphContactEmail
|
||||
public string? Address { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphContactFolder
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("parentFolderId")]
|
||||
public string? ParentFolderId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphTodoTaskList
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
@@ -593,6 +678,9 @@ internal sealed class GraphEvent
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; } // singleInstance, occurrence, exception, seriesMaster
|
||||
|
||||
[JsonPropertyName("originalStart")]
|
||||
public DateTimeOffset? OriginalStart { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphPlannerTask
|
||||
@@ -624,11 +712,17 @@ internal sealed class GraphPlannerTask
|
||||
[JsonPropertyName("percentComplete")]
|
||||
public int? PercentComplete { get; set; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int? Priority { get; set; }
|
||||
|
||||
[JsonPropertyName("hasDescription")]
|
||||
public bool? HasDescription { get; set; }
|
||||
|
||||
[JsonPropertyName("assignments")]
|
||||
public JsonElement? Assignments { get; set; } // map keyed by userId
|
||||
|
||||
[JsonPropertyName("appliedCategories")]
|
||||
public JsonElement? AppliedCategories { get; set; } // map keyed by category
|
||||
}
|
||||
|
||||
internal sealed class GraphChat
|
||||
@@ -875,6 +969,9 @@ public sealed class GraphIdentitySet
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,6 +1133,12 @@ internal sealed class GraphCreateMailFolderRequest
|
||||
public string DisplayName { get; set; } = "";
|
||||
}
|
||||
|
||||
internal sealed class GraphCreateCalendarRequest
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
internal sealed class GraphEmailMessageMetadata
|
||||
{
|
||||
// Only fields we want to round-trip for restore
|
||||
@@ -1090,4 +1193,272 @@ internal sealed class GraphDateTimeTimeZone
|
||||
|
||||
[JsonPropertyName("timeZone")]
|
||||
public string? TimeZone { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class GraphList
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("webUrl")]
|
||||
public string? WebUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("createdDateTime")]
|
||||
public DateTimeOffset? CreatedDateTime { get; set; }
|
||||
|
||||
[JsonPropertyName("lastModifiedDateTime")]
|
||||
public DateTimeOffset? LastModifiedDateTime { get; set; }
|
||||
|
||||
[JsonPropertyName("list")]
|
||||
public GraphListInfo? List { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphListInfo
|
||||
{
|
||||
[JsonPropertyName("contentTypesEnabled")]
|
||||
public bool? ContentTypesEnabled { get; set; }
|
||||
|
||||
[JsonPropertyName("hidden")]
|
||||
public bool? Hidden { get; set; }
|
||||
|
||||
[JsonPropertyName("template")]
|
||||
public string? Template { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphListItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("createdDateTime")]
|
||||
public DateTimeOffset? CreatedDateTime { get; set; }
|
||||
|
||||
[JsonPropertyName("lastModifiedDateTime")]
|
||||
public DateTimeOffset? LastModifiedDateTime { get; set; }
|
||||
|
||||
[JsonPropertyName("webUrl")]
|
||||
public string? WebUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public GraphContentTypeInfo? ContentType { get; set; }
|
||||
|
||||
[JsonPropertyName("fields")]
|
||||
public JsonElement? Fields { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphContentTypeInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphTeamsTab
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("webUrl")]
|
||||
public string? WebUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("configuration")]
|
||||
public GraphTeamsTabConfiguration? Configuration { get; set; }
|
||||
|
||||
[JsonPropertyName("teamsApp")]
|
||||
public GraphTeamsApp? TeamsApp { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphTeamsTabConfiguration
|
||||
{
|
||||
[JsonPropertyName("entityId")]
|
||||
public string? EntityId { get; set; }
|
||||
|
||||
[JsonPropertyName("contentUrl")]
|
||||
public string? ContentUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("removeUrl")]
|
||||
public string? RemoveUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("websiteUrl")]
|
||||
public string? WebsiteUrl { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphTeamsAppInstallation
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("teamsApp")]
|
||||
public GraphTeamsApp? TeamsApp { get; set; }
|
||||
|
||||
[JsonPropertyName("teamsAppDefinition")]
|
||||
public GraphTeamsAppDefinition? TeamsAppDefinition { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphTeamsApp
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("distributionMethod")]
|
||||
public string? DistributionMethod { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphTeamsAppDefinition
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("teamsAppId")]
|
||||
public string? TeamsAppId { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphMessageRule
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
public int? Sequence { get; set; }
|
||||
|
||||
[JsonPropertyName("isEnabled")]
|
||||
public bool? IsEnabled { get; set; }
|
||||
|
||||
[JsonPropertyName("hasError")]
|
||||
public bool? HasError { get; set; }
|
||||
|
||||
[JsonPropertyName("isReadOnly")]
|
||||
public bool? IsReadOnly { get; set; }
|
||||
|
||||
[JsonPropertyName("conditions")]
|
||||
public object? Conditions { get; set; }
|
||||
|
||||
[JsonPropertyName("actions")]
|
||||
public object? Actions { get; set; }
|
||||
|
||||
[JsonPropertyName("exceptions")]
|
||||
public object? Exceptions { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphMailboxSettings
|
||||
{
|
||||
[JsonPropertyName("automaticRepliesSetting")]
|
||||
public GraphAutomaticRepliesSetting? AutomaticRepliesSetting { get; set; }
|
||||
|
||||
[JsonPropertyName("archiveFolder")]
|
||||
public string? ArchiveFolder { get; set; }
|
||||
|
||||
[JsonPropertyName("timeZone")]
|
||||
public string? TimeZone { get; set; }
|
||||
|
||||
[JsonPropertyName("language")]
|
||||
public GraphLocaleInfo? Language { get; set; }
|
||||
|
||||
[JsonPropertyName("dateFormat")]
|
||||
public string? DateFormat { get; set; }
|
||||
|
||||
[JsonPropertyName("timeFormat")]
|
||||
public string? TimeFormat { get; set; }
|
||||
|
||||
[JsonPropertyName("workingHours")]
|
||||
public object? WorkingHours { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphAutomaticRepliesSetting
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; set; } // disabled, alwaysEnabled, scheduled
|
||||
|
||||
[JsonPropertyName("externalAudience")]
|
||||
public string? ExternalAudience { get; set; } // none, contactsOnly, all
|
||||
|
||||
[JsonPropertyName("scheduledStartDateTime")]
|
||||
public GraphDateTimeTimeZone? ScheduledStartDateTime { get; set; }
|
||||
|
||||
[JsonPropertyName("scheduledEndDateTime")]
|
||||
public GraphDateTimeTimeZone? ScheduledEndDateTime { get; set; }
|
||||
|
||||
[JsonPropertyName("internalReplyMessage")]
|
||||
public string? InternalReplyMessage { get; set; }
|
||||
|
||||
[JsonPropertyName("externalReplyMessage")]
|
||||
public string? ExternalReplyMessage { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphLocaleInfo
|
||||
{
|
||||
[JsonPropertyName("locale")]
|
||||
public string? Locale { get; set; }
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphPermission
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("roles")]
|
||||
public List<string>? Roles { get; set; }
|
||||
|
||||
[JsonPropertyName("grantedTo")]
|
||||
public GraphIdentitySet? GrantedTo { get; set; }
|
||||
|
||||
[JsonPropertyName("grantedToIdentities")]
|
||||
public List<GraphIdentitySet>? GrantedToIdentities { get; set; }
|
||||
|
||||
[JsonPropertyName("link")]
|
||||
public GraphSharingLink? Link { get; set; }
|
||||
|
||||
[JsonPropertyName("invitation")]
|
||||
public GraphSharingInvitation? Invitation { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphSharingLink
|
||||
{
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
[JsonPropertyName("webUrl")]
|
||||
public string? WebUrl { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class GraphSharingInvitation
|
||||
{
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[JsonPropertyName("signInRequired")]
|
||||
public bool? SignInRequired { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj" />
|
||||
<ProjectReference Include="../../Duplicati/Library/Utility/Duplicati.Library.Utility.csproj" />
|
||||
<ProjectReference Include="../LicenseChecker/Duplicati.Proprietary.LicenseChecker.csproj" />
|
||||
<PackageReference Include="MimeKit" Version="4.12.0" />
|
||||
<PackageReference Include="jose-jwt" Version="5.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -9,6 +9,8 @@ internal static class OptionsHelper
|
||||
internal const string OFFICE_TENANT_OPTION = "office-tenant-id";
|
||||
internal const string OFFICE_CLIENT_OPTION = "office-client-id";
|
||||
internal const string OFFICE_SECRET_OPTION = "office-client-secret";
|
||||
internal const string OFFICE_CERTIFICATE_PATH_OPTION = "office-certificate-path";
|
||||
internal const string OFFICE_CERTIFICATE_PASSWORD_OPTION = "office-certificate-password";
|
||||
internal const string OFFICE_GRAPH_BASE_OPTION = "office-graph-base-url";
|
||||
|
||||
internal const string DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com";
|
||||
@@ -19,7 +21,9 @@ internal static class OptionsHelper
|
||||
internal sealed record ParsedOptions(
|
||||
string TenantId,
|
||||
AuthOptionsHelper.AuthOptions AuthOptions,
|
||||
string GraphBaseUrl
|
||||
string GraphBaseUrl,
|
||||
string? CertificatePath,
|
||||
string? CertificatePassword
|
||||
);
|
||||
|
||||
internal static ParsedOptions ParseAndValidateOptions(string url, Dictionary<string, string?> options)
|
||||
@@ -34,10 +38,15 @@ internal static class OptionsHelper
|
||||
if (string.IsNullOrWhiteSpace(_graphBaseUrl))
|
||||
_graphBaseUrl = DEFAULT_GRAPH_BASE_URL;
|
||||
|
||||
var _certificatePath = options.GetValueOrDefault(OFFICE_CERTIFICATE_PATH_OPTION);
|
||||
var _certificatePassword = options.GetValueOrDefault(OFFICE_CERTIFICATE_PASSWORD_OPTION);
|
||||
|
||||
return new ParsedOptions(
|
||||
TenantId: _tenantId,
|
||||
AuthOptions: _authOptions,
|
||||
GraphBaseUrl: _graphBaseUrl
|
||||
GraphBaseUrl: _graphBaseUrl,
|
||||
CertificatePath: _certificatePath,
|
||||
CertificatePassword: _certificatePassword
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +55,8 @@ internal static class OptionsHelper
|
||||
new CommandLineArgument(OFFICE_TENANT_OPTION, CommandLineArgument.ArgumentType.String, Strings.OfficeTenantOptionShort, Strings.OfficeTenantOptionLong),
|
||||
new CommandLineArgument(OFFICE_CLIENT_OPTION, CommandLineArgument.ArgumentType.String, Strings.OfficeClientOptionShort, Strings.OfficeClientOptionLong, null, [AuthOptionsHelper.AuthUsernameOption], null),
|
||||
new CommandLineArgument(OFFICE_SECRET_OPTION, CommandLineArgument.ArgumentType.Password, Strings.OfficeSecretOptionShort, Strings.OfficeSecretOptionLong, null, [AuthOptionsHelper.AuthPasswordOption], null),
|
||||
new CommandLineArgument(OFFICE_CERTIFICATE_PATH_OPTION, CommandLineArgument.ArgumentType.Path, Strings.OfficeCertificatePathOptionShort, Strings.OfficeCertificatePathOptionLong),
|
||||
new CommandLineArgument(OFFICE_CERTIFICATE_PASSWORD_OPTION, CommandLineArgument.ArgumentType.Password, Strings.OfficeCertificatePasswordOptionShort, Strings.OfficeCertificatePasswordOptionLong),
|
||||
new CommandLineArgument(OFFICE_GRAPH_BASE_OPTION, CommandLineArgument.ArgumentType.String, Strings.OfficeGraphBaseOptionShort, Strings.OfficeGraphBaseOptionLong, DEFAULT_GRAPH_BASE_URL)
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
partial class RestoreProvider
|
||||
{
|
||||
internal CalendarApiImpl CalendarApi => new CalendarApiImpl(_apiHelper);
|
||||
private CalendarRestoreHelper? _calendarRestoreHelper = null;
|
||||
internal CalendarRestoreHelper CalendarRestore => _calendarRestoreHelper ??= new CalendarRestoreHelper(this);
|
||||
|
||||
internal class CalendarApiImpl(APIHelper provider)
|
||||
{
|
||||
public async Task RestoreCalendarEventToCalendarAsync(
|
||||
string userId,
|
||||
string targetCalendarId,
|
||||
Stream eventJsonStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var calendar = Uri.EscapeDataString(targetCalendarId);
|
||||
|
||||
// Create event in a specific calendar:
|
||||
// POST /users/{id}/calendars/{id}/events (application/json event body)
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{calendar}/events";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new System.Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
|
||||
// Important: create new content per attempt
|
||||
eventJsonStream.Position = 0;
|
||||
req.Content = new StreamContent(eventJsonStream);
|
||||
req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<GraphCalendar> CreateCalendarAsync(
|
||||
string userId,
|
||||
string name,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars";
|
||||
|
||||
var body = new GraphCreateCalendarRequest { Name = name };
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
=> new HttpRequestMessage(HttpMethod.Post, new System.Uri(url))
|
||||
{
|
||||
Headers =
|
||||
{
|
||||
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
|
||||
},
|
||||
Content = JsonContent.Create(body)
|
||||
};
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var created = await JsonSerializer.DeserializeAsync<GraphCalendar>(respStream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
if (created is null || string.IsNullOrWhiteSpace(created.Id))
|
||||
throw new InvalidOperationException("Graph did not return the created calendar id.");
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<GraphCalendar?> GetCalendarByNameAsync(
|
||||
string userId,
|
||||
string name,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var filterName = name.Replace("'", "''");
|
||||
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars?$filter=name eq '{filterName}'&$top=1";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new System.Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var result = await JsonSerializer.DeserializeAsync<GraphPage<GraphCalendar>>(stream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
return result?.Value?.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<GraphEvent> CreateCalendarEventAsync(
|
||||
string userId,
|
||||
string calendarId,
|
||||
GraphEvent eventItem,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var calendar = Uri.EscapeDataString(calendarId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{calendar}/events";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
=> new HttpRequestMessage(HttpMethod.Post, new System.Uri(url))
|
||||
{
|
||||
Headers =
|
||||
{
|
||||
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
|
||||
},
|
||||
Content = JsonContent.Create(eventItem)
|
||||
};
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<GraphEvent>(respStream, cancellationToken: ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize created event");
|
||||
}
|
||||
|
||||
public async Task<GraphEvent> UpdateCalendarEventAsync(
|
||||
string userId,
|
||||
string eventId,
|
||||
GraphEvent eventUpdate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/events/{eventId}";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
=> new HttpRequestMessage(HttpMethod.Patch, new System.Uri(url))
|
||||
{
|
||||
Headers =
|
||||
{
|
||||
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
|
||||
},
|
||||
Content = JsonContent.Create(eventUpdate)
|
||||
};
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<GraphEvent>(respStream, cancellationToken: ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to deserialize updated event");
|
||||
}
|
||||
|
||||
public async Task<List<GraphEvent>> GetCalendarEventInstancesAsync(
|
||||
string userId,
|
||||
string eventId,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset end,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
// Format dates as ISO 8601
|
||||
var startStr = start.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
var endStr = end.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/events/{eventId}/instances?startDateTime={startStr}&endDateTime={endStr}";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new System.Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var page = await JsonSerializer.DeserializeAsync<GraphPage<GraphEvent>>(stream, cancellationToken: ct).ConfigureAwait(false);
|
||||
return page?.Value ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<GraphEvent>> FindEventsAsync(
|
||||
string userId,
|
||||
string calendarId,
|
||||
string subject,
|
||||
DateTimeOffset start,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var calendar = Uri.EscapeDataString(calendarId);
|
||||
|
||||
// Filter by subject and start time range (e.g. +/- 1 minute to account for precision issues)
|
||||
var startStr = start.AddMinutes(-1).ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
var endStr = start.AddMinutes(1).ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
var subjectFilter = subject.Replace("'", "''");
|
||||
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{calendar}/events?$filter=subject eq '{subjectFilter}' and start/dateTime ge '{startStr}' and start/dateTime le '{endStr}'";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new System.Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var page = await JsonSerializer.DeserializeAsync<GraphPage<GraphEvent>>(stream, cancellationToken: ct).ConfigureAwait(false);
|
||||
return page?.Value ?? [];
|
||||
}
|
||||
|
||||
public async Task AddAttachmentAsync(
|
||||
string userId,
|
||||
string calendarId,
|
||||
string eventId,
|
||||
string name,
|
||||
string contentType,
|
||||
Stream contentStream,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var cal = Uri.EscapeDataString(calendarId);
|
||||
var ev = Uri.EscapeDataString(eventId);
|
||||
|
||||
// Check size
|
||||
if (contentStream.Length > 3 * 1024 * 1024) // 3MB limit for simple upload
|
||||
{
|
||||
// Large file upload
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events/{ev}/attachments/createUploadSession";
|
||||
var sessionBody = new { AttachmentItem = new { attachmentType = "file", name = name, size = contentStream.Length } };
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new System.Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(sessionBody);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var session = await JsonSerializer.DeserializeAsync<GraphUploadSession>(respStream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
if (session?.UploadUrl != null)
|
||||
{
|
||||
await provider.UploadFileToSessionAsync(session.UploadUrl, contentStream, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Simple upload
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events/{ev}/attachments";
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await contentStream.CopyToAsync(ms, ct);
|
||||
var bytes = ms.ToArray();
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
|
||||
var body = new
|
||||
{
|
||||
ODataType = "#microsoft.graph.fileAttachment",
|
||||
Name = name,
|
||||
ContentType = contentType,
|
||||
ContentBytes = base64
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new System.Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class CalendarRestoreHelper(RestoreProvider Provider)
|
||||
{
|
||||
private string? _targetUserId = null;
|
||||
private string? _targetCalendarId = null;
|
||||
private bool _hasLoadedTargetInfo = false;
|
||||
|
||||
public async Task<(string? UserId, string? CalendarId)> GetUserIdAndCalendarTarget(CancellationToken cancel)
|
||||
{
|
||||
if (_hasLoadedTargetInfo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetCalendarId))
|
||||
return (null, null);
|
||||
return (_targetUserId!, _targetCalendarId!);
|
||||
}
|
||||
|
||||
var target = Provider.RestoreTarget;
|
||||
if (target == null)
|
||||
throw new InvalidOperationException("Restore target is not set");
|
||||
|
||||
if (target.Type == SourceItemType.User)
|
||||
{
|
||||
_targetUserId = target.Metadata["o365:Id"]!;
|
||||
_targetCalendarId = await GetDefaultRestoreTargetCalendar(_targetUserId, cancel);
|
||||
}
|
||||
else if (target.Type == SourceItemType.UserCalendar || target.Type == SourceItemType.Calendar)
|
||||
{
|
||||
_targetUserId = target.Path.TrimStart(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar).Skip(1).FirstOrDefault();
|
||||
_targetCalendarId = target.Metadata["o365:Id"];
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarEventsInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring calendar events.");
|
||||
}
|
||||
|
||||
_hasLoadedTargetInfo = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetCalendarId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreCalendarEventsMissingIds", null, $"Missing target userId or calendarId for restoring calendar events.");
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
return (_targetUserId, _targetCalendarId);
|
||||
}
|
||||
|
||||
private async Task<string> GetDefaultRestoreTargetCalendar(string userId, CancellationToken cancel)
|
||||
{
|
||||
const string RESTORED_CALENDAR_NAME = "Restored";
|
||||
|
||||
var existing = await Provider.CalendarApi.GetCalendarByNameAsync(userId, RESTORED_CALENDAR_NAME, cancel);
|
||||
if (existing != null)
|
||||
return existing.Id;
|
||||
|
||||
var created = await Provider.CalendarApi.CreateCalendarAsync(userId, RESTORED_CALENDAR_NAME, cancel);
|
||||
return created.Id;
|
||||
}
|
||||
|
||||
public async Task RestoreEvents(List<KeyValuePair<string, Dictionary<string, string?>>> events, CancellationToken cancel)
|
||||
{
|
||||
(var userId, var calendarId) = await GetUserIdAndCalendarTarget(cancel);
|
||||
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(calendarId))
|
||||
return;
|
||||
|
||||
// Group attachments by event path
|
||||
var attachments = Provider.GetMetadataByType(SourceItemType.CalendarEventAttachment)
|
||||
.GroupBy(k => Util.AppendDirSeparator(Path.GetDirectoryName(k.Key.TrimEnd(Path.DirectorySeparatorChar))))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var singles = new List<(string Path, GraphEvent Event)>();
|
||||
var masters = new List<(string Path, GraphEvent Event)>();
|
||||
var exceptions = new List<(string Path, GraphEvent Event)>();
|
||||
|
||||
foreach (var eventItem in events)
|
||||
{
|
||||
if (cancel.IsCancellationRequested) break;
|
||||
|
||||
var originalPath = eventItem.Key;
|
||||
GraphEvent? graphEvent = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Try to read as file (old format)
|
||||
if (await Provider.FileExists(originalPath, cancel))
|
||||
{
|
||||
using var stream = await Provider.OpenRead(originalPath, cancel);
|
||||
graphEvent = await JsonSerializer.DeserializeAsync<GraphEvent>(stream, cancellationToken: cancel);
|
||||
}
|
||||
// Try to read content.json (new format)
|
||||
else
|
||||
{
|
||||
var contentPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
|
||||
if (await Provider.FileExists(contentPath, cancel))
|
||||
{
|
||||
using var stream = await Provider.OpenRead(contentPath, cancel);
|
||||
graphEvent = await JsonSerializer.DeserializeAsync<GraphEvent>(stream, cancellationToken: cancel);
|
||||
|
||||
// Clean up content file metadata
|
||||
Provider._metadata.TryRemove(contentPath, out _);
|
||||
}
|
||||
}
|
||||
|
||||
if (graphEvent == null) continue;
|
||||
|
||||
if (graphEvent.Type == "seriesMaster")
|
||||
masters.Add((originalPath, graphEvent));
|
||||
else if (graphEvent.Type == "exception")
|
||||
exceptions.Add((originalPath, graphEvent));
|
||||
else if (graphEvent.Type == "occurrence")
|
||||
continue; // Skip occurrences
|
||||
else
|
||||
singles.Add((originalPath, graphEvent));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreCalendarEventAnalysisFailed", ex, $"Failed to analyze event {originalPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Restore singles
|
||||
foreach (var item in singles)
|
||||
{
|
||||
await RestoreSingleEvent(userId, calendarId, item.Path, item.Event, attachments, cancel);
|
||||
}
|
||||
|
||||
// Restore masters
|
||||
var masterMap = new Dictionary<string, string>(); // OldId -> NewId
|
||||
foreach (var item in masters)
|
||||
{
|
||||
var newId = await RestoreMasterEvent(userId, calendarId, item.Path, item.Event, attachments, cancel);
|
||||
if (newId != null)
|
||||
masterMap[item.Event.Id] = newId;
|
||||
}
|
||||
|
||||
// Restore exceptions
|
||||
foreach (var item in exceptions)
|
||||
{
|
||||
await RestoreExceptionEvent(userId, calendarId, item.Path, item.Event, masterMap, attachments, cancel);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreAttachments(string userId, string calendarId, string eventId, string eventPath, Dictionary<string, List<KeyValuePair<string, Dictionary<string, string?>>>> attachments, CancellationToken cancel)
|
||||
{
|
||||
var eventPathWithSep = Util.AppendDirSeparator(eventPath);
|
||||
if (!attachments.TryGetValue(eventPathWithSep, out var eventAttachments))
|
||||
return;
|
||||
|
||||
foreach (var att in eventAttachments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attPath = att.Key;
|
||||
var metadata = att.Value;
|
||||
var name = metadata.GetValueOrDefault("o365:Name") ?? "attachment";
|
||||
var contentType = metadata.GetValueOrDefault("o365:ContentType") ?? "application/octet-stream";
|
||||
|
||||
using var stream = await Provider.OpenRead(attPath, cancel);
|
||||
await Provider.CalendarApi.AddAttachmentAsync(userId, calendarId, eventId, name, contentType, stream, cancel);
|
||||
|
||||
Provider._metadata.TryRemove(attPath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreCalendarAttachmentFailed", ex, $"Failed to restore attachment for event {eventPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreSingleEvent(string userId, string calendarId, string path, GraphEvent eventItem, Dictionary<string, List<KeyValuePair<string, Dictionary<string, string?>>>> attachments, CancellationToken cancel)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Provider._overwrite && !string.IsNullOrWhiteSpace(eventItem.Subject))
|
||||
{
|
||||
DateTimeOffset? start = null;
|
||||
if (eventItem.Start is JsonElement startElem && startElem.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (startElem.TryGetProperty("dateTime", out var dtProp) && dtProp.GetString() is string dtStr)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(dtStr, out var dt))
|
||||
{
|
||||
start = dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (start.HasValue)
|
||||
{
|
||||
var existing = await Provider.CalendarApi.FindEventsAsync(userId, calendarId, eventItem.Subject, start.Value, cancel);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
Log.WriteInformationMessage(LOGTAG, "RestoreSingleEventSkipDuplicate", null, $"Skipping duplicate event {path} (Subject: {eventItem.Subject})");
|
||||
Provider._metadata.TryRemove(path, out _);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up properties that shouldn't be sent on creation
|
||||
eventItem.Id = "";
|
||||
eventItem.CreatedDateTime = null;
|
||||
eventItem.LastModifiedDateTime = null;
|
||||
|
||||
var created = await Provider.CalendarApi.CreateCalendarEventAsync(userId, calendarId, eventItem, cancel);
|
||||
await RestoreAttachments(userId, calendarId, created.Id, path, attachments, cancel);
|
||||
Provider._metadata.TryRemove(path, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreSingleEventFailed", ex, $"Failed to restore event {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> RestoreMasterEvent(string userId, string calendarId, string path, GraphEvent eventItem, Dictionary<string, List<KeyValuePair<string, Dictionary<string, string?>>>> attachments, CancellationToken cancel)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Provider._overwrite && !string.IsNullOrWhiteSpace(eventItem.Subject))
|
||||
{
|
||||
DateTimeOffset? start = null;
|
||||
if (eventItem.Start is JsonElement startElem && startElem.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (startElem.TryGetProperty("dateTime", out var dtProp) && dtProp.GetString() is string dtStr)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(dtStr, out var dt))
|
||||
{
|
||||
start = dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (start.HasValue)
|
||||
{
|
||||
var existing = await Provider.CalendarApi.FindEventsAsync(userId, calendarId, eventItem.Subject, start.Value, cancel);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
Log.WriteInformationMessage(LOGTAG, "RestoreMasterEventSkipDuplicate", null, $"Skipping duplicate master event {path} (Subject: {eventItem.Subject})");
|
||||
Provider._metadata.TryRemove(path, out _);
|
||||
// Return the ID of the existing event so exceptions can be attached to it?
|
||||
// If we skip it, we should probably return the existing ID.
|
||||
return existing[0].Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventItem.Id = "";
|
||||
eventItem.CreatedDateTime = null;
|
||||
eventItem.LastModifiedDateTime = null;
|
||||
|
||||
var created = await Provider.CalendarApi.CreateCalendarEventAsync(userId, calendarId, eventItem, cancel);
|
||||
await RestoreAttachments(userId, calendarId, created.Id, path, attachments, cancel);
|
||||
Provider._metadata.TryRemove(path, out _);
|
||||
return created.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreMasterEventFailed", ex, $"Failed to restore master event {path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreExceptionEvent(string userId, string calendarId, string path, GraphEvent eventItem, Dictionary<string, string> masterMap, Dictionary<string, List<KeyValuePair<string, Dictionary<string, string?>>>> attachments, CancellationToken cancel)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventItem.SeriesMasterId) || !masterMap.TryGetValue(eventItem.SeriesMasterId, out var newMasterId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreExceptionEventMissingMaster", null, $"Could not find restored master for exception event {path}, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventItem.OriginalStart == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreExceptionEventMissingOriginalStart", null, $"Missing original start time for exception event {path}, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the occurrence
|
||||
// We search for instances around the original start time
|
||||
var searchStart = eventItem.OriginalStart.Value.AddMinutes(-1);
|
||||
var searchEnd = eventItem.OriginalStart.Value.AddMinutes(1);
|
||||
|
||||
var instances = await Provider.CalendarApi.GetCalendarEventInstancesAsync(userId, newMasterId, searchStart, searchEnd, cancel);
|
||||
|
||||
var instance = instances.FirstOrDefault(i =>
|
||||
{
|
||||
// Parse start time from instance
|
||||
if (i.Start is JsonElement startElem && startElem.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (startElem.TryGetProperty("dateTime", out var dtProp) && dtProp.GetString() is string dtStr)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(dtStr, out var dt))
|
||||
{
|
||||
return Math.Abs((dt - eventItem.OriginalStart.Value).TotalSeconds) < 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (instance == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreExceptionEventInstanceNotFound", null, $"Could not find occurrence instance for exception event {path}, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the instance
|
||||
eventItem.Id = "";
|
||||
eventItem.SeriesMasterId = null;
|
||||
eventItem.Type = null;
|
||||
eventItem.CreatedDateTime = null;
|
||||
eventItem.LastModifiedDateTime = null;
|
||||
eventItem.OriginalStart = null;
|
||||
|
||||
var updated = await Provider.CalendarApi.UpdateCalendarEventAsync(userId, instance.Id, eventItem, cancel);
|
||||
await RestoreAttachments(userId, calendarId, updated.Id, path, attachments, cancel);
|
||||
Provider._metadata.TryRemove(path, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreExceptionEventFailed", ex, $"Failed to restore exception event {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
public partial class RestoreProvider
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, string> _restoredChatMap = new();
|
||||
|
||||
internal ChatApiImpl ChatApi => new ChatApiImpl(_apiHelper);
|
||||
|
||||
internal class ChatApiImpl(APIHelper provider)
|
||||
{
|
||||
internal async Task<GraphChat> CreateChatAsync(string chatType, string? topic, IEnumerable<string> members, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var url = $"{baseUrl}/v1.0/chats";
|
||||
|
||||
var membersList = members.Select(id => new
|
||||
{
|
||||
roles = new[] { "owner" },
|
||||
// For oneOnOne chats, we need to provide the user ID
|
||||
// For group chats, we also provide user IDs
|
||||
// The format for members in create chat is:
|
||||
// "members": [ { "@odata.type": "#microsoft.graph.aadUserConversationMember", "roles": ["owner"], "user@odata.bind": "https://graph.microsoft.com/v1.0/users('userId')" } ]
|
||||
// But we might only have the ID.
|
||||
// Let's assume we have the user ID.
|
||||
}).ToList();
|
||||
|
||||
// Constructing the payload is tricky because we need to bind users.
|
||||
// We need to know if 'members' contains user IDs.
|
||||
// Assuming 'members' are user IDs.
|
||||
|
||||
var membersPayload = members.Select(userId => new
|
||||
{
|
||||
odata_type = "#microsoft.graph.aadUserConversationMember",
|
||||
roles = new[] { "owner" },
|
||||
user_odata_bind = $"https://graph.microsoft.com/v1.0/users('{userId}')"
|
||||
}).ToList();
|
||||
|
||||
// We need to use a dictionary or custom object to handle the property names with special characters like @ and .
|
||||
// But System.Text.Json handles standard properties.
|
||||
// For "@odata.type" and "user@odata.bind", we can use JsonPropertyName attribute if we had a class,
|
||||
// or use a Dictionary<string, object>.
|
||||
|
||||
var payloadMembers = new List<Dictionary<string, object>>();
|
||||
foreach (var userId in members)
|
||||
{
|
||||
var member = new Dictionary<string, object>
|
||||
{
|
||||
{ "@odata.type", "#microsoft.graph.aadUserConversationMember" },
|
||||
{ "roles", new[] { "owner" } },
|
||||
{ "user@odata.bind", $"https://graph.microsoft.com/v1.0/users('{userId}')" }
|
||||
};
|
||||
payloadMembers.Add(member);
|
||||
}
|
||||
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
{ "chatType", chatType },
|
||||
{ "members", payloadMembers }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(topic))
|
||||
{
|
||||
payload["topic"] = topic;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
return await provider.PostGraphItemAsync<GraphChat>(url, content, ct);
|
||||
}
|
||||
|
||||
internal async Task SendMessageAsync(string chatId, string? subject, object? body, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var chat = Uri.EscapeDataString(chatId);
|
||||
|
||||
var url = $"{baseUrl}/v1.0/chats/{chat}/messages";
|
||||
|
||||
var payload = new Dictionary<string, object>();
|
||||
|
||||
if (!string.IsNullOrEmpty(subject))
|
||||
{
|
||||
payload["subject"] = subject;
|
||||
}
|
||||
|
||||
if (body != null)
|
||||
{
|
||||
payload["body"] = body;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
await provider.PostGraphItemNoResponseAsync(url, content, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreChats(CancellationToken cancel)
|
||||
{
|
||||
if (RestoreTarget == null)
|
||||
throw new InvalidOperationException("Restore target entry is not set");
|
||||
|
||||
var chats = GetMetadataByType(SourceItemType.Chat);
|
||||
if (chats.Count == 0)
|
||||
return;
|
||||
|
||||
// We need to know who the current user is to include them in the chat if needed?
|
||||
// Or we just restore the chat with the original members.
|
||||
// But we need to map members if we are restoring to a different tenant (which is a low priority task, so maybe assume same tenant for now).
|
||||
// However, we need to get the members from somewhere.
|
||||
// The Chat metadata might contain member IDs?
|
||||
// Or we have ChatMember items.
|
||||
|
||||
// Let's look at ChatMember items.
|
||||
var chatMembers = GetMetadataByType(SourceItemType.ChatMember);
|
||||
|
||||
foreach (var chat in chats)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = chat.Key;
|
||||
var metadata = chat.Value;
|
||||
var chatType = metadata.GetValueOrDefault("o365:ChatType") ?? "group"; // Default to group if unknown
|
||||
var topic = metadata.GetValueOrDefault("o365:Topic");
|
||||
|
||||
// Find members for this chat
|
||||
// ChatMember path: .../ChatId/members/MemberId
|
||||
var members = chatMembers
|
||||
.Where(m => m.Key.StartsWith(originalPath + Path.DirectorySeparatorChar))
|
||||
.Select(m => m.Value.GetValueOrDefault("o365:UserId")) // Assuming we stored UserId in metadata
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (members.Count == 0)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreChatsNoMembers", null, $"Chat {originalPath} has no members, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create chat
|
||||
// Note: If chatType is oneOnOne, we can't set the topic.
|
||||
// And we need exactly 2 members (one of them being the caller usually, but here we are restoring).
|
||||
// If we are restoring, we are the caller. So we need to include ourselves?
|
||||
// The API will include the caller automatically if not specified?
|
||||
// Actually, for oneOnOne, we just provide the other user.
|
||||
// But let's try to provide all members we found.
|
||||
|
||||
var newChat = await ChatApi.CreateChatAsync(chatType, topic, members!, cancel);
|
||||
_restoredChatMap[originalPath] = newChat.Id;
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreChatsFailed", ex, $"Failed to restore chat {chat.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreChatMessages(CancellationToken cancel)
|
||||
{
|
||||
if (RestoreTarget == null)
|
||||
throw new InvalidOperationException("Restore target entry is not set");
|
||||
|
||||
var messages = GetMetadataByType(SourceItemType.ChatMessage);
|
||||
if (messages.Count == 0)
|
||||
return;
|
||||
|
||||
// Sort messages by creation time to restore in order
|
||||
var sortedMessages = messages.OrderBy(m => m.Value.GetValueOrDefault("o365:CreatedDateTime")).ToList();
|
||||
|
||||
foreach (var message in sortedMessages)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = message.Key;
|
||||
var metadata = message.Value;
|
||||
var subject = metadata.GetValueOrDefault("o365:Subject");
|
||||
|
||||
// Find chat ID
|
||||
// Message path: .../ChatId/messages/MessageId
|
||||
var parent = Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)); // .../ChatId/messages
|
||||
if (parent == null) continue;
|
||||
|
||||
var grandParent = Path.GetDirectoryName(parent); // .../ChatId
|
||||
if (grandParent == null) continue;
|
||||
|
||||
if (!_restoredChatMap.TryGetValue(grandParent, out var chatId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesMissingChat", null, $"Could not find restored chat for message {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
|
||||
if (contentEntry == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesMissingContent", null, $"Missing content for message {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
object? body = null;
|
||||
using (var stream = SystemIO.IO_OS.FileOpenRead(contentEntry))
|
||||
{
|
||||
var graphMessage = await JsonSerializer.DeserializeAsync<GraphChatMessage>(stream, cancellationToken: cancel);
|
||||
body = graphMessage?.Body;
|
||||
}
|
||||
|
||||
if (body == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesMissingBody", null, $"Missing body for message {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await ChatApi.SendMessageAsync(chatId, subject, body, cancel);
|
||||
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
_temporaryFiles.TryRemove(originalPath, out var contentFile);
|
||||
contentFile?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreChatMessagesFailed", ex, $"Failed to restore message {message.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreChatHostedContent(CancellationToken cancel)
|
||||
{
|
||||
// Hosted content is usually inline images in messages.
|
||||
// Since we can't easily modify the message body to point to new hosted content (it requires uploading and getting a new URL),
|
||||
// and the message body we restore still points to the old URL (which might be broken or inaccessible),
|
||||
// restoring hosted content separately might not be enough.
|
||||
// However, if we want to restore it, we need to know where to put it.
|
||||
// The API for chat messages allows sending attachments, but hosted content is different.
|
||||
// Hosted content is typically read-only via API.
|
||||
// So we might skip this for now or just log a warning.
|
||||
|
||||
// But the task says "Implement RestoreChatHostedContent()".
|
||||
// If we can't restore it, we should at least acknowledge it.
|
||||
|
||||
var contents = GetMetadataByType(SourceItemType.ChatHostedContent);
|
||||
foreach (var content in contents)
|
||||
{
|
||||
_metadata.TryRemove(content.Key, out _);
|
||||
_temporaryFiles.TryRemove(content.Key, out var f);
|
||||
f?.Dispose();
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
partial class RestoreProvider
|
||||
{
|
||||
internal ContactApiImpl ContactApi => new ContactApiImpl(_apiHelper);
|
||||
private ContactRestoreHelper? _contactRestoreHelper = null;
|
||||
internal ContactRestoreHelper ContactRestore => _contactRestoreHelper ??= new ContactRestoreHelper(this);
|
||||
|
||||
internal class ContactApiImpl(APIHelper provider)
|
||||
{
|
||||
public async Task<GraphContact> RestoreContactToFolderAsync(
|
||||
string userId,
|
||||
string targetFolderId,
|
||||
Stream contactJsonStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var folder = Uri.EscapeDataString(targetFolderId);
|
||||
|
||||
// POST /users/{id}/contactFolders/{id}/contacts
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{folder}/contacts";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
contactJsonStream.Position = 0;
|
||||
req.Content = new StreamContent(contactJsonStream);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var respStream = await resp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var created = await JsonSerializer.DeserializeAsync<GraphContact>(respStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (created is null || string.IsNullOrWhiteSpace(created.Id))
|
||||
throw new InvalidOperationException("Graph did not return the created contact id.");
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<GraphContactFolder> CreateContactFolderAsync(
|
||||
string userId,
|
||||
string displayName,
|
||||
string? parentFolderId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
|
||||
string url;
|
||||
if (string.IsNullOrWhiteSpace(parentFolderId))
|
||||
{
|
||||
// POST /users/{id}/contactFolders
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contactFolders";
|
||||
}
|
||||
else
|
||||
{
|
||||
// POST /users/{id}/contactFolders/{id}/childFolders
|
||||
var parent = Uri.EscapeDataString(parentFolderId);
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{parent}/childFolders";
|
||||
}
|
||||
|
||||
var body = new { displayName = displayName };
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
=> new HttpRequestMessage(HttpMethod.Post, new Uri(url))
|
||||
{
|
||||
Headers =
|
||||
{
|
||||
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
|
||||
},
|
||||
Content = JsonContent.Create(body)
|
||||
};
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var created = await JsonSerializer.DeserializeAsync<GraphContactFolder>(respStream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
if (created is null || string.IsNullOrWhiteSpace(created.Id))
|
||||
throw new InvalidOperationException("Graph did not return the created contact folder id.");
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task RestoreContactPhotoAsync(
|
||||
string userId,
|
||||
string contactId,
|
||||
string? contactFolderId,
|
||||
Stream photoStream,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var contact = Uri.EscapeDataString(contactId);
|
||||
|
||||
string url;
|
||||
if (string.IsNullOrWhiteSpace(contactFolderId))
|
||||
{
|
||||
// PUT /users/{id}/contacts/{id}/photo/$value
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contacts/{contact}/photo/$value";
|
||||
}
|
||||
else
|
||||
{
|
||||
// PUT /users/{id}/contactFolders/{id}/contacts/{id}/photo/$value
|
||||
var folder = Uri.EscapeDataString(contactFolderId);
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{folder}/contacts/{contact}/photo/$value";
|
||||
}
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
photoStream.Position = 0;
|
||||
req.Content = new StreamContent(photoStream);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); // Assuming JPEG, but Graph usually detects or accepts generic
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<GraphContactFolder?> GetContactFolderByNameAsync(
|
||||
string userId,
|
||||
string displayName,
|
||||
string? parentFolderId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var filterName = displayName.Replace("'", "''");
|
||||
|
||||
string url;
|
||||
if (string.IsNullOrWhiteSpace(parentFolderId))
|
||||
{
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contactFolders?$filter=displayName eq '{filterName}'&$top=1";
|
||||
}
|
||||
else
|
||||
{
|
||||
var parent = Uri.EscapeDataString(parentFolderId);
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{parent}/childFolders?$filter=displayName eq '{filterName}'&$top=1";
|
||||
}
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var result = await JsonSerializer.DeserializeAsync<GraphPage<GraphContactFolder>>(stream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
return result?.Value?.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<List<GraphContact>> FindContactsAsync(
|
||||
string userId,
|
||||
string folderId,
|
||||
string? email,
|
||||
string? displayName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var folder = Uri.EscapeDataString(folderId);
|
||||
|
||||
var filters = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
filters.Add($"emailAddresses/any(a:a/address eq '{email}')");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
filters.Add($"displayName eq '{displayName.Replace("'", "''")}'");
|
||||
}
|
||||
|
||||
if (filters.Count == 0) return [];
|
||||
|
||||
var filter = string.Join(" or ", filters);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{folder}/contacts?$filter={filter}";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var page = await JsonSerializer.DeserializeAsync<GraphPage<GraphContact>>(stream, cancellationToken: ct).ConfigureAwait(false);
|
||||
return page?.Value ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
internal class ContactRestoreHelper(RestoreProvider Provider)
|
||||
{
|
||||
private string? _targetUserId = null;
|
||||
private string? _targetFolderId = null;
|
||||
private bool _hasLoadedTargetInfo = false;
|
||||
|
||||
public async Task<(string? UserId, string? FolderId)> GetUserIdAndContactFolderTarget(CancellationToken cancel)
|
||||
{
|
||||
if (_hasLoadedTargetInfo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetFolderId))
|
||||
return (null, null);
|
||||
return (_targetUserId!, _targetFolderId!);
|
||||
}
|
||||
|
||||
var target = Provider.RestoreTarget;
|
||||
if (target == null)
|
||||
throw new InvalidOperationException("Restore target is not set");
|
||||
|
||||
if (target.Type == SourceItemType.User)
|
||||
{
|
||||
_targetUserId = target.Metadata["o365:Id"]!;
|
||||
_targetFolderId = await GetDefaultRestoreTargetContactFolder(_targetUserId, cancel);
|
||||
}
|
||||
else if (target.Type == SourceItemType.UserContacts)
|
||||
{
|
||||
_targetUserId = target.Path.TrimStart(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar).Skip(1).FirstOrDefault();
|
||||
|
||||
if (_targetUserId != null)
|
||||
_targetFolderId = await GetDefaultRestoreTargetContactFolder(_targetUserId, cancel);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreContactsInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring contacts.");
|
||||
}
|
||||
|
||||
_hasLoadedTargetInfo = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetFolderId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreContactsMissingIds", null, $"Missing target userId or folderId for restoring contacts.");
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
return (_targetUserId, _targetFolderId);
|
||||
}
|
||||
|
||||
private async Task<string> GetDefaultRestoreTargetContactFolder(string userId, CancellationToken cancel)
|
||||
{
|
||||
const string RESTORED_FOLDER_NAME = "Restored";
|
||||
|
||||
var existing = await Provider.ContactApi.GetContactFolderByNameAsync(userId, RESTORED_FOLDER_NAME, null, cancel);
|
||||
if (existing != null)
|
||||
return existing.Id;
|
||||
|
||||
var created = await Provider.ContactApi.CreateContactFolderAsync(userId, RESTORED_FOLDER_NAME, null, cancel);
|
||||
return created.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
partial class RestoreProvider
|
||||
{
|
||||
internal DriveApiImpl DriveApi => new DriveApiImpl(_apiHelper);
|
||||
|
||||
internal class DriveApiImpl(APIHelper provider)
|
||||
{
|
||||
public async Task<GraphDriveItem> CreateDriveFolderAsync(
|
||||
string driveId,
|
||||
string parentItemId,
|
||||
string name,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var parent = Uri.EscapeDataString(parentItemId);
|
||||
|
||||
// POST /drives/{drive-id}/items/{parent-id}/children
|
||||
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{parent}/children";
|
||||
|
||||
var body = new
|
||||
{
|
||||
name = name,
|
||||
folder = new { },
|
||||
@microsoft_graph_conflictBehavior = "rename"
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
await using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<GraphDriveItem>(respStream, cancellationToken: ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Graph did not return the created folder item.");
|
||||
}
|
||||
|
||||
public async Task<GraphDriveItem> RestoreDriveItemToFolderAsync(
|
||||
string driveId,
|
||||
string targetFolderItemId,
|
||||
string fileName,
|
||||
Stream contentStream,
|
||||
string? contentType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Use upload session for files larger than 4MB
|
||||
if (contentStream.Length > 4 * 1024 * 1024)
|
||||
{
|
||||
return await RestoreLargeDriveItemToFolderAsync(driveId, targetFolderItemId, fileName, contentStream, contentType, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var folder = Uri.EscapeDataString(targetFolderItemId);
|
||||
var name = Uri.EscapeDataString(fileName);
|
||||
|
||||
// PUT /drives/{drive-id}/items/{parent-id}:/{filename}:/content
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/drives/{drive}/items/{folder}:/{name}:/content";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
|
||||
contentStream.Position = 0;
|
||||
req.Content = new StreamContent(contentStream);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType ?? "application/octet-stream");
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var respStream = await resp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<GraphDriveItem>(respStream, cancellationToken: cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Graph did not return the restored item.");
|
||||
}
|
||||
|
||||
private async Task<GraphDriveItem> RestoreLargeDriveItemToFolderAsync(
|
||||
string driveId,
|
||||
string targetFolderItemId,
|
||||
string fileName,
|
||||
Stream contentStream,
|
||||
string? contentType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var folder = Uri.EscapeDataString(targetFolderItemId);
|
||||
var name = Uri.EscapeDataString(fileName);
|
||||
|
||||
// 1. Create upload session
|
||||
// POST /drives/{drive-id}/items/{parent-id}:/{filename}:/createUploadSession
|
||||
var createSessionUrl = $"{baseUrl}/v1.0/drives/{drive}/items/{folder}:/{name}:/createUploadSession";
|
||||
|
||||
var sessionBody = new
|
||||
{
|
||||
item = new
|
||||
{
|
||||
@microsoft_graph_conflictBehavior = "rename",
|
||||
name = name
|
||||
}
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> createSessionRequestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(createSessionUrl));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(sessionBody);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var sessionResp = await provider.SendWithRetryShortAsync(
|
||||
createSessionRequestFactory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(sessionResp, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var uploadSession = await sessionResp.Content.ReadFromJsonAsync<GraphUploadSession>(cancellationToken: cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to create upload session.");
|
||||
|
||||
// 2. Upload fragments
|
||||
var uploadUrl = uploadSession.uploadUrl;
|
||||
var totalSize = contentStream.Length;
|
||||
// 320 KiB is the minimum fragment size, we use a multiple of it (e.g. 5MB approx)
|
||||
// 320 * 1024 = 327680 bytes. 16 * 327680 = 5242880 bytes (5MB)
|
||||
var fragmentSize = 320 * 1024 * 16;
|
||||
var buffer = new byte[fragmentSize];
|
||||
long position = 0;
|
||||
|
||||
contentStream.Position = 0;
|
||||
|
||||
GraphDriveItem? resultItem = null;
|
||||
|
||||
while (position < totalSize)
|
||||
{
|
||||
var bytesRead = await contentStream.ReadAsync(buffer, 0, fragmentSize, cancellationToken).ConfigureAwait(false);
|
||||
var isLast = position + bytesRead >= totalSize;
|
||||
|
||||
async Task<HttpRequestMessage> uploadFragmentRequestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Put, new Uri(uploadUrl));
|
||||
// No auth header needed for upload session URL
|
||||
req.Content = new ByteArrayContent(buffer, 0, bytesRead);
|
||||
req.Content.Headers.ContentRange = new ContentRangeHeaderValue(position, position + bytesRead - 1, totalSize);
|
||||
req.Content.Headers.ContentLength = bytesRead;
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
using var uploadResp = await provider.SendWithRetryShortAsync(
|
||||
uploadFragmentRequestFactory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!uploadResp.IsSuccessStatusCode)
|
||||
{
|
||||
// If we get an error, we should probably check if the session is still valid or if we can retry.
|
||||
// For now, we rely on SendWithRetryAsync for transient errors, but if it fails here, it's likely fatal or needs session recreation.
|
||||
// We'll throw and let the caller handle it.
|
||||
var error = await uploadResp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to upload fragment: {uploadResp.StatusCode} {error}");
|
||||
}
|
||||
|
||||
if (isLast)
|
||||
{
|
||||
await using var respStream = await uploadResp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
resultItem = await JsonSerializer.DeserializeAsync<GraphDriveItem>(respStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
position += bytesRead;
|
||||
}
|
||||
|
||||
return resultItem ?? throw new InvalidOperationException("Upload completed but no item returned.");
|
||||
}
|
||||
|
||||
public async Task UpdateDriveItemFileSystemInfoAsync(
|
||||
string driveId,
|
||||
string itemId,
|
||||
DateTimeOffset? created,
|
||||
DateTimeOffset? modified,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (created == null && modified == null) return;
|
||||
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var item = Uri.EscapeDataString(itemId);
|
||||
|
||||
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{item}";
|
||||
|
||||
var body = new
|
||||
{
|
||||
fileSystemInfo = new
|
||||
{
|
||||
createdDateTime = created,
|
||||
lastModifiedDateTime = modified
|
||||
}
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private class GraphUploadSession
|
||||
{
|
||||
public string uploadUrl { get; set; } = "";
|
||||
public DateTimeOffset? expirationDateTime { get; set; }
|
||||
public string[]? nextExpectedRanges { get; set; }
|
||||
}
|
||||
|
||||
public async Task RestoreDriveItemMetadataAsync(
|
||||
string driveId,
|
||||
string itemId,
|
||||
Stream metadataJsonStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var item = Uri.EscapeDataString(itemId);
|
||||
|
||||
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{item}";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
|
||||
metadataJsonStream.Position = 0;
|
||||
req.Content = new StreamContent(metadataJsonStream);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<GraphDriveItem?> GetDriveItemAsync(
|
||||
string driveId,
|
||||
string parentId,
|
||||
string name,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var parent = Uri.EscapeDataString(parentId);
|
||||
var itemName = Uri.EscapeDataString(name);
|
||||
|
||||
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{parent}:/{itemName}";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<GraphDriveItem>(stream, cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RestoreDriveItemPermissionsAsync(
|
||||
string driveId,
|
||||
string itemId,
|
||||
string permissionsJson,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(permissionsJson)) return;
|
||||
|
||||
List<GraphPermission>? permissions;
|
||||
try
|
||||
{
|
||||
permissions = JsonSerializer.Deserialize<List<GraphPermission>>(permissionsJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissions == null || permissions.Count == 0) return;
|
||||
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var item = Uri.EscapeDataString(itemId);
|
||||
|
||||
// POST /drives/{drive-id}/items/{item-id}/invite
|
||||
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{item}/invite";
|
||||
|
||||
foreach (var perm in permissions)
|
||||
{
|
||||
// We can only restore permissions that have roles and recipients (grantedTo or grantedToIdentities)
|
||||
// We skip links for now as they are complex to restore (might need to create new links)
|
||||
// We also skip if no roles are defined.
|
||||
|
||||
if (perm.Roles == null || perm.Roles.Count == 0) continue;
|
||||
|
||||
var recipients = new List<object>();
|
||||
|
||||
if (perm.GrantedTo?.User?.Email != null)
|
||||
{
|
||||
recipients.Add(new { email = perm.GrantedTo.User.Email });
|
||||
}
|
||||
|
||||
if (perm.GrantedToIdentities != null)
|
||||
{
|
||||
foreach (var identity in perm.GrantedToIdentities)
|
||||
{
|
||||
if (identity.User?.Email != null)
|
||||
{
|
||||
recipients.Add(new { email = identity.User.Email });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check Invitation email
|
||||
if (perm.Invitation?.Email != null)
|
||||
{
|
||||
recipients.Add(new { email = perm.Invitation.Email });
|
||||
}
|
||||
|
||||
if (recipients.Count == 0) continue;
|
||||
|
||||
var body = new
|
||||
{
|
||||
requireSignIn = true, // Default to true for security
|
||||
sendInvitation = false, // Don't spam users during restore
|
||||
roles = perm.Roles,
|
||||
recipients = recipients,
|
||||
message = "Restored by Duplicati"
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// We don't throw on permission restore failure, just log/ignore
|
||||
// await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore permission restore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
@@ -96,5 +97,21 @@ partial class RestoreProvider
|
||||
var restoredFolder = await Provider.EmailApi.CreateMailFolderAsync(userId, "msgfolderroot", RESTORED_FOLDER_NAME, cancel);
|
||||
return restoredFolder.Id;
|
||||
}
|
||||
|
||||
public async Task RestoreMailboxSettings(string userId, Stream settingsStream, CancellationToken cancel)
|
||||
{
|
||||
var settings = await JsonSerializer.DeserializeAsync<GraphMailboxSettings>(settingsStream, cancellationToken: cancel);
|
||||
if (settings == null) return;
|
||||
|
||||
await Provider.EmailApi.UpdateMailboxSettingsAsync(userId, settings, cancel);
|
||||
}
|
||||
|
||||
public async Task RestoreMessageRule(string userId, Stream ruleStream, CancellationToken cancel)
|
||||
{
|
||||
var rule = await JsonSerializer.DeserializeAsync<GraphMessageRule>(ruleStream, cancellationToken: cancel);
|
||||
if (rule == null) return;
|
||||
|
||||
await Provider.EmailApi.CreateMessageRuleAsync(userId, rule, cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
public partial class RestoreProvider
|
||||
{
|
||||
internal OnenoteApiImpl OnenoteApi => new OnenoteApiImpl(_apiHelper);
|
||||
|
||||
internal class OnenoteApiImpl(APIHelper provider)
|
||||
{
|
||||
internal Task<GraphNotebook> CreateNotebookAsync(string userIdOrUpn, string displayName, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/onenote/notebooks";
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(new { displayName }), Encoding.UTF8, "application/json");
|
||||
return provider.PostGraphItemAsync<GraphNotebook>(url, content, ct);
|
||||
}
|
||||
|
||||
internal Task<GraphOnenoteSection> CreateSectionAsync(string parentId, string displayName, bool isNotebookParent, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var parent = Uri.EscapeDataString(parentId);
|
||||
var url = isNotebookParent
|
||||
? $"{baseUrl}/v1.0/onenote/notebooks/{parent}/sections"
|
||||
: $"{baseUrl}/v1.0/onenote/sectionGroups/{parent}/sections";
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(new { displayName }), Encoding.UTF8, "application/json");
|
||||
return provider.PostGraphItemAsync<GraphOnenoteSection>(url, content, ct);
|
||||
}
|
||||
|
||||
internal Task<GraphOnenoteSectionGroup> CreateSectionGroupAsync(string parentId, string displayName, bool isNotebookParent, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var parent = Uri.EscapeDataString(parentId);
|
||||
var url = isNotebookParent
|
||||
? $"{baseUrl}/v1.0/onenote/notebooks/{parent}/sectionGroups"
|
||||
: $"{baseUrl}/v1.0/onenote/sectionGroups/{parent}/sectionGroups";
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(new { displayName }), Encoding.UTF8, "application/json");
|
||||
return provider.PostGraphItemAsync<GraphOnenoteSectionGroup>(url, content, ct);
|
||||
}
|
||||
|
||||
internal Task<GraphOnenotePage> CreatePageAsync(string sectionId, Stream contentStream, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var section = Uri.EscapeDataString(sectionId);
|
||||
var url = $"{baseUrl}/v1.0/onenote/sections/{section}/pages";
|
||||
|
||||
var content = new StreamContent(contentStream);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("text/html");
|
||||
return provider.PostGraphItemAsync<GraphOnenotePage>(url, content, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreNotebooks(CancellationToken cancel)
|
||||
{
|
||||
if (RestoreTarget == null)
|
||||
throw new InvalidOperationException("Restore target entry is not set");
|
||||
|
||||
var notebooks = GetMetadataByType(SourceItemType.Notebook);
|
||||
if (notebooks.Count == 0)
|
||||
return;
|
||||
|
||||
string? targetUserId = null;
|
||||
if (RestoreTarget.Type == SourceItemType.User)
|
||||
{
|
||||
targetUserId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetUserId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreNotebooksMissingUser", null, "Could not determine target user for notebook restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var notebook in notebooks)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = notebook.Key;
|
||||
var metadata = notebook.Value;
|
||||
var displayName = metadata.GetValueOrDefault("o365:Name") ?? metadata.GetValueOrDefault("o365:DisplayName") ?? "Restored Notebook";
|
||||
|
||||
var newNotebook = await OnenoteApi.CreateNotebookAsync(targetUserId, displayName, cancel);
|
||||
_restoredNotebookMap[originalPath] = newNotebook.Id;
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreNotebooksFailed", ex, $"Failed to restore notebook {notebook.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreNotebookSectionGroups(CancellationToken cancel)
|
||||
{
|
||||
var sectionGroups = GetMetadataByType(SourceItemType.NotebookSectionGroup);
|
||||
if (sectionGroups.Count == 0)
|
||||
return;
|
||||
|
||||
// Sort by path length to ensure parents are created first
|
||||
var sortedGroups = sectionGroups.OrderBy(k => k.Key.Split(Path.DirectorySeparatorChar).Length).ToList();
|
||||
|
||||
foreach (var group in sortedGroups)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = group.Key;
|
||||
var metadata = group.Value;
|
||||
var displayName = metadata.GetValueOrDefault("o365:Name") ?? metadata.GetValueOrDefault("o365:DisplayName") ?? "Restored Section Group";
|
||||
|
||||
// Find parent
|
||||
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)));
|
||||
|
||||
string? parentId = null;
|
||||
bool isNotebookParent = false;
|
||||
|
||||
if (parentPath != null)
|
||||
{
|
||||
if (_restoredNotebookMap.TryGetValue(parentPath, out var notebookId))
|
||||
{
|
||||
parentId = notebookId;
|
||||
isNotebookParent = true;
|
||||
}
|
||||
else if (_restoredSectionGroupMap.TryGetValue(parentPath, out var groupId))
|
||||
{
|
||||
parentId = groupId;
|
||||
isNotebookParent = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentId == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreNotebookSectionGroupsMissingParent", null, $"Could not find parent for section group {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var newGroup = await OnenoteApi.CreateSectionGroupAsync(parentId, displayName, isNotebookParent, cancel);
|
||||
_restoredSectionGroupMap[originalPath] = newGroup.Id;
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreNotebookSectionGroupsFailed", ex, $"Failed to restore section group {group.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreNotebookSections(CancellationToken cancel)
|
||||
{
|
||||
var sections = GetMetadataByType(SourceItemType.NotebookSection);
|
||||
if (sections.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var section in sections)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = section.Key;
|
||||
var metadata = section.Value;
|
||||
var displayName = metadata.GetValueOrDefault("o365:Name") ?? metadata.GetValueOrDefault("o365:DisplayName") ?? "Restored Section";
|
||||
|
||||
// Find parent
|
||||
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)));
|
||||
|
||||
string? parentId = null;
|
||||
bool isNotebookParent = false;
|
||||
|
||||
if (parentPath != null)
|
||||
{
|
||||
if (_restoredNotebookMap.TryGetValue(parentPath, out var notebookId))
|
||||
{
|
||||
parentId = notebookId;
|
||||
isNotebookParent = true;
|
||||
}
|
||||
else if (_restoredSectionGroupMap.TryGetValue(parentPath, out var groupId))
|
||||
{
|
||||
parentId = groupId;
|
||||
isNotebookParent = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentId == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreNotebookSectionsMissingParent", null, $"Could not find parent for section {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var newSection = await OnenoteApi.CreateSectionAsync(parentId, displayName, isNotebookParent, cancel);
|
||||
_restoredSectionMap[originalPath] = newSection.Id;
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreNotebookSectionsFailed", ex, $"Failed to restore section {section.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreNotebookPages(CancellationToken cancel)
|
||||
{
|
||||
// Iterate over temporary files to find pages
|
||||
// Pages are .html files whose parent is a restored section
|
||||
|
||||
var files = _temporaryFiles.Keys.ToList();
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (!filePath.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(filePath.TrimEnd(Path.DirectorySeparatorChar)));
|
||||
if (parentPath == null)
|
||||
continue;
|
||||
|
||||
if (_restoredSectionMap.TryGetValue(parentPath, out var sectionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var contentEntry = _temporaryFiles[filePath];
|
||||
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
|
||||
{
|
||||
await OnenoteApi.CreatePageAsync(sectionId, contentStream, cancel);
|
||||
}
|
||||
|
||||
_temporaryFiles.TryRemove(filePath, out var file);
|
||||
file?.Dispose();
|
||||
_metadata.TryRemove(filePath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreNotebookPagesFailed", ex, $"Failed to restore page {filePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
partial class RestoreProvider
|
||||
{
|
||||
internal PlannerApiImpl PlannerApi => new PlannerApiImpl(_apiHelper);
|
||||
|
||||
internal class PlannerApiImpl(APIHelper provider)
|
||||
{
|
||||
public async Task<GraphPlannerBucket> CreatePlannerBucketAsync(
|
||||
string planId,
|
||||
string name,
|
||||
string? orderHint,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var url = $"{baseUrl}/v1.0/planner/buckets";
|
||||
|
||||
var body = new
|
||||
{
|
||||
name,
|
||||
planId,
|
||||
orderHint
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
await using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var created = await JsonSerializer.DeserializeAsync<GraphPlannerBucket>(respStream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
if (created is null || string.IsNullOrWhiteSpace(created.Id))
|
||||
throw new InvalidOperationException("Graph did not return the created bucket id.");
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<GraphPlannerTask> CreatePlannerTaskAsync(
|
||||
string planId,
|
||||
string? bucketId,
|
||||
string title,
|
||||
DateTimeOffset? startDateTime,
|
||||
DateTimeOffset? dueDateTime,
|
||||
int? percentComplete,
|
||||
int? priority,
|
||||
JsonElement? assignments,
|
||||
JsonElement? appliedCategories,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var url = $"{baseUrl}/v1.0/planner/tasks";
|
||||
|
||||
var body = new
|
||||
{
|
||||
planId,
|
||||
bucketId,
|
||||
title,
|
||||
startDateTime,
|
||||
dueDateTime,
|
||||
percentComplete,
|
||||
priority,
|
||||
assignments,
|
||||
appliedCategories
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(body, options: new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
await using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var created = await JsonSerializer.DeserializeAsync<GraphPlannerTask>(respStream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
if (created is null || string.IsNullOrWhiteSpace(created.Id))
|
||||
throw new InvalidOperationException("Graph did not return the created task id.");
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task UpdatePlannerTaskDetailsAsync(
|
||||
string taskId,
|
||||
Stream detailsStream,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var url = $"{baseUrl}/v1.0/planner/tasks/{taskId}/details";
|
||||
|
||||
// 1. Get current details to get ETag
|
||||
async Task<HttpRequestMessage> getRequestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
return req;
|
||||
}
|
||||
|
||||
string etag;
|
||||
using (var getResp = await provider.SendWithRetryShortAsync(getRequestFactory, ct).ConfigureAwait(false))
|
||||
{
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(getResp, ct).ConfigureAwait(false);
|
||||
etag = getResp.Headers.ETag?.Tag ?? throw new InvalidOperationException("ETag missing from planner task details response");
|
||||
}
|
||||
|
||||
// 2. Patch details
|
||||
// We need to deserialize the stream to get the properties to update
|
||||
// The stream contains the full details object from the backup
|
||||
if (detailsStream.CanSeek) detailsStream.Position = 0;
|
||||
var details = await JsonSerializer.DeserializeAsync<GraphPlannerTaskDetails>(detailsStream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
if (details == null) return;
|
||||
|
||||
var patchBody = new
|
||||
{
|
||||
description = details.Description,
|
||||
previewType = details.PreviewType,
|
||||
references = details.References,
|
||||
checklist = details.Checklist
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> patchRequestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Headers.IfMatch.Add(new EntityTagHeaderValue(etag));
|
||||
req.Content = JsonContent.Create(patchBody, options: new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); // Graph requires this
|
||||
return req;
|
||||
}
|
||||
|
||||
using var patchResp = await provider.SendWithRetryShortAsync(patchRequestFactory, ct).ConfigureAwait(false);
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(patchResp, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<List<GraphPlannerTask>> GetPlannerTasksAsync(
|
||||
string planId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var plan = Uri.EscapeDataString(planId);
|
||||
|
||||
var url = $"{baseUrl}/v1.0/planner/plans/{plan}/tasks";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var page = await JsonSerializer.DeserializeAsync<GraphPage<GraphPlannerTask>>(stream, cancellationToken: ct).ConfigureAwait(false);
|
||||
return page?.Value ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class GraphPlannerTaskDetails
|
||||
{
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("previewType")]
|
||||
public string? PreviewType { get; set; }
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public object? References { get; set; }
|
||||
|
||||
[JsonPropertyName("checklist")]
|
||||
public object? Checklist { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
public partial class RestoreProvider
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _restoredSharePointListMap = new();
|
||||
|
||||
private async Task RestoreSharePointLists(CancellationToken cancel)
|
||||
{
|
||||
if (RestoreTarget == null)
|
||||
throw new InvalidOperationException("Restore target entry is not set");
|
||||
|
||||
var lists = GetMetadataByType(SourceItemType.SharePointList);
|
||||
if (lists.Count == 0)
|
||||
return;
|
||||
|
||||
string? targetSiteId = null;
|
||||
if (RestoreTarget.Type == SourceItemType.Site)
|
||||
{
|
||||
targetSiteId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetSiteId))
|
||||
{
|
||||
// Try to infer from path if possible, or log error
|
||||
// For now, we assume we are restoring to a site
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var list in lists)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = list.Key;
|
||||
var metadata = list.Value;
|
||||
var displayName = metadata.GetValueOrDefault("o365:DisplayName") ?? "Restored List";
|
||||
var listInfoJson = metadata.GetValueOrDefault("o365:ListInfo");
|
||||
GraphListInfo? listInfo = null;
|
||||
if (!string.IsNullOrEmpty(listInfoJson))
|
||||
{
|
||||
listInfo = JsonSerializer.Deserialize<GraphListInfo>(listInfoJson);
|
||||
}
|
||||
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
|
||||
// Check if list exists
|
||||
var existingList = await SourceProvider.SharePointListApi.GetListAsync(targetSiteId, displayName, cancel);
|
||||
if (existingList != null)
|
||||
{
|
||||
_restoredSharePointListMap[originalPath] = existingList.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
var newList = await SourceProvider.SharePointListApi.CreateListAsync(targetSiteId, displayName, listInfo, cancel);
|
||||
_restoredSharePointListMap[originalPath] = newList.Id;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreSharePointListFailed", ex, $"Failed to restore list {list.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreSharePointListItems(CancellationToken cancel)
|
||||
{
|
||||
if (RestoreTarget == null)
|
||||
throw new InvalidOperationException("Restore target entry is not set");
|
||||
|
||||
var items = GetMetadataByType(SourceItemType.SharePointListItem);
|
||||
if (items.Count == 0)
|
||||
return;
|
||||
|
||||
string? targetSiteId = null;
|
||||
if (RestoreTarget.Type == SourceItemType.Site)
|
||||
{
|
||||
targetSiteId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetSiteId))
|
||||
return;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = item.Key;
|
||||
|
||||
// Determine List ID
|
||||
// Path: .../ListId/ItemId.json
|
||||
var parent = Path.GetDirectoryName(originalPath);
|
||||
if (parent == null) continue;
|
||||
|
||||
string? listId = null;
|
||||
if (_restoredSharePointListMap.TryGetValue(parent, out var mappedListId))
|
||||
{
|
||||
listId = mappedListId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Maybe we are restoring to the same list ID if we didn't restore the list itself (e.g. partial restore)
|
||||
// But we don't know the target list ID unless we mapped it.
|
||||
// If we didn't restore the list, we might assume the parent folder name is the list ID?
|
||||
// But the parent folder name is the source list ID.
|
||||
// If we are restoring to the same site, maybe the list exists with the same ID? Unlikely.
|
||||
// If we are restoring to a different site, we definitely need the map.
|
||||
// If the list was not in the backup (e.g. only items selected), we can't restore items easily without knowing where to put them.
|
||||
// We'll skip if we can't find the list.
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreSharePointListItemMissingList", null, $"Missing target list for item {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var contentPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
|
||||
var contentEntry = _temporaryFiles.GetValueOrDefault(contentPath);
|
||||
if (contentEntry == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreSharePointListItemMissingContent", null, $"Missing content for item {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
GraphListItem? itemData = null;
|
||||
using (var stream = SystemIO.IO_OS.FileOpenRead(contentEntry))
|
||||
{
|
||||
itemData = await JsonSerializer.DeserializeAsync<GraphListItem>(stream, cancellationToken: cancel);
|
||||
}
|
||||
|
||||
if (itemData?.Fields != null)
|
||||
{
|
||||
await SourceProvider.SharePointListApi.CreateListItemAsync(targetSiteId, listId, itemData.Fields.Value, cancel);
|
||||
}
|
||||
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
_temporaryFiles.TryRemove(originalPath, out var contentFile);
|
||||
contentFile?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreSharePointListItemFailed", ex, $"Failed to restore list item {item.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
partial class RestoreProvider
|
||||
{
|
||||
internal SiteApiImpl SiteApi => new SiteApiImpl(_apiHelper);
|
||||
|
||||
internal class SiteApiImpl(APIHelper provider)
|
||||
{
|
||||
internal IAsyncEnumerable<GraphDrive> ListSiteDrivesAsync(string siteId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphDrive>();
|
||||
var site = Uri.EscapeDataString(siteId);
|
||||
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/sites/{site}/drives" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$top={OptionsHelper.GENERAL_PAGE_SIZE}";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphDrive>(url, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreSiteDrives(CancellationToken cancel)
|
||||
{
|
||||
if (RestoreTarget == null)
|
||||
throw new InvalidOperationException("Restore target entry is not set");
|
||||
|
||||
// Only proceed if we are restoring to a Site
|
||||
if (RestoreTarget.Type != SourceItemType.Site)
|
||||
return;
|
||||
|
||||
var driveSources = GetMetadataByType(SourceItemType.Drive);
|
||||
if (driveSources.Count == 0)
|
||||
return;
|
||||
|
||||
var targetSiteId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
if (string.IsNullOrWhiteSpace(targetSiteId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreSiteDrivesMissingId", null, "Target site ID is missing, cannot restore site drives.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// List existing drives on the target site
|
||||
var targetDrives = new List<GraphDrive>();
|
||||
await foreach (var drive in SiteApi.ListSiteDrivesAsync(targetSiteId, cancel))
|
||||
{
|
||||
targetDrives.Add(drive);
|
||||
}
|
||||
|
||||
foreach (var driveSource in driveSources)
|
||||
{
|
||||
var metadata = driveSource.Value;
|
||||
var sourceName = metadata.GetValueOrDefault("o365:Name");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceName))
|
||||
{
|
||||
// Fallback to using the last part of the path if name is missing
|
||||
sourceName = Path.GetFileName(driveSource.Key.TrimEnd(Path.DirectorySeparatorChar));
|
||||
}
|
||||
|
||||
// Try to match by name
|
||||
var match = targetDrives.FirstOrDefault(d =>
|
||||
string.Equals(d.Name, sourceName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Also try to match by "Documents" if source is "Documents" but target might be named differently in URL but same display name?
|
||||
// Or if source is "Shared Documents" and target is "Documents" (common in SharePoint).
|
||||
// But let's stick to exact name match for now.
|
||||
|
||||
if (match != null)
|
||||
{
|
||||
_restoredDriveMap[driveSource.Key] = match.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreSiteDrivesNoMatch", null, $"Could not find matching drive for '{sourceName}' in target site. Creating new document libraries is not yet supported.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreSiteDrivesFailed", ex, $"Failed to restore site drives: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
public partial class RestoreProvider
|
||||
{
|
||||
internal TodoApiImpl TodoApi => new TodoApiImpl(_apiHelper);
|
||||
|
||||
internal class TodoApiImpl(APIHelper provider)
|
||||
{
|
||||
internal Task<GraphTodoTaskList> CreateTaskListAsync(string userIdOrUpn, string displayName, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/todo/lists";
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(new { displayName }), Encoding.UTF8, "application/json");
|
||||
return provider.PostGraphItemAsync<GraphTodoTaskList>(url, content, ct);
|
||||
}
|
||||
|
||||
internal Task<GraphTodoTask> CreateTaskAsync(string userIdOrUpn, string taskListId, GraphTodoTask task, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var list = Uri.EscapeDataString(taskListId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks";
|
||||
|
||||
// Ensure ID is null to avoid errors
|
||||
task.Id = "";
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(task), Encoding.UTF8, "application/json");
|
||||
return provider.PostGraphItemAsync<GraphTodoTask>(url, content, ct);
|
||||
}
|
||||
|
||||
internal Task<GraphTodoChecklistItem> CreateChecklistItemAsync(string userIdOrUpn, string taskListId, string taskId, string displayName, bool isChecked, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var list = Uri.EscapeDataString(taskListId);
|
||||
var task = Uri.EscapeDataString(taskId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}/checklistItems";
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(new { displayName, isChecked }), Encoding.UTF8, "application/json");
|
||||
return provider.PostGraphItemAsync<GraphTodoChecklistItem>(url, content, ct);
|
||||
}
|
||||
|
||||
internal Task<GraphTodoLinkedResource> CreateLinkedResourceAsync(string userIdOrUpn, string taskListId, string taskId, GraphTodoLinkedResource resource, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var list = Uri.EscapeDataString(taskListId);
|
||||
var task = Uri.EscapeDataString(taskId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}/linkedResources";
|
||||
|
||||
// Ensure ID is null
|
||||
resource.Id = "";
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(resource), Encoding.UTF8, "application/json");
|
||||
return provider.PostGraphItemAsync<GraphTodoLinkedResource>(url, content, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreTaskLists(CancellationToken cancel)
|
||||
{
|
||||
if (RestoreTarget == null)
|
||||
throw new InvalidOperationException("Restore target entry is not set");
|
||||
|
||||
var taskLists = GetMetadataByType(SourceItemType.TaskList);
|
||||
if (taskLists.Count == 0)
|
||||
return;
|
||||
|
||||
string? targetUserId = null;
|
||||
if (RestoreTarget.Type == SourceItemType.User)
|
||||
{
|
||||
targetUserId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetUserId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskListsMissingUser", null, "Could not determine target user for task list restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var taskList in taskLists)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = taskList.Key;
|
||||
var metadata = taskList.Value;
|
||||
var displayName = metadata.GetValueOrDefault("o365:Name") ?? metadata.GetValueOrDefault("o365:DisplayName") ?? "Restored Task List";
|
||||
|
||||
var newList = await TodoApi.CreateTaskListAsync(targetUserId, displayName, cancel);
|
||||
_restoredTaskListMap[originalPath] = newList.Id;
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreTaskListsFailed", ex, $"Failed to restore task list {taskList.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreTaskListTasks(CancellationToken cancel)
|
||||
{
|
||||
var tasks = GetMetadataByType(SourceItemType.TaskListTask);
|
||||
if (tasks.Count == 0)
|
||||
return;
|
||||
|
||||
string? targetUserId = null;
|
||||
if (RestoreTarget?.Type == SourceItemType.User)
|
||||
{
|
||||
targetUserId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetUserId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskListTasksMissingUser", null, "Could not determine target user for task restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = task.Key;
|
||||
|
||||
// Find parent list
|
||||
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)));
|
||||
if (parentPath == null || !_restoredTaskListMap.TryGetValue(parentPath, out var listId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskListTasksMissingParent", null, $"Could not find parent list for task {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
|
||||
if (contentEntry == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskListTasksMissingContent", null, $"Missing content for task {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
GraphTodoTask? taskObj;
|
||||
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
|
||||
{
|
||||
taskObj = await JsonSerializer.DeserializeAsync<GraphTodoTask>(contentStream, cancellationToken: cancel);
|
||||
}
|
||||
|
||||
if (taskObj == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskListTasksInvalidContent", null, $"Invalid content for task {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var newTask = await TodoApi.CreateTaskAsync(targetUserId, listId, taskObj, cancel);
|
||||
_restoredTaskMap[originalPath] = newTask.Id;
|
||||
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
_temporaryFiles.TryRemove(originalPath, out var file);
|
||||
file?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreTaskListTasksFailed", ex, $"Failed to restore task {task.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreTaskChecklistItems(CancellationToken cancel)
|
||||
{
|
||||
var items = GetMetadataByType(SourceItemType.TaskListChecklistItem);
|
||||
if (items.Count == 0)
|
||||
return;
|
||||
|
||||
string? targetUserId = null;
|
||||
if (RestoreTarget?.Type == SourceItemType.User)
|
||||
{
|
||||
targetUserId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetUserId))
|
||||
return;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = item.Key;
|
||||
var metadata = item.Value;
|
||||
var displayName = metadata.GetValueOrDefault("o365:Name") ?? "Checklist Item";
|
||||
var isChecked = metadata.GetValueOrDefault("o365:IsChecked") == "True";
|
||||
|
||||
// Find parent task
|
||||
// Path: .../Lists/ListId/Tasks/TaskId/ChecklistItems/ItemId
|
||||
// Parent: .../Lists/ListId/Tasks/TaskId/ChecklistItems
|
||||
// GrandParent: .../Lists/ListId/Tasks/TaskId
|
||||
|
||||
var parentDir = Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)); // ChecklistItems
|
||||
if (parentDir == null) continue;
|
||||
|
||||
var taskPath = Util.AppendDirSeparator(Path.GetDirectoryName(parentDir)); // TaskId
|
||||
|
||||
if (taskPath == null || !_restoredTaskMap.TryGetValue(taskPath, out var taskId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskChecklistItemsMissingParent", null, $"Could not find parent task for checklist item {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
// We also need listId.
|
||||
// Task path: .../Lists/ListId/Tasks/TaskId
|
||||
// List path: .../Lists/ListId
|
||||
var tasksDir = Path.GetDirectoryName(taskPath.TrimEnd(Path.DirectorySeparatorChar)); // Tasks
|
||||
if (tasksDir == null) continue;
|
||||
|
||||
var listPath = Util.AppendDirSeparator(Path.GetDirectoryName(tasksDir)); // ListId
|
||||
|
||||
if (listPath == null || !_restoredTaskListMap.TryGetValue(listPath, out var listId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskChecklistItemsMissingList", null, $"Could not find parent list for checklist item {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await TodoApi.CreateChecklistItemAsync(targetUserId, listId, taskId, displayName, isChecked, cancel);
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreTaskChecklistItemsFailed", ex, $"Failed to restore checklist item {item.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreTaskLinkedResources(CancellationToken cancel)
|
||||
{
|
||||
var resources = GetMetadataByType(SourceItemType.TaskListLinkedResource);
|
||||
if (resources.Count == 0)
|
||||
return;
|
||||
|
||||
string? targetUserId = null;
|
||||
if (RestoreTarget?.Type == SourceItemType.User)
|
||||
{
|
||||
targetUserId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetUserId))
|
||||
return;
|
||||
|
||||
foreach (var resource in resources)
|
||||
{
|
||||
if (cancel.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = resource.Key;
|
||||
|
||||
// Find parent task
|
||||
var parentDir = Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)); // LinkedResources
|
||||
if (parentDir == null) continue;
|
||||
|
||||
var taskPath = Util.AppendDirSeparator(Path.GetDirectoryName(parentDir)); // TaskId
|
||||
|
||||
if (taskPath == null || !_restoredTaskMap.TryGetValue(taskPath, out var taskId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskLinkedResourcesMissingParent", null, $"Could not find parent task for linked resource {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
// We also need listId.
|
||||
var tasksDir = Path.GetDirectoryName(taskPath.TrimEnd(Path.DirectorySeparatorChar)); // Tasks
|
||||
if (tasksDir == null) continue;
|
||||
|
||||
var listPath = Util.AppendDirSeparator(Path.GetDirectoryName(tasksDir)); // ListId
|
||||
|
||||
if (listPath == null || !_restoredTaskListMap.TryGetValue(listPath, out var listId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskLinkedResourcesMissingList", null, $"Could not find parent list for linked resource {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
|
||||
if (contentEntry == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskLinkedResourcesMissingContent", null, $"Missing content for linked resource {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
GraphTodoLinkedResource? resourceObj;
|
||||
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
|
||||
{
|
||||
resourceObj = await JsonSerializer.DeserializeAsync<GraphTodoLinkedResource>(contentStream, cancellationToken: cancel);
|
||||
}
|
||||
|
||||
if (resourceObj == null)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreTaskLinkedResourcesInvalidContent", null, $"Invalid content for linked resource {originalPath}, skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await TodoApi.CreateLinkedResourceAsync(targetUserId, listId, taskId, resourceObj, cancel);
|
||||
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
_temporaryFiles.TryRemove(originalPath, out var file);
|
||||
file?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreTaskLinkedResourcesFailed", ex, $"Failed to restore linked resource {resource.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MimeKit;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
@@ -13,6 +14,10 @@ partial class RestoreProvider
|
||||
|
||||
internal class EmailApiImpl(APIHelper provider)
|
||||
{
|
||||
/// <summary>
|
||||
/// Limit for ensuring we stay under the 4MB limit for simple email restore.
|
||||
/// </summary>
|
||||
private const long MAX_SIZE_FOR_SIMPLE_EMAIL_RESTORE = (long)((4 * 1024 * 1024) * (1 - 0.33)) - 1024; // 4MB - 33% base64 overhead - 1KB margin
|
||||
public async Task<string> RestoreEmailToFolderAsync(
|
||||
string userId,
|
||||
string targetFolderId,
|
||||
@@ -23,6 +28,12 @@ partial class RestoreProvider
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = System.Uri.EscapeDataString(userId);
|
||||
|
||||
long length = 0;
|
||||
try { length = contentStream.Length; } catch { }
|
||||
|
||||
if (length > MAX_SIZE_FOR_SIMPLE_EMAIL_RESTORE)
|
||||
return await RestoreLargeEmailAsync(userId, targetFolderId, contentStream, metadataStream, ct);
|
||||
|
||||
// 1) Create draft message via MIME (always goes to Drafts by default)
|
||||
// POST /users/{id}/messages (MIME = base64 in text/plain body)
|
||||
var createUrl = $"{baseUrl}/v1.0/users/{user}/messages";
|
||||
@@ -54,10 +65,8 @@ partial class RestoreProvider
|
||||
return req;
|
||||
}
|
||||
|
||||
using var createResp = await provider.SendWithRetryAsync(
|
||||
using var createResp = await provider.SendWithRetryShortAsync(
|
||||
createRequestFactory,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
null,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(createResp, ct).ConfigureAwait(false);
|
||||
@@ -79,10 +88,8 @@ partial class RestoreProvider
|
||||
return req;
|
||||
}
|
||||
|
||||
using var moveResp = await provider.SendWithRetryAsync(
|
||||
using var moveResp = await provider.SendWithRetryShortAsync(
|
||||
moveRequestFactory,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
null,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(moveResp, ct).ConfigureAwait(false);
|
||||
@@ -102,6 +109,248 @@ partial class RestoreProvider
|
||||
return movedId;
|
||||
}
|
||||
|
||||
private async Task<string> RestoreLargeEmailAsync(
|
||||
string userId,
|
||||
string targetFolderId,
|
||||
Stream contentStream,
|
||||
Stream metadataStream,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Parse MIME
|
||||
if (contentStream.CanSeek) contentStream.Position = 0;
|
||||
var message = await MimeMessage.LoadAsync(contentStream, ct);
|
||||
|
||||
// 2. Create Draft Message (without attachments)
|
||||
var draft = new GraphMessage
|
||||
{
|
||||
Subject = message.Subject,
|
||||
Body = new GraphBody
|
||||
{
|
||||
ContentType = message.HtmlBody != null ? "html" : "text",
|
||||
Content = message.HtmlBody ?? message.TextBody ?? ""
|
||||
},
|
||||
From = ConvertToGraphRecipient(message.From),
|
||||
Sender = ConvertToGraphRecipient(message.Sender),
|
||||
ToRecipients = ConvertToGraphRecipients(message.To),
|
||||
CcRecipients = ConvertToGraphRecipients(message.Cc),
|
||||
BccRecipients = ConvertToGraphRecipients(message.Bcc),
|
||||
HasAttachments = message.Attachments.Any()
|
||||
};
|
||||
|
||||
var createdDraft = await CreateMessageAsync(userId, draft, ct);
|
||||
|
||||
// 3. Upload Attachments
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
await UploadAttachmentAsync(userId, createdDraft.Id, attachment, ct);
|
||||
}
|
||||
|
||||
// 4. Move to target folder
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var userEncoded = Uri.EscapeDataString(userId);
|
||||
var moveUrl = $"{baseUrl}/v1.0/users/{userEncoded}/messages/{Uri.EscapeDataString(createdDraft.Id)}/move";
|
||||
|
||||
async Task<HttpRequestMessage> moveRequestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, moveUrl);
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
|
||||
var moveBody = JsonSerializer.SerializeToUtf8Bytes(new GraphMoveRequest { DestinationId = targetFolderId });
|
||||
req.Content = new ByteArrayContent(moveBody);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
return req;
|
||||
}
|
||||
|
||||
using var moveResp = await provider.SendWithRetryShortAsync(
|
||||
moveRequestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(moveResp, ct).ConfigureAwait(false);
|
||||
|
||||
var movedId = await ReadIdAsync(moveResp, ct).ConfigureAwait(false);
|
||||
|
||||
// 5. Patch metadata
|
||||
if (metadataStream != null)
|
||||
{
|
||||
await PatchRestoredEmailMetadataAsync(
|
||||
userId,
|
||||
movedId,
|
||||
metadataStream,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return movedId;
|
||||
}
|
||||
|
||||
private async Task<GraphCreatedMessage> CreateMessageAsync(string userId, GraphMessage message, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/messages";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(message);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
using var s = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<GraphCreatedMessage>(s, cancellationToken: ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Failed to create draft message");
|
||||
}
|
||||
|
||||
private async Task UploadAttachmentAsync(string userId, string messageId, MimeEntity attachment, CancellationToken ct)
|
||||
{
|
||||
if (attachment is MessagePart msgPart)
|
||||
{
|
||||
// Handle attached message as .eml file
|
||||
using var ms = new MemoryStream();
|
||||
await msgPart.Message.WriteToAsync(ms, ct);
|
||||
ms.Position = 0;
|
||||
|
||||
// Create a fake MimePart for upload logic
|
||||
var fakePart = new MimePart("message", "rfc822")
|
||||
{
|
||||
Content = new MimeContent(ms),
|
||||
FileName = (msgPart.Message.Subject ?? "attached") + ".eml"
|
||||
};
|
||||
|
||||
await UploadAttachmentAsync(userId, messageId, fakePart, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment is not MimePart part) return;
|
||||
|
||||
// Check size
|
||||
long size = 0;
|
||||
using (var measure = new MemoryStream())
|
||||
{
|
||||
await part.Content.DecodeToAsync(measure, ct);
|
||||
size = measure.Length;
|
||||
}
|
||||
|
||||
if (size < 3 * 1024 * 1024)
|
||||
{
|
||||
// Small attachment: POST /users/{id}/messages/{id}/attachments
|
||||
await UploadSmallAttachmentAsync(userId, messageId, part, size, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Large attachment: createUploadSession
|
||||
await UploadLargeAttachmentAsync(userId, messageId, part, size, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadSmallAttachmentAsync(string userId, string messageId, MimePart part, long size, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var msg = Uri.EscapeDataString(messageId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/messages/{msg}/attachments";
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await part.Content.DecodeToAsync(ms, ct);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
var attach = new GraphAttachment
|
||||
{
|
||||
ODataType = "#microsoft.graph.fileAttachment",
|
||||
Name = part.FileName ?? "attachment",
|
||||
ContentType = part.ContentType.MimeType,
|
||||
Size = (int)size,
|
||||
IsInline = !string.IsNullOrEmpty(part.ContentId),
|
||||
ContentId = part.ContentId,
|
||||
ContentBytes = Convert.ToBase64String(bytes)
|
||||
};
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(attach);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UploadLargeAttachmentAsync(string userId, string messageId, MimePart part, long size, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var msg = Uri.EscapeDataString(messageId);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/messages/{msg}/attachments/createUploadSession";
|
||||
|
||||
var attachmentItem = new GraphAttachmentItem
|
||||
{
|
||||
AttachmentType = "file",
|
||||
Name = part.FileName ?? "attachment",
|
||||
Size = size
|
||||
};
|
||||
|
||||
var body = new { AttachmentItem = attachmentItem };
|
||||
|
||||
async Task<HttpRequestMessage> sessionRequestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var sessionResp = await provider.SendWithRetryShortAsync(sessionRequestFactory, ct).ConfigureAwait(false);
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(sessionResp, ct).ConfigureAwait(false);
|
||||
|
||||
using var sessionStream = await sessionResp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var session = await JsonSerializer.DeserializeAsync<GraphUploadSession>(sessionStream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
if (session?.UploadUrl == null) throw new InvalidOperationException("Failed to create upload session");
|
||||
|
||||
// Upload chunks
|
||||
using var contentStream = new MemoryStream();
|
||||
await part.Content.DecodeToAsync(contentStream, ct);
|
||||
contentStream.Position = 0;
|
||||
|
||||
await provider.UploadFileToSessionAsync(session.UploadUrl, contentStream, ct);
|
||||
}
|
||||
|
||||
private GraphRecipient? ConvertToGraphRecipient(InternetAddressList? addresses)
|
||||
{
|
||||
var addr = addresses?.Mailboxes.FirstOrDefault();
|
||||
if (addr == null) return null;
|
||||
return new GraphRecipient
|
||||
{
|
||||
EmailAddress = new GraphEmailAddress { Name = addr.Name, Address = addr.Address }
|
||||
};
|
||||
}
|
||||
|
||||
private GraphRecipient? ConvertToGraphRecipient(InternetAddress? address)
|
||||
{
|
||||
if (address is MailboxAddress mailbox)
|
||||
{
|
||||
return new GraphRecipient
|
||||
{
|
||||
EmailAddress = new GraphEmailAddress { Name = mailbox.Name, Address = mailbox.Address }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<GraphRecipient>? ConvertToGraphRecipients(InternetAddressList? addresses)
|
||||
{
|
||||
if (addresses == null || addresses.Count == 0) return null;
|
||||
return addresses.Mailboxes.Select(addr => new GraphRecipient
|
||||
{
|
||||
EmailAddress = new GraphEmailAddress { Name = addr.Name, Address = addr.Address }
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadIdAsync(HttpResponseMessage resp, CancellationToken ct)
|
||||
{
|
||||
await using var s = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
@@ -168,10 +417,8 @@ partial class RestoreProvider
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryAsync(
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
@@ -202,10 +449,8 @@ partial class RestoreProvider
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryAsync(
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
null,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
@@ -244,10 +489,8 @@ partial class RestoreProvider
|
||||
Content = JsonContent.Create(body)
|
||||
};
|
||||
|
||||
using var resp = await provider.SendWithRetryAsync(
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
null,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
@@ -285,7 +528,7 @@ partial class RestoreProvider
|
||||
$"&$select=id" +
|
||||
$"&$top=1";
|
||||
|
||||
using var stream = await provider.GetGraphAsStreamAsync(
|
||||
using var stream = await provider.GetGraphItemAsStreamAsync(
|
||||
url, "application/json", ct).ConfigureAwait(false);
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct)
|
||||
@@ -296,114 +539,33 @@ partial class RestoreProvider
|
||||
value.GetArrayLength() > 0;
|
||||
}
|
||||
|
||||
public async Task RestoreCalendarEventToCalendarAsync(
|
||||
string userId,
|
||||
string targetCalendarId,
|
||||
Stream eventJsonStream,
|
||||
CancellationToken cancellationToken)
|
||||
public async Task UpdateMailboxSettingsAsync(string userIdOrUpn, GraphMailboxSettings settings, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userId);
|
||||
var calendar = Uri.EscapeDataString(targetCalendarId);
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/mailboxSettings";
|
||||
|
||||
// Create event in a specific calendar:
|
||||
// POST /users/{id}/calendars/{id}/events (application/json event body)
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{calendar}/events";
|
||||
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
// Buffer the body so retries can resend it
|
||||
byte[] payload;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
await eventJsonStream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
|
||||
payload = ms.ToArray();
|
||||
}
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
|
||||
// Important: create new content per attempt
|
||||
req.Content = new ByteArrayContent(payload);
|
||||
req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryAsync(
|
||||
requestFactory,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
await provider.PatchGraphItemAsync(url, content, ct);
|
||||
}
|
||||
|
||||
public async Task RestoreDriveItemToFolderAsync(
|
||||
string driveId,
|
||||
string targetFolderItemId,
|
||||
string fileName,
|
||||
Stream contentStream,
|
||||
string? contentType,
|
||||
CancellationToken cancellationToken)
|
||||
public async Task CreateMessageRuleAsync(string userIdOrUpn, GraphMessageRule rule, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var folder = Uri.EscapeDataString(targetFolderItemId);
|
||||
var name = Uri.EscapeDataString(fileName);
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/mailFolders/inbox/messageRules";
|
||||
|
||||
// PUT /drives/{drive-id}/items/{parent-id}:/{filename}:/content
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/drives/{drive}/items/{folder}:/{name}:/content";
|
||||
// Remove ID and read-only properties before creating
|
||||
rule.Id = "";
|
||||
rule.IsReadOnly = null;
|
||||
rule.HasError = null;
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
var json = JsonSerializer.Serialize(rule, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
contentStream.Position = 0;
|
||||
req.Content = new StreamContent(contentStream);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType ?? "application/octet-stream");
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryAsync(
|
||||
requestFactory,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RestoreDriveItemMetadataAsync(
|
||||
string driveId,
|
||||
string itemId,
|
||||
Stream metadataJsonStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var item = Uri.EscapeDataString(itemId);
|
||||
|
||||
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{item}";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
||||
|
||||
metadataJsonStream.Position = 0;
|
||||
req.Content = new StreamContent(metadataJsonStream);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryAsync(
|
||||
requestFactory, HttpCompletionOption.ResponseHeadersRead, null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
||||
await provider.PostGraphItemAsync<GraphMessageRule>(url, content, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Logging;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
using NetUri = System.Uri;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
partial class RestoreProvider
|
||||
{
|
||||
internal UserProfileApiImpl UserProfileApi => new UserProfileApiImpl(_apiHelper);
|
||||
|
||||
internal class UserProfileApiImpl(APIHelper provider)
|
||||
{
|
||||
public async Task UpdateUserPhotoAsync(string userIdOrUpn, Stream photoStream, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = NetUri.EscapeDataString(userIdOrUpn);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/photo/$value";
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
|
||||
if (photoStream.CanSeek) photoStream.Position = 0;
|
||||
|
||||
req.Content = new StreamContent(photoStream);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpdateUserPropertiesAsync(string userIdOrUpn, Stream propertiesStream, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = NetUri.EscapeDataString(userIdOrUpn);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}";
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(propertiesStream, cancellationToken: ct);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var updateDict = new Dictionary<string, object?>();
|
||||
|
||||
if (root.TryGetProperty("displayName", out var displayName)) updateDict["displayName"] = displayName.GetString();
|
||||
if (root.TryGetProperty("jobTitle", out var jobTitle)) updateDict["jobTitle"] = jobTitle.GetString();
|
||||
if (root.TryGetProperty("department", out var department)) updateDict["department"] = department.GetString();
|
||||
if (root.TryGetProperty("officeLocation", out var officeLocation)) updateDict["officeLocation"] = officeLocation.GetString();
|
||||
if (root.TryGetProperty("mobilePhone", out var mobilePhone)) updateDict["mobilePhone"] = mobilePhone.GetString();
|
||||
if (root.TryGetProperty("usageLocation", out var usageLocation)) updateDict["usageLocation"] = usageLocation.GetString();
|
||||
if (root.TryGetProperty("preferredLanguage", out var preferredLanguage)) updateDict["preferredLanguage"] = preferredLanguage.GetString();
|
||||
|
||||
if (root.TryGetProperty("businessPhones", out var businessPhones) && businessPhones.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var phones = new List<string?>();
|
||||
foreach (var phone in businessPhones.EnumerateArray())
|
||||
phones.Add(phone.GetString());
|
||||
updateDict["businessPhones"] = phones;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("accountEnabled", out var accountEnabled)) updateDict["accountEnabled"] = accountEnabled.GetBoolean();
|
||||
|
||||
if (updateDict.Count == 0) return;
|
||||
|
||||
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
|
||||
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
||||
req.Content = JsonContent.Create(updateDict);
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryShortAsync(
|
||||
requestFactory,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreUserProfile(CancellationToken cancel)
|
||||
{
|
||||
if (RestoreTarget == null)
|
||||
throw new InvalidOperationException("Restore target entry is not set");
|
||||
|
||||
var userProfiles = GetMetadataByType(SourceItemType.UserProfile);
|
||||
if (userProfiles.Count == 0)
|
||||
return;
|
||||
|
||||
string? targetUserId = null;
|
||||
if (RestoreTarget.Type == SourceItemType.User)
|
||||
{
|
||||
targetUserId = RestoreTarget.Metadata.GetValueOrDefault("o365:Id");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetUserId))
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "RestoreUserProfileMissingTargetUser", null, "Cannot restore user profile without a target user.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var profile in userProfiles)
|
||||
{
|
||||
if (cancel.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var originalPath = profile.Key;
|
||||
|
||||
// Restore Properties
|
||||
var contentPath = SystemIO.IO_OS.PathCombine(originalPath, "user.json");
|
||||
var contentEntry = _temporaryFiles.GetValueOrDefault(contentPath);
|
||||
|
||||
if (contentEntry != null)
|
||||
{
|
||||
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
|
||||
{
|
||||
await UserProfileApi.UpdateUserPropertiesAsync(targetUserId, contentStream, cancel);
|
||||
}
|
||||
_temporaryFiles.TryRemove(contentPath, out var f);
|
||||
f?.Dispose();
|
||||
}
|
||||
|
||||
// Restore Photo
|
||||
var photoContentPath = SystemIO.IO_OS.PathCombine(originalPath, "photo.jpg");
|
||||
var photoEntry = _temporaryFiles.GetValueOrDefault(photoContentPath);
|
||||
|
||||
if (photoEntry != null)
|
||||
{
|
||||
using (var photoStream = SystemIO.IO_OS.FileOpenRead(photoEntry))
|
||||
{
|
||||
await UserProfileApi.UpdateUserPhotoAsync(targetUserId, photoStream, cancel);
|
||||
}
|
||||
_temporaryFiles.TryRemove(photoContentPath, out var f);
|
||||
f?.Dispose();
|
||||
}
|
||||
|
||||
_metadata.TryRemove(originalPath, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteErrorMessage(LOGTAG, "RestoreUserProfileFailed", ex, $"Failed to restore user profile {profile.Key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using Duplicati.Library.Common.IO;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class CalendarEventAttachmentSourceEntry(SourceProvider provider, string path, GraphUser user, GraphCalendar calendar, GraphEvent eventItem, GraphAttachment attachment)
|
||||
: StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(path, attachment.Id),
|
||||
DateTime.UnixEpoch,
|
||||
DateTime.UnixEpoch,
|
||||
attachment.Size ?? -1,
|
||||
ct => provider.CalendarApi.GetCalendarEventAttachmentStreamAsync(user.Id, calendar.Id, eventItem.Id, attachment.Id, ct),
|
||||
minorMetadataFactory: ct => Task.FromResult(new Dictionary<string, string?>
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", attachment.Id },
|
||||
{ "o365:Name", attachment.Name ?? "" },
|
||||
{ "o365:Type", SourceItemType.CalendarEventAttachment.ToString() },
|
||||
{ "o365:ContentType", attachment.ContentType ?? "" },
|
||||
{ "o365:Size", attachment.Size?.ToString() ?? "" },
|
||||
{ "o365:IsInline", attachment.IsInline?.ToString() ?? "" },
|
||||
}.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value)))
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using Duplicati.Library.Common.IO;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class CalendarEventContentSourceEntry(SourceProvider provider, string path, GraphUser user, GraphCalendar calendar, GraphEvent eventItem)
|
||||
: StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(path, "content.json"),
|
||||
eventItem.CreatedDateTime.FromGraphDateTime(),
|
||||
eventItem.LastModifiedDateTime.FromGraphDateTime(),
|
||||
-1,
|
||||
ct => provider.CalendarApi.GetCalendarEventStreamAsync(user.Id, calendar.Id, eventItem.Id, ct),
|
||||
minorMetadataFactory: ct => Task.FromResult(new Dictionary<string, string?>
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", eventItem.Id },
|
||||
{ "o365:Name", "content.json" },
|
||||
{ "o365:Type", SourceItemType.CalendarEventContent.ToString() },
|
||||
}.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value)))
|
||||
{
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class CalendarEventSourceEntry(SourceProvider provider, string path, GraphCalendarGroup calendarGroup, GraphCalendar calendar, GraphEvent eventItem)
|
||||
: StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(path, eventItem.Id),
|
||||
eventItem.CreatedDateTime.FromGraphDateTime(),
|
||||
eventItem.LastModifiedDateTime.FromGraphDateTime(),
|
||||
-1,
|
||||
ct => provider.CalendarApi.GetCalendarEventStreamAsync(calendarGroup.Id, calendar.Id, eventItem.Id, ct),
|
||||
minorMetadataFactory: ct => Task.FromResult(new Dictionary<string, string?>
|
||||
internal class CalendarEventSourceEntry(SourceProvider provider, string path, GraphUser user, GraphCalendar calendar, GraphEvent eventItem)
|
||||
: MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, eventItem.Id)), null, null)
|
||||
{
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", eventItem.Id },
|
||||
@@ -21,6 +20,20 @@ internal class CalendarEventSourceEntry(SourceProvider provider, string path, Gr
|
||||
{ "o365:Start", eventItem.Start?.ToString() ??"" },
|
||||
{ "o365:End", eventItem.End?.ToString() ?? "" },
|
||||
}.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value)))
|
||||
{
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// Yield content file
|
||||
yield return new CalendarEventContentSourceEntry(provider, this.Path, user, calendar, eventItem);
|
||||
|
||||
// Yield attachments
|
||||
await foreach (var attachment in provider.CalendarApi.ListCalendarEventAttachmentsAsync(user.Id, calendar.Id, eventItem.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
yield return new CalendarEventAttachmentSourceEntry(provider, this.Path, user, calendar, eventItem, attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ internal class CalendarSourceEntry(SourceProvider provider, string path, GraphUs
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
yield return new CalendarEventSourceEntry(provider, this.Path, calendarGroup, calendar, eventItem);
|
||||
yield return new CalendarEventSourceEntry(provider, this.Path, user, calendar, eventItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Logging;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
@@ -17,8 +18,9 @@ internal class DriveFileSourceEntry(SourceProvider provider, string path, GraphD
|
||||
public override Task<Stream> OpenRead(CancellationToken cancellationToken)
|
||||
=> provider.OneDriveApi.GetDriveItemContentStreamAsync(drive.Id, item.Id, cancellationToken);
|
||||
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>()
|
||||
public override async Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", item.Id },
|
||||
@@ -31,7 +33,29 @@ internal class DriveFileSourceEntry(SourceProvider provider, string path, GraphD
|
||||
{ "o365:FileSystemInfo", JsonSerializer.Serialize(item.FileSystemInfo) ?? "" },
|
||||
{ "o365:DownloadUrl", item.DownloadUrl ?? "" },
|
||||
{ "o365:Hashes", JsonSerializer.Serialize(item.File?.Hashes) ?? "" }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var permissions = new List<GraphPermission>();
|
||||
await foreach (var perm in provider.OneDriveApi.GetDriveItemPermissionsAsync(drive.Id, item.Id, cancellationToken))
|
||||
{
|
||||
permissions.Add(perm);
|
||||
}
|
||||
|
||||
if (permissions.Count > 0)
|
||||
{
|
||||
metadata["o365:Permissions"] = JsonSerializer.Serialize(permissions);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log warning but don't fail the backup if permissions cannot be read
|
||||
Duplicati.Library.Logging.Log.WriteWarningMessage(Log.LogTagFromType<DriveFileSourceEntry>(), "PermissionReadError", ex, $"Failed to read permissions for file {item.Id}");
|
||||
}
|
||||
|
||||
return metadata
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ internal class DriveFolderSourceEntry(SourceProvider provider, string path, Grap
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>()
|
||||
public override async Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", item.Id },
|
||||
@@ -43,9 +44,30 @@ internal class DriveFolderSourceEntry(SourceProvider provider, string path, Grap
|
||||
{ "o365:ParentReference", JsonSerializer.Serialize(item.ParentReference) ?? "" },
|
||||
{ "o365:FileSystemInfo", JsonSerializer.Serialize(item.FileSystemInfo) ?? "" },
|
||||
{ "o365:DownloadUrl", item.DownloadUrl ?? "" }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var permissions = new List<GraphPermission>();
|
||||
await foreach (var perm in provider.OneDriveApi.GetDriveItemPermissionsAsync(drive.Id, item.Id, cancellationToken))
|
||||
{
|
||||
permissions.Add(perm);
|
||||
}
|
||||
|
||||
if (permissions.Count > 0)
|
||||
{
|
||||
metadata["o365:Permissions"] = JsonSerializer.Serialize(permissions);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.WriteWarningMessage(LOGTAG, "PermissionReadError", ex, $"Failed to read permissions for folder {item.Id}");
|
||||
}
|
||||
|
||||
return metadata
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
}
|
||||
|
||||
public override Task<bool> FileExists(string filename, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -15,11 +15,21 @@ internal class GroupCalendarEventsSourceEntry(SourceProvider provider, string pa
|
||||
yield break;
|
||||
|
||||
yield return new StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(this.Path, entry.Id + ".ics"),
|
||||
SystemIO.IO_OS.PathCombine(this.Path, entry.Id + ".json"),
|
||||
createdUtc: entry.CreatedDateTime.FromGraphDateTime(),
|
||||
lastModificationUtc: entry.LastModifiedDateTime.FromGraphDateTime(),
|
||||
size: -1,
|
||||
streamFactory: (ct) => provider.GroupCalendarApi.GetGroupEventStreamAsync(group.Id, entry.Id, ct));
|
||||
streamFactory: (ct) => provider.GroupCalendarApi.GetGroupEventStreamAsync(group.Id, entry.Id, ct),
|
||||
minorMetadataFactory: ct => Task.FromResult(new Dictionary<string, string?>
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", entry.Id },
|
||||
{ "o365:Type", SourceItemType.GroupCalendarEvent.ToString() },
|
||||
{ "o365:Subject", entry.Subject ?? "" },
|
||||
{ "o365:Start", entry.Start?.ToString() ?? "" },
|
||||
{ "o365:End", entry.End?.ToString() ?? "" },
|
||||
}.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,5 +37,13 @@ internal class GroupChannelSourceEntry(SourceProvider provider, string path, Gra
|
||||
yield return new GroupChannelMessageSourceEntry(provider, this.Path, group, channel, message);
|
||||
|
||||
}
|
||||
|
||||
await foreach (var tab in provider.GroupTeamsApi.ListChannelTabsAsync(group.Id, channel.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
yield return new GroupChannelTabSourceEntry(provider, this.Path, group, channel, tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class GroupChannelTabSourceEntry(SourceProvider provider, string path, GraphGroup group, GraphChannel channel, GraphTeamsTab tab)
|
||||
: MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, tab.Id)), DateTime.UnixEpoch, null)
|
||||
{
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", tab.Id },
|
||||
{ "o365:Type", SourceItemType.GroupChannelTab.ToString() },
|
||||
{ "o365:DisplayName", tab.DisplayName },
|
||||
{ "o365:WebUrl", tab.WebUrl },
|
||||
{ "o365:TeamsAppId", tab.TeamsApp?.Id },
|
||||
{ "o365:EntityId", tab.Configuration?.EntityId },
|
||||
{ "o365:ContentUrl", tab.Configuration?.ContentUrl },
|
||||
{ "o365:RemoveUrl", tab.Configuration?.RemoveUrl },
|
||||
{ "o365:WebsiteUrl", tab.Configuration?.WebsiteUrl },
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield return new StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: -1,
|
||||
streamFactory: (ct) => provider.GroupTeamsApi.GetChannelTabStreamAsync(group.Id, channel.Id, tab.Id, ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class GroupInstalledAppSourceEntry(SourceProvider provider, string path, GraphGroup group, GraphTeamsAppInstallation app)
|
||||
: MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, app.Id)), DateTime.UnixEpoch, null)
|
||||
{
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", app.Id },
|
||||
{ "o365:Type", SourceItemType.GroupInstalledApp.ToString() },
|
||||
{ "o365:TeamsAppId", app.TeamsApp?.Id },
|
||||
{ "o365:TeamsAppDisplayName", app.TeamsApp?.DisplayName },
|
||||
{ "o365:TeamsAppDistributionMethod", app.TeamsApp?.DistributionMethod },
|
||||
{ "o365:TeamsAppDefinitionId", app.TeamsAppDefinition?.Id },
|
||||
{ "o365:TeamsAppDefinitionDisplayName", app.TeamsAppDefinition?.DisplayName },
|
||||
{ "o365:TeamsAppDefinitionVersion", app.TeamsAppDefinition?.Version },
|
||||
{ "o365:TeamsAppDefinitionTeamsAppId", app.TeamsAppDefinition?.TeamsAppId },
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield return new StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: -1,
|
||||
streamFactory: (ct) => provider.GroupTeamsApi.GetTeamInstalledAppStreamAsync(group.Id, app.Id, ct));
|
||||
}
|
||||
}
|
||||
@@ -47,19 +47,31 @@ internal class GroupSourceEntry(SourceProvider provider, string path, GraphGroup
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: -1,
|
||||
streamFactory: (ct) => provider.GroupApi.GetGroupMetadataStreamAsync(group.Id, ct)),
|
||||
streamFactory: (ct) => provider.GroupApi.GetGroupMetadataStreamAsync(group.Id, ct),
|
||||
minorMetadataFactory: (ct) => Task.FromResult(new Dictionary<string, string?> {
|
||||
{ "o365:Type", SourceItemType.GroupSettings.ToString() },
|
||||
{ "o365:Id", group.Id }
|
||||
})),
|
||||
|
||||
new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "members.json"),
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: -1,
|
||||
streamFactory: (ct) => DirectoryEntriesAsStream(provider.GroupApi.ListGroupMembersAsync(group.Id, ct), ct)),
|
||||
streamFactory: (ct) => DirectoryEntriesAsStream(provider.GroupApi.ListGroupMembersAsync(group.Id, ct), ct),
|
||||
minorMetadataFactory: (ct) => Task.FromResult(new Dictionary<string, string?> {
|
||||
{ "o365:Type", SourceItemType.GroupMember.ToString() },
|
||||
{ "o365:Id", group.Id }
|
||||
})),
|
||||
|
||||
new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "owners.json"),
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: -1,
|
||||
streamFactory: (ct) => DirectoryEntriesAsStream(provider.GroupApi.ListGroupOwnersAsync(group.Id, ct), ct)),
|
||||
streamFactory: (ct) => DirectoryEntriesAsStream(provider.GroupApi.ListGroupOwnersAsync(group.Id, ct), ct),
|
||||
minorMetadataFactory: (ct) => Task.FromResult(new Dictionary<string, string?> {
|
||||
{ "o365:Type", SourceItemType.GroupOwner.ToString() },
|
||||
{ "o365:Id", group.Id }
|
||||
})),
|
||||
};
|
||||
|
||||
foreach (var entry in res)
|
||||
|
||||
@@ -160,5 +160,13 @@ internal class GroupTypeSourceEntry(SourceProvider provider, string path, GraphG
|
||||
yield return new GroupChannelSourceEntry(provider, this.Path, group, channel);
|
||||
}
|
||||
|
||||
await foreach (var app in provider.GroupTeamsApi.ListTeamInstalledAppsAsync(group.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
yield return new GroupInstalledAppSourceEntry(provider, this.Path, group, app);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ internal class MetaRootSourceEntry(SourceProvider provider, string mountPoint, O
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
if (!provider.LicenseApprovedForEntry(Path, type, user.Id))
|
||||
yield break;
|
||||
|
||||
yield return new UserSourceEntry(provider, Path, user);
|
||||
}
|
||||
break;
|
||||
@@ -60,6 +64,10 @@ internal class MetaRootSourceEntry(SourceProvider provider, string mountPoint, O
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
if (!provider.LicenseApprovedForEntry(Path, type, group.Id))
|
||||
yield break;
|
||||
|
||||
yield return new GroupSourceEntry(provider, this.Path, group);
|
||||
}
|
||||
break;
|
||||
@@ -68,6 +76,10 @@ internal class MetaRootSourceEntry(SourceProvider provider, string mountPoint, O
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
if (!provider.LicenseApprovedForEntry(Path, type, site.Id))
|
||||
yield break;
|
||||
|
||||
yield return new SiteSourceEntry(provider, this.Path, site);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
@@ -10,6 +11,19 @@ internal class PlannerTaskSourceEntry(SourceProvider provider, string path, Grap
|
||||
{
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield return new StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(this.Path, "task.json"),
|
||||
createdUtc: task.CreatedDateTime.FromGraphDateTime(),
|
||||
lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(),
|
||||
size: -1,
|
||||
streamFactory: async (ct) =>
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
await JsonSerializer.SerializeAsync(stream, task, cancellationToken: ct);
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
});
|
||||
|
||||
yield return new StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
|
||||
createdUtc: task.CreatedDateTime.FromGraphDateTime(),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using Duplicati.Library.Common.IO;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class SharePointListItemAttachmentSourceEntry(SourceProvider provider, string path, string listId, string itemId, GraphSite site, GraphAttachment attachment)
|
||||
: StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(path, attachment.Name ?? attachment.Id))
|
||||
{
|
||||
public override DateTime CreatedUtc => DateTime.UnixEpoch;
|
||||
public override DateTime LastModificationUtc => DateTime.UnixEpoch;
|
||||
public override long Size => attachment.Size ?? 0;
|
||||
|
||||
public override Task<Stream> OpenRead(CancellationToken cancellationToken)
|
||||
=> provider.SharePointListApi.GetAttachmentContentStreamAsync(site.WebUrl!, listId, itemId, attachment.Id, cancellationToken);
|
||||
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", attachment.Id },
|
||||
{ "o365:Type", SourceItemType.SharePointListItemAttachment.ToString() },
|
||||
{ "o365:Name", attachment.Name ?? "" },
|
||||
{ "o365:ContentType", attachment.ContentType ?? "" },
|
||||
{ "o365:Size", attachment.Size?.ToString() ?? "0" }
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class SharePointListItemSourceEntry(SourceProvider provider, string path, string listId, GraphSite site, GraphListItem item)
|
||||
: MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, item.Id)), item.CreatedDateTime.FromGraphDateTime(), item.LastModifiedDateTime.FromGraphDateTime())
|
||||
{
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield return new StreamResourceEntryFunction(
|
||||
SystemIO.IO_OS.PathCombine(this.Path, "content.json"),
|
||||
createdUtc: CreatedUtc,
|
||||
lastModificationUtc: LastModificationUtc,
|
||||
size: -1,
|
||||
streamFactory: (ct) =>
|
||||
{
|
||||
var json = JsonSerializer.Serialize(item, new JsonSerializerOptions { WriteIndented = true });
|
||||
return Task.FromResult<Stream>(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
|
||||
}
|
||||
);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(site.WebUrl))
|
||||
yield break;
|
||||
|
||||
// TODO: This requires specific authentication
|
||||
// await foreach (var attachment in provider.SharePointListApi.ListListItemAttachmentsAsync(site.WebUrl, listId, item.Id, cancellationToken).ConfigureAwait(false))
|
||||
// {
|
||||
// if (cancellationToken.IsCancellationRequested)
|
||||
// yield break;
|
||||
|
||||
// yield return new SharePointListItemAttachmentSourceEntry(provider, Path, listId, item.Id, site, attachment);
|
||||
// }
|
||||
}
|
||||
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", item.Id },
|
||||
{ "o365:Type", SourceItemType.SharePointListItem.ToString() },
|
||||
{ "o365:WebUrl", item.WebUrl ?? "" },
|
||||
{ "o365:ContentType", item.ContentType?.Name ?? "" },
|
||||
{ "o365:ContentTypeId", item.ContentType?.Id ?? "" }
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class SharePointListSourceEntry(SourceProvider provider, string path, GraphSite site, GraphList list)
|
||||
: MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, list.Id)), list.CreatedDateTime.FromGraphDateTime(), list.LastModifiedDateTime.FromGraphDateTime())
|
||||
{
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var item in provider.SharePointListApi.ListListItemsAsync(site.Id, list.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
yield return new SharePointListItemSourceEntry(provider, Path, list.Id, site, item);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", list.Id },
|
||||
{ "o365:Type", SourceItemType.SharePointList.ToString() },
|
||||
{ "o365:Name", list.Name ?? list.DisplayName ?? "" },
|
||||
{ "o365:DisplayName", list.DisplayName ?? "" },
|
||||
{ "o365:Description", list.Description ?? "" },
|
||||
{ "o365:WebUrl", list.WebUrl ?? "" },
|
||||
{ "o365:ListInfo", JsonSerializer.Serialize(list.List) ?? "" }
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value));
|
||||
}
|
||||
@@ -41,5 +41,13 @@ internal class SiteSourceEntry(SourceProvider provider, string path, GraphSite s
|
||||
|
||||
yield return new DriveSourceEntry(provider, this.Path, drive);
|
||||
}
|
||||
|
||||
await foreach (var list in provider.SharePointListApi.ListListsAsync(site.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
yield return new SharePointListSourceEntry(provider, this.Path, site, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public enum SourceItemType
|
||||
Calendar,
|
||||
CalendarGroup,
|
||||
CalendarEvent,
|
||||
CalendarEventContent,
|
||||
CalendarEventAttachment,
|
||||
Chat,
|
||||
ChatHostedContent,
|
||||
ChatMessage,
|
||||
@@ -21,9 +23,15 @@ public enum SourceItemType
|
||||
DriveFolder,
|
||||
Group,
|
||||
GroupCalendar,
|
||||
GroupCalendarEvent,
|
||||
GroupChannel,
|
||||
GroupChannelMessage,
|
||||
GroupChannelMessageReply,
|
||||
GroupChannelTab,
|
||||
GroupInstalledApp,
|
||||
GroupMember,
|
||||
GroupOwner,
|
||||
GroupSettings,
|
||||
GroupConversation,
|
||||
GroupConversationThread,
|
||||
GroupConversationThreadPost,
|
||||
@@ -32,6 +40,9 @@ public enum SourceItemType
|
||||
NotebookSectionGroup,
|
||||
Planner,
|
||||
Site,
|
||||
SharePointList,
|
||||
SharePointListItem,
|
||||
SharePointListItemAttachment,
|
||||
TaskList,
|
||||
TaskListTask,
|
||||
TaskListLinkedResource,
|
||||
@@ -39,8 +50,12 @@ public enum SourceItemType
|
||||
UserMailbox,
|
||||
UserMailboxFolder,
|
||||
UserMailboxEmail,
|
||||
UserMailboxSettings,
|
||||
UserMailboxRule,
|
||||
User,
|
||||
UserContact,
|
||||
UserContactPhoto,
|
||||
UserContactFolder,
|
||||
UserProfile,
|
||||
UserCalendar,
|
||||
UserContacts,
|
||||
|
||||
@@ -33,7 +33,7 @@ internal class StreamResourceEntryFunction(string path, DateTime createdUtc, Dat
|
||||
public override async Task<Stream> OpenRead(CancellationToken cancellationToken)
|
||||
=> _stream != null
|
||||
? _stream
|
||||
: await streamFactory(cancellationToken).ConfigureAwait(false);
|
||||
: (await streamFactory(cancellationToken).ConfigureAwait(false) ?? throw new FileNotFoundException($"Resource not found: {Path}"));
|
||||
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> minorMetadataFactory != null
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class UserContactFolderSourceEntry(SourceProvider provider, GraphUser user, string path, GraphContactFolder folder)
|
||||
: MetaEntryBase(Util.AppendDirSeparator(path), null, null)
|
||||
{
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// List contacts in this folder
|
||||
await foreach (var contact in provider.ContactsApi.ListContactsInFolderAsync(user.Id, folder.Id, cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
var ms = new MemoryStream();
|
||||
System.Text.Json.JsonSerializer.Serialize(ms, contact);
|
||||
var jsonBytes = ms.ToArray();
|
||||
|
||||
yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, contact.Id + ".json"),
|
||||
createdUtc: contact.CreatedDateTime.FromGraphDateTime(),
|
||||
lastModificationUtc: contact.LastModifiedDateTime.FromGraphDateTime(),
|
||||
size: jsonBytes.Length,
|
||||
streamFactory: (ct) => Task.FromResult<Stream>(new MemoryStream(jsonBytes)),
|
||||
minorMetadataFactory: (ct) => Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", contact.Id },
|
||||
{ "o365:Type", SourceItemType.UserContact.ToString() },
|
||||
{ "o365:Name", contact.DisplayName },
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value)));
|
||||
|
||||
// Contact Photo
|
||||
var photoEntry = new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, contact.Id + ".photo"),
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: -1,
|
||||
streamFactory: (ct) => provider.ContactsApi.GetContactPhotoStreamAsync(user.Id, contact.Id, folder.Id, ct),
|
||||
minorMetadataFactory: (ct) => Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", contact.Id },
|
||||
{ "o365:Type", SourceItemType.UserContactPhoto.ToString() },
|
||||
{ "o365:Name", $"{contact.DisplayName} - Photo" },
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)));
|
||||
|
||||
if (await photoEntry.ProbeIfExistsAsync(cancellationToken).ConfigureAwait(false))
|
||||
yield return photoEntry;
|
||||
}
|
||||
|
||||
// List child folders
|
||||
await foreach (var childFolder in provider.ContactsApi.ListContactFoldersAsync(user.Id, folder.Id, cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
yield return new UserContactFolderSourceEntry(
|
||||
provider,
|
||||
user,
|
||||
SystemIO.IO_OS.PathCombine(this.Path, childFolder.Id),
|
||||
childFolder);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", folder.Id },
|
||||
{ "o365:Type", SourceItemType.UserContactFolder.ToString() },
|
||||
{ "o365:Name", folder.DisplayName ?? "" },
|
||||
{ "o365:DisplayName", folder.DisplayName ?? "" },
|
||||
{ "o365:ParentFolderId", folder.ParentFolderId ?? "" },
|
||||
}
|
||||
.Where(kvp => !string.IsNullOrEmpty(kvp.Value))
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Duplicati.Library.Common.IO;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
internal class UserMailboxRulesSourceEntry(SourceProvider provider, GraphUser user, string path)
|
||||
: MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "rules")), DateTime.UnixEpoch, null)
|
||||
{
|
||||
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var rule in provider.UserEmailApi.ListMessageRulesAsync(user.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
var ms = new MemoryStream();
|
||||
System.Text.Json.JsonSerializer.Serialize(ms, rule);
|
||||
var jsonBytes = ms.ToArray();
|
||||
|
||||
yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, rule.Id + ".json"),
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: jsonBytes.Length,
|
||||
streamFactory: (ct) => Task.FromResult<Stream>(new MemoryStream(jsonBytes)),
|
||||
minorMetadataFactory: (ct) => Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Id", rule.Id },
|
||||
{ "o365:Type", SourceItemType.UserMailboxRule.ToString() },
|
||||
{ "o365:Name", rule.DisplayName },
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value)));
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Dictionary<string, string?>
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Type", "UserMailboxRules" },
|
||||
{ "o365:Name", "rules" }
|
||||
});
|
||||
}
|
||||
@@ -140,6 +140,24 @@ internal class UserTypeSourceEntry(SourceProvider provider, string path, GraphUs
|
||||
|
||||
yield return entry;
|
||||
}
|
||||
|
||||
// Add Mailbox Settings
|
||||
yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "settings.json"),
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: -1,
|
||||
streamFactory: (ct) => provider.UserEmailApi.GetMailboxSettingsStreamAsync(user.Id, ct),
|
||||
minorMetadataFactory: (ct) => Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
{ "o365:Type", SourceItemType.UserMailboxSettings.ToString() },
|
||||
{ "o365:Name", "Mailbox Settings" },
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value)));
|
||||
|
||||
// Add Mailbox Rules
|
||||
yield return new UserMailboxRulesSourceEntry(provider, user, this.Path);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<ISourceProviderEntry> ContactsEntries([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
@@ -151,13 +169,13 @@ internal class UserTypeSourceEntry(SourceProvider provider, string path, GraphUs
|
||||
|
||||
var ms = new MemoryStream();
|
||||
System.Text.Json.JsonSerializer.Serialize(ms, contact);
|
||||
ms.Seek(0, SeekOrigin.Begin);
|
||||
var jsonBytes = ms.ToArray();
|
||||
|
||||
yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, contact.Id + ".json"),
|
||||
createdUtc: contact.CreatedDateTime.FromGraphDateTime(),
|
||||
lastModificationUtc: contact.LastModifiedDateTime.FromGraphDateTime(),
|
||||
size: -1,
|
||||
streamFactory: (ct) => Task.FromResult<Stream>(ms),
|
||||
size: jsonBytes.Length,
|
||||
streamFactory: (ct) => Task.FromResult<Stream>(new MemoryStream(jsonBytes)),
|
||||
minorMetadataFactory: (ct) => Task.FromResult(new Dictionary<string, string?>()
|
||||
{
|
||||
{ "o365:v", "1" },
|
||||
@@ -167,6 +185,24 @@ internal class UserTypeSourceEntry(SourceProvider provider, string path, GraphUs
|
||||
}
|
||||
.Where(kv => !string.IsNullOrEmpty(kv.Value))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value)));
|
||||
|
||||
// Contact Photo
|
||||
var photoEntry = new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, contact.Id + ".photo"),
|
||||
createdUtc: DateTime.UnixEpoch,
|
||||
lastModificationUtc: DateTime.UnixEpoch,
|
||||
size: -1,
|
||||
streamFactory: (ct) => provider.ContactsApi.GetContactPhotoStreamAsync(user.Id, contact.Id, null, ct));
|
||||
|
||||
if (await photoEntry.ProbeIfExistsAsync(cancellationToken).ConfigureAwait(false))
|
||||
yield return photoEntry;
|
||||
}
|
||||
|
||||
await foreach (var folder in provider.ContactsApi.ListContactFoldersAsync(user.Id, null, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
yield break;
|
||||
|
||||
yield return new UserContactFolderSourceEntry(provider, user, SystemIO.IO_OS.PathCombine(this.Path, folder.Id), folder);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/groups/{group}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<GraphDirectoryObject> ListGroupMembersAsync(string groupId, CancellationToken ct)
|
||||
@@ -154,7 +154,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/groups/{group}/threads/{thread}/posts/{post}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/groups/{group}/calendar" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<GraphCalendar> GetGroupCalendarAsync(string groupId, CancellationToken ct)
|
||||
@@ -219,7 +219,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/groups/{group}/events/{ev}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/planner/tasks/{task}/details" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetPlannerProgressTaskBoardFormatStreamAsync(string taskId, CancellationToken ct)
|
||||
@@ -318,7 +318,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/planner/tasks/{task}/progressTaskBoardFormat" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetPlannerAssignedToTaskBoardFormatStreamAsync(string taskId, CancellationToken ct)
|
||||
@@ -331,7 +331,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/planner/tasks/{task}/assignedToTaskBoardFormat" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetPlannerBucketTaskBoardFormatStreamAsync(string taskId, CancellationToken ct)
|
||||
@@ -344,7 +344,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/planner/tasks/{task}/bucketTaskBoardFormat" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/teams/{group}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetTeamMetadataStreamAsync(string groupId, CancellationToken ct)
|
||||
@@ -377,7 +377,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/teams/{team}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphTeamMember> ListTeamMembersAsync(string groupId, CancellationToken ct)
|
||||
@@ -421,7 +421,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/teams/{team}/channels/{channel}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphChannelMessage> ListChannelMessagesAsync(string groupId, string channelId, CancellationToken ct)
|
||||
@@ -474,7 +474,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/teams/{team}/channels/{channel}/messages/{msg}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetChannelMessageReplyStreamAsync(
|
||||
@@ -498,7 +498,71 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/teams/{team}/channels/{channel}/messages/{msg}/replies/{reply}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphTeamsTab> ListChannelTabsAsync(string groupId, string channelId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var team = Uri.EscapeDataString(groupId);
|
||||
var channel = Uri.EscapeDataString(channelId);
|
||||
|
||||
// configuration is a complex property (selectable) but NOT expandable
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphTeamsTab>();
|
||||
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/teams/{team}/channels/{channel}/tabs" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$expand=teamsApp";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphTeamsTab>(url, ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetChannelTabStreamAsync(string groupId, string channelId, string tabId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var team = Uri.EscapeDataString(groupId);
|
||||
var channel = Uri.EscapeDataString(channelId);
|
||||
var tab = Uri.EscapeDataString(tabId);
|
||||
|
||||
// configuration is a complex property (selectable) but NOT expandable
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphTeamsTab>();
|
||||
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/teams/{team}/channels/{channel}/tabs/{tab}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$expand=teamsApp";
|
||||
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphTeamsAppInstallation> ListTeamInstalledAppsAsync(string groupId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var team = Uri.EscapeDataString(groupId);
|
||||
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphTeamsAppInstallation>();
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/teams/{team}/installedApps" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$expand=teamsApp,teamsAppDefinition";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphTeamsAppInstallation>(url, ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetTeamInstalledAppStreamAsync(string groupId, string appId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var team = Uri.EscapeDataString(groupId);
|
||||
var app = Uri.EscapeDataString(appId);
|
||||
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphTeamsAppInstallation>();
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/teams/{team}/installedApps/{app}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$expand=teamsApp,teamsAppDefinition";
|
||||
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal async Task<bool> IsGroupTeamAsync(string groupId, CancellationToken ct)
|
||||
@@ -515,7 +579,7 @@ partial class SourceProvider
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, ct).ConfigureAwait(false);
|
||||
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
|
||||
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
partial class SourceProvider
|
||||
{
|
||||
internal SharePointListApiImpl SharePointListApi => new SharePointListApiImpl(_apiHelper);
|
||||
|
||||
internal class SharePointListApiImpl(APIHelper provider)
|
||||
{
|
||||
internal IAsyncEnumerable<GraphList> ListListsAsync(string siteId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphList>();
|
||||
var site = Uri.EscapeDataString(siteId);
|
||||
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/sites/{site}/lists" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$top={GENERAL_PAGE_SIZE}";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphList>(url, ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphListItem> ListListItemsAsync(string siteId, string listId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
// Exclude 'fields' from select because we expand it
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphListItem>(["fields"]);
|
||||
var site = Uri.EscapeDataString(siteId);
|
||||
var list = Uri.EscapeDataString(listId);
|
||||
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/sites/{site}/lists/{list}/items" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$expand=fields" +
|
||||
$"&$top={GENERAL_PAGE_SIZE}";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphListItem>(url, ct);
|
||||
}
|
||||
|
||||
internal async IAsyncEnumerable<GraphAttachment> ListListItemAttachmentsAsync(
|
||||
string siteWebUrl,
|
||||
string listId,
|
||||
string itemId,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
// SharePoint REST: /_api/web/lists(guid'...')/items(<id>)/AttachmentFiles
|
||||
var webUrl = siteWebUrl.TrimEnd('/');
|
||||
var listGuid = listId.Trim('{', '}');
|
||||
|
||||
var url =
|
||||
$"{webUrl}/_api/web/lists(guid'{Uri.EscapeDataString(listGuid)}')" +
|
||||
$"/items({Uri.EscapeDataString(itemId)})/AttachmentFiles";
|
||||
|
||||
using var stream = await provider.GetGraphItemAsStreamAsync(
|
||||
url,
|
||||
"application/json",
|
||||
"odata=nometadata",
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
JsonElement items;
|
||||
if (doc.RootElement.TryGetProperty("value", out var v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
items = v;
|
||||
}
|
||||
else if (doc.RootElement.TryGetProperty("d", out var d) &&
|
||||
d.TryGetProperty("results", out var r) &&
|
||||
r.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
items = r;
|
||||
}
|
||||
else
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var el in items.EnumerateArray())
|
||||
{
|
||||
if (!el.TryGetProperty("FileName", out var fn))
|
||||
continue;
|
||||
|
||||
var fileName = fn.GetString();
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
continue;
|
||||
|
||||
yield return new GraphAttachment
|
||||
{
|
||||
// For SharePoint list-item attachments, the filename is the identifier
|
||||
Id = fileName!,
|
||||
Name = fileName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal Task<Stream> GetAttachmentContentStreamAsync(
|
||||
string siteWebUrl,
|
||||
string listId,
|
||||
string itemId,
|
||||
string attachmentFileName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// SharePoint REST:
|
||||
// /_api/web/lists(guid'...')/items(<id>)/AttachmentFiles('<filename>')/$value
|
||||
var webUrl = siteWebUrl.TrimEnd('/');
|
||||
var listGuid = listId.Trim('{', '}');
|
||||
|
||||
// Escape single quotes for OData
|
||||
var safeFileName = attachmentFileName.Replace("'", "''");
|
||||
|
||||
var url =
|
||||
$"{webUrl}/_api/web/lists(guid'{Uri.EscapeDataString(listGuid)}')" +
|
||||
$"/items({Uri.EscapeDataString(itemId)})" +
|
||||
$"/AttachmentFiles('{Uri.EscapeDataString(safeFileName)}')/$value";
|
||||
|
||||
return provider.GetGraphResponseAsRealStreamAsync(url, "application/octet-stream", ct);
|
||||
}
|
||||
|
||||
internal async Task<GraphList> CreateListAsync(string siteId, string displayName, GraphListInfo? listInfo, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var site = Uri.EscapeDataString(siteId);
|
||||
var url = $"{baseUrl}/v1.0/sites/{site}/lists";
|
||||
|
||||
var list = new GraphList
|
||||
{
|
||||
DisplayName = displayName,
|
||||
List = listInfo ?? new GraphListInfo { Template = "genericList" }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(list);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
return await provider.PostGraphItemAsync<GraphList>(url, content, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task<GraphList?> GetListAsync(string siteId, string displayName, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var site = Uri.EscapeDataString(siteId);
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphList>();
|
||||
|
||||
// Filter by displayName
|
||||
var filter = $"displayName eq '{displayName.Replace("'", "''")}'";
|
||||
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/sites/{site}/lists" +
|
||||
$"?$filter={Uri.EscapeDataString(filter)}" +
|
||||
$"&$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
var lists = await provider.GetAllGraphItemsAsync<GraphList>(url, ct).ToListAsync(ct).ConfigureAwait(false);
|
||||
return lists.FirstOrDefault();
|
||||
}
|
||||
|
||||
internal async Task<GraphListItem> CreateListItemAsync(string siteId, string listId, JsonElement fields, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var site = Uri.EscapeDataString(siteId);
|
||||
var list = Uri.EscapeDataString(listId);
|
||||
var url = $"{baseUrl}/v1.0/sites/{site}/lists/{list}/items";
|
||||
|
||||
var item = new { fields };
|
||||
var json = JsonSerializer.Serialize(item);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
return await provider.PostGraphItemAsync<GraphListItem>(url, content, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
||||
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
partial class SourceProvider
|
||||
@@ -18,7 +20,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/sites/{site}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphDrive> ListSiteDrivesAsync(string siteId, CancellationToken ct)
|
||||
@@ -34,5 +36,22 @@ partial class SourceProvider
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphDrive>(url, ct);
|
||||
}
|
||||
|
||||
internal async Task<GraphDrive> GetSitePrimaryDriveAsync(string siteId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var site = Uri.EscapeDataString(siteId);
|
||||
|
||||
var select = "id,driveType,webUrl,createdDateTime,lastModifiedDateTime,owner";
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/sites/{site}/drive" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
var drive = await provider.GetGraphItemAsync<GraphDrive>(url, ct).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(drive.Id))
|
||||
throw new UserInformationException("Failed to read site's primary drive.", nameof(SourceProvider));
|
||||
|
||||
return drive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Duplicati.Library.Interface;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
@@ -100,7 +101,7 @@ partial class SourceProvider
|
||||
var msg = Uri.EscapeDataString(messageId);
|
||||
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/messages/{msg}/$value";
|
||||
return provider.GetGraphAsStreamAsync(url, "message/rfc822", ct);
|
||||
return provider.GetGraphResponseAsRealStreamAsync(url, "message/rfc822", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetEmailMetadataStreamAsync(string userIdOrUpn, string messageId, CancellationToken ct)
|
||||
@@ -117,7 +118,72 @@ partial class SourceProvider
|
||||
"from,toRecipients,ccRecipients,bccRecipients,replyTo,sender,subject,hasAttachments";
|
||||
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/messages/{msg}?$select={Uri.EscapeDataString(select)}";
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphMessageRule> ListMessageRulesAsync(string userIdOrUpn, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphMessageRule>();
|
||||
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/users/{user}/mailFolders/inbox/messageRules" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$top={GENERAL_PAGE_SIZE}";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphMessageRule>(url, ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetMessageRuleStreamAsync(string userIdOrUpn, string ruleId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var rule = Uri.EscapeDataString(ruleId);
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphMessageRule>();
|
||||
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/mailFolders/inbox/messageRules/{rule}?$select={Uri.EscapeDataString(select)}";
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetMailboxSettingsStreamAsync(string userIdOrUpn, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphMailboxSettings>();
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/mailboxSettings?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal async Task UpdateMailboxSettingsAsync(string userIdOrUpn, GraphMailboxSettings settings, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/mailboxSettings";
|
||||
|
||||
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
await provider.PatchGraphItemAsync(url, content, ct);
|
||||
}
|
||||
|
||||
internal async Task CreateMessageRuleAsync(string userIdOrUpn, GraphMessageRule rule, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/mailFolders/inbox/messageRules";
|
||||
|
||||
// Remove ID and read-only properties before creating
|
||||
rule.Id = "";
|
||||
rule.IsReadOnly = null;
|
||||
rule.HasError = null;
|
||||
|
||||
var json = JsonSerializer.Serialize(rule, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
await provider.PostGraphItemAsync<GraphMessageRule>(url, content, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +208,64 @@ partial class SourceProvider
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphContact>(url, ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphContactFolder> ListContactFoldersAsync(string userIdOrUpn, string? parentFolderId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphContactFolder>();
|
||||
|
||||
string url;
|
||||
if (string.IsNullOrWhiteSpace(parentFolderId))
|
||||
{
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contactFolders" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$top={GENERAL_PAGE_SIZE}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var parent = Uri.EscapeDataString(parentFolderId);
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{parent}/childFolders" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$top={GENERAL_PAGE_SIZE}";
|
||||
}
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphContactFolder>(url, ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphContact> ListContactsInFolderAsync(string userIdOrUpn, string folderId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var folder = Uri.EscapeDataString(folderId);
|
||||
var select = GraphSelectBuilder.BuildSelect<GraphContact>();
|
||||
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{folder}/contacts" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$top={GENERAL_PAGE_SIZE}";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphContact>(url, ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetContactPhotoStreamAsync(string userIdOrUpn, string contactId, string? folderId, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var contact = Uri.EscapeDataString(contactId);
|
||||
|
||||
string url;
|
||||
if (string.IsNullOrWhiteSpace(folderId))
|
||||
{
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contacts/{contact}/photo/$value";
|
||||
}
|
||||
else
|
||||
{
|
||||
var folder = Uri.EscapeDataString(folderId);
|
||||
url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{folder}/contacts/{contact}/photo/$value";
|
||||
}
|
||||
|
||||
return provider.GetGraphResponseAsRealStreamAsync(url, "application/octet-stream", ct);
|
||||
}
|
||||
}
|
||||
|
||||
internal TodoApiImpl TodoApi => new TodoApiImpl(_apiHelper);
|
||||
@@ -217,11 +341,6 @@ partial class SourceProvider
|
||||
return provider.GetAllGraphItemsAsync<GraphTodoLinkedResource>(url, ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetStreamFromUrl(string url, CancellationToken ct)
|
||||
{
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetTaskStreamAsync(
|
||||
string userIdOrUpn,
|
||||
string taskListId,
|
||||
@@ -238,7 +357,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetTaskChecklistItemStreamAsync(
|
||||
@@ -259,7 +378,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}/checklistItems/{item}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetTaskLinkedResourceStreamAsync(
|
||||
@@ -280,7 +399,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}/linkedResources/{lr}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +480,7 @@ partial class SourceProvider
|
||||
{
|
||||
// contentUrl is typically an absolute Microsoft Graph URL. Use it as-is.
|
||||
// Page content is HTML; Accept header is optional but helps.
|
||||
return provider.GetGraphAsStreamAsync(contentUrl, "text/html", ct);
|
||||
return provider.GetGraphResponseAsRealStreamAsync(contentUrl, "text/html", ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +520,7 @@ partial class SourceProvider
|
||||
return req;
|
||||
}
|
||||
|
||||
using var resp = await provider.SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, ct).ConfigureAwait(false);
|
||||
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
|
||||
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
||||
|
||||
var page = await APIHelper.ParseResponseJson<GraphDeltaPage<GraphDriveItem>>(resp, ct).ConfigureAwait(false)
|
||||
@@ -529,7 +648,24 @@ partial class SourceProvider
|
||||
|
||||
// Returns the file content stream (302 redirect handled by HttpClient by default)
|
||||
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{item}/content";
|
||||
return provider.GetGraphAsStreamAsync(url, "application/octet-stream", ct);
|
||||
return provider.GetGraphResponseAsRealStreamAsync(url, "application/octet-stream", ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphPermission> GetDriveItemPermissionsAsync(
|
||||
string driveId,
|
||||
string itemId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var drive = Uri.EscapeDataString(driveId);
|
||||
var item = Uri.EscapeDataString(itemId);
|
||||
|
||||
var select = "id,roles,grantedTo,grantedToIdentities,link,invitation";
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/drives/{drive}/items/{item}/permissions" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphPermission>(url, ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetDriveItemMetadataStreamAsync(
|
||||
@@ -549,7 +685,7 @@ partial class SourceProvider
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/drives/{drive}/items/{item}?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,7 +763,44 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events/{ev}" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
internal IAsyncEnumerable<GraphAttachment> ListCalendarEventAttachmentsAsync(
|
||||
string userIdOrUpn,
|
||||
string calendarId,
|
||||
string eventId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var cal = Uri.EscapeDataString(calendarId);
|
||||
var ev = Uri.EscapeDataString(eventId);
|
||||
|
||||
var select = "id,name,contentType,size,isInline,lastModifiedDateTime";
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events/{ev}/attachments" +
|
||||
$"?$select={Uri.EscapeDataString(select)}" +
|
||||
$"&$top={GENERAL_PAGE_SIZE}";
|
||||
|
||||
return provider.GetAllGraphItemsAsync<GraphAttachment>(url, ct);
|
||||
}
|
||||
|
||||
internal Task<Stream> GetCalendarEventAttachmentStreamAsync(
|
||||
string userIdOrUpn,
|
||||
string calendarId,
|
||||
string eventId,
|
||||
string attachmentId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
||||
var user = Uri.EscapeDataString(userIdOrUpn);
|
||||
var cal = Uri.EscapeDataString(calendarId);
|
||||
var ev = Uri.EscapeDataString(eventId);
|
||||
var att = Uri.EscapeDataString(attachmentId);
|
||||
|
||||
var url = $"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events/{ev}/attachments/{att}/$value";
|
||||
return provider.GetGraphResponseAsRealStreamAsync(url, "application/octet-stream", ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,7 +907,7 @@ partial class SourceProvider
|
||||
var url =
|
||||
$"{baseUrl}/v1.0/chats/{chat}/messages/{msg}/hostedContents/{hc}/$value";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/octet-stream", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/octet-stream", ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,28 +13,28 @@ partial class SourceProvider
|
||||
{
|
||||
var url = $"{provider.GraphBaseUrl.TrimEnd('/')}/v1.0/users/{Uri.EscapeDataString(userIdOrUpn)}" +
|
||||
"?$select=id,displayName,userPrincipalName,mail,accountEnabled,jobTitle,department,officeLocation,mobilePhone,businessPhones,usageLocation,preferredLanguage,onPremisesSyncEnabled,onPremisesImmutableId";
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
// /users/{id}/photo/$value
|
||||
public Task<Stream> GetUserPhotoStreamAsync(string userIdOrUpn, CancellationToken ct)
|
||||
{
|
||||
var url = $"{provider.GraphBaseUrl.TrimEnd('/')}/v1.0/users/{Uri.EscapeDataString(userIdOrUpn)}/photo/$value";
|
||||
return provider.GetGraphAsStreamAsync(url, "application/octet-stream", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/octet-stream", ct);
|
||||
}
|
||||
|
||||
// /users/{id}/licenseDetails
|
||||
public Task<Stream> GetUserLicenseDetailsStreamAsync(string userIdOrUpn, CancellationToken ct)
|
||||
{
|
||||
var url = $"{provider.GraphBaseUrl.TrimEnd('/')}/v1.0/users/{Uri.EscapeDataString(userIdOrUpn)}/licenseDetails";
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
// /users/{id}/manager
|
||||
public Task<Stream> GetUserManagerStreamAsync(string userIdOrUpn, CancellationToken ct)
|
||||
{
|
||||
var url = $"{provider.GraphBaseUrl.TrimEnd('/')}/v1.0/users/{Uri.EscapeDataString(userIdOrUpn)}/manager";
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
|
||||
// /users/{id}/joinedTeams
|
||||
@@ -50,7 +50,7 @@ partial class SourceProvider
|
||||
$"{baseUrl}/v1.0/users/{user}/joinedTeams" +
|
||||
$"?$select={Uri.EscapeDataString(select)}";
|
||||
|
||||
return provider.GetGraphAsStreamAsync(url, "application/json", ct);
|
||||
return provider.GetGraphItemAsStreamAsync(url, "application/json", ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,50 +3,118 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Duplicati.Library.Interface;
|
||||
using Duplicati.Library.Utility.Options;
|
||||
using Duplicati.Proprietary.Office365.SourceItems;
|
||||
|
||||
namespace Duplicati.Proprietary.Office365;
|
||||
|
||||
/// <summary>
|
||||
/// The source provider for Office365.
|
||||
/// </summary>
|
||||
public sealed partial class SourceProvider : ISourceProviderModule, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The log tag for this class.
|
||||
/// </summary>
|
||||
private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType<SourceProvider>();
|
||||
|
||||
/// <summary>
|
||||
/// The general page size for Graph API requests.
|
||||
/// </summary>
|
||||
private const int GENERAL_PAGE_SIZE = OptionsHelper.GENERAL_PAGE_SIZE;
|
||||
|
||||
/// <summary>
|
||||
/// API helper for making requests to the Graph API.
|
||||
/// </summary>
|
||||
private readonly APIHelper _apiHelper;
|
||||
|
||||
/// <summary>
|
||||
/// The mount point for this source provider instance.
|
||||
/// </summary>
|
||||
private readonly string _mountPoint;
|
||||
|
||||
/// <summary>
|
||||
/// The timeout options for the backend
|
||||
/// </summary>
|
||||
private readonly TimeoutOptionsHelper.Timeouts _timeouts;
|
||||
|
||||
/// <summary>
|
||||
/// Cache of already resolved source entries by their path.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, ISourceProviderEntry> _entryCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Counter for tracking which licensed paths are being enumerated.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, bool> _enumerationCounter = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether a license warning has been issued.
|
||||
/// </summary>
|
||||
private int _licenseWarningIssued = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this provider is being used for a restore operation.
|
||||
/// </summary>
|
||||
internal bool UsedForRestoreOperation { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// User types that require delegated permissions, default disabled.
|
||||
/// </summary>
|
||||
private static readonly Office365UserType[] DELEGATED_USER_TYPES =
|
||||
[
|
||||
Office365UserType.Tasks,
|
||||
Office365UserType.Notes
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Group types that require delegated permissions, default disabled.
|
||||
/// </summary>
|
||||
private static readonly Office365GroupType[] DELEGATED_GROUP_TYPES =
|
||||
[
|
||||
Office365GroupType.Calendar
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The default included root types.
|
||||
/// </summary>
|
||||
private static readonly Office365MetaType[] DEFAULT_INCLUDED_ROOT_TYPES =
|
||||
Enum.GetValues<Office365MetaType>()
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// The default included group types.
|
||||
/// </summary>
|
||||
private static readonly Office365GroupType[] DEFAULT_INCLUDED_GROUP_TYPES =
|
||||
Enum.GetValues<Office365GroupType>()
|
||||
.Except(DELEGATED_GROUP_TYPES)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// The default included user types.
|
||||
/// </summary>
|
||||
private static readonly Office365UserType[] DEFAULT_INCLUDED_USER_TYPES =
|
||||
Enum.GetValues<Office365UserType>()
|
||||
.Except(DELEGATED_USER_TYPES)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SourceProvider"/> class.
|
||||
/// Only used for loading metadata properties.
|
||||
/// </summary>
|
||||
public SourceProvider()
|
||||
{
|
||||
_apiHelper = null!;
|
||||
_mountPoint = null!;
|
||||
_timeouts = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SourceProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="url">The source provider URL.</param>
|
||||
/// <param name="mountPoint">The mount point.</param>
|
||||
/// <param name="options">The source provider options.</param>
|
||||
public SourceProvider(string url, string mountPoint, Dictionary<string, string?> options)
|
||||
{
|
||||
if (!Library.Utility.Utility.ParseBoolOption(options, "store-metadata-content-in-database"))
|
||||
@@ -54,37 +122,50 @@ public sealed partial class SourceProvider : ISourceProviderModule, IDisposable
|
||||
|
||||
_mountPoint = mountPoint;
|
||||
var parsedOptions = OptionsHelper.ParseAndValidateOptions(url, options);
|
||||
_timeouts = TimeoutOptionsHelper.Parse(options);
|
||||
_apiHelper = APIHelper.Create(
|
||||
tenantId: parsedOptions.TenantId,
|
||||
authOptions: parsedOptions.AuthOptions,
|
||||
graphBaseUrl: parsedOptions.GraphBaseUrl
|
||||
graphBaseUrl: parsedOptions.GraphBaseUrl,
|
||||
timeouts: _timeouts,
|
||||
certificatePath: parsedOptions.CertificatePath,
|
||||
certificatePassword: parsedOptions.CertificatePassword
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => OptionsHelper.ModuleKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => Strings.ProviderDisplayName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => Strings.ProviderDescription;
|
||||
|
||||
public IList<ICommandLineArgument> SupportedCommands => OptionsHelper.SupportedCommands;
|
||||
/// <inheritdoc />
|
||||
public IList<ICommandLineArgument> SupportedCommands => OptionsHelper.SupportedCommands.Concat(TimeoutOptionsHelper.GetOptions()).ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string MountedPath => _mountPoint;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_apiHelper?.Dispose();
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task Initialize(CancellationToken cancellationToken)
|
||||
=> _apiHelper.AcquireAccessTokenAsync(true, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
yield return new RootSourceEntry(this, _mountPoint);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ISourceProviderEntry?> GetEntry(string path, bool isFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_entryCache.TryGetValue(path, out var cachedEntry))
|
||||
@@ -109,13 +190,14 @@ public sealed partial class SourceProvider : ISourceProviderModule, IDisposable
|
||||
? targetPath
|
||||
: Path.GetRelativePath(currentPath, targetPath);
|
||||
|
||||
var pathsegments = relativePath?.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();
|
||||
if (pathsegments.Length == 0)
|
||||
pathsegments = [""];
|
||||
var pathsegments = relativePath?.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)
|
||||
?? Array.Empty<string>();
|
||||
|
||||
var segmentIndex = 0;
|
||||
foreach (var item in pathsegments)
|
||||
{
|
||||
var found = false;
|
||||
var isLastSegment = segmentIndex == pathsegments.Length - 1;
|
||||
await foreach (var entry in resultEntry.Enumerate(cancellationToken))
|
||||
{
|
||||
_entryCache.TryAdd(entry.Path, entry);
|
||||
@@ -125,7 +207,9 @@ public sealed partial class SourceProvider : ISourceProviderModule, IDisposable
|
||||
.Last();
|
||||
if (name.Equals(item, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!entry.IsFolder)
|
||||
if (isLastSegment && entry.IsFolder != isFolder)
|
||||
throw new UserInformationException($"Path segment '{item}' is not a {(isFolder ? "folder" : "file")}", "PathSegmentNotFolder");
|
||||
else if (!isLastSegment && !entry.IsFolder)
|
||||
throw new UserInformationException($"Path segment '{item}' is not a folder", "PathSegmentNotFolder");
|
||||
|
||||
resultEntry = entry;
|
||||
@@ -134,6 +218,7 @@ public sealed partial class SourceProvider : ISourceProviderModule, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
segmentIndex++;
|
||||
if (!found)
|
||||
return null;
|
||||
}
|
||||
@@ -141,13 +226,53 @@ public sealed partial class SourceProvider : ISourceProviderModule, IDisposable
|
||||
return resultEntry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the license is approved for the given entry.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to verify.</param>
|
||||
/// <param name="type">The type of entry.</param>
|
||||
/// <param name="id">The ID of the entry.</param>
|
||||
/// <returns><c>true</c> if the license is approved; otherwise, <c>false</c>.</returns>
|
||||
internal bool LicenseApprovedForEntry(string path, Office365MetaType type, string id)
|
||||
{
|
||||
// We do not limit restores
|
||||
if (UsedForRestoreOperation)
|
||||
return true;
|
||||
|
||||
// Make a unique target path for the type and id, just for counting purposes, not matching actual paths
|
||||
var targetpath = $"{path.TrimEnd(Path.DirectorySeparatorChar)}{Path.DirectorySeparatorChar}{type}{Path.DirectorySeparatorChar}{id}";
|
||||
|
||||
if (_enumerationCounter.ContainsKey(targetpath))
|
||||
return true;
|
||||
|
||||
var approved = LicenseChecker.LicenseHelper.AvailableOffice365FeatureSeats;
|
||||
if (_enumerationCounter.Count >= approved)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _licenseWarningIssued, 1) == 0)
|
||||
Library.Logging.Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Office 365 feature seats exceeded ({approved}). Some items will not be backed up.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_enumerationCounter[targetpath] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the included root types.
|
||||
/// </summary>
|
||||
internal IEnumerable<Office365MetaType> IncludedRootTypes =>
|
||||
DEFAULT_INCLUDED_ROOT_TYPES;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the included user types.
|
||||
/// </summary>
|
||||
internal IEnumerable<Office365UserType> IncludedUserTypes =>
|
||||
DEFAULT_INCLUDED_USER_TYPES;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the included group types.
|
||||
/// </summary>
|
||||
internal IEnumerable<Office365GroupType> IncludedGroupTypes =>
|
||||
DEFAULT_INCLUDED_GROUP_TYPES;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ internal static class Strings
|
||||
|
||||
public static string OfficeSecretOptionLong => LC.L("Client secret used for OAuth2 client credential flow against the Office 365 Management API.");
|
||||
|
||||
public static string OfficeCertificatePathOptionShort => LC.L("Path to the PKCS12 certificate file.");
|
||||
public static string OfficeCertificatePathOptionLong => LC.L("Path to the PKCS12 certificate file used for OAuth2 client credential flow against the Office 365 Management API.");
|
||||
|
||||
public static string OfficeCertificatePasswordOptionShort => LC.L("Password for the certificate file.");
|
||||
public static string OfficeCertificatePasswordOptionLong => LC.L("Password for the PKCS12 certificate file used for OAuth2 client credential flow against the Office 365 Management API.");
|
||||
|
||||
public static string OfficeGraphBaseOptionShort => LC.L("Microsoft Graph base URL.");
|
||||
|
||||
public static string OfficeGraphBaseOptionLong => LC.L("Base URL for Microsoft Graph if targeting a sovereign cloud.");
|
||||
|
||||
Vendored
+17
-17
@@ -1,21 +1,21 @@
|
||||
## License Information
|
||||
MIT License
|
||||
|
||||
MimeKit is Copyright (C) 2013-2016 Xamarin Inc. and is licensed under the MIT license:
|
||||
Copyright (C) 2012-2025 .NET Foundation and Contributors
|
||||
|
||||
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:
|
||||
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 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.
|
||||
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.
|
||||
Reference in New Issue
Block a user