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 initial SFTP implementation using SSH.NET #15477

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions src/Files.App.Storage/Files.App.Storage.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

<ItemGroup>
<PackageReference Include="FluentFTP" Version="43.0.1" />
<PackageReference Include="SSH.NET" Version="2024.0.0" />
</ItemGroup>

<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
Expand Down
47 changes: 47 additions & 0 deletions src/Files.App.Storage/Storables/SftpStorage/SftpHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2024 Files Community
// Licensed under the MIT License. See the LICENSE.

using Files.App.Storage.FtpStorage;
using Files.Shared.Extensions;
using Renci.SshNet;

namespace Files.App.Storage.SftpStorage
{
internal static class SftpHelpers
{
public static string GetSftpPath(string path) => FtpHelpers.GetFtpPath(path);

public static Task EnsureConnectedAsync(this SftpClient sftpClient, CancellationToken cancellationToken = default)
=> sftpClient.IsConnected ? Task.CompletedTask : sftpClient.ConnectAsync(cancellationToken);

public static string GetSftpAuthority(string path) => FtpHelpers.GetFtpAuthority(path);

public static string GetSftpHost(string path)
{
var authority = GetSftpAuthority(path);
var index = authority.IndexOf(':', StringComparison.Ordinal);

return index == -1 ? authority : authority[..index];
}

public static int GetSftpPort(string path)
{
var authority = GetSftpAuthority(path);
var index = authority.IndexOf(':', StringComparison.Ordinal);

if (index == -1)
return 22;

return ushort.Parse(authority[(index + 1)..]);
}

public static SftpClient GetSftpClient(string ftpPath)
{
var host = GetSftpHost(ftpPath);
var port = GetSftpPort(ftpPath);
var credentials = SftpManager.Credentials.Get(host, SftpManager.EmptyCredentials);

return new(host, port, credentials?.UserName, credentials?.Password);
}
}
}
14 changes: 14 additions & 0 deletions src/Files.App.Storage/Storables/SftpStorage/SftpManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) 2024 Files Community
// Licensed under the MIT License. See the LICENSE.

using System.Net;

namespace Files.App.Storage.SftpStorage
{
public static class SftpManager
{
public static readonly Dictionary<string, NetworkCredential> Credentials = [];

public static readonly NetworkCredential EmptyCredentials = new(string.Empty, string.Empty);
}
}
41 changes: 41 additions & 0 deletions src/Files.App.Storage/Storables/SftpStorage/SftpStorable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2024 Files Community
// Licensed under the MIT License. See the LICENSE.

using Renci.SshNet;

namespace Files.App.Storage.SftpStorage
{
public abstract class SftpStorable : ILocatableStorable, INestedStorable
{
/// <inheritdoc/>
public virtual string Path { get; protected set; }

/// <inheritdoc/>
public virtual string Name { get; protected set; }

/// <inheritdoc/>
public virtual string Id { get; }

/// <summary>
/// Gets the parent folder of the storable, if any.
/// </summary>
protected virtual IFolder? Parent { get; }

protected internal SftpStorable(string path, string name, IFolder? parent)
{
Path = SftpHelpers.GetSftpPath(path);
Name = name;
Id = Path;
Parent = parent;
}

/// <inheritdoc/>
public Task<IFolder?> GetParentAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(Parent);
}

protected SftpClient GetSftpClient()
=> SftpHelpers.GetSftpClient(Path);
}
}
29 changes: 29 additions & 0 deletions src/Files.App.Storage/Storables/SftpStorage/SftpStorageFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) 2024 Files Community
// Licensed under the MIT License. See the LICENSE.

using System.IO;

namespace Files.App.Storage.SftpStorage
{
public sealed class SftpStorageFile : SftpStorable, IModifiableFile, ILocatableFile, INestedFile
{
public SftpStorageFile(string path, string name, IFolder? parent)
: base(path, name, parent)
{
}

/// <inheritdoc/>
public async Task<Stream> OpenStreamAsync(FileAccess access, CancellationToken cancellationToken = default)
{
using var sftpClient = GetSftpClient();
await sftpClient.EnsureConnectedAsync(cancellationToken);

if (access.HasFlag(FileAccess.Write))
return await sftpClient.OpenAsync(Path, FileMode.Open, FileAccess.Write, cancellationToken);
else if (access.HasFlag(FileAccess.Read))
return await sftpClient.OpenAsync(Path, FileMode.Open, FileAccess.Read, cancellationToken);
else
throw new ArgumentException($"Invalid {nameof(access)} flag.");
}
}
}
172 changes: 172 additions & 0 deletions src/Files.App.Storage/Storables/SftpStorage/SftpStorageFolder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright (c) 2024 Files Community
// Licensed under the MIT License. See the LICENSE.

