diff --git a/Moonlight/Core/Services/MailService.cs b/Moonlight/Core/Services/MailService.cs index e5a94e4..0dab8c8 100644 --- a/Moonlight/Core/Services/MailService.cs +++ b/Moonlight/Core/Services/MailService.cs @@ -56,6 +56,9 @@ public class MailService try { + Logger.Debug($"Sending {templateName} mail to {user.Email}"); + Logger.Debug($"Body: {body.HtmlBody}"); + await smtpClient.ConnectAsync(config.Host, config.Port, config.UseSsl); await smtpClient.AuthenticateAsync(config.Email, config.Password); await smtpClient.SendAsync(message); diff --git a/Moonlight/Core/UI/Layouts/MainLayout.razor b/Moonlight/Core/UI/Layouts/MainLayout.razor index 8bb0289..0f8b830 100644 --- a/Moonlight/Core/UI/Layouts/MainLayout.razor +++ b/Moonlight/Core/UI/Layouts/MainLayout.razor @@ -20,6 +20,7 @@ @inject NavigationManager Navigation @inject ConnectionService ConnectionService @inject AdBlockService AdBlockService +@inject HotKeyService HotKeyService @{ var url = new Uri(Navigation.Uri); @@ -164,6 +165,8 @@ else if(ConfigService.Get().Advertisement.PreventAdBlockers) AdBlockerDetected = await AdBlockService.Detect(); + await HotKeyService.Initialize(); + Initialized = true; await InvokeAsync(StateHasChanged); diff --git a/Moonlight/Features/Servers/Actions/ServerServiceDefinition.cs b/Moonlight/Features/Servers/Actions/ServerServiceDefinition.cs index 14e0410..2220671 100644 --- a/Moonlight/Features/Servers/Actions/ServerServiceDefinition.cs +++ b/Moonlight/Features/Servers/Actions/ServerServiceDefinition.cs @@ -15,6 +15,7 @@ public class ServerServiceDefinition : ServiceDefinition context.Layout = typeof(UserLayout); await context.AddPage("Console", "/console", "bx bx-sm bxs-terminal"); + await context.AddPage("Files", "/files", "bx bx-sm bxs-folder"); await context.AddPage("Reset", "/reset", "bx bx-sm bx-revision"); } diff --git a/Moonlight/Features/Servers/Helpers/ServerFtpFileAccess.cs b/Moonlight/Features/Servers/Helpers/ServerFtpFileAccess.cs new file mode 100644 index 0000000..549570a --- /dev/null +++ b/Moonlight/Features/Servers/Helpers/ServerFtpFileAccess.cs @@ -0,0 +1,252 @@ +using System.Net; +using System.Text; +using FluentFTP; +using Moonlight.Features.FileManager.Models.Abstractions.FileAccess; + +namespace Moonlight.Features.Servers.Helpers; + +public class ServerFtpFileAccess : IFileAccess +{ + private FtpClient Client; + private string CurrentDirectory = "/"; + + private readonly string Host; + private readonly int Port; + private readonly string Username; + private readonly string Password; + private readonly int OperationTimeout; + + public ServerFtpFileAccess(string host, int port, string username, string password, int operationTimeout = 5) + { + Host = host; + Port = port; + Username = username; + Password = password; + OperationTimeout = (int)TimeSpan.FromSeconds(5).TotalMilliseconds; + + Client = CreateClient(); + } + + public async Task List() + { + return await ExecuteHandled(() => + { + var items = Client.GetListing() ?? Array.Empty(); + var result = items.Select(item => new FileEntry + { + Name = item.Name, + IsDirectory = item.Type == FtpObjectType.Directory, + IsFile = item.Type == FtpObjectType.File, + LastModifiedAt = item.Modified, + Size = item.Size + }).ToArray(); + + return Task.FromResult(result); + }); + } + + public async Task ChangeDirectory(string relativePath) + { + await ExecuteHandled(() => + { + var newPath = Path.Combine(CurrentDirectory, relativePath); + newPath = Path.GetFullPath(newPath); + + Client.SetWorkingDirectory(newPath); + CurrentDirectory = Client.GetWorkingDirectory(); + + return Task.CompletedTask; + }); + } + + public async Task SetDirectory(string path) + { + await ExecuteHandled(() => + { + Client.SetWorkingDirectory(path); + CurrentDirectory = Client.GetWorkingDirectory(); + + return Task.CompletedTask; + }); + } + + public Task GetCurrentDirectory() + { + return Task.FromResult(CurrentDirectory); + } + + public async Task Delete(string path) + { + await ExecuteHandled(() => + { + if (Client.FileExists(path)) + Client.DeleteFile(path); + else + Client.DeleteDirectory(path); + + return Task.CompletedTask; + }); + } + + public async Task Move(string from, string to) + { + await ExecuteHandled(() => + { + var fromEntry = Client.GetObjectInfo(from); + + if (fromEntry.Type == FtpObjectType.Directory) + // We need to add the folder name here, because some ftp servers would refuse to move the folder if its missing + Client.MoveDirectory(from, to + Path.GetFileName(from)); + else + // We need to add the file name here, because some ftp servers would refuse to move the file if its missing + Client.MoveFile(from, to + Path.GetFileName(from)); + + return Task.CompletedTask; + }); + } + + public async Task CreateDirectory(string name) + { + await ExecuteHandled(() => + { + Client.CreateDirectory(name); + return Task.CompletedTask; + }); + } + + public async Task CreateFile(string name) + { + await ExecuteHandled(() => + { + using var stream = new MemoryStream(); + Client.UploadStream(stream, name); + + return Task.CompletedTask; + }); + } + + public async Task ReadFile(string name) + { + return await ExecuteHandled(async () => + { + await using var stream = Client.OpenRead(name); + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + }); + } + + public async Task WriteFile(string name, string content) + { + await ExecuteHandled(() => + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + Client.UploadStream(stream, name); + + return Task.CompletedTask; + }); + } + + public async Task ReadFileStream(string name) + { + return await ExecuteHandled(() => + { + var stream = Client.OpenRead(name); + return Task.FromResult(stream); + }); + } + + public async Task WriteFileStream(string name, Stream dataStream) + { + await ExecuteHandled(() => + { + Client.UploadStream(dataStream, name, FtpRemoteExists.Overwrite); + return Task.CompletedTask; + }); + } + + public IFileAccess Clone() + { + return new ServerFtpFileAccess(Host, Port, Username, Password) + { + CurrentDirectory = CurrentDirectory + }; + } + + public void Dispose() + { + Client.Dispose(); + } + + #region Helpers + + private Task EnsureConnected() + { + if (!Client.IsConnected) + { + Client.Connect(); + + // This will set the correct current directory + // on cloned or reconnected FtpFileAccess instances + if(CurrentDirectory != "/") + Client.SetWorkingDirectory(CurrentDirectory); + } + + return Task.CompletedTask; + } + + private async Task ExecuteHandled(Func func) + { + try + { + await EnsureConnected(); + await func.Invoke(); + } + catch (TimeoutException) + { + Client.Dispose(); + Client = CreateClient(); + + await EnsureConnected(); + + await func.Invoke(); + } + } + + private async Task ExecuteHandled(Func> func) + { + try + { + await EnsureConnected(); + return await func.Invoke(); + } + catch (TimeoutException) + { + Client.Dispose(); + Client = CreateClient(); + + await EnsureConnected(); + + return await func.Invoke(); + } + } + + + + private FtpClient CreateClient() + { + var client = new FtpClient(); + client.Host = Host; + client.Port = Port; + client.Credentials = new NetworkCredential(Username, Password); + client.Config.DataConnectionType = FtpDataConnectionType.PASV; + + client.Config.ConnectTimeout = OperationTimeout; + client.Config.ReadTimeout = OperationTimeout; + client.Config.DataConnectionConnectTimeout = OperationTimeout; + client.Config.DataConnectionReadTimeout = OperationTimeout; + + return client; + } + + #endregion +} diff --git a/Moonlight/Features/Servers/Http/Controllers/FtpController.cs b/Moonlight/Features/Servers/Http/Controllers/FtpController.cs new file mode 100644 index 0000000..215be2f --- /dev/null +++ b/Moonlight/Features/Servers/Http/Controllers/FtpController.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MoonCore.Abstractions; +using MoonCore.Helpers; +using Moonlight.Core.Database.Entities; +using Moonlight.Core.Services.Utils; +using Moonlight.Features.Servers.Entities; +using Moonlight.Features.Servers.Extensions.Attributes; +using Moonlight.Features.Servers.Http.Requests; +using Moonlight.Features.ServiceManagement.Services; + +namespace Moonlight.Features.Servers.Http.Controllers; + +[ApiController] +[Route("api/servers/ftp")] +[EnableNodeMiddleware] +public class FtpController : Controller +{ + private readonly IServiceProvider ServiceProvider; + private readonly JwtService JwtService; + + public FtpController( + IServiceProvider serviceProvider, + JwtService jwtService) + { + ServiceProvider = serviceProvider; + JwtService = jwtService; + } + + [HttpPost] + public async Task Post([FromBody] FtpLogin login) + { + // If it looks like a jwt, try authenticate it + if (await TryJwtLogin(login)) + return Ok(); + + // Search for user + var userRepo = ServiceProvider.GetRequiredService>(); + var user = userRepo + .Get() + .FirstOrDefault(x => x.Username == login.Username); + + // Unknown user + if (user == null) + return StatusCode(403); + + // Check password + if (!HashHelper.Verify(login.Password, user.Password)) + { + Logger.Warn($"A failed login attempt via ftp has occured. Username: '{login.Username}', Server Id: '{login.ServerId}'"); + return StatusCode(403); + } + + // Load node from context + var node = HttpContext.Items["Node"] as ServerNode; + + // Load server from db + var serverRepo = ServiceProvider.GetRequiredService>(); + var server = serverRepo + .Get() + .Include(x => x.Service) + .FirstOrDefault(x => x.Id == login.ServerId && x.Node.Id == node!.Id); + + // Unknown server or wrong node? + if (server == null) + return StatusCode(403); + + var serviceManageService = ServiceProvider.GetRequiredService(); + + // Has user access to this server? + if (await serviceManageService.CheckAccess(server.Service, user)) + return Ok(); + + return StatusCode(403); + } + + private async Task TryJwtLogin(FtpLogin login) + { + if (!await JwtService.Validate(login.Password, "FtpServerLogin")) + return false; + + var data = await JwtService.Decode(login.Password); + + if (!data.ContainsKey("ServerId")) + return false; + + if (!int.TryParse(data["ServerId"], out int serverId)) + return false; + + return login.ServerId == serverId; + } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/Http/Requests/FtpLogin.cs b/Moonlight/Features/Servers/Http/Requests/FtpLogin.cs new file mode 100644 index 0000000..3c72cfd --- /dev/null +++ b/Moonlight/Features/Servers/Http/Requests/FtpLogin.cs @@ -0,0 +1,8 @@ +namespace Moonlight.Features.Servers.Http.Requests; + +public class FtpLogin +{ + public string Username { get; set; } + public string Password { get; set; } + public int ServerId { get; set; } +} \ No newline at end of file diff --git a/Moonlight/Features/Servers/UI/UserViews/Files.razor b/Moonlight/Features/Servers/UI/UserViews/Files.razor new file mode 100644 index 0000000..675ddbd --- /dev/null +++ b/Moonlight/Features/Servers/UI/UserViews/Files.razor @@ -0,0 +1,47 @@ +@using Moonlight.Core.Configuration +@using Moonlight.Core.Services.Utils +@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess +@using Moonlight.Features.Servers.Entities +@using Moonlight.Features.ServiceManagement.Entities +@using MoonCore.Services +@using Moonlight.Features.FileManager.UI.Components +@using Moonlight.Features.Servers.Helpers + +@inject JwtService JwtService +@inject ConfigService ConfigService + +@implements IDisposable + + + + + +@code +{ + [CascadingParameter] public Service Service { get; set; } + + [CascadingParameter] public Server Server { get; set; } + + private IFileAccess FileAccess; + + private async Task Load(LazyLoader lazyLoader) + { + var ftpLoginJwt = await JwtService.Create(data => + { + data.Add("ServerId", Server.Id.ToString()); + }, "FtpServerLogin", TimeSpan.FromMinutes(5)); + + FileAccess = new ServerFtpFileAccess( + Server.Node.Fqdn, + Server.Node.FtpPort, + $"moonlight.{Server.Id}", + ftpLoginJwt, + ConfigService.Get().FileManager.OperationTimeout + ); + } + + public void Dispose() + { + FileAccess.Dispose(); + } +} \ No newline at end of file diff --git a/Moonlight/Moonlight.csproj b/Moonlight/Moonlight.csproj index ce38036..8b2ece0 100644 --- a/Moonlight/Moonlight.csproj +++ b/Moonlight/Moonlight.csproj @@ -44,7 +44,6 @@ - @@ -54,6 +53,7 @@ + @@ -61,7 +61,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Moonlight/wwwroot/svg/upload.svg b/Moonlight/wwwroot/svg/upload.svg new file mode 100644 index 0000000..bfd47f3 --- /dev/null +++ b/Moonlight/wwwroot/svg/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file