From 17bc4472a95d85eec1e45da6909afbc6aa821bdf Mon Sep 17 00:00:00 2001 From: danm Date: Thu, 4 Mar 2021 15:41:27 -0500 Subject: [PATCH] Added support for OneDrive shared folders. This adds support for shared folders which a user has "added to their OneDrive". Such folders appear as, effectively, symbolic links in the user's default root folder. These changes do not provide a way to access other OneDrive items that have been shared with the user. --- .../OneDrive/OneDriveApiExtensions.cs | 58 ++++++- .../OneDrive/OneDriveHelper.cs | 2 +- .../OneDrive/OneDriveStorageProvider.cs | 143 +++++++++++------- 3 files changed, 144 insertions(+), 59 deletions(-) diff --git a/KeeAnywhere/StorageProviders/OneDrive/OneDriveApiExtensions.cs b/KeeAnywhere/StorageProviders/OneDrive/OneDriveApiExtensions.cs index 2a631f2..f56bcce 100644 --- a/KeeAnywhere/StorageProviders/OneDrive/OneDriveApiExtensions.cs +++ b/KeeAnywhere/StorageProviders/OneDrive/OneDriveApiExtensions.cs @@ -1,17 +1,67 @@ using Microsoft.Graph; +using System; +using System.Linq; +using System.Threading.Tasks; namespace KeeAnywhere.StorageProviders.OneDrive { public static class OneDriveApiExtensions { - public static bool IsFolder(this DriveItem item) + + /// + /// Configures a Graph API request for an object identified by the + /// item identifier stored in a StorageProviderItem object. + /// + /// A Graph API request builder. + /// + /// The item ID from a StorageProviderItem object that was created + /// by the OneDrive storage provider. + /// + /// + public static IDriveItemRequestBuilder DriveItemFromStorageProviderItemId(this IGraphServiceClient api, string itemId) { - return item.Folder != null; + // ID must include a drive id prefix. + // See also OneDriveStorageProvider.MakeStorageProviderItemId. + var idParts = itemId.Split('/'); + if (idParts.Length != 2) throw new ArgumentOutOfRangeException("itemId"); + return api.Drives[idParts[0]].Items[idParts[1]]; } - public static bool IsFile(this DriveItem item) + /// + /// Configures a Graph API request for a OneDrive drive based on a + /// path from a user's default drive. Accommodates top-level folders + /// which are links to remote, shared items. + /// + /// A Graph API request builder. + /// + /// A URI path relative to the user's default drive. + /// + /// + /// A task that yields a drive item request builder. + /// + /// + /// An extra Web request has to be made to determine if the top + /// folder is remote or local. That's why this method is async. + /// + public async static Task DriveItemFromPathAsync(this IGraphServiceClient api, string path) { - return item.File != null; + // The top folder could be a shared folder, in which case it's + // on a different drive than the default. The path will use the + // name of the link in the user's root, which may be different + // from its actual (remote) name. + if (string.IsNullOrEmpty(path)) throw new ArgumentOutOfRangeException("path"); + var parts = path.Split('/'); + if (parts.Length == 1) + return api.Drive.Root.ItemWithPath(parts[0]); + + var topFolder = await api.Drive.Root.ItemWithPath(Uri.EscapeDataString(parts[0])).Request().GetAsync(); + var driveId = topFolder.RemoteItem == null ? topFolder.ParentReference.DriveId : topFolder.RemoteItem.ParentReference.DriveId; + var topFolderId = topFolder.RemoteItem == null ? topFolder.Id : topFolder.RemoteItem.Id; + // The top folder's apparent name can be different from its + // actual name, so don't use the name as part of the path at all. + // We have the id, so navigate from there instead. + return api.Drives[driveId].Items[topFolderId].ItemWithPath(Uri.EscapeDataString(string.Join("/",parts.Skip(1)))); } + } } \ No newline at end of file diff --git a/KeeAnywhere/StorageProviders/OneDrive/OneDriveHelper.cs b/KeeAnywhere/StorageProviders/OneDrive/OneDriveHelper.cs index 5bc0165..e6aa84c 100644 --- a/KeeAnywhere/StorageProviders/OneDrive/OneDriveHelper.cs +++ b/KeeAnywhere/StorageProviders/OneDrive/OneDriveHelper.cs @@ -45,7 +45,7 @@ public static OidcFlow CreateOidcFlow() }; } - public static async Task GetApi(AccountConfiguration account) + public static IGraphServiceClient GetApi(AccountConfiguration account) { if (Cache.ContainsKey(account.Id)) return Cache[account.Id]; diff --git a/KeeAnywhere/StorageProviders/OneDrive/OneDriveStorageProvider.cs b/KeeAnywhere/StorageProviders/OneDrive/OneDriveStorageProvider.cs index b26c55f..6e260d8 100644 --- a/KeeAnywhere/StorageProviders/OneDrive/OneDriveStorageProvider.cs +++ b/KeeAnywhere/StorageProviders/OneDrive/OneDriveStorageProvider.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Threading; using System.Threading.Tasks; using KeeAnywhere.Configuration; using Microsoft.Graph; @@ -13,21 +11,18 @@ namespace KeeAnywhere.StorageProviders.OneDrive public class OneDriveStorageProvider : IStorageProvider { private readonly AccountConfiguration _account; + private readonly IGraphServiceClient _api; public OneDriveStorageProvider(AccountConfiguration account) { if (account == null) throw new ArgumentNullException("account"); _account = account; + _api = OneDriveHelper.GetApi(account); } public async Task Load(string path) { - var api = await OneDriveHelper.GetApi(_account); - - var escapedpath = Uri.EscapeDataString(path); - var stream = await api.Drive - .Root - .ItemWithPath(escapedpath) + var stream = await (await _api.DriveItemFromPathAsync(path)) .Content .Request() .GetAsync(); @@ -38,13 +33,7 @@ public async Task Load(string path) public async Task Save(Stream stream, string path) { - var api = await OneDriveHelper.GetApi(_account); - - var escapedpath = Uri.EscapeDataString(path); - - var uploadedItem = await api.Drive - .Root - .ItemWithPath(escapedpath) + var uploadedItem = await (await _api.DriveItemFromPathAsync(path)) .Content .Request() .PutAsync(stream); @@ -56,20 +45,12 @@ public async Task Save(Stream stream, string path) public async Task Copy(string sourcePath, string destPath) { - var api = await OneDriveHelper.GetApi(_account); - - var escapedpath = Uri.EscapeDataString(sourcePath); - - var destFolder = Uri.EscapeDataString(CloudPath.GetDirectoryName(destPath)); var destFilename = CloudPath.GetFileName(destPath); - var destItem = await api.Drive.Root.ItemWithPath(destFolder).Request().GetAsync(); + var destItem = await (await _api.DriveItemFromPathAsync(destPath)).Request().GetAsync(); if (destItem == null) - throw new FileNotFoundException("OneDrive: Folder not found.", destFolder); + throw new FileNotFoundException("OneDrive: Folder not found.", destPath); - await api - .Drive - .Root - .ItemWithPath(escapedpath) + await (await _api.DriveItemFromPathAsync(sourcePath)) .Copy(destFilename, new ItemReference {Id = destItem.Id}) .Request(/*new[] {new HeaderOption("Prefer", "respond-async"), }*/) .PostAsync(); @@ -77,28 +58,24 @@ await api public async Task Delete(string path) { - var api = await OneDriveHelper.GetApi(_account); - - var escapedpath = Uri.EscapeDataString(path); - await api - .Drive - .Root - .ItemWithPath(escapedpath) - .Request() - .DeleteAsync(); - + await (await _api.DriveItemFromPathAsync(path)).Request().DeleteAsync(); } public async Task GetRootItem() { - var api = await OneDriveHelper.GetApi(_account); - var odItem = await api.Drive.Root.Request().GetAsync(); + var odItem = await _api.Drive.Root.Request().GetAsync(); if (odItem == null) return null; - var item = CreateStorageProviderItemFromOneDriveItem(odItem); - + var item = new StorageProviderItem + { + Type = StorageProviderItemType.Folder, + Id = MakeStorageProviderItemId(odItem), + Name = odItem.Name, + LastModifiedDateTime = odItem.LastModifiedDateTime, + ParentReferenceId = null + }; return item; } @@ -106,9 +83,7 @@ public async Task> GetChildrenByParentItem(Stor { if (parent == null) throw new ArgumentNullException("parent"); - var api = await OneDriveHelper.GetApi(_account); - - var odChildren = await api.Drive.Items[parent.Id].Children.Request().GetAsync(); + var odChildren = await _api.DriveItemFromStorageProviderItemId(parent.Id).Children.Request().GetAsync(); var children = odChildren.Select(odItem => CreateStorageProviderItemFromOneDriveItem(odItem)).ToArray(); @@ -118,10 +93,7 @@ public async Task> GetChildrenByParentItem(Stor public async Task> GetChildrenByParentPath(string path) { - var api = await OneDriveHelper.GetApi(_account); - - var odChildren = await api.Drive.Root.ItemWithPath(path).Children.Request().GetAsync(); - + var odChildren = await (await _api.DriveItemFromPathAsync(path)).Children.Request().GetAsync(); var children = odChildren.Select(odItem => CreateStorageProviderItemFromOneDriveItem(odItem)).ToArray(); @@ -136,21 +108,84 @@ public bool IsFilenameValid(string filename) return filename.All(c => c >= 32 && !invalidChars.Contains(c)); } - protected StorageProviderItem CreateStorageProviderItemFromOneDriveItem(DriveItem item) + protected static StorageProviderItem CreateStorageProviderItemFromOneDriveItem(DriveItem item) { var providerItem = new StorageProviderItem { - Type = - item.IsFolder() - ? StorageProviderItemType.Folder - : (item.IsFile() ? StorageProviderItemType.File : StorageProviderItemType.Unknown), - Id = item.Id, + Type = DetermineStorageProviderItemType(item), + Id = MakeStorageProviderItemId(item), Name = item.Name, LastModifiedDateTime = item.LastModifiedDateTime, - ParentReferenceId = item.ParentReference != null && !string.IsNullOrEmpty(item.ParentReference.Path) ? item.ParentReference.Id : null + ParentReferenceId = MakeStorageProviderItemParentId(item) }; return providerItem; } + + public static StorageProviderItemType DetermineStorageProviderItemType(DriveItem item) + { + if (item.RemoteItem == null) + { + if (item.Folder != null) return StorageProviderItemType.Folder; + if (item.File != null) return StorageProviderItemType.File; + } + else + { + if (item.RemoteItem.Folder != null) return StorageProviderItemType.Folder; + if (item.RemoteItem.File != null) return StorageProviderItemType.File; + } + return StorageProviderItemType.Unknown; + } + + private static string MakeStorageProviderItemId(DriveItem item) + { + if (item == null) throw new ArgumentNullException("item"); + + // If a user "adds" a folder was shared with them to their + // OneDrive, they effectively get a symbolic link in their + // default root folder which we will see as an item with + // a non-null RemoteItem. To access its contents, we need + // to use its real drive identifier, and the item identifier + // for that context, both of which we get via RemoteItem. + // We store these in the item's ID, instead of information + // about the symbolic link itself, so that when we're + // given its StorageProviderItem later, we can retrieve + // the folder content without doing another lookup. + // + // This works because, as of this writing, the only + // IStorageProvider method that takes a StorageProviderItem + // as an argument is the one requesting its children. If + // that changes, then we will need to store information about + // both the symbolic link and the remote item. + if (item.RemoteItem == null) + return MakeStorageProviderItemId(item.ParentReference.DriveId, item.Id); + return MakeStorageProviderItemId(item.RemoteItem.ParentReference.DriveId, item.RemoteItem.Id); + } + + private static string MakeStorageProviderItemParentId(DriveItem item) + { + if (item == null) throw new ArgumentNullException("item"); + // If RemoteItem is not null, this is a top-level access point to + // a shared item. The parent ID in this case should refer back to + // something in the user's own file space (currently, always the + // default drive root), so we ignore the parent information in + // RemoteItem. + // + // If this item is remote, its parent's id will be an id in the + // remote drive. See also comments in MakeStorageProviderItemId. + return MakeStorageProviderItemId(item.ParentReference.DriveId, item.ParentReference.Id); + } + + public static string MakeStorageProviderItemId(string drive, string item) + { + // Since "remote" items are on a drive other than the default, we + // construct item identifiers that include both the drive + // identifier and the actual item identifier, making the drive + // selection explicit. + if (string.IsNullOrEmpty(drive)) throw new ArgumentOutOfRangeException("drive"); + if (string.IsNullOrEmpty(item)) throw new ArgumentOutOfRangeException("item"); + return drive + "/" + item; + } + } } \ No newline at end of file