Implemented Restore feature and refactored a bit.

Added a free tier for the Office 365 module.
This commit is contained in:
Kenneth Skovhede
2026-01-20 10:36:37 +01:00
parent 391d7c1704
commit a4b33e5d16
57 changed files with 7339 additions and 296 deletions
@@ -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
+4 -1
View File
@@ -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 />
@@ -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();
});
}
+2 -2
View File
@@ -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 -1
View File
@@ -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()
+378 -63
View File
@@ -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()
{
+372 -1
View File
@@ -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>
+13 -2
View File
@@ -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;
}
+6
View File
@@ -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.");
+17 -17
View File
@@ -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.