diff --git a/Duplicati/Library/Main/Database/LocalRestoreDatabase.cs b/Duplicati/Library/Main/Database/LocalRestoreDatabase.cs index 07972a364..3e2bba917 100644 --- a/Duplicati/Library/Main/Database/LocalRestoreDatabase.cs +++ b/Duplicati/Library/Main/Database/LocalRestoreDatabase.cs @@ -862,7 +862,7 @@ namespace Duplicati.Library.Main.Database ELSE ""Path"" END ") - .SetParameterValue("@Path", Util.AppendDirSeparator(System.IO.Path.GetPathRoot(Library.Utility.TempFolder.SystemTempPath)).Replace("\\", "/")) + .SetParameterValue("@Path", Util.AppendDirSeparator(System.IO.Path.GetPathRoot(Library.Utility.TempFolder.SystemTempPath) ?? "").Replace("\\", "/")) .ExecuteNonQueryAsync(token) .ConfigureAwait(false); } diff --git a/proprietary/GoogleWorkspace/APIHelper.cs b/proprietary/GoogleWorkspace/APIHelper.cs index bdf3d0926..f59541b26 100644 --- a/proprietary/GoogleWorkspace/APIHelper.cs +++ b/proprietary/GoogleWorkspace/APIHelper.cs @@ -24,73 +24,31 @@ public class APIHelper private readonly string? _refreshToken; private readonly string? _serviceAccountJson; private readonly string? _adminEmail; - private readonly OptionsHelper.GoogleWorkspaceOptions _options; - private readonly HashSet _scopes; + private readonly bool _isRestoreOperation; - private GmailService? _gmailService; - private DirectoryService? _directoryService; - private CalendarService? _calendarService; + private DirectoryService? _directoryServiceForUsers; + private DirectoryService? _directoryServiceForGroups; + private DirectoryService? _directoryServiceForOrgUnits; private DriveService? _driveService; - private PeopleServiceService? _peopleService; - private TasksService? _tasksService; - private KeepService? _keepService; private GroupssettingsService? _groupsSettingsService; - private HangoutsChatService? _chatService; - private static readonly Dictionary _rootTypeScopes = new() + public APIHelper(OptionsHelper.GoogleWorkspaceOptions options, bool isRestoreOperation) { - { GoogleRootType.Users, [DirectoryService.Scope.AdminDirectoryUserReadonly] }, - { GoogleRootType.Groups, [DirectoryService.Scope.AdminDirectoryGroupReadonly, GroupssettingsService.Scope.AppsGroupsSettings] }, - { GoogleRootType.SharedDrives, [DriveService.Scope.DriveReadonly] }, - { GoogleRootType.Sites, [DriveService.Scope.DriveReadonly] }, - { GoogleRootType.OrganizationalUnits, [DirectoryService.Scope.AdminDirectoryOrgunitReadonly] } - }; - - private static readonly Dictionary _userTypeScopes = new() - { - { GoogleUserType.Gmail, [GmailService.Scope.GmailReadonly] }, - { GoogleUserType.Drive, [DriveService.Scope.DriveReadonly] }, - { GoogleUserType.Calendar, [CalendarService.Scope.CalendarReadonly] }, - { GoogleUserType.Contacts, [PeopleServiceService.Scope.ContactsReadonly] }, - { GoogleUserType.Tasks, [TasksService.Scope.TasksReadonly] }, - { GoogleUserType.Keep, [KeepService.Scope.KeepReadonly] }, - { GoogleUserType.Chat, [HangoutsChatService.Scope.ChatSpacesReadonly, HangoutsChatService.Scope.ChatMessagesReadonly, HangoutsChatService.Scope.ChatMembershipsReadonly] } - }; - - public APIHelper(OptionsHelper.GoogleWorkspaceOptions options) - { - _options = options; _clientId = options.ClientId; _clientSecret = options.ClientSecret; _refreshToken = options.RefreshToken; _serviceAccountJson = options.ServiceAccountJson; _adminEmail = options.AdminEmail; - _scopes = GetConfiguredScopes(); + _isRestoreOperation = isRestoreOperation; } - public void Initialize() - { - var initializer = GetServiceInitializer(); - - _gmailService = new GmailService(initializer); - _directoryService = new DirectoryService(initializer); - _calendarService = new CalendarService(initializer); - _driveService = new DriveService(initializer); - _peopleService = new PeopleServiceService(initializer); - _tasksService = new TasksService(initializer); - _keepService = new KeepService(initializer); - _groupsSettingsService = new GroupssettingsService(initializer); - _chatService = new HangoutsChatService(initializer); - } - - private BaseClientService.Initializer GetServiceInitializer(string? userId = null) + private BaseClientService.Initializer GetServiceInitializer(string? userId, IEnumerable scopes) { var backoffInitializer = new BackOffInitializer(); - if (!string.IsNullOrEmpty(_serviceAccountJson)) { var credential = GoogleCredential.FromJson(_serviceAccountJson) - .CreateScoped(_scopes.ToArray()); + .CreateScoped(scopes.ToArray()); var userToImpersonate = userId ?? _adminEmail; if (!string.IsNullOrEmpty(userToImpersonate)) @@ -132,110 +90,139 @@ public class APIHelper public void TestConnection() { - if (_gmailService == null) Initialize(); - _gmailService!.Users.GetProfile("me").Execute(); + // Test connection by getting the Gmail service + var gmailService = GetGmailService("me"); + gmailService.Users.GetProfile("me").Execute(); } - - private HashSet GetConfiguredScopes() - { - var scopes = new HashSet(); - if (_options.RequestedScopes != null && _options.RequestedScopes.Length > 0) - { - scopes.UnionWith(_options.RequestedScopes); - } - else - { - foreach (var type in _options.IncludedRootTypes) - if (_rootTypeScopes.TryGetValue(type, out var s)) - scopes.UnionWith(s); - - foreach (var type in _options.IncludedUserTypes) - if (_userTypeScopes.TryGetValue(type, out var s)) - scopes.UnionWith(s); - } - return scopes; - } - - public bool HasScope(string scope) - { - return _scopes.Contains(scope); - } - public GmailService GetGmailService(string userId) { - return new GmailService(GetServiceInitializer(userId)); + var scopes = _isRestoreOperation + ? new[] { "https://www.googleapis.com/auth/gmail.modify" } + : new[] { GmailService.Scope.GmailReadonly }; + return new GmailService(GetServiceInitializer(userId, scopes)); } - public DirectoryService GetDirectoryService() + public DirectoryService GetDirectoryServiceForUsers() { - if (_directoryService == null) Initialize(); - return _directoryService!; - } - - public CalendarService GetCalendarService(string? userId = null) - { - if (userId != null) + if (_directoryServiceForUsers == null) { - return new CalendarService(GetServiceInitializer(userId)); + var scopes = _isRestoreOperation + ? new[] { DirectoryService.Scope.AdminDirectoryUser } + : new[] { DirectoryService.Scope.AdminDirectoryUserReadonly }; + _directoryServiceForUsers = new DirectoryService(GetServiceInitializer(null, scopes)); } - if (_calendarService == null) Initialize(); - return _calendarService!; + return _directoryServiceForUsers!; + } + + public DirectoryService GetDirectoryServiceForGroups() + { + if (_directoryServiceForGroups == null) + { + var scopes = _isRestoreOperation + ? new[] { DirectoryService.Scope.AdminDirectoryGroup } + : new[] { DirectoryService.Scope.AdminDirectoryGroupReadonly }; + _directoryServiceForGroups = new DirectoryService(GetServiceInitializer(null, scopes)); + } + return _directoryServiceForGroups!; + } + + public DirectoryService GetDirectoryServiceForOrgUnits() + { + if (_directoryServiceForOrgUnits == null) + { + var scopes = _isRestoreOperation + ? new[] { DirectoryService.Scope.AdminDirectoryOrgunit } + : new[] { DirectoryService.Scope.AdminDirectoryOrgunitReadonly }; + _directoryServiceForOrgUnits = new DirectoryService(GetServiceInitializer(null, scopes)); + } + return _directoryServiceForOrgUnits!; + } + + public CalendarService GetCalendarService(string userId) + { + var scopes = _isRestoreOperation + ? new[] { CalendarService.Scope.Calendar } + : new[] { CalendarService.Scope.CalendarReadonly }; + + return new CalendarService(GetServiceInitializer(userId, scopes)); + } + + public CalendarService GetCalendarAclService(string userId) + { + // ACL reading requires WRITE permissions + var scopes = new[] { CalendarService.Scope.Calendar }; + return new CalendarService(GetServiceInitializer(userId, scopes)); } public DriveService GetDriveService(string? userId = null) { + var scopes = _isRestoreOperation + ? new[] { DriveService.Scope.Drive } + : new[] { DriveService.Scope.DriveReadonly }; + if (userId != null) { - return new DriveService(GetServiceInitializer(userId)); + return new DriveService(GetServiceInitializer(userId, scopes)); + } + if (_driveService == null) + { + _driveService = new DriveService(GetServiceInitializer(null, scopes)); } - if (_driveService == null) Initialize(); return _driveService!; } - public PeopleServiceService GetPeopleService(string? userId = null) + public PeopleServiceService GetPeopleService(string userId) { - if (userId != null) - { - return new PeopleServiceService(GetServiceInitializer(userId)); - } - if (_peopleService == null) Initialize(); - return _peopleService!; + var scopes = _isRestoreOperation + ? new[] { "https://www.googleapis.com/auth/contacts" } + : new[] { PeopleServiceService.Scope.ContactsReadonly }; + + return new PeopleServiceService(GetServiceInitializer(userId, scopes)); } - public TasksService GetTasksService(string? userId = null) + public TasksService GetTasksService(string userId) { - if (userId != null) - { - return new TasksService(GetServiceInitializer(userId)); - } - if (_tasksService == null) Initialize(); - return _tasksService!; + var scopes = _isRestoreOperation + ? new[] { TasksService.Scope.Tasks } + : new[] { TasksService.Scope.TasksReadonly }; + + return new TasksService(GetServiceInitializer(userId, scopes)); } - public KeepService GetKeepService(string? userId = null) + public KeepService GetKeepService(string userId) { - if (userId != null) - { - return new KeepService(GetServiceInitializer(userId)); - } - if (_keepService == null) Initialize(); - return _keepService!; + var scopes = _isRestoreOperation + ? new[] { KeepService.Scope.Keep } + : new[] { KeepService.Scope.KeepReadonly }; + + return new KeepService(GetServiceInitializer(userId, scopes)); } public GroupssettingsService GetGroupsSettingsService() { - if (_groupsSettingsService == null) Initialize(); + if (_groupsSettingsService == null) + { + var scopes = new[] { GroupssettingsService.Scope.AppsGroupsSettings }; + _groupsSettingsService = new GroupssettingsService(GetServiceInitializer(null, scopes)); + } return _groupsSettingsService!; } - public HangoutsChatService GetChatService(string? userId = null) + public HangoutsChatService GetChatService(string userId) { - if (_chatService == null) Initialize(); - if (userId != null) - { - return new HangoutsChatService(GetServiceInitializer(userId)); - } - return _chatService!; + var scopes = _isRestoreOperation + ? new[] + { + HangoutsChatService.Scope.ChatMessages + } + : new[] + { + HangoutsChatService.Scope.ChatSpacesReadonly, + HangoutsChatService.Scope.ChatMessagesReadonly, + HangoutsChatService.Scope.ChatMembershipsReadonly + }; + + return new HangoutsChatService(GetServiceInitializer(userId, scopes)); } private class CompositeHttpClientInitializer : IConfigurableHttpClientInitializer diff --git a/proprietary/GoogleWorkspace/OptionsHelper.cs b/proprietary/GoogleWorkspace/OptionsHelper.cs index d78818417..349e6c4b6 100644 --- a/proprietary/GoogleWorkspace/OptionsHelper.cs +++ b/proprietary/GoogleWorkspace/OptionsHelper.cs @@ -39,9 +39,10 @@ public static class OptionsHelper public const string GOOGLE_ADMIN_EMAIL_OPTION = "google-admin-email"; public const string GOOGLE_INCLUDED_ROOT_TYPES_OPTION = "google-included-root-types"; public const string GOOGLE_INCLUDED_USER_TYPES_OPTION = "google-included-user-types"; - public const string GOOGLE_REQUESTED_SCOPES_OPTION = "google-requested-scopes"; + public const string GOOGLE_IGNORE_EXISTING_OPTION = "google-ignore-existing"; + public const string GOOGLE_AVOID_CALENDAR_ACL_OPTION = "google-ignore-calendar-acl"; - public static readonly ICommandLineArgument[] SupportedCommands = + private static readonly ICommandLineArgument[] SupportedCommands = [ new CommandLineArgument(GOOGLE_CLIENT_ID_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.GoogleClientIdShort, Strings.Options.GoogleClientIdLong, null, [AuthOptionsHelper.AuthUsernameOption]), new CommandLineArgument(GOOGLE_CLIENT_SECRET_OPTION, CommandLineArgument.ArgumentType.Password, Strings.Options.GoogleClientSecretShort, Strings.Options.GoogleClientSecretLong, null, [AuthOptionsHelper.AuthPasswordOption]), @@ -49,9 +50,20 @@ public static class OptionsHelper new CommandLineArgument(GOOGLE_SERVICE_ACCOUNT_JSON_OPTION, CommandLineArgument.ArgumentType.Password, Strings.Options.GoogleServiceAccountJsonShort, Strings.Options.GoogleServiceAccountJsonLong), new CommandLineArgument(GOOGLE_SERVICE_ACCOUNT_FILE_OPTION, CommandLineArgument.ArgumentType.Path, Strings.Options.GoogleServiceAccountFileShort, Strings.Options.GoogleServiceAccountFileLong), new CommandLineArgument(GOOGLE_ADMIN_EMAIL_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.GoogleAdminEmailShort, Strings.Options.GoogleAdminEmailLong), + ]; + + public static readonly ICommandLineArgument[] RestoreSupportedCommands = + [ + ..SupportedCommands, + new CommandLineArgument(GOOGLE_IGNORE_EXISTING_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.Options.GoogleIgnoreExistingOptionShort, Strings.Options.GoogleIgnoreExistingOptionLong), + ]; + + public static readonly ICommandLineArgument[] SourceProviderSupportedCommands = + [ + ..SupportedCommands, new CommandLineArgument(GOOGLE_INCLUDED_ROOT_TYPES_OPTION, CommandLineArgument.ArgumentType.Flags, Strings.Options.GoogleIncludedRootTypesShort, Strings.Options.GoogleIncludedRootTypesLong, string.Join(",", Enum.GetValues().Select(n => n.ToString())), null, Enum.GetNames(typeof(GoogleRootType))), new CommandLineArgument(GOOGLE_INCLUDED_USER_TYPES_OPTION, CommandLineArgument.ArgumentType.Flags, Strings.Options.GoogleIncludedUserTypesShort, Strings.Options.GoogleIncludedUserTypesLong, string.Join(",", Enum.GetValues().Select(n => n.ToString())), null, Enum.GetNames(typeof(GoogleUserType))), - new CommandLineArgument(GOOGLE_REQUESTED_SCOPES_OPTION, CommandLineArgument.ArgumentType.String, Strings.Options.GoogleRequestedScopesShort, Strings.Options.GoogleRequestedScopesLong), + new CommandLineArgument(GOOGLE_AVOID_CALENDAR_ACL_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.Options.GoogleAvoidCalendarAclOptionShort, Strings.Options.GoogleAvoidCalendarAclOptionLong), ]; public class GoogleWorkspaceOptions @@ -63,7 +75,6 @@ public static class OptionsHelper public string? AdminEmail { get; set; } public GoogleRootType[] IncludedRootTypes { get; set; } = Enum.GetValues().ToArray(); public GoogleUserType[] IncludedUserTypes { get; set; } = Enum.GetValues().ToArray(); - public string[]? RequestedScopes { get; set; } } public static GoogleWorkspaceOptions ParseOptions(Dictionary options) @@ -98,11 +109,6 @@ public static class OptionsHelper result.IncludedRootTypes = Enum.GetValues().Where(n => includedRootTypes.HasFlag(n)).ToArray(); result.IncludedUserTypes = Enum.GetValues().Where(n => includedUserTypes.HasFlag(n)).ToArray(); - if (options.ContainsKey(GOOGLE_REQUESTED_SCOPES_OPTION)) - { - result.RequestedScopes = options[GOOGLE_REQUESTED_SCOPES_OPTION]?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToArray(); - } - return result; } } diff --git a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Calendar.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Calendar.cs new file mode 100644 index 000000000..a94af2a2e --- /dev/null +++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Calendar.cs @@ -0,0 +1,321 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Logging; +using Duplicati.Proprietary.GoogleWorkspace.SourceItems; +using Google.Apis.Calendar.v3.Data; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +partial class RestoreProvider +{ + private CalendarRestoreHelper? _calendarRestoreHelper = null; + internal CalendarRestoreHelper CalendarRestore => _calendarRestoreHelper ??= new CalendarRestoreHelper(this); + + internal class CalendarRestoreHelper(RestoreProvider Provider) + { + private string? _targetUserId = null; + private string? _targetCalendarId = null; + private bool _hasLoadedTargetInfo = false; + + public async Task<(string? UserId, string? CalendarId)> GetUserIdAndCalendarTarget(CancellationToken cancel) + { + if (_hasLoadedTargetInfo) + { + if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetCalendarId)) + return (null, null); + return (_targetUserId!, _targetCalendarId!); + } + + var target = Provider.RestoreTarget; + if (target == null) + throw new InvalidOperationException("Restore target is not set"); + + if (target.Type == SourceItemType.User) + { + _targetUserId = target.Metadata.GetValueOrDefault("gsuite:Id"); + if (!string.IsNullOrWhiteSpace(_targetUserId)) + _targetCalendarId = await GetDefaultRestoreTargetCalendar(_targetUserId, cancel); + } + else if (target.Type == SourceItemType.UserCalendar) + { + _targetUserId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(_targetUserId)) + _targetCalendarId = await GetDefaultRestoreTargetCalendar(_targetUserId, cancel); + } + else if (target.Type == SourceItemType.Calendar) + { + _targetUserId = target.Metadata.GetValueOrDefault("gsuite:UserId"); + if (string.IsNullOrWhiteSpace(_targetUserId)) + _targetUserId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + _targetCalendarId = target.Metadata.GetValueOrDefault("gsuite:Id"); + } + else + { + Log.WriteWarningMessage(LOGTAG, "RestoreCalendarInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring calendar events."); + } + + _hasLoadedTargetInfo = true; + + if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetCalendarId)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreCalendarMissingIds", null, $"Missing target userId or calendarId for restoring calendar events."); + return (null, null); + } + + return (_targetUserId, _targetCalendarId); + } + + private async Task GetDefaultRestoreTargetCalendar(string userId, CancellationToken cancel) + { + const string RESTORED_CALENDAR_NAME = "Restored"; + + var calendarService = Provider._apiHelper.GetCalendarService(userId); + + // Check if calendar already exists + var calendars = await calendarService.CalendarList.List().ExecuteAsync(cancel); + var existingCalendar = calendars.Items?.FirstOrDefault(c => + c.Summary?.Equals(RESTORED_CALENDAR_NAME, StringComparison.OrdinalIgnoreCase) == true); + + if (existingCalendar != null) + return existingCalendar.Id; + + // Create new calendar + var newCalendar = new Calendar + { + Summary = RESTORED_CALENDAR_NAME, + TimeZone = "UTC" + }; + + var createdCalendar = await calendarService.Calendars.Insert(newCalendar).ExecuteAsync(cancel); + return createdCalendar.Id; + } + + public async Task CreateEvent(string userId, string calendarId, Event eventItem, CancellationToken cancel) + { + var calendarService = Provider._apiHelper.GetCalendarService(userId); + + // Check for duplicates by iCalUID if available + if (!Provider._ignoreExisting && !string.IsNullOrWhiteSpace(eventItem.ICalUID)) + { + var existingEvents = await calendarService.Events.List(calendarId).ExecuteAsync(cancel); + var duplicate = existingEvents.Items?.FirstOrDefault(e => + e.ICalUID?.Equals(eventItem.ICalUID, StringComparison.Ordinal) == true); + + if (duplicate != null) + { + Log.WriteInformationMessage(LOGTAG, "CreateEventSkipDuplicate", $"Event with ICalUID {eventItem.ICalUID} already exists, skipping."); + return duplicate.Id; + } + } + + // Clean up properties that shouldn't be sent on creation + eventItem.Id = null; + eventItem.ETag = null; + eventItem.HtmlLink = null; + eventItem.CreatedRaw = null; + eventItem.UpdatedRaw = null; + + var createdEvent = await calendarService.Events.Insert(eventItem, calendarId).ExecuteAsync(cancel); + return createdEvent.Id; + } + + public async Task AddAttachment(string userId, string calendarId, string eventId, string fileName, Stream contentStream, string mimeType, CancellationToken cancel) + { + var calendarService = Provider._apiHelper.GetCalendarService(userId); + + // Google Calendar API doesn't support direct attachment uploads + // Attachments need to be stored in Drive and linked + // For now, we log a warning + Log.WriteWarningMessage(LOGTAG, "CalendarAttachmentsNotSupported", null, $"Calendar attachments are not directly supported in Google Calendar API. File: {fileName}"); + } + + public async Task RestoreAcls(string userId, string calendarId, Stream aclStream, CancellationToken cancel) + { + var calendarService = Provider._apiHelper.GetCalendarService(userId); + + // Deserialize the ACL list + Acl? acl; + try + { + acl = await JsonSerializer.DeserializeAsync(aclStream, cancellationToken: cancel); + } + catch + { + return; + } + + if (acl?.Items == null || acl.Items.Count == 0) + return; + + foreach (var aclRule in acl.Items) + { + try + { + // Skip owner permissions (can't change ownership easily) + if (aclRule.Role == "owner") + continue; + + // Create a new ACL rule without the ID and ETag (these are assigned by Google) + var newAclRule = new AclRule + { + Role = aclRule.Role, + Scope = aclRule.Scope != null ? new AclRule.ScopeData + { + Type = aclRule.Scope.Type, + Value = aclRule.Scope.Value + } : null + }; + + await calendarService.Acl.Insert(newAclRule, calendarId).ExecuteAsync(cancel); + } + catch (Exception ex) + { + Log.WriteWarningMessage(LOGTAG, "RestoreCalendarAclFailed", ex, $"Failed to restore ACL rule with role {aclRule.Role} for calendar {calendarId}"); + } + } + } + } + + private async Task RestoreCalendarEvents(CancellationToken cancel) + { + if (RestoreTarget == null) + throw new InvalidOperationException("Restore target entry is not set"); + + var events = GetMetadataByType(SourceItemType.CalendarEvent); + if (events.Count == 0) + return; + + (var userId, var calendarId) = await CalendarRestore.GetUserIdAndCalendarTarget(cancel); + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(calendarId)) + return; + + // Group attachments by event path + var attachments = GetMetadataByType(SourceItemType.CalendarEventAttachment) + .GroupBy(k => Util.AppendDirSeparator(Path.GetDirectoryName(k.Key.TrimEnd(Path.DirectorySeparatorChar)) ?? "")) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var eventItem in events) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = eventItem.Key; + var contentPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json"); + var icsPath = SystemIO.IO_OS.PathCombine(originalPath, "event.ics"); + var contentEntry = _temporaryFiles.GetValueOrDefault(contentPath); + + if (contentEntry == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreCalendarEventsMissingContent", null, $"Missing content for event {originalPath}, skipping."); + continue; + } + + Event? calendarEvent; + using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry)) + { + calendarEvent = await JsonSerializer.DeserializeAsync(contentStream, cancellationToken: cancel); + } + + if (calendarEvent == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreCalendarEventsInvalidContent", null, $"Invalid content for event {originalPath}, skipping."); + continue; + } + + var newEventId = await CalendarRestore.CreateEvent(userId, calendarId, calendarEvent, cancel); + + // Restore attachments if any + var eventPathWithSep = Util.AppendDirSeparator(originalPath); + if (newEventId != null && attachments.TryGetValue(eventPathWithSep, out var eventAttachments)) + { + foreach (var att in eventAttachments) + { + try + { + var attPath = att.Key; + var attMetadata = att.Value; + var name = attMetadata.GetValueOrDefault("gsuite:Name") ?? "attachment"; + var mimeType = attMetadata.GetValueOrDefault("gsuite:MimeType") ?? "application/octet-stream"; + + if (_temporaryFiles.TryRemove(attPath, out var attContent)) + { + using var attStream = SystemIO.IO_OS.FileOpenRead(attContent); + await CalendarRestore.AddAttachment(userId, calendarId, newEventId, name, attStream, mimeType, cancel); + _metadata.TryRemove(attPath, out _); + attContent?.Dispose(); + } + } + catch (Exception ex) + { + Log.WriteWarningMessage(LOGTAG, "RestoreCalendarAttachmentFailed", ex, $"Failed to restore attachment for event {originalPath}"); + } + } + } + + _metadata.TryRemove(originalPath, out _); + _metadata.TryRemove(contentPath, out _); + _temporaryFiles.TryRemove(contentPath, out var cFile); + cFile?.Dispose(); + + // We do not use the ICS file for restore, so we can remove it + _metadata.TryRemove(icsPath, out _); + _temporaryFiles.TryRemove(icsPath, out var iFile); + iFile?.Dispose(); + + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreCalendarEventsFailed", ex, $"Failed to restore event {eventItem.Key}"); + } + } + } + + private async Task RestoreCalendarAcls(CancellationToken cancel) + { + if (RestoreTarget == null) + throw new InvalidOperationException("Restore target entry is not set"); + + var aclEntries = GetMetadataByType(SourceItemType.CalendarEventACL); + if (aclEntries.Count == 0) + return; + + (var userId, var calendarId) = await CalendarRestore.GetUserIdAndCalendarTarget(cancel); + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(calendarId)) + return; + + foreach (var aclEntry in aclEntries) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = aclEntry.Key; + var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath); + + if (contentEntry == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreCalendarAclsMissingContent", null, $"Missing content for ACL {originalPath}, skipping."); + continue; + } + + using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry)) + { + await CalendarRestore.RestoreAcls(userId, calendarId, contentStream, cancel); + } + + _metadata.TryRemove(originalPath, out _); + _temporaryFiles.TryRemove(originalPath, out var contentFile); + contentFile?.Dispose(); + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreCalendarAclsFailed", ex, $"Failed to restore ACLs for {aclEntry.Key}"); + } + } + } +} diff --git a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Chat.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Chat.cs new file mode 100644 index 000000000..9488f6387 --- /dev/null +++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Chat.cs @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Logging; +using Duplicati.Proprietary.GoogleWorkspace.SourceItems; +using Google.Apis.HangoutsChat.v1.Data; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +partial class RestoreProvider +{ + private ChatRestoreHelper? _chatRestoreHelper = null; + internal ChatRestoreHelper ChatRestore => _chatRestoreHelper ??= new ChatRestoreHelper(this); + + internal class ChatRestoreHelper(RestoreProvider Provider) + { + private string? _targetUserId = null; + private bool _hasLoadedTargetInfo = false; + + public async Task GetUserIdAndChatTarget(CancellationToken cancel) + { + if (_hasLoadedTargetInfo) + return _targetUserId; + + var target = Provider.RestoreTarget; + if (target == null) + throw new InvalidOperationException("Restore target is not set"); + + // Only restore Chat messages if the target is a User or UserChat + if (target.Type == SourceItemType.User) + { + _targetUserId = target.Metadata.GetValueOrDefault("gsuite:Id"); + } + else if (target.Type == SourceItemType.UserChat) + { + _targetUserId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + } + else if (target.Type == SourceItemType.ChatSpace) + { + _targetUserId = target.Metadata.GetValueOrDefault("gsuite:UserId"); + } + else + { + Log.WriteWarningMessage(LOGTAG, "RestoreChatInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring chat messages. Chat messages can only be restored to a User, UserChat, or ChatSpace target."); + } + + _hasLoadedTargetInfo = true; + + if (string.IsNullOrWhiteSpace(_targetUserId)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreChatMissingUserId", null, $"Missing target userId for restoring chat messages."); + return null; + } + + return _targetUserId; + } + + public async Task CreateSpace(string userId, Space space, CancellationToken cancel) + { + var chatService = Provider._apiHelper.GetChatService(userId); + + // Check for duplicates by display name if available + if (!Provider._ignoreExisting && !string.IsNullOrWhiteSpace(space.DisplayName)) + { + try + { + var existingSpaces = await chatService.Spaces.List().ExecuteAsync(cancel); + var duplicate = existingSpaces.Spaces?.FirstOrDefault(s => + s.DisplayName?.Equals(space.DisplayName, StringComparison.OrdinalIgnoreCase) == true); + + if (duplicate != null) + { + Log.WriteInformationMessage(LOGTAG, "CreateSpaceSkipDuplicate", $"Space with name '{space.DisplayName}' already exists, skipping."); + return duplicate.Name; + } + } + catch + { + // List might fail, continue with creation + } + } + + // Clean up properties that shouldn't be sent on creation + space.Name = null; + space.CreateTimeDateTimeOffset = null; + + var createdSpace = await chatService.Spaces.Create(space).ExecuteAsync(cancel); + return createdSpace.Name; + } + + public async Task CreateMessage(string userId, string spaceName, Message message, CancellationToken cancel) + { + var chatService = Provider._apiHelper.GetChatService(userId); + + // Clean up properties that shouldn't be sent on creation + message.Name = null; +#pragma warning disable CS0618 // Type or member is obsolete + message.CreateTime = null; + message.LastUpdateTime = null; +#pragma warning restore CS0618 // Type or member is obsolete + message.CreateTimeDateTimeOffset = null; + message.LastUpdateTimeDateTimeOffset = null; + + // Note: Creating messages in Chat API has limitations + // Messages can typically only be created by the user who is sending them + // For restore purposes, we attempt to create the message + try + { + var createdMessage = await chatService.Spaces.Messages.Create(message, spaceName).ExecuteAsync(cancel); + return createdMessage.Name; + } + catch (Exception ex) + { + Log.WriteWarningMessage(LOGTAG, "CreateMessageFailed", ex, $"Failed to create message in space {spaceName}. Note: Chat API has limitations on message creation."); + return null; + } + } + } + + private async Task RestoreChatMessages(CancellationToken cancel) + { + if (RestoreTarget == null) + throw new InvalidOperationException("Restore target entry is not set"); + + // Note: Google Chat API has significant limitations for restoring messages + // Messages can typically only be created by the user who authored them + // This implementation attempts to restore spaces but may not be able to restore messages + + var spaces = GetMetadataByType(SourceItemType.ChatSpace); + var messages = GetMetadataByType(SourceItemType.ChatMessage); + + if (spaces.Count == 0 && messages.Count == 0) + return; + + var userId = await ChatRestore.GetUserIdAndChatTarget(cancel); + if (string.IsNullOrWhiteSpace(userId)) + return; + + // First, restore spaces + foreach (var space in spaces) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = space.Key; + var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath); + + if (contentEntry == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreChatSpacesMissingContent", null, $"Missing content for space {originalPath}, skipping."); + continue; + } + + Space? spaceData; + using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry)) + { + spaceData = await JsonSerializer.DeserializeAsync(contentStream, cancellationToken: cancel); + } + + if (spaceData == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreChatSpacesInvalidContent", null, $"Invalid content for space {originalPath}, skipping."); + continue; + } + + await ChatRestore.CreateSpace(userId, spaceData, cancel); + + _metadata.TryRemove(originalPath, out _); + _temporaryFiles.TryRemove(originalPath, out var contentFile); + contentFile?.Dispose(); + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreChatSpacesFailed", ex, $"Failed to restore Chat space {space.Key}"); + } + } + + // Log warning about message restoration limitations + if (messages.Count > 0) + { + Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesLimitation", null, $"Found {messages.Count} chat messages to restore. Note: Google Chat API has limitations on message creation - messages can typically only be created by the original author. These messages may not be restorable."); + } + + // Attempt to restore messages (will likely fail due to API limitations) + foreach (var message in messages) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = message.Key; + var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath); + + if (contentEntry == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesMissingContent", null, $"Missing content for message {originalPath}, skipping."); + continue; + } + + // Get space name from parent path + var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? ""); + var spaceName = message.Value.GetValueOrDefault("gsuite:SpaceName"); + + if (string.IsNullOrWhiteSpace(spaceName)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesMissingSpace", null, $"Missing space name for message {originalPath}, skipping."); + continue; + } + + Message? messageData; + using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry)) + { + messageData = await JsonSerializer.DeserializeAsync(contentStream, cancellationToken: cancel); + } + + if (messageData == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesInvalidContent", null, $"Invalid content for message {originalPath}, skipping."); + continue; + } + + await ChatRestore.CreateMessage(userId, spaceName, messageData, cancel); + + _metadata.TryRemove(originalPath, out _); + _temporaryFiles.TryRemove(originalPath, out var contentFile); + contentFile?.Dispose(); + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreChatMessagesFailed", ex, $"Failed to restore Chat message {message.Key}"); + } + } + } +} diff --git a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Contacts.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Contacts.cs new file mode 100644 index 000000000..a0a0e2a01 --- /dev/null +++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Contacts.cs @@ -0,0 +1,275 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Logging; +using Duplicati.Proprietary.GoogleWorkspace.SourceItems; +using Google.Apis.PeopleService.v1.Data; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +partial class RestoreProvider +{ + private ContactRestoreHelper? _contactRestoreHelper = null; + internal ContactRestoreHelper ContactRestore => _contactRestoreHelper ??= new ContactRestoreHelper(this); + + internal class ContactRestoreHelper(RestoreProvider Provider) + { + private string? _targetUserId = null; + private bool _hasLoadedTargetInfo = false; + + public async Task GetUserIdAndContactsTarget(CancellationToken cancel) + { + if (_hasLoadedTargetInfo) + return _targetUserId; + + var target = Provider.RestoreTarget; + if (target == null) + throw new InvalidOperationException("Restore target is not set"); + + if (target.Type == SourceItemType.User) + { + _targetUserId = target.Metadata.GetValueOrDefault("gsuite:Id"); + } + else if (target.Type == SourceItemType.UserContacts) + { + _targetUserId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + } + else + { + Log.WriteWarningMessage(LOGTAG, "RestoreContactsInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring contacts."); + } + + _hasLoadedTargetInfo = true; + + if (string.IsNullOrWhiteSpace(_targetUserId)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreContactsMissingUserId", null, $"Missing target userId for restoring contacts."); + return null; + } + + return _targetUserId; + } + + public async Task CreateContact(string userId, Person contact, CancellationToken cancel) + { + var peopleService = Provider._apiHelper.GetPeopleService(userId); + + // Check for duplicates by email if available + if (!Provider._ignoreExisting && contact.EmailAddresses?.Count > 0) + { + var searchEmail = contact.EmailAddresses[0].Value; + if (!string.IsNullOrWhiteSpace(searchEmail)) + { + try + { + var searchRequest = peopleService.People.SearchContacts(); + searchRequest.Query = searchEmail; + var searchResponse = await searchRequest.ExecuteAsync(cancel); + var duplicate = searchResponse.Results?.FirstOrDefault(r => + r.Person?.EmailAddresses?.Any(e => e.Value?.Equals(searchEmail, StringComparison.OrdinalIgnoreCase) == true) == true); + + if (duplicate != null) + { + Log.WriteInformationMessage(LOGTAG, "CreateContactSkipDuplicate", $"Contact with email {searchEmail} already exists, skipping."); + return duplicate.Person?.ResourceName; + } + } + catch + { + // Search might fail, continue with creation + } + } + } + + // Clean up properties that shouldn't be sent on creation + contact.ResourceName = null; + contact.ETag = null; + contact.Metadata = null; + + var createdContact = await peopleService.People.CreateContact(contact).ExecuteAsync(cancel); + return createdContact.ResourceName; + } + + public async Task CreateContactGroup(string userId, ContactGroup group, CancellationToken cancel) + { + var peopleService = Provider._apiHelper.GetPeopleService(userId); + + // Check for duplicates by name + if (!Provider._ignoreExisting) + { + var existingGroups = await peopleService.ContactGroups.List().ExecuteAsync(cancel); + var duplicate = existingGroups.ContactGroups?.FirstOrDefault(g => + g.Name?.Equals(group.Name, StringComparison.OrdinalIgnoreCase) == true); + + if (duplicate != null) + { + Log.WriteInformationMessage(LOGTAG, "CreateContactGroupSkipDuplicate", $"Contact group {group.Name} already exists, skipping."); + return duplicate.ResourceName; + } + } + + // Clean up properties + group.ResourceName = null; + group.ETag = null; + group.MemberCount = null; + group.MemberResourceNames = null; + + var createdGroup = await peopleService.ContactGroups.Create(new CreateContactGroupRequest { ContactGroup = group }).ExecuteAsync(cancel); + return createdGroup.ResourceName; + } + + public async Task AddContactToGroup(string userId, string groupResourceName, string contactResourceName, CancellationToken cancel) + { + var peopleService = Provider._apiHelper.GetPeopleService(userId); + + var modifyRequest = new ModifyContactGroupMembersRequest + { + ResourceNamesToAdd = new List { contactResourceName } + }; + + await peopleService.ContactGroups.Members.Modify(modifyRequest, groupResourceName).ExecuteAsync(cancel); + } + } + + private async Task RestoreContacts(CancellationToken cancel) + { + if (RestoreTarget == null) + throw new InvalidOperationException("Restore target entry is not set"); + + var contacts = GetMetadataByType(SourceItemType.Contact); + if (contacts.Count == 0) + return; + + var userId = await ContactRestore.GetUserIdAndContactsTarget(cancel); + if (string.IsNullOrWhiteSpace(userId)) + return; + + foreach (var contact in contacts) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = contact.Key; + var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath); + + if (contentEntry == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreContactsMissingContent", null, $"Missing content for contact {originalPath}, skipping."); + continue; + } + + Person? contactData; + using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry)) + { + contactData = await JsonSerializer.DeserializeAsync(contentStream, cancellationToken: cancel); + } + + if (contactData == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreContactsInvalidContent", null, $"Invalid content for contact {originalPath}, skipping."); + continue; + } + + await ContactRestore.CreateContact(userId, contactData, cancel); + + _metadata.TryRemove(originalPath, out _); + _temporaryFiles.TryRemove(originalPath, out var contentFile); + contentFile?.Dispose(); + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreContactsFailed", ex, $"Failed to restore contact {contact.Key}"); + } + } + } + + private async Task RestoreContactGroups(CancellationToken cancel) + { + if (RestoreTarget == null) + throw new InvalidOperationException("Restore target entry is not set"); + + var groups = GetMetadataByType(SourceItemType.ContactGroup); + if (groups.Count == 0) + return; + + var userId = await ContactRestore.GetUserIdAndContactsTarget(cancel); + if (string.IsNullOrWhiteSpace(userId)) + return; + + foreach (var group in groups) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = group.Key; + var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath); + + if (contentEntry == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupsMissingContent", null, $"Missing content for group {originalPath}, skipping."); + continue; + } + + ContactGroup? groupData; + using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry)) + { + groupData = await JsonSerializer.DeserializeAsync(contentStream, cancellationToken: cancel); + } + + if (groupData == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupsInvalidContent", null, $"Invalid content for group {originalPath}, skipping."); + continue; + } + + var newGroupResourceName = await ContactRestore.CreateContactGroup(userId, groupData, cancel); + + // Restore group members if available + var membersPath = SystemIO.IO_OS.PathCombine(originalPath, "members.json"); + if (_temporaryFiles.TryRemove(membersPath, out var membersEntry)) + { + try + { + using var membersStream = SystemIO.IO_OS.FileOpenRead(membersEntry); + var members = await JsonSerializer.DeserializeAsync>(membersStream, cancellationToken: cancel); + + if (members != null && newGroupResourceName != null) + { + foreach (var memberResourceName in members) + { + try + { + await ContactRestore.AddContactToGroup(userId, newGroupResourceName, memberResourceName, cancel); + } + catch (Exception ex) + { + Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupMemberFailed", ex, $"Failed to add member {memberResourceName} to group {newGroupResourceName}"); + } + } + } + + _metadata.TryRemove(membersPath, out _); + membersEntry?.Dispose(); + } + catch (Exception ex) + { + Log.WriteWarningMessage(LOGTAG, "RestoreContactGroupMembersFailed", ex, $"Failed to restore members for group {originalPath}"); + } + } + + _metadata.TryRemove(originalPath, out _); + _temporaryFiles.TryRemove(originalPath, out var contentFile); + contentFile?.Dispose(); + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreContactGroupsFailed", ex, $"Failed to restore contact group {group.Key}"); + } + } + } +} diff --git a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Drive.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Drive.cs new file mode 100644 index 000000000..ec3e86548 --- /dev/null +++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Drive.cs @@ -0,0 +1,476 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Logging; +using Duplicati.Proprietary.GoogleWorkspace.SourceItems; +using Google.Apis.Drive.v3.Data; +using Google.Apis.Upload; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +partial class RestoreProvider +{ + private DriveRestoreHelper? _driveRestoreHelper = null; + internal DriveRestoreHelper DriveRestore => _driveRestoreHelper ??= new DriveRestoreHelper(this); + + internal class DriveRestoreHelper(RestoreProvider Provider) + { + private string? _cachedDefaultDriveId = null; + private string? _cachedDefaultFolderId = null; + private bool _defaultDriveIdChecked = false; + + 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; + string? userId = null; + + if (target.Type == SourceItemType.Drive) + { + driveId = target.Metadata.GetValueOrDefault("gsuite:Id"); + } + else if (target.Type == SourceItemType.DriveFolder) + { + driveId = target.Metadata.GetValueOrDefault("gsuite:DriveId"); + _cachedDefaultFolderId = target.Metadata.GetValueOrDefault("gsuite:Id"); + } + else if (target.Type == SourceItemType.User) + { + userId = target.Metadata.GetValueOrDefault("gsuite:Id"); + } + else if (target.Type == SourceItemType.UserDrive) + { + userId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(userId) && string.IsNullOrWhiteSpace(driveId)) + { + try + { + var driveService = Provider._apiHelper.GetDriveService(userId); + var drive = await driveService.Drives.Get("root").ExecuteAsync(cancel); + driveId = drive.Id; + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "GetDefaultDriveIdFailed", ex, $"Failed to get user drive"); + } + } + + if (driveId != null && string.IsNullOrWhiteSpace(_cachedDefaultFolderId)) + { + _cachedDefaultDriveId = driveId; + + // Create "Restored" folder + try + { + var driveService = Provider._apiHelper.GetDriveService(userId ?? "me"); + + // Check if folder already exists + var listRequest = driveService.Files.List(); + listRequest.Q = $"mimeType='application/vnd.google-apps.folder' and name='Restored' and 'root' in parents and trashed=false"; + listRequest.Spaces = "drive"; + var existingFolders = await listRequest.ExecuteAsync(cancel); + + if (existingFolders.Files?.Count > 0) + { + _cachedDefaultFolderId = existingFolders.Files[0].Id; + } + else + { + // Create new folder + var folderMetadata = new Google.Apis.Drive.v3.Data.File + { + Name = "Restored", + MimeType = "application/vnd.google-apps.folder", + Parents = new List { "root" } + }; + + var createdFolder = await driveService.Files.Create(folderMetadata).ExecuteAsync(cancel); + _cachedDefaultFolderId = createdFolder.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); + } + + public async Task CreateFolder(string userId, string parentFolderId, string name, CancellationToken cancel) + { + var driveService = Provider._apiHelper.GetDriveService(userId); + + // Check if folder already exists + var listRequest = driveService.Files.List(); + listRequest.Q = $"mimeType='application/vnd.google-apps.folder' and name='{name.Replace("'", "\\'")}' and '{parentFolderId}' in parents and trashed=false"; + listRequest.Spaces = "drive"; + var existingFolders = await listRequest.ExecuteAsync(cancel); + + if (existingFolders.Files?.Count > 0) + { + if (Provider._ignoreExisting) + { + Log.WriteInformationMessage(LOGTAG, "CreateFolderSkipExisting", $"Folder {name} already exists, skipping."); + return existingFolders.Files[0].Id; + } + // Return existing folder ID + return existingFolders.Files[0].Id; + } + + // Create new folder + var folderMetadata = new Google.Apis.Drive.v3.Data.File + { + Name = name, + MimeType = "application/vnd.google-apps.folder", + Parents = new List { parentFolderId } + }; + + var createdFolder = await driveService.Files.Create(folderMetadata).ExecuteAsync(cancel); + return createdFolder.Id; + } + + public async Task UploadFile(string userId, string parentFolderId, string name, Stream contentStream, string? mimeType, CancellationToken cancel) + { + var driveService = Provider._apiHelper.GetDriveService(userId); + + // Check if file already exists + if (!Provider._ignoreExisting) + { + var listRequest = driveService.Files.List(); + listRequest.Q = $"name='{name.Replace("'", "\\'")}' and '{parentFolderId}' in parents and trashed=false"; + listRequest.Spaces = "drive"; + var existingFiles = await listRequest.ExecuteAsync(cancel); + + if (existingFiles.Files?.Count > 0) + { + var existingFile = existingFiles.Files[0]; + // Check if it's the same file by size + if (existingFile.Size == contentStream.Length) + { + Log.WriteInformationMessage(LOGTAG, "UploadFileSkipDuplicate", $"File {name} already exists with same size, skipping."); + return existingFile.Id; + } + } + } + + // Upload file + var fileMetadata = new Google.Apis.Drive.v3.Data.File + { + Name = name, + Parents = new List { parentFolderId } + }; + + // For Google Workspace files, we need to handle them differently + if (mimeType?.StartsWith("application/vnd.google-apps.") == true) + { + // Create the file + var createdFile = await driveService.Files.Create(fileMetadata).ExecuteAsync(cancel); + return createdFile.Id; + } + else + { + // Upload binary content + var uploadRequest = driveService.Files.Create(fileMetadata, contentStream, mimeType ?? "application/octet-stream"); + var uploadResult = await uploadRequest.UploadAsync(cancel); + + if (uploadResult.Status == UploadStatus.Failed) + { + throw new Exception($"Failed to upload file: {uploadResult.Exception?.Message}"); + } + + return uploadRequest.ResponseBody?.Id; + } + } + + public async Task RestorePermissions(string userId, string fileId, Stream permissionsStream, CancellationToken cancel) + { + var driveService = Provider._apiHelper.GetDriveService(userId); + + List? permissions; + try + { + permissions = await JsonSerializer.DeserializeAsync>(permissionsStream, cancellationToken: cancel); + } + catch + { + return; + } + + if (permissions == null || permissions.Count == 0) return; + + foreach (var permission in permissions) + { + try + { + // Skip owner permissions (can't change ownership easily) + if (permission.Role == "owner") continue; + + var newPermission = new Permission + { + Role = permission.Role, + Type = permission.Type, + EmailAddress = permission.EmailAddress, + Domain = permission.Domain, + AllowFileDiscovery = permission.AllowFileDiscovery + }; + + await driveService.Permissions.Create(newPermission, fileId).ExecuteAsync(cancel); + } + catch (Exception ex) + { + Log.WriteWarningMessage(LOGTAG, "RestorePermissionFailed", ex, $"Failed to restore permission for file {fileId}"); + } + } + } + } + + private async Task RestoreDriveFolders(CancellationToken cancel) + { + if (RestoreTarget == null) + throw new InvalidOperationException("Restore target entry is not set"); + + var folders = GetMetadataByType(SourceItemType.DriveFolder); + if (folders.Count == 0) + return; + + (var driveId, var defaultFolderId) = await DriveRestore.GetDefaultDriveAndFolder(cancel); + + string? userId = null; + if (RestoreTarget.Type == SourceItemType.User) + { + userId = RestoreTarget.Metadata.GetValueOrDefault("gsuite:Id"); + } + else if (RestoreTarget.Type == SourceItemType.UserDrive) + { + userId = RestoreTarget.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + } + else if (RestoreTarget.Type == SourceItemType.DriveFolder) + { + userId = RestoreTarget.Metadata.GetValueOrDefault("gsuite:UserId"); + } + + if (string.IsNullOrWhiteSpace(userId)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreDriveFoldersMissingUser", null, "Could not determine target user for Drive folder restore."); + return; + } + + // Sort folders by path length to ensure parents are created before children + var sortedFolders = folders.OrderBy(k => k.Key.Split(Path.DirectorySeparatorChar).Length).ToList(); + + foreach (var folder in sortedFolders) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = folder.Key; + var metadata = folder.Value; + var displayName = metadata.GetValueOrDefault("gsuite:Name") ?? Path.GetFileName(originalPath.TrimEnd(Path.DirectorySeparatorChar)); + + _metadata.TryRemove(originalPath, out _); + + if (string.IsNullOrWhiteSpace(displayName)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreDriveFoldersMissingName", null, $"Missing display name for folder {originalPath}, skipping."); + continue; + } + + // Determine parent folder ID + var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? ""); + var parentId = defaultFolderId ?? "root"; + + if (parentPath != null && _restoredDriveFolderMap.TryGetValue(parentPath, out var mappedParentId)) + { + parentId = mappedParentId; + } + + var newFolderId = await DriveRestore.CreateFolder(userId, parentId, displayName, cancel); + if (newFolderId != null) + { + _restoredDriveFolderMap[originalPath] = newFolderId; + } + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreDriveFoldersFailed", ex, $"Failed to restore folder {folder.Key}"); + } + } + } + + private async Task RestoreDriveFiles(CancellationToken cancel) + { + if (RestoreTarget == null) + throw new InvalidOperationException("Restore target entry is not set"); + + var files = GetMetadataByType(SourceItemType.DriveFile); + if (files.Count == 0) + return; + + (var driveId, var defaultFolderId) = await DriveRestore.GetDefaultDriveAndFolder(cancel); + + string? userId = null; + if (RestoreTarget.Type == SourceItemType.User) + { + userId = RestoreTarget.Metadata.GetValueOrDefault("gsuite:Id"); + } + else if (RestoreTarget.Type == SourceItemType.UserDrive) + { + userId = RestoreTarget.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + } + else if (RestoreTarget.Type == SourceItemType.DriveFolder) + { + userId = RestoreTarget.Metadata.GetValueOrDefault("gsuite:UserId"); + } + + if (string.IsNullOrWhiteSpace(userId)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreDriveFilesMissingUser", null, "Could not determine target user for Drive file restore."); + return; + } + + foreach (var file in files) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = file.Key; + var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath); + + if (contentEntry == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreDriveFilesMissingContent", null, $"Missing content for file {originalPath}, skipping."); + continue; + } + + var metadata = file.Value; + var displayName = metadata.GetValueOrDefault("gsuite:Name") ?? Path.GetFileName(originalPath); + var mimeType = metadata.GetValueOrDefault("gsuite:MimeType"); + + // Determine parent folder ID + var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? ""); + var parentId = defaultFolderId ?? "root"; + + if (parentPath != null && _restoredDriveFolderMap.TryGetValue(parentPath, out var mappedParentId)) + { + parentId = mappedParentId; + } + + using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry)) + { + await DriveRestore.UploadFile(userId, parentId, displayName, contentStream, mimeType, cancel); + } + + _metadata.TryRemove(originalPath, out _); + _temporaryFiles.TryRemove(originalPath, out var contentFile); + contentFile?.Dispose(); + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreDriveFilesFailed", ex, $"Failed to restore file {file.Key}"); + } + } + } + + private async Task RestoreDrivePermissions(CancellationToken cancel) + { + if (RestoreTarget == null) + throw new InvalidOperationException("Restore target entry is not set"); + + var permissions = GetMetadataByType(SourceItemType.DrivePermission); + if (permissions.Count == 0) + return; + + string? userId = null; + if (RestoreTarget.Type == SourceItemType.User) + { + userId = RestoreTarget.Metadata.GetValueOrDefault("gsuite:Id"); + } + else if (RestoreTarget.Type == SourceItemType.UserDrive) + { + userId = RestoreTarget.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + } + + if (string.IsNullOrWhiteSpace(userId)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreDrivePermissionsMissingUser", null, "Could not determine target user for Drive permission restore."); + return; + } + + foreach (var permission in permissions) + { + if (cancel.IsCancellationRequested) + break; + + try + { + var originalPath = permission.Key; + var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath); + + if (contentEntry == null) + { + Log.WriteWarningMessage(LOGTAG, "RestoreDrivePermissionsMissingContent", null, $"Missing content for permission {originalPath}, skipping."); + continue; + } + + // Get the file ID from parent path + var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? ""); + string? fileId = null; + + if (parentPath != null) + { + // Check if it's a folder + if (_restoredDriveFolderMap.TryGetValue(parentPath, out var folderId)) + { + fileId = folderId; + } + else + { + // It might be a file - check metadata + var fileMetadata = _metadata.FirstOrDefault(m => m.Key == parentPath.TrimEnd(Path.DirectorySeparatorChar)); + if (!string.IsNullOrEmpty(fileMetadata.Key)) + { + fileId = fileMetadata.Value.GetValueOrDefault("gsuite:Id"); + } + } + } + + if (string.IsNullOrWhiteSpace(fileId)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreDrivePermissionsMissingFileId", null, $"Could not find file ID for permission {originalPath}, skipping."); + continue; + } + + using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry)) + { + await DriveRestore.RestorePermissions(userId, fileId, contentStream, cancel); + } + + _metadata.TryRemove(originalPath, out _); + _temporaryFiles.TryRemove(originalPath, out var contentFile); + contentFile?.Dispose(); + } + catch (Exception ex) + { + Log.WriteErrorMessage(LOGTAG, "RestoreDrivePermissionsFailed", ex, $"Failed to restore permission {permission.Key}"); + } + } + } +} diff --git a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Gmail.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Gmail.cs new file mode 100644 index 000000000..30b1cc95f --- /dev/null +++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Gmail.cs @@ -0,0 +1,400 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Logging; +using Duplicati.Proprietary.GoogleWorkspace.SourceItems; +using Google.Apis.Gmail.v1.Data; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +partial class RestoreProvider +{ + private GmailRestoreHelper? _gmailRestoreHelper = null; + internal GmailRestoreHelper GmailRestore => _gmailRestoreHelper ??= new GmailRestoreHelper(this); + + internal class GmailRestoreHelper(RestoreProvider Provider) + { + private string? _targetUserId = null; + private string? _targetLabelId = null; + private bool _hasLoadedTargetInfo = false; + + public async Task<(string? UserId, string? LabelId)> GetUserIdAndLabelTarget(CancellationToken cancel) + { + if (_hasLoadedTargetInfo) + { + if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetLabelId)) + return (null, null); + return (_targetUserId!, _targetLabelId!); + } + + var target = Provider.RestoreTarget; + if (target == null) + throw new InvalidOperationException("Restore target is not set"); + + if (target.Type == SourceItemType.User) + { + _targetUserId = target.Metadata.GetValueOrDefault("gsuite:Id"); + if (!string.IsNullOrWhiteSpace(_targetUserId)) + _targetLabelId = await GetDefaultRestoreTargetLabel(_targetUserId, cancel); + } + else if (target.Type == SourceItemType.UserGmail) + { + _targetUserId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(_targetUserId)) + throw new InvalidOperationException("User ID is not set"); + _targetLabelId = await GetDefaultRestoreTargetLabel(_targetUserId, cancel); + } + else if (target.Type == SourceItemType.GmailLabel) + { + _targetUserId = target.Path.TrimStart('/').Split('/').Skip(1).FirstOrDefault(); + _targetLabelId = target.Metadata.GetValueOrDefault("gsuite:Id"); + } + else + { + Log.WriteWarningMessage(LOGTAG, "RestoreGmailInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring Gmail items."); + } + + _hasLoadedTargetInfo = true; + + if (string.IsNullOrWhiteSpace(_targetUserId) || string.IsNullOrWhiteSpace(_targetLabelId)) + { + Log.WriteWarningMessage(LOGTAG, "RestoreGmailMissingIds", null, $"Missing target userId or labelId for restoring Gmail items."); + return (null, null); + } + + return (_targetUserId, _targetLabelId); + } + + private async Task GetDefaultRestoreTargetLabel(string userId, CancellationToken cancel) + { + const string RESTORED_LABEL_NAME = "Restored"; + + var gmailService = Provider._apiHelper.GetGmailService(userId); + + // Check if label already exists + var labelsResponse = await gmailService.Users.Labels.List(userId).ExecuteAsync(cancel); + var existingLabel = labelsResponse.Labels?.FirstOrDefault(l => + l.Name?.Equals(RESTORED_LABEL_NAME, StringComparison.OrdinalIgnoreCase) == true); + + if (existingLabel != null) + return existingLabel.Id; + + // Create new label + var newLabel = new Label + { + Name = RESTORED_LABEL_NAME, + LabelListVisibility = "labelShow", + MessageListVisibility = "show" + }; + + var createdLabel = await gmailService.Users.Labels.Create(newLabel, userId).ExecuteAsync(cancel); + return createdLabel.Id; + } + + public async Task RestoreLabel(string userId, Stream labelStream, CancellationToken cancel) + { + var label = await JsonSerializer.DeserializeAsync