using Files.App.Storage.FtpStorage;
using Files.Shared.Helpers;
using System.IO;
using System.Runtime.CompilerServices;

namespace Files.App.Storage.SftpStorage
{
public sealed class SftpStorageFolder : SftpStorable, ILocatableFolder, IModifiableFolder, IFolderExtended, INestedFolder, IDirectCopy, IDirectMove
{
public SftpStorageFolder(string path, string name, IFolder? parent)
: base(path, name, parent)
{
}

/// <inheritdoc/>
public async Task<INestedFile> GetFileAsync(string fileName, CancellationToken cancellationToken = default)
{
using var sftpClient = GetSftpClient();
await sftpClient.EnsureConnectedAsync(cancellationToken);

var path = SftpHelpers.GetSftpPath(PathHelpers.Combine(Path, fileName));
var item = await Task.Run(() => sftpClient.Get(path), cancellationToken);

if (item is null || item.IsDirectory)
throw new FileNotFoundException();

return new SftpStorageFile(path, item.Name, this);
}

/// <inheritdoc/>
public async Task<INestedFolder> GetFolderAsync(string folderName, CancellationToken cancellationToken = default)
{
using var sftpClient = GetSftpClient();
await sftpClient.EnsureConnectedAsync(cancellationToken);

var path = FtpHelpers.GetFtpPath(PathHelpers.Combine(Path, folderName));
var item = await Task.Run(() => sftpClient.Get(path), cancellationToken);

if (item is null || !item.IsDirectory)
throw new DirectoryNotFoundException();

return new SftpStorageFolder(path, item.Name, this);
}

/// <inheritdoc/>
public async IAsyncEnumerable<INestedStorable> GetItemsAsync(StorableKind kind = StorableKind.All, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var sftpClient = GetSftpClient();
await sftpClient.EnsureConnectedAsync(cancellationToken);

if (kind == StorableKind.Files)
{
await foreach (var item in sftpClient.ListDirectoryAsync(Path, cancellationToken))
{
if (!item.IsDirectory)
yield return new SftpStorageFile(item.FullName, item.Name, this);
}
}
else if (kind == StorableKind.Folders)
{
await foreach (var item in sftpClient.ListDirectoryAsync(Path, cancellationToken))
{
if (item.IsDirectory)
yield return new SftpStorageFolder(item.FullName, item.Name, this);
}
}
else
{
await foreach (var item in sftpClient.ListDirectoryAsync(Path, cancellationToken))
{
if (!item.IsDirectory)
yield return new SftpStorageFile(item.FullName, item.Name, this);

if (item.IsDirectory)
yield return new SftpStorageFolder(item.FullName, item.Name, this);
}
}
}

/// <inheritdoc/>
public async Task DeleteAsync(INestedStorable item, bool permanently = false, CancellationToken cancellationToken = default)
{
using var sftpClient = GetSftpClient();
await sftpClient.EnsureConnectedAsync(cancellationToken);

if (item is ILocatableFile locatableFile)
{
await sftpClient.DeleteFileAsync(locatableFile.Path, cancellationToken);
}
else if (item is ILocatableFolder locatableFolder)
{
// SSH.NET doesn't have an async equalivent for DeleteDirectory, for now a Task.Run could do.
await Task.Run(() => sftpClient.DeleteDirectory(locatableFolder.Path), cancellationToken);
}
else
{
throw new ArgumentException($"Could not delete {item}.");
}
}

/// <inheritdoc/>
public async Task<INestedStorable> CreateCopyOfAsync(INestedStorable itemToCopy, bool overwrite = default, CancellationToken cancellationToken = default)
{
if (itemToCopy is IFile sourceFile)
{
var copiedFile = await CreateFileAsync(itemToCopy.Name, overwrite, cancellationToken);
await sourceFile.CopyContentsToAsync(copiedFile, cancellationToken);

return copiedFile;
}
else
{
throw new NotSupportedException("Copying folders is not supported.");
}
}

/// <inheritdoc/>
public async Task<INestedStorable> MoveFromAsync(INestedStorable itemToMove, IModifiableFolder source, bool overwrite = default, CancellationToken cancellationToken = default)
{
using var sftpClient = GetSftpClient();
await sftpClient.EnsureConnectedAsync(cancellationToken);

var newItem = await CreateCopyOfAsync(itemToMove, overwrite, cancellationToken);
await source.DeleteAsync(itemToMove, true, cancellationToken);

return newItem;
}

/// <inheritdoc/>
public async Task<INestedFile> CreateFileAsync(string desiredName, bool overwrite = default, CancellationToken cancellationToken = default)
{
using var sftpClient = GetSftpClient();
await sftpClient.EnsureConnectedAsync(cancellationToken);

var newPath = $"{Path}/{desiredName}";
if (overwrite && await Task.Run(() => sftpClient.Exists(newPath)))
throw new IOException("File already exists.");

using var stream = new MemoryStream();

try
{
await Task.Run(() => sftpClient.UploadFile(stream, newPath), cancellationToken);
return new SftpStorageFile(newPath, desiredName, this);
}
catch
{
// File creation failed
throw new IOException("File creation failed.");
}
}

/// <inheritdoc/>
public async Task<INestedFolder> CreateFolderAsync(string desiredName, bool overwrite = default, CancellationToken cancellationToken = default)
{
using var sftpClient = GetSftpClient();
await sftpClient.EnsureConnectedAsync(cancellationToken);

var newPath = $"{Path}/{desiredName}";
if (overwrite && await Task.Run(() => sftpClient.Exists(newPath), cancellationToken))
throw new IOException("Directory already exists.");

// SSH.NET doesn't have an async equalivent for CreateDirectory, for now a Task.Run could do.
await Task.Run(() => sftpClient.CreateDirectory(newPath), cancellationToken);

return new SftpStorageFolder(newPath, desiredName, this);
}
}
}
39 changes: 39 additions & 0 deletions src/Files.App.Storage/Storables/SftpStorage/SftpStorageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2024 Files Community
// Licensed under the MIT License. See the LICENSE.

using System.IO;

namespace Files.App.Storage.SftpStorage
{
/// <inheritdoc cref="IFtpStorageService"/>
public sealed class SftpStorageService : ISftpStorageService
{
/// <inheritdoc/>
public async Task<IFolder> GetFolderAsync(string id, CancellationToken cancellationToken = default)
{
using var sftpClient = SftpHelpers.GetSftpClient(id);
await sftpClient.EnsureConnectedAsync(cancellationToken);

var ftpPath = SftpHelpers.GetSftpPath(id);
var item = await Task.Run(() => sftpClient.Get(ftpPath), cancellationToken);
if (item is null || !item.IsDirectory)
throw new DirectoryNotFoundException("Directory was not found from path.");

return new SftpStorageFolder(ftpPath, item.Name, null);
}

/// <inheritdoc/>
public async Task<IFile> GetFileAsync(string id, CancellationToken cancellationToken = default)
{
using var sftpClient = SftpHelpers.GetSftpClient(id);
await sftpClient.EnsureConnectedAsync(cancellationToken);

var ftpPath = SftpHelpers.GetSftpPath(id);
var item = await Task.Run(() => sftpClient.Get(ftpPath), cancellationToken);
if (item is null || item.IsDirectory)
throw new FileNotFoundException("File was not found from path.");

return new SftpStorageFile(ftpPath, item.Name, null);
}
}
}
1 change: 1 addition & 0 deletions src/Files.App/Files.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
<PackageReference Include="CommunityToolkit.WinUI.Notifications" Version="7.1.2" />
<PackageReference Include="CommunityToolkit.WinUI.UI.Behaviors" Version="7.1.2" />
<PackageReference Include="CommunityToolkit.WinUI.UI.Controls" Version="7.1.2" />
<PackageReference Include="SSH.NET" Version="2024.0.0" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
<PackageReference Include="Tulpep.ActiveDirectoryObjectPicker" Version="3.0.11" />
<PackageReference Include="WinUIEx" Version="2.3.4" />
Expand Down
Loading
Loading