Files
duplicati/proprietary/Office365/RestoreProvider/RestoreProvider.Contact.cs
T
Kenneth Skovhede a4b33e5d16 Implemented Restore feature and refactored a bit.
Added a free tier for the Office 365 module.
2026-01-20 10:36:37 +01:00

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;
}
}
}