diff --git a/Duplicati.slnx b/Duplicati.slnx index 76d7df4a3..7d010723a 100644 --- a/Duplicati.slnx +++ b/Duplicati.slnx @@ -75,6 +75,7 @@ + diff --git a/proprietary/GoogleWorkspace/APIHelper.cs b/proprietary/GoogleWorkspace/APIHelper.cs new file mode 100644 index 000000000..f72ec74f1 --- /dev/null +++ b/proprietary/GoogleWorkspace/APIHelper.cs @@ -0,0 +1,248 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Google.Apis.Auth.OAuth2; +using Google.Apis.Auth.OAuth2.Flows; +using Google.Apis.Gmail.v1; +using Google.Apis.Admin.Directory.directory_v1; +using Google.Apis.Calendar.v3; +using Google.Apis.Drive.v3; +using Google.Apis.PeopleService.v1; +using Google.Apis.Tasks.v1; +using Google.Apis.Keep.v1; +using Google.Apis.Groupssettings.v1; +using Google.Apis.HangoutsChat.v1; +using Google.Apis.Services; +using Google.Apis.Http; +using Google.Apis.Util; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +public class APIHelper +{ + private readonly string? _clientId; + private readonly string? _clientSecret; + private readonly string? _refreshToken; + private readonly string? _serviceAccountJson; + private readonly string? _adminEmail; + private readonly OptionsHelper.GoogleWorkspaceOptions _options; + private readonly HashSet _scopes; + + private GmailService? _gmailService; + private DirectoryService? _directoryService; + private CalendarService? _calendarService; + private DriveService? _driveService; + private PeopleServiceService? _peopleService; + private TasksService? _tasksService; + private KeepService? _keepService; + private GroupssettingsService? _groupsSettingsService; + private HangoutsChatService? _chatService; + + private static readonly Dictionary _rootTypeScopes = new() + { + { 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(); + } + + 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) + { + var backoffInitializer = new BackOffInitializer(); + + if (!string.IsNullOrEmpty(_serviceAccountJson)) + { + var credential = GoogleCredential.FromJson(_serviceAccountJson) + .CreateScoped(_scopes.ToArray()); + + var userToImpersonate = userId ?? _adminEmail; + if (!string.IsNullOrEmpty(userToImpersonate)) + { + credential = credential.CreateWithUser(userToImpersonate); + } + + return new BaseClientService.Initializer() + { + HttpClientInitializer = new CompositeHttpClientInitializer(credential, backoffInitializer), + ApplicationName = "Duplicati", + }; + } + else if (!string.IsNullOrEmpty(_clientId) && !string.IsNullOrEmpty(_clientSecret) && !string.IsNullOrEmpty(_refreshToken)) + { + var credential = new UserCredential(new GoogleAuthorizationCodeFlow( + new GoogleAuthorizationCodeFlow.Initializer + { + ClientSecrets = new ClientSecrets + { + ClientId = _clientId, + ClientSecret = _clientSecret + } + }), + "user", + new Google.Apis.Auth.OAuth2.Responses.TokenResponse { RefreshToken = _refreshToken }); + + return new BaseClientService.Initializer() + { + HttpClientInitializer = new CompositeHttpClientInitializer(credential, backoffInitializer), + ApplicationName = "Duplicati", + }; + } + else + { + throw new Exception("Missing credentials"); + } + } + + public void TestConnection() + { + if (_gmailService == null) Initialize(); + _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)); + } + + public DirectoryService GetDirectoryService() + { + if (_directoryService == null) Initialize(); + return _directoryService!; + } + + public CalendarService GetCalendarService() + { + if (_calendarService == null) Initialize(); + return _calendarService!; + } + + public DriveService GetDriveService() + { + if (_driveService == null) Initialize(); + return _driveService!; + } + + public PeopleServiceService GetPeopleService() + { + if (_peopleService == null) Initialize(); + return _peopleService!; + } + + public TasksService GetTasksService() + { + if (_tasksService == null) Initialize(); + return _tasksService!; + } + + public KeepService GetKeepService() + { + if (_keepService == null) Initialize(); + return _keepService!; + } + + public GroupssettingsService GetGroupsSettingsService() + { + if (_groupsSettingsService == null) Initialize(); + return _groupsSettingsService!; + } + + public HangoutsChatService GetChatService() + { + if (_chatService == null) Initialize(); + return _chatService!; + } + + private class CompositeHttpClientInitializer : IConfigurableHttpClientInitializer + { + private readonly IConfigurableHttpClientInitializer[] _initializers; + + public CompositeHttpClientInitializer(params IConfigurableHttpClientInitializer[] initializers) + { + _initializers = initializers; + } + + public void Initialize(ConfigurableHttpClient httpClient) + { + foreach (var initializer in _initializers) + { + initializer.Initialize(httpClient); + } + } + } + + private class BackOffInitializer : IConfigurableHttpClientInitializer + { + public void Initialize(ConfigurableHttpClient httpClient) + { + var backoffHandler = new BackOffHandler(new BackOffHandler.Initializer(new ExponentialBackOff()) + { + HandleUnsuccessfulResponseFunc = (r) => r.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable || r.StatusCode == (System.Net.HttpStatusCode)429 + }); + + httpClient.MessageHandler.AddUnsuccessfulResponseHandler(backoffHandler); + httpClient.MessageHandler.AddExceptionHandler(backoffHandler); + } + } +} diff --git a/proprietary/GoogleWorkspace/Duplicati.Proprietary.GoogleWorkspace.csproj b/proprietary/GoogleWorkspace/Duplicati.Proprietary.GoogleWorkspace.csproj new file mode 100644 index 000000000..65471b8d2 --- /dev/null +++ b/proprietary/GoogleWorkspace/Duplicati.Proprietary.GoogleWorkspace.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + Library + Duplicati.Proprietary.GoogleWorkspace + enable + enable + + + + + + + + + + + + + + + + + + + + + + diff --git a/proprietary/GoogleWorkspace/OptionsHelper.cs b/proprietary/GoogleWorkspace/OptionsHelper.cs new file mode 100644 index 000000000..fc9c41cce --- /dev/null +++ b/proprietary/GoogleWorkspace/OptionsHelper.cs @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Interface; +using Duplicati.Library.Utility.Options; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +[Flags] +public enum GoogleRootType +{ + Users = 1, + Groups = 2, + SharedDrives = 4, + Sites = 8, + OrganizationalUnits = 16 +} + +[Flags] +public enum GoogleUserType +{ + Gmail = 1, + Drive = 2, + Calendar = 4, + Contacts = 8, + Tasks = 16, + Keep = 32, + Chat = 64 +} + +public static class OptionsHelper +{ + public const string ModuleKey = "googleworkspace"; + + public const string GOOGLE_CLIENT_ID_OPTION = "google-client-id"; + public const string GOOGLE_CLIENT_SECRET_OPTION = "google-client-secret"; + public const string GOOGLE_REFRESH_TOKEN_OPTION = "google-refresh-token"; + public const string GOOGLE_SERVICE_ACCOUNT_JSON_OPTION = "google-service-account-json"; + public const string GOOGLE_SERVICE_ACCOUNT_FILE_OPTION = "google-service-account-file"; + 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 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]), + new CommandLineArgument(GOOGLE_REFRESH_TOKEN_OPTION, CommandLineArgument.ArgumentType.Password, Strings.Options.GoogleRefreshTokenShort, Strings.Options.GoogleRefreshTokenLong), + new CommandLineArgument(GOOGLE_SERVICE_ACCOUNT_JSON_OPTION, CommandLineArgument.ArgumentType.String, 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), + 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), + ]; + + public class GoogleWorkspaceOptions + { + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? RefreshToken { get; set; } + public string? ServiceAccountJson { get; set; } + 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) + { + var result = new GoogleWorkspaceOptions(); + + if (options.ContainsKey(GOOGLE_CLIENT_ID_OPTION)) + result.ClientId = options[GOOGLE_CLIENT_ID_OPTION]; + if (options.ContainsKey(GOOGLE_CLIENT_SECRET_OPTION)) + result.ClientSecret = options[GOOGLE_CLIENT_SECRET_OPTION]; + if (options.ContainsKey(GOOGLE_REFRESH_TOKEN_OPTION)) + result.RefreshToken = options[GOOGLE_REFRESH_TOKEN_OPTION]; + + if (options.ContainsKey(GOOGLE_SERVICE_ACCOUNT_JSON_OPTION)) + result.ServiceAccountJson = options[GOOGLE_SERVICE_ACCOUNT_JSON_OPTION]; + + if (string.IsNullOrEmpty(result.ServiceAccountJson) && options.ContainsKey(GOOGLE_SERVICE_ACCOUNT_FILE_OPTION)) + { + var path = options[GOOGLE_SERVICE_ACCOUNT_FILE_OPTION]; + if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) + { + result.ServiceAccountJson = System.IO.File.ReadAllText(path); + } + } + + if (options.ContainsKey(GOOGLE_ADMIN_EMAIL_OPTION)) + result.AdminEmail = options[GOOGLE_ADMIN_EMAIL_OPTION]; + + var includedRootTypes = Library.Utility.Utility.ParseFlagsOption(options, GOOGLE_INCLUDED_ROOT_TYPES_OPTION, (GoogleRootType)Enum.GetValues().Cast().Aggregate(0, (acc, type) => acc | type)); + var includedUserTypes = Library.Utility.Utility.ParseFlagsOption(options, GOOGLE_INCLUDED_USER_TYPES_OPTION, (GoogleUserType)Enum.GetValues().Cast().Aggregate(0, (acc, type) => acc | type)); + + 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.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs new file mode 100644 index 000000000..786ab7bcd --- /dev/null +++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +public sealed class RestoreProvider : IRestoreDestinationProviderModule, IDisposable +{ + private readonly APIHelper _apiHelper; + private readonly string _restorePath; + private readonly OptionsHelper.GoogleWorkspaceOptions _options; + private readonly string _tempPath; + private readonly Dictionary _tempFiles = new(); + private readonly Dictionary> _metadata = new(); + + public RestoreProvider() + { + _apiHelper = null!; + _restorePath = null!; + _options = null!; + _tempPath = null!; + } + + public RestoreProvider(string url, string restorePath, Dictionary options) + { + _restorePath = restorePath; + _options = OptionsHelper.ParseOptions(options); + _apiHelper = new APIHelper(_options); + + _tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Duplicati-GoogleWorkspace-Restore-" + Guid.NewGuid().ToString()); + System.IO.Directory.CreateDirectory(_tempPath); + } + + public string Key => OptionsHelper.ModuleKey; + + public string DisplayName => Strings.Common.DisplayName; + + public string Description => Strings.Common.Description; + + public IList SupportedCommands => OptionsHelper.SupportedCommands; + + public string TargetDestination => _restorePath; + + public void Dispose() + { + if (!string.IsNullOrEmpty(_tempPath) && System.IO.Directory.Exists(_tempPath)) + { + try + { + System.IO.Directory.Delete(_tempPath, true); + } + catch { } + } + } + + public Task Initialize(CancellationToken cancellationToken) + { + _apiHelper.Initialize(); + return Task.CompletedTask; + } + + public Task Test(CancellationToken cancellationToken) + { + _apiHelper.TestConnection(); + return Task.CompletedTask; + } + + public Task CreateFolderIfNotExists(string path, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task FileExists(string path, CancellationToken cancellationToken) + { + return Task.FromResult(_tempFiles.ContainsKey(path)); + } + + public Task OpenWrite(string path, CancellationToken cancellationToken) + { + var tempFile = System.IO.Path.Combine(_tempPath, Guid.NewGuid().ToString()); + _tempFiles[path] = tempFile; + return Task.FromResult(new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None)); + } + + public Task OpenRead(string path, CancellationToken cancellationToken) + { + if (_tempFiles.TryGetValue(path, out var tempFile) && System.IO.File.Exists(tempFile)) + { + return Task.FromResult(new FileStream(tempFile, FileMode.Open, FileAccess.Read, FileShare.Read)); + } + throw new FileNotFoundException("File not found in temp storage", path); + } + + public Task OpenReadWrite(string path, CancellationToken cancellationToken) + { + if (_tempFiles.TryGetValue(path, out var tempFile) && System.IO.File.Exists(tempFile)) + { + return Task.FromResult(new FileStream(tempFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None)); + } + throw new FileNotFoundException("File not found in temp storage", path); + } + + public Task GetFileLength(string path, CancellationToken cancellationToken) + { + if (_tempFiles.TryGetValue(path, out var tempFile) && System.IO.File.Exists(tempFile)) + { + return Task.FromResult(new FileInfo(tempFile).Length); + } + return Task.FromResult(-1L); + } + + public Task HasReadOnlyAttribute(string path, CancellationToken cancellationToken) + { + return Task.FromResult(false); + } + + public Task ClearReadOnlyAttribute(string path, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task WriteMetadata(string path, Dictionary metadata, bool isFolder, bool isRoot, CancellationToken cancellationToken) + { + _metadata[path] = metadata; + return Task.FromResult(true); + } + + public Task DeleteFolder(string path, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task DeleteFile(string path, CancellationToken cancellationToken) + { + if (_tempFiles.TryGetValue(path, out var tempFile)) + { + if (System.IO.File.Exists(tempFile)) + System.IO.File.Delete(tempFile); + _tempFiles.Remove(path); + } + return Task.CompletedTask; + } + + public Task Finalize(Action? progress, CancellationToken cancellationToken) + { + // TODO: Implement restore logic based on _tempFiles and _metadata + return Task.CompletedTask; + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarAclSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarAclSourceEntry.cs new file mode 100644 index 000000000..3a9db0965 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/CalendarAclSourceEntry.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class CalendarAclSourceEntry(SourceProvider provider, string parentPath, string calendarId) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "acl.json"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetCalendarService(); + var request = service.Acl.List(calendarId); + var acl = await request.ExecuteAsync(cancellationToken); + + var json = JsonSerializer.Serialize(acl, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarEventAttachmentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarEventAttachmentSourceEntry.cs new file mode 100644 index 000000000..b505b164a --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/CalendarEventAttachmentSourceEntry.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Calendar.v3.Data; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class CalendarEventAttachmentSourceEntry(SourceProvider provider, string parentPath, EventAttachment attachment) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, attachment.Title), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(attachment.FileId)) + { + var service = provider.ApiHelper.GetDriveService(); + var request = service.Files.Get(attachment.FileId); + var stream = new MemoryStream(); + await request.DownloadAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + else if (!string.IsNullOrEmpty(attachment.FileUrl)) + { + using var client = new HttpClient(); + var response = await client.GetAsync(attachment.FileUrl, cancellationToken); + if (response.IsSuccessStatusCode) + { + var stream = new MemoryStream(); + await response.Content.CopyToAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + } + + throw new FileNotFoundException("Cannot download attachment"); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.CalendarEventAttachment.ToString() }, + { "gsuite:Name", attachment.Title }, + { "gsuite:id", attachment.FileId ?? attachment.FileUrl }, + { "gsuite:MimeType", attachment.MimeType } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarEventFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarEventFileSourceEntry.cs new file mode 100644 index 000000000..bcc334f76 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/CalendarEventFileSourceEntry.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Calendar.v3.Data; +using System.Text; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class CalendarEventFileSourceEntry(string parentPath, Event evt) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "event.ics"), + evt.CreatedDateTimeOffset.HasValue ? evt.CreatedDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, + evt.UpdatedDateTimeOffset.HasValue ? evt.UpdatedDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var sb = new StringBuilder(); + sb.AppendLine("BEGIN:VCALENDAR"); + sb.AppendLine("VERSION:2.0"); + sb.AppendLine("PRODID:-//Duplicati//Google Workspace Backup//EN"); + sb.AppendLine("BEGIN:VEVENT"); + sb.AppendLine($"UID:{evt.Id}"); + sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}"); + + if (evt.Start != null) + { + if (evt.Start.DateTimeDateTimeOffset.HasValue) + sb.AppendLine($"DTSTART:{evt.Start.DateTimeDateTimeOffset.Value.UtcDateTime:yyyyMMddTHHmmssZ}"); + else if (evt.Start.Date != null) + sb.AppendLine($"DTSTART;VALUE=DATE:{evt.Start.Date.Replace("-", "")}"); + } + + if (evt.End != null) + { + if (evt.End.DateTimeDateTimeOffset.HasValue) + sb.AppendLine($"DTEND:{evt.End.DateTimeDateTimeOffset.Value.UtcDateTime:yyyyMMddTHHmmssZ}"); + else if (evt.End.Date != null) + sb.AppendLine($"DTEND;VALUE=DATE:{evt.End.Date.Replace("-", "")}"); + } + + if (!string.IsNullOrEmpty(evt.Summary)) sb.AppendLine($"SUMMARY:{evt.Summary}"); + if (!string.IsNullOrEmpty(evt.Description)) sb.AppendLine($"DESCRIPTION:{evt.Description}"); + if (!string.IsNullOrEmpty(evt.Location)) sb.AppendLine($"LOCATION:{evt.Location}"); + + sb.AppendLine("END:VEVENT"); + sb.AppendLine("END:VCALENDAR"); + + return new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.CalendarEvent.ToString() }, + { "gsuite:Name", "event.ics" }, + { "gsuite:id", evt.Id }, + { "gsuite:HtmlLink", evt.HtmlLink } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarEventSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarEventSourceEntry.cs new file mode 100644 index 000000000..8a6e88f01 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/CalendarEventSourceEntry.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.Calendar.v3.Data; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class CalendarEventSourceEntry(SourceProvider provider, string parentPath, Event evt) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, evt.Id)), evt.CreatedDateTimeOffset.HasValue ? evt.CreatedDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, evt.UpdatedDateTimeOffset.HasValue ? evt.UpdatedDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new CalendarEventFileSourceEntry(this.Path, evt); + + if (evt.Attachments != null) + { + foreach (var attachment in evt.Attachments) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new CalendarEventAttachmentSourceEntry(provider, this.Path, attachment); + } + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.CalendarEvent.ToString() }, + { "gsuite:Name", evt.Summary ?? evt.Id }, + { "gsuite:id", evt.Id }, + { "gsuite:HtmlLink", evt.HtmlLink } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarMetadataSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarMetadataSourceEntry.cs new file mode 100644 index 000000000..95ed73be6 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/CalendarMetadataSourceEntry.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Calendar.v3.Data; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class CalendarMetadataSourceEntry(string parentPath, CalendarListEntry calendar) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "metadata.json"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(calendar, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/CalendarSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/CalendarSourceEntry.cs new file mode 100644 index 000000000..3c010ab3c --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/CalendarSourceEntry.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.Calendar.v3; +using Google.Apis.Calendar.v3.Data; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class CalendarSourceEntry(SourceProvider provider, string parentPath, CalendarListEntry calendar) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, calendar.SummaryOverride ?? calendar.Summary)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new CalendarMetadataSourceEntry(this.Path, calendar); + + if (cancellationToken.IsCancellationRequested) yield break; + if (provider.ApiHelper.HasScope(CalendarService.Scope.Calendar)) + yield return new CalendarAclSourceEntry(provider, this.Path, calendar.Id); + + var service = provider.ApiHelper.GetCalendarService(); + var request = service.Events.List(calendar.Id); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var events = await request.ExecuteAsync(cancellationToken); + + if (events.Items != null) + { + foreach (var evt in events.Items) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new CalendarEventSourceEntry(provider, this.Path, evt); + } + } + nextPageToken = events.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.Calendar.ToString() }, + { "gsuite:Name", calendar.SummaryOverride ?? calendar.Summary }, + { "gsuite:id", calendar.Id }, + { "gsuite:Description", calendar.Description } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatAttachmentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatAttachmentSourceEntry.cs new file mode 100644 index 000000000..072a1dbec --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ChatAttachmentSourceEntry.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.HangoutsChat.v1.Data; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ChatAttachmentSourceEntry(string parentPath, Attachment attachment) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, attachment.ContentName ?? attachment.Name.Split('/').Last()), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(attachment.DownloadUri)) + { + using var client = new HttpClient(); + var response = await client.GetAsync(attachment.DownloadUri, cancellationToken); + if (response.IsSuccessStatusCode) + { + var stream = new MemoryStream(); + await response.Content.CopyToAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + } + + throw new FileNotFoundException("Cannot download attachment"); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.ChatAttachment.ToString() }, + { "gsuite:Name", attachment.ContentName ?? attachment.Name.Split('/').Last() }, + { "gsuite:id", attachment.Name }, + { "gsuite:ContentType", attachment.ContentType } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs new file mode 100644 index 000000000..f21fd2d99 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.HangoutsChat.v1.Data; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ChatMessageFileSourceEntry(string parentPath, Message message) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "message.json"), + message.CreateTimeDateTimeOffset.HasValue ? message.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, + DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.ChatMessage.ToString() }, + { "gsuite:Name", "message.json" }, + { "gsuite:id", message.Name } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs new file mode 100644 index 000000000..ac963c492 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.HangoutsChat.v1.Data; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ChatMessageSourceEntry(string parentPath, Message message) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, message.Name.Split('/').Last())), + message.CreateTimeDateTimeOffset.HasValue ? message.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, + DateTime.UnixEpoch) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ChatMessageFileSourceEntry(this.Path, message); + + if (message.Attachment != null) + { + foreach (var attachment in message.Attachment) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ChatAttachmentSourceEntry(this.Path, attachment); + } + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.ChatMessage.ToString() }, + { "gsuite:Name", message.Name }, + { "gsuite:id", message.Name } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatSourceEntry.cs new file mode 100644 index 000000000..9f6240790 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ChatSourceEntry.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ChatSourceEntry(SourceProvider provider, string parentPath) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Chat")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetChatService(); + var request = service.Spaces.List(); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var spaces = await request.ExecuteAsync(cancellationToken); + + if (spaces.Spaces != null) + { + foreach (var space in spaces.Spaces) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ChatSpaceSourceEntry(provider, this.Path, space); + } + } + nextPageToken = spaces.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.UserChat.ToString() }, + { "gsuite:Name", "Chat" }, + { "gsuite:id", "Chat" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs new file mode 100644 index 000000000..afb1b7f1b --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.HangoutsChat.v1.Data; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ChatSpaceSourceEntry(SourceProvider provider, string parentPath, Space space) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, space.DisplayName ?? space.Name)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetChatService(); + var request = service.Spaces.Messages.List(space.Name); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var messages = await request.ExecuteAsync(cancellationToken); + + if (messages.Messages != null) + { + foreach (var message in messages.Messages) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ChatMessageSourceEntry(this.Path, message); + } + } + nextPageToken = messages.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.ChatSpace.ToString() }, + { "gsuite:Name", space.DisplayName ?? space.Name }, + { "gsuite:id", space.Name }, + { "gsuite:SpaceType", space.Type } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactFileSourceEntry.cs new file mode 100644 index 000000000..20e4eaa67 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ContactFileSourceEntry.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.PeopleService.v1.Data; +using System.Text; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ContactFileSourceEntry(string parentPath, Person person) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "contact.vcf"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + var sb = new StringBuilder(); + sb.AppendLine("BEGIN:VCARD"); + sb.AppendLine("VERSION:3.0"); + + if (person.Names != null) + { + foreach (var name in person.Names) + { + sb.AppendLine($"FN:{name.DisplayName}"); + sb.AppendLine($"N:{name.FamilyName};{name.GivenName};{name.MiddleName};;"); + } + } + + if (person.EmailAddresses != null) + { + foreach (var email in person.EmailAddresses) + { + sb.AppendLine($"EMAIL;TYPE={email.Type}:{email.Value}"); + } + } + + if (person.PhoneNumbers != null) + { + foreach (var phone in person.PhoneNumbers) + { + sb.AppendLine($"TEL;TYPE={phone.Type}:{phone.Value}"); + } + } + + sb.AppendLine("END:VCARD"); + + return Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString()))); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.Contact.ToString() }, + { "gsuite:Name", person.Names?.FirstOrDefault()?.DisplayName ?? person.ResourceName }, + { "gsuite:id", person.ResourceName }, + { "gsuite:Etag", person.ETag } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactGroupSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactGroupSourceEntry.cs new file mode 100644 index 000000000..17e2debef --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ContactGroupSourceEntry.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.PeopleService.v1.Data; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ContactGroupSourceEntry(string parentPath, ContactGroup group) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, (group.FormattedName ?? group.Name) + ".json"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(group, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.ContactGroup.ToString() }, + { "gsuite:Name", group.FormattedName ?? group.Name }, + { "gsuite:id", group.ResourceName }, + { "gsuite:Etag", group.ETag } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactGroupsFolderSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactGroupsFolderSourceEntry.cs new file mode 100644 index 000000000..e2d9f8464 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ContactGroupsFolderSourceEntry.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ContactGroupsFolderSourceEntry(SourceProvider provider, string parentPath) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Groups")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetPeopleService(); + var request = service.ContactGroups.List(); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var groups = await request.ExecuteAsync(cancellationToken); + + if (groups.ContactGroups != null) + { + foreach (var group in groups.ContactGroups) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ContactGroupSourceEntry(this.Path, group); + } + } + nextPageToken = groups.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.ContactGroup.ToString() }, // Or UserContactGroups? + { "gsuite:Name", "Groups" }, + { "gsuite:id", "Groups" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactPhotoSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactPhotoSourceEntry.cs new file mode 100644 index 000000000..2608702f3 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ContactPhotoSourceEntry.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.PeopleService.v1.Data; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ContactPhotoSourceEntry(string parentPath, Photo photo) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "photo.jpg"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(photo.Url)) + { + using var client = new HttpClient(); + var response = await client.GetAsync(photo.Url, cancellationToken); + if (response.IsSuccessStatusCode) + { + var stream = new MemoryStream(); + await response.Content.CopyToAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + } + + throw new FileNotFoundException("Cannot download photo"); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.ContactPhoto.ToString() }, + { "gsuite:Name", "photo.jpg" }, + { "gsuite:id", photo.Metadata?.Source?.Id ?? photo.Url } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactSourceEntry.cs new file mode 100644 index 000000000..4a0a49d2f --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ContactSourceEntry.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.PeopleService.v1.Data; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ContactSourceEntry(string parentPath, Person person) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, person.Names?.FirstOrDefault()?.DisplayName ?? person.ResourceName)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ContactFileSourceEntry(this.Path, person); + + if (person.Photos != null) + { + foreach (var photo in person.Photos) + { + // Skip if it's the default placeholder (if we can detect it) + // Usually default photos have a specific URL pattern or metadata. + // For now, let's just include all photos. + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ContactPhotoSourceEntry(this.Path, photo); + } + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.Contact.ToString() }, + { "gsuite:Name", person.Names?.FirstOrDefault()?.DisplayName ?? person.ResourceName }, + { "gsuite:id", person.ResourceName }, + { "gsuite:Etag", person.ETag } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/ContactsFolderSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ContactsFolderSourceEntry.cs new file mode 100644 index 000000000..6b2395cb4 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/ContactsFolderSourceEntry.cs @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class ContactsFolderSourceEntry(SourceProvider provider, string parentPath) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Contacts")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetPeopleService(); + var request = service.People.Connections.List("people/me"); + request.PersonFields = "names,emailAddresses,phoneNumbers,photos"; + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var connections = await request.ExecuteAsync(cancellationToken); + + if (connections.Connections != null) + { + foreach (var person in connections.Connections) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ContactSourceEntry(this.Path, person); + } + } + nextPageToken = connections.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.UserContacts.ToString() }, + { "gsuite:Name", "Contacts" }, + { "gsuite:id", "Contacts" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileCommentsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileCommentsSourceEntry.cs new file mode 100644 index 000000000..813bc14aa --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileCommentsSourceEntry.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; +using File = Google.Apis.Drive.v3.Data.File; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveFileCommentsSourceEntry(SourceProvider provider, string parentPath, File file) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "comments.json"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + var request = service.Comments.List(file.Id); + request.Fields = "*"; + var comments = await request.ExecuteAsync(cancellationToken); + + var json = JsonSerializer.Serialize(comments, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DriveFileComment.ToString() }, + { "gsuite:Name", "comments.json" }, + { "gsuite:id", file.Id } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileContentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileContentSourceEntry.cs new file mode 100644 index 000000000..cbbe163a5 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileContentSourceEntry.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using File = Google.Apis.Drive.v3.Data.File; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveFileContentSourceEntry(SourceProvider provider, string parentPath, File file) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "content"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => file.Size ?? -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + + if (IsGoogleDoc(file.MimeType)) + { + var exportMimeType = GetExportMimeType(file.MimeType); + var request = service.Files.Export(file.Id, exportMimeType); + var stream = new MemoryStream(); + await request.DownloadAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + else + { + var request = service.Files.Get(file.Id); + var stream = new MemoryStream(); + await request.DownloadAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + } + + private static bool IsGoogleDoc(string mimeType) + { + return mimeType.StartsWith("application/vnd.google-apps.") && + mimeType != "application/vnd.google-apps.folder" && + mimeType != "application/vnd.google-apps.shortcut"; + } + + private static string GetExportMimeType(string mimeType) + { + return mimeType switch + { + "application/vnd.google-apps.document" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.google-apps.spreadsheet" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.google-apps.presentation" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.google-apps.script" => "application/vnd.google-apps.script+json", + _ => "application/pdf" + }; + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DriveFileContent.ToString() }, + { "gsuite:Name", "content" }, + { "gsuite:id", file.Id }, + { "gsuite:MimeType", file.MimeType } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileMetadataSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileMetadataSourceEntry.cs new file mode 100644 index 000000000..562a58317 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileMetadataSourceEntry.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; +using File = Google.Apis.Drive.v3.Data.File; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveFileMetadataSourceEntry(string parentPath, File file) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "metadata.json"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DriveFileMetadata.ToString() }, + { "gsuite:Name", "metadata.json" }, + { "gsuite:id", file.Id } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFilePermissionsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFilePermissionsSourceEntry.cs new file mode 100644 index 000000000..b55b51a21 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveFilePermissionsSourceEntry.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; +using File = Google.Apis.Drive.v3.Data.File; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveFilePermissionsSourceEntry(SourceProvider provider, string parentPath, File file) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "permissions.json"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + var request = service.Permissions.List(file.Id); + var permissions = await request.ExecuteAsync(cancellationToken); + + var json = JsonSerializer.Serialize(permissions, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DrivePermission.ToString() }, + { "gsuite:Name", "permissions.json" }, + { "gsuite:id", file.Id } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionContentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionContentSourceEntry.cs new file mode 100644 index 000000000..ed0d1df45 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionContentSourceEntry.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Drive.v3.Data; +using File = Google.Apis.Drive.v3.Data.File; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveFileRevisionContentSourceEntry(SourceProvider provider, string parentPath, File file, Revision revision) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, revision.Id), revision.ModifiedTimeDateTimeOffset.HasValue ? revision.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, revision.ModifiedTimeDateTimeOffset.HasValue ? revision.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => revision.Size ?? -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + var url = $"https://www.googleapis.com/drive/v3/files/{file.Id}/revisions/{revision.Id}?alt=media"; + + if (IsGoogleDoc(file.MimeType)) + { + var exportMimeType = GetExportMimeType(file.MimeType); + if (revision.ExportLinks != null && revision.ExportLinks.ContainsKey(exportMimeType)) + { + url = revision.ExportLinks[exportMimeType]; + } + else + { + // Try to construct export URL if not present? + // Or skip. + // For now, let's try the constructed URL, but it might fail for Docs. + // Actually, for Docs, we can't download via alt=media. + // If ExportLinks is missing, we might be out of luck for that revision. + if (revision.ExportLinks == null || !revision.ExportLinks.ContainsKey(exportMimeType)) + { + throw new NotSupportedException($"Cannot export revision {revision.Id} for mimeType {file.MimeType}"); + } + } + } + + var response = await service.HttpClient.GetAsync(url, cancellationToken); + if (response.IsSuccessStatusCode) + { + var stream = new MemoryStream(); + await response.Content.CopyToAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + + throw new FileNotFoundException($"Cannot download revision: {response.StatusCode}"); + } + + private static bool IsGoogleDoc(string mimeType) + { + return mimeType.StartsWith("application/vnd.google-apps.") && + mimeType != "application/vnd.google-apps.folder" && + mimeType != "application/vnd.google-apps.shortcut"; + } + + private static string GetExportMimeType(string mimeType) + { + return mimeType switch + { + "application/vnd.google-apps.document" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.google-apps.spreadsheet" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.google-apps.presentation" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.google-apps.script" => "application/vnd.google-apps.script+json", + _ => "application/pdf" + }; + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DriveFileRevision.ToString() }, + { "gsuite:Name", revision.Id }, + { "gsuite:id", revision.Id }, + { "gsuite:MimeType", revision.MimeType } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionsSourceEntry.cs new file mode 100644 index 000000000..0d1b6a56b --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileRevisionsSourceEntry.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; +using File = Google.Apis.Drive.v3.Data.File; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveFileRevisionsSourceEntry(SourceProvider provider, string parentPath, File file) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Revisions")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + var request = service.Revisions.List(file.Id); + var revisions = await request.ExecuteAsync(cancellationToken); + + if (revisions.Revisions != null) + { + foreach (var revision in revisions.Revisions) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveFileRevisionContentSourceEntry(provider, this.Path, file, revision); + } + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DriveFileRevision.ToString() }, // Folder + { "gsuite:Name", "Revisions" }, + { "gsuite:id", file.Id + "/revisions" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFileSourceEntry.cs new file mode 100644 index 000000000..0e3b37e88 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveFileSourceEntry.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; +using File = Google.Apis.Drive.v3.Data.File; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveFileSourceEntry(SourceProvider provider, string parentPath, File file) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, file.Name)), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveFileContentSourceEntry(provider, this.Path, file); + + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveFileMetadataSourceEntry(this.Path, file); + + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveFilePermissionsSourceEntry(provider, this.Path, file); + + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveFileCommentsSourceEntry(provider, this.Path, file); + + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveFileRevisionsSourceEntry(provider, this.Path, file); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DriveFile.ToString() }, + { "gsuite:Name", file.Name }, + { "gsuite:id", file.Id }, + { "gsuite:MimeType", file.MimeType } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveFolderSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveFolderSourceEntry.cs new file mode 100644 index 000000000..6600fedd3 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveFolderSourceEntry.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveFolderSourceEntry(SourceProvider provider, string userId, string parentPath, string name, string folderId) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, name)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + var request = service.Files.List(); + request.Q = $"'{folderId}' in parents and trashed = false"; + request.Fields = "nextPageToken, files(id, name, mimeType, createdTime, modifiedTime, size, description, parents)"; + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var files = await request.ExecuteAsync(cancellationToken); + + if (files.Files != null) + { + foreach (var file in files.Files) + { + if (cancellationToken.IsCancellationRequested) yield break; + + if (file.MimeType == "application/vnd.google-apps.folder") + { + yield return new DriveFolderSourceEntry(provider, userId, this.Path, file.Name, file.Id); + } + else + { + yield return new DriveFileSourceEntry(provider, this.Path, file); + } + } + } + nextPageToken = files.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DriveFolder.ToString() }, + { "gsuite:Name", name }, + { "gsuite:id", folderId } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/DriveSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/DriveSourceEntry.cs new file mode 100644 index 000000000..6149a6bd3 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/DriveSourceEntry.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class DriveSourceEntry(SourceProvider provider, string userId, string parentPath) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Drive")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveFolderSourceEntry(provider, userId, this.Path, "My Drive", "root"); + + if (cancellationToken.IsCancellationRequested) yield break; + yield return new SharedDrivesSourceEntry(provider, userId, this.Path); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.Drive.ToString() }, + { "gsuite:Name", "Drive" }, + { "gsuite:id", "Drive" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/GmailLabelSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GmailLabelSourceEntry.cs new file mode 100644 index 000000000..57f4a119e --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/GmailLabelSourceEntry.cs @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.Gmail.v1.Data; +using Google.Apis.Gmail.v1; +using Google.Apis.Requests; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class GmailLabelSourceEntry(SourceProvider provider, string userId, string parentPath, Label label) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, label.Name)), null, null) +{ + public override IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + { + return EnumerateMessages(cancellationToken); + } + + private async IAsyncEnumerable EnumerateMessages([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetGmailService(userId); + var request = service.Users.Messages.List(userId); + request.LabelIds = new[] { label.Id }; + + do + { + if (cancellationToken.IsCancellationRequested) + yield break; + + var response = await request.ExecuteAsync(cancellationToken); + if (response.Messages != null) + { + var batch = new BatchRequest(service); + var messages = new List(); + + foreach (var msg in response.Messages) + { + var msgRequest = service.Users.Messages.Get(userId, msg.Id); + msgRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Minimal; + batch.Queue(msgRequest, (message, error, index, httpResponse) => + { + if (error == null) + { + lock (messages) + { + messages.Add(message); + } + } + }); + } + + await batch.ExecuteAsync(cancellationToken); + + foreach (var message in messages) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new GmailMessageSourceEntry(provider, userId, this.Path, message); + } + } + + request.PageToken = response.NextPageToken; + } while (!string.IsNullOrEmpty(request.PageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.GmailLabel.ToString() }, + { "gsuite:Name", label.Name }, + { "gsuite:id", label.Id }, + { "gsuite:LabelType", label.Type } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/GmailMessageSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GmailMessageSourceEntry.cs new file mode 100644 index 000000000..92e40a8bc --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/GmailMessageSourceEntry.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Gmail.v1.Data; +using Google.Apis.Gmail.v1; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class GmailMessageSourceEntry(SourceProvider provider, string userId, string parentPath, Message message) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, message.Id + ".eml"), + message.InternalDate.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(message.InternalDate.Value).UtcDateTime : DateTime.UnixEpoch, + message.InternalDate.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(message.InternalDate.Value).UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => message.SizeEstimate ?? 0; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetGmailService(userId); + var request = service.Users.Messages.Get(userId, message.Id); + request.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw; + var msg = await request.ExecuteAsync(cancellationToken); + + // Raw is base64url encoded string + var bytes = FromBase64Url(msg.Raw); + return new MemoryStream(bytes); + } + + private static byte[] FromBase64Url(string base64Url) + { + string padded = base64Url.Length % 4 == 0 + ? base64Url + : base64Url + new string('=', 4 - base64Url.Length % 4); + string base64 = padded.Replace('-', '+').Replace('_', '/'); + return Convert.FromBase64String(base64); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.GmailMessage.ToString() }, + { "gsuite:Name", message.Id }, + { "gsuite:id", message.Id }, + { "gsuite:ThreadId", message.ThreadId }, + { "gsuite:Snippet", message.Snippet } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/GmailSettingFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GmailSettingFileSourceEntry.cs new file mode 100644 index 000000000..8f534b7cb --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/GmailSettingFileSourceEntry.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using Duplicati.Library.Common.IO; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal enum GmailSettingType +{ + Filters, + Forwarding, + Vacation, + Signatures +} + +internal class GmailSettingFileSourceEntry(SourceProvider provider, string userId, string parentPath, string filename, GmailSettingType type) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, filename), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetGmailService(userId); + object? data = null; + + switch (type) + { + case GmailSettingType.Filters: + var filtersReq = service.Users.Settings.Filters.List(userId); + data = await filtersReq.ExecuteAsync(cancellationToken); + break; + case GmailSettingType.Forwarding: + var forwardingReq = service.Users.Settings.ForwardingAddresses.List(userId); + var forwarding = await forwardingReq.ExecuteAsync(cancellationToken); + var autoForwardingReq = service.Users.Settings.GetAutoForwarding(userId); + var autoForwarding = await autoForwardingReq.ExecuteAsync(cancellationToken); + data = new { ForwardingAddresses = forwarding, AutoForwarding = autoForwarding }; + break; + case GmailSettingType.Vacation: + var vacationReq = service.Users.Settings.GetVacation(userId); + data = await vacationReq.ExecuteAsync(cancellationToken); + break; + case GmailSettingType.Signatures: + var sendAsReq = service.Users.Settings.SendAs.List(userId); + data = await sendAsReq.ExecuteAsync(cancellationToken); + break; + } + + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", type.ToString() }, + { "gsuite:Name", filename }, + { "gsuite:id", filename } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/GmailSettingsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GmailSettingsSourceEntry.cs new file mode 100644 index 000000000..4cdc750aa --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/GmailSettingsSourceEntry.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class GmailSettingsSourceEntry(SourceProvider provider, string userId, string parentPath) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Settings")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + yield break; + yield return new GmailSettingFileSourceEntry(provider, userId, this.Path, "filters.json", GmailSettingType.Filters); + if (cancellationToken.IsCancellationRequested) + yield break; + yield return new GmailSettingFileSourceEntry(provider, userId, this.Path, "forwarding.json", GmailSettingType.Forwarding); + if (cancellationToken.IsCancellationRequested) + yield break; + yield return new GmailSettingFileSourceEntry(provider, userId, this.Path, "vacation.json", GmailSettingType.Vacation); + if (cancellationToken.IsCancellationRequested) + yield break; + yield return new GmailSettingFileSourceEntry(provider, userId, this.Path, "signatures.json", GmailSettingType.Signatures); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.GmailSettings.ToString() }, + { "gsuite:Name", "Settings" }, + { "gsuite:id", "Settings" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/GroupMembersSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GroupMembersSourceEntry.cs new file mode 100644 index 000000000..5633e1a75 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/GroupMembersSourceEntry.cs @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class GroupMembersSourceEntry(SourceProvider provider, string parentPath, string groupEmail) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "members.json"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDirectoryService(); + var request = service.Members.List(groupEmail); + var members = new List(); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) break; + request.PageToken = nextPageToken; + var response = await request.ExecuteAsync(cancellationToken); + + if (response.MembersValue != null) + { + members.AddRange(response.MembersValue); + } + nextPageToken = response.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + + var json = JsonSerializer.Serialize(members, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.GroupMember.ToString() }, + { "gsuite:Name", "members.json" }, + { "gsuite:id", groupEmail } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/GroupSettingsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GroupSettingsSourceEntry.cs new file mode 100644 index 000000000..5a74700e4 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/GroupSettingsSourceEntry.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class GroupSettingsSourceEntry(SourceProvider provider, string parentPath, string groupEmail) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "settings.json"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetGroupsSettingsService(); + var request = service.Groups.Get(groupEmail); + var settings = await request.ExecuteAsync(cancellationToken); + + var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.GroupSettings.ToString() }, + { "gsuite:Name", "settings.json" }, + { "gsuite:id", groupEmail } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/GroupSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/GroupSourceEntry.cs new file mode 100644 index 000000000..db2a8bec8 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/GroupSourceEntry.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.Admin.Directory.directory_v1.Data; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class GroupSourceEntry(SourceProvider provider, string parentPath, Group group) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, group.Name ?? group.Email)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new GroupSettingsSourceEntry(provider, this.Path, group.Email); + + if (cancellationToken.IsCancellationRequested) yield break; + yield return new GroupMembersSourceEntry(provider, this.Path, group.Email); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.Group.ToString() }, + { "gsuite:Name", group.Name ?? group.Email }, + { "gsuite:id", group.Id }, + { "gsuite:Email", group.Email } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/KeepNoteAttachmentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/KeepNoteAttachmentSourceEntry.cs new file mode 100644 index 000000000..9341d6cbd --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/KeepNoteAttachmentSourceEntry.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Keep.v1.Data; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class KeepNoteAttachmentSourceEntry(SourceProvider provider, string parentPath, Attachment attachment) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, attachment.Name.Split('/').Last()), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + // Keep API attachments are downloaded via media URL? + // attachment.Name is like "notes/123/attachments/456". + // Docs say: GET https://keep.googleapis.com/v1/notes/{noteId}/attachments/{attachmentId}?alt=media + + var service = provider.ApiHelper.GetKeepService(); + var url = $"https://keep.googleapis.com/v1/{attachment.Name}?alt=media"; + + var response = await service.HttpClient.GetAsync(url, cancellationToken); + if (response.IsSuccessStatusCode) + { + var stream = new MemoryStream(); + await response.Content.CopyToAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + + throw new FileNotFoundException($"Cannot download attachment: {response.StatusCode}"); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.KeepNoteAttachment.ToString() }, + { "gsuite:Name", attachment.Name.Split('/').Last() }, + { "gsuite:id", attachment.Name }, + { "gsuite:MimeType", attachment.MimeType != null ? string.Join(",", attachment.MimeType) : null } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/KeepNoteFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/KeepNoteFileSourceEntry.cs new file mode 100644 index 000000000..91f74befd --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/KeepNoteFileSourceEntry.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Keep.v1.Data; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class KeepNoteFileSourceEntry(string parentPath, Note note) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "note.json"), + note.CreateTimeDateTimeOffset.HasValue ? note.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, + note.UpdateTimeDateTimeOffset.HasValue ? note.UpdateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(note, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.KeepNote.ToString() }, + { "gsuite:Name", "note.json" }, + { "gsuite:id", note.Name } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/KeepNoteSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/KeepNoteSourceEntry.cs new file mode 100644 index 000000000..9ef2d118d --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/KeepNoteSourceEntry.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.Keep.v1.Data; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class KeepNoteSourceEntry(SourceProvider provider, string parentPath, Note note) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, note.Title ?? note.Name)), + note.CreateTimeDateTimeOffset.HasValue ? note.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, + note.UpdateTimeDateTimeOffset.HasValue ? note.UpdateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new KeepNoteFileSourceEntry(this.Path, note); + + if (note.Attachments != null) + { + foreach (var attachment in note.Attachments) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new KeepNoteAttachmentSourceEntry(provider, this.Path, attachment); + } + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.KeepNote.ToString() }, + { "gsuite:Name", note.Title ?? note.Name }, + { "gsuite:id", note.Name } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/KeepSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/KeepSourceEntry.cs new file mode 100644 index 000000000..25fa13657 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/KeepSourceEntry.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class KeepSourceEntry(SourceProvider provider, string parentPath) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Keep")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetKeepService(); + var request = service.Notes.List(); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var notes = await request.ExecuteAsync(cancellationToken); + + if (notes.Notes != null) + { + foreach (var note in notes.Notes) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new KeepNoteSourceEntry(provider, this.Path, note); + } + } + nextPageToken = notes.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.UserKeep.ToString() }, + { "gsuite:Name", "Keep" }, + { "gsuite:id", "Keep" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/MetaEntryBase.cs b/proprietary/GoogleWorkspace/SourceItems/MetaEntryBase.cs new file mode 100644 index 000000000..17b7185b1 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/MetaEntryBase.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal abstract class MetaEntryBase(string path, DateTime? createdUtc, DateTime? lastModificationUtc) : ISourceProviderEntry +{ + public bool IsFolder => true; + + public virtual bool IsMetaEntry => false; + + public virtual bool IsRootEntry => false; + + public DateTime CreatedUtc => createdUtc ?? DateTime.UnixEpoch; + + public DateTime LastModificationUtc => lastModificationUtc ?? DateTime.UnixEpoch; + + public string Path => path; + + public long Size => -1; + public bool IsSymlink => false; + + public string? SymlinkTarget => null; + + public FileAttributes Attributes => FileAttributes.Directory; + + public bool IsBlockDevice => false; + + public bool IsCharacterDevice => false; + + public bool IsAlternateStream => false; + public string? HardlinkTargetId => null; + + public abstract IAsyncEnumerable Enumerate(CancellationToken cancellationToken); + + public virtual Task FileExists(string filename, CancellationToken cancellationToken) + => Task.FromResult(false); + + public virtual Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary()); + + public Task OpenRead(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/MetaRootSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/MetaRootSourceEntry.cs new file mode 100644 index 000000000..3d6f58cdc --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/MetaRootSourceEntry.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class MetaRootSourceEntry(SourceProvider provider, string parentPath, string name, SourceItemType type) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, name)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + if (type == SourceItemType.MetaRootUsers) + { + var service = provider.ApiHelper.GetDirectoryService(); + var request = service.Users.List(); + request.Customer = "my_customer"; + + string? nextPageToken = null; + + do + { + if (cancellationToken.IsCancellationRequested) yield break; + + request.PageToken = nextPageToken; + var users = await request.ExecuteAsync(cancellationToken); + + if (users != null && users.UsersValue != null) + { + foreach (var user in users.UsersValue) + { + if (cancellationToken.IsCancellationRequested) yield break; + + if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.Users, user.Id)) + yield break; + + yield return new UserSourceEntry(provider, this.Path, user.PrimaryEmail); + } + } + + nextPageToken = users?.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + else if (type == SourceItemType.MetaRootGroups) + { + var service = provider.ApiHelper.GetDirectoryService(); + var request = service.Groups.List(); + request.Customer = "my_customer"; + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var groups = await request.ExecuteAsync(cancellationToken); + + if (groups.GroupsValue != null) + { + foreach (var group in groups.GroupsValue) + { + if (cancellationToken.IsCancellationRequested) yield break; + + if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.Groups, group.Id)) + yield break; + + yield return new GroupSourceEntry(provider, this.Path, group); + } + } + nextPageToken = groups.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + else if (type == SourceItemType.MetaRootSharedDrives) + { + yield return new SharedDrivesSourceEntry(provider, "me", this.Path); + } + else if (type == SourceItemType.MetaRootSites) + { + yield return new SitesSourceEntry(provider, this.Path); + } + else if (type == SourceItemType.MetaRootOrganizationalUnits) + { + // OrganizationalUnits is a single file containing all OUs, so we check license once + if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.OrganizationalUnits, "organizational_units")) + yield break; + + yield return new OrganizationalUnitsSourceEntry(provider, this.Path); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "gsuite:v", "1" }, + { "gsuite:Type", type.ToString() }, + { "gsuite:Name", System.IO.Path.GetFileName(Path) }, + { "gsuite:id", System.IO.Path.GetFileName(Path) } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); +} diff --git a/proprietary/GoogleWorkspace/SourceItems/OrganizationalUnitsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/OrganizationalUnitsSourceEntry.cs new file mode 100644 index 000000000..8e08a5b2e --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/OrganizationalUnitsSourceEntry.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class OrganizationalUnitsSourceEntry(SourceProvider provider, string parentPath) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "organizational_units.json"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDirectoryService(); + var request = service.Orgunits.List("my_customer"); + request.Type = Google.Apis.Admin.Directory.directory_v1.OrgunitsResource.ListRequest.TypeEnum.All; + var orgUnits = await request.ExecuteAsync(cancellationToken); + + var json = JsonSerializer.Serialize(orgUnits, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.OrganizationalUnit.ToString() }, + { "gsuite:Name", "organizational_units.json" }, + { "gsuite:id", "organizational_units" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/RootSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/RootSourceEntry.cs new file mode 100644 index 000000000..53c6f10bf --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/RootSourceEntry.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class RootSourceEntry(SourceProvider provider) + : MetaEntryBase(Util.AppendDirSeparator(provider.MountedPath), null, null) +{ + internal sealed record BackupDescription( + string Version, + string DuplicatiVersion, + string MachineId, + string PackageTypeId, + string OSType, + string OSVersion, + string DirectorySeparator, + string MountPoint + ); + + public override bool IsRootEntry => true; + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.MetaRoot.ToString() }, + { "gsuite:Name", "Google Workspace Tenant" }, + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var description = new BackupDescription( + Version: "1.0", + DuplicatiVersion: Library.AutoUpdater.UpdaterManager.SelfVersion.Version ?? "", + MachineId: Library.AutoUpdater.DataFolderManager.MachineID, + PackageTypeId: Library.AutoUpdater.UpdaterManager.PackageTypeId, + OSType: Library.AutoUpdater.UpdaterManager.OperatingSystemName, + OSVersion: Library.Utility.OSInfoHelper.PlatformString, + DirectorySeparator: System.IO.Path.DirectorySeparatorChar.ToString(), + MountPoint: Path + ); + + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new StreamResourceEntryFunction(SystemIO.IO_OS.PathCombine(this.Path, "google-workspace-backup-description.json"), + createdUtc: DateTime.UnixEpoch, + lastModificationUtc: DateTime.UnixEpoch, + size: -1, + streamFactory: async (ct) => + { + var ms = new MemoryStream(); + await System.Text.Json.JsonSerializer.SerializeAsync(ms, description, cancellationToken: ct).ConfigureAwait(false); + ms.Seek(0, SeekOrigin.Begin); + return ms; + }, + minorMetadataFactory: (ct) => Task.FromResult(new Dictionary() + { + { "ExtType", "gsuite"}, + { "Ext:DuplicatiVersion", description.DuplicatiVersion }, + { "Ext:MachineId", description.MachineId }, + { "Ext:PackageTypeId", description.PackageTypeId }, + { "Ext:OSType", description.OSType }, + { "Ext:OSVersion", description.OSVersion }, + { "Ext:DirectorySeparator", description.DirectorySeparator }, + { "Ext:MountPoint", description.MountPoint } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)) + ); + + if (cancellationToken.IsCancellationRequested) + yield break; + + foreach (var type in provider.Options.IncludedRootTypes) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + if (type == GoogleRootType.Users) + { + yield return new MetaRootSourceEntry(provider, this.Path, "Users", SourceItemType.MetaRootUsers); + } + else if (type == GoogleRootType.Groups) + { + yield return new MetaRootSourceEntry(provider, this.Path, "Groups", SourceItemType.MetaRootGroups); + } + else if (type == GoogleRootType.SharedDrives) + { + yield return new MetaRootSourceEntry(provider, this.Path, "Shared Drives", SourceItemType.MetaRootSharedDrives); + } + else if (type == GoogleRootType.Sites) + { + yield return new MetaRootSourceEntry(provider, this.Path, "Sites", SourceItemType.MetaRootSites); + } + else if (type == GoogleRootType.OrganizationalUnits) + { + yield return new MetaRootSourceEntry(provider, this.Path, "Organizational Units", SourceItemType.MetaRootOrganizationalUnits); + } + } + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/SharedDriveMetadataSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SharedDriveMetadataSourceEntry.cs new file mode 100644 index 000000000..19c216b19 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/SharedDriveMetadataSourceEntry.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Drive.v3.Data; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class SharedDriveMetadataSourceEntry(string parentPath, Drive drive) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "metadata.json"), drive.CreatedTimeDateTimeOffset.HasValue ? drive.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(drive, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DriveFileMetadata.ToString() }, // Reuse or new type? + { "gsuite:Name", "metadata.json" }, + { "gsuite:id", drive.Id } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/SharedDrivePermissionsSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SharedDrivePermissionsSourceEntry.cs new file mode 100644 index 000000000..d4ac65d92 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/SharedDrivePermissionsSourceEntry.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Google.Apis.Drive.v3.Data; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class SharedDrivePermissionsSourceEntry(SourceProvider provider, string parentPath, Drive drive) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "permissions.json"), drive.CreatedTimeDateTimeOffset.HasValue ? drive.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + var request = service.Permissions.List(drive.Id); + request.SupportsAllDrives = true; + var permissions = await request.ExecuteAsync(cancellationToken); + + var json = JsonSerializer.Serialize(permissions, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.DrivePermission.ToString() }, + { "gsuite:Name", "permissions.json" }, + { "gsuite:id", drive.Id } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/SharedDriveSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SharedDriveSourceEntry.cs new file mode 100644 index 000000000..50af7136a --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/SharedDriveSourceEntry.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.Drive.v3.Data; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class SharedDriveSourceEntry(SourceProvider provider, string userId, string parentPath, Drive drive) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, drive.Name)), drive.CreatedTimeDateTimeOffset.HasValue ? drive.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new SharedDriveMetadataSourceEntry(this.Path, drive); + + if (cancellationToken.IsCancellationRequested) yield break; + yield return new SharedDrivePermissionsSourceEntry(provider, this.Path, drive); + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveFolderSourceEntry(provider, userId, this.Path, "Content", drive.Id); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.SharedDrives.ToString() }, // Or specific type? + { "gsuite:Name", drive.Name }, + { "gsuite:id", drive.Id } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/SharedDrivesSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SharedDrivesSourceEntry.cs new file mode 100644 index 000000000..2f30e244f --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/SharedDrivesSourceEntry.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class SharedDrivesSourceEntry(SourceProvider provider, string userId, string parentPath) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Shared Drives")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + var request = service.Drives.List(); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var drives = await request.ExecuteAsync(cancellationToken); + + if (drives.Drives != null) + { + foreach (var drive in drives.Drives) + { + if (cancellationToken.IsCancellationRequested) yield break; + + if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.SharedDrives, drive.Id)) + yield break; + + yield return new SharedDriveSourceEntry(provider, userId, this.Path, drive); + } + } + nextPageToken = drives.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.SharedDrives.ToString() }, + { "gsuite:Name", "Shared Drives" }, + { "gsuite:id", "Shared Drives" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/SiteSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SiteSourceEntry.cs new file mode 100644 index 000000000..bd31917cc --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/SiteSourceEntry.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; +using File = Google.Apis.Drive.v3.Data.File; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class SiteSourceEntry(string parentPath, File file) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, file.Name + ".site"), file.CreatedTimeDateTimeOffset.HasValue ? file.CreatedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch, file.ModifiedTimeDateTimeOffset.HasValue ? file.ModifiedTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + // Google Sites cannot be exported via Drive API. + // We serialize the metadata to JSON so we at least have a record of it. + var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.Site.ToString() }, + { "gsuite:Name", file.Name }, + { "gsuite:id", file.Id }, + { "gsuite:MimeType", file.MimeType } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/SitesSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/SitesSourceEntry.cs new file mode 100644 index 000000000..b1aa4da5e --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/SitesSourceEntry.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using System.Runtime.CompilerServices; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class SitesSourceEntry(SourceProvider provider, string parentPath) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, "Sites")), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDriveService(); + var request = service.Files.List(); + request.Q = "mimeType='application/vnd.google-apps.site' and trashed=false"; + request.SupportsAllDrives = true; + request.IncludeItemsFromAllDrives = true; + // Request all drives so we do not get user drives only + request.Corpora = "allDrives"; + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + + Google.Apis.Drive.v3.Data.FileList? files = null; + try + { + files = await request.ExecuteAsync(cancellationToken); + } + catch + { + // Fallback or ignore + yield break; + } + + if (files != null && files.Files != null) + { + foreach (var file in files.Files) + { + if (cancellationToken.IsCancellationRequested) yield break; + + if (!provider.LicenseApprovedForEntry(Path, GoogleRootType.Sites, file.Id)) + yield break; + + yield return new SiteSourceEntry(this.Path, file); + } + } + nextPageToken = files?.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.MetaRootSites.ToString() }, + { "gsuite:Name", "Sites" }, + { "gsuite:id", "Sites" } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs b/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs new file mode 100644 index 000000000..024d42764 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +public enum SourceItemType +{ + MetaRoot, + MetaRootUsers, + MetaRootGroups, + MetaRootSharedDrives, + MetaRootSites, + MetaRootOrganizationalUnits, + User, + UserGmail, + UserDrive, + UserCalendar, + UserContacts, + UserTasks, + GmailLabel, + GmailMessage, + GmailAttachment, + GmailSettings, + Drive, + DriveFile, + DriveFolder, + DrivePermission, + DriveFileContent, + DriveFileMetadata, + DriveFileComment, + DriveFileRevision, + SharedDrives, + Calendar, + CalendarEvent, + CalendarEventAttachment, + Contact, + ContactGroup, + ContactPhoto, + TaskList, + Task, + UserKeep, + UserChat, + KeepNote, + KeepNoteAttachment, + UserProfile, + OrganizationalUnit, + Group, + GroupMember, + GroupSettings, + Site, + ChatSpace, + ChatMessage, + ChatAttachment +} diff --git a/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryBase.cs b/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryBase.cs new file mode 100644 index 000000000..7288f7e23 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryBase.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal abstract class StreamResourceEntryBase(string path, DateTime createdUtc, DateTime lastModificationUtc) : ISourceProviderEntry +{ + public bool IsFolder => false; + + public bool IsMetaEntry => false; + + public virtual bool IsRootEntry => false; + + public virtual DateTime CreatedUtc => createdUtc; + + public virtual DateTime LastModificationUtc => lastModificationUtc; + + public string Path => path; + + public abstract long Size { get; } + public bool IsSymlink => false; + + public string? SymlinkTarget => null; + + public virtual FileAttributes Attributes => FileAttributes.Normal; + + public bool IsBlockDevice => false; + + public bool IsCharacterDevice => false; + + public bool IsAlternateStream => false; + public string? HardlinkTargetId => null; + + public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + => throw new NotSupportedException("Cannot enumerate a file entry"); + + public Task FileExists(string filename, CancellationToken cancellationToken) + => throw new NotSupportedException("Cannot enumerate a file entry"); + + public virtual Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary()); + + public abstract Task OpenRead(CancellationToken cancellationToken); +} diff --git a/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryFunction.cs b/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryFunction.cs new file mode 100644 index 000000000..51156b245 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/StreamResourceEntryFunction.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class StreamResourceEntryFunction(string path, DateTime createdUtc, DateTime lastModificationUtc, long size, Func> streamFactory, Func>>? minorMetadataFactory = null) + : StreamResourceEntryBase(path, createdUtc, lastModificationUtc) +{ + public override long Size => _stream == null ? size : _stream.Length; + private Stream? _stream; + + public async Task ProbeIfExistsAsync(CancellationToken cancellationToken) + { + if (_stream != null) + return true; + + try + { + _stream = await streamFactory(cancellationToken).ConfigureAwait(false); + return _stream != null; + } + catch (FileNotFoundException) + { + return false; + } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return false; + } + } + + public override async Task OpenRead(CancellationToken cancellationToken) + => _stream != null + ? _stream + : (await streamFactory(cancellationToken).ConfigureAwait(false) ?? throw new FileNotFoundException($"Resource not found: {Path}")); + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => minorMetadataFactory != null + ? minorMetadataFactory(cancellationToken) + : base.GetMinorMetadata(cancellationToken); +} diff --git a/proprietary/GoogleWorkspace/SourceItems/TaskListSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/TaskListSourceEntry.cs new file mode 100644 index 000000000..99781af2f --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/TaskListSourceEntry.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Google.Apis.Tasks.v1.Data; +using System.Runtime.CompilerServices; +using Task = System.Threading.Tasks.Task; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class TaskListSourceEntry(SourceProvider provider, string parentPath, TaskList taskList) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, taskList.Title)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetTasksService(); + var request = service.Tasks.List(taskList.Id); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var tasks = await request.ExecuteAsync(cancellationToken); + + if (tasks.Items != null) + { + foreach (var task in tasks.Items) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new TaskSourceEntry(this.Path, task); + } + } + nextPageToken = tasks.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.TaskList.ToString() }, + { "gsuite:Name", taskList.Title }, + { "gsuite:id", taskList.Id }, + { "gsuite:Updated", taskList.Updated } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/TaskSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/TaskSourceEntry.cs new file mode 100644 index 000000000..75a5086f4 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/TaskSourceEntry.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; +using GTask = Google.Apis.Tasks.v1.Data.Task; +using Task = System.Threading.Tasks.Task; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class TaskSourceEntry(string parentPath, GTask task) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, (task.Title ?? task.Id) + ".json"), + !string.IsNullOrEmpty(task.Updated) ? DateTime.Parse(task.Updated!) : DateTime.UnixEpoch, + !string.IsNullOrEmpty(task.Updated) ? DateTime.Parse(task.Updated!) : DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override Task OpenRead(CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(task, new JsonSerializerOptions { WriteIndented = true }); + return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.Task.ToString() }, + { "gsuite:Name", task.Title ?? task.Id }, + { "gsuite:id", task.Id }, + { "gsuite:Status", task.Status }, + { "gsuite:Due", task.Due } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/UserProfileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/UserProfileSourceEntry.cs new file mode 100644 index 000000000..ed2e46ce7 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/UserProfileSourceEntry.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Common.IO; +using System.Text.Json; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class UserProfileSourceEntry(SourceProvider provider, string parentPath, string userId) + : StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "profile.json"), DateTime.UnixEpoch, DateTime.UnixEpoch) +{ + public override long Size => -1; + + public override async Task OpenRead(CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetDirectoryService(); + var request = service.Users.Get(userId); + var user = await request.ExecuteAsync(cancellationToken); + + var json = JsonSerializer.Serialize(user, new JsonSerializerOptions { WriteIndented = true }); + return new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + { + return Task.FromResult(new Dictionary + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.UserProfile.ToString() }, + { "gsuite:Name", "profile.json" }, + { "gsuite:id", userId } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); + } +} diff --git a/proprietary/GoogleWorkspace/SourceItems/UserSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/UserSourceEntry.cs new file mode 100644 index 000000000..61bc60556 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/UserSourceEntry.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class UserSourceEntry(SourceProvider provider, string parentPath, string userId) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, userId)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new UserProfileSourceEntry(provider, this.Path, userId); + + foreach (var type in provider.Options.IncludedUserTypes) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + if (type == GoogleUserType.Gmail) + yield return new UserTypeSourceEntry(provider, this.Path, userId, "Gmail", SourceItemType.UserGmail); + else if (type == GoogleUserType.Calendar) + yield return new UserTypeSourceEntry(provider, this.Path, userId, "Calendar", SourceItemType.UserCalendar); + else if (type == GoogleUserType.Contacts) + yield return new UserTypeSourceEntry(provider, this.Path, userId, "Contacts", SourceItemType.UserContacts); + else if (type == GoogleUserType.Drive) + yield return new UserTypeSourceEntry(provider, this.Path, userId, "Drive", SourceItemType.UserDrive); + else if (type == GoogleUserType.Tasks) + yield return new UserTypeSourceEntry(provider, this.Path, userId, "Tasks", SourceItemType.UserTasks); + else if (type == GoogleUserType.Keep) + yield return new UserTypeSourceEntry(provider, this.Path, userId, "Keep", SourceItemType.UserKeep); + else if (type == GoogleUserType.Chat) + yield return new UserTypeSourceEntry(provider, this.Path, userId, "Chat", SourceItemType.UserChat); + } + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "gsuite:v", "1" }, + { "gsuite:Type", SourceItemType.User.ToString() }, + { "gsuite:Name", userId }, + { "gsuite:id", userId } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); +} diff --git a/proprietary/GoogleWorkspace/SourceItems/UserTypeSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/UserTypeSourceEntry.cs new file mode 100644 index 000000000..20c291af4 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceItems/UserTypeSourceEntry.cs @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Runtime.CompilerServices; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; + +namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +internal class UserTypeSourceEntry(SourceProvider provider, string parentPath, string userId, string name, SourceItemType type) + : MetaEntryBase(Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parentPath, name)), null, null) +{ + public override async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + var source = type switch + { + SourceItemType.UserGmail => EnumerateGmail(cancellationToken), + SourceItemType.UserCalendar => EnumerateCalendar(cancellationToken), + SourceItemType.UserContacts => EnumerateContacts(cancellationToken), + SourceItemType.UserDrive => EnumerateDrive(cancellationToken), + SourceItemType.UserTasks => EnumerateTasks(cancellationToken), + SourceItemType.UserKeep => EnumerateKeep(cancellationToken), + SourceItemType.UserChat => EnumerateChat(cancellationToken), + _ => throw new NotImplementedException() + }; + + await foreach (var entry in source) + yield return entry; + } + + private async IAsyncEnumerable EnumerateGmail([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetGmailService(userId); + var request = service.Users.Labels.List(userId); + Google.Apis.Gmail.v1.Data.ListLabelsResponse response; + + try + { + response = await request.ExecuteAsync(cancellationToken); + } + catch (Exception ex) + { + // Customize exception to indicate which user failed + throw new Exception($"Error enumerating Gmail labels for user {userId}: {ex.Message}", ex); + } + + if (response.Labels != null) + { + foreach (var label in response.Labels) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new GmailLabelSourceEntry(provider, userId, this.Path, label); + } + } + + if (cancellationToken.IsCancellationRequested) + yield break; + + yield return new GmailSettingsSourceEntry(provider, userId, this.Path); + } + + private async IAsyncEnumerable EnumerateCalendar([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetCalendarService(); + var request = service.CalendarList.List(); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var calendars = await request.ExecuteAsync(cancellationToken); + + if (calendars.Items != null) + { + foreach (var calendar in calendars.Items) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new CalendarSourceEntry(provider, this.Path, calendar); + } + } + nextPageToken = calendars.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + private async IAsyncEnumerable EnumerateContacts([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ContactsFolderSourceEntry(provider, this.Path); + + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ContactGroupsFolderSourceEntry(provider, this.Path); + } + + private async IAsyncEnumerable EnumerateDrive([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new DriveSourceEntry(provider, userId, this.Path); + } + + private async IAsyncEnumerable EnumerateTasks([EnumeratorCancellation] CancellationToken cancellationToken) + { + var service = provider.ApiHelper.GetTasksService(); + var request = service.Tasklists.List(); + + string? nextPageToken = null; + do + { + if (cancellationToken.IsCancellationRequested) yield break; + request.PageToken = nextPageToken; + var taskLists = await request.ExecuteAsync(cancellationToken); + + if (taskLists.Items != null) + { + foreach (var taskList in taskLists.Items) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new TaskListSourceEntry(provider, this.Path, taskList); + } + } + nextPageToken = taskLists.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + } + + private async IAsyncEnumerable EnumerateKeep([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new KeepSourceEntry(provider, this.Path); + } + + private async IAsyncEnumerable EnumerateChat([EnumeratorCancellation] CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return new ChatSourceEntry(provider, this.Path); + } + + public override Task> GetMinorMetadata(CancellationToken cancellationToken) + => Task.FromResult(new Dictionary() + { + { "gsuite:v", "1" }, + { "gsuite:Type", type.ToString() }, + { "gsuite:Name", System.IO.Path.GetFileName(Path) }, + { "gsuite:id", System.IO.Path.GetFileName(Path) } + } + .Where(kv => !string.IsNullOrEmpty(kv.Value)) + .ToDictionary(kv => kv.Key, kv => kv.Value)); +} diff --git a/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs b/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs new file mode 100644 index 000000000..11ac45ca3 --- /dev/null +++ b/proprietary/GoogleWorkspace/SourceProvider/SourceProvider.cs @@ -0,0 +1,289 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Interface; +using Duplicati.Library.Logging; +using Duplicati.Proprietary.GoogleWorkspace.SourceItems; +using System.Collections.Concurrent; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +public sealed class SourceProvider : ISourceProviderModule, IDisposable +{ + /// + /// The log tag for this class. + /// + private static readonly string LOGTAG = Log.LogTagFromType(); + + private readonly APIHelper _apiHelper; + private readonly string _mountPoint; + private readonly OptionsHelper.GoogleWorkspaceOptions _options; + private readonly ConcurrentDictionary _entryCache = new(); + + /// + /// Counter for tracking which licensed paths are being enumerated. + /// + private readonly ConcurrentDictionary _enumerationCounter = new(); + + /// + /// The current number of users that has been enumerated. + /// + private int _userCount = 0; + /// + /// The current number of groups that has been enumerated. + /// + private int _groupCount = 0; + /// + /// The current number of shared drives that has been enumerated. + /// + private int _sharedDriveCount = 0; + /// + /// The current number of sites that has been enumerated. + /// + private int _siteCount = 0; + /// + /// The current number of organizational units that has been enumerated. + /// + private int _organizationalUnitCount = 0; + + /// + /// Whether a license warning has been issued for users. + /// + private int _userLicenseWarningIssued = 0; + /// + /// Whether a license warning has been issued for groups. + /// + private int _groupLicenseWarningIssued = 0; + /// + /// Whether a license warning has been issued for shared drives. + /// + private int _sharedDriveLicenseWarningIssued = 0; + /// + /// Whether a license warning has been issued for sites. + /// + private int _siteLicenseWarningIssued = 0; + /// + /// Whether a license warning has been issued for organizational units. + /// + private int _organizationalUnitLicenseWarningIssued = 0; + + /// + /// Whether this provider is being used for a restore operation. + /// + internal bool UsedForRestoreOperation { get; set; } = false; + + /// + /// Indicates whether the metadata storage option has been set. + /// + private readonly bool _hasSetMetadataStorageOption; + + public APIHelper ApiHelper => _apiHelper; + public OptionsHelper.GoogleWorkspaceOptions Options => _options; + + public SourceProvider() + { + _apiHelper = null!; + _mountPoint = null!; + _options = null!; + } + + public SourceProvider(string url, string mountPoint, Dictionary options) + { + _hasSetMetadataStorageOption = Library.Utility.Utility.ParseBoolOption(options, "store-metadata-content-in-database"); + + _mountPoint = mountPoint; + _options = OptionsHelper.ParseOptions(options); + + _apiHelper = new APIHelper(_options); + } + + public string Key => OptionsHelper.ModuleKey; + + public string DisplayName => Strings.Common.DisplayName; + + public string Description => Strings.Common.Description; + + public IList SupportedCommands => OptionsHelper.SupportedCommands; + + public string MountedPath => _mountPoint; + + public void Dispose() + { + } + + public IAsyncEnumerable Enumerate(CancellationToken cancellationToken) + { + return new RootSourceEntry(this).Enumerate(cancellationToken); + } + + public async Task GetEntry(string path, bool isFolder, CancellationToken cancellationToken) + { + if (_entryCache.TryGetValue(path, out var cachedEntry)) + return cachedEntry; + + ISourceProviderEntry resultEntry = new RootSourceEntry(this); + var targetPath = path.TrimEnd(Path.DirectorySeparatorChar); + + var currentPath = targetPath; + while (!string.IsNullOrEmpty(currentPath)) + { + var parentPath = Path.GetDirectoryName(currentPath); + if (parentPath != null && _entryCache.TryGetValue(parentPath, out var cachedParent)) + { + resultEntry = cachedParent; + break; + } + currentPath = parentPath; + } + + var relativePath = string.IsNullOrEmpty(currentPath) + ? targetPath + : Path.GetRelativePath(currentPath, targetPath); + + var pathsegments = relativePath?.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries) + ?? Array.Empty(); + + var segmentIndex = 0; + foreach (var item in pathsegments) + { + var found = false; + var isLastSegment = segmentIndex == pathsegments.Length - 1; + await foreach (var entry in resultEntry.Enumerate(cancellationToken)) + { + _entryCache.TryAdd(entry.Path, entry); + + var name = entry.Path.TrimEnd(Path.DirectorySeparatorChar) + .Split(Path.DirectorySeparatorChar) + .Last(); + if (name.Equals(item, StringComparison.OrdinalIgnoreCase)) + { + if (isLastSegment && entry.IsFolder != isFolder) + throw new UserInformationException($"Path segment '{item}' is not a {(isFolder ? "folder" : "file")}", "PathSegmentNotFolder"); + else if (!isLastSegment && !entry.IsFolder) + throw new UserInformationException($"Path segment '{item}' is not a folder", "PathSegmentNotFolder"); + + resultEntry = entry; + found = true; + break; + } + } + + segmentIndex++; + if (!found) + return null; + } + + return resultEntry; + } + + public Task Initialize(CancellationToken cancellationToken) + { + if (!_hasSetMetadataStorageOption) + throw new UserInformationException(Strings.MetadataStorageNotEnabled("store-metadata-content-in-database"), "DatabaseMetadataStorageNotEnabled"); + + _apiHelper.Initialize(); + return Task.CompletedTask; + } + + public Task Test(CancellationToken cancellationToken) + { + _apiHelper.TestConnection(); + return Task.CompletedTask; + } + + /// + /// Determines whether the license is approved for the given entry. + /// + /// The path to verify. + /// The type of entry. + /// The ID of the entry. + /// true if the license is approved; otherwise, false. + internal bool LicenseApprovedForEntry(string path, GoogleRootType type, string id) + { + // We do not limit restores + if (UsedForRestoreOperation) + return true; + + // Make a unique target path for the type and id, just for counting purposes, not matching actual paths + var targetpath = $"{path.TrimEnd(Path.DirectorySeparatorChar)}{Path.DirectorySeparatorChar}{type}{Path.DirectorySeparatorChar}{id}"; + + if (_enumerationCounter.ContainsKey(targetpath)) + return true; + + if (type == GoogleRootType.Users) + { + var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceUserSeats; + var current = _userCount; + if (current >= approved) + { + if (Interlocked.Exchange(ref _userLicenseWarningIssued, 1) == 0) + Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up."); + return false; + } + } + else if (type == GoogleRootType.Groups) + { + var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceGroupSeats; + var current = _groupCount; + if (current >= approved) + { + if (Interlocked.Exchange(ref _groupLicenseWarningIssued, 1) == 0) + Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up."); + return false; + } + } + else if (type == GoogleRootType.SharedDrives) + { + var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceSharedDriveSeats; + var current = _sharedDriveCount; + if (current >= approved) + { + if (Interlocked.Exchange(ref _sharedDriveLicenseWarningIssued, 1) == 0) + Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up."); + return false; + } + } + else if (type == GoogleRootType.Sites) + { + var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceSiteSeats; + var current = _siteCount; + if (current >= approved) + { + if (Interlocked.Exchange(ref _siteLicenseWarningIssued, 1) == 0) + Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up."); + return false; + } + } + else if (type == GoogleRootType.OrganizationalUnits) + { + var approved = LicenseChecker.LicenseHelper.AvailableGoogleWorkspaceOrganizationalUnitSeats; + var current = _organizationalUnitCount; + if (current >= approved) + { + if (Interlocked.Exchange(ref _organizationalUnitLicenseWarningIssued, 1) == 0) + Log.WriteWarningMessage(LOGTAG, "LicenseWarning", null, $"Licensed Google Workspace feature seats exceeded for {type} ({approved}). Some items will not be backed up."); + return false; + } + } + else + { + // Unknown type, allow it + return true; + } + + if (_enumerationCounter.TryAdd(targetpath, true)) + { + if (type == GoogleRootType.Users) + Interlocked.Increment(ref _userCount); + else if (type == GoogleRootType.Groups) + Interlocked.Increment(ref _groupCount); + else if (type == GoogleRootType.SharedDrives) + Interlocked.Increment(ref _sharedDriveCount); + else if (type == GoogleRootType.Sites) + Interlocked.Increment(ref _siteCount); + else if (type == GoogleRootType.OrganizationalUnits) + Interlocked.Increment(ref _organizationalUnitCount); + } + + return true; + } +} diff --git a/proprietary/GoogleWorkspace/Strings.cs b/proprietary/GoogleWorkspace/Strings.cs new file mode 100644 index 000000000..aabebcef0 --- /dev/null +++ b/proprietary/GoogleWorkspace/Strings.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using Duplicati.Library.Localization.Short; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +internal static class Strings +{ + internal static class Options + { + public static string GoogleClientIdShort => LC.L("The Google Client ID"); + public static string GoogleClientIdLong => LC.L("The Client ID for the Google App."); + public static string GoogleClientSecretShort => LC.L("The Google Client Secret"); + public static string GoogleClientSecretLong => LC.L("The Client Secret for the Google App."); + public static string GoogleRefreshTokenShort => LC.L("The Google Refresh Token"); + public static string GoogleRefreshTokenLong => LC.L("The Refresh Token for the Google App."); + public static string GoogleServiceAccountJsonShort => LC.L("The Google Service Account JSON"); + public static string GoogleServiceAccountJsonLong => LC.L("The Google Service Account JSON content."); + public static string GoogleServiceAccountFileShort => LC.L("The Google Service Account File"); + public static string GoogleServiceAccountFileLong => LC.L("The path to the Google Service Account JSON file."); + public static string GoogleAdminEmailShort => LC.L("The Google Admin Email"); + public static string GoogleAdminEmailLong => LC.L("The email of the Google Admin user to impersonate."); + public static string GoogleIncludedRootTypesShort => LC.L("Included Root Types"); + public static string GoogleIncludedRootTypesLong => LC.L("Comma-separated list of root types to include (e.g. Users, Groups, SharedDrives)."); + public static string GoogleIncludedUserTypesShort => LC.L("Included User Types"); + public static string GoogleIncludedUserTypesLong => LC.L("Comma-separated list of user types to include (e.g. Gmail, Drive, Calendar)."); + public static string GoogleRequestedScopesShort => LC.L("Requested Scopes"); + public static string GoogleRequestedScopesLong => LC.L("Comma-separated list of scopes to request. If not provided, the scopes will be calculated based on the included root and user types."); + } + + internal static class Common + { + public static string DisplayName => LC.L("Google Workspace"); + public static string Description => LC.L("Backup and restore Google Workspace data."); + public static string WebModuleDisplayName => LC.L("Google Workspace Web Module"); + public static string WebModuleDescription => LC.L("Web module for Google Workspace."); + public static string WebModuleOperationShort => LC.L("Operation"); + public static string WebModuleOperationLong => LC.L("The operation to perform."); + public static string WebModuleURLShort => LC.L("URL"); + public static string WebModuleURLLong => LC.L("The URL to connect to."); + public static string WebModulePathShort => LC.L("Path"); + public static string WebModulePathLong => LC.L("The path to list."); + } + + public static string MetadataStorageNotEnabled(string optionname) => LC.L( + $"Storing metadata content in the database must be enabled to use the Google Workspace source provider. Use the option: --{optionname}"); +} diff --git a/proprietary/GoogleWorkspace/WebModule/WebModule.cs b/proprietary/GoogleWorkspace/WebModule/WebModule.cs new file mode 100644 index 000000000..9b5c5ff1e --- /dev/null +++ b/proprietary/GoogleWorkspace/WebModule/WebModule.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2026 Duplicati Inc. All rights reserved. + +using System.Text.Json; +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; +using Duplicati.Proprietary.GoogleWorkspace.SourceItems; + +namespace Duplicati.Proprietary.GoogleWorkspace; + +public class WebModule : IWebModule +{ + public string Key => OptionsHelper.ModuleKey; + + public string DisplayName => Strings.Common.WebModuleDisplayName; + + public string Description => Strings.Common.WebModuleDescription; + + public enum Operation + { + ListDestination, + ListDestinationRestoreTargets + } + + private static readonly Operation DEFAULT_OPERATION = Operation.ListDestination; + private const string KEY_OPERATION = "operation"; + private const string KEY_URL = "url"; + private const string KEY_PATH = "path"; + + private static readonly IReadOnlySet RESTORE_TARGET_TYPES = new HashSet + { + SourceItemType.User.ToString(), + SourceItemType.UserGmail.ToString(), + SourceItemType.UserDrive.ToString(), + SourceItemType.UserCalendar.ToString(), + SourceItemType.UserContacts.ToString(), + SourceItemType.UserTasks.ToString(), + SourceItemType.UserKeep.ToString(), + SourceItemType.UserChat.ToString(), + SourceItemType.GmailLabel.ToString(), + SourceItemType.Drive.ToString(), + SourceItemType.DriveFolder.ToString(), + SourceItemType.Calendar.ToString(), + SourceItemType.TaskList.ToString(), + SourceItemType.ContactGroup.ToString(), + SourceItemType.Group.ToString(), + SourceItemType.SharedDrives.ToString(), + SourceItemType.Site.ToString(), + SourceItemType.ChatSpace.ToString(), + }; + + private static readonly IReadOnlySet RESTORE_TARGET_LEAF_TYPES = new HashSet + { + SourceItemType.GmailLabel.ToString(), + SourceItemType.DriveFolder.ToString(), + SourceItemType.Calendar.ToString(), + SourceItemType.TaskList.ToString(), + SourceItemType.ContactGroup.ToString(), + }; + + private static readonly IReadOnlySet RESTORE_TARGET_NONSELECTABLE_FOLDERS = new HashSet + { + SourceItemType.MetaRoot.ToString(), + SourceItemType.MetaRootUsers.ToString(), + SourceItemType.MetaRootGroups.ToString(), + SourceItemType.MetaRootSharedDrives.ToString(), + SourceItemType.MetaRootSites.ToString(), + SourceItemType.MetaRootOrganizationalUnits.ToString(), + }; + + + public IList SupportedCommands => [ + new CommandLineArgument(KEY_OPERATION, CommandLineArgument.ArgumentType.Enumeration, Strings.Common.WebModuleOperationShort, Strings.Common.WebModuleOperationLong, DEFAULT_OPERATION.ToString(), null, Enum.GetNames(typeof(Operation))), + new CommandLineArgument(KEY_URL, CommandLineArgument.ArgumentType.String, Strings.Common.WebModuleURLShort, Strings.Common.WebModuleURLLong), + new CommandLineArgument(KEY_PATH, CommandLineArgument.ArgumentType.String, Strings.Common.WebModulePathShort, Strings.Common.WebModulePathLong) + ]; + + public async Task> Execute(IDictionary options, CancellationToken cancellationToken) + { + var op = Utility.ParseEnumOption(options.AsReadOnly(), KEY_OPERATION, DEFAULT_OPERATION); + options.TryGetValue(KEY_URL, out var url); + options.TryGetValue(KEY_PATH, out var path); + + if (op != Operation.ListDestination && op != Operation.ListDestinationRestoreTargets) + throw new UserInformationException($"Unsupported operation: {op}", "UnsupportedOperation"); + + if (string.IsNullOrWhiteSpace(url)) + throw new UserInformationException($"Missing URL", "MissingURL"); + + var forwardoptions = new Dictionary() + { + { "store-metadata-content-in-database", "true" } + }; + + var uri = new Library.Utility.Uri(url); + foreach (var key in uri.QueryParameters.AllKeys) + forwardoptions[key!] = uri.QueryParameters[key]; + + using var client = new SourceProvider(url, "", forwardoptions); + await client.Initialize(cancellationToken); + + var targetEntry = await client.GetEntry((path ?? "").TrimStart('/'), isFolder: true, cancellationToken).ConfigureAwait(false); + if (targetEntry == null) + throw new DirectoryNotFoundException($"Path not found: {path}"); + + var result = new Dictionary(); + await foreach (var entry in targetEntry.Enumerate(cancellationToken)) + { + if (op == Operation.ListDestinationRestoreTargets) + { + if (!entry.IsFolder) + continue; + } + + var targetpath = entry.Path; + var metadata = new Dictionary(); + try + { + if (!entry.IsMetaEntry) + metadata = await entry.GetMinorMetadata(cancellationToken); + } + catch + { + // ignore metadata errors + } + + // For restore targets, treat leafs as non-folders + if (op == Operation.ListDestinationRestoreTargets) + { + var type = metadata.GetValueOrDefault("gsuite:Type"); + if (type != null) + { + if (RESTORE_TARGET_LEAF_TYPES.Contains(type)) + targetpath = targetpath.TrimEnd(Path.DirectorySeparatorChar); + + if (!RESTORE_TARGET_NONSELECTABLE_FOLDERS.Contains(type) && !RESTORE_TARGET_TYPES.Contains(type)) + continue; + } + } + + result[targetpath] = JsonSerializer.Serialize(metadata); + } + + return result; + + } + + public IDictionary> GetLookups() + => new Dictionary>(); +} diff --git a/proprietary/LicenseChecker/DuplicatiLicenseFeatures.cs b/proprietary/LicenseChecker/DuplicatiLicenseFeatures.cs index 5780c7fc5..9ee3107eb 100644 --- a/proprietary/LicenseChecker/DuplicatiLicenseFeatures.cs +++ b/proprietary/LicenseChecker/DuplicatiLicenseFeatures.cs @@ -22,4 +22,29 @@ public static class DuplicatiLicenseFeatures /// The feature indicating support for Office365 site backups /// public const string Office365Sites = "duplicati:office365:sites"; + + /// + /// The feature indicating support for Google Workspace user backups + /// + public const string GoogleWorkspaceUsers = "duplicati:googleworkspace:users"; + + /// + /// The feature indicating support for Google Workspace group backups + /// + public const string GoogleWorkspaceGroups = "duplicati:googleworkspace:groups"; + + /// + /// The feature indicating support for Google Workspace shared drive backups + /// + public const string GoogleWorkspaceSharedDrives = "duplicati:googleworkspace:shareddrives"; + + /// + /// The feature indicating support for Google Workspace site backups + /// + public const string GoogleWorkspaceSites = "duplicati:googleworkspace:sites"; + + /// + /// The feature indicating support for Google Workspace organizational unit backups + /// + public const string GoogleWorkspaceOrganizationalUnits = "duplicati:googleworkspace:organizationalunits"; } diff --git a/proprietary/LicenseChecker/LicenseHelper.cs b/proprietary/LicenseChecker/LicenseHelper.cs index ad4297823..0d9aee8d2 100644 --- a/proprietary/LicenseChecker/LicenseHelper.cs +++ b/proprietary/LicenseChecker/LicenseHelper.cs @@ -13,16 +13,34 @@ public static class LicenseHelper public static int AvailableOffice365GroupSeats => GetFeatureSeats(DuplicatiLicenseFeatures.Office365Groups); public static int AvailableOffice365SiteSeats => GetFeatureSeats(DuplicatiLicenseFeatures.Office365Sites); + public static int AvailableGoogleWorkspaceUserSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceUsers); + public static int AvailableGoogleWorkspaceGroupSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceGroups); + public static int AvailableGoogleWorkspaceSharedDriveSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceSharedDrives); + public static int AvailableGoogleWorkspaceSiteSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceSites); + public static int AvailableGoogleWorkspaceOrganizationalUnitSeats => GetFeatureSeats(DuplicatiLicenseFeatures.GoogleWorkspaceOrganizationalUnits); + public static bool IsOffice365Enabled => AvailableOffice365UserSeats > 0 || AvailableOffice365GroupSeats > 0 || AvailableOffice365SiteSeats > 0; + public static bool IsGoogleWorkspaceEnabled => + AvailableGoogleWorkspaceUserSeats > 0 || + AvailableGoogleWorkspaceGroupSeats > 0 || + AvailableGoogleWorkspaceSharedDriveSeats > 0 || + AvailableGoogleWorkspaceSiteSeats > 0 || + AvailableGoogleWorkspaceOrganizationalUnitSeats > 0; + private static Dictionary UnlicensedSeats = new Dictionary { { DuplicatiLicenseFeatures.Office365Users, 5 }, { DuplicatiLicenseFeatures.Office365Groups, 5 }, { DuplicatiLicenseFeatures.Office365Sites, 5 }, + { DuplicatiLicenseFeatures.GoogleWorkspaceUsers, 5 }, + { DuplicatiLicenseFeatures.GoogleWorkspaceGroups, 5 }, + { DuplicatiLicenseFeatures.GoogleWorkspaceSharedDrives, 5 }, + { DuplicatiLicenseFeatures.GoogleWorkspaceSites, 5 }, + { DuplicatiLicenseFeatures.GoogleWorkspaceOrganizationalUnits, 5 }, }; private static int GetDefaultSeats(string feature) diff --git a/proprietary/LoaderHelper/Duplicati.Proprietary.LoaderHelper.csproj b/proprietary/LoaderHelper/Duplicati.Proprietary.LoaderHelper.csproj index 6eaf1ecf6..0b854e598 100644 --- a/proprietary/LoaderHelper/Duplicati.Proprietary.LoaderHelper.csproj +++ b/proprietary/LoaderHelper/Duplicati.Proprietary.LoaderHelper.csproj @@ -13,6 +13,7 @@ + diff --git a/proprietary/LoaderHelper/RestoreDestinationProviderModules.cs b/proprietary/LoaderHelper/RestoreDestinationProviderModules.cs index ce8472481..abf12a9b5 100644 --- a/proprietary/LoaderHelper/RestoreDestinationProviderModules.cs +++ b/proprietary/LoaderHelper/RestoreDestinationProviderModules.cs @@ -35,7 +35,8 @@ public static class RestoreDestinationProviderModules /// private static readonly Lazy> LicensedRestoreDestinationProvidersLazy = new(() => new IRestoreDestinationProviderModule?[] { - LicenseHelper.IsOffice365Enabled ? new Office365.RestoreProvider() : null + LicenseHelper.IsOffice365Enabled ? new Office365.RestoreProvider() : null, + LicenseHelper.IsGoogleWorkspaceEnabled ? new GoogleWorkspace.RestoreProvider() : null } .WhereNotNull() .ToList() diff --git a/proprietary/LoaderHelper/SourceProviderModules.cs b/proprietary/LoaderHelper/SourceProviderModules.cs index 228e3f527..7193dedad 100644 --- a/proprietary/LoaderHelper/SourceProviderModules.cs +++ b/proprietary/LoaderHelper/SourceProviderModules.cs @@ -35,7 +35,8 @@ public static class SourceProviderModules /// private static Lazy> LicensedSourceProvidersLazy = new(() => new ISourceProviderModule?[] { - LicenseHelper.IsOffice365Enabled ? new Office365.SourceProvider() : null + LicenseHelper.IsOffice365Enabled ? new Office365.SourceProvider() : null, + LicenseHelper.IsGoogleWorkspaceEnabled ? new GoogleWorkspace.SourceProvider() : null } .WhereNotNull() .ToList() diff --git a/proprietary/LoaderHelper/WebModules.cs b/proprietary/LoaderHelper/WebModules.cs index b5d713cc3..e8c90fe03 100644 --- a/proprietary/LoaderHelper/WebModules.cs +++ b/proprietary/LoaderHelper/WebModules.cs @@ -35,7 +35,8 @@ public static class WebModules /// private static Lazy> LicensedWebModulesLazy = new(() => new IWebModule?[] { - LicenseHelper.IsOffice365Enabled ? new Office365.WebModule() : null + LicenseHelper.IsOffice365Enabled ? new Office365.WebModule() : null, + LicenseHelper.IsGoogleWorkspaceEnabled ? new GoogleWorkspace.WebModule() : null } .WhereNotNull() .ToList()