From 7f90b5e08b42a257e676515980dd61a9b3d62791 Mon Sep 17 00:00:00 2001 From: itsWindows11 Date: Sun, 26 May 2024 18:14:53 +0300 Subject: [PATCH 1/2] Added initial SFTP implementation using SSH.NET. --- .../Files.App.Storage.csproj | 1 + .../Storables/SftpStorage/SftpHelpers.cs | 47 +++ .../Storables/SftpStorage/SftpManager.cs | 14 + .../Storables/SftpStorage/SftpStorable.cs | 41 ++ .../Storables/SftpStorage/SftpStorageFile.cs | 29 ++ .../SftpStorage/SftpStorageFolder.cs | 172 +++++++++ .../SftpStorage/SftpStorageService.cs | 39 ++ src/Files.App/Files.App.csproj | 1 + .../Utils/Storage/Helpers/SftpHelpers.cs | 80 ++++ .../Storage/Helpers/StorageFileExtensions.cs | 9 +- .../Storage/StorageItems/SftpStorageFile.cs | 346 +++++++++++++++++ .../Storage/StorageItems/SftpStorageFolder.cs | 357 ++++++++++++++++++ src/Files.Core.Storage/IFtpStorageService.cs | 2 +- src/Files.Core.Storage/ISftpStorageService.cs | 12 + 14 files changed, 1146 insertions(+), 4 deletions(-) create mode 100644 src/Files.App.Storage/Storables/SftpStorage/SftpHelpers.cs create mode 100644 src/Files.App.Storage/Storables/SftpStorage/SftpManager.cs create mode 100644 src/Files.App.Storage/Storables/SftpStorage/SftpStorable.cs create mode 100644 src/Files.App.Storage/Storables/SftpStorage/SftpStorageFile.cs create mode 100644 src/Files.App.Storage/Storables/SftpStorage/SftpStorageFolder.cs create mode 100644 src/Files.App.Storage/Storables/SftpStorage/SftpStorageService.cs create mode 100644 src/Files.App/Utils/Storage/Helpers/SftpHelpers.cs create mode 100644 src/Files.App/Utils/Storage/StorageItems/SftpStorageFile.cs create mode 100644 src/Files.App/Utils/Storage/StorageItems/SftpStorageFolder.cs create mode 100644 src/Files.Core.Storage/ISftpStorageService.cs diff --git a/src/Files.App.Storage/Files.App.Storage.csproj b/src/Files.App.Storage/Files.App.Storage.csproj index c08d08cbe3f0..35b75b20e825 100644 --- a/src/Files.App.Storage/Files.App.Storage.csproj +++ b/src/Files.App.Storage/Files.App.Storage.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Files.App.Storage/Storables/SftpStorage/SftpHelpers.cs b/src/Files.App.Storage/Storables/SftpStorage/SftpHelpers.cs new file mode 100644 index 000000000000..e4bde44b3862 --- /dev/null +++ b/src/Files.App.Storage/Storables/SftpStorage/SftpHelpers.cs @@ -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); + } + } +} diff --git a/src/Files.App.Storage/Storables/SftpStorage/SftpManager.cs b/src/Files.App.Storage/Storables/SftpStorage/SftpManager.cs new file mode 100644 index 000000000000..b8eda89c6bc2 --- /dev/null +++ b/src/Files.App.Storage/Storables/SftpStorage/SftpManager.cs @@ -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 Credentials = []; + + public static readonly NetworkCredential EmptyCredentials = new(string.Empty, string.Empty); + } +} diff --git a/src/Files.App.Storage/Storables/SftpStorage/SftpStorable.cs b/src/Files.App.Storage/Storables/SftpStorage/SftpStorable.cs new file mode 100644 index 000000000000..5caa1ede869c --- /dev/null +++ b/src/Files.App.Storage/Storables/SftpStorage/SftpStorable.cs @@ -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 + { + /// + public virtual string Path { get; protected set; } + + /// + public virtual string Name { get; protected set; } + + /// + public virtual string Id { get; } + + /// + /// Gets the parent folder of the storable, if any. + /// + protected virtual IFolder? Parent { get; } + + protected internal SftpStorable(string path, string name, IFolder? parent) + { + Path = SftpHelpers.GetSftpPath(path); + Name = name; + Id = Path; + Parent = parent; + } + + /// + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(Parent); + } + + protected SftpClient GetSftpClient() + => SftpHelpers.GetSftpClient(Path); + } +} diff --git a/src/Files.App.Storage/Storables/SftpStorage/SftpStorageFile.cs b/src/Files.App.Storage/Storables/SftpStorage/SftpStorageFile.cs new file mode 100644 index 000000000000..290a80160293 --- /dev/null +++ b/src/Files.App.Storage/Storables/SftpStorage/SftpStorageFile.cs @@ -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) + { + } + + /// + public async Task 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."); + } + } +} diff --git a/src/Files.App.Storage/Storables/SftpStorage/SftpStorageFolder.cs b/src/Files.App.Storage/Storables/SftpStorage/SftpStorageFolder.cs new file mode 100644 index 000000000000..f44c32362225 --- /dev/null +++ b/src/Files.App.Storage/Storables/SftpStorage/SftpStorageFolder.cs @@ -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) + { + } + + /// + public async Task 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); + } + + /// + public async Task 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); + } + + /// + public async IAsyncEnumerable 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); + } + } + } + + /// + 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}."); + } + } + + /// + public async Task 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."); + } + } + + /// + public async Task 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; + } + + /// + public async Task 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."); + } + } + + /// + public async Task 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); + } + } +} diff --git a/src/Files.App.Storage/Storables/SftpStorage/SftpStorageService.cs b/src/Files.App.Storage/Storables/SftpStorage/SftpStorageService.cs new file mode 100644 index 000000000000..d6f7b013d9c2 --- /dev/null +++ b/src/Files.App.Storage/Storables/SftpStorage/SftpStorageService.cs @@ -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 +{ + /// + public sealed class SftpStorageService : ISftpStorageService + { + /// + public async Task 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); + } + + /// + public async Task 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); + } + } +} diff --git a/src/Files.App/Files.App.csproj b/src/Files.App/Files.App.csproj index 33cdbc317433..5d3ee9011a5f 100644 --- a/src/Files.App/Files.App.csproj +++ b/src/Files.App/Files.App.csproj @@ -91,6 +91,7 @@ + diff --git a/src/Files.App/Utils/Storage/Helpers/SftpHelpers.cs b/src/Files.App/Utils/Storage/Helpers/SftpHelpers.cs new file mode 100644 index 000000000000..cf3d0558546a --- /dev/null +++ b/src/Files.App/Utils/Storage/Helpers/SftpHelpers.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2024 Files Community +// Licensed under the MIT License. See the LICENSE. + +using FluentFTP; +using Renci.SshNet; + +namespace Files.App.Utils.Storage +{ + public static class SftpHelpers + { + public static async Task EnsureConnectedAsync(this SftpClient ftpClient) + { + if (!ftpClient.IsConnected) + { + await ftpClient.ConnectAsync(default); + } + + return true; + } + + public static bool IsSftpPath(string path) + { + if (!string.IsNullOrEmpty(path)) + { + return path.StartsWith("sftp://", StringComparison.OrdinalIgnoreCase); + } + return false; + } + + public static bool VerifyFtpPath(string path) + { + var authority = GetSftpAuthority(path); + var index = authority.IndexOf(':', StringComparison.Ordinal); + + return index == -1 || ushort.TryParse(authority.AsSpan(index + 1), out _); + } + + public static string GetSftpHost(string path) + { + var authority = GetSftpAuthority(path); + var index = authority.IndexOf(':', StringComparison.Ordinal); + + return index == -1 ? authority : authority[..index]; + } + + public static ushort 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 string GetSftpAuthority(string path) + { + path = path.Replace("\\", "/", StringComparison.Ordinal); + if (Uri.TryCreate(path, UriKind.Absolute, out var uri)) + return uri.Authority; + return string.Empty; + } + + public static string GetSftpPath(string path) + { + path = path.Replace("\\", "/", StringComparison.Ordinal); + var schemaIndex = path.IndexOf("://", StringComparison.Ordinal) + 3; + var hostIndex = path.IndexOf('/', schemaIndex); + return hostIndex == -1 ? "/" : path.Substring(hostIndex); + } + + public static int GetRootIndex(string path) + { + path = path.Replace("\\", "/", StringComparison.Ordinal); + var schemaIndex = path.IndexOf("://", StringComparison.Ordinal) + 3; + return path.IndexOf('/', schemaIndex); + } + } +} \ No newline at end of file diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs index c1f541e6f9ac..67058c172a49 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs @@ -17,6 +17,9 @@ public static class StorageFileExtensions public static readonly ImmutableHashSet _ftpPaths = new HashSet() { "ftp:/", "ftps:/", "ftpes:/" }.ToImmutableHashSet(); + public static readonly ImmutableHashSet _sftpPaths = + new HashSet() { "sftp:/" }.ToImmutableHashSet(); + public static BaseStorageFile? AsBaseStorageFile(this IStorageItem item) { if (item is null || !item.IsOfType(StorageItemTypes.File)) @@ -122,7 +125,7 @@ public static List GetDirectoryPathComponents(string value) var component = value.Substring(lastIndex, i - lastIndex); var path = value.Substring(0, i + 1); - if (!_ftpPaths.Contains(path, StringComparer.OrdinalIgnoreCase)) + if (!_ftpPaths.Contains(path, StringComparer.OrdinalIgnoreCase) && !_sftpPaths.Contains(path, StringComparer.OrdinalIgnoreCase)) pathBoxItems.Add(GetPathItem(component, path)); lastIndex = i + 1; @@ -197,7 +200,7 @@ public async static Task DangerousGetFileWithPathFromPathAs } } - var fullPath = (parentFolder is not null && !FtpHelpers.IsFtpPath(value) && !Path.IsPathRooted(value) && !ShellStorageFolder.IsShellPath(value)) // "::{" not a valid root + var fullPath = (parentFolder is not null && !FtpHelpers.IsFtpPath(value) && !SftpHelpers.IsSftpPath(value) && !Path.IsPathRooted(value) && !ShellStorageFolder.IsShellPath(value)) // "::{" not a valid root ? Path.GetFullPath(Path.Combine(parentFolder.Path, value)) // Relative path : value; var item = await BaseStorageFile.GetFileFromPathAsync(fullPath); @@ -251,7 +254,7 @@ public async static Task DangerousGetFolderWithPathFromPa } } - var fullPath = (parentFolder is not null && !FtpHelpers.IsFtpPath(value) && !Path.IsPathRooted(value) && !ShellStorageFolder.IsShellPath(value)) // "::{" not a valid root + var fullPath = (parentFolder is not null && !FtpHelpers.IsFtpPath(value) && !SftpHelpers.IsSftpPath(value) && !Path.IsPathRooted(value) && !ShellStorageFolder.IsShellPath(value)) // "::{" not a valid root ? Path.GetFullPath(Path.Combine(parentFolder.Path, value)) // Relative path : value; var item = await BaseStorageFolder.GetFolderFromPathAsync(fullPath); diff --git a/src/Files.App/Utils/Storage/StorageItems/SftpStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/SftpStorageFile.cs new file mode 100644 index 000000000000..94b35644eeb3 --- /dev/null +++ b/src/Files.App/Utils/Storage/StorageItems/SftpStorageFile.cs @@ -0,0 +1,346 @@ +// Copyright (c) 2024 Files Community +// Licensed under the MIT License. See the LICENSE. + +using Files.App.Storage.FtpStorage; +using Renci.SshNet; +using Renci.SshNet.Sftp; +using System.IO; +using System.Net; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Streams; +using IO = System.IO; + +namespace Files.App.Utils.Storage +{ + public sealed class SftpStorageFile : BaseStorageFile, IPasswordProtectedItem + { + public override string Path { get; } + public override string Name { get; } + public override string DisplayName => Name; + public override string ContentType => "application/octet-stream"; + public override string FileType => IO.Path.GetExtension(Name); + public string FtpPath { get; } + public override string FolderRelativeId => $"0\\{Name}"; + + public override string DisplayType + { + get + { + var itemType = "File".GetLocalizedResource(); + if (Name.Contains('.', StringComparison.Ordinal)) + { + itemType = IO.Path.GetExtension(Name).Trim('.') + " " + itemType; + } + return itemType; + } + } + + public override DateTimeOffset DateCreated { get; } + public override Windows.Storage.FileAttributes Attributes { get; } = Windows.Storage.FileAttributes.Normal; + public override IStorageItemExtraProperties Properties => new BaseBasicStorageItemExtraProperties(this); + + public StorageCredential Credentials { get; set; } + + public Func> PasswordRequestedCallback { get; set; } + + public SftpStorageFile(string path, string name, DateTimeOffset dateCreated) + { + Path = path; + Name = name; + FtpPath = FtpHelpers.GetFtpPath(path); + DateCreated = dateCreated; + } + public SftpStorageFile(string folder, ISftpFile ftpItem) + { + Path = PathNormalization.Combine(folder, ftpItem.Name); + Name = ftpItem.Name; + FtpPath = FtpHelpers.GetFtpPath(Path); + DateCreated = DateTimeOffset.MinValue; + } + public SftpStorageFile(IStorageItemWithPath item) + { + Path = item.Path; + Name = IO.Path.GetFileName(item.Path); + FtpPath = FtpHelpers.GetFtpPath(item.Path); + } + + public static IAsyncOperation FromPathAsync(string path) + => SftpHelpers.IsSftpPath(path) && FtpHelpers.VerifyFtpPath(path) + ? Task.FromResult(new SftpStorageFile(new StorageFileWithPath(null, path))).AsAsyncOperation() + : Task.FromResult(null).AsAsyncOperation(); + + public override IAsyncOperation ToStorageFileAsync() + => StorageFile.CreateStreamedFileAsync(Name, FtpDataStreamingHandlerAsync, null); + + public override bool IsEqual(IStorageItem item) => item?.Path == Path; + public override bool IsOfType(StorageItemTypes type) => type is StorageItemTypes.File; + + public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); + + public override IAsyncOperation GetBasicPropertiesAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return new BaseBasicProperties(); + } + + var item = await Task.Run(() => ftpClient.Get(FtpPath), cancellationToken); + return item is null ? new BaseBasicProperties() : new SftpFileBasicProperties(item); + }, (_, _) => Task.FromResult(new BaseBasicProperties()))); + } + + public override IAsyncOperation OpenAsync(FileAccessMode accessMode) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return null; + } + + if (accessMode is FileAccessMode.Read) + { + var inStream = await ftpClient.OpenAsync(FtpPath, FileMode.Open, FileAccess.Read, cancellationToken); + return new NonSeekableRandomAccessStreamForRead(inStream, (ulong)inStream.Length) + { + DisposeCallback = ftpClient.Dispose + }; + } + return new NonSeekableRandomAccessStreamForWrite(await ftpClient.OpenAsync(FtpPath, FileMode.OpenOrCreate, FileAccess.Write, cancellationToken)) + { + DisposeCallback = ftpClient.Dispose + }; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + public override IAsyncOperation OpenAsync(FileAccessMode accessMode, StorageOpenOptions options) => OpenAsync(accessMode); + + public override IAsyncOperation OpenReadAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return null; + } + + var inStream = await ftpClient.OpenAsync(FtpPath, FileMode.Open, FileAccess.Read, cancellationToken); + var nsStream = new NonSeekableRandomAccessStreamForRead(inStream, (ulong)inStream.Length) { DisposeCallback = ftpClient.Dispose }; + return new StreamWithContentType(nsStream); + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + public override IAsyncOperation OpenSequentialReadAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return null; + } + + var inStream = await ftpClient.OpenAsync(FtpPath, FileMode.Open, FileAccess.Read, cancellationToken); + return new InputStreamWithDisposeCallback(inStream) { DisposeCallback = () => ftpClient.Dispose() }; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation OpenTransactedWriteAsync() => throw new NotSupportedException(); + public override IAsyncOperation OpenTransactedWriteAsync(StorageOpenOptions options) => throw new NotSupportedException(); + + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder) + => CopyAsync(destinationFolder, Name, NameCollisionOption.FailIfExists); + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName) + => CopyAsync(destinationFolder, desiredNewName, NameCollisionOption.FailIfExists); + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return null; + } + + BaseStorageFolder destFolder = destinationFolder.AsBaseStorageFolder(); + + if (destFolder is ICreateFileWithStream cwsf) + { + using var inStream = await ftpClient.OpenAsync(FtpPath, FileMode.Open, FileAccess.Read, cancellationToken); + return await cwsf.CreateFileAsync(inStream, desiredNewName, option.Convert()); + } + else + { + BaseStorageFile file = await destFolder.CreateFileAsync(desiredNewName, option.Convert()); + using var stream = await file.OpenStreamForWriteAsync(); + + try + { + await Task.Run(() => ftpClient.DownloadFile(FtpPath, stream), cancellationToken); + return file; + } catch + { + } + + return null; + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder) + => MoveAsync(destinationFolder, Name, NameCollisionOption.FailIfExists); + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName) + => MoveAsync(destinationFolder, desiredNewName, NameCollisionOption.FailIfExists); + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + throw new IOException($"Failed to connect to FTP server."); + + BaseStorageFolder destFolder = destinationFolder.AsBaseStorageFolder(); + + if (destFolder is SftpStorageFolder ftpFolder) + { + string destName = $"{ftpFolder.FtpPath}/{Name}"; + + if (await Task.Run(() => ftpClient.Exists(destName), cancellationToken) && option != NameCollisionOption.ReplaceExisting) + { + return; + } + + try + { + await ftpClient.RenameFileAsync(FtpPath, destName, cancellationToken); + } catch + { + throw new IOException($"Failed to move file from {Path} to {destFolder}."); + } + } + else + throw new NotSupportedException(); + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + + public override IAsyncAction CopyAndReplaceAsync(IStorageFile fileToReplace) => throw new NotSupportedException(); + public override IAsyncAction MoveAndReplaceAsync(IStorageFile fileToReplace) => throw new NotSupportedException(); + + public override IAsyncAction RenameAsync(string desiredName) + => RenameAsync(desiredName, NameCollisionOption.FailIfExists); + public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return; + } + + string destination = $"{PathNormalization.GetParentDir(FtpPath)}/{desiredName}"; + + if (await Task.Run(() => ftpClient.Exists(destination), cancellationToken) + && option != NameCollisionOption.ReplaceExisting) + { + return; + } + + try + { + await ftpClient.RenameFileAsync(FtpPath, destination, cancellationToken); + } catch + { + if (option is NameCollisionOption.GenerateUniqueName) + { + // TODO: handle name generation + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncAction DeleteAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + using var ftpClient = GetSftpClient(); + if (await ftpClient.EnsureConnectedAsync()) + { + await Task.Run(() => ftpClient.DeleteFile(FtpPath), cancellationToken); + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + public override IAsyncAction DeleteAsync(StorageDeleteOption option) => DeleteAsync(); + + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) + => Task.FromResult(null).AsAsyncOperation(); + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) + => Task.FromResult(null).AsAsyncOperation(); + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) + => Task.FromResult(null).AsAsyncOperation(); + + private SftpClient GetSftpClient() + { + var host = SftpHelpers.GetSftpHost(Path); + var port = SftpHelpers.GetSftpPort(Path); + var credentials = Credentials is not null ? + new NetworkCredential(Credentials.UserName, Credentials.SecurePassword) : + FtpManager.Credentials.Get(host, FtpManager.Anonymous); ; + + return new(host, port, credentials?.UserName, credentials?.Password); + } + + private async void FtpDataStreamingHandlerAsync(StreamedFileDataRequest request) + { + try + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); + return; + } + + using (var outStream = request.AsStreamForWrite()) + { + await Task.Run(() => ftpClient.DownloadFile(FtpPath, outStream)); + await outStream.FlushAsync(); + } + request.Dispose(); + } + catch + { + request.FailAndClose(StreamedFileFailureMode.Incomplete); + } + } + + private sealed class SftpFileBasicProperties : BaseBasicProperties + { + public override ulong Size { get; } + + public override DateTimeOffset DateCreated { get; } + public override DateTimeOffset DateModified { get; } + + public SftpFileBasicProperties(FtpItem item) + { + Size = (ulong)item.FileSizeBytes; + DateCreated = item.ItemDateCreatedReal; + DateModified = item.ItemDateModifiedReal; + } + + public SftpFileBasicProperties(ISftpFile item) + { + Size = (ulong)item.Attributes.Size; + DateCreated = DateTimeOffset.MinValue; + DateModified = DateTimeOffset.MinValue; + } + } + } +} \ No newline at end of file diff --git a/src/Files.App/Utils/Storage/StorageItems/SftpStorageFolder.cs b/src/Files.App/Utils/Storage/StorageItems/SftpStorageFolder.cs new file mode 100644 index 000000000000..95043bd4dc49 --- /dev/null +++ b/src/Files.App/Utils/Storage/StorageItems/SftpStorageFolder.cs @@ -0,0 +1,357 @@ +// Copyright (c) 2024 Files Community +// Licensed under the MIT License. See the LICENSE. + +using Files.App.Storage.FtpStorage; +using Renci.SshNet; +using Renci.SshNet.Sftp; +using System.IO; +using System.Net; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Search; + +namespace Files.App.Utils.Storage +{ + public sealed class SftpStorageFolder : BaseStorageFolder, IPasswordProtectedItem + { + public override string Path { get; } + public override string Name { get; } + public override string DisplayName => Name; + public override string DisplayType => "Folder".GetLocalizedResource(); + public string FtpPath { get; } + public override string FolderRelativeId => $"0\\{Name}"; + + public override DateTimeOffset DateCreated { get; } + public override Windows.Storage.FileAttributes Attributes { get; } = Windows.Storage.FileAttributes.Directory; + public override IStorageItemExtraProperties Properties => new BaseBasicStorageItemExtraProperties(this); + + public StorageCredential Credentials { get; set; } + + public Func> PasswordRequestedCallback { get; set; } + + public SftpStorageFolder(string path, string name, DateTimeOffset dateCreated) + { + Path = path; + Name = name; + FtpPath = FtpHelpers.GetFtpPath(path); + DateCreated = dateCreated; + } + public SftpStorageFolder(string folder, ISftpFile ftpItem) + { + Path = PathNormalization.Combine(folder, ftpItem.Name); + Name = ftpItem.Name; + FtpPath = FtpHelpers.GetFtpPath(Path); + DateCreated = DateTimeOffset.MinValue; + } + public SftpStorageFolder(IStorageItemWithPath item) + { + Path = item.Path; + Name = System.IO.Path.GetFileName(item.Path); + FtpPath = FtpHelpers.GetFtpPath(item.Path); + } + + public static IAsyncOperation FromPathAsync(string path) + => SftpHelpers.IsSftpPath(path) && FtpHelpers.VerifyFtpPath(path) + ? Task.FromResult(new SftpStorageFolder(new StorageFolderWithPath(null, path))).AsAsyncOperation() + : Task.FromResult(null).AsAsyncOperation(); + + public override IAsyncOperation ToStorageFolderAsync() => throw new NotSupportedException(); + + public SftpStorageFolder CloneWithPath(string path) => new(new StorageFolderWithPath(null, path)); + + public override bool IsEqual(IStorageItem item) => item?.Path == Path; + public override bool IsOfType(StorageItemTypes type) => type is StorageItemTypes.Folder; + + public override IAsyncOperation GetIndexedStateAsync() => Task.FromResult(IndexedState.NotIndexed).AsAsyncOperation(); + + public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); + + public override IAsyncOperation GetBasicPropertiesAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return new BaseBasicProperties(); + } + + var item = await Task.Run(() => ftpClient.Get(FtpPath)); + return item is null ? new BaseBasicProperties() : new SftpFolderBasicProperties(item); + }, (_, _) => Task.FromResult(new BaseBasicProperties()))); + } + + public override IAsyncOperation GetItemAsync(string name) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return null; + } + + var item = await Task.Run(() => ftpClient.Get(FtpHelpers.GetFtpPath(PathNormalization.Combine(Path, name)))); + if (item is not null) + { + if (!item.IsDirectory) + { + var file = new SftpStorageFile(Path, item); + ((IPasswordProtectedItem)file).CopyFrom(this); + return file; + } + if (item.IsDirectory) + { + var folder = new SftpStorageFolder(Path, item); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + } + } + return null; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + public override IAsyncOperation TryGetItemAsync(string name) + { + return AsyncInfo.Run(async (cancellationToken) => + { + try + { + return await GetItemAsync(name); + } + catch + { + return null; + } + }); + } + public override IAsyncOperation> GetItemsAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap>(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return null; + } + + var items = new List(); + + await foreach (var item in ftpClient.ListDirectoryAsync(FtpPath, default)) + { + if (!item.IsDirectory) + { + var file = new SftpStorageFile(Path, item); + ((IPasswordProtectedItem)file).CopyFrom(this); + items.Add(file); + } + else if (item.IsDirectory) + { + var folder = new SftpStorageFolder(Path, item); + ((IPasswordProtectedItem)folder).CopyFrom(this); + items.Add(folder); + } + } + + return items; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + public override IAsyncOperation> GetItemsAsync(uint startIndex, uint maxItemsToRetrieve) + => AsyncInfo.Run>(async (cancellationToken) + => (await GetItemsAsync()).Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList()); + + public override IAsyncOperation GetFileAsync(string name) + => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as BaseStorageFile); + public override IAsyncOperation> GetFilesAsync() + => AsyncInfo.Run>(async (cancellationToken) => (await GetItemsAsync())?.OfType().ToList()); + public override IAsyncOperation> GetFilesAsync(CommonFileQuery query) + => AsyncInfo.Run(async (cancellationToken) => await GetFilesAsync()); + public override IAsyncOperation> GetFilesAsync(CommonFileQuery query, uint startIndex, uint maxItemsToRetrieve) + => AsyncInfo.Run>(async (cancellationToken) + => (await GetFilesAsync()).Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList()); + + public override IAsyncOperation GetFolderAsync(string name) + => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as BaseStorageFolder); + public override IAsyncOperation> GetFoldersAsync() + => AsyncInfo.Run>(async (cancellationToken) => (await GetItemsAsync())?.OfType().ToList()); + public override IAsyncOperation> GetFoldersAsync(CommonFolderQuery query) + => AsyncInfo.Run(async (cancellationToken) => await GetFoldersAsync()); + public override IAsyncOperation> GetFoldersAsync(CommonFolderQuery query, uint startIndex, uint maxItemsToRetrieve) + => AsyncInfo.Run>(async (cancellationToken) + => (await GetFoldersAsync()).Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList()); + + public override IAsyncOperation CreateFileAsync(string desiredName) + => CreateFileAsync(desiredName, CreationCollisionOption.FailIfExists); + public override IAsyncOperation CreateFileAsync(string desiredName, CreationCollisionOption options) + { + /*return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + return null; + } + + using var stream = new MemoryStream(); + + var ftpRemoteExists = options is CreationCollisionOption.ReplaceExisting ? FtpRemoteExists.Overwrite : FtpRemoteExists.Skip; + + FtpStatus result; + string finalName; + var remotePath = $"{FtpPath}/{desiredName}"; + var nameWithoutExt = System.IO.Path.GetFileNameWithoutExtension(desiredName); + var extension = System.IO.Path.GetExtension(desiredName); + ushort attempt = 1; + + do + { + finalName = desiredName; + result = await ftpClient.UploadFile(stream, remotePath, ftpRemoteExists); + desiredName = $"{nameWithoutExt} ({attempt}){extension}"; + remotePath = $"{FtpPath}/{desiredName}"; + } + while (result is FtpStatus.Skipped && ++attempt < 1024 && options == CreationCollisionOption.GenerateUniqueName); + + if (result is FtpStatus.Success) + { + var file = new FtpStorageFile(new StorageFileWithPath(null, $"{Path}/{finalName}")); + ((IPasswordProtectedItem)file).CopyFrom(this); + return file; + } + + if (result is FtpStatus.Skipped) + { + if (options is CreationCollisionOption.FailIfExists) + throw new FileAlreadyExistsException("File already exists.", desiredName); + + return null; + } + + throw new IOException($"Failed to create file {remotePath}."); + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync));*/ + throw new NotSupportedException(); + } + + public override IAsyncOperation CreateFolderAsync(string desiredName) + => CreateFolderAsync(desiredName, CreationCollisionOption.FailIfExists); + public override IAsyncOperation CreateFolderAsync(string desiredName, CreationCollisionOption options) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using var ftpClient = GetSftpClient(); + if (!await ftpClient.EnsureConnectedAsync()) + { + throw new IOException($"Failed to connect to SFTP server."); + } + + string fileName = $"{FtpPath}/{desiredName}"; + if (await Task.Run(() => ftpClient.Exists(fileName))) + { + var item = new SftpStorageFolder(new StorageFileWithPath(null, fileName)); + ((IPasswordProtectedItem)item).CopyFrom(this); + return item; + } + + bool replaceExisting = options is CreationCollisionOption.ReplaceExisting; + + if (replaceExisting) + { + await Task.Run(() => ftpClient.DeleteDirectory(fileName), cancellationToken); + } + + await Task.Run(() => + { + try + { + ftpClient.CreateDirectory(fileName); + } + catch + { + throw new IOException($"Failed to create folder {desiredName}."); + } + }, cancellationToken); + + var folder = new SftpStorageFolder(new StorageFileWithPath(null, $"{Path}/{desiredName}")); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder) + => MoveAsync(destinationFolder, NameCollisionOption.FailIfExists); + public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder, NameCollisionOption option) + { + throw new NotSupportedException(); + } + + public override IAsyncAction RenameAsync(string desiredName) + => RenameAsync(desiredName, NameCollisionOption.FailIfExists); + public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) + { + throw new NotSupportedException(); + } + + public override IAsyncAction DeleteAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + using var ftpClient = GetSftpClient(); + if (await ftpClient.EnsureConnectedAsync()) + { + await Task.Run(() => ftpClient.DeleteDirectory(FtpPath), cancellationToken); + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + public override IAsyncAction DeleteAsync(StorageDeleteOption option) => DeleteAsync(); + + public override bool AreQueryOptionsSupported(QueryOptions queryOptions) => false; + public override bool IsCommonFileQuerySupported(CommonFileQuery query) => false; + public override bool IsCommonFolderQuerySupported(CommonFolderQuery query) => false; + + public override StorageItemQueryResult CreateItemQuery() => throw new NotSupportedException(); + public override BaseStorageItemQueryResult CreateItemQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override StorageFileQueryResult CreateFileQuery() => throw new NotSupportedException(); + public override StorageFileQueryResult CreateFileQuery(CommonFileQuery query) => throw new NotSupportedException(); + public override BaseStorageFileQueryResult CreateFileQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override StorageFolderQueryResult CreateFolderQuery() => throw new NotSupportedException(); + public override StorageFolderQueryResult CreateFolderQuery(CommonFolderQuery query) => throw new NotSupportedException(); + public override BaseStorageFolderQueryResult CreateFolderQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) + => Task.FromResult(null).AsAsyncOperation(); + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) + => Task.FromResult(null).AsAsyncOperation(); + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) + => Task.FromResult(null).AsAsyncOperation(); + + private SftpClient GetSftpClient() + { + var host = SftpHelpers.GetSftpHost(Path); + var port = SftpHelpers.GetSftpPort(Path); + var credentials = Credentials is not null ? + new NetworkCredential(Credentials.UserName, Credentials.SecurePassword) : + FtpManager.Credentials.Get(host, FtpManager.Anonymous); ; + + return new(host, port, credentials?.UserName, credentials?.Password); + } + + private sealed class SftpFolderBasicProperties : BaseBasicProperties + { + public override ulong Size { get; } + + public override DateTimeOffset DateCreated { get; } + public override DateTimeOffset DateModified { get; } + + public SftpFolderBasicProperties(ISftpFile item) + { + Size = (ulong)item.Attributes.Size; + + DateCreated = DateTimeOffset.MinValue; + DateModified = DateTimeOffset.MinValue; + } + } + } +} \ No newline at end of file diff --git a/src/Files.Core.Storage/IFtpStorageService.cs b/src/Files.Core.Storage/IFtpStorageService.cs index df7c21a81f91..e79655ee6063 100644 --- a/src/Files.Core.Storage/IFtpStorageService.cs +++ b/src/Files.Core.Storage/IFtpStorageService.cs @@ -6,7 +6,7 @@ namespace Files.Core.Storage /// /// Provides an abstract layer for accessing an ftp file system /// - public interface IFtpStorageService : IStorageService + public interface ISftpStorageService : IStorageService { } } diff --git a/src/Files.Core.Storage/ISftpStorageService.cs b/src/Files.Core.Storage/ISftpStorageService.cs new file mode 100644 index 000000000000..df7c21a81f91 --- /dev/null +++ b/src/Files.Core.Storage/ISftpStorageService.cs @@ -0,0 +1,12 @@ +// Copyright (c) 2024 Files Community +// Licensed under the MIT License. See the LICENSE. + +namespace Files.Core.Storage +{ + /// + /// Provides an abstract layer for accessing an ftp file system + /// + public interface IFtpStorageService : IStorageService + { + } +} From ed11c24f390bf9d84b327900cc5529a7d261c6b0 Mon Sep 17 00:00:00 2001 From: itsWindows11 Date: Mon, 27 May 2024 10:52:16 +0300 Subject: [PATCH 2/2] Addressed reviews --- src/Files.Core.Storage/IFtpStorageService.cs | 2 +- src/Files.Core.Storage/ISftpStorageService.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Files.Core.Storage/IFtpStorageService.cs b/src/Files.Core.Storage/IFtpStorageService.cs index e79655ee6063..df7c21a81f91 100644 --- a/src/Files.Core.Storage/IFtpStorageService.cs +++ b/src/Files.Core.Storage/IFtpStorageService.cs @@ -6,7 +6,7 @@ namespace Files.Core.Storage /// /// Provides an abstract layer for accessing an ftp file system /// - public interface ISftpStorageService : IStorageService + public interface IFtpStorageService : IStorageService { } } diff --git a/src/Files.Core.Storage/ISftpStorageService.cs b/src/Files.Core.Storage/ISftpStorageService.cs index df7c21a81f91..e57c59dd1cbe 100644 --- a/src/Files.Core.Storage/ISftpStorageService.cs +++ b/src/Files.Core.Storage/ISftpStorageService.cs @@ -4,9 +4,9 @@ namespace Files.Core.Storage { /// - /// Provides an abstract layer for accessing an ftp file system + /// Provides an abstract layer for accessing an sftp file system /// - public interface IFtpStorageService : IStorageService + public interface ISftpStorageService : IStorageService { } }