mirror of
https://github.com/duplicati/duplicati.git
synced 2026-05-06 07:16:38 -04:00
a4b33e5d16
Added a free tier for the Office 365 module.
292 lines
12 KiB
C#
292 lines
12 KiB
C#
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
|
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using Duplicati.Library.Logging;
|
|
using Duplicati.Proprietary.Office365.SourceItems;
|
|
|
|
namespace Duplicati.Proprietary.Office365;
|
|
|
|
partial class RestoreProvider
|
|
{
|
|
internal ContactApiImpl ContactApi => new ContactApiImpl(_apiHelper);
|
|
private ContactRestoreHelper? _contactRestoreHelper = null;
|
|
internal ContactRestoreHelper ContactRestore => _contactRestoreHelper ??= new ContactRestoreHelper(this);
|
|
|
|
internal class ContactApiImpl(APIHelper provider)
|
|
{
|
|
public async Task<GraphContact> RestoreContactToFolderAsync(
|
|
string userId,
|
|
string targetFolderId,
|
|
Stream contactJsonStream,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var user = Uri.EscapeDataString(userId);
|
|
var folder = Uri.EscapeDataString(targetFolderId);
|
|
|
|
// POST /users/{id}/contactFolders/{id}/contacts
|
|
var url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{folder}/contacts";
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
|
contactJsonStream.Position = 0;
|
|
req.Content = new StreamContent(contactJsonStream);
|
|
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
|
|
|
using var respStream = await resp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
var created = await JsonSerializer.DeserializeAsync<GraphContact>(respStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
|
|
if (created is null || string.IsNullOrWhiteSpace(created.Id))
|
|
throw new InvalidOperationException("Graph did not return the created contact id.");
|
|
|
|
return created;
|
|
}
|
|
|
|
public async Task<GraphContactFolder> CreateContactFolderAsync(
|
|
string userId,
|
|
string displayName,
|
|
string? parentFolderId,
|
|
CancellationToken ct)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var user = Uri.EscapeDataString(userId);
|
|
|
|
string url;
|
|
if (string.IsNullOrWhiteSpace(parentFolderId))
|
|
{
|
|
// POST /users/{id}/contactFolders
|
|
url = $"{baseUrl}/v1.0/users/{user}/contactFolders";
|
|
}
|
|
else
|
|
{
|
|
// POST /users/{id}/contactFolders/{id}/childFolders
|
|
var parent = Uri.EscapeDataString(parentFolderId);
|
|
url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{parent}/childFolders";
|
|
}
|
|
|
|
var body = new { displayName = displayName };
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
|
=> new HttpRequestMessage(HttpMethod.Post, new Uri(url))
|
|
{
|
|
Headers =
|
|
{
|
|
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
|
|
},
|
|
Content = JsonContent.Create(body)
|
|
};
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
ct).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
|
|
|
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
|
var created = await JsonSerializer.DeserializeAsync<GraphContactFolder>(respStream, cancellationToken: ct).ConfigureAwait(false);
|
|
|
|
if (created is null || string.IsNullOrWhiteSpace(created.Id))
|
|
throw new InvalidOperationException("Graph did not return the created contact folder id.");
|
|
|
|
return created;
|
|
}
|
|
|
|
public async Task RestoreContactPhotoAsync(
|
|
string userId,
|
|
string contactId,
|
|
string? contactFolderId,
|
|
Stream photoStream,
|
|
CancellationToken ct)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var user = Uri.EscapeDataString(userId);
|
|
var contact = Uri.EscapeDataString(contactId);
|
|
|
|
string url;
|
|
if (string.IsNullOrWhiteSpace(contactFolderId))
|
|
{
|
|
// PUT /users/{id}/contacts/{id}/photo/$value
|
|
url = $"{baseUrl}/v1.0/users/{user}/contacts/{contact}/photo/$value";
|
|
}
|
|
else
|
|
{
|
|
// PUT /users/{id}/contactFolders/{id}/contacts/{id}/photo/$value
|
|
var folder = Uri.EscapeDataString(contactFolderId);
|
|
url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{folder}/contacts/{contact}/photo/$value";
|
|
}
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
|
photoStream.Position = 0;
|
|
req.Content = new StreamContent(photoStream);
|
|
req.Content.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); // Assuming JPEG, but Graph usually detects or accepts generic
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
ct).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<GraphContactFolder?> GetContactFolderByNameAsync(
|
|
string userId,
|
|
string displayName,
|
|
string? parentFolderId,
|
|
CancellationToken ct)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var user = Uri.EscapeDataString(userId);
|
|
var filterName = displayName.Replace("'", "''");
|
|
|
|
string url;
|
|
if (string.IsNullOrWhiteSpace(parentFolderId))
|
|
{
|
|
url = $"{baseUrl}/v1.0/users/{user}/contactFolders?$filter=displayName eq '{filterName}'&$top=1";
|
|
}
|
|
else
|
|
{
|
|
var parent = Uri.EscapeDataString(parentFolderId);
|
|
url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{parent}/childFolders?$filter=displayName eq '{filterName}'&$top=1";
|
|
}
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
ct).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
|
|
|
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
|
var result = await JsonSerializer.DeserializeAsync<GraphPage<GraphContactFolder>>(stream, cancellationToken: ct).ConfigureAwait(false);
|
|
|
|
return result?.Value?.FirstOrDefault();
|
|
}
|
|
|
|
public async Task<List<GraphContact>> FindContactsAsync(
|
|
string userId,
|
|
string folderId,
|
|
string? email,
|
|
string? displayName,
|
|
CancellationToken ct)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var user = Uri.EscapeDataString(userId);
|
|
var folder = Uri.EscapeDataString(folderId);
|
|
|
|
var filters = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(email))
|
|
{
|
|
filters.Add($"emailAddresses/any(a:a/address eq '{email}')");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(displayName))
|
|
{
|
|
filters.Add($"displayName eq '{displayName.Replace("'", "''")}'");
|
|
}
|
|
|
|
if (filters.Count == 0) return [];
|
|
|
|
var filter = string.Join(" or ", filters);
|
|
var url = $"{baseUrl}/v1.0/users/{user}/contactFolders/{folder}/contacts?$filter={filter}";
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
ct).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
|
|
|
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
|
var page = await JsonSerializer.DeserializeAsync<GraphPage<GraphContact>>(stream, cancellationToken: ct).ConfigureAwait(false);
|
|
return page?.Value ?? [];
|
|
}
|
|
}
|
|
|
|
internal class ContactRestoreHelper(RestoreProvider Provider)
|
|
{
|
|
private string? _targetUserId = null;
|
|
private string? _targetFolderId = null;
|
|
private bool _hasLoadedTargetInfo = false;
|
|
|
|
public async Task<(string? UserId, string? FolderId)> GetUserIdAndContactFolderTarget(CancellationToken cancel)
|
|
{
|
|
if (_hasLoadedTargetInfo)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetFolderId))
|
|
return (null, null);
|
|
return (_targetUserId!, _targetFolderId!);
|
|
}
|
|
|
|
var target = Provider.RestoreTarget;
|
|
if (target == null)
|
|
throw new InvalidOperationException("Restore target is not set");
|
|
|
|
if (target.Type == SourceItemType.User)
|
|
{
|
|
_targetUserId = target.Metadata["o365:Id"]!;
|
|
_targetFolderId = await GetDefaultRestoreTargetContactFolder(_targetUserId, cancel);
|
|
}
|
|
else if (target.Type == SourceItemType.UserContacts)
|
|
{
|
|
_targetUserId = target.Path.TrimStart(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar).Skip(1).FirstOrDefault();
|
|
|
|
if (_targetUserId != null)
|
|
_targetFolderId = await GetDefaultRestoreTargetContactFolder(_targetUserId, cancel);
|
|
}
|
|
else
|
|
{
|
|
Log.WriteWarningMessage(LOGTAG, "RestoreContactsInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring contacts.");
|
|
}
|
|
|
|
_hasLoadedTargetInfo = true;
|
|
|
|
if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetFolderId))
|
|
{
|
|
Log.WriteWarningMessage(LOGTAG, "RestoreContactsMissingIds", null, $"Missing target userId or folderId for restoring contacts.");
|
|
return (null, null);
|
|
}
|
|
|
|
return (_targetUserId, _targetFolderId);
|
|
}
|
|
|
|
private async Task<string> GetDefaultRestoreTargetContactFolder(string userId, CancellationToken cancel)
|
|
{
|
|
const string RESTORED_FOLDER_NAME = "Restored";
|
|
|
|
var existing = await Provider.ContactApi.GetContactFolderByNameAsync(userId, RESTORED_FOLDER_NAME, null, cancel);
|
|
if (existing != null)
|
|
return existing.Id;
|
|
|
|
var created = await Provider.ContactApi.CreateContactFolderAsync(userId, RESTORED_FOLDER_NAME, null, cancel);
|
|
return created.Id;
|
|
}
|
|
}
|
|
}
|