548 lines
19 KiB
C#
548 lines
19 KiB
C#
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
|
|
|
|
using System.Collections.Concurrent;
|
|
using Duplicati.Library.Common.IO;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Logging;
|
|
using Duplicati.Library.Utility;
|
|
using Duplicati.Proprietary.GoogleWorkspace.SourceItems;
|
|
|
|
namespace Duplicati.Proprietary.GoogleWorkspace;
|
|
|
|
public partial class RestoreProvider : IRestoreDestinationProviderModule
|
|
{
|
|
/// <summary>
|
|
/// Log tag for RestoreProvider
|
|
/// </summary>
|
|
private static readonly string LOGTAG = Log.LogTagFromType<RestoreProvider>();
|
|
|
|
/// <summary>
|
|
/// API helper for accessing Google Workspace APIs
|
|
/// </summary>
|
|
private readonly APIHelper _apiHelper;
|
|
/// <summary>
|
|
/// Path to restore to
|
|
/// </summary>
|
|
private readonly string _restorePath;
|
|
/// <summary>
|
|
/// Whether the overwrite option has been set
|
|
/// </summary>
|
|
private readonly bool _hasSetOverwriteOption;
|
|
/// <summary>
|
|
/// Source provider for accessing currently existing items on the destination
|
|
/// </summary>
|
|
internal SourceProvider SourceProvider { get; init; }
|
|
|
|
/// <summary>
|
|
/// Properties related to the restore target
|
|
/// </summary>
|
|
/// <param name="Path">The path to restore to</param>
|
|
/// <param name="Entry">The source provider entry to restore to</param>
|
|
/// <param name="Metadata">The metadata associated with the restore target</param>
|
|
/// <param name="Type">The type of the restore target</param>
|
|
internal record RestoreTargetData(
|
|
string Path,
|
|
ISourceProviderEntry Entry,
|
|
Dictionary<string, string?> Metadata,
|
|
SourceItemType Type);
|
|
|
|
/// <summary>
|
|
/// The restore target data
|
|
/// </summary>
|
|
internal RestoreTargetData? RestoreTarget { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Temporary files created during the restore process
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, TempFile> _temporaryFiles = new();
|
|
/// <summary>
|
|
/// Metadata recorded during the restore process
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, Dictionary<string, string?>> _metadata = new();
|
|
/// <summary>
|
|
/// Map of restored Gmail labels to their corresponding IDs
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, string> _restoredLabelMap = new();
|
|
|
|
/// <summary>
|
|
/// Map of restored Drive folders to their corresponding IDs
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, string> _restoredDriveFolderMap = new();
|
|
|
|
/// <summary>
|
|
/// Map of restored task lists to their corresponding IDs
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, string> _restoredTaskListMap = new();
|
|
|
|
/// <summary>
|
|
/// Map of restored Drive files to their corresponding IDs
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, string> _restoredDriveFileMap = new();
|
|
|
|
/// <summary>
|
|
/// Whether to overwrite existing items
|
|
/// </summary>
|
|
internal readonly bool _ignoreExisting;
|
|
|
|
/// <summary>
|
|
/// Default constructor for the restore provider.
|
|
/// Only used for loading metadata about the provider.
|
|
/// </summary>
|
|
public RestoreProvider()
|
|
{
|
|
_apiHelper = null!;
|
|
_restorePath = null!;
|
|
SourceProvider = null!;
|
|
_hasSetOverwriteOption = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constructs the RestoreProvider with the given URL and options.
|
|
/// </summary>
|
|
/// <param name="url">The destination URL for the restore operation</param>
|
|
/// <param name="options">The options for the restore operation</param>
|
|
public RestoreProvider(string url, Dictionary<string, string?> options)
|
|
{
|
|
var uri = new Library.Utility.Uri(url);
|
|
_restorePath = uri.HostAndPath;
|
|
|
|
var parsedOptions = OptionsHelper.ParseOptions(options);
|
|
_apiHelper = new APIHelper(parsedOptions, isRestoreOperation: true);
|
|
|
|
_ignoreExisting = Utility.ParseBoolOption(options, OptionsHelper.GOOGLE_IGNORE_EXISTING_OPTION);
|
|
_hasSetOverwriteOption = Utility.ParseBoolOption(options, "overwrite");
|
|
|
|
var sourceOpts = new Dictionary<string, string?>(options);
|
|
sourceOpts["store-metadata-content-in-database"] = "true";
|
|
|
|
SourceProvider = new SourceProvider("googleworkspace://", "", sourceOpts, true);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string Key => OptionsHelper.ModuleKey;
|
|
|
|
/// <inheritdoc />
|
|
public string DisplayName => Strings.Common.DisplayName;
|
|
|
|
/// <inheritdoc />
|
|
public string Description => Strings.Common.Description;
|
|
|
|
/// <inheritdoc />
|
|
public IList<ICommandLineArgument> SupportedCommands => OptionsHelper.RestoreSupportedCommands.ToList();
|
|
|
|
/// <inheritdoc />
|
|
public string TargetDestination => _restorePath;
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> CreateFolderIfNotExists(string path, CancellationToken cancel)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task Initialize(CancellationToken cancel)
|
|
{
|
|
if (!_hasSetOverwriteOption)
|
|
throw new UserInformationException(Strings.RestoreTargetMissingOverwriteOption("overwrite", OptionsHelper.GOOGLE_IGNORE_EXISTING_OPTION), "OverwriteOptionNotSet");
|
|
|
|
await SourceProvider.Initialize(cancel);
|
|
var entry = await SourceProvider.GetEntry(_restorePath, isFolder: true, cancel);
|
|
if (entry == null)
|
|
throw new UserInformationException(Strings.RestoreTargetNotFound(_restorePath), "RestoreTargetNotFound");
|
|
var metadata = await entry.GetMinorMetadata(cancel);
|
|
var type = metadata.TryGetValue("gsuite:Type", out var typeStr)
|
|
&& Enum.TryParse<SourceItemType>(typeStr, out var sourceItemType)
|
|
? sourceItemType
|
|
: throw new UserInformationException(Strings.InvalidRestoreTargetType(typeStr), "InvalidRestoreTargetType");
|
|
|
|
RestoreTarget = new RestoreTargetData(_restorePath, entry, metadata, type);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task Test(CancellationToken cancellationToken)
|
|
{
|
|
_apiHelper.TestConnection();
|
|
|
|
// If we have no path, just check that we can connect
|
|
if (string.IsNullOrWhiteSpace(_restorePath))
|
|
return;
|
|
|
|
// Otherwise, check that the path exists and is a valid target
|
|
await Initialize(cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes the given path by applying the RemovedRestorePrefix if it has been removed
|
|
/// </summary>
|
|
/// <param name="path">The path to normalize</param>
|
|
/// <returns>The normalized path</returns>
|
|
private string NormalizePath(string path)
|
|
=> path;
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
SourceProvider?.Dispose();
|
|
|
|
foreach (var file in _temporaryFiles.Values)
|
|
file.Dispose();
|
|
_temporaryFiles.Clear();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> FileExists(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
if (_temporaryFiles.ContainsKey(path))
|
|
return true;
|
|
|
|
var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false);
|
|
return entry != null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<Stream> OpenWrite(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
var file = _temporaryFiles.GetOrAdd(path, _ => new TempFile());
|
|
return Task.FromResult<Stream>(SystemIO.IO_OS.FileOpenWrite(file));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Stream> OpenRead(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
if (_temporaryFiles.ContainsKey(path))
|
|
return SystemIO.IO_OS.FileOpenRead(_temporaryFiles[path]);
|
|
|
|
var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false);
|
|
if (entry != null)
|
|
return await entry.OpenRead(cancel).ConfigureAwait(false);
|
|
|
|
throw new FileNotFoundException($"File not found: {path}");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Stream> OpenReadWrite(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
if (_temporaryFiles.ContainsKey(path))
|
|
return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]);
|
|
|
|
var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false);
|
|
if (entry == null)
|
|
{
|
|
_temporaryFiles.GetOrAdd(path, _ => new TempFile());
|
|
return SystemIO.IO_OS.FileOpenReadWrite(_temporaryFiles[path]);
|
|
}
|
|
|
|
if (entry.IsFolder)
|
|
throw new FileNotFoundException($"Path is a folder: {path}");
|
|
|
|
var file = new TempFile();
|
|
using (var fs = SystemIO.IO_OS.FileOpenWrite(file))
|
|
using (var stream = await entry.OpenRead(cancel).ConfigureAwait(false))
|
|
await stream.CopyToAsync(fs).ConfigureAwait(false);
|
|
|
|
_temporaryFiles[path] = file;
|
|
return SystemIO.IO_OS.FileOpenReadWrite(file);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<long> GetFileLength(string path, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
if (_temporaryFiles.ContainsKey(path))
|
|
return SystemIO.IO_OS.FileLength(_temporaryFiles[path]);
|
|
|
|
var entry = await SourceProvider.GetEntry(path, isFolder: false, cancel).ConfigureAwait(false);
|
|
if (entry == null)
|
|
throw new FileNotFoundException($"File not found: {path}");
|
|
|
|
return entry.Size;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> HasReadOnlyAttribute(string path, CancellationToken cancel)
|
|
=> Task.FromResult(false);
|
|
|
|
/// <inheritdoc />
|
|
public Task ClearReadOnlyAttribute(string path, CancellationToken cancel)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> WriteMetadata(string path, Dictionary<string, string?> metadata, bool restoreSymlinkMetadata, bool restorePermissions, CancellationToken cancel)
|
|
{
|
|
path = NormalizePath(path);
|
|
|
|
_metadata.AddOrUpdate(path, metadata, (_, _) => metadata);
|
|
return Task.FromResult(true);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteFolder(string path, CancellationToken cancel)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteFile(string path, CancellationToken cancel)
|
|
=> throw new NotImplementedException("File deletion is not supported in Google Workspace RestoreProvider");
|
|
|
|
/// <inheritdoc />
|
|
public async Task Finalize(Action<double>? progressCallback, CancellationToken cancel)
|
|
{
|
|
var totalFiles = _metadata.Count;
|
|
var completedPhases = 0;
|
|
const int totalPhases = 13;
|
|
|
|
// Phase 1: Restore Gmail Labels
|
|
try
|
|
{
|
|
await RestoreGmailLabels(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailLabelsFailed", ex, $"Failed to restore Gmail labels");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 2: Restore Gmail Messages
|
|
try
|
|
{
|
|
await RestoreGmailMessages(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailMessagesFailed", ex, $"Failed to restore Gmail messages");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 3: Restore Gmail Settings
|
|
try
|
|
{
|
|
await RestoreGmailSettings(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreGmailSettingsFailed", ex, $"Failed to restore Gmail settings");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 4: Restore Drive Folders
|
|
try
|
|
{
|
|
await RestoreDriveFolders(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDriveFoldersFailed", ex, $"Failed to restore Drive folders");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 5: Restore Drive Files
|
|
try
|
|
{
|
|
await RestoreDriveFiles(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDriveFilesFailed", ex, $"Failed to restore Drive files");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 6: Restore Drive Permissions
|
|
try
|
|
{
|
|
await RestoreDrivePermissions(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreDrivePermissionsFailed", ex, $"Failed to restore Drive permissions");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 7: Restore Calendar Events
|
|
try
|
|
{
|
|
await RestoreCalendarEvents(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreCalendarEventsFailed", ex, $"Failed to restore calendar events");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 8: Restore Calendar ACLs
|
|
try
|
|
{
|
|
await RestoreCalendarAcls(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreCalendarEventsFailed", ex, $"Failed to restore calendar events");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 9: Restore Contacts
|
|
try
|
|
{
|
|
await RestoreContacts(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactsFailed", ex, $"Failed to restore contacts");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 10: Restore Contact Groups
|
|
try
|
|
{
|
|
await RestoreContactGroups(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreContactGroupsFailed", ex, $"Failed to restore contact groups");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 11: Restore Task Lists and Tasks
|
|
try
|
|
{
|
|
await RestoreTaskLists(cancel).ConfigureAwait(false);
|
|
await RestoreTasks(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreTasksFailed", ex, $"Failed to restore tasks");
|
|
}
|
|
completedPhases++;
|
|
progressCallback?.Invoke(completedPhases / (double)totalPhases);
|
|
|
|
// Phase 12: Restore Keep Notes (if target supports it)
|
|
try
|
|
{
|
|
await RestoreKeepNotes(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreKeepNotesFailed", ex, $"Failed to restore Keep notes");
|
|
}
|
|
|
|
// Phase 13: Restore Chat Messages (if target supports it)
|
|
try
|
|
{
|
|
await RestoreChatMessages(cancel).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.WriteErrorMessage(LOGTAG, "FinalizeRestoreChatMessagesFailed", ex, $"Failed to restore chat messages");
|
|
}
|
|
|
|
RemoveMetaEntriesForNonRestoredItems();
|
|
|
|
// We should have restored all items by now
|
|
if (_metadata.Count > 0 || _temporaryFiles.Count > 0)
|
|
{
|
|
var displayItems = _metadata.Values
|
|
.Select(x => (Type: x.GetValueOrDefault("gsuite:Type"), Name: x.GetValueOrDefault("gsuite:Name") ?? x.GetValueOrDefault("gsuite:DisplayName")))
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Type) && !string.IsNullOrWhiteSpace(x.Name))
|
|
.Take(10);
|
|
|
|
foreach (var item in displayItems)
|
|
Log.WriteWarningMessage(LOGTAG, "FinalizeIncompleteItem", null, $"Unrestored item - Type: {item.Type}, Name: {item.Name}");
|
|
|
|
if (_metadata.Count > displayItems.Count())
|
|
Log.WriteWarningMessage(LOGTAG, "FinalizeIncomplete", null, $"... and {_metadata.Count - displayItems.Count()} more unrestored items.");
|
|
}
|
|
|
|
var tempFiles = _temporaryFiles.Values.ToList();
|
|
_temporaryFiles.Clear();
|
|
foreach (var file in tempFiles)
|
|
file.Dispose();
|
|
_metadata.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets metadata entries by their source item type
|
|
/// </summary>
|
|
/// <param name="type">The source item type to filter by</param>
|
|
/// <returns>The list of metadata entries matching the given type</returns>
|
|
private List<KeyValuePair<string, Dictionary<string, string?>>> GetMetadataByType(SourceItemType type)
|
|
=> _metadata
|
|
.Where(kv => kv.Value.TryGetValue("gsuite:Type", out var typeStr)
|
|
&& typeStr == type.ToString())
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// Removes metadata entries for items that were not restored and are not required to be processed
|
|
/// </summary>
|
|
private void RemoveMetaEntriesForNonRestoredItems()
|
|
{
|
|
var toRemove = new HashSet<string>();
|
|
foreach (var kv in _metadata)
|
|
{
|
|
// Some intermediate directories may not have a type
|
|
if (!kv.Value.ContainsKey("gsuite:Type"))
|
|
{
|
|
toRemove.Add(kv.Key);
|
|
continue;
|
|
}
|
|
|
|
var key = kv.Value.GetValueOrDefault("gsuite:Type");
|
|
if (!Enum.TryParse<SourceItemType>(key, out var type))
|
|
continue;
|
|
|
|
switch (type)
|
|
{
|
|
// Various meta-types that are not restored directly
|
|
case SourceItemType.User:
|
|
case SourceItemType.UserGmail:
|
|
case SourceItemType.UserDrive:
|
|
case SourceItemType.UserCalendar:
|
|
case SourceItemType.UserContacts:
|
|
case SourceItemType.UserTasks:
|
|
case SourceItemType.UserKeep:
|
|
case SourceItemType.UserChat:
|
|
case SourceItemType.Calendar:
|
|
toRemove.Add(kv.Key);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
|
|
// If we are restoring TO a specific label, we don't need to restore the label itself
|
|
if (type == SourceItemType.GmailLabel && RestoreTarget != null && RestoreTarget.Type == SourceItemType.GmailLabel)
|
|
toRemove.Add(kv.Key);
|
|
|
|
// If we are restoring TO a specific folder, we don't need to restore the folder itself
|
|
if (type == SourceItemType.DriveFolder && RestoreTarget != null && RestoreTarget.Type == SourceItemType.DriveFolder)
|
|
toRemove.Add(kv.Key);
|
|
}
|
|
|
|
foreach (var key in toRemove)
|
|
{
|
|
_metadata.TryRemove(key, out _);
|
|
_temporaryFiles.TryRemove(key, out var f);
|
|
f?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IList<string> GetPriorityFiles() => new List<string>();
|
|
}
|