493 lines
20 KiB
C#
493 lines
20 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
|
|
{
|
|
private DriveRestoreHelper? _driveRestoreHelper = null;
|
|
internal DriveRestoreHelper DriveRestore => _driveRestoreHelper ??= new DriveRestoreHelper(this);
|
|
|
|
internal class DriveRestoreHelper(RestoreProvider Provider)
|
|
{
|
|
private string? _cachedDefaultDriveId;
|
|
private string? _cachedDefaultFolderId;
|
|
private bool _defaultDriveIdChecked;
|
|
|
|
public async Task<(string? DriveId, string? FolderId)> GetDefaultDriveAndFolder(CancellationToken cancel)
|
|
{
|
|
if (_defaultDriveIdChecked)
|
|
return (_cachedDefaultDriveId, _cachedDefaultFolderId);
|
|
|
|
_defaultDriveIdChecked = true;
|
|
|
|
var target = Provider.RestoreTarget;
|
|
if (target == null)
|
|
return (null, null);
|
|
|
|
string? driveId = null;
|
|
|
|
if (target.Type == SourceItemType.Drive)
|
|
{
|
|
driveId = target.Metadata.GetValueOrDefault("o365:Id");
|
|
}
|
|
else
|
|
{
|
|
string? targetUserId = null;
|
|
string? targetSiteId = null;
|
|
|
|
if (target.Type == SourceItemType.User)
|
|
{
|
|
targetUserId = target.Metadata.GetValueOrDefault("o365:Id");
|
|
}
|
|
else if (target.Type == SourceItemType.Site)
|
|
{
|
|
targetSiteId = target.Metadata.GetValueOrDefault("o365:Id");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(targetUserId))
|
|
{
|
|
try
|
|
{
|
|
var primaryDrive = await Provider.SourceProvider.OneDriveApi.GetUserPrimaryDriveAsync(targetUserId, cancel);
|
|
driveId = primaryDrive.Id;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "GetDefaultDriveIdFailed", ex, $"Failed to get user primary drive");
|
|
}
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(targetSiteId))
|
|
{
|
|
try
|
|
{
|
|
var primaryDrive = await Provider.SourceProvider.SiteApi.GetSitePrimaryDriveAsync(targetSiteId, cancel);
|
|
driveId = primaryDrive.Id;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "GetDefaultDriveIdFailed", ex, $"Failed to get site primary drive");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (driveId != null)
|
|
{
|
|
_cachedDefaultDriveId = driveId;
|
|
|
|
// Create "Restored" folder
|
|
try
|
|
{
|
|
var restoredFolder = await Provider.DriveApi.GetDriveItemAsync(driveId, "root", "Restored", cancel);
|
|
if (restoredFolder == null)
|
|
{
|
|
restoredFolder = await Provider.DriveApi.CreateDriveFolderAsync(driveId, "root", "Restored", cancel);
|
|
}
|
|
_cachedDefaultFolderId = restoredFolder.Id;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteWarningMessage(LOGTAG, "CreateRestoredFolderFailed", ex, $"Failed to create 'Restored' folder in drive {driveId}");
|
|
// Fallback to root
|
|
_cachedDefaultFolderId = "root";
|
|
}
|
|
}
|
|
|
|
return (_cachedDefaultDriveId, _cachedDefaultFolderId);
|
|
}
|
|
}
|
|
|
|
internal DriveApiImpl DriveApi => new DriveApiImpl(_apiHelper);
|
|
|
|
internal class DriveApiImpl(APIHelper provider)
|
|
{
|
|
public async Task<GraphDriveItem> CreateDriveFolderAsync(
|
|
string driveId,
|
|
string parentItemId,
|
|
string name,
|
|
CancellationToken ct)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var drive = Uri.EscapeDataString(driveId);
|
|
var parent = Uri.EscapeDataString(parentItemId);
|
|
|
|
// POST /drives/{drive-id}/items/{parent-id}/children
|
|
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{parent}/children";
|
|
|
|
var body = new
|
|
{
|
|
name = name,
|
|
folder = new { },
|
|
@microsoft_graph_conflictBehavior = "rename"
|
|
};
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
|
req.Content = JsonContent.Create(body);
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
ct).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
|
|
|
await using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
|
return await JsonSerializer.DeserializeAsync<GraphDriveItem>(respStream, cancellationToken: ct).ConfigureAwait(false)
|
|
?? throw new InvalidOperationException("Graph did not return the created folder item.");
|
|
}
|
|
|
|
public async Task<GraphDriveItem> RestoreDriveItemToFolderAsync(
|
|
string driveId,
|
|
string targetFolderItemId,
|
|
string fileName,
|
|
Stream contentStream,
|
|
string? contentType,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Use upload session for files larger than 4MB
|
|
if (contentStream.Length > 4 * 1024 * 1024)
|
|
{
|
|
return await RestoreLargeDriveItemToFolderAsync(driveId, targetFolderItemId, fileName, contentStream, contentType, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var drive = Uri.EscapeDataString(driveId);
|
|
var folder = Uri.EscapeDataString(targetFolderItemId);
|
|
var name = Uri.EscapeDataString(fileName);
|
|
|
|
// PUT /drives/{drive-id}/items/{parent-id}:/{filename}:/content
|
|
var url =
|
|
$"{baseUrl}/v1.0/drives/{drive}/items/{folder}:/{name}:/content";
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
|
|
|
contentStream.Position = 0;
|
|
req.Content = new StreamContent(contentStream);
|
|
req.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType ?? "application/octet-stream");
|
|
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
|
|
|
await using var respStream = await resp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
return await JsonSerializer.DeserializeAsync<GraphDriveItem>(respStream, cancellationToken: cancellationToken).ConfigureAwait(false)
|
|
?? throw new InvalidOperationException("Graph did not return the restored item.");
|
|
}
|
|
|
|
private async Task<GraphDriveItem> RestoreLargeDriveItemToFolderAsync(
|
|
string driveId,
|
|
string targetFolderItemId,
|
|
string fileName,
|
|
Stream contentStream,
|
|
string? contentType,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var drive = Uri.EscapeDataString(driveId);
|
|
var folder = Uri.EscapeDataString(targetFolderItemId);
|
|
var name = Uri.EscapeDataString(fileName);
|
|
|
|
// 1. Create upload session
|
|
// POST /drives/{drive-id}/items/{parent-id}:/{filename}:/createUploadSession
|
|
var createSessionUrl = $"{baseUrl}/v1.0/drives/{drive}/items/{folder}:/{name}:/createUploadSession";
|
|
|
|
var sessionBody = new
|
|
{
|
|
item = new
|
|
{
|
|
@microsoft_graph_conflictBehavior = "rename",
|
|
name = name
|
|
}
|
|
};
|
|
|
|
async Task<HttpRequestMessage> createSessionRequestFactory(CancellationToken ct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(createSessionUrl));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
|
req.Content = JsonContent.Create(sessionBody);
|
|
return req;
|
|
}
|
|
|
|
using var sessionResp = await provider.SendWithRetryShortAsync(
|
|
createSessionRequestFactory,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(sessionResp, cancellationToken).ConfigureAwait(false);
|
|
|
|
var uploadSession = await sessionResp.Content.ReadFromJsonAsync<GraphUploadSession>(cancellationToken: cancellationToken).ConfigureAwait(false)
|
|
?? throw new InvalidOperationException("Failed to create upload session.");
|
|
|
|
// 2. Upload fragments
|
|
var uploadUrl = uploadSession.uploadUrl;
|
|
var totalSize = contentStream.Length;
|
|
// 320 KiB is the minimum fragment size, we use a multiple of it (e.g. 5MB approx)
|
|
// 320 * 1024 = 327680 bytes. 16 * 327680 = 5242880 bytes (5MB)
|
|
var fragmentSize = 320 * 1024 * 16;
|
|
var buffer = new byte[fragmentSize];
|
|
long position = 0;
|
|
|
|
contentStream.Position = 0;
|
|
|
|
GraphDriveItem? resultItem = null;
|
|
|
|
while (position < totalSize)
|
|
{
|
|
var bytesRead = await contentStream.ReadAsync(buffer, 0, fragmentSize, cancellationToken).ConfigureAwait(false);
|
|
var isLast = position + bytesRead >= totalSize;
|
|
|
|
async Task<HttpRequestMessage> uploadFragmentRequestFactory(CancellationToken ct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Put, new Uri(uploadUrl));
|
|
// No auth header needed for upload session URL
|
|
req.Content = new ByteArrayContent(buffer, 0, bytesRead);
|
|
req.Content.Headers.ContentRange = new ContentRangeHeaderValue(position, position + bytesRead - 1, totalSize);
|
|
req.Content.Headers.ContentLength = bytesRead;
|
|
|
|
return req;
|
|
}
|
|
|
|
using var uploadResp = await provider.SendWithRetryShortAsync(
|
|
uploadFragmentRequestFactory,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!uploadResp.IsSuccessStatusCode)
|
|
{
|
|
// If we get an error, we should probably check if the session is still valid or if we can retry.
|
|
// For now, we rely on SendWithRetryAsync for transient errors, but if it fails here, it's likely fatal or needs session recreation.
|
|
// We'll throw and let the caller handle it.
|
|
var error = await uploadResp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
throw new InvalidOperationException($"Failed to upload fragment: {uploadResp.StatusCode} {error}");
|
|
}
|
|
|
|
if (isLast)
|
|
{
|
|
await using var respStream = await uploadResp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
resultItem = await JsonSerializer.DeserializeAsync<GraphDriveItem>(respStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
position += bytesRead;
|
|
}
|
|
|
|
return resultItem ?? throw new InvalidOperationException("Upload completed but no item returned.");
|
|
}
|
|
|
|
public async Task UpdateDriveItemFileSystemInfoAsync(
|
|
string driveId,
|
|
string itemId,
|
|
DateTimeOffset? created,
|
|
DateTimeOffset? modified,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (created == null && modified == null) return;
|
|
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var drive = Uri.EscapeDataString(driveId);
|
|
var item = Uri.EscapeDataString(itemId);
|
|
|
|
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{item}";
|
|
|
|
var body = new
|
|
{
|
|
fileSystemInfo = new
|
|
{
|
|
createdDateTime = created,
|
|
lastModifiedDateTime = modified
|
|
}
|
|
};
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
|
req.Content = JsonContent.Create(body);
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory, cancellationToken).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private class GraphUploadSession
|
|
{
|
|
public string uploadUrl { get; set; } = "";
|
|
public DateTimeOffset? expirationDateTime { get; set; }
|
|
public string[]? nextExpectedRanges { get; set; }
|
|
}
|
|
|
|
public async Task RestoreDriveItemMetadataAsync(
|
|
string driveId,
|
|
string itemId,
|
|
Stream metadataJsonStream,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var drive = Uri.EscapeDataString(driveId);
|
|
var item = Uri.EscapeDataString(itemId);
|
|
|
|
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{item}";
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Patch, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
|
|
|
metadataJsonStream.Position = 0;
|
|
req.Content = new StreamContent(metadataJsonStream);
|
|
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory, cancellationToken).ConfigureAwait(false);
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<GraphDriveItem?> GetDriveItemAsync(
|
|
string driveId,
|
|
string parentId,
|
|
string name,
|
|
CancellationToken ct)
|
|
{
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var drive = Uri.EscapeDataString(driveId);
|
|
var parent = Uri.EscapeDataString(parentId);
|
|
var itemName = Uri.EscapeDataString(name);
|
|
|
|
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{parent}:/{itemName}";
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
|
|
return req;
|
|
}
|
|
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
ct).ConfigureAwait(false);
|
|
|
|
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
return null;
|
|
|
|
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
|
|
|
|
using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
|
return await JsonSerializer.DeserializeAsync<GraphDriveItem>(stream, cancellationToken: ct).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task RestoreDriveItemPermissionsAsync(
|
|
string driveId,
|
|
string itemId,
|
|
string permissionsJson,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(permissionsJson)) return;
|
|
|
|
List<GraphPermission>? permissions;
|
|
try
|
|
{
|
|
permissions = JsonSerializer.Deserialize<List<GraphPermission>>(permissionsJson);
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (permissions == null || permissions.Count == 0) return;
|
|
|
|
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
|
|
var drive = Uri.EscapeDataString(driveId);
|
|
var item = Uri.EscapeDataString(itemId);
|
|
|
|
// POST /drives/{drive-id}/items/{item-id}/invite
|
|
var url = $"{baseUrl}/v1.0/drives/{drive}/items/{item}/invite";
|
|
|
|
foreach (var perm in permissions)
|
|
{
|
|
// We can only restore permissions that have roles and recipients (grantedTo or grantedToIdentities)
|
|
// We skip links for now as they are complex to restore (might need to create new links)
|
|
// We also skip if no roles are defined.
|
|
|
|
if (perm.Roles == null || perm.Roles.Count == 0) continue;
|
|
|
|
var recipients = new List<object>();
|
|
|
|
if (perm.GrantedTo?.User?.Email != null)
|
|
{
|
|
recipients.Add(new { email = perm.GrantedTo.User.Email });
|
|
}
|
|
|
|
if (perm.GrantedToIdentities != null)
|
|
{
|
|
foreach (var identity in perm.GrantedToIdentities)
|
|
{
|
|
if (identity.User?.Email != null)
|
|
{
|
|
recipients.Add(new { email = identity.User.Email });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also check Invitation email
|
|
if (perm.Invitation?.Email != null)
|
|
{
|
|
recipients.Add(new { email = perm.Invitation.Email });
|
|
}
|
|
|
|
if (recipients.Count == 0) continue;
|
|
|
|
var body = new
|
|
{
|
|
requireSignIn = true, // Default to true for security
|
|
sendInvitation = false, // Don't spam users during restore
|
|
roles = perm.Roles,
|
|
recipients = recipients,
|
|
message = "Restored by Duplicati"
|
|
};
|
|
|
|
async Task<HttpRequestMessage> requestFactory(CancellationToken ct)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
|
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false);
|
|
req.Content = JsonContent.Create(body);
|
|
return req;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var resp = await provider.SendWithRetryShortAsync(
|
|
requestFactory,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
// We don't throw on permission restore failure, just log/ignore
|
|
// await APIHelper.EnsureOfficeApiSuccessAsync(resp, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// Ignore permission restore errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|