From 5072d828e6f7a0295fe89681a745a7a12a0d8489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20Kov=C3=A1cs?= Date: Thu, 17 Feb 2022 23:24:41 +0100 Subject: [PATCH] Sftp, fullname+nativepath refactor --- .../DependencyInjection.cs | 7 +- .../FileTime.App.DependencyInjection.csproj | 1 + src/Core/FileTime.Core/Components/Tab.cs | 1 - src/Core/FileTime.Core/Models/IContainer.cs | 1 + .../Providers/AbstractContainer.cs | 134 ++++++++++++++ .../Providers/IContentProvider.cs | 1 + .../FileTime.Core/Timeline/TimeProvider.cs | 2 + src/FileTime.sln | 23 +++ src/GuiApp/FileTime.Avalonia/App.axaml | 1 + src/GuiApp/FileTime.Avalonia/App.axaml.cs | 1 - .../Application/TabContainer.cs | 13 +- .../Converters/StringReplaceConverter.cs | 19 ++ src/GuiApp/FileTime.Avalonia/Startup.cs | 11 -- .../FileTime.Avalonia/Views/MainWindow.axaml | 2 +- .../FileTime.Providers.Local.csproj | 1 + .../LocalContentProvider.cs | 3 +- .../FileTime.Providers.Local/Startup.cs | 9 +- .../FileTime.Providers.Sftp.csproj | 18 ++ .../SftpClientContext.cs | 63 +++++++ .../SftpContentProvider.cs | 153 +++++++++++++++ .../FileTime.Providers.Sftp/SftpFile.cs | 71 +++++++ .../FileTime.Providers.Sftp/SftpFolder.cs | 44 +++++ .../FileTime.Providers.Sftp/SftpServer.cs | 175 ++++++++++++++++++ .../FileTime.Providers.Sftp/Startup.cs | 14 ++ .../SmbContentProvider.cs | 14 +- .../FileTime.Providers.Smb/SmbFile.cs | 10 +- .../FileTime.Providers.Smb/SmbFolder.cs | 12 +- .../FileTime.Providers.Smb/SmbServer.cs | 11 +- .../FileTime.Providers.Smb/SmbShare.cs | 4 +- .../FileTime.Providers.Smb/Startup.cs | 4 +- 30 files changed, 772 insertions(+), 51 deletions(-) create mode 100644 src/Core/FileTime.Core/Providers/AbstractContainer.cs create mode 100644 src/GuiApp/FileTime.Avalonia/Converters/StringReplaceConverter.cs create mode 100644 src/Providers/FileTime.Providers.Sftp/FileTime.Providers.Sftp.csproj create mode 100644 src/Providers/FileTime.Providers.Sftp/SftpClientContext.cs create mode 100644 src/Providers/FileTime.Providers.Sftp/SftpContentProvider.cs create mode 100644 src/Providers/FileTime.Providers.Sftp/SftpFile.cs create mode 100644 src/Providers/FileTime.Providers.Sftp/SftpFolder.cs create mode 100644 src/Providers/FileTime.Providers.Sftp/SftpServer.cs create mode 100644 src/Providers/FileTime.Providers.Sftp/Startup.cs diff --git a/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs b/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs index 7e092e7..a547a10 100644 --- a/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs +++ b/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs @@ -4,6 +4,7 @@ using FileTime.Core.CommandHandlers; using FileTime.Core.Providers; using FileTime.Core.Timeline; using FileTime.Providers.Local; +using FileTime.Providers.Sftp; using FileTime.Providers.Smb; using Microsoft.Extensions.DependencyInjection; @@ -18,11 +19,11 @@ namespace FileTime.App.Core return serviceCollection .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton(sp => sp.GetService() ?? throw new Exception($"No {nameof(LocalContentProvider)} instance found")) - .AddSingleton() .AddSingleton() .AddSingleton() + .AddLocalServices() + .AddSmbServices() + .AddSftpServices() .RegisterCommandHandlers(); } diff --git a/src/AppCommon/FileTime.App.DependencyInjection/FileTime.App.DependencyInjection.csproj b/src/AppCommon/FileTime.App.DependencyInjection/FileTime.App.DependencyInjection.csproj index 0cacd89..f3a2791 100644 --- a/src/AppCommon/FileTime.App.DependencyInjection/FileTime.App.DependencyInjection.csproj +++ b/src/AppCommon/FileTime.App.DependencyInjection/FileTime.App.DependencyInjection.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Core/FileTime.Core/Components/Tab.cs b/src/Core/FileTime.Core/Components/Tab.cs index 9149bf1..f53f4ac 100644 --- a/src/Core/FileTime.Core/Components/Tab.cs +++ b/src/Core/FileTime.Core/Components/Tab.cs @@ -61,7 +61,6 @@ namespace FileTime.Core.Components { if (_currentlySelecting) return false; - if (_currentSelectedItem == value) return false; IItem? itemToSelect = null; if (value != null) { diff --git a/src/Core/FileTime.Core/Models/IContainer.cs b/src/Core/FileTime.Core/Models/IContainer.cs index 9e62f41..33396a2 100644 --- a/src/Core/FileTime.Core/Models/IContainer.cs +++ b/src/Core/FileTime.Core/Models/IContainer.cs @@ -12,6 +12,7 @@ namespace FileTime.Core.Models Task RefreshAsync(CancellationToken token = default); async Task GetByPath(string path, bool acceptDeepestMatch = false) { + if (path == null) return this; var paths = path.Split(Constants.SeparatorChar); var item = (await GetItems())?.FirstOrDefault(i => i.Name == paths[0]); diff --git a/src/Core/FileTime.Core/Providers/AbstractContainer.cs b/src/Core/FileTime.Core/Providers/AbstractContainer.cs new file mode 100644 index 0000000..d94b423 --- /dev/null +++ b/src/Core/FileTime.Core/Providers/AbstractContainer.cs @@ -0,0 +1,134 @@ +using AsyncEvent; +using FileTime.Core.Models; + +namespace FileTime.Core.Providers +{ + public abstract class AbstractContainer : IContainer where TProvider : IContentProvider + { + private readonly IContainer _parent; + private readonly List _exceptions = new(); + private IReadOnlyList? _containers; + private IReadOnlyList? _items; + private IReadOnlyList? _elements; + + public IReadOnlyList Exceptions { get; } + + public bool IsLoaded { get; protected set; } + + public bool SupportsDirectoryLevelSoftDelete { get; protected set; } + + public AsyncEventHandler Refreshed { get; protected set; } = new(); + + public string Name { get; protected set; } + + public string? FullName { get; protected set; } + + public string? NativePath { get; protected set; } + + public virtual bool IsHidden { get; protected set; } + + public bool IsDestroyed { get; protected set; } + + public virtual SupportsDelete CanDelete { get; protected set; } + + public virtual bool CanRename { get; protected set; } + + public TProvider Provider { get; } + + IContentProvider IItem.Provider => Provider; + + protected AbstractContainer(TProvider provider, IContainer parent, string name) + { + _parent = parent; + Provider = provider; + Name = name; + FullName = (parent?.FullName ?? Name) + Constants.SeparatorChar + Name; + Exceptions = _exceptions.AsReadOnly(); + } + + public abstract Task CanOpenAsync(); + + public abstract Task CloneAsync(); + + public abstract Task CreateContainerAsync(string name); + + public abstract Task CreateElementAsync(string name); + + public abstract Task Delete(bool hardDelete = false); + + public virtual void Destroy() + { + _items = null; + _containers = null; + _elements = null; + IsDestroyed = true; + Refreshed = new AsyncEventHandler(); + } + + public virtual async Task?> GetContainers(CancellationToken token = default) + { + if (_containers == null) await RefreshAsync(token); + return _containers; + } + + public virtual async Task?> GetElements(CancellationToken token = default) + { + if (_elements == null) await RefreshAsync(token); + return _elements; + } + + public virtual async Task?> GetItems(CancellationToken token = default) + { + if (_items == null) await RefreshAsync(token); + return _items; + } + + public virtual IContainer? GetParent() => _parent; + + public virtual async Task IsExistsAsync(string name) + { + var items = await GetItems(); + return items?.Any(i => i.Name == name) ?? false; + } + + public virtual async Task RefreshAsync(CancellationToken token = default) + { + var containers = new List(); + var elements = new List(); + foreach (var item in await RefreshItems(token)) + { + if (item is IContainer container) + { + containers.Add(container); + } + else if (item is IElement element) + { + elements.Add(element); + } + } + + if (_items != null) + { + foreach (var item in _items) + { + item.Destroy(); + } + } + + _containers = containers.OrderBy(c => c.Name).ToList().AsReadOnly(); + _elements = elements.OrderBy(e => e.Name).ToList().AsReadOnly(); + _items = _containers.Cast().Concat(_elements).ToList().AsReadOnly(); + if (Refreshed != null) await Refreshed.InvokeAsync(this, AsyncEventArgs.Empty, token); + } + public abstract Task> RefreshItems(CancellationToken token = default); + + public abstract Task Rename(string newName); + + public virtual void Unload() + { + _items = null; + _containers = null; + _elements = null; + } + } +} \ No newline at end of file diff --git a/src/Core/FileTime.Core/Providers/IContentProvider.cs b/src/Core/FileTime.Core/Providers/IContentProvider.cs index 9086d17..ec04185 100644 --- a/src/Core/FileTime.Core/Providers/IContentProvider.cs +++ b/src/Core/FileTime.Core/Providers/IContentProvider.cs @@ -5,6 +5,7 @@ namespace FileTime.Core.Providers public interface IContentProvider : IContainer { bool SupportsContentStreams { get; } + string Protocol { get; } Task> GetRootContainers(CancellationToken token = default); diff --git a/src/Core/FileTime.Core/Timeline/TimeProvider.cs b/src/Core/FileTime.Core/Timeline/TimeProvider.cs index a6ae7c3..0dc41c9 100644 --- a/src/Core/FileTime.Core/Timeline/TimeProvider.cs +++ b/src/Core/FileTime.Core/Timeline/TimeProvider.cs @@ -32,6 +32,8 @@ namespace FileTime.Core.Timeline public bool IsDestroyed => false; public bool SupportsContentStreams => false; + public string Protocol => "time2://"; + public TimeProvider(PointInTime pointInTime) { _pointInTime = pointInTime; diff --git a/src/FileTime.sln b/src/FileTime.sln index 757758c..3a8279e 100644 --- a/src/FileTime.sln +++ b/src/FileTime.sln @@ -39,6 +39,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{0D2B4BAA EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileTime.Tools.Compression", "Tools\FileTime.Tools.Compression\FileTime.Tools.Compression.csproj", "{B6F6A8F9-9B7B-4E3E-AE99-A90ECFDDC966}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileTime.Providers.Sftp", "Providers\FileTime.Providers.Sftp\FileTime.Providers.Sftp.csproj", "{0E650206-801D-4E8D-95BA-4565B32092E1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -293,6 +295,26 @@ Global {B6F6A8F9-9B7B-4E3E-AE99-A90ECFDDC966}.Release|x64.Build.0 = Release|Any CPU {B6F6A8F9-9B7B-4E3E-AE99-A90ECFDDC966}.Release|x86.ActiveCfg = Release|Any CPU {B6F6A8F9-9B7B-4E3E-AE99-A90ECFDDC966}.Release|x86.Build.0 = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|ARM.ActiveCfg = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|ARM.Build.0 = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|ARM64.Build.0 = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|x64.Build.0 = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Debug|x86.Build.0 = Debug|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|Any CPU.Build.0 = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|ARM.ActiveCfg = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|ARM.Build.0 = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|ARM64.ActiveCfg = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|ARM64.Build.0 = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|x64.ActiveCfg = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|x64.Build.0 = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|x86.ActiveCfg = Release|Any CPU + {0E650206-801D-4E8D-95BA-4565B32092E1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -310,6 +332,7 @@ Global {9BDAC126-200F-4056-8D35-36EC059B40F3} = {38B1B927-4201-4B7A-87EE-737B8C6D4090} {22B33BC6-3987-4BE6-8C54-BFC75C78CCE7} = {890275FF-943A-4D07-83BA-14E5C52D7846} {B6F6A8F9-9B7B-4E3E-AE99-A90ECFDDC966} = {0D2B4BAA-0399-459C-B022-41DB7F408225} + {0E650206-801D-4E8D-95BA-4565B32092E1} = {517D96CE-A956-4638-A93D-465D34DE22B1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8D679DCE-AC84-4A91-BFED-8F8D8E1D8183} diff --git a/src/GuiApp/FileTime.Avalonia/App.axaml b/src/GuiApp/FileTime.Avalonia/App.axaml index 68ab626..1503821 100644 --- a/src/GuiApp/FileTime.Avalonia/App.axaml +++ b/src/GuiApp/FileTime.Avalonia/App.axaml @@ -147,6 +147,7 @@ + diff --git a/src/GuiApp/FileTime.Avalonia/App.axaml.cs b/src/GuiApp/FileTime.Avalonia/App.axaml.cs index 6700181..21db923 100644 --- a/src/GuiApp/FileTime.Avalonia/App.axaml.cs +++ b/src/GuiApp/FileTime.Avalonia/App.axaml.cs @@ -21,7 +21,6 @@ namespace FileTime.Avalonia .AddServices() .RegisterLogging() .AddViewModels() - .RegisterCommandHandlers() .BuildServiceProvider() .InitSerilog(); diff --git a/src/GuiApp/FileTime.Avalonia/Application/TabContainer.cs b/src/GuiApp/FileTime.Avalonia/Application/TabContainer.cs index be5d140..87f485c 100644 --- a/src/GuiApp/FileTime.Avalonia/Application/TabContainer.cs +++ b/src/GuiApp/FileTime.Avalonia/Application/TabContainer.cs @@ -51,16 +51,11 @@ namespace FileTime.Avalonia.Application { if (!_updateFromCode && value != null) { - /*try - {*/ - /*var task = SetSelectedItemAsync(value, true); - Task.WaitAll(new Task[] { task }, 100);*/ - Task.Run(async () => await SetSelectedItemAsync(value, true)).Wait(); - /*} - catch + try { - //TODO: Debug, linux start after restore 3 tabs - }*/ + Task.Run(async () => await SetSelectedItemAsync(value, true)).Wait(); + } + catch (AggregateException e) when (e.InnerExceptions.Count == 1 && e.InnerExceptions[0] is IndexOutOfRangeException) { } } } } diff --git a/src/GuiApp/FileTime.Avalonia/Converters/StringReplaceConverter.cs b/src/GuiApp/FileTime.Avalonia/Converters/StringReplaceConverter.cs new file mode 100644 index 0000000..342f651 --- /dev/null +++ b/src/GuiApp/FileTime.Avalonia/Converters/StringReplaceConverter.cs @@ -0,0 +1,19 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace FileTime.Avalonia.Converters +{ + public class StringReplaceConverter : IValueConverter + { + public string? OldValue { get; set; } + public string? NewValue { get; set; } + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is string s && OldValue != null && NewValue != null ? s.Replace(OldValue, NewValue) : value; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/GuiApp/FileTime.Avalonia/Startup.cs b/src/GuiApp/FileTime.Avalonia/Startup.cs index e383d2a..258465a 100644 --- a/src/GuiApp/FileTime.Avalonia/Startup.cs +++ b/src/GuiApp/FileTime.Avalonia/Startup.cs @@ -10,7 +10,6 @@ using FileTime.Avalonia.ViewModels; using FileTime.Core.Command; using FileTime.Core.Interactions; using FileTime.Core.Persistence; -using FileTime.Providers.Smb; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Serilog; @@ -39,7 +38,6 @@ namespace FileTime.Avalonia .AddSingleton(new PersistenceSettings(Program.AppDataRoot)) .AddSingleton() .AddSingleton() - .AddSmbServices() .AddSingleton(); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -53,15 +51,6 @@ namespace FileTime.Avalonia return serviceCollection; } - internal static IServiceCollection RegisterCommandHandlers(this IServiceCollection serviceCollection) - { - foreach (var commandHandler in Providers.Local.Startup.GetCommandHandlers()) - { - serviceCollection.AddTransient(typeof(ICommandHandler), commandHandler); - } - - return serviceCollection; - } internal static IServiceCollection RegisterLogging(this IServiceCollection serviceCollection) { diff --git a/src/GuiApp/FileTime.Avalonia/Views/MainWindow.axaml b/src/GuiApp/FileTime.Avalonia/Views/MainWindow.axaml index c0fd3af..8d4f928 100644 --- a/src/GuiApp/FileTime.Avalonia/Views/MainWindow.axaml +++ b/src/GuiApp/FileTime.Avalonia/Views/MainWindow.axaml @@ -35,7 +35,7 @@ - + diff --git a/src/Providers/FileTime.Providers.Local/FileTime.Providers.Local.csproj b/src/Providers/FileTime.Providers.Local/FileTime.Providers.Local.csproj index 1193e6b..acdcfd5 100644 --- a/src/Providers/FileTime.Providers.Local/FileTime.Providers.Local.csproj +++ b/src/Providers/FileTime.Providers.Local/FileTime.Providers.Local.csproj @@ -5,6 +5,7 @@ + net6.0 diff --git a/src/Providers/FileTime.Providers.Local/LocalContentProvider.cs b/src/Providers/FileTime.Providers.Local/LocalContentProvider.cs index 0d2371a..6514829 100644 --- a/src/Providers/FileTime.Providers.Local/LocalContentProvider.cs +++ b/src/Providers/FileTime.Providers.Local/LocalContentProvider.cs @@ -18,6 +18,7 @@ namespace FileTime.Providers.Local private readonly IReadOnlyList? _elements = new List().AsReadOnly(); public string Name { get; } = "local"; + public string Protocol { get; } = "local://"; public string? FullName { get; } public string? NativePath => null; @@ -59,7 +60,7 @@ namespace FileTime.Providers.Local path = path.Replace(Path.DirectorySeparatorChar, Constants.SeparatorChar).TrimEnd(Constants.SeparatorChar); var pathParts = (IsCaseInsensitive ? path.ToLower() : path).TrimStart(Constants.SeparatorChar).Split(Constants.SeparatorChar); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && pathParts.Length == 1 && pathParts[0] == "") return this; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && pathParts.Length == 1 && pathParts[0]?.Length == 0) return this; var normalizedRootContainerName = NormalizePath(pathParts[0]); var rootContainer = _rootContainers.FirstOrDefault(c => NormalizePath(c.Name) == normalizedRootContainerName); diff --git a/src/Providers/FileTime.Providers.Local/Startup.cs b/src/Providers/FileTime.Providers.Local/Startup.cs index 3e2e652..0555cff 100644 --- a/src/Providers/FileTime.Providers.Local/Startup.cs +++ b/src/Providers/FileTime.Providers.Local/Startup.cs @@ -1,10 +1,15 @@ +using FileTime.Core.Providers; +using Microsoft.Extensions.DependencyInjection; + namespace FileTime.Providers.Local { public static class Startup { - public static Type[] GetCommandHandlers() + public static IServiceCollection AddLocalServices(this IServiceCollection serviceCollection) { - return Array.Empty(); + return serviceCollection + .AddSingleton() + .AddSingleton(sp => sp.GetService() ?? throw new Exception($"No {nameof(LocalContentProvider)} instance found")); } } } \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Sftp/FileTime.Providers.Sftp.csproj b/src/Providers/FileTime.Providers.Sftp/FileTime.Providers.Sftp.csproj new file mode 100644 index 0000000..5a371c9 --- /dev/null +++ b/src/Providers/FileTime.Providers.Sftp/FileTime.Providers.Sftp.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Providers/FileTime.Providers.Sftp/SftpClientContext.cs b/src/Providers/FileTime.Providers.Sftp/SftpClientContext.cs new file mode 100644 index 0000000..15f8c47 --- /dev/null +++ b/src/Providers/FileTime.Providers.Sftp/SftpClientContext.cs @@ -0,0 +1,63 @@ +using Renci.SshNet; + +namespace FileTime.Providers.Sftp +{ + public class SftpClientContext + { + private readonly Func> _getSftpClient; + private readonly Action _disposeClient; + private bool _isRunning; + private readonly object _lock = new(); + + public SftpClientContext(Func> getSftpClient, Action disposeClient) + { + _getSftpClient = getSftpClient; + _disposeClient = disposeClient; + } + public async Task RunWithSftpClientAsync(Action action, int maxRetries = SftpServer.MAXRETRIES) + { + await RunWithSftpClientAsync((client) => { action(client); return null; }, maxRetries); + } + + public async Task RunWithSftpClientAsync(Func func, int maxRetries = SftpServer.MAXRETRIES) + { + while (true) + { + lock (_lock) + { + if (!_isRunning) + { + _isRunning = true; + break; + } + } + + await Task.Delay(1); + } + try + { + SftpClient client; + while (true) + { + try + { + client = await _getSftpClient(maxRetries); + return func(client); + } + //TODO: dispose client on Sftp exception + catch (Exception e) + { + throw new Exception("Exception was thrown while executing method with SftpClient.", e); + } + } + } + finally + { + lock (_lock) + { + _isRunning = false; + } + } + } + } +} \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Sftp/SftpContentProvider.cs b/src/Providers/FileTime.Providers.Sftp/SftpContentProvider.cs new file mode 100644 index 0000000..d095f5c --- /dev/null +++ b/src/Providers/FileTime.Providers.Sftp/SftpContentProvider.cs @@ -0,0 +1,153 @@ +using AsyncEvent; +using FileTime.Core.Interactions; +using FileTime.Core.Models; +using FileTime.Core.Providers; +using Microsoft.Extensions.Logging; + +namespace FileTime.Providers.Sftp +{ + public class SftpContentProvider : IContentProvider + { + private IContainer? _parent; + private readonly IInputInterface _inputInterface; + private readonly List _rootContainers; + private readonly IReadOnlyList _rootContainersReadOnly; + private IReadOnlyList? _items; + private readonly IReadOnlyList _elements = new List().AsReadOnly(); + private readonly ILogger _logger; + + public bool SupportsContentStreams => false; + + public IReadOnlyList Exceptions { get; } = new List().AsReadOnly(); + + public bool IsLoaded => true; + + public bool SupportsDirectoryLevelSoftDelete => false; + + public AsyncEventHandler Refreshed { get; } = new AsyncEventHandler(); + + public string Name => "sftp"; + + public string? FullName => null; + + public string? NativePath => null; + + public bool IsHidden => false; + + public bool IsDestroyed => false; + + public SupportsDelete CanDelete => SupportsDelete.False; + + public bool CanRename => false; + + public IContentProvider Provider => this; + + public string Protocol => "sftp://"; + + public SftpContentProvider(IInputInterface inputInterface, ILogger logger) + { + _logger = logger; + _rootContainers = new List(); + _items = new List(); + _rootContainersReadOnly = _rootContainers.AsReadOnly(); + _inputInterface = inputInterface; + } + + public bool CanHandlePath(string path) => path.StartsWith("sftp://"); + public Task CanOpenAsync() => Task.FromResult(true); + public Task CloneAsync() => Task.FromResult((IContainer)this); + + public async Task CreateContainerAsync(string name) + { + var container = _rootContainers.Find(c => c.Name == name); + + if (container == null) + { + container = new SftpServer(name, this, _inputInterface); + _rootContainers.Add(container); + _items = _rootContainers.OrderBy(c => c.Name).ToList().AsReadOnly(); + } + + await RefreshAsync(); + + //await SaveServers(); + + return container; + } + + public Task CreateElementAsync(string name) + { + throw new NotSupportedException(); + } + + public Task Delete(bool hardDelete = false) + { + throw new NotSupportedException(); + } + + public void Destroy() { } + + public async Task?> GetContainers(CancellationToken token = default) + { + await Init(); + return _rootContainersReadOnly; + } + public Task?> GetElements(CancellationToken token = default) => Task.FromResult((IReadOnlyList?)_elements); + + public async Task?> GetItems(CancellationToken token = default) + { + await Init(); + return _items; + } + + public IContainer? GetParent() => _parent; + public Task> GetRootContainers(CancellationToken token = default) => Task.FromResult(_rootContainersReadOnly); + + public async Task IsExistsAsync(string name) => (await GetItems())?.Any(i => i.Name == name) ?? false; + + public async Task RefreshAsync(CancellationToken token = default) => await Refreshed.InvokeAsync(this, AsyncEventArgs.Empty, token); + + public Task Rename(string newName) => throw new NotSupportedException(); + + public void SetParent(IContainer container) => _parent = container; + + public void Unload() { } + + private Task Init() + { + return Task.CompletedTask; + } + + public async Task GetByPath(string path, bool acceptDeepestMatch = false) + { + if (path == null) return this; + + var pathParts = path.TrimStart(Constants.SeparatorChar).Split(Constants.SeparatorChar); + + var rootContainer = (await GetContainers())?.FirstOrDefault(c => c.Name == pathParts[0]); + + if (rootContainer == null) + { + return null; + } + + var remainingPath = string.Join(Constants.SeparatorChar, pathParts.Skip(1)); + try + { + return remainingPath.Length == 0 ? rootContainer : await rootContainer.GetByPath(remainingPath, acceptDeepestMatch); + } + catch (Exception e) + { + _logger.LogError(e, "Error while getting path {Path}", path); + if (acceptDeepestMatch) + { + return rootContainer ?? this; + } + else + { + throw; + } + } + } + } +} \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Sftp/SftpFile.cs b/src/Providers/FileTime.Providers.Sftp/SftpFile.cs new file mode 100644 index 0000000..af5fe6f --- /dev/null +++ b/src/Providers/FileTime.Providers.Sftp/SftpFile.cs @@ -0,0 +1,71 @@ +using FileTime.Core.Models; +using FileTime.Core.Providers; + +namespace FileTime.Providers.Sftp +{ + public class SftpFile : IElement + { + public bool IsSpecial => throw new NotImplementedException(); + + public string Name => throw new NotImplementedException(); + + public string? FullName => throw new NotImplementedException(); + + public string? NativePath => throw new NotImplementedException(); + + public bool IsHidden => throw new NotImplementedException(); + + public bool IsDestroyed => throw new NotImplementedException(); + + public SupportsDelete CanDelete => throw new NotImplementedException(); + + public bool CanRename => throw new NotImplementedException(); + + public IContentProvider Provider => throw new NotImplementedException(); + + public Task Delete(bool hardDelete = false) + { + throw new NotImplementedException(); + } + + public void Destroy() + { + throw new NotImplementedException(); + } + + public Task GetContent(CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public Task GetContentReaderAsync() + { + throw new NotImplementedException(); + } + + public Task GetContentWriterAsync() + { + throw new NotImplementedException(); + } + + public Task GetElementSize(CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public IContainer? GetParent() + { + throw new NotImplementedException(); + } + + public string GetPrimaryAttributeText() + { + throw new NotImplementedException(); + } + + public Task Rename(string newName) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Sftp/SftpFolder.cs b/src/Providers/FileTime.Providers.Sftp/SftpFolder.cs new file mode 100644 index 0000000..df43e29 --- /dev/null +++ b/src/Providers/FileTime.Providers.Sftp/SftpFolder.cs @@ -0,0 +1,44 @@ +using FileTime.Core.Models; +using FileTime.Core.Providers; + +namespace FileTime.Providers.Sftp +{ + public class SftpFolder : AbstractContainer + { + private readonly SftpServer _server; + + public SftpFolder(SftpContentProvider provider, SftpServer server, IContainer parent, string path) : base(provider, parent, path) + { + _server = server; + NativePath = FullName; + } + public override Task CanOpenAsync() => Task.FromResult(true); + + public override Task CloneAsync() + { + return Task.FromResult((IContainer)new SftpFolder(Provider, _server, GetParent()!, Name)); + } + + public override Task CreateContainerAsync(string name) + { + throw new NotImplementedException(); + } + + public override Task CreateElementAsync(string name) + { + throw new NotImplementedException(); + } + + public override Task Delete(bool hardDelete = false) + { + throw new NotImplementedException(); + } + + public override async Task> RefreshItems(CancellationToken token = default) => await _server.ListDirectory(FullName![(_server.FullName!.Length + 1)..]); + + public override Task Rename(string newName) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Sftp/SftpServer.cs b/src/Providers/FileTime.Providers.Sftp/SftpServer.cs new file mode 100644 index 0000000..c198805 --- /dev/null +++ b/src/Providers/FileTime.Providers.Sftp/SftpServer.cs @@ -0,0 +1,175 @@ +using FileTime.Core.Interactions; +using FileTime.Core.Models; +using FileTime.Core.Providers; +using Renci.SshNet; +using Renci.SshNet.Common; + +namespace FileTime.Providers.Sftp +{ + public class SftpServer : AbstractContainer + { + internal const int MAXRETRIES = 5; + private bool _reenterCredentials; + private SftpClient? _client; + private readonly SftpClientContext _sftpClientContext; + private bool _refreshingClient; + private readonly object _clientGuard = new(); + private readonly IInputInterface _inputInterface; + + public string? Username { get; private set; } + public string? Password { get; private set; } + + public SftpServer(string name, SftpContentProvider sftpContentProvider, IInputInterface inputInterface, string? username = null, string? password = null) + : base(sftpContentProvider, sftpContentProvider, name) + { + _inputInterface = inputInterface; + _sftpClientContext = new SftpClientContext(GetClient, () => { }); + Username = username; + Password = password; + + Name = name; + NativePath = FullName = sftpContentProvider.Protocol + Constants.SeparatorChar + name; + + CanDelete = SupportsDelete.True; + } + + public override async Task> RefreshItems(CancellationToken token = default) => await ListDirectory(""); + + public override Task CanOpenAsync() => Task.FromResult(true); + + public override Task CloneAsync() => Task.FromResult((IContainer)this); + + public override Task CreateContainerAsync(string name) + { + throw new NotImplementedException(); + } + + public override Task CreateElementAsync(string name) + { + throw new NotImplementedException(); + } + + public override Task Delete(bool hardDelete = false) + { + throw new NotImplementedException(); + } + + public override Task Rename(string newName) + { + throw new NotImplementedException(); + } + + public override void Unload() + { + base.Unload(); + + lock (_clientGuard) + { + _client?.Disconnect(); + _client = null; + } + } + + private async Task GetClient(int maxRetries = MAXRETRIES) + { + bool isClientNull; + lock (_clientGuard) + { + isClientNull = _client == null; + } + + int reTries = 0; + while (isClientNull) + { + if (!await RefreshSftpClient()) + { + await Task.Delay(1); + } + + lock (_clientGuard) + { + isClientNull = _client == null; + } + + if (reTries >= maxRetries) + { + throw new Exception($"Could not connect to server {Name} after {reTries} retry"); + } + reTries++; + } + return _client!; + } + + private async Task RefreshSftpClient() + { + lock (_clientGuard) + { + if (_refreshingClient) return false; + _refreshingClient = true; + } + try + { + if (_reenterCredentials || Username == null || Password == null) + { + var inputs = await _inputInterface.ReadInputs( + new InputElement[] + { + InputElement.ForText($"Username for '{Name}'", Username ?? ""), + InputElement.ForPassword($"Password for '{Name}'", Password ?? "") + }); + + Username = inputs[0]; + Password = inputs[1]; + } + + var client = new SftpClient(Name, Username, Password); + try + { + client.Connect(); + } + catch (SshAuthenticationException) + { + _reenterCredentials = true; + } + catch + { + throw; + } + + lock (_clientGuard) + { + _client = client; + } + } + finally + { + lock (_clientGuard) + { + _refreshingClient = false; + } + } + + return true; + } + + public async Task> ListDirectory(string path) + { + return await _sftpClientContext.RunWithSftpClientAsync(client => + { + var containers = new List(); + var elements = new List(); + + foreach (var file in client.ListDirectory(path)) + { + if (file.IsDirectory) + { + var container = new SftpFolder(Provider, this, this, file.Name); + containers.Add(container); + } + } + + return containers.Cast().Concat(elements); + }); + } + } +} \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Sftp/Startup.cs b/src/Providers/FileTime.Providers.Sftp/Startup.cs new file mode 100644 index 0000000..f565ca3 --- /dev/null +++ b/src/Providers/FileTime.Providers.Sftp/Startup.cs @@ -0,0 +1,14 @@ +using FileTime.Core.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace FileTime.Providers.Sftp +{ + public static class Startup + { + public static IServiceCollection AddSftpServices(this IServiceCollection serviceCollection) + { + return serviceCollection + .AddSingleton(); + } + } +} \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Smb/SmbContentProvider.cs b/src/Providers/FileTime.Providers.Smb/SmbContentProvider.cs index 0d836f4..0fcbdab 100644 --- a/src/Providers/FileTime.Providers.Smb/SmbContentProvider.cs +++ b/src/Providers/FileTime.Providers.Smb/SmbContentProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.InteropServices; using AsyncEvent; using FileTime.Core.Interactions; using FileTime.Core.Models; @@ -22,8 +23,9 @@ namespace FileTime.Providers.Smb private readonly ILogger _logger; public string Name { get; } = "smb"; + public string Protocol { get; } = "smb://"; - public string? FullName { get; } + public string? FullName => null; public string? NativePath => null; public bool IsHidden => false; @@ -53,12 +55,11 @@ namespace FileTime.Providers.Smb public async Task CreateContainerAsync(string name) { - var fullName = "\\\\" + name; var container = _rootContainers.Find(c => c.Name == name); if (container == null) { - container = new SmbServer(fullName, this, _inputInterface); + container = new SmbServer(name, this, _inputInterface); _rootContainers.Add(container); _items = _rootContainers.OrderBy(c => c.Name).ToList().AsReadOnly(); } @@ -84,7 +85,10 @@ namespace FileTime.Providers.Smb { if (path == null) return this; - var pathParts = path.TrimStart(Constants.SeparatorChar).Split(Constants.SeparatorChar); + path = path.TrimStart(Constants.SeparatorChar); + if (path.StartsWith("\\\\")) path = path[2..]; + else if (path.StartsWith("smb://")) path = path[6..]; + var pathParts = path.Split(Constants.SeparatorChar); var rootContainer = (await GetContainers())?.FirstOrDefault(c => c.Name == pathParts[0]); @@ -195,6 +199,6 @@ namespace FileTime.Providers.Smb } } - public static string GetNativePath(string fullName) => fullName.Replace("/", "\\"); + public static string GetNativePathSeparator() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "\\" : "/"; } } \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Smb/SmbFile.cs b/src/Providers/FileTime.Providers.Smb/SmbFile.cs index e56d3b9..ed89dd8 100644 --- a/src/Providers/FileTime.Providers.Smb/SmbFile.cs +++ b/src/Providers/FileTime.Providers.Smb/SmbFile.cs @@ -27,14 +27,14 @@ namespace FileTime.Providers.Smb public SmbFile(string name, SmbContentProvider provider, SmbShare smbShare, IContainer parent, SmbClientContext smbClientContext) { - Name = name; - FullName = parent.FullName + Constants.SeparatorChar + Name; - NativePath = SmbContentProvider.GetNativePath(FullName); - Provider = provider; _parent = parent; _smbClientContext = smbClientContext; _smbShare = smbShare; + + Name = name; + FullName = parent.FullName + Constants.SeparatorChar + Name; + NativePath = parent.NativePath + SmbContentProvider.GetNativePathSeparator() + name; } public async Task Delete(bool hardDelete = false) @@ -146,6 +146,6 @@ namespace FileTime.Providers.Smb }); } - private string GetPathFromShare() => SmbContentProvider.GetNativePath(FullName![(_smbShare.FullName!.Length + 1)..]); + private string GetPathFromShare() => FullName![(_smbShare.FullName!.Length + 1)..].Replace("/", "\\"); } } \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Smb/SmbFolder.cs b/src/Providers/FileTime.Providers.Smb/SmbFolder.cs index 51482a5..2c5975c 100644 --- a/src/Providers/FileTime.Providers.Smb/SmbFolder.cs +++ b/src/Providers/FileTime.Providers.Smb/SmbFolder.cs @@ -37,19 +37,19 @@ namespace FileTime.Providers.Smb public SmbFolder(string name, SmbContentProvider contentProvider, SmbShare smbShare, IContainer parent, SmbClientContext smbClientContext) { _parent = parent; + _smbClientContext = smbClientContext; SmbShare = smbShare; Name = name; - FullName = parent?.FullName == null ? Name : parent.FullName + Constants.SeparatorChar + Name; - NativePath = SmbContentProvider.GetNativePath(FullName); + FullName = parent.FullName! + Constants.SeparatorChar + Name; + NativePath = parent.NativePath + SmbContentProvider.GetNativePathSeparator() + name; Provider = contentProvider; - _smbClientContext = smbClientContext; } public async Task CreateContainerAsync(string name) { var path = FullName![(SmbShare.FullName!.Length + 1)..] + Constants.SeparatorChar + name; - await SmbShare.CreateContainerWithPathAsync(SmbContentProvider.GetNativePath(path)); + await SmbShare.CreateContainerWithPathAsync(path.Replace("/", "\\")); await RefreshAsync(); return _containers!.FirstOrDefault(e => e.Name == name)!; @@ -58,7 +58,7 @@ namespace FileTime.Providers.Smb public async Task CreateElementAsync(string name) { var path = FullName![(SmbShare.FullName!.Length + 1)..] + Constants.SeparatorChar + name; - await SmbShare.CreateElementWithPathAsync(SmbContentProvider.GetNativePath(path)); + await SmbShare.CreateElementWithPathAsync(path.Replace("/", "\\")); await RefreshAsync(); return _elements!.FirstOrDefault(e => e.Name == name)!; @@ -161,6 +161,6 @@ namespace FileTime.Providers.Smb _elements = null; } - private string GetPathFromShare() => SmbContentProvider.GetNativePath(FullName![(SmbShare.FullName!.Length + 1)..]); + private string GetPathFromShare() => FullName![(SmbShare.FullName!.Length + 1)..].Replace("/", "\\"); } } \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Smb/SmbServer.cs b/src/Providers/FileTime.Providers.Smb/SmbServer.cs index a6538d7..6bcde52 100644 --- a/src/Providers/FileTime.Providers.Smb/SmbServer.cs +++ b/src/Providers/FileTime.Providers.Smb/SmbServer.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Runtime.InteropServices; using AsyncEvent; using FileTime.Core.Interactions; using FileTime.Core.Models; @@ -56,7 +57,11 @@ namespace FileTime.Providers.Smb Password = password; Provider = contentProvider; - NativePath = FullName = Name = path; + Name = path; + FullName = contentProvider.Protocol + Name; + NativePath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "\\\\" + Name + : contentProvider.Protocol + Name; } public async Task?> GetItems(CancellationToken token = default) @@ -182,11 +187,11 @@ namespace FileTime.Providers.Smb } try { - var couldParse = IPAddress.TryParse(Name[2..], out var ipAddress); + var couldParse = IPAddress.TryParse(Name, out var ipAddress); var client = new SMB2Client(); var connected = couldParse ? client.Connect(ipAddress, SMBTransportType.DirectTCPTransport) - : client.Connect(Name[2..], SMBTransportType.DirectTCPTransport); + : client.Connect(Name, SMBTransportType.DirectTCPTransport); if (connected) { diff --git a/src/Providers/FileTime.Providers.Smb/SmbShare.cs b/src/Providers/FileTime.Providers.Smb/SmbShare.cs index 9f14f91..74379ce 100644 --- a/src/Providers/FileTime.Providers.Smb/SmbShare.cs +++ b/src/Providers/FileTime.Providers.Smb/SmbShare.cs @@ -40,8 +40,8 @@ namespace FileTime.Providers.Smb _smbClientContext = smbClientContext; Name = name; - FullName = parent?.FullName == null ? Name : parent.FullName + Constants.SeparatorChar + Name; - NativePath = SmbContentProvider.GetNativePath(FullName); + FullName = parent.FullName! + Constants.SeparatorChar + Name; + NativePath = parent.NativePath + SmbContentProvider.GetNativePathSeparator() + name; Provider = contentProvider; } diff --git a/src/Providers/FileTime.Providers.Smb/Startup.cs b/src/Providers/FileTime.Providers.Smb/Startup.cs index 04a9629..eec9fa8 100644 --- a/src/Providers/FileTime.Providers.Smb/Startup.cs +++ b/src/Providers/FileTime.Providers.Smb/Startup.cs @@ -1,3 +1,4 @@ +using FileTime.Core.Providers; using FileTime.Providers.Smb.Persistence; using Microsoft.Extensions.DependencyInjection; @@ -8,7 +9,8 @@ namespace FileTime.Providers.Smb public static IServiceCollection AddSmbServices(this IServiceCollection serviceCollection) { return serviceCollection - .AddSingleton(); + .AddSingleton() + .AddSingleton(); } } } \ No newline at end of file