Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for OneDrive shared folders. #261

Merged
merged 1 commit into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions KeeAnywhere/StorageProviders/OneDrive/OneDriveApiExtensions.cs
Original file line number Diff line number Diff line change
@@ -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)

/// <summary>
/// Configures a Graph API request for an object identified by the
/// item identifier stored in a StorageProviderItem object.
/// </summary>
/// <param name="api">A Graph API request builder.</param>
/// <param name="itemId">
/// The item ID from a StorageProviderItem object that was created
/// by the OneDrive storage provider.
/// </param>
/// <returns></returns>
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)
/// <summary>
/// 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.
/// </summary>
/// <param name="api">A Graph API request builder.</param>
/// <param name="path">
/// A URI path relative to the user's default drive.
/// </param>
/// <returns>
/// A task that yields a drive item request builder.
/// </returns>
/// <remarks>
/// 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.
/// </remarks>
public async static Task<IDriveItemRequestBuilder> 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))));
}

}
}
2 changes: 1 addition & 1 deletion KeeAnywhere/StorageProviders/OneDrive/OneDriveHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public static OidcFlow CreateOidcFlow()
};
}

public static async Task<IGraphServiceClient> GetApi(AccountConfiguration account)
public static IGraphServiceClient GetApi(AccountConfiguration account)
{
if (Cache.ContainsKey(account.Id)) return Cache[account.Id];

Expand Down
143 changes: 89 additions & 54 deletions KeeAnywhere/StorageProviders/OneDrive/OneDriveStorageProvider.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Stream> 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();
Expand All @@ -38,13 +33,7 @@ public async Task<Stream> 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<DriveItem>(stream);
Expand All @@ -56,59 +45,45 @@ 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();
}

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<StorageProviderItem> 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;
}

public async Task<IEnumerable<StorageProviderItem>> GetChildrenByParentItem(StorageProviderItem parent)
{
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();
Expand All @@ -118,10 +93,7 @@ public async Task<IEnumerable<StorageProviderItem>> GetChildrenByParentItem(Stor

public async Task<IEnumerable<StorageProviderItem>> 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();

Expand All @@ -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;
}

}
}