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 82d408c05a33..c94f32bddc7e 100644
--- a/src/Files.App/Files.App.csproj
+++ b/src/Files.App/Files.App.csproj
@@ -85,6 +85,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/ISftpStorageService.cs b/src/Files.Core.Storage/ISftpStorageService.cs
new file mode 100644
index 000000000000..e57c59dd1cbe
--- /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 sftp file system
+ ///
+ public interface ISftpStorageService : IStorageService
+ {
+ }
+}