Skip to content
Closed
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