Skip to content
Closed
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
3 changes: 3 additions & 0 deletions ManagedCode.Storage.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
<Project Path="Storages/ManagedCode.Storage.FileSystem/ManagedCode.Storage.FileSystem.csproj" />
<Project Path="Storages/ManagedCode.Storage.Sftp/ManagedCode.Storage.Sftp.csproj" />
<Project Path="Storages/ManagedCode.Storage.Google/ManagedCode.Storage.Google.csproj" />
<Project Path="Storages/ManagedCode.Storage.GoogleDrive/ManagedCode.Storage.GoogleDrive.csproj" />
<Project Path="Storages/ManagedCode.Storage.OneDrive/ManagedCode.Storage.OneDrive.csproj" />
<Project Path="Storages/ManagedCode.Storage.Dropbox/ManagedCode.Storage.Dropbox.csproj" />
</Folder>
<Folder Name="/Tests/">
<Project Path="Tests/ManagedCode.Storage.Tests/ManagedCode.Storage.Tests.csproj" />
Expand Down
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,66 @@ Cloud storage vendors expose distinct SDKs, option models, and authentication pa
| [ManagedCode.Storage.FileSystem](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.FileSystem.svg)](https://www.nuget.org/packages/ManagedCode.Storage.FileSystem) | Local file system implementation for hybrid or on-premises workloads. |
| [ManagedCode.Storage.Sftp](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | [![NuGet](https://img.shields.io/nuget/v/ManagedCode.Storage.Sftp.svg)](https://www.nuget.org/packages/ManagedCode.Storage.Sftp) | SFTP provider powered by SSH.NET for legacy and air-gapped environments. |

### Configuring OneDrive, Google Drive, and Dropbox

> iCloud does not expose a public file API suitable for server-side integrations, so only Microsoft, Google, and Dropbox cloud drives are covered here.

**OneDrive / Microsoft Graph**

1. Create an app registration in Azure Active Directory (Entra ID) and record the **Application (client) ID**, **Directory (tenant) ID**, and a **client secret**.
2. Add the Microsoft Graph **Files.ReadWrite.All** delegated permission (or **Sites.ReadWrite.All** if you target SharePoint drives) and grant admin consent.
3. In your ASP.NET app, acquire a token via `ClientSecretCredential` or another `TokenCredential` and pass it to `new GraphServiceClient(credential, new[] { "https://graph.microsoft.com/.default" })`.
4. Register OneDrive storage with the Graph client and the drive/root you want to scope to:

```csharp
builder.Services.AddOneDriveStorageAsDefault(options =>
{
options.GraphClient = graphClient; // from step 3
options.DriveId = "me"; // or a specific drive ID
options.RootPath = "app-data"; // folder will be created when CreateContainerIfNotExists is true
options.CreateContainerIfNotExists = true;
});
```

5. If you need to impersonate a specific drive item, swap `DriveId` for the drive GUID returned by Graph.

**Google Drive**

1. In [Google Cloud Console](https://console.cloud.google.com/), create a project and enable the **Google Drive API**.
2. Configure an OAuth consent screen and create an **OAuth 2.0 Client ID** (Desktop or Web). Record the client ID and secret.
3. Exchange the OAuth code for a refresh token with the `https://www.googleapis.com/auth/drive.file` scope (or broader if necessary).
4. Add the Google Drive provider and feed the credentials to the options:

```csharp
builder.Services.AddGoogleDriveStorage(options =>
{
options.ClientId = configuration["GoogleDrive:ClientId"]!;
options.ClientSecret = configuration["GoogleDrive:ClientSecret"]!;
options.RefreshToken = configuration["GoogleDrive:RefreshToken"]!;
options.RootFolderId = "root"; // or a shared drive folder id
});
```

5. Store tokens in user secrets or environment variables; never commit them to source control.

**Dropbox**

1. Create an app in the [Dropbox App Console](https://www.dropbox.com/developers/apps) and choose **Scoped access** with the **Full Dropbox** or **App folder** type.
2. Under **Permissions**, enable `files.content.write`, `files.content.read`, and `files.metadata.write` and generate a refresh token via OAuth.
3. Register Dropbox storage with the access credentials and a root path (use `/` for full access apps or `/Apps/<your-app>` for app folders):

```csharp
builder.Services.AddDropboxStorage(options =>
{
options.AppKey = configuration["Dropbox:AppKey"]!;
options.AppSecret = configuration["Dropbox:AppSecret"]!;
options.RefreshToken = configuration["Dropbox:RefreshToken"]!;
options.RootPath = "/apps/my-app";
});
```

4. Dropbox issues short-lived access tokens from refresh tokens; the SDK handles the exchange automatically once configured.

### ASP.NET & Clients

| Package | Latest | Description |
Expand Down
154 changes: 154 additions & 0 deletions Storages/ManagedCode.Storage.Dropbox/Clients/DropboxClientWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Dropbox.Api;
using Dropbox.Api.Files;

namespace ManagedCode.Storage.Dropbox.Clients;

public class DropboxClientWrapper : IDropboxClientWrapper
{
private readonly DropboxClient _client;

public DropboxClientWrapper(DropboxClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}

public async Task EnsureRootAsync(string rootPath, bool createIfNotExists, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
return;
}

var normalized = Normalize(rootPath);
try
{
await _client.Files.GetMetadataAsync(normalized);
}
catch (ApiException<GetMetadataError> ex) when (ex.ErrorResponse.IsPath && ex.ErrorResponse.AsPath.Value.IsNotFound)
{
if (!createIfNotExists)
{
return;
}

await _client.Files.CreateFolderV2Async(normalized, autorename: false);
}
}
Comment on lines +22 to +43
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EnsureRootAsync implementation creates a folder but doesn't verify if it already exists before creation, which could lead to exceptions if createIfNotExists is false but the folder doesn't exist. The method should check existence first and only create when both the folder is missing and createIfNotExists is true.

Copilot uses AI. Check for mistakes.

public async Task<DropboxItemMetadata> UploadAsync(string rootPath, string path, Stream content, string? contentType, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
var uploaded = await _client.Files.UploadAsync(fullPath, WriteMode.Overwrite.Instance, body: content);
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancellationToken parameter is not used in any of the Stream or Content operations within the UploadAsync method, which means long-running uploads cannot be cancelled properly. The token should be passed to the UploadAsync call.

Suggested change
var uploaded = await _client.Files.UploadAsync(fullPath, WriteMode.Overwrite.Instance, body: content);
var uploaded = await _client.Files.UploadAsync(fullPath, WriteMode.Overwrite.Instance, body: content, cancellationToken: cancellationToken);

Copilot uses AI. Check for mistakes.
var metadata = (await _client.Files.GetMetadataAsync(uploaded.PathLower)).AsFile;
return ToItem(metadata);
}

public async Task<Stream> DownloadAsync(string rootPath, string path, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
var response = await _client.Files.DownloadAsync(fullPath);
return await response.GetContentAsStreamAsync();
Comment on lines +56 to +57
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancellationToken is not used in the DownloadAsync call, preventing cancellation of long-running downloads. The Dropbox API supports cancellation tokens, so they should be passed through.

Suggested change
var response = await _client.Files.DownloadAsync(fullPath);
return await response.GetContentAsStreamAsync();
var response = await _client.Files.DownloadAsync(fullPath, cancellationToken: cancellationToken);
return await response.GetContentAsStreamAsync(cancellationToken);

Copilot uses AI. Check for mistakes.
}

public async Task<bool> DeleteAsync(string rootPath, string path, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
await _client.Files.DeleteV2Async(fullPath);
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancellationToken parameter is not passed to the DeleteV2Async call, which means delete operations cannot be cancelled. Pass the cancellationToken to the API call.

Suggested change
await _client.Files.DeleteV2Async(fullPath);
await _client.Files.DeleteV2Async(fullPath, cancellationToken: cancellationToken);

Copilot uses AI. Check for mistakes.
return true;
}

public async Task<bool> ExistsAsync(string rootPath, string path, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
try
{
await _client.Files.GetMetadataAsync(fullPath);
return true;
}
catch (ApiException<GetMetadataError> ex) when (ex.ErrorResponse.IsPath && ex.ErrorResponse.AsPath.Value.IsNotFound)
{
return false;
}
}

public async Task<DropboxItemMetadata?> GetMetadataAsync(string rootPath, string path, CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, path);
try
{
var metadata = await _client.Files.GetMetadataAsync(fullPath);
return metadata.IsFile ? ToItem(metadata.AsFile) : null;
}
catch (ApiException<GetMetadataError> ex) when (ex.ErrorResponse.IsPath && ex.ErrorResponse.AsPath.Value.IsNotFound)
{
return null;
}
}
Comment on lines +67 to +93
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetMetadataAsync and ExistsAsync methods don't pass the cancellationToken to the API calls, preventing cancellation of these operations.

Copilot uses AI. Check for mistakes.

public async IAsyncEnumerable<DropboxItemMetadata> ListAsync(string rootPath, string? directory, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var fullPath = Combine(rootPath, directory ?? string.Empty);
var list = await _client.Files.ListFolderAsync(fullPath);
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
}

while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
Comment on lines +99 to +115
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Suggested change
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
}
while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
foreach (var item in list.Entries.Where(item => item.IsFile))
{
yield return ToItem(item.AsFile);
}
while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries.Where(item => item.IsFile))
{
yield return ToItem(item.AsFile);

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +115
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Suggested change
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
}
while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries)
{
if (item.IsFile)
{
yield return ToItem(item.AsFile);
}
foreach (var item in list.Entries.Where(item => item.IsFile))
{
yield return ToItem(item.AsFile);
}
while (list.HasMore)
{
list = await _client.Files.ListFolderContinueAsync(list.Cursor);
foreach (var item in list.Entries.Where(item => item.IsFile))
{
yield return ToItem(item.AsFile);

Copilot uses AI. Check for mistakes.
}
}
}
Comment on lines +95 to +118
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ListFolderAsync and ListFolderContinueAsync calls don't pass the cancellationToken, and the while loop doesn't check for cancellation between pages. This means large folder listings cannot be cancelled mid-operation.

Copilot uses AI. Check for mistakes.

private static DropboxItemMetadata ToItem(FileMetadata file)
{
return new DropboxItemMetadata
{
Name = file.Name,
Path = file.PathLower ?? file.PathDisplay ?? string.Empty,
Size = file.Size,
ClientModified = file.ClientModified,
ServerModified = file.ServerModified
};
}

private static string Normalize(string path)
{
var normalized = path.Replace("\\", "/");
if (!normalized.StartsWith('/'))
{
normalized = "/" + normalized;
}

return normalized.TrimEnd('/') == string.Empty ? "/" : normalized.TrimEnd('/');
}

private static string Combine(string root, string path)
{
var normalizedRoot = Normalize(root);
var normalizedPath = path.Replace("\\", "/").Trim('/');
if (string.IsNullOrWhiteSpace(normalizedPath))
{
return normalizedRoot;
}

return normalizedRoot.EndsWith("/") ? normalizedRoot + normalizedPath : normalizedRoot + "/" + normalizedPath;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;

namespace ManagedCode.Storage.Dropbox.Clients;

public class DropboxItemMetadata
{
public required string Name { get; set; }
public required string Path { get; set; }
public ulong Size { get; set; }
public DateTime ClientModified { get; set; }
public DateTime ServerModified { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Dropbox.Api.Files;

namespace ManagedCode.Storage.Dropbox.Clients;

public interface IDropboxClientWrapper
{
Task EnsureRootAsync(string rootPath, bool createIfNotExists, CancellationToken cancellationToken);

Task<DropboxItemMetadata> UploadAsync(string rootPath, string path, Stream content, string? contentType, CancellationToken cancellationToken);

Task<Stream> DownloadAsync(string rootPath, string path, CancellationToken cancellationToken);

Task<bool> DeleteAsync(string rootPath, string path, CancellationToken cancellationToken);

Task<bool> ExistsAsync(string rootPath, string path, CancellationToken cancellationToken);

Task<DropboxItemMetadata?> GetMetadataAsync(string rootPath, string path, CancellationToken cancellationToken);

IAsyncEnumerable<DropboxItemMetadata> ListAsync(string rootPath, string? directory, CancellationToken cancellationToken);
}
Loading
Loading