diff --git a/proprietary/Office365/APIHelper.cs b/proprietary/Office365/APIHelper.cs new file mode 100644 index 000000000..bc5a2ea49 --- /dev/null +++ b/proprietary/Office365/APIHelper.cs @@ -0,0 +1,450 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; +using Duplicati.Library.Utility.Options; +using NetUri = System.Uri; + +namespace Duplicati.Proprietary.Office365; + +/// +/// Implements the API helper class for Microsoft Graph API access +/// +internal class APIHelper : IDisposable +{ + /// + /// The HTTP client used for API requests + /// + private readonly HttpClient _httpClient; + /// + /// The cached Graph access token + /// + private string? _graphAccessToken; + /// + /// The authentication values + /// + private readonly AuthOptionsHelper.AuthOptions _authOptions; + /// + /// The tenant ID for the API requests + /// + private readonly string _tenantId; + /// + /// The base URL for Graph API requests + /// + private readonly string _graphBaseUrl; + /// + /// The base URL for Graph API requests + /// + public string GraphBaseUrl => _graphBaseUrl; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client to use for API requests + /// The authentication options + /// The tenant ID + /// The base URL for Graph API requests + private APIHelper(HttpClient httpClient, AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl) + { + _httpClient = httpClient; + _authOptions = authOptions; + _tenantId = tenantId; + _graphBaseUrl = graphBaseUrl; + } + + /// + /// Creates a new instance of the class. + /// + /// The authentication options + /// The tenant ID + /// The base URL for Graph API requests + /// A new instance of the class + public static APIHelper Create(AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl) + { + var handler = new HttpClientHandler + { + UseCookies = false + }; + + var httpClient = HttpClientHelper.CreateClient(handler); + httpClient.Timeout = Timeout.InfiniteTimeSpan; + httpClient.DefaultRequestHeaders.Add("User-Agent", $"Duplicati/{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"); + + return new APIHelper(httpClient, authOptions, tenantId, graphBaseUrl); + } + + /// + /// Gets the authentication header for the API requests. + /// + /// Whether to refresh the access token + /// The cancellation token + /// The authentication header + public async Task GetAuthenticationHeaderAsync(bool refresh, CancellationToken cancellationToken) + { + var accessToken = await AcquireAccessTokenAsync(refresh, cancellationToken).ConfigureAwait(false); + return new AuthenticationHeaderValue("Bearer", accessToken); + } + + /// + /// Acquires an access token for the API requests. + /// + /// Whether to refresh the access token + /// The cancellation token + /// The access token + public async Task AcquireAccessTokenAsync(bool refresh, CancellationToken cancellationToken) + { + if (!refresh && !string.IsNullOrWhiteSpace(_graphAccessToken)) + return _graphAccessToken; + + var tokenEndpoint = new NetUri($"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token"); + using var content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = _authOptions.Username!, + ["client_secret"] = _authOptions.Password!, + ["scope"] = $"{_graphBaseUrl.TrimEnd('/')}/.default" + }); + + using var client = HttpClientHelper.CreateClient(); + using var response = await client.PostAsync(tokenEndpoint, content, cancellationToken).ConfigureAwait(false); + await EnsureOfficeApiSuccessAsync(response, cancellationToken).ConfigureAwait(false); + + var token = await ParseResponseJson(response, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token?.AccessToken)) + throw new UserInformationException(Strings.MissingAccessToken, "MissingAccessTokenInResponse"); + + return token.AccessToken; + } + + /// + /// Parses the response JSON. + /// + /// The type to deserialize to + /// The HTTP response message + /// The cancellation token + /// The deserialized object + public static async Task ParseResponseJson(HttpResponseMessage response, CancellationToken cancellationToken) + { + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + try + { + return JsonSerializer.Deserialize(content) ?? throw new JsonException("Deserialized object is null"); + } + catch (JsonException ex) + { + throw new UserInformationException($"Failed to parse response JSON: {ex.Message}, JSON: {content}", "FailedToParseResponseJson", ex); + } + } + + /// + /// Ensures that the response from the Office API is successful. + /// + /// The HTTP response message + /// The cancellation token + /// An awaitable task + public static async Task EnsureOfficeApiSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + string? responseBody = null; + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(1)); + + responseBody = response.Content == null + ? null + : await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); + } + catch + { + // Ignore failures when reading the response body; we'll throw with status code regardless. + } + + var message = TryExtractOfficeApiErrorMessage(responseBody) + ?? responseBody + ?? response.ReasonPhrase + ?? "Request failed."; + + // Ensure the status code is both in the message (human readable) and on the exception (machine readable). + var statusCode = response.StatusCode; + var detailedMessage = $"{message} (HTTP {(int)statusCode} {statusCode}) - Request URI: {response.RequestMessage?.RequestUri}"; + if (response?.Headers?.TryGetValues("Location", out var location) == true) + detailedMessage += $" - Location: {location.First()}"; + throw new HttpRequestException(detailedMessage, inner: null, statusCode); + } + + /// + /// Attempts to extract the error message from the Office API response. + /// + /// The response body + /// The extracted error message, or null if none could be found + private static string? TryExtractOfficeApiErrorMessage(string? responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + return null; + + // Fast filter: if it doesn't look like JSON, don't attempt to parse. + var trimmed = responseBody.AsSpan().TrimStart(); + if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '[')) + return null; + + try + { + using var doc = JsonDocument.Parse(responseBody); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return null; + + if (doc.RootElement.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object) + { + if (error.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String) + { + var message = messageElement.GetString(); + return string.IsNullOrWhiteSpace(message) ? null : message; + } + } + } + catch (JsonException) + { + // Not JSON (or invalid JSON) - fall back to entire response body. + } + + return null; + } + + /// + /// Gets a single page of Graph API results. + /// + /// The type of items in the page + /// The URL to fetch the page from + /// A callback to determine if a request should be retried + /// The cancellation token + /// >The page of results + public async Task?> GetGraphPageAsync(string url, Func? shouldRetry, CancellationToken ct) + { + async Task requestFactory(CancellationToken ct) + { + var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url)); + req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false); + req.Headers.Accept.Clear(); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return req; + } + + using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, ct).ConfigureAwait(false); + await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); + + return await ParseResponseJson>(resp, ct).ConfigureAwait(false) + ?? new GraphPage(); + } + + + /// + /// Gets all Graph API results from a paged endpoint as an asynchronous enumerable. + /// + /// The type of items in the page + /// The initial URL to fetch results from + /// The cancellation token + /// An asynchronous enumerable of all items + public IAsyncEnumerable GetAllGraphItemsAsync(string initialUrl, CancellationToken ct) + => GetAllGraphItemsAsync(initialUrl, null, ct); + + /// + /// Gets all Graph API results from a paged endpoint as an asynchronous enumerable. + /// + /// The type of items in the page + /// The initial URL to fetch results from + /// A callback to determine if a request should be retried + /// The cancellation token + /// An asynchronous enumerable of all items + public async IAsyncEnumerable GetAllGraphItemsAsync(string initialUrl, Func? shouldRetry, [EnumeratorCancellation] CancellationToken ct) + { + var next = initialUrl; + while (!string.IsNullOrWhiteSpace(next)) + { + ct.ThrowIfCancellationRequested(); + + var page = await GetGraphPageAsync(next, shouldRetry, ct).ConfigureAwait(false); + + // If page is null, the shouldRetry callback indicated we should treat this as empty results + // (e.g., 503 when OneDrive is not provisioned for a user) + if (page == null) + yield break; + + foreach (var item in page.Value) + yield return item; + next = page.NextLink; + } + } + + /// + /// Gets a single Graph API item. + /// + /// The graph item to get + /// The URL to fetch + /// The cancellation token + /// >The graph item + public async Task GetGraphItemAsync(string url, CancellationToken ct) + { + var select = GraphSelectBuilder.BuildSelect(); + + async Task requestFactory(CancellationToken cancellationToken) + { + var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url)); + req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken); + req.Headers.Accept.Clear(); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return req; + } + + using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, ct).ConfigureAwait(false); + await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); + + return await ParseResponseJson(resp, ct).ConfigureAwait(false) + ?? throw new UserInformationException("Failed to parse Graph item response.", nameof(SourceProvider)); + } + + /// + /// Returns the response from the Graph API as a stream. + /// + /// The URL to fetch + /// The Accept header value + /// The cancellation token + /// The response stream + public Task GetGraphAsStreamAsync(string url, string accept, CancellationToken ct) + => GetGraphAsStreamAsync(url, accept, null, ct); + + /// + /// Returns the response from the Graph API as a stream. + /// + /// The URL to fetch + /// The Accept header value + /// A callback to determine if a request should be retried + /// The cancellation token + /// The response stream + private async Task GetGraphAsStreamAsync(string url, string accept, Func? shouldRetry, CancellationToken ct) + { + async Task requestFactory(CancellationToken ct) + { + var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url)); + req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false); + req.Headers.Accept.Clear(); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept)); + return req; + } + + using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, 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(); + await resp.Content.CopyToAsync(ms, ct).ConfigureAwait(false); + ms.Position = 0; + return ms; + } + + /// + /// Gets the HTTP client, acquiring an access token if needed. + /// + /// The cancellation token + /// The HTTP client + private async Task GetHttpClientAsync(CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_graphAccessToken)) + _graphAccessToken = await AcquireAccessTokenAsync(false, ct).ConfigureAwait(false); + + return _httpClient; + } + + /// + /// Sends an HTTP request with retry logic for transient failures. + /// + /// The factory to create the HTTP request + /// The HTTP completion option + /// A callback to determine if a request should be retried + /// The cancellation token + /// The maximum number of retries + /// >The HTTP response message + public async Task SendWithRetryAsync( + Func> requestFactory, + HttpCompletionOption completionOption, + Func? shouldRetry, + CancellationToken ct, + int maxRetries = 4) + { + var attempt = 0; + var isReAuthAttempt = false; + var client = await GetHttpClientAsync(ct).ConfigureAwait(false); + + while (true) + { + 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) + { + 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 + + // Respect Retry-After if present, else exponential backoff. + TimeSpan delay; + if (resp.Headers.RetryAfter?.Delta is TimeSpan ra) + { + delay = ra; + } + else + { + var baseSeconds = Math.Min(60, Math.Pow(2, attempt)); // cap at 60s + var jitterMs = Random.Shared.Next(0, 500); + delay = TimeSpan.FromSeconds(baseSeconds) + TimeSpan.FromMilliseconds(jitterMs); + } + + resp.Dispose(); + await Task.Delay(delay, ct).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + _httpClient.Dispose(); + _graphAccessToken = null; + } +} diff --git a/proprietary/Office365/DTOs.cs b/proprietary/Office365/DTOs.cs new file mode 100644 index 000000000..634810cbc --- /dev/null +++ b/proprietary/Office365/DTOs.cs @@ -0,0 +1,1019 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Duplicati.Proprietary.Office365; + +internal sealed class GraphUser +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("userPrincipalName")] + public string? UserPrincipalName { get; set; } + + [JsonPropertyName("accountEnabled")] + public bool? AccountEnabled { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + +} + +internal sealed class GraphSite +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("webUrl")] + public string? WebUrl { get; set; } + + [JsonPropertyName("siteCollection")] + public GraphSiteCollection? SiteCollection { get; set; } +} + +internal sealed class GraphSiteCollection +{ + [JsonPropertyName("hostname")] + public string? Hostname { get; set; } + + [JsonPropertyName("personalSite")] + public bool? PersonalSite { get; set; } +} + +internal sealed class OfficeTokenResponse +{ + [JsonPropertyName("token_type")] + public string TokenType { get; set; } = string.Empty; + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + [JsonPropertyName("expires_on")] + public string ExpiresOn { get; set; } = string.Empty; +} + +internal sealed class GraphPage +{ + [JsonPropertyName("value")] + public List Value { get; set; } = []; + + [JsonPropertyName("@odata.nextLink")] + public string? NextLink { get; set; } +} + +internal sealed class GraphMailFolder +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("parentFolderId")] + public string? ParentFolderId { get; set; } + + [JsonPropertyName("childFolderCount")] + public int? ChildFolderCount { get; set; } + + [JsonPropertyName("totalItemCount")] + public int? TotalItemCount { get; set; } + + [JsonPropertyName("unreadItemCount")] + public int? UnreadItemCount { get; set; } + + // Indicates whether the folder is hidden (not returned by default when listing) + [JsonPropertyName("isHidden")] + public bool? IsHidden { get; set; } +} + +internal sealed class GraphMessage +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("receivedDateTime")] + public DateTimeOffset? ReceivedDateTime { get; set; } + + [JsonPropertyName("sentDateTime")] + public DateTimeOffset? SentDateTime { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + [JsonPropertyName("internetMessageId")] + public string? InternetMessageId { get; set; } + + [JsonPropertyName("from")] + public GraphRecipient? From { get; set; } + + [JsonPropertyName("toRecipients")] + public List? ToRecipients { get; set; } + + [JsonPropertyName("hasAttachments")] + public bool? HasAttachments { get; set; } +} + +internal sealed class GraphRecipient +{ + [JsonPropertyName("emailAddress")] + public GraphEmailAddress? EmailAddress { get; set; } +} + +internal sealed class GraphEmailAddress +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("address")] + public string? Address { get; set; } +} + +internal sealed class GraphContact +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("givenName")] + public string? GivenName { get; set; } + + [JsonPropertyName("surname")] + public string? Surname { get; set; } + + [JsonPropertyName("emailAddresses")] + public List? EmailAddresses { get; set; } + + [JsonPropertyName("mobilePhone")] + public string? MobilePhone { get; set; } + + [JsonPropertyName("businessPhones")] + public List? BusinessPhones { get; set; } + + [JsonPropertyName("companyName")] + public string? CompanyName { get; set; } + + [JsonPropertyName("jobTitle")] + public string? JobTitle { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } +} + +internal sealed class GraphContactEmail +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("address")] + public string? Address { get; set; } +} + +internal sealed class GraphTodoTaskList +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("isOwner")] + public bool? IsOwner { get; set; } + + [JsonPropertyName("isShared")] + public bool? IsShared { get; set; } + + // none, defaultList, flaggedEmails, unknownFutureValue + [JsonPropertyName("wellknownListName")] + public string? WellknownListName { get; set; } +} + +internal sealed class GraphNotebook +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + [JsonPropertyName("isDefault")] + public bool? IsDefault { get; set; } +} + +internal sealed class GraphOnenoteSection +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } +} + +internal sealed class GraphOnenoteSectionGroup +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } +} + +internal sealed class GraphOnenotePage +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + [JsonPropertyName("contentUrl")] + public string? ContentUrl { get; set; } +} + +internal sealed class GraphTodoTask +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("importance")] + public string? Importance { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + [JsonPropertyName("completedDateTime")] + public object? CompletedDateTime { get; set; } + + [JsonPropertyName("dueDateTime")] + public object? DueDateTime { get; set; } + + [JsonPropertyName("body")] + public object? Body { get; set; } +} + +internal sealed class GraphTodoChecklistItem +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("isChecked")] + public bool? IsChecked { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } +} + +internal sealed class GraphTodoLinkedResource +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("webUrl")] + public string? WebUrl { get; set; } + + [JsonPropertyName("applicationName")] + public string? ApplicationName { get; set; } + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("externalId")] + public string? ExternalId { get; set; } +} + +internal sealed class GraphDriveItem +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("size")] + public long? Size { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + [JsonPropertyName("eTag")] + public string? ETag { get; set; } + + [JsonPropertyName("cTag")] + public string? CTag { get; set; } + + [JsonPropertyName("parentReference")] + public GraphDriveItemParentReference? ParentReference { get; set; } + + // Present in delta results when deleted + [JsonPropertyName("deleted")] + public JsonElement? Deleted { get; set; } + + // Facets (presence indicates item type) + [JsonPropertyName("folder")] + public GraphDriveFolderFacet? Folder { get; set; } + + [JsonPropertyName("file")] + public GraphDriveFileFacet? File { get; set; } + + [JsonPropertyName("fileSystemInfo")] + public GraphDriveFileSystemInfo? FileSystemInfo { get; set; } + + // Often useful when downloading + [JsonPropertyName("@microsoft.graph.downloadUrl")] + public string? DownloadUrl { get; set; } +} + +internal sealed class GraphDriveFolderFacet +{ + [JsonPropertyName("childCount")] + public int? ChildCount { get; set; } +} + +internal sealed class GraphDriveFileFacet +{ + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + [JsonPropertyName("hashes")] + public GraphDriveHashes? Hashes { get; set; } +} + +internal sealed class GraphDriveHashes +{ + [JsonPropertyName("quickXorHash")] + public string? QuickXorHash { get; set; } + + [JsonPropertyName("sha1Hash")] + public string? Sha1Hash { get; set; } + + [JsonPropertyName("sha256Hash")] + public string? Sha256Hash { get; set; } +} + +internal sealed class GraphDriveFileSystemInfo +{ + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } +} + +internal sealed class GraphDriveItemParentReference +{ + [JsonPropertyName("driveId")] + public string? DriveId { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } +} + +internal sealed class GraphDeltaPage +{ + [JsonPropertyName("value")] + public List Value { get; set; } = []; + + [JsonPropertyName("@odata.nextLink")] + public string? NextLink { get; set; } + + [JsonPropertyName("@odata.deltaLink")] + public string? DeltaLink { get; set; } +} + +internal sealed record GraphDeltaResult(List Items, string DeltaLink); + +internal sealed class GraphDrive +{ + // baseItem + [JsonPropertyName("id")] + public string Id { 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("createdBy")] + public object? CreatedBy { get; set; } + + [JsonPropertyName("lastModifiedBy")] + public object? LastModifiedBy { get; set; } + + // drive + [JsonPropertyName("driveType")] + public string? DriveType { get; set; } + + [JsonPropertyName("owner")] + public object? Owner { get; set; } + + [JsonPropertyName("quota")] + public object? Quota { get; set; } + + [JsonPropertyName("sharepointIds")] + public object? SharePointIds { get; set; } + + [JsonPropertyName("system")] + public object? System { get; set; } +} + +internal sealed class GraphCalendarGroup +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("classId")] + public string? ClassId { get; set; } + + [JsonPropertyName("changeKey")] + public string? ChangeKey { get; set; } +} + +internal sealed class GraphCalendar +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("changeKey")] + public string? ChangeKey { get; set; } + + // Graph: calendarColor enum (kept as string for forward compatibility) + [JsonPropertyName("color")] + public string? Color { get; set; } + + // Graph: hexColor string (e.g. "#FF0000") + [JsonPropertyName("hexColor")] + public string? HexColor { get; set; } + + [JsonPropertyName("isDefaultCalendar")] + public bool? IsDefaultCalendar { get; set; } + + [JsonPropertyName("isRemovable")] + public bool? IsRemovable { get; set; } + + [JsonPropertyName("canEdit")] + public bool? CanEdit { get; set; } + + [JsonPropertyName("canShare")] + public bool? CanShare { get; set; } + + [JsonPropertyName("canViewPrivateItems")] + public bool? CanViewPrivateItems { get; set; } + + [JsonPropertyName("isTallyingResponses")] + public bool? IsTallyingResponses { get; set; } + + // Values like: unknown, skypeForBusiness, skypeForBusinessForTeams, teamsForBusiness + [JsonPropertyName("defaultOnlineMeetingProvider")] + public string? DefaultOnlineMeetingProvider { get; set; } + + [JsonPropertyName("allowedOnlineMeetingProviders")] + public List? AllowedOnlineMeetingProviders { get; set; } + + [JsonPropertyName("owner")] + public GraphEmailAddress? Owner { get; set; } +} + +internal sealed class GraphEvent +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + // Graph uses dateTimeTimeZone objects for start/end + [JsonPropertyName("start")] + public object? Start { get; set; } + + [JsonPropertyName("end")] + public object? End { get; set; } + + [JsonPropertyName("isAllDay")] + public bool? IsAllDay { get; set; } + + [JsonPropertyName("isCancelled")] + public bool? IsCancelled { get; set; } + + [JsonPropertyName("location")] + public object? Location { get; set; } + + [JsonPropertyName("organizer")] + public object? Organizer { get; set; } + + [JsonPropertyName("attendees")] + public object? Attendees { get; set; } + + [JsonPropertyName("recurrence")] + public object? Recurrence { get; set; } + + [JsonPropertyName("seriesMasterId")] + public string? SeriesMasterId { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } // singleInstance, occurrence, exception, seriesMaster +} + +internal sealed class GraphPlannerTask +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("planId")] + public string? PlanId { get; set; } + + [JsonPropertyName("bucketId")] + public string? BucketId { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("startDateTime")] + public DateTimeOffset? StartDateTime { get; set; } + + [JsonPropertyName("dueDateTime")] + public DateTimeOffset? DueDateTime { get; set; } + + [JsonPropertyName("completedDateTime")] + public DateTimeOffset? CompletedDateTime { get; set; } + + [JsonPropertyName("percentComplete")] + public int? PercentComplete { get; set; } + + [JsonPropertyName("hasDescription")] + public bool? HasDescription { get; set; } + + [JsonPropertyName("assignments")] + public JsonElement? Assignments { get; set; } // map keyed by userId +} + +internal sealed class GraphChat +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("chatType")] + public string? ChatType { get; set; } // oneOnOne, group, meeting + + [JsonPropertyName("topic")] + public string? Topic { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastUpdatedDateTime")] + public DateTimeOffset? LastUpdatedDateTime { get; set; } + + [JsonPropertyName("webUrl")] + public string? WebUrl { get; set; } +} + +internal sealed class GraphChatMember +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("roles")] + public List? Roles { get; set; } + + [JsonPropertyName("visibleHistoryStartDateTime")] + public DateTimeOffset? VisibleHistoryStartDateTime { get; set; } + + [JsonPropertyName("userId")] + public string? UserId { get; set; } // present on some member types +} + +internal sealed class GraphChatMessage +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + [JsonPropertyName("deletedDateTime")] + public DateTimeOffset? DeletedDateTime { get; set; } + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("body")] + public object? Body { get; set; } + + [JsonPropertyName("from")] + public object? From { get; set; } + + [JsonPropertyName("attachments")] + public object? Attachments { get; set; } + + [JsonPropertyName("mentions")] + public object? Mentions { get; set; } + + [JsonPropertyName("reactions")] + public object? Reactions { get; set; } + + [JsonPropertyName("replyToId")] + public string? ReplyToId { get; set; } +} + +public sealed class GraphChatHostedContent +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + [JsonPropertyName("contentBytes")] + public string? ContentBytes { get; set; } + + [JsonPropertyName("@microsoft.graph.temporaryId")] + public string? TemporaryId { get; set; } +} + +public sealed class GraphGroup +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("mail")] + public string? Mail { get; set; } + + [JsonPropertyName("mailEnabled")] + public bool? MailEnabled { get; set; } + + [JsonPropertyName("securityEnabled")] + public bool? SecurityEnabled { get; set; } + + [JsonPropertyName("visibility")] + public string? Visibility { get; set; } + + [JsonPropertyName("groupTypes")] + public List? GroupTypes { get; set; } + + [JsonPropertyName("resourceProvisioningOptions")] + public List? ResourceProvisioningOptions { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } +} + +public sealed class GraphDirectoryObject +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("@odata.type")] + public string? ODataType { get; set; } + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("userPrincipalName")] + public string? UserPrincipalName { get; set; } + + [JsonPropertyName("mail")] + public string? Mail { get; set; } +} + +internal sealed class GraphConversation +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("topic")] + public string? Topic { get; set; } + + [JsonPropertyName("hasAttachments")] + public bool? HasAttachments { get; set; } + + [JsonPropertyName("lastDeliveredDateTime")] + public DateTimeOffset? LastDeliveredDateTime { get; set; } + + [JsonPropertyName("uniqueSenders")] + public List? UniqueSenders { get; set; } + + [JsonPropertyName("preview")] + public string? Preview { get; set; } +} + +internal sealed class GraphConversationThread +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("topic")] + public string? Topic { get; set; } + + [JsonPropertyName("lastDeliveredDateTime")] + public DateTimeOffset? LastDeliveredDateTime { get; set; } + + [JsonPropertyName("hasAttachments")] + public bool? HasAttachments { get; set; } + + [JsonPropertyName("preview")] + public string? Preview { get; set; } + + [JsonPropertyName("uniqueSenders")] + public List? UniqueSenders { get; set; } +} + +internal sealed class GraphPost +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + [JsonPropertyName("from")] + public GraphRecipient? From { get; set; } + + [JsonPropertyName("sender")] + public GraphRecipient? Sender { get; set; } + + [JsonPropertyName("body")] + public object? Body { get; set; } + + [JsonPropertyName("hasAttachments")] + public bool? HasAttachments { get; set; } + + [JsonPropertyName("attachments")] + public object? Attachments { get; set; } +} + +public sealed class GraphPlannerPlan +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("title")] + public string? Title { get; set; } + + // Deprecated in Graph: use Container instead + [JsonPropertyName("owner")] + public string? Owner { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("createdBy")] + public GraphIdentitySet? CreatedBy { get; set; } + + [JsonPropertyName("container")] + public GraphPlannerPlanContainer? Container { get; set; } +} + +public sealed class GraphIdentitySet +{ + [JsonPropertyName("user")] + public GraphIdentity? User { get; set; } + + public sealed class GraphIdentity + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + } +} + +public sealed class GraphPlannerPlanContainer +{ + [JsonPropertyName("containerId")] + public string? ContainerId { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } // e.g. "group" + + [JsonPropertyName("url")] + public string? Url { get; set; } +} + +public sealed class GraphPlannerBucket +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("planId")] + public string? PlanId { get; set; } + + [JsonPropertyName("orderHint")] + public string? OrderHint { get; set; } +} + +internal sealed class GraphTeamMember +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("roles")] + public List? Roles { get; set; } // contains "owner" for owners + + [JsonPropertyName("visibleHistoryStartDateTime")] + public DateTimeOffset? VisibleHistoryStartDateTime { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + [JsonPropertyName("userId")] + public string? UserId { get; set; } + + [JsonPropertyName("@odata.type")] + public string? ODataType { get; set; } +} + +public sealed class GraphChannel +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("membershipType")] + public string? MembershipType { get; set; } // standard, private, shared + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("isFavoriteByDefault")] + public bool? IsFavoriteByDefault { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("webUrl")] + public string? WebUrl { get; set; } +} + +public sealed class GraphChannelMessage +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("replyToId")] + public string? ReplyToId { get; set; } + + [JsonPropertyName("createdDateTime")] + public DateTimeOffset? CreatedDateTime { get; set; } + + [JsonPropertyName("lastModifiedDateTime")] + public DateTimeOffset? LastModifiedDateTime { get; set; } + + [JsonPropertyName("deletedDateTime")] + public DateTimeOffset? DeletedDateTime { get; set; } + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("from")] + public GraphChatMessageFromIdentitySet? From { get; set; } + + [JsonPropertyName("body")] + public object? Body { get; set; } + + [JsonPropertyName("attachments")] + public object? Attachments { get; set; } + + [JsonPropertyName("mentions")] + public object? Mentions { get; set; } + + [JsonPropertyName("reactions")] + public object? Reactions { get; set; } +} + +public sealed class GraphChatMessageFromIdentitySet +{ + [JsonPropertyName("user")] + public GraphChatMessageIdentity? User { get; set; } + + [JsonPropertyName("application")] + public GraphChatMessageIdentity? Application { get; set; } + + [JsonPropertyName("device")] + public GraphChatMessageIdentity? Device { get; set; } + + [JsonIgnore] + public string DisplayName => User?.DisplayName ?? Application?.DisplayName ?? Device?.DisplayName ?? ""; +} + +public sealed class GraphChatMessageIdentity +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("userIdentityType")] + public string? UserIdentityType { get; set; } +} \ No newline at end of file diff --git a/proprietary/Office365/Duplicati.Proprietary.Office365.csproj b/proprietary/Office365/Duplicati.Proprietary.Office365.csproj new file mode 100644 index 000000000..68b2950e5 --- /dev/null +++ b/proprietary/Office365/Duplicati.Proprietary.Office365.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + Library + Duplicati.Proprietary.Office365 + enable + enable + + + + + + + + diff --git a/proprietary/Office365/GraphSelectBuilder.cs b/proprietary/Office365/GraphSelectBuilder.cs new file mode 100644 index 000000000..e09c70c99 --- /dev/null +++ b/proprietary/Office365/GraphSelectBuilder.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Duplicati.Proprietary.Office365; + +internal static class GraphSelectBuilder +{ + /// + /// Cache of built select strings per type. + /// + private static readonly ConcurrentDictionary _cache = new(); + + /// + /// Builds an OData $select list from a CLR type by reading [JsonPropertyName] attributes. + /// Falls back to camelCase of the property name if [JsonPropertyName] is missing. + /// Excludes properties marked with [JsonIgnore]. + /// + /// The type to build the select for. + /// The OData $select string. + internal static string BuildSelect(IEnumerable? exclude = null) => BuildSelect(typeof(T), exclude); + + /// + /// Builds an OData $select list from a CLR type by reading [JsonPropertyName] attributes. + /// Falls back to camelCase of the property name if [JsonPropertyName] is missing. + /// Excludes properties marked with [JsonIgnore]. + /// + /// The type to build the select for. + /// The OData $select string. + internal static string BuildSelect(Type type, IEnumerable? exclude = null) + { + var names = _cache.GetOrAdd(type, BuildSelectInternal); + if (exclude == null || !exclude.Any()) return string.Join(",", names); + return string.Join(",", names.Except(exclude, StringComparer.OrdinalIgnoreCase)); + } + + /// + /// Builds an OData $select list from a CLR type by reading [JsonPropertyName] attributes. + /// Falls back to camelCase of the property name if [JsonPropertyName] is missing. + /// Excludes properties marked with [JsonIgnore]. + /// + /// The type to build the select for. + /// The OData $select string. + private static string[] BuildSelectInternal(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + static string ToCamelCase(string s) + { + if (string.IsNullOrEmpty(s)) return s; + if (s.Length == 1) return char.ToLowerInvariant(s[0]).ToString(); + return char.ToLowerInvariant(s[0]) + s.Substring(1); + } + + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var names = new List(props.Length); + + foreach (var p in props) + { + if (!p.CanRead) continue; + + // Skip [JsonIgnore] + var ignore = p.GetCustomAttribute(); + if (ignore != null && ignore.Condition != JsonIgnoreCondition.Never) + continue; + + // Skip indexers + if (p.GetIndexParameters().Length != 0) + continue; + + var jp = p.GetCustomAttribute(); + var name = jp?.Name; + + if (string.IsNullOrWhiteSpace(name)) + name = ToCamelCase(p.Name); + + // Skip special JSON metadata fields if any + if (name.StartsWith("@", StringComparison.Ordinal)) + continue; + + names.Add(name); + } + + // Deterministic output for caching/testability + names.Sort(StringComparer.Ordinal); + + return names.ToArray(); + } +} \ No newline at end of file diff --git a/proprietary/Office365/GraphUtils.cs b/proprietary/Office365/GraphUtils.cs new file mode 100644 index 000000000..9414d78bc --- /dev/null +++ b/proprietary/Office365/GraphUtils.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +namespace Duplicati.Proprietary.Office365; + +public static class GraphUtils +{ + public static DateTime FromGraphDateTime(this DateTimeOffset? dto) + => dto?.UtcDateTime ?? DateTime.UnixEpoch; + + public static string SanitizePathComponent(string pathComponent) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(pathComponent.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray()); + return sanitized; + } +} diff --git a/proprietary/Office365/OptionsHelper.cs b/proprietary/Office365/OptionsHelper.cs new file mode 100644 index 000000000..b06fa35c8 --- /dev/null +++ b/proprietary/Office365/OptionsHelper.cs @@ -0,0 +1,51 @@ +using Duplicati.Library.Interface; +using Duplicati.Library.Utility.Options; + +namespace Duplicati.Proprietary.Office365; + +internal static class OptionsHelper +{ + internal const string ModuleKey = "office365"; + 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_GRAPH_BASE_OPTION = "office-graph-base-url"; + + internal const string DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com"; + + internal const int GENERAL_PAGE_SIZE = 100; + internal const int CHATS_PAGE_SIZE = 50; + + internal sealed record ParsedOptions( + string TenantId, + AuthOptionsHelper.AuthOptions AuthOptions, + string GraphBaseUrl + ); + + internal static ParsedOptions ParseAndValidateOptions(string url, Dictionary options) + { + var _authOptions = AuthOptionsHelper.ParseWithAlias(options, new Library.Utility.Uri(url), OFFICE_CLIENT_OPTION, OFFICE_SECRET_OPTION) + .RequireCredentials(); + var _tenantId = options.GetValueOrDefault(OFFICE_TENANT_OPTION)!; + if (string.IsNullOrWhiteSpace(_tenantId)) + throw new UserInformationException(Strings.MissingTenantId, "MissingTenantId"); + + var _graphBaseUrl = options.GetValueOrDefault(OFFICE_GRAPH_BASE_OPTION) ?? DEFAULT_GRAPH_BASE_URL; + if (string.IsNullOrWhiteSpace(_graphBaseUrl)) + _graphBaseUrl = DEFAULT_GRAPH_BASE_URL; + + return new ParsedOptions( + TenantId: _tenantId, + AuthOptions: _authOptions, + GraphBaseUrl: _graphBaseUrl + ); + } + + internal static IList SupportedCommands => + [ + 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_GRAPH_BASE_OPTION, CommandLineArgument.ArgumentType.String, Strings.OfficeGraphBaseOptionShort, Strings.OfficeGraphBaseOptionLong, DEFAULT_GRAPH_BASE_URL) + ]; +} \ No newline at end of file diff --git a/proprietary/Office365/SourceItems/CalendarEventSourceEntry.cs b/proprietary/Office365/SourceItems/CalendarEventSourceEntry.cs new file mode 100644 index 000000000..6cdb06ca6 --- /dev/null +++ b/proprietary/Office365/SourceItems/CalendarEventSourceEntry.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; + +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 + { + { "o365:v", "1" }, + { "o365:Id", eventItem.Id }, + { "o365:Name", eventItem.Subject ?? "" }, + { "o365:Type", SourceItemType.CalendarEvent.ToString() }, + { "o365:Subject", eventItem.Subject ?? "" }, + { "o365:Start", eventItem.Start?.ToString() ??"" }, + { "o365:End", eventItem.End?.ToString() ?? "" }, + }.Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))) +{ +} diff --git a/proprietary/Office365/SourceItems/CalendarGroupSourceEntry.cs b/proprietary/Office365/SourceItems/CalendarGroupSourceEntry.cs new file mode 100644 index 000000000..0220e2cb4 --- /dev/null +++ b/proprietary/Office365/SourceItems/CalendarGroupSourceEntry.cs @@ -0,0 +1,35 @@ +// 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 CalendarGroupSourceEntry(SourceProvider provider, string path, GraphUser user, GraphCalendarGroup group) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, group.Id)), null, null) +{ + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", group.Id }, + { "o365:Type", SourceItemType.CalendarGroup.ToString() }, + { "o365:Name", group.Name ?? "" }, + { "o365:ClassId", group.ClassId ?? "" }, + { "o365:ChangeKey", group.ChangeKey ?? "" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var calendar in provider.CalendarApi.ListUserCalendarsInCalendarGroupAsync(user.Id, group.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new CalendarSourceEntry(provider, this.Path, user, group, calendar); + } + } +} diff --git a/proprietary/Office365/SourceItems/CalendarSourceEntry.cs b/proprietary/Office365/SourceItems/CalendarSourceEntry.cs new file mode 100644 index 000000000..336825c46 --- /dev/null +++ b/proprietary/Office365/SourceItems/CalendarSourceEntry.cs @@ -0,0 +1,46 @@ +// 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 CalendarSourceEntry(SourceProvider provider, string path, GraphUser user, GraphCalendarGroup calendarGroup, GraphCalendar calendar) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, calendar.Id)), null, null) +{ + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", calendar.Id }, + { "o365:Name", calendar.Name ?? "" }, + { "o365:Type", SourceItemType.Calendar.ToString() }, + { "o365:ChangeKey", calendar.ChangeKey ?? "" }, + { "o365:Color", calendar.Color ?? "" }, + { "o365:HexColor", calendar.HexColor ?? "" }, + { "o365:IsDefaultCalendar", calendar.IsDefaultCalendar.ToString() ?? "" }, + { "o365:IsRemovable", calendar.IsRemovable.ToString() ?? "" }, + { "o365:CanEdit", calendar.CanEdit.ToString() ?? "" }, + { "o365:CanShare", calendar.CanShare.ToString() ?? "" }, + { "o365:CanViewPrivateItems", calendar.CanViewPrivateItems.ToString() ?? "" }, + { "o365:IsTallyingResponses", calendar.IsTallyingResponses.ToString() ?? "" }, + { "o365:DefaultOnlineMeetingProvider", calendar.DefaultOnlineMeetingProvider ?? "" }, + { "o365:AllowedOnlineMeetingProviders", JsonSerializer.Serialize(calendar.AllowedOnlineMeetingProviders) ?? "" }, + { "o365:Owner", JsonSerializer.Serialize(calendar.Owner) ?? "" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var eventItem in provider.CalendarApi.ListCalendarEventsAsync(user.Id, calendar.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new CalendarEventSourceEntry(provider, this.Path, calendarGroup, calendar, eventItem); + } + } +} diff --git a/proprietary/Office365/SourceItems/ChatEntriesSourceEntry.cs b/proprietary/Office365/SourceItems/ChatEntriesSourceEntry.cs new file mode 100644 index 000000000..ea668a5d0 --- /dev/null +++ b/proprietary/Office365/SourceItems/ChatEntriesSourceEntry.cs @@ -0,0 +1,55 @@ +// 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 ChatEntriesSourceEntry(SourceProvider provider, string path, GraphChat chat, ChatEntryType type) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, type.ToString().ToLowerInvariant())), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (type == ChatEntryType.Message) + { + await foreach (var message in provider.ChatApi.ListChatMessagesAsync(chat.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new ChatMessageSourceEntry(provider, this.Path, chat, message); + } + } + else if (type == ChatEntryType.Member) + { + await foreach (var member in provider.ChatApi.ListChatMembersAsync(chat.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, member.Id + ".json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: async (ct) => + { + var ms = new MemoryStream(); + await System.Text.Json.JsonSerializer.SerializeAsync(ms, member, cancellationToken: ct).ConfigureAwait(false); + ms.Seek(0, SeekOrigin.Begin); + return ms; + }, + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", member.Id }, + { "o365:Type", SourceItemType.ChatMember.ToString() }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + } + } + else + throw new NotImplementedException(); + } +} diff --git a/proprietary/Office365/SourceItems/ChatHostedContentSourceEntry.cs b/proprietary/Office365/SourceItems/ChatHostedContentSourceEntry.cs new file mode 100644 index 000000000..044cf58d5 --- /dev/null +++ b/proprietary/Office365/SourceItems/ChatHostedContentSourceEntry.cs @@ -0,0 +1,35 @@ +// 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 ChatHostedContentSourceEntry(SourceProvider provider, string path, GraphChat chat, GraphChatMessage message) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "hostedcontent")), null, null) +{ + public async override IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var attachment in provider.ChatApi.ListChatHostedContentsAsync(chat.Id, message.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, attachment.Id + ".json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: async (ct) => await provider.ChatApi.GetChatHostedContentValueStreamAsync(chat.Id, message.Id, attachment.Id, ct).ConfigureAwait(false), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + ["o365:v"] = "1", + ["o365:Id"] = attachment.Id, + ["o365:Type"] = SourceItemType.ChatHostedContent.ToString(), + ["o365:ContentType"] = attachment.ContentType ?? "", + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + } + } +} diff --git a/proprietary/Office365/SourceItems/ChatMessageSourceEntry.cs b/proprietary/Office365/SourceItems/ChatMessageSourceEntry.cs new file mode 100644 index 000000000..dcced3a09 --- /dev/null +++ b/proprietary/Office365/SourceItems/ChatMessageSourceEntry.cs @@ -0,0 +1,38 @@ +// 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 ChatMessageSourceEntry(SourceProvider provider, string path, GraphChat chat, GraphChatMessage message) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, message.Id)), message.CreatedDateTime.FromGraphDateTime(), message.LastModifiedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "content.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: async (ct) => + { + var ms = new MemoryStream(); + await System.Text.Json.JsonSerializer.SerializeAsync(ms, message, cancellationToken: ct).ConfigureAwait(false); + ms.Seek(0, SeekOrigin.Begin); + return ms; + }, + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", message.Id }, + { "o365:Type", SourceItemType.ChatMessage.ToString() }, + { "o365:Name", message.Subject ?? "" }, + { "o365:From", message.From?.ToString() ?? "" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + + yield return new ChatHostedContentSourceEntry(provider, this.Path, chat, message); + } +} diff --git a/proprietary/Office365/SourceItems/ChatSourceEntry.cs b/proprietary/Office365/SourceItems/ChatSourceEntry.cs new file mode 100644 index 000000000..e57a8bb4b --- /dev/null +++ b/proprietary/Office365/SourceItems/ChatSourceEntry.cs @@ -0,0 +1,42 @@ +// 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 enum ChatEntryType +{ + Message, + Member +} + +internal class ChatSourceEntry(SourceProvider provider, string path, GraphChat chat) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, chat.Id)), chat.CreatedDateTime.FromGraphDateTime(), chat.LastUpdatedDateTime.FromGraphDateTime()) +{ + public async override IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var ms = new MemoryStream(); + System.Text.Json.JsonSerializer.Serialize(ms, chat); + ms.Seek(0, SeekOrigin.Begin); + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "content.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => Task.FromResult(ms), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", chat.Id }, + { "o365:Type", SourceItemType.Chat.ToString() }, + { "o365:Name", chat.Topic ?? "" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + + yield return new ChatEntriesSourceEntry(provider, this.Path, chat, ChatEntryType.Message); + yield return new ChatEntriesSourceEntry(provider, this.Path, chat, ChatEntryType.Member); + } +} diff --git a/proprietary/Office365/SourceItems/DriveFileSourceEntry.cs b/proprietary/Office365/SourceItems/DriveFileSourceEntry.cs new file mode 100644 index 000000000..7dea00a68 --- /dev/null +++ b/proprietary/Office365/SourceItems/DriveFileSourceEntry.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using Duplicati.Library.Common.IO; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class DriveFileSourceEntry(SourceProvider provider, string path, GraphDrive drive, GraphDriveItem item) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(path, item.Id)) +{ + public override DateTime CreatedUtc => item.CreatedDateTime.FromGraphDateTime(); + + public override DateTime LastModificationUtc => item.LastModifiedDateTime.FromGraphDateTime(); + + public override long Size => item.Size ?? -1; + + public override Task OpenRead(CancellationToken cancellationToken) + => provider.OneDriveApi.GetDriveItemContentStreamAsync(drive.Id, item.Id, cancellationToken); + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", item.Id }, + { "o365:Type", SourceItemType.DriveFile.ToString() }, + { "o365:Name", item.Name ?? "" }, + { "o365:ETag", item.ETag ?? "" }, + { "o365:CTag", item.CTag ?? "" }, + { "o365:MimeType", item.File?.MimeType ?? "" }, + { "o365:ParentReference", JsonSerializer.Serialize(item.ParentReference) ?? "" }, + { "o365:FileSystemInfo", JsonSerializer.Serialize(item.FileSystemInfo) ?? "" }, + { "o365:DownloadUrl", item.DownloadUrl ?? "" }, + { "o365:Hashes", JsonSerializer.Serialize(item.File?.Hashes) ?? "" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); +} diff --git a/proprietary/Office365/SourceItems/DriveFolderSourceEntry.cs b/proprietary/Office365/SourceItems/DriveFolderSourceEntry.cs new file mode 100644 index 000000000..6ab6b3bf4 --- /dev/null +++ b/proprietary/Office365/SourceItems/DriveFolderSourceEntry.cs @@ -0,0 +1,55 @@ +// 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; +using Duplicati.Library.Logging; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class DriveFolderSourceEntry(SourceProvider provider, string path, GraphDrive drive, GraphDriveItem item) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, item.Id)), item.CreatedDateTime.FromGraphDateTime(), item.LastModifiedDateTime.FromGraphDateTime()) +{ + private static readonly string LOGTAG = Log.LogTagFromType(); + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var child in provider.OneDriveApi.ListDriveFolderChildrenAsync(drive.Id, item.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + if (child.Deleted != null) + continue; + if (child.Folder != null) + yield return new DriveFolderSourceEntry(provider, this.Path, drive, child); + else if (child.File != null) + yield return new DriveFileSourceEntry(provider, this.Path, drive, child); + else + Log.WriteWarningMessage(LOGTAG, "UnknownDriveItemType", null, $"Unknown drive item type: {item.Id}"); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", item.Id }, + { "o365:Type", SourceItemType.DriveFolder.ToString() }, + { "o365:Name", item.Name ?? "" }, + { "o365:FolderChildCount", item.Folder?.ChildCount.ToString() ?? "" }, + { "o365:ETag", item.ETag ?? "" }, + { "o365:CTag", item.CTag ?? "" }, + { "o365:ParentReference", JsonSerializer.Serialize(item.ParentReference) ?? "" }, + { "o365:FileSystemInfo", JsonSerializer.Serialize(item.FileSystemInfo) ?? "" }, + { "o365:DownloadUrl", item.DownloadUrl ?? "" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public override Task FileExists(string filename, CancellationToken cancellationToken) + { + // TODO: Implement this + return Task.FromResult(false); + } +} diff --git a/proprietary/Office365/SourceItems/DriveSourceEntry.cs b/proprietary/Office365/SourceItems/DriveSourceEntry.cs new file mode 100644 index 000000000..b60dffcf7 --- /dev/null +++ b/proprietary/Office365/SourceItems/DriveSourceEntry.cs @@ -0,0 +1,54 @@ +// 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; +using Duplicati.Library.Logging; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class DriveSourceEntry(SourceProvider provider, string path, GraphDrive drive) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, drive.Id)), drive.CreatedDateTime.FromGraphDateTime(), drive.LastModifiedDateTime.FromGraphDateTime()) +{ + private static readonly string LOGTAG = Log.LogTagFromType(); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var item in provider.OneDriveApi.ListDriveRootChildrenAsync(drive.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + if (item.Deleted != null) + continue; + + if (item.Folder != null) + yield return new DriveFolderSourceEntry(provider, Path, drive, item); + else if (item.File != null) + yield return new DriveFileSourceEntry(provider, Path, drive, item); + else + Log.WriteWarningMessage(LOGTAG, "UnknownDriveItemType", null, $"Unknown drive item type: {item.Id}"); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", drive.Id }, + { "o365:Type", SourceItemType.Drive.ToString() }, + { "o365:Name", drive.Name ?? "" }, + { "o365:Description", drive.Description ?? "" }, + { "o365:WebUrl", drive.WebUrl ?? "" }, + { "o365:CreatedBy", JsonSerializer.Serialize(drive.CreatedBy) ?? "" }, + { "o365:LastModifiedBy", JsonSerializer.Serialize(drive.LastModifiedBy) ?? "" }, + { "o365:DriveType", drive.DriveType ?? "" }, + { "o365:Owner", JsonSerializer.Serialize(drive.Owner) ?? "" }, + { "o365:Quota", JsonSerializer.Serialize(drive.Quota) ?? "" }, + { "o365:SharePointIds", JsonSerializer.Serialize(drive.SharePointIds) ?? "" }, + { "o365:System", JsonSerializer.Serialize(drive.System) ?? "" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); +} diff --git a/proprietary/Office365/SourceItems/GroupCalendarEventsSourceEntry.cs b/proprietary/Office365/SourceItems/GroupCalendarEventsSourceEntry.cs new file mode 100644 index 000000000..002a6da52 --- /dev/null +++ b/proprietary/Office365/SourceItems/GroupCalendarEventsSourceEntry.cs @@ -0,0 +1,25 @@ +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class GroupCalendarEventsSourceEntry(SourceProvider provider, string path, GraphGroup group) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "events")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var entry in provider.GroupCalendarApi.ListGroupEventsAsync(group.Id, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, entry.Id + ".ics"), + createdUtc: entry.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: entry.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.GroupCalendarApi.GetGroupEventStreamAsync(group.Id, entry.Id, ct)); + } + } +} diff --git a/proprietary/Office365/SourceItems/GroupChannelMessageReplySourceEntry.cs b/proprietary/Office365/SourceItems/GroupChannelMessageReplySourceEntry.cs new file mode 100644 index 000000000..f623395c6 --- /dev/null +++ b/proprietary/Office365/SourceItems/GroupChannelMessageReplySourceEntry.cs @@ -0,0 +1,38 @@ +// 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 GroupChannelMessageReplySourceEntry(SourceProvider provider, string path, GraphGroup group, GraphChannel channel, GraphChannelMessage message) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "replies")), message.CreatedDateTime.FromGraphDateTime(), message.LastModifiedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var reply in provider.GroupTeamsApi.ListChannelMessageRepliesAsync(group.Id, channel.Id, message.Id, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, reply.Id), + createdUtc: reply.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: reply.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.GroupTeamsApi.GetChannelMessageReplyStreamAsync(group.Id, channel.Id, message.Id, reply.Id, ct), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", reply.Id }, + { "o365:Type", SourceItemType.GroupChannelMessageReply.ToString() }, + { "o365:Name", $"Re: {reply.From?.DisplayName} - {reply.Subject}" }, + { "o365:Subject", reply.Subject }, + { "o365:From", reply.From?.ToString() ?? "" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + } + } +} \ No newline at end of file diff --git a/proprietary/Office365/SourceItems/GroupChannelMessageSourceEntry.cs b/proprietary/Office365/SourceItems/GroupChannelMessageSourceEntry.cs new file mode 100644 index 000000000..298e43739 --- /dev/null +++ b/proprietary/Office365/SourceItems/GroupChannelMessageSourceEntry.cs @@ -0,0 +1,34 @@ +// 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 GroupChannelMessageSourceEntry(SourceProvider provider, string path, GraphGroup group, GraphChannel channel, GraphChannelMessage message) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, message.Id)), message.CreatedDateTime.FromGraphDateTime(), message.LastModifiedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "content.json"), + createdUtc: message.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: message.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.GroupTeamsApi.GetChannelMessageStreamAsync(group.Id, channel.Id, message.Id, ct), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", message.Id }, + { "o365:Type", SourceItemType.GroupChannelMessage.ToString() }, + { "o365:Name", $"{message.From?.DisplayName} - {message.Subject}" }, + { "o365:Subject", message.Subject }, + { "o365:From", message.From?.ToString() ?? "" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + + yield return new GroupChannelMessageReplySourceEntry(provider, this.Path, group, channel, message); + } +} diff --git a/proprietary/Office365/SourceItems/GroupChannelSourceEntry.cs b/proprietary/Office365/SourceItems/GroupChannelSourceEntry.cs new file mode 100644 index 000000000..d6c041dc2 --- /dev/null +++ b/proprietary/Office365/SourceItems/GroupChannelSourceEntry.cs @@ -0,0 +1,41 @@ +// 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 GroupChannelSourceEntry(SourceProvider provider, string path, GraphGroup group, GraphChannel channel) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, channel.Id)), channel.CreatedDateTime.FromGraphDateTime(), null) +{ + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", channel.Id }, + { "o365:Type", SourceItemType.GroupChannel.ToString() }, + { "o365:Name", channel.DisplayName }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public override async IAsyncEnumerable Enumerate([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.GetTeamChannelStreamAsync(group.Id, channel.Id, ct)); + + await foreach (var message in provider.GroupTeamsApi.ListChannelMessagesAsync(group.Id, channel.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new GroupChannelMessageSourceEntry(provider, this.Path, group, channel, message); + + } + } +} diff --git a/proprietary/Office365/SourceItems/GroupConversationSourceEntry.cs b/proprietary/Office365/SourceItems/GroupConversationSourceEntry.cs new file mode 100644 index 000000000..0eb1f35f6 --- /dev/null +++ b/proprietary/Office365/SourceItems/GroupConversationSourceEntry.cs @@ -0,0 +1,36 @@ +// 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 GroupConversationSourceEntry(SourceProvider provider, GraphGroup group, string path, GraphConversation conversation) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, conversation.Id)), null, conversation.LastDeliveredDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var thread in provider.GroupConversationApi.ListConversationThreadsAsync(group.Id, conversation.Id, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new GroupConversationThreadSourceEntry(provider, this.Path, group, thread); + } + } + + override public Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", conversation.Id }, + { "o365:Type", SourceItemType.GroupConversation.ToString() }, + { "o365:Name", conversation.Topic }, + { "o365:Topic", conversation.Topic }, + { "o365:HasAttachments", conversation.HasAttachments.ToString() }, + { "o365:LastDeliveredDateTime", conversation.LastDeliveredDateTime.FromGraphDateTime().ToString("o") } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); +} diff --git a/proprietary/Office365/SourceItems/GroupConversationThread.cs b/proprietary/Office365/SourceItems/GroupConversationThread.cs new file mode 100644 index 000000000..55c6466c4 --- /dev/null +++ b/proprietary/Office365/SourceItems/GroupConversationThread.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class GroupConversationThreadSourceEntry(SourceProvider provider, string path, GraphGroup group, GraphConversationThread thread) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, thread.Id)), null, thread.LastDeliveredDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var post in provider.GroupConversationApi.ListThreadPostsAsync(group.Id, thread.Id, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, post.Id + ".json"), + createdUtc: post.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: post.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.GroupConversationApi.GetThreadPostStreamAsync(group.Id, thread.Id, post.Id, ct), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", post.Id }, + { "o365:Type", SourceItemType.GroupConversationThreadPost.ToString() }, + { "o365:Name", $"{post.From?.EmailAddress?.Name} - {post.CreatedDateTime.FromGraphDateTime()}" }, + { "o365:From", post.From?.ToString() }, + { "o365:CreatedDateTime", post.CreatedDateTime.FromGraphDateTime().ToString("o") }, + { "o365:LastModifiedDateTime", post.LastModifiedDateTime.FromGraphDateTime().ToString("o") } + } + .WhereNotNull() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)) + ); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", thread.Id }, + { "o365:Type", SourceItemType.GroupConversationThread.ToString() }, + { "o365:Name", $"Thread: {thread.Topic ?? ""}" }, + { "o365:Topic", thread.Topic }, + { "o365:LastDeliveredDateTime", thread.LastDeliveredDateTime.FromGraphDateTime().ToString("o") } + } + .WhereNotNull() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); +} diff --git a/proprietary/Office365/SourceItems/GroupSourceEntry.cs b/proprietary/Office365/SourceItems/GroupSourceEntry.cs new file mode 100644 index 000000000..314afbf8e --- /dev/null +++ b/proprietary/Office365/SourceItems/GroupSourceEntry.cs @@ -0,0 +1,100 @@ +// 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; +using Duplicati.Library.Utility; + +namespace Duplicati.Proprietary.Office365.SourceItems; + + +internal enum Office365GroupType +{ + Mailbox, + Calendar, + Files, + Planner, + Teams +} + +internal class GroupSourceEntry(SourceProvider provider, string path, GraphGroup group) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, group.Id)), group.CreatedDateTime.FromGraphDateTime(), null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var entry in MetadataEntries(cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return entry; + } + + foreach (var type in provider.IncludedGroupTypes) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new GroupTypeSourceEntry(provider, Path, group, type); + } + } + + private async IAsyncEnumerable MetadataEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + var res = new StreamResourceEntryFunction[] { + new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.GroupApi.GetGroupMetadataStreamAsync(group.Id, ct)), + + 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)), + + 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)), + }; + + foreach (var entry in res) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return entry; + } + } + + private async Task DirectoryEntriesAsStream(IAsyncEnumerable items, CancellationToken cancellationToken) + { + var stream = new MemoryStream(); + JsonSerializer.Serialize(stream, await items.ToListAsync(cancellationToken)); + stream.Position = 0; + return stream; + } + + public override Task FileExists(string filename, CancellationToken cancellationToken) + => Task.FromResult(provider.IncludedGroupTypes.Any(x => x.ToString() == filename)); + + override public Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", group.Id }, + { "o365:Type", SourceItemType.Group.ToString() }, + { "o365:Name", group.DisplayName }, + { "o365:Description", group.Description }, + { "o365:CreatedDateTime", group.CreatedDateTime.FromGraphDateTime().ToString("o") }, + { "o365:Visibility", group.Visibility }, + { "o365:MailEnabled", group.MailEnabled.ToString() }, + { "o365:SecurityEnabled", group.SecurityEnabled.ToString() }, + } + .WhereNotNull() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); +} diff --git a/proprietary/Office365/SourceItems/GroupTypeSourceEntry.cs b/proprietary/Office365/SourceItems/GroupTypeSourceEntry.cs new file mode 100644 index 000000000..d0290c099 --- /dev/null +++ b/proprietary/Office365/SourceItems/GroupTypeSourceEntry.cs @@ -0,0 +1,164 @@ +// 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; +using Duplicati.Library.Logging; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class GroupTypeSourceEntry(SourceProvider provider, string path, GraphGroup group, Office365GroupType groupType) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, groupType.ToString().ToLowerInvariant())), group.CreatedDateTime.FromGraphDateTime(), null) +{ + private static readonly string LOGTAG = Log.LogTagFromType(); + + public override bool IsMetaEntry + { + get + { + // Minor hack to skip Teams meta entry if the group is not a Team + if (groupType != Office365GroupType.Teams) + return false; + + // Skip if the group is not a Team + if (!group.ResourceProvisioningOptions?.Contains("Team", StringComparer.OrdinalIgnoreCase) == true) + return true; + + return false; + } + } + + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var entries = groupType switch + { + Office365GroupType.Mailbox => MailboxEntries(cancellationToken), + Office365GroupType.Calendar => CalendarEntries(cancellationToken), + Office365GroupType.Files => FilesEntries(cancellationToken), + Office365GroupType.Planner => PlannerEntries(cancellationToken), + Office365GroupType.Teams => TeamsEntries(cancellationToken), + + _ => null + }; + + if (entries is null) + { + Log.WriteWarningMessage(LOGTAG, "UnknownGroupType", null, $"Unknown group type '{groupType}' for group '{group.Id}'"); + yield break; + } + + await foreach (var entry in entries) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + // Probe if the entry exists, if it is a stream resource + if (entry is StreamResourceEntryFunction sref) + if (!await sref.ProbeIfExistsAsync(cancellationToken).ConfigureAwait(false)) + continue; + + yield return entry; + } + } + + private async IAsyncEnumerable MailboxEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + if ((group.GroupTypes?.Contains("Unified") ?? false) && (group.MailEnabled ?? false)) + { + await foreach (var entry in provider.GroupConversationApi.ListGroupConversationsAsync(group.Id, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new GroupConversationSourceEntry(provider, group, this.Path, entry); + } + } + } + + private async IAsyncEnumerable CalendarEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.GroupCalendarApi.GetGroupCalendarStreamAsync(group.Id, ct), + minorMetadataFactory: async (ct) => + { + var calendar = await provider.GroupCalendarApi.GetGroupCalendarAsync(group.Id, ct).ConfigureAwait(false); + return new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", calendar.Id }, + { "o365:Type", SourceItemType.GroupCalendar.ToString() }, + { "o365:Name", calendar.Name ?? "" } + }; + }); + + yield return new GroupCalendarEventsSourceEntry(provider, this.Path, group); + } + + private async IAsyncEnumerable FilesEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var drive in provider.GroupDriveApi.ListGroupDrivesAsync(group.Id, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new DriveSourceEntry(provider, this.Path, drive); + } + } + + private async IAsyncEnumerable PlannerEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var plan in provider.GroupPlannerApi.ListGroupPlannerPlansAsync(group.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new PlannerPlanSourceEntry(provider, this.Path, plan); + } + } + + private async IAsyncEnumerable TeamsEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + // Skip if the group is not a Team + if (!group.ResourceProvisioningOptions?.Contains("Team", StringComparer.OrdinalIgnoreCase) == true) + yield break; + + // It can be provisioned as a Team but not have a Team yet + if (!await provider.GroupTeamsApi.IsGroupTeamAsync(group.Id, cancellationToken)) + yield break; + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.GroupTeamsApi.GetTeamMetadataStreamAsync(group.Id, ct)); + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "members.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: async (ct) => + { + var ms = new MemoryStream(); + await JsonSerializer.SerializeAsync(ms, provider.GroupTeamsApi.ListTeamMembersAsync(group.Id, ct).ToListAsync(ct), cancellationToken: ct); + ms.Position = 0; + return ms; + }); + + await foreach (var channel in provider.GroupTeamsApi.ListTeamChannelsAsync(group.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new GroupChannelSourceEntry(provider, this.Path, group, channel); + } + + } +} diff --git a/proprietary/Office365/SourceItems/MetaEntryBase.cs b/proprietary/Office365/SourceItems/MetaEntryBase.cs new file mode 100644 index 000000000..e001ff7a4 --- /dev/null +++ b/proprietary/Office365/SourceItems/MetaEntryBase.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal abstract class MetaEntryBase(string path, DateTime? createdUtc, DateTime? lastModificationUtc) : ISourceProviderEntry +{ + public bool IsFolder => true; + + public virtual bool IsMetaEntry => false; + + public bool IsRootEntry => false; + + public DateTime CreatedUtc => createdUtc ?? DateTime.UnixEpoch; + + public DateTime LastModificationUtc => lastModificationUtc ?? DateTime.UnixEpoch; + + public string Path => path; + + public long Size => -1; + public bool IsSymlink => false; + + public string? SymlinkTarget => null; + + public FileAttributes Attributes => FileAttributes.Directory; + + public bool IsBlockDevice => false; + + public bool IsCharacterDevice => false; + + public bool IsAlternateStream => false; + public string? HardlinkTargetId => null; + + public abstract IAsyncEnumerable Enumerate(CancellationToken cancellationToken); + + public virtual Task FileExists(string filename, CancellationToken cancellationToken) + => Task.FromResult(false); + + public virtual Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary()); + + public Task OpenRead(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/proprietary/Office365/SourceItems/MetaRootSourceEntry.cs b/proprietary/Office365/SourceItems/MetaRootSourceEntry.cs new file mode 100644 index 000000000..b59b00aec --- /dev/null +++ b/proprietary/Office365/SourceItems/MetaRootSourceEntry.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Logging; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal enum Office365MetaType +{ + // Tenant directory / discovery roots + Users, + Groups, + Sites, + + // // SharePoint structured content + // Lists, // SharePoint lists + // ListItems, // SharePoint list rows/items + + // // Compliance/audit + // AuditLogBlobs // Management Activity API content items +} + +internal class MetaRootSourceEntry(SourceProvider provider, string mountPoint, Office365MetaType type) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(mountPoint, type.ToString().ToLower())), null, null) +{ + private static readonly string LOGTAG = Log.LogTagFromType(); + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Type", type switch + { + Office365MetaType.Users => SourceItemType.MetaRootUsers.ToString(), + Office365MetaType.Groups => SourceItemType.MetaRootGroups.ToString(), + Office365MetaType.Sites => SourceItemType.MetaRootSites.ToString(), + _ => SourceItemType.MetaRoot.ToString() + } }, + { "o365:MetaType", type.ToString() }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + switch (type) + { + case Office365MetaType.Users: + await foreach (var user in provider.RootApi.ListAllUsersAsync(cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + yield return new UserSourceEntry(provider, Path, user); + } + break; + case Office365MetaType.Groups: + await foreach (var group in provider.RootApi.ListAllGroupsAsync(cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + yield return new GroupSourceEntry(provider, this.Path, group); + } + break; + case Office365MetaType.Sites: + await foreach (var site in provider.RootApi.ListAllSitesAsync(cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + yield return new SiteSourceEntry(provider, this.Path, site); + } + break; + default: + Log.WriteWarningMessage(LOGTAG, "MetaSourceEntryEnumerateUnknownType", null, $"Attempted to enumerate unknown meta entry type: {type}"); + break; + } + yield break; + } +} diff --git a/proprietary/Office365/SourceItems/NotebookSectionGroupSourceEntry.cs b/proprietary/Office365/SourceItems/NotebookSectionGroupSourceEntry.cs new file mode 100644 index 000000000..3df0e7899 --- /dev/null +++ b/proprietary/Office365/SourceItems/NotebookSectionGroupSourceEntry.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class NotebookSectionGroupSourceEntry(SourceProvider provider, string path, GraphUser user, GraphNotebook notebook, GraphOnenoteSectionGroup sectionGroup) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, sectionGroup.Id)), sectionGroup.CreatedDateTime.FromGraphDateTime(), sectionGroup.LastModifiedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var section in provider.OnenoteApi.ListSectionGroupSectionsAsync(sectionGroup.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new NotebookSectionSourceEntry(provider, this.Path, section); + } + + await foreach (var subSectionGroup in provider.OnenoteApi.ListNotebookSectionGroupsAsync(notebook.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + await foreach (var sectionGroup in provider.OnenoteApi.ListSectionGroupSectionGroupsAsync(sectionGroup.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new NotebookSectionGroupSourceEntry(provider, this.Path, user, notebook, sectionGroup); + } + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", sectionGroup.Id }, + { "o365:Type", SourceItemType.NotebookSectionGroup.ToString() }, + { "o365:Name", sectionGroup.DisplayName ?? "" }, + { "o365:DisplayName", sectionGroup.DisplayName ?? "" } + } + .WhereNotNull() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); + +} diff --git a/proprietary/Office365/SourceItems/NotebookSectionSourceEntry.cs b/proprietary/Office365/SourceItems/NotebookSectionSourceEntry.cs new file mode 100644 index 000000000..cedfd5cbd --- /dev/null +++ b/proprietary/Office365/SourceItems/NotebookSectionSourceEntry.cs @@ -0,0 +1,41 @@ +// 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 NotebookSectionSourceEntry(SourceProvider provider, string path, GraphOnenoteSection section) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, section.Id)), section.CreatedDateTime.FromGraphDateTime(), section.LastModifiedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var page in provider.OnenoteApi.ListSectionPagesAsync(section.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + if (page.ContentUrl == null) + continue; + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, page.Id + ".html"), + createdUtc: page.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: page.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.OnenoteApi.GetOnenotePageContentStreamAsync(page.ContentUrl, ct)); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", section.Id }, + { "o365:Type", SourceItemType.NotebookSection.ToString() }, + { "o365:Name", section.DisplayName ?? "" }, + { "o365:DisplayName", section.DisplayName ?? "" } + } + .Where(kvp => !string.IsNullOrEmpty(kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); +} diff --git a/proprietary/Office365/SourceItems/NotebookSourceEntry.cs b/proprietary/Office365/SourceItems/NotebookSourceEntry.cs new file mode 100644 index 000000000..6e7e6dd6c --- /dev/null +++ b/proprietary/Office365/SourceItems/NotebookSourceEntry.cs @@ -0,0 +1,34 @@ +// 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 NotebookSourceEntry(SourceProvider provider, string path, GraphUser user, GraphNotebook notebook) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, notebook.Id)), notebook.CreatedDateTime.FromGraphDateTime(), notebook.LastModifiedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var section in provider.OnenoteApi.ListNotebookSectionGroupsAsync(notebook.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new NotebookSectionGroupSourceEntry(provider, this.Path, user, notebook, section); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", notebook.Id }, + { "o365:Type", SourceItemType.Notebook.ToString() }, + { "o365:Name", notebook.DisplayName ?? "" }, + { "o365:DisplayName", notebook.DisplayName ?? "" } + } + .Where(kvp => !string.IsNullOrEmpty(kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); +} diff --git a/proprietary/Office365/SourceItems/PlannerPlanBucketSourceEntry.cs b/proprietary/Office365/SourceItems/PlannerPlanBucketSourceEntry.cs new file mode 100644 index 000000000..7b0ac4696 --- /dev/null +++ b/proprietary/Office365/SourceItems/PlannerPlanBucketSourceEntry.cs @@ -0,0 +1,41 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class PlannerPlanBucketSourceEntry(SourceProvider provider, string path, GraphPlannerPlan plan) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "buckets")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var bucket in provider.PlannerApi.ListPlannerBucketsAsync(plan.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, bucket.Id + ".json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => + { + var stream = new MemoryStream(); + JsonSerializer.Serialize(stream, bucket); + stream.Position = 0; + return Task.FromResult(stream); + }, + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", bucket.Id }, + { "o365:Type", SourceItemType.PlannerBucket.ToString() }, + { "o365:Name", bucket.Name }, + { "o365:OrderHint", bucket.OrderHint } + }) + ); + } + } +} diff --git a/proprietary/Office365/SourceItems/PlannerPlanSourceEntry.cs b/proprietary/Office365/SourceItems/PlannerPlanSourceEntry.cs new file mode 100644 index 000000000..ce2e98266 --- /dev/null +++ b/proprietary/Office365/SourceItems/PlannerPlanSourceEntry.cs @@ -0,0 +1,34 @@ +// 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 PlannerPlanSourceEntry(SourceProvider provider, string parentPath, GraphPlannerPlan plan) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, plan.Id)), plan.CreatedDateTime.FromGraphDateTime(), plan.CreatedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new PlannerPlanBucketSourceEntry(provider, this.Path, plan); + yield return new PlannerPlanTasksSourceEntry(provider, this.Path, plan); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", plan.Id }, + { "o365:Type", SourceItemType.Planner.ToString() }, + { "o365:Name", plan.Title ?? "" }, + { "o365:Title", plan.Title ?? "" }, + { "o365:CreatedBy", JsonSerializer.Serialize(plan.CreatedBy) ?? "" }, + { "o365:Container", JsonSerializer.Serialize(plan.Container) ?? "" }, + { "o365:Owner", plan.Owner ?? "" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + +} diff --git a/proprietary/Office365/SourceItems/PlannerPlanTasksSourceEntry.cs b/proprietary/Office365/SourceItems/PlannerPlanTasksSourceEntry.cs new file mode 100644 index 000000000..12d0a48bb --- /dev/null +++ b/proprietary/Office365/SourceItems/PlannerPlanTasksSourceEntry.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class PlannerPlanTasksSourceEntry(SourceProvider provider, string path, GraphPlannerPlan plan) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, "tasks")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var task in provider.PlannerApi.ListPlannerTasksAsync(plan.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new PlannerTaskSourceEntry(provider, this.Path, task); + } + } +} diff --git a/proprietary/Office365/SourceItems/PlannerTaskSourceEntry.cs b/proprietary/Office365/SourceItems/PlannerTaskSourceEntry.cs new file mode 100644 index 000000000..84f2b6880 --- /dev/null +++ b/proprietary/Office365/SourceItems/PlannerTaskSourceEntry.cs @@ -0,0 +1,55 @@ + +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class PlannerTaskSourceEntry(SourceProvider provider, string path, GraphPlannerTask task) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, task.Id)), task.CreatedDateTime.FromGraphDateTime(), null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "content.json"), + createdUtc: task.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.PlannerApi.GetPlannerTaskDetailsStreamAsync(task.Id, ct)); + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "assignedToTaskBoardFormat.json"), + createdUtc: task.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.PlannerApi.GetPlannerAssignedToTaskBoardFormatStreamAsync(task.Id, ct)); + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "bucketTaskBoardFormat.json"), + createdUtc: task.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.PlannerApi.GetPlannerBucketTaskBoardFormatStreamAsync(task.Id, ct)); + + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "progressTaskBoardFormat.json"), + createdUtc: task.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: task.CreatedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.PlannerApi.GetPlannerProgressTaskBoardFormatStreamAsync(task.Id, ct)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", task.Id }, + { "o365:Type", SourceItemType.UserPlannerTasks.ToString() }, + { "o365:Name", task.Title }, + { "o365:Title", task.Title }, + { "o365:BucketId", task.BucketId }, + { "o365:PercentComplete", task.PercentComplete?.ToString() }, + }); + + +} diff --git a/proprietary/Office365/SourceItems/RootSourceEntry.cs b/proprietary/Office365/SourceItems/RootSourceEntry.cs new file mode 100644 index 000000000..56b8de72f --- /dev/null +++ b/proprietary/Office365/SourceItems/RootSourceEntry.cs @@ -0,0 +1,82 @@ +// 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 RootSourceEntry(SourceProvider provider, string mountPoint) + : MetaEntryBase(Util.AppendDirSeparator(mountPoint), null, null) +{ + internal sealed record BackupDescription( + string Version, + string DuplicatiVersion, + string MachineId, + string PackageTypeId, + string OSType, + string OSVersion, + string DirectorySeparator, + string MountPoint + ); + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Type", SourceItemType.MetaRoot.ToString() }, + { "o365:Name", "Office 365 Tenant" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var description = new BackupDescription( + Version: "1.0", + DuplicatiVersion: Library.AutoUpdater.UpdaterManager.SelfVersion.Version ?? "", + MachineId: Library.AutoUpdater.DataFolderManager.MachineID, + PackageTypeId: Library.AutoUpdater.UpdaterManager.PackageTypeId, + OSType: Library.AutoUpdater.UpdaterManager.OperatingSystemName, + OSVersion: Library.Utility.OSInfoHelper.PlatformString, + DirectorySeparator: System.IO.Path.DirectorySeparatorChar.ToString(), + MountPoint: Path + ); + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "office-365-backup-description.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: async (ct) => + { + var ms = new MemoryStream(); + await System.Text.Json.JsonSerializer.SerializeAsync(ms, description, cancellationToken: ct).ConfigureAwait(false); + ms.Seek(0, SeekOrigin.Begin); + return ms; + }, + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "ExtType", "o365"}, + { "Ext:DuplicatiVersion", description.DuplicatiVersion }, + { "Ext:MachineId", description.MachineId }, + { "Ext:PackageTypeId", description.PackageTypeId }, + { "Ext:OSType", description.OSType }, + { "Ext:OSVersion", description.OSVersion }, + { "Ext:DirectorySeparator", description.DirectorySeparator }, + { "Ext:MountPoint", description.MountPoint } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)) + ); + + foreach (var type in provider.IncludedRootTypes) + { + if (cancellationToken.IsCancellationRequested) + yield break; + yield return new MetaRootSourceEntry(provider, Path, type); + } + } + + public override Task FileExists(string filename, CancellationToken cancellationToken) + => Task.FromResult(Enum.GetValues().Any(x => x.ToString() == filename)); +} diff --git a/proprietary/Office365/SourceItems/SiteSourceEntry.cs b/proprietary/Office365/SourceItems/SiteSourceEntry.cs new file mode 100644 index 000000000..1adb2bba9 --- /dev/null +++ b/proprietary/Office365/SourceItems/SiteSourceEntry.cs @@ -0,0 +1,45 @@ +// 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 SiteSourceEntry(SourceProvider provider, string path, GraphSite site) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, site.Id)), null, null) +{ + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", site.Id }, + { "o365:Type", SourceItemType.Site.ToString() }, + { "o365:Name", $"{site.DisplayName}{(site.SiteCollection?.PersonalSite == true ? " (Personal)" : "" )}" }, + { "o365:DisplayName", site.DisplayName }, + { "o365:WebUrl", site.WebUrl }, + { "o365:Hostname", site.SiteCollection?.Hostname }, + { "o365:PersonalSite", site.SiteCollection?.PersonalSite?.ToString() } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new StreamResourceEntryFunction( + SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.SiteApi.GetSiteMetadataStreamAsync(site.Id, ct) + ); + + await foreach (var drive in provider.SiteApi.ListSiteDrivesAsync(site.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new DriveSourceEntry(provider, this.Path, drive); + } + } +} diff --git a/proprietary/Office365/SourceItems/SourceItemType.cs b/proprietary/Office365/SourceItems/SourceItemType.cs new file mode 100644 index 000000000..22a6fd7a7 --- /dev/null +++ b/proprietary/Office365/SourceItems/SourceItemType.cs @@ -0,0 +1,46 @@ +namespace Duplicati.Proprietary.Office365.SourceItems; + +/// +/// The type of the source items found in Office365 +/// +public enum SourceItemType +{ + MetaRoot, + MetaRootUsers, + MetaRootGroups, + MetaRootSites, + Calendar, + CalendarGroup, + CalendarEvent, + Chat, + ChatHostedContent, + ChatMessage, + ChatMember, + Drive, + DriveFile, + DriveFolder, + Group, + GroupCalendar, + GroupChannel, + GroupChannelMessage, + GroupChannelMessageReply, + GroupConversation, + GroupConversationThread, + GroupConversationThreadPost, + Notebook, + NotebookSection, + NotebookSectionGroup, + Planner, + Site, + TaskList, + TaskListTask, + TaskListLinkedResource, + TaskListChecklistItem, + UserMailbox, + UserMailboxFolder, + UserMailboxEmail, + User, + UserContact, + UserPlannerTasks, + PlannerBucket +} \ No newline at end of file diff --git a/proprietary/Office365/SourceItems/StreamResourceEntryBase.cs b/proprietary/Office365/SourceItems/StreamResourceEntryBase.cs new file mode 100644 index 000000000..c2b04010f --- /dev/null +++ b/proprietary/Office365/SourceItems/StreamResourceEntryBase.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal abstract class StreamResourceEntryBase(string path) : ISourceProviderEntry +{ + public bool IsFolder => false; + + public bool IsMetaEntry => false; + + public virtual bool IsRootEntry => false; + + public abstract DateTime CreatedUtc { get; } + + public abstract DateTime LastModificationUtc { get; } + + public string Path => path; + + public abstract long Size { get; } + public bool IsSymlink => false; + + public string? SymlinkTarget => null; + + public virtual FileAttributes Attributes => FileAttributes.Normal; + + public bool IsBlockDevice => false; + + public bool IsCharacterDevice => false; + + public bool IsAlternateStream => false; + public string? HardlinkTargetId => null; + + public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + => throw new NotSupportedException("Cannot enumerate a file entry"); + + public Task FileExists(string filename, CancellationToken cancellationToken) + => throw new NotSupportedException("Cannot enumerate a file entry"); + + public virtual Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary()); + + public abstract Task OpenRead(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/proprietary/Office365/SourceItems/StreamResourceEntryFunction.cs b/proprietary/Office365/SourceItems/StreamResourceEntryFunction.cs new file mode 100644 index 000000000..1619509b4 --- /dev/null +++ b/proprietary/Office365/SourceItems/StreamResourceEntryFunction.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class StreamResourceEntryFunction(string path, DateTime createdUtc, DateTime lastModificationUtc, long size, Func> streamFactory, Func>>? minorMetadataFactory = null) : StreamResourceEntryBase(path) +{ + public override DateTime CreatedUtc => createdUtc; + + public override DateTime LastModificationUtc => lastModificationUtc; + public override long Size => _stream == null ? size : _stream.Length; + private Stream? _stream; + + public async Task ProbeIfExistsAsync(CancellationToken cancellationToken) + { + if (_stream != null) + return true; + + try + { + _stream = await streamFactory(cancellationToken).ConfigureAwait(false); + return _stream != null; + } + catch (FileNotFoundException) + { + return false; + } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return false; + } + } + + public override async Task OpenRead(CancellationToken cancellationToken) + => _stream != null + ? _stream + : await streamFactory(cancellationToken).ConfigureAwait(false); + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => minorMetadataFactory != null + ? minorMetadataFactory(cancellationToken) + : base.GetMinorMetadata(cancellationToken); +} diff --git a/proprietary/Office365/SourceItems/TaskListSourceEntry.cs b/proprietary/Office365/SourceItems/TaskListSourceEntry.cs new file mode 100644 index 000000000..6332c1e68 --- /dev/null +++ b/proprietary/Office365/SourceItems/TaskListSourceEntry.cs @@ -0,0 +1,37 @@ +// 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 TaskListSourceEntry(SourceProvider provider, string path, GraphUser user, GraphTodoTaskList taskList) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, taskList.Id)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var task in provider.TodoApi.ListTaskListTasksAsync(user.Id, taskList.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new TaskListTaskSourceEntry(provider, this.Path, user, taskList, task); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", taskList.Id }, + { "o365:Type", SourceItemType.TaskList.ToString() }, + { "o365:Name", taskList.DisplayName ?? "" }, + { "o365:DisplayName", taskList.DisplayName ?? "" }, + { "o365:WellknownListName", taskList.WellknownListName ?? "" }, + { "o365:IsOwner", taskList.IsOwner?.ToString() ?? "" }, + { "o365:IsShared", taskList.IsShared?.ToString() ?? "" } + } + .Where(kvp => !string.IsNullOrEmpty(kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); +} diff --git a/proprietary/Office365/SourceItems/TaskListTaskCheclistItemsSourceEntry.cs b/proprietary/Office365/SourceItems/TaskListTaskCheclistItemsSourceEntry.cs new file mode 100644 index 000000000..e17d4840d --- /dev/null +++ b/proprietary/Office365/SourceItems/TaskListTaskCheclistItemsSourceEntry.cs @@ -0,0 +1,69 @@ +// 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 TaskListTaskSubitemsSourceEntry(SourceProvider provider, string path, GraphUser user, GraphTodoTaskList taskList, GraphTodoTask task, TaskListTaskType type) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, type.ToString().ToLowerInvariant())), task.CreatedDateTime.FromGraphDateTime(), task.LastModifiedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (type == TaskListTaskType.ChecklistItems) + { + await foreach (var checklistItem in provider.TodoApi.ListTaskChecklistItemsAsync(user.Id, taskList.Id, task.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, checklistItem.Id + ".json"), + createdUtc: checklistItem.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: task.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.TodoApi.GetTaskChecklistItemStreamAsync(user.Id, taskList.Id, task.Id, checklistItem.Id, ct), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", checklistItem.Id }, + { "o365:Type", SourceItemType.TaskListChecklistItem.ToString() }, + { "o365:Name", checklistItem.DisplayName ?? "" }, + { "o365:IsChecked", checklistItem.IsChecked?.ToString() ?? "" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + } + yield break; + } + + if (type == TaskListTaskType.LinkedResources) + { + await foreach (var linkedResource in provider.TodoApi.ListTaskLinkedResourcesAsync(user.Id, taskList.Id, task.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, linkedResource.Id + ".json"), + createdUtc: task.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: task.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.TodoApi.GetTaskLinkedResourceStreamAsync(user.Id, taskList.Id, task.Id, linkedResource.Id, ct), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", linkedResource.Id }, + { "o365:Type", SourceItemType.TaskListLinkedResource.ToString() }, + { "o365:Name", linkedResource.DisplayName ?? "" }, + { "o365:WebUrl", linkedResource.WebUrl ?? "" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + } + yield break; + } + + throw new InvalidOperationException($"Unsupported TaskListTaskType: {type}"); + } +} + diff --git a/proprietary/Office365/SourceItems/TaskListTaskSourceEntry.cs b/proprietary/Office365/SourceItems/TaskListTaskSourceEntry.cs new file mode 100644 index 000000000..9b2d6b1ae --- /dev/null +++ b/proprietary/Office365/SourceItems/TaskListTaskSourceEntry.cs @@ -0,0 +1,46 @@ +// 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 enum TaskListTaskType +{ + ChecklistItems, + LinkedResources +} + +internal class TaskListTaskSourceEntry(SourceProvider provider, string path, GraphUser user, GraphTodoTaskList taskList, GraphTodoTask task) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, task.Id)), task.CreatedDateTime.FromGraphDateTime(), task.LastModifiedDateTime.FromGraphDateTime()) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "content.json"), + createdUtc: task.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: task.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.TodoApi.GetTaskStreamAsync(user.Id, taskList.Id, task.Id, ct), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", task.Id }, + { "o365:Type", SourceItemType.TaskListTask.ToString() }, + { "o365:Name", task.Title ?? "" }, + { "o365:LastModifiedDateTime", task.LastModifiedDateTime.FromGraphDateTime().ToString("o") }, + { "o365:CreatedDateTime", task.CreatedDateTime.FromGraphDateTime().ToString("o") }, + { "o365:Status", task.Status ?? "" }, + { "o365:Importance", task.Importance ?? "" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value))); + + yield return new TaskListTaskSubitemsSourceEntry(provider, this.Path, user, taskList, task, TaskListTaskType.ChecklistItems); + yield return new TaskListTaskSubitemsSourceEntry(provider, this.Path, user, taskList, task, TaskListTaskType.LinkedResources); + + } +} diff --git a/proprietary/Office365/SourceItems/UserMailboxEmailSourceEntry.cs b/proprietary/Office365/SourceItems/UserMailboxEmailSourceEntry.cs new file mode 100644 index 000000000..57fc6f760 --- /dev/null +++ b/proprietary/Office365/SourceItems/UserMailboxEmailSourceEntry.cs @@ -0,0 +1,48 @@ +// 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 UserMailboxEmailSourceEntry(SourceProvider provider, string path, GraphUser user, GraphMessage email) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, email.Id)), email.CreatedDateTime.FromGraphDateTime(), email.LastModifiedDateTime.FromGraphDateTime()) +{ + private readonly Dictionary _minorMetadata = new Dictionary { + { "o365:v", "1" }, + { "o365:Id", email.Id }, + { "o365:Type", SourceItemType.UserMailboxEmail.ToString() }, + { "o365:Name", email.Subject ?? "" }, + { "o365:Subject", email.Subject ?? "" }, + { "o365:From", email.From?.EmailAddress?.Address ?? "" }, + { "o365:To", string.Join(",", email.ToRecipients?.Select(x => x.EmailAddress?.Address) ?? []) }, + { "o365:CreatedDateTime", email.CreatedDateTime.FromGraphDateTime().ToString("o") }, + { "o365:ReceivedDateTime", email.ReceivedDateTime.FromGraphDateTime().ToString("o") }, + { "o365:SentDateTime", email.SentDateTime.FromGraphDateTime().ToString("o") }, + { "o365:InternetMessageId", email.InternetMessageId ?? "" }, + { "o365:HasAttachments", email.HasAttachments?.ToString() ?? "" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + public override Task> GetMinorMetadata(CancellationToken ct) + => Task.FromResult(_minorMetadata); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "content.eml"), + createdUtc: email.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: email.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.UserEmailApi.GetEmailContentStreamAsync(user.Id, email.Id, ct) + ); + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "metadata.json"), + createdUtc: email.CreatedDateTime.FromGraphDateTime(), + lastModificationUtc: email.LastModifiedDateTime.FromGraphDateTime(), + size: -1, + streamFactory: (ct) => provider.UserEmailApi.GetEmailMetadataStreamAsync(user.Id, email.Id, ct) + ); + } +} diff --git a/proprietary/Office365/SourceItems/UserMailboxFolderSourceEntry.cs b/proprietary/Office365/SourceItems/UserMailboxFolderSourceEntry.cs new file mode 100644 index 000000000..f97e1891b --- /dev/null +++ b/proprietary/Office365/SourceItems/UserMailboxFolderSourceEntry.cs @@ -0,0 +1,48 @@ +// 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 UserMailboxFolderSourceEntry(SourceProvider provider, GraphUser user, string path, GraphMailFolder folder) + : MetaEntryBase(Util.AppendDirSeparator(path), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var email in provider.UserEmailApi.ListAllEmailsInFolderAsync(user.Id, folder.Id, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new UserMailboxEmailSourceEntry(provider, this.Path, user, email); + } + + await foreach (var childFolder in provider.UserEmailApi.ListMailChildFoldersAsync(user.Id, folder.Id, cancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new UserMailboxFolderSourceEntry( + provider, + user, + SystemIO.IO_OS.PathCombine(this.Path, childFolder.Id), + childFolder); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", folder.Id }, + { "o365:Type", SourceItemType.UserMailboxFolder.ToString() }, + { "o365:Name", folder.DisplayName ?? "" }, + { "o365:DisplayName", folder.DisplayName ?? "" }, + { "o365:ParentFolderId", folder.ParentFolderId ?? "" }, + { "o365:IsHidden", folder.IsHidden?.ToString() ?? "" }, + } + .Where(kvp => !string.IsNullOrEmpty(kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); +} diff --git a/proprietary/Office365/SourceItems/UserSourceEntry.cs b/proprietary/Office365/SourceItems/UserSourceEntry.cs new file mode 100644 index 000000000..415f94aad --- /dev/null +++ b/proprietary/Office365/SourceItems/UserSourceEntry.cs @@ -0,0 +1,51 @@ +// 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 enum Office365UserType +{ + Profile, + Mailbox, + Calendar, + Contacts, + Tasks, + Notes, + Planner, + Chats +} + +internal class UserSourceEntry(SourceProvider provider, string path, GraphUser user) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, user.Id)), user.CreatedDateTime.FromGraphDateTime(), null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (var type in provider.IncludedUserTypes) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new UserTypeSourceEntry(provider, Path, user, type); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary + { + { "o365:v", "1" }, + { "o365:Id", user.Id }, + { "o365:Type", SourceItemType.User.ToString() }, + { "o365:Name", user.DisplayName ?? "" }, + { "o365:DisplayName", user.DisplayName ?? "" }, + { "o365:UserPrincipalName", user.UserPrincipalName ?? "" }, + { "o365:AccountEnabled", user.AccountEnabled?.ToString() ?? "" }, + } + .Where(kvp => !string.IsNullOrEmpty(kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); + + public override Task FileExists(string filename, CancellationToken cancellationToken) + => Task.FromResult(provider.IncludedUserTypes.Any(x => x.ToString() == filename)); +} \ No newline at end of file diff --git a/proprietary/Office365/SourceItems/UserTypeSourceEntry.cs b/proprietary/Office365/SourceItems/UserTypeSourceEntry.cs new file mode 100644 index 000000000..f4c0080c1 --- /dev/null +++ b/proprietary/Office365/SourceItems/UserTypeSourceEntry.cs @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Logging; + +namespace Duplicati.Proprietary.Office365.SourceItems; + +internal class UserTypeSourceEntry(SourceProvider provider, string path, GraphUser user, Office365UserType userType) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(path, userType.ToString().ToLowerInvariant())), user.CreatedDateTime.FromGraphDateTime(), null) +{ + private static readonly string LOGTAG = Log.LogTagFromType(); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var entries = userType switch + { + Office365UserType.Profile => ProfileEntries(cancellationToken), + Office365UserType.Mailbox => MailboxEntries(cancellationToken), + Office365UserType.Calendar => CalendarEntries(cancellationToken), + Office365UserType.Contacts => ContactsEntries(cancellationToken), + Office365UserType.Tasks => TasksEntries(cancellationToken), + Office365UserType.Notes => NotesEntries(cancellationToken), + Office365UserType.Planner => PlannerEntries(cancellationToken), + Office365UserType.Chats => ChatsEntries(cancellationToken), + + _ => null + }; + + if (entries is null) + { + Log.WriteWarningMessage(LOGTAG, "UnknownUserType", null, $"Unknown user type '{userType}' for user '{user.Id}'"); + yield break; + } + + await foreach (var entry in entries) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + // Probe if the entry exists, if it is a stream resource + if (entry is StreamResourceEntryFunction sref) + if (!await sref.ProbeIfExistsAsync(cancellationToken).ConfigureAwait(false)) + continue; + + yield return entry; + } + } + + private async IAsyncEnumerable ProfileEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + var res = new StreamResourceEntryFunction[] { + new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "user.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.UserProfileApi.GetUserObjectStreamAsync(user.Id, ct)), + + new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "photo.jpg"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.UserProfileApi.GetUserPhotoStreamAsync(user.Id, ct)), + + new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "licenses.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.UserProfileApi.GetUserLicenseDetailsStreamAsync(user.Id, ct)), + + new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "manager.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.UserProfileApi.GetUserManagerStreamAsync(user.Id, ct)), + + new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "teams.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => provider.UserProfileApi.GetUserTeamMembershipStreamAsync(user.Id, ct)), + + }; + + foreach (var entry in res) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return entry; + } + } + + private async IAsyncEnumerable MailboxEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + var rootFolder = await provider.UserEmailApi.GetMailRootFolderAsync(user.Id, cancellationToken).ConfigureAwait(false); + + // Don't introduce another level for the root folder, just enumerate its contents here + var p = new UserMailboxFolderSourceEntry(provider, user, this.Path, rootFolder); + await foreach (var entry in p.Enumerate(cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return entry; + } + } + + private async IAsyncEnumerable ContactsEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var contact in provider.ContactsApi.ListAllContactsAsync(user.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + var ms = new MemoryStream(); + System.Text.Json.JsonSerializer.Serialize(ms, contact); + ms.Seek(0, SeekOrigin.Begin); + + 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(ms), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "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))); + } + } + + private async IAsyncEnumerable TasksEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + + await foreach (var taskList in provider.TodoApi.ListUserTaskListsAsync(user.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new TaskListSourceEntry(provider, this.Path, user, taskList); + } + } + + private async IAsyncEnumerable NotesEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var note in provider.OnenoteApi.ListUserNotebooksAsync(user.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new NotebookSourceEntry(provider, this.Path, user, note); + } + } + + // private async IAsyncEnumerable OneDriveEntries([EnumeratorCancellation] CancellationToken cancellationToken) + // { + // await foreach (var drive in provider.OneDriveApi.ListUserDrivesAsync(user.Id, cancellationToken).ConfigureAwait(false)) + // { + // if (cancellationToken.IsCancellationRequested) + // yield break; + + // yield return new DriveSourceEntry(provider, this.Path, drive); + // } + // } + + private async IAsyncEnumerable CalendarEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var calendar in provider.CalendarApi.ListUserCalendarGroupsAsync(user.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new CalendarGroupSourceEntry(provider, this.Path, user, calendar); + } + } + + private async IAsyncEnumerable PlannerEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var plan in provider.UserPlannerApi.ListUserAssignedPlannerTasksAsync(user.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + var ms = new MemoryStream(); + System.Text.Json.JsonSerializer.Serialize(ms, plan); + ms.Seek(0, SeekOrigin.Begin); + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, plan.Id + ".json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: (ct) => Task.FromResult(ms), + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "o365:v", "1" }, + { "o365:Id", plan.Id }, + { "o365:Type", SourceItemType.UserPlannerTasks.ToString() }, + { "o365:Name", plan.Title }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)) + ); + } + } + + private async IAsyncEnumerable ChatsEntries([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var chat in provider.ChatApi.ListUserChatsAsync(user.Id, cancellationToken).ConfigureAwait(false)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new ChatSourceEntry(provider, this.Path, chat); + } + } +} \ No newline at end of file diff --git a/proprietary/Office365/SourceProvider/SourceProvider.Group.cs b/proprietary/Office365/SourceProvider/SourceProvider.Group.cs new file mode 100644 index 000000000..386c10c22 --- /dev/null +++ b/proprietary/Office365/SourceProvider/SourceProvider.Group.cs @@ -0,0 +1,527 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.CommandLine; +using System.Net.Http.Headers; + +namespace Duplicati.Proprietary.Office365; + +partial class SourceProvider +{ + internal GroupApiImpl GroupApi => new GroupApiImpl(_apiHelper); + + internal class GroupApiImpl(APIHelper provider) + { + public Task GetGroupMetadataStreamAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = + "id,displayName,description,mail,mailEnabled,securityEnabled,visibility," + + "groupTypes,resourceProvisioningOptions,createdDateTime"; + + var url = + $"{baseUrl}/v1.0/groups/{group}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + public IAsyncEnumerable ListGroupMembersAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/members" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + public IAsyncEnumerable ListGroupOwnersAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/owners" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + } + + internal GroupConversationApiImpl GroupConversationApi => new GroupConversationApiImpl(_apiHelper); + + internal sealed class GroupConversationApiImpl(APIHelper provider) + { + /// + /// Lists the group conversations (mailbox-like “threads of discussion”). + /// GET /groups/{id}/conversations + /// + internal IAsyncEnumerable ListGroupConversationsAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/conversations" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + /// + /// Lists all threads in a group. + /// GET /groups/{id}/threads + /// + internal IAsyncEnumerable ListGroupThreadsAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/threads" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + /// + /// Lists threads for a specific conversation. + /// GET /groups/{id}/conversations/{conversationId}/threads + /// + internal IAsyncEnumerable ListConversationThreadsAsync( + string groupId, + string conversationId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + var conv = Uri.EscapeDataString(conversationId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/conversations/{conv}/threads" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + /// + /// Lists posts in a thread. + /// GET /groups/{id}/threads/{threadId}/posts + /// (Also works as /groups/{id}/conversations/{conversationId}/threads/{threadId}/posts) + /// + internal IAsyncEnumerable ListThreadPostsAsync(string groupId, string threadId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + var thread = Uri.EscapeDataString(threadId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/threads/{thread}/posts" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + /// + /// Returns a post (message) as JSON stream. + /// GET /groups/{id}/threads/{threadId}/posts/{postId} + /// + internal Task GetThreadPostStreamAsync(string groupId, string threadId, string postId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + var thread = Uri.EscapeDataString(threadId); + var post = Uri.EscapeDataString(postId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/threads/{thread}/posts/{post}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + } + + internal GroupCalendarApiImpl GroupCalendarApi => new GroupCalendarApiImpl(_apiHelper); + + internal sealed class GroupCalendarApiImpl(APIHelper provider) + { + internal Task GetGroupCalendarStreamAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/calendar" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetGroupCalendarAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/calendar" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphItemAsync(url, ct); + } + + internal IAsyncEnumerable ListGroupEventsAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + + // Underlying objects (series masters + single instances) + var url = + $"{baseUrl}/v1.0/groups/{group}/events" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetGroupEventStreamAsync(string groupId, string eventId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + var ev = Uri.EscapeDataString(eventId); + + var select = + "id,subject,createdDateTime,lastModifiedDateTime,start,end,isAllDay,isCancelled," + + "location,organizer,attendees,recurrence,seriesMasterId,type," + + "body,bodyPreview,importance,sensitivity,categories,showAs,responseStatus,onlineMeeting,webLink"; + + var url = + $"{baseUrl}/v1.0/groups/{group}/events/{ev}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + } + + internal DriveApiImpl GroupDriveApi => new DriveApiImpl(_apiHelper); + + internal sealed class DriveApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListGroupDrivesAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/drives" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + } + + internal GroupPlannerApiImpl GroupPlannerApi => new GroupPlannerApiImpl(_apiHelper); + + internal sealed class GroupPlannerApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListGroupPlannerPlansAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups/{group}/planner/plans" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + } + + internal PlannerApiImpl PlannerApi => new PlannerApiImpl(_apiHelper); + + internal sealed class PlannerApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListPlannerBucketsAsync(string planId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var plan = Uri.EscapeDataString(planId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/planner/plans/{plan}/buckets" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListPlannerTasksAsync(string planId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var plan = Uri.EscapeDataString(planId); + + var select = GraphSelectBuilder.BuildSelect(); + + var url = + $"{baseUrl}/v1.0/planner/plans/{plan}/tasks" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetPlannerTaskDetailsStreamAsync(string taskId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var task = Uri.EscapeDataString(taskId); + + // Task details: description + checklist + references + previewType + var select = "id,description,checklist,references,previewType"; + var url = + $"{baseUrl}/v1.0/planner/tasks/{task}/details" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetPlannerProgressTaskBoardFormatStreamAsync(string taskId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var task = Uri.EscapeDataString(taskId); + + var select = "id,orderHint"; + var url = + $"{baseUrl}/v1.0/planner/tasks/{task}/progressTaskBoardFormat" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetPlannerAssignedToTaskBoardFormatStreamAsync(string taskId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var task = Uri.EscapeDataString(taskId); + + var select = "id,orderHintsByAssignee"; + var url = + $"{baseUrl}/v1.0/planner/tasks/{task}/assignedToTaskBoardFormat" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetPlannerBucketTaskBoardFormatStreamAsync(string taskId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var task = Uri.EscapeDataString(taskId); + + var select = "id,orderHint"; + var url = + $"{baseUrl}/v1.0/planner/tasks/{task}/bucketTaskBoardFormat" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + } + + internal GroupTeamsApiImpl GroupTeamsApi => new GroupTeamsApiImpl(_apiHelper); + + internal class GroupTeamsApiImpl(APIHelper provider) + { + public Task GetGroupTeamStreamAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var group = Uri.EscapeDataString(groupId); + + var select = + "id,displayName,description,createdDateTime,visibility,internalId,isArchived,webUrl"; + + var url = + $"{baseUrl}/v1.0/teams/{group}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetTeamMetadataStreamAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + + var select = "id,displayName,description,createdDateTime,visibility,isArchived,webUrl,internalId"; + var url = + $"{baseUrl}/v1.0/teams/{team}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal IAsyncEnumerable ListTeamMembersAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect( + // These will be returned but cannot be selected in the API call + exclude: [nameof(GraphTeamMember.Email), nameof(GraphTeamMember.UserId)] + ); + + var url = + $"{baseUrl}/v1.0/teams/{team}/members" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListTeamChannelsAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/teams/{team}/channels" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetTeamChannelStreamAsync(string groupId, string channelId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + var channel = Uri.EscapeDataString(channelId); + + var select = "id,displayName,description,membershipType,createdDateTime,isFavoriteByDefault,email,webUrl"; + var url = + $"{baseUrl}/v1.0/teams/{team}/channels/{channel}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal IAsyncEnumerable ListChannelMessagesAsync(string groupId, string channelId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + var channel = Uri.EscapeDataString(channelId); + + var url = + $"{baseUrl}/v1.0/teams/{team}/channels/{channel}/messages" + + $"?$top={OptionsHelper.CHATS_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListChannelMessageRepliesAsync( + string groupId, + string channelId, + string messageId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + var channel = Uri.EscapeDataString(channelId); + var msg = Uri.EscapeDataString(messageId); + + var url = + $"{baseUrl}/v1.0/teams/{team}/channels/{channel}/messages/{msg}/replies" + + $"?$top={OptionsHelper.CHATS_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetChannelMessageStreamAsync( + string groupId, + string channelId, + string messageId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + var channel = Uri.EscapeDataString(channelId); + var msg = Uri.EscapeDataString(messageId); + + var select = + "id,replyToId,createdDateTime,lastModifiedDateTime,deletedDateTime,subject,from,body," + + "attachments,mentions,reactions"; + + var url = + $"{baseUrl}/v1.0/teams/{team}/channels/{channel}/messages/{msg}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetChannelMessageReplyStreamAsync( + string groupId, + string channelId, + string messageId, + string replyId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + var channel = Uri.EscapeDataString(channelId); + var msg = Uri.EscapeDataString(messageId); + var reply = Uri.EscapeDataString(replyId); + + var select = + "id,replyToId,createdDateTime,lastModifiedDateTime,deletedDateTime,subject,from,body," + + "attachments,mentions,reactions"; + + var url = + $"{baseUrl}/v1.0/teams/{team}/channels/{channel}/messages/{msg}/replies/{reply}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal async Task IsGroupTeamAsync(string groupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var team = Uri.EscapeDataString(groupId); + + async Task requestFactory(CancellationToken ct) + { + var req = new HttpRequestMessage(HttpMethod.Get, new Uri($"{baseUrl}/v1.0/teams/{team}")); + req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct); + req.Headers.Accept.Clear(); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return req; + } + + using var resp = await provider.SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, ct).ConfigureAwait(false); + + if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) + return false; + + await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); + return true; + } + } +} diff --git a/proprietary/Office365/SourceProvider/SourceProvider.RootItems.cs b/proprietary/Office365/SourceProvider/SourceProvider.RootItems.cs new file mode 100644 index 000000000..e3e9830dd --- /dev/null +++ b/proprietary/Office365/SourceProvider/SourceProvider.RootItems.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +namespace Duplicati.Proprietary.Office365; + +partial class SourceProvider +{ + internal RootApiImpl RootApi => new RootApiImpl(_apiHelper); + + internal class RootApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListAllUsersAsync(CancellationToken ct) + { + // GET /users with paging via @odata.nextLink + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var select = GraphSelectBuilder.BuildSelect(); + + var url = + $"{baseUrl}/v1.0/users" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListAllGroupsAsync(CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/groups" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListUnifiedGroupsAsync(CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var filter = "groupTypes/any(c:c eq 'Unified')"; + var select = GraphSelectBuilder.BuildSelect(); + + var url = + $"{baseUrl}/v1.0/groups" + + $"?$filter={Uri.EscapeDataString(filter)}" + + $"&$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListAllSitesAsync(CancellationToken ct) + { + // Tenant-wide enumeration: /sites/getAllSites + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var select = GraphSelectBuilder.BuildSelect(); + + var url = + $"{baseUrl}/v1.0/sites/getAllSites" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + } +} diff --git a/proprietary/Office365/SourceProvider/SourceProvider.Site.cs b/proprietary/Office365/SourceProvider/SourceProvider.Site.cs new file mode 100644 index 000000000..1e123ce58 --- /dev/null +++ b/proprietary/Office365/SourceProvider/SourceProvider.Site.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +namespace Duplicati.Proprietary.Office365; + +partial class SourceProvider +{ + internal SiteApiImpl SiteApi => new SiteApiImpl(_apiHelper); + + internal class SiteApiImpl(APIHelper provider) + { + internal Task GetSiteMetadataStreamAsync(string siteId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var site = Uri.EscapeDataString(siteId); + + var select = "id,name,displayName,webUrl,createdDateTime,description,siteCollection,root"; + var url = + $"{baseUrl}/v1.0/sites/{site}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal IAsyncEnumerable ListSiteDrivesAsync(string siteId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var select = GraphSelectBuilder.BuildSelect(); + var site = Uri.EscapeDataString(siteId); + + var url = + $"{baseUrl}/v1.0/sites/{site}/drives" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + } +} diff --git a/proprietary/Office365/SourceProvider/SourceProvider.User.cs b/proprietary/Office365/SourceProvider/SourceProvider.User.cs new file mode 100644 index 000000000..b88b3d006 --- /dev/null +++ b/proprietary/Office365/SourceProvider/SourceProvider.User.cs @@ -0,0 +1,720 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.Office365; + +partial class SourceProvider +{ + internal UserEmailApiImpl UserEmailApi => new UserEmailApiImpl(_apiHelper); + + internal class UserEmailApiImpl(APIHelper provider) + { + /// + /// Lists all messages in the user's mailbox (across all folders) using paging via @odata.nextLink. + /// Requires Microsoft Graph application permission Mail.Read (or Mail.ReadBasic.All for limited fields). + /// + internal IAsyncEnumerable ListAllEmailsAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var select = GraphSelectBuilder.BuildSelect(); + + // Consider adding $filter=receivedDateTime ge ... for incremental runs. + var url = + $"{baseUrl}/v1.0/users/{user}/messages" + + $"?$select={Uri.EscapeDataString(select)}" + + "&$orderby=receivedDateTime asc" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + /// + /// Lists all messages in a specific mail folder. + /// + internal IAsyncEnumerable ListAllEmailsInFolderAsync( + 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(); + + var url = + $"{baseUrl}/v1.0/users/{user}/mailFolders/{folder}/messages" + + $"?$select={Uri.EscapeDataString(select)}" + + "&$orderby=receivedDateTime asc" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal async Task GetMailRootFolderAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var select = GraphSelectBuilder.BuildSelect(); + + var url = + $"{baseUrl}/v1.0/users/{user}/mailFolders/msgfolderroot" + + $"?$select={Uri.EscapeDataString(select)}"; + + var folder = await provider.GetGraphItemAsync(url, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(folder.Id)) + throw new UserInformationException("Failed to read root mail folder.", nameof(SourceProvider)); + + return folder; + } + + /// + /// Enumerates mail folders under a given folder. Use folderId = "msgfolderroot" to list top-level folders. + /// For a full tree, call this recursively for each returned folder. + /// + internal IAsyncEnumerable ListMailChildFoldersAsync(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(); + + var url = + $"{baseUrl}/v1.0/users/{user}/mailFolders/{folder}/childFolders" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetEmailContentStreamAsync(string userIdOrUpn, string messageId, CancellationToken ct) + { + // MIME (RFC 822) content of the message + // Graph returns message/rfc822 + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var msg = Uri.EscapeDataString(messageId); + + var url = $"{baseUrl}/v1.0/users/{user}/messages/{msg}/$value"; + return provider.GetGraphAsStreamAsync(url, "message/rfc822", ct); + } + + internal Task GetEmailMetadataStreamAsync(string userIdOrUpn, string messageId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var msg = Uri.EscapeDataString(messageId); + + // Mailbox-specific state that is NOT preserved in RFC822 + var select = + "id,parentFolderId,isRead,importance,categories,flag," + + "conversationId,conversationIndex,internetMessageId," + + "receivedDateTime,sentDateTime,createdDateTime,lastModifiedDateTime," + + "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); + } + } + + internal ContactsApiImpl ContactsApi => new ContactsApiImpl(_apiHelper); + internal class ContactsApiImpl(APIHelper provider) + { + /// + /// Enumerates all contacts in the user's default Contacts container. + /// For contact folders too, enumerate /contactFolders and then /contactFolders/{id}/contacts. + /// Requires Microsoft Graph application permission Contacts.Read (or Contacts.ReadWrite). + /// + internal IAsyncEnumerable ListAllContactsAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var select = GraphSelectBuilder.BuildSelect(); + + var url = + $"{baseUrl}/v1.0/users/{user}/contacts" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + } + + internal TodoApiImpl TodoApi => new TodoApiImpl(_apiHelper); + + internal class TodoApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListUserTaskListsAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/todo/lists" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListTaskListTasksAsync(string userIdOrUpn, string taskListId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var list = Uri.EscapeDataString(taskListId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListTaskChecklistItemsAsync( + string userIdOrUpn, + string taskListId, + string taskId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var list = Uri.EscapeDataString(taskListId); + var task = Uri.EscapeDataString(taskId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}/checklistItems" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListTaskLinkedResourcesAsync( + string userIdOrUpn, + string taskListId, + string taskId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var list = Uri.EscapeDataString(taskListId); + var task = Uri.EscapeDataString(taskId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}/linkedResources" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetStreamFromUrl(string url, CancellationToken ct) + { + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetTaskStreamAsync( + string userIdOrUpn, + string taskListId, + string taskId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var list = Uri.EscapeDataString(taskListId); + var task = Uri.EscapeDataString(taskId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetTaskChecklistItemStreamAsync( + string userIdOrUpn, + string taskListId, + string taskId, + string checklistItemId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var list = Uri.EscapeDataString(taskListId); + var task = Uri.EscapeDataString(taskId); + var item = Uri.EscapeDataString(checklistItemId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}/checklistItems/{item}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + internal Task GetTaskLinkedResourceStreamAsync( + string userIdOrUpn, + string taskListId, + string taskId, + string linkedResourceId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var list = Uri.EscapeDataString(taskListId); + var task = Uri.EscapeDataString(taskId); + var lr = Uri.EscapeDataString(linkedResourceId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/todo/lists/{list}/tasks/{task}/linkedResources/{lr}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + } + + internal OnenoteApiImpl OnenoteApi => new OnenoteApiImpl(_apiHelper); + + internal class OnenoteApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListUserNotebooksAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/onenote/notebooks" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListNotebookSectionGroupsAsync(string notebookId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var nb = Uri.EscapeDataString(notebookId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/onenote/notebooks/{nb}/sectionGroups" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListSectionGroupSectionGroupsAsync(string sectionGroupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var sg = Uri.EscapeDataString(sectionGroupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/onenote/sectionGroups/{sg}/sectionGroups" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + internal IAsyncEnumerable ListSectionGroupSectionsAsync(string sectionGroupId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var sg = Uri.EscapeDataString(sectionGroupId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/onenote/sectionGroups/{sg}/sections" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListSectionPagesAsync(string sectionId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var section = Uri.EscapeDataString(sectionId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/onenote/sections/{section}/pages" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetOnenotePageContentStreamAsync(string contentUrl, CancellationToken ct) + { + // 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); + } + } + + internal OneDriveApiImpl OneDriveApi => new OneDriveApiImpl(_apiHelper); + + internal class OneDriveApiImpl(APIHelper provider) + { + + internal async Task> ListDriveDeltaAsync( + string driveId, + string? deltaLink, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var drive = Uri.EscapeDataString(driveId); + + var select = GraphSelectBuilder.BuildSelect(); + var nextUrl = string.IsNullOrWhiteSpace(deltaLink) + ? $"{baseUrl}/v1.0/drives/{drive}/root/delta" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}" + : deltaLink; + + var items = new List(); + string? finalDeltaLink = null; + + while (!string.IsNullOrWhiteSpace(nextUrl)) + { + ct.ThrowIfCancellationRequested(); + + async Task requestFactory(CancellationToken ct) + { + var req = new HttpRequestMessage(HttpMethod.Get, new Uri(nextUrl)); + req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct); + req.Headers.Accept.Clear(); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return req; + } + + using var resp = await provider.SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, ct).ConfigureAwait(false); + await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); + + var page = await APIHelper.ParseResponseJson>(resp, ct).ConfigureAwait(false) + ?? new GraphDeltaPage(); + + if (page.Value.Count > 0) + items.AddRange(page.Value); + + if (!string.IsNullOrWhiteSpace(page.DeltaLink)) + finalDeltaLink = page.DeltaLink; + + nextUrl = page.NextLink; + } + + if (string.IsNullOrWhiteSpace(finalDeltaLink)) + throw new UserInformationException("Drive delta did not return an @odata.deltaLink.", nameof(SourceProvider)); + + return new GraphDeltaResult(items, finalDeltaLink); + } + + internal IAsyncEnumerable ListDriveRootChildrenAsync(string driveId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var drive = Uri.EscapeDataString(driveId); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/drives/{drive}/root/children" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListUserDrivesAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + + var select = GraphSelectBuilder.BuildSelect(); + var url = + $"{baseUrl}/v1.0/users/{user}/drives" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + bool? EvaluateStatusCode(HttpResponseMessage m) + { + return m.StatusCode switch + { + HttpStatusCode.ServiceUnavailable => false, + _ => null, + }; + } + + // Map 503 to success, as it means the user has no OneDrive provisioned. + return WithTryCatch( + provider.GetAllGraphItemsAsync(url, EvaluateStatusCode, ct), + ex => ex is HttpRequestException httpEx && httpEx.StatusCode == HttpStatusCode.ServiceUnavailable, + ct); + } + + private async IAsyncEnumerable WithTryCatch(IAsyncEnumerable source, Func? ignoreException, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var enumerator = source.GetAsyncEnumerator(cancellationToken); + + while (true) + { + try + { + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + yield break; + } + catch (Exception ex) + { + if (ignoreException?.Invoke(ex) != true) + throw; + + yield break; + } + + yield return enumerator.Current; + } + } + + internal async Task GetUserPrimaryDriveAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + + var select = "id,driveType,webUrl,createdDateTime,lastModifiedDateTime,owner"; + var url = + $"{baseUrl}/v1.0/users/{user}/drive" + + $"?$select={Uri.EscapeDataString(select)}"; + + var drive = await provider.GetGraphItemAsync(url, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(drive.Id)) + throw new UserInformationException("Failed to read user's primary drive.", nameof(SourceProvider)); + + return drive; + } + + internal IAsyncEnumerable ListDriveFolderChildrenAsync( + string driveId, + string folderItemId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var drive = Uri.EscapeDataString(driveId); + var folder = Uri.EscapeDataString(folderItemId); + + var select = "id,name,size,createdDateTime,lastModifiedDateTime,eTag,cTag,parentReference,deleted,folder,file,fileSystemInfo"; + var url = + $"{baseUrl}/v1.0/drives/{drive}/items/{folder}/children" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetDriveItemContentStreamAsync(string driveId, string itemId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var drive = Uri.EscapeDataString(driveId); + var item = Uri.EscapeDataString(itemId); + + // 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); + } + } + + internal CalendarApiImpl CalendarApi => new CalendarApiImpl(_apiHelper); + + internal class CalendarApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListUserCalendarGroupsAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + + var select = "id,name,classId,changeKey"; + var url = + $"{baseUrl}/v1.0/users/{user}/calendarGroups" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListUserCalendarsInCalendarGroupAsync( + string userIdOrUpn, + string calendarGroupId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var group = Uri.EscapeDataString(calendarGroupId); + + var select = "id,name,changeKey,color,isDefaultCalendar,isRemovable,canShare,canViewPrivateItems"; + var url = + $"{baseUrl}/v1.0/users/{user}/calendarGroups/{group}/calendars" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListCalendarEventsAsync(string userIdOrUpn, string calendarId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + var cal = Uri.EscapeDataString(calendarId); + + var select = + "id,subject,createdDateTime,lastModifiedDateTime,start,end,isAllDay,isCancelled," + + "location,organizer,attendees,recurrence,seriesMasterId,type"; + + var url = + $"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal Task GetCalendarEventStreamAsync( + 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,subject,createdDateTime,lastModifiedDateTime,start,end,isAllDay,isCancelled," + + "location,organizer,attendees,recurrence,seriesMasterId,type," + + "body,bodyPreview,importance,sensitivity,categories,showAs,responseStatus,onlineMeeting,webLink"; + + var url = + $"{baseUrl}/v1.0/users/{user}/calendars/{cal}/events/{ev}" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + } + + internal UserPlannerApiImpl UserPlannerApi => new UserPlannerApiImpl(_apiHelper); + + internal class UserPlannerApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListUserAssignedPlannerTasksAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + + var select = + "id,planId,bucketId,title,createdDateTime,startDateTime,dueDateTime,completedDateTime," + + "percentComplete,hasDescription,assignments"; + + var url = + $"{baseUrl}/v1.0/users/{user}/planner/tasks" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + } + + internal ChatApiImpl ChatApi => new ChatApiImpl(_apiHelper); + internal class ChatApiImpl(APIHelper provider) + { + internal IAsyncEnumerable ListUserChatsAsync(string userIdOrUpn, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + + var select = "id,chatType,topic,createdDateTime,lastUpdatedDateTime,webUrl"; + var url = + $"{baseUrl}/v1.0/users/{user}/chats" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={OptionsHelper.CHATS_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListChatMembersAsync(string chatId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var chat = Uri.EscapeDataString(chatId); + + var select = "id,displayName,roles,visibleHistoryStartDateTime,userId"; + var url = + $"{baseUrl}/v1.0/chats/{chat}/members" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + internal IAsyncEnumerable ListChatMessagesAsync(string chatId, CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var chat = Uri.EscapeDataString(chatId); + + var select = + "id,createdDateTime,lastModifiedDateTime,deletedDateTime,subject,body,from," + + "attachments,mentions,reactions,replyToId"; + + var url = + $"{baseUrl}/v1.0/chats/{chat}/messages" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + + public IAsyncEnumerable ListChatHostedContentsAsync( + string chatId, + string messageId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var chat = Uri.EscapeDataString(chatId); + var msg = Uri.EscapeDataString(messageId); + + var select = "id,contentType"; + var url = + $"{baseUrl}/v1.0/chats/{chat}/messages/{msg}/hostedContents" + + $"?$select={Uri.EscapeDataString(select)}" + + $"&$top={GENERAL_PAGE_SIZE}"; + + return provider.GetAllGraphItemsAsync(url, ct); + } + + public Task GetChatHostedContentValueStreamAsync( + string chatId, + string messageId, + string hostedContentId, + CancellationToken ct) + { + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var chat = Uri.EscapeDataString(chatId); + var msg = Uri.EscapeDataString(messageId); + var hc = Uri.EscapeDataString(hostedContentId); + + var url = + $"{baseUrl}/v1.0/chats/{chat}/messages/{msg}/hostedContents/{hc}/$value"; + + return provider.GetGraphAsStreamAsync(url, "application/octet-stream", ct); + } + } +} diff --git a/proprietary/Office365/SourceProvider/SourceProvider.UserProfile.cs b/proprietary/Office365/SourceProvider/SourceProvider.UserProfile.cs new file mode 100644 index 000000000..e6e411c48 --- /dev/null +++ b/proprietary/Office365/SourceProvider/SourceProvider.UserProfile.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +namespace Duplicati.Proprietary.Office365; + +partial class SourceProvider +{ + internal UserProfileImpl UserProfileApi => new UserProfileImpl(_apiHelper); + + internal class UserProfileImpl(APIHelper provider) + { + // /users/{id} + public Task GetUserObjectStreamAsync(string userIdOrUpn, CancellationToken ct) + { + 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); + } + + // /users/{id}/photo/$value + public Task 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); + } + + // /users/{id}/licenseDetails + public Task GetUserLicenseDetailsStreamAsync(string userIdOrUpn, CancellationToken ct) + { + var url = $"{provider.GraphBaseUrl.TrimEnd('/')}/v1.0/users/{Uri.EscapeDataString(userIdOrUpn)}/licenseDetails"; + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + // /users/{id}/manager + public Task GetUserManagerStreamAsync(string userIdOrUpn, CancellationToken ct) + { + var url = $"{provider.GraphBaseUrl.TrimEnd('/')}/v1.0/users/{Uri.EscapeDataString(userIdOrUpn)}/manager"; + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + + // /users/{id}/joinedTeams + public Task GetUserTeamMembershipStreamAsync(string userIdOrUpn, CancellationToken ct) + { + // Lists the Teams the user has joined. + // Note: This returns a collection of Team objects (membership context), not the full Team configuration. + var baseUrl = provider.GraphBaseUrl.TrimEnd('/'); + var user = Uri.EscapeDataString(userIdOrUpn); + + var select = "id,displayName,description"; + var url = + $"{baseUrl}/v1.0/users/{user}/joinedTeams" + + $"?$select={Uri.EscapeDataString(select)}"; + + return provider.GetGraphAsStreamAsync(url, "application/json", ct); + } + } +} diff --git a/proprietary/Office365/SourceProvider/SourceProvider.cs b/proprietary/Office365/SourceProvider/SourceProvider.cs new file mode 100644 index 000000000..15a0885d0 --- /dev/null +++ b/proprietary/Office365/SourceProvider/SourceProvider.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Interface; +using Duplicati.Proprietary.Office365.SourceItems; + +namespace Duplicati.Proprietary.Office365; + +public sealed partial class SourceProvider : ISourceProviderModule, IDisposable +{ + private const int GENERAL_PAGE_SIZE = OptionsHelper.GENERAL_PAGE_SIZE; + + private readonly APIHelper _apiHelper; + private readonly string _mountPoint; + + private static readonly Office365UserType[] DELEGATED_USER_TYPES = + [ + Office365UserType.Tasks, + Office365UserType.Notes + ]; + + private static readonly Office365GroupType[] DELEGATED_GROUP_TYPES = + [ + Office365GroupType.Calendar + ]; + + private static readonly Office365MetaType[] DEFAULT_INCLUDED_ROOT_TYPES = + Enum.GetValues() + .ToArray(); + + private static readonly Office365GroupType[] DEFAULT_INCLUDED_GROUP_TYPES = + Enum.GetValues() + .Except(DELEGATED_GROUP_TYPES) + .ToArray(); + + private static readonly Office365UserType[] DEFAULT_INCLUDED_USER_TYPES = + Enum.GetValues() + .Except(DELEGATED_USER_TYPES) + .ToArray(); + + public SourceProvider() + { + _apiHelper = null!; + _mountPoint = null!; + } + + public SourceProvider(string url, string mountPoint, Dictionary options) + { + if (!Library.Utility.Utility.ParseBoolOption(options, "store-metadata-content-in-database")) + throw new UserInformationException(Strings.MetadataStorageNotEnabled, "DatabaseMetadataStorageNotEnabled"); + + _mountPoint = mountPoint; + var parsedOptions = OptionsHelper.ParseAndValidateOptions(url, options); + _apiHelper = APIHelper.Create( + tenantId: parsedOptions.TenantId, + authOptions: parsedOptions.AuthOptions, + graphBaseUrl: parsedOptions.GraphBaseUrl + ); + } + + public string Key => OptionsHelper.ModuleKey; + + public string DisplayName => Strings.ProviderDisplayName; + + public string Description => Strings.ProviderDescription; + + public IList SupportedCommands => OptionsHelper.SupportedCommands; + + public string MountedPath => _mountPoint; + + public void Dispose() + { + _apiHelper?.Dispose(); + } + + + public Task Initialize(CancellationToken cancellationToken) + => _apiHelper.AcquireAccessTokenAsync(true, cancellationToken); + + public async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new RootSourceEntry(this, _mountPoint); + } + + public Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + { + // TODO: Implement this + throw new NotImplementedException(); + } + + internal IEnumerable IncludedRootTypes => + DEFAULT_INCLUDED_ROOT_TYPES; + + internal IEnumerable IncludedUserTypes => + DEFAULT_INCLUDED_USER_TYPES; + + + internal IEnumerable IncludedGroupTypes => + DEFAULT_INCLUDED_GROUP_TYPES; +} diff --git a/proprietary/Office365/Strings.cs b/proprietary/Office365/Strings.cs new file mode 100644 index 000000000..5e07b0a2d --- /dev/null +++ b/proprietary/Office365/Strings.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Localization.Short; + +namespace Duplicati.Proprietary.Office365; + +internal static class Strings +{ + public static string ProviderDisplayName => LC.L("Microsoft 365 Provider"); + + public static string ProviderDescription => LC.L( + "Expose Microsoft 365 content through a virtual folder structure organized by content type and internal structure."); + + public static string WebModuleDisplayName => LC.L("Microsoft 365 Web Module"); + public static string WebModuleDescription => LC.L("Provides a web API for managing Microsoft 365 backups."); + + public static string MissingAccessToken => LC.L("An OAuth access token is required for this workspace backend."); + public static string InvalidRestoreTargetType(string? type) => LC.L($"Invalid restore target type: {type}"); + + public static string IncorrectTargetUrlFormat(RestoreTargetType targetType, int expected, string[] parts) => LC.L( + $"The target URL does not have the correct number of parts for restore target type {targetType}: expected {expected}, got {parts.Length}"); + + public static string MetadataStorageNotEnabled => LC.L( + "Storing metadata content in the database must be enabled to use the Microsoft 365 source provider."); + + public static string MissingTenantId => LC.L("A tenant ID must be supplied to query the Office 365 Management API."); + + public static string OfficeTenantOptionShort => LC.L("Azure AD tenant identifier."); + + public static string OfficeTenantOptionLong => LC.L("The tenant GUID used when calling the Office 365 Management Activity APIs."); + + public static string OfficeClientOptionShort => LC.L("Azure application (client) ID."); + + public static string OfficeClientOptionLong => LC.L("Client identifier used for OAuth2 client credential flow against the Office 365 Management API."); + + public static string OfficeSecretOptionShort => LC.L("Azure application client secret."); + + public static string OfficeSecretOptionLong => LC.L("Client secret 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."); + + public static string WebModuleOperationShort => LC.L("The operation to perform."); + public static string WebModuleOperationLong => LC.L("The operation that the web module should perform."); + + public static string WebModuleURLShort => LC.L("The Microsoft 365 URL."); + public static string WebModuleURLLong => LC.L("The Microsoft 365 URL to the destination."); + + public static string WebModulePathShort => LC.L("The path within the Microsoft 365 destination."); + public static string WebModulePathLong => LC.L("The path within the Microsoft 365 destination to list."); + +} diff --git a/proprietary/Office365/WebModule/WebModule.cs b/proprietary/Office365/WebModule/WebModule.cs new file mode 100644 index 000000000..4652d0e64 --- /dev/null +++ b/proprietary/Office365/WebModule/WebModule.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; +using Duplicati.Proprietary.Office365.SourceItems; + +namespace Duplicati.Proprietary.Office365; + +public class WebModule : IWebModule +{ + public string Key => OptionsHelper.ModuleKey; + + public string DisplayName => Strings.WebModuleDisplayName; + + public string Description => Strings.WebModuleDescription; + + public enum Operation + { + ListDestination, + ListDestinationRestoreTargets + } + + private static readonly Operation DEFAULT_OPERATION = Operation.ListDestination; + private const string KEY_OPERATION = "operation"; + private const string KEY_URL = "url"; + private const string KEY_PATH = "path"; + + private static readonly IReadOnlySet RESTORE_TARGET_TYPES = new HashSet + { + SourceItemType.User.ToString(), + SourceItemType.UserMailbox.ToString(), + SourceItemType.UserMailboxFolder.ToString(), + SourceItemType.TaskList.ToString(), + SourceItemType.Planner.ToString(), + SourceItemType.PlannerBucket.ToString(), + SourceItemType.Group.ToString(), + SourceItemType.Drive.ToString(), + SourceItemType.DriveFolder.ToString(), + SourceItemType.Calendar.ToString(), + SourceItemType.CalendarGroup.ToString(), + SourceItemType.Chat.ToString(), + SourceItemType.ChatMessage.ToString(), + SourceItemType.Notebook.ToString(), + SourceItemType.Site.ToString(), + }; + + private static readonly IReadOnlySet RESTORE_TARGET_LEAF_TYPES = new HashSet + { + SourceItemType.TaskListTask.ToString(), + SourceItemType.PlannerBucket.ToString(), + SourceItemType.DriveFolder.ToString(), + SourceItemType.Calendar.ToString(), + SourceItemType.ChatMessage.ToString(), + SourceItemType.Notebook.ToString(), + }; + + private static readonly IReadOnlySet RESTORE_TARGET_NONSELELCTABLE_FOLDERS = new HashSet + { + SourceItemType.MetaRoot.ToString(), + SourceItemType.MetaRootUsers.ToString(), + SourceItemType.MetaRootGroups.ToString(), + SourceItemType.MetaRootSites.ToString(), + }; + + + public IList SupportedCommands => [ + new CommandLineArgument(KEY_OPERATION, CommandLineArgument.ArgumentType.Enumeration, Strings.WebModuleOperationShort, Strings.WebModuleOperationLong, DEFAULT_OPERATION.ToString(), null, Enum.GetNames(typeof(Operation))), + new CommandLineArgument(KEY_URL, CommandLineArgument.ArgumentType.String, Strings.WebModuleURLShort, Strings.WebModuleURLLong), + new CommandLineArgument(KEY_PATH, CommandLineArgument.ArgumentType.String, Strings.WebModulePathShort, Strings.WebModulePathLong) + ]; + + public async Task> Execute(IDictionary options, CancellationToken cancellationToken) + { + var op = Utility.ParseEnumOption(options.AsReadOnly(), KEY_OPERATION, DEFAULT_OPERATION); + options.TryGetValue(KEY_URL, out var url); + options.TryGetValue(KEY_PATH, out var path); + + if (op != Operation.ListDestination && op != Operation.ListDestinationRestoreTargets) + throw new UserInformationException($"Unsupported operation: {op}", "UnsupportedOperation"); + + if (string.IsNullOrWhiteSpace(url)) + throw new UserInformationException($"Missing URL", "MissingURL"); + + var forwardoptions = new Dictionary() + { + { "store-metadata-content-in-database", "true" } + }; + + var uri = new Library.Utility.Uri(url); + foreach (var key in uri.QueryParameters.AllKeys) + forwardoptions[key!] = uri.QueryParameters[key]; + + using var client = new SourceProvider(url, "", forwardoptions); + var entries = client.Enumerate(cancellationToken); + path = path?.TrimEnd(Path.DirectorySeparatorChar); + + var pathsegments = path?.Split(Path.DirectorySeparatorChar) ?? Array.Empty(); + if (pathsegments.Length == 0) + pathsegments = [""]; + + foreach (var item in pathsegments) + { + var found = false; + await foreach (var entry in entries.WithCancellation(cancellationToken)) + { + var name = entry.Path.TrimEnd(Path.DirectorySeparatorChar) + .Split(Path.DirectorySeparatorChar) + .Last(); + if (name.Equals(item, StringComparison.OrdinalIgnoreCase)) + { + if (!entry.IsFolder) + throw new UserInformationException($"Path segment '{item}' is not a folder", "PathSegmentNotFolder"); + + entries = entry.Enumerate(cancellationToken); + found = true; + break; + } + } + + if (!found) + throw new UserInformationException($"Path segment '{item}' not found", "PathSegmentNotFound"); + } + + var result = new Dictionary(); + await foreach (var entry in entries.WithCancellation(cancellationToken)) + { + if (op == Operation.ListDestinationRestoreTargets) + { + if (!entry.IsFolder) + continue; + } + + var targetpath = entry.Path; + var metadata = new Dictionary(); + try + { + if (!entry.IsMetaEntry) + metadata = await entry.GetMinorMetadata(cancellationToken); + } + catch + { + // ignore metadata errors + } + + // For restore targets, treat leafs as non-folders + if (op == Operation.ListDestinationRestoreTargets) + { + var type = metadata.GetValueOrDefault("o365:Type"); + if (type != null) + { + if (RESTORE_TARGET_LEAF_TYPES.Contains(type)) + targetpath = targetpath.TrimEnd(Path.DirectorySeparatorChar); + + if (!RESTORE_TARGET_NONSELELCTABLE_FOLDERS.Contains(type) && !RESTORE_TARGET_TYPES.Contains(type)) + continue; + } + } + + result[targetpath] = JsonSerializer.Serialize(metadata); + } + + return result; + + } + + public IDictionary> GetLookups() + => new Dictionary>(); +}