// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Duplicati.Library.Backend.GoogleServices; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Utility; using Duplicati.Library.Utility.Options; using System.Net.Http.Json; using System.Runtime.CompilerServices; using System.Text; namespace Duplicati.Library.Backend.GoogleDrive { // ReSharper disable once UnusedMember.Global // This class is instantiated dynamically in the BackendLoader. public class GoogleDrive : IBackend, IStreamingBackend, IQuotaEnabledBackend, IRenameEnabledBackend, IFolderEnabledBackend { private const string TEAMDRIVE_ID = "googledrive-teamdrive-id"; private const string FOLDER_MIMETYPE = "application/vnd.google-apps.folder"; private static readonly string TOKEN_URL = AuthIdOptionsHelper.GetOAuthLoginUrl("googledrive", null); private readonly string m_path; private readonly string? m_teamDriveID; private readonly OAuthHelperHttpClient m_oauth; private readonly Dictionary m_filecache; private readonly TimeoutOptionsHelper.Timeouts m_timeouts; private string? m_currentFolderId; public GoogleDrive() { m_path = null!; m_oauth = null!; m_filecache = null!; m_timeouts = null!; } public GoogleDrive(string url, Dictionary options) { var uri = new Utility.Uri(url); m_path = Util.AppendDirSeparator(uri.HostAndPath, "/"); var auth = AuthIdOptionsHelper.Parse(options) .RequireCredentials(TOKEN_URL); m_timeouts = TimeoutOptionsHelper.Parse(options); m_teamDriveID = options.GetValueOrDefault(TEAMDRIVE_ID); m_oauth = new OAuthHelperHttpClient(auth.AuthId!, this.ProtocolKey, auth.OAuthUrl) { AutoAuthHeader = true }; m_filecache = new Dictionary(); } private async Task GetFolderIdAsync(string path, bool autocreate, CancellationToken cancelToken) { var curparent = string.IsNullOrWhiteSpace(m_teamDriveID) ? (await GetAboutInfoAsync(cancelToken).ConfigureAwait(false)).rootFolderId : m_teamDriveID; if (string.IsNullOrEmpty(curparent)) throw new UserInformationException("Unable to get root folder", "GoogleDriveNoRootFolder"); var curdisplay = new StringBuilder("/"); foreach (var p in path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries)) { var res = await ListFolder(curparent, true, p, cancelToken).ToArrayAsync(cancelToken).ConfigureAwait(false); if (res.Length == 0) { if (!autocreate) throw new FolderMissingException(); curparent = (await CreateFolderAsync(p, curparent, cancelToken)).id; } else if (res.Length > 1) { throw new UserInformationException(Strings.GoogleDrive.MultipleEntries(p, curdisplay.ToString()), "GoogleDriveMultipleEntries"); } else { curparent = res[0].id; } curdisplay.Append(p).Append("/"); if (string.IsNullOrWhiteSpace(curparent)) throw new UserInformationException($"Unable to get folder id for {curdisplay}", "GoogleDriveNoFolderId"); } return curparent; } private async Task GetCurrentFolderIdAsync(CancellationToken cancelToken) { if (string.IsNullOrEmpty(m_currentFolderId)) m_currentFolderId = await GetFolderIdAsync(m_path, false, cancelToken).ConfigureAwait(false); return m_currentFolderId; } private async Task GetFileEntriesAsync(string remotename, bool throwMissingException, CancellationToken cancelToken) { m_filecache.TryGetValue(remotename, out var entries); if (entries != null) return entries; if (remotename.Contains('/')) { var parts = GetAbsolutePath(remotename).Split('/', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) throw new FileMissingException(); var fileName = parts.Last(); var folderPath = string.Join("/", parts.Take(parts.Length - 1)); var folderId = await GetFolderIdAsync(folderPath, false, cancelToken).ConfigureAwait(false); entries = await ListFolder(folderId, false, fileName, cancelToken).ToArrayAsync(cancelToken).ConfigureAwait(false); } else { var currentFolderId = await GetCurrentFolderIdAsync(cancelToken).ConfigureAwait(false); entries = await ListFolder(currentFolderId, false, remotename, cancelToken).ToArrayAsync(cancelToken).ConfigureAwait(false); } if (entries == null || entries.Length == 0) { if (throwMissingException) throw new FileMissingException(Strings.GoogleDrive.FilenotFound(remotename)); else return null; } return m_filecache[remotename] = entries; } private IFileEntry ParseEntry(GoogleDriveFolderItem n) { FileEntry fe; if (n.fileSize == null) fe = new FileEntry(n.title); else if (n.modifiedDate == null) fe = new FileEntry(n.title, n.fileSize.Value); else fe = new FileEntry(n.title, n.fileSize.Value, n.modifiedDate.Value, n.modifiedDate.Value); fe.IsFolder = FOLDER_MIMETYPE.Equals(n.mimeType, StringComparison.OrdinalIgnoreCase); if (fe.IsFolder && !fe.Name.EndsWith("/")) fe.Name += "/"; return fe; } private string GetAbsolutePath(string? path) { if (string.IsNullOrWhiteSpace(path)) return m_path; var p = path.Replace(Path.DirectorySeparatorChar, '/').Trim('/'); if (string.IsNullOrWhiteSpace(m_path) || m_path == "/") return "/" + p; else return Util.AppendDirSeparator(m_path, "/") + p; } public async IAsyncEnumerable ListAsync(string? path, [EnumeratorCancellation] CancellationToken cancellationToken) { var fullPath = GetAbsolutePath(path); string folderId; try { folderId = await GetFolderIdAsync(fullPath, false, cancellationToken).ConfigureAwait(false); } catch (FolderMissingException) { yield break; } await foreach (var n in ListFolder(folderId, null, null, cancellationToken).ConfigureAwait(false)) { yield return ParseEntry(n); } } public async Task GetEntryAsync(string path, CancellationToken cancellationToken) { var fullPath = GetAbsolutePath(path); var curparent = string.IsNullOrWhiteSpace(m_teamDriveID) ? (await GetAboutInfoAsync(cancellationToken).ConfigureAwait(false)).rootFolderId : m_teamDriveID; if (string.IsNullOrWhiteSpace(curparent)) throw new UserInformationException("Unable to get root folder", "GoogleDriveNoRootFolder"); var parts = fullPath.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) return new FileEntry(string.Empty) { IsFolder = true }; GoogleDriveFolderItem? currentItem = null; for (var i = 0; i < parts.Length; i++) { var p = parts[i]; var isLast = i == parts.Length - 1; // For intermediate parts, we need folders. // For the last part, it can be file or folder. var onlyFolders = isLast ? (bool?)null : true; var res = await ListFolder(curparent, onlyFolders, p, cancellationToken).ToArrayAsync(cancellationToken).ConfigureAwait(false); if (res.Length == 0) return null; if (res.Length > 1) throw new UserInformationException(Strings.GoogleDrive.MultipleEntries(p, fullPath), "GoogleDriveMultipleEntries"); currentItem = res[0]; if (string.IsNullOrWhiteSpace(currentItem.id)) return null; curparent = currentItem.id; } if (currentItem == null) return null; return ParseEntry(currentItem); } private static string EscapeTitleEntries(string title) { return title.Replace("'", "\\'"); } #region IStreamingBackend implementation public async Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken) { try { // Figure out if we update or create the file if (m_filecache.Count == 0) await foreach (var file in ListAsync(cancelToken).ConfigureAwait(false)) { /* Enumerate the full listing */ } m_filecache.TryGetValue(remotename, out var files); string? fileId = null; if (files != null) { if (files.Length == 1) fileId = files[0].id; else await DeleteAsync(remotename, cancelToken); } var isUpdate = !string.IsNullOrWhiteSpace(fileId); var url = WebApi.GoogleDrive.PutUrl(fileId, m_teamDriveID != null); var currentFolderId = await GetCurrentFolderIdAsync(cancelToken).ConfigureAwait(false); var item = new GoogleDriveFolderItem { title = remotename, description = remotename, mimeType = "application/octet-stream", labels = new GoogleDriveFolderItemLabels { hidden = true }, parents = [new GoogleDriveParentReference { id = currentFolderId }], teamDriveId = m_teamDriveID }; var res = await GoogleCommon.ChunkedUploadWithResumeAsync(m_oauth, item, url, stream, m_timeouts.ShortTimeout, m_timeouts.ReadWriteTimeout, cancelToken, isUpdate ? HttpMethod.Put : HttpMethod.Post).ConfigureAwait(false); m_filecache[remotename] = [res]; } catch { m_filecache.Clear(); throw; } } public async Task GetAsync(string remotename, Stream stream, CancellationToken cancelToken) { // Prevent repeated download url lookups if (m_filecache.Count == 0) await foreach (var file in ListAsync(cancelToken).ConfigureAwait(false)) { /* Enumerate the full listing */ } var fileId = (await GetFileEntriesAsync(remotename, true, cancelToken).ConfigureAwait(false))?.OrderByDescending(x => x.createdDate).FirstOrDefault()?.id; if (string.IsNullOrEmpty(fileId)) throw new FileMissingException(Strings.GoogleDrive.FilenotFound(remotename)); var req = await m_oauth.CreateRequestAsync(WebApi.GoogleDrive.GetUrl(fileId), HttpMethod.Get, cancelToken).ConfigureAwait(false); using var resp = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, ct => m_oauth.GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)).ConfigureAwait(false); using var rs = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, ct => resp.Content.ReadAsStreamAsync(ct)).ConfigureAwait(false); using (var ts = rs.ObserveReadTimeout(m_timeouts.ReadWriteTimeout)) await Utility.Utility.CopyStreamAsync(ts, stream, cancelToken); } #endregion #region IBackend implementation /// public async IAsyncEnumerable ListAsync([EnumeratorCancellation] CancellationToken cancelToken) { bool success = false; try { m_filecache.Clear(); // For now, this class assumes that List() fully populates the file cache var currentFolderId = await GetCurrentFolderIdAsync(cancelToken).ConfigureAwait(false); await foreach (var n in ListFolder(currentFolderId, null, null, cancelToken).ConfigureAwait(false)) { var fe = ParseEntry(n); if (!fe.IsFolder) { if (!m_filecache.TryGetValue(fe.Name, out var lst)) { m_filecache[fe.Name] = [n]; } else { Array.Resize(ref lst, lst.Length + 1); lst[lst.Length - 1] = n; } } yield return fe; } success = true; } finally { // If the enumeration either failed or didn't complete, clear the file cache. // This way, other operations which require a fully populated file cache will see an empty one and can populate it themselves. if (!success) { m_filecache.Clear(); } } } public async Task PutAsync(string remotename, string filename, CancellationToken cancelToken) { using (var fs = File.OpenRead(filename)) await PutAsync(remotename, fs, cancelToken).ConfigureAwait(false); } public async Task GetAsync(string remotename, string filename, CancellationToken cancelToken) { using (var fs = File.Create(filename)) await GetAsync(remotename, fs, cancelToken); } public async Task DeleteAsync(string remotename, CancellationToken cancelToken) { try { var entries = await GetFileEntriesAsync(remotename, true, cancelToken).ConfigureAwait(false); if (entries == null) throw new FileMissingException(Strings.GoogleDrive.FilenotFound(remotename)); foreach (var fileid in entries.Select(x => x.id).WhereNotNullOrWhiteSpace()) { var url = WebApi.GoogleDrive.DeleteUrl(Library.Utility.Uri.UrlPathEncode(fileid), m_teamDriveID); await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, async ct => { using var req = await m_oauth.CreateRequestAsync(url, HttpMethod.Delete, ct).ConfigureAwait(false); using var resp = await m_oauth.GetResponseAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false); }).ConfigureAwait(false); } m_filecache.Remove(remotename); } catch { m_filecache.Clear(); throw; } } public Task TestAsync(CancellationToken cancelToken) => this.TestReadWritePermissionsAsync(cancelToken); public async Task CreateFolderAsync(CancellationToken cancelToken) { m_filecache.Clear(); m_currentFolderId = await GetFolderIdAsync(m_path, true, cancelToken).ConfigureAwait(false); } /// public string DisplayName => Strings.GoogleDrive.DisplayName; /// public string ProtocolKey => "googledrive"; /// public bool SupportsStreaming => true; /// public IList SupportedCommands => [ .. AuthIdOptionsHelper.GetOptions(TOKEN_URL), new CommandLineArgument(TEAMDRIVE_ID, CommandLineArgument.ArgumentType.String, Strings.GoogleDrive.TeamDriveIdShort, Strings.GoogleDrive.TeamDriveIdLong), .. TimeoutOptionsHelper.GetOptions(), ]; /// public string Description => Strings.GoogleDrive.Description; #endregion #region IQuotaEnabledBackend implementation public async Task GetQuotaInfoAsync(CancellationToken cancelToken) { try { var about = await this.GetAboutInfoAsync(cancelToken).ConfigureAwait(false); return new QuotaInfo(about.quotaBytesTotal ?? -1, about.quotaBytesTotal - about.quotaBytesUsed ?? -1); } catch { return null; } } public Task GetDNSNamesAsync(CancellationToken cancelToken) => Task.FromResult(WebApi.GoogleDrive.Hosts()); #endregion #region IRenameEnabledBackend implementation public async Task RenameAsync(string oldname, string newname, CancellationToken cancellationToken) { try { var files = await GetFileEntriesAsync(oldname, true, cancellationToken).ConfigureAwait(false); if (files == null) throw new FileMissingException(Strings.GoogleDrive.FilenotFound(oldname)); if (files.Length > 1) throw new UserInformationException(Strings.GoogleDrive.MultipleEntries(oldname, m_path), "GoogleDriveMultipleEntries"); using var stream = new MemoryStream(); await GetAsync(oldname, stream, cancellationToken).ConfigureAwait(false); await PutAsync(newname, stream, cancellationToken).ConfigureAwait(false); await DeleteAsync(oldname, cancellationToken).ConfigureAwait(false); m_filecache.Remove(oldname); } catch { m_filecache.Clear(); throw; } } #endregion #region IDisposable implementation public void Dispose() { } #endregion private class GoogleDriveParentReference { public string? id { get; set; } } private class GoogleDriveListResponse { public string? nextPageToken { get; set; } public GoogleDriveFolderItem[]? items { get; set; } } private class GoogleDriveFolderItemLabels { public bool hidden { get; set; } } private class GoogleDriveFolderItem { public string? id { get; set; } public string? title { get; set; } public string? description { get; set; } public string? mimeType { get; set; } public GoogleDriveFolderItemLabels? labels { get; set; } public DateTime? createdDate { get; set; } public DateTime? modifiedDate { get; set; } public long? fileSize { get; set; } public string? teamDriveId { get; set; } public GoogleDriveParentReference[]? parents { get; set; } } private class GoogleDriveAboutResponse { public long? quotaBytesTotal { get; set; } public long? quotaBytesUsed { get; set; } public string? rootFolderId { get; set; } } private async IAsyncEnumerable ListFolder(string parentfolder, bool? onlyFolders, string? name, [EnumeratorCancellation] CancellationToken cancelToken) { var fileQuery = new string?[] { string.IsNullOrEmpty(name) ? null : string.Format("title = '{0}'", EscapeTitleEntries(name)), onlyFolders == null ? null : string.Format("mimeType {0}= '{1}'", onlyFolders.Value ? "" : "!", FOLDER_MIMETYPE), string.Format("'{0}' in parents", EscapeTitleEntries(parentfolder)), "trashed=false" }; var encodedFileQuery = Utility.Uri.UrlEncode(string.Join(" and ", fileQuery.Where(x => x != null))); var url = WebApi.GoogleDrive.ListUrl(encodedFileQuery, m_teamDriveID); while (true) { var res = await Utility.Utility.WithTimeout(m_timeouts.ListTimeout, cancelToken, async ct => { using var req = await m_oauth.CreateRequestAsync(url, HttpMethod.Get, ct).ConfigureAwait(false); using var resp = await m_oauth.GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); return await resp.Content.ReadFromJsonAsync(ct).ConfigureAwait(false) ?? throw new UserInformationException(Strings.GoogleDrive.ListResponseError, "GoogleDriveListResponseError"); }).ConfigureAwait(false); foreach (var n in res.items ?? []) yield return n; var token = res.nextPageToken; if (string.IsNullOrWhiteSpace(token)) break; url = WebApi.GoogleDrive.ListUrl(encodedFileQuery, m_teamDriveID, token); } } private Task GetAboutInfoAsync(CancellationToken cancelToken) => Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, async ct => { using var req = await m_oauth.CreateRequestAsync(WebApi.GoogleDrive.AboutInfoUrl(), HttpMethod.Get, ct).ConfigureAwait(false); using var resp = await m_oauth.GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); return await resp.Content.ReadFromJsonAsync(ct).ConfigureAwait(false) ?? throw new UserInformationException(Strings.GoogleDrive.AboutResponseError, "GoogleDriveAboutResponseError"); }); private Task CreateFolderAsync(string name, string parent, CancellationToken cancelToken) { var folder = new GoogleDriveFolderItem() { title = name, description = name, mimeType = FOLDER_MIMETYPE, labels = new GoogleDriveFolderItemLabels { hidden = true }, parents = [new GoogleDriveParentReference { id = parent }] }; return Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, async ct => { using var req = await m_oauth.CreateRequestAsync(WebApi.GoogleDrive.CreateFolderUrl(m_teamDriveID), HttpMethod.Post, ct).ConfigureAwait(false); req.Content = JsonContent.Create(folder); using var resp = await m_oauth.GetResponseAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); return await resp.Content.ReadFromJsonAsync(ct).ConfigureAwait(false) ?? throw new UserInformationException(Strings.GoogleDrive.CreateFolderResponseError, "GoogleDriveListResponseError"); }); } } }