From 7c5dc657dc52ed0b44a7ce03e37a353f6bdc9a48 Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Thu, 5 Mar 2026 10:56:52 +0000 Subject: [PATCH] Implemented node crud and status health check. Added daemon status health endpoint. Refactored project structure. Added sidebar items and ui views --- .../Properties/launchSettings.json | 9 - .../Admin/Nodes/CrudController.cs | 158 ++++++++++++++++++ .../Admin/Nodes/HealthController.cs | 51 ++++++ .../Admin/Nodes/NodeMapper.cs | 17 ++ .../Admin/Nodes/NodeService.cs | 132 +++++++++++++++ .../Http/Controllers/FormController.cs | 21 --- .../Configuration/NodeTokenOptions.cs | 7 + .../Infrastructure/Database/DataContext.cs | 38 +++++ .../Database/DatabaseRepository.cs | 46 +++++ .../Database/DbMigrationService.cs | 48 ++++++ .../Infrastructure/Database/Entities/Node.cs | 25 +++ .../Database/Interfaces/IActionTimestamps.cs | 7 + ...305104238_AddedBasicNodeEntity.Designer.cs | 70 ++++++++ .../20260305104238_AddedBasicNodeEntity.cs | 46 +++++ .../Migrations/DataContextModelSnapshot.cs | 67 ++++++++ .../NodeToken/NodeTokenSchemeHandler.cs | 101 +++++++++++ .../NodeToken/NodeTokenSchemeOptions.cs | 9 + .../MoonlightServers.Api.csproj | 11 +- .../Remote/Nodes/PingController.cs | 14 ++ MoonlightServers.Api/Startup.cs | 29 ++++ .../Configuration/RemoteOptions.cs | 8 + .../Http/Controllers/HealthController.cs | 30 ++++ .../TokenScheme/TokenSchemeHandler.cs | 44 +++++ .../TokenScheme/TokenSchemeOptions.cs | 8 + .../MoonlightServers.Daemon.csproj | 4 + MoonlightServers.Daemon/Program.cs | 28 +++- .../Services/RemoteService.cs | 117 +++++++++++++ .../Http/Daemon/HealthDto.cs | 6 + .../Http/ProblemDetails.cs | 14 ++ .../Http/SerializationContext.cs | 14 ++ .../MoonlightServers.DaemonShared.csproj | 4 + .../Admin/Nodes/Create.razor | 104 ++++++++++++ .../Admin/Nodes/Edit.razor | 154 +++++++++++++++++ .../Admin/Nodes/NodeHealthDisplay.razor | 109 ++++++++++++ .../Admin/Nodes/Overview.razor | 158 ++++++++++++++++++ .../Implementations/PermissionProvider.cs | 17 -- .../Implementations/SidebarProvider.cs | 21 --- .../Infrastructure/PermissionProvider.cs | 21 +++ .../Infrastructure/SidebarProvider.cs | 29 ++++ .../MoonlightServers.Frontend.csproj | 1 + MoonlightServers.Frontend/Startup.cs | 2 +- .../UI/Components/FormDialog.razor | 74 -------- MoonlightServers.Frontend/UI/Views/Demo.razor | 43 ----- MoonlightServers.Frontend/UI/_Imports.razor | 6 - MoonlightServers.Frontend/_Imports.razor | 10 ++ .../Admin/Nodes/CreateNodeDto.cs | 14 ++ .../Admin/Nodes/NodeDto.cs | 9 + .../Admin/Nodes/NodeHealthDto.cs | 8 + .../Admin/Nodes/UpdateNodeDto.cs | 14 ++ .../Http/Requests/FormSubmitDto.cs | 8 - .../Http/Responses/FormResultDto.cs | 6 - .../MoonlightServers.Shared.csproj | 8 + MoonlightServers.Shared/Permissions.cs | 15 ++ .../SerializationContext.cs | 16 +- 54 files changed, 1808 insertions(+), 222 deletions(-) create mode 100644 MoonlightServers.Api/Admin/Nodes/CrudController.cs create mode 100644 MoonlightServers.Api/Admin/Nodes/HealthController.cs create mode 100644 MoonlightServers.Api/Admin/Nodes/NodeMapper.cs create mode 100644 MoonlightServers.Api/Admin/Nodes/NodeService.cs delete mode 100644 MoonlightServers.Api/Http/Controllers/FormController.cs create mode 100644 MoonlightServers.Api/Infrastructure/Configuration/NodeTokenOptions.cs create mode 100644 MoonlightServers.Api/Infrastructure/Database/DataContext.cs create mode 100644 MoonlightServers.Api/Infrastructure/Database/DatabaseRepository.cs create mode 100644 MoonlightServers.Api/Infrastructure/Database/DbMigrationService.cs create mode 100644 MoonlightServers.Api/Infrastructure/Database/Entities/Node.cs create mode 100644 MoonlightServers.Api/Infrastructure/Database/Interfaces/IActionTimestamps.cs create mode 100644 MoonlightServers.Api/Infrastructure/Database/Migrations/20260305104238_AddedBasicNodeEntity.Designer.cs create mode 100644 MoonlightServers.Api/Infrastructure/Database/Migrations/20260305104238_AddedBasicNodeEntity.cs create mode 100644 MoonlightServers.Api/Infrastructure/Database/Migrations/DataContextModelSnapshot.cs create mode 100644 MoonlightServers.Api/Infrastructure/Implementations/NodeToken/NodeTokenSchemeHandler.cs create mode 100644 MoonlightServers.Api/Infrastructure/Implementations/NodeToken/NodeTokenSchemeOptions.cs create mode 100644 MoonlightServers.Api/Remote/Nodes/PingController.cs create mode 100644 MoonlightServers.Daemon/Configuration/RemoteOptions.cs create mode 100644 MoonlightServers.Daemon/Http/Controllers/HealthController.cs create mode 100644 MoonlightServers.Daemon/Implementations/TokenScheme/TokenSchemeHandler.cs create mode 100644 MoonlightServers.Daemon/Implementations/TokenScheme/TokenSchemeOptions.cs create mode 100644 MoonlightServers.Daemon/Services/RemoteService.cs create mode 100644 MoonlightServers.DaemonShared/Http/Daemon/HealthDto.cs create mode 100644 MoonlightServers.DaemonShared/Http/ProblemDetails.cs create mode 100644 MoonlightServers.DaemonShared/Http/SerializationContext.cs create mode 100644 MoonlightServers.Frontend/Admin/Nodes/Create.razor create mode 100644 MoonlightServers.Frontend/Admin/Nodes/Edit.razor create mode 100644 MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor create mode 100644 MoonlightServers.Frontend/Admin/Nodes/Overview.razor delete mode 100644 MoonlightServers.Frontend/Implementations/PermissionProvider.cs delete mode 100644 MoonlightServers.Frontend/Implementations/SidebarProvider.cs create mode 100644 MoonlightServers.Frontend/Infrastructure/PermissionProvider.cs create mode 100644 MoonlightServers.Frontend/Infrastructure/SidebarProvider.cs delete mode 100644 MoonlightServers.Frontend/UI/Components/FormDialog.razor delete mode 100644 MoonlightServers.Frontend/UI/Views/Demo.razor delete mode 100644 MoonlightServers.Frontend/UI/_Imports.razor create mode 100644 MoonlightServers.Frontend/_Imports.razor create mode 100644 MoonlightServers.Shared/Admin/Nodes/CreateNodeDto.cs create mode 100644 MoonlightServers.Shared/Admin/Nodes/NodeDto.cs create mode 100644 MoonlightServers.Shared/Admin/Nodes/NodeHealthDto.cs create mode 100644 MoonlightServers.Shared/Admin/Nodes/UpdateNodeDto.cs delete mode 100644 MoonlightServers.Shared/Http/Requests/FormSubmitDto.cs delete mode 100644 MoonlightServers.Shared/Http/Responses/FormResultDto.cs create mode 100644 MoonlightServers.Shared/Permissions.cs diff --git a/Hosts/MoonlightServers.Api.Host/Properties/launchSettings.json b/Hosts/MoonlightServers.Api.Host/Properties/launchSettings.json index f2c4d7a..b81e01e 100644 --- a/Hosts/MoonlightServers.Api.Host/Properties/launchSettings.json +++ b/Hosts/MoonlightServers.Api.Host/Properties/launchSettings.json @@ -9,15 +9,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7240;http://localhost:5031", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/MoonlightServers.Api/Admin/Nodes/CrudController.cs b/MoonlightServers.Api/Admin/Nodes/CrudController.cs new file mode 100644 index 0000000..287dbf0 --- /dev/null +++ b/MoonlightServers.Api/Admin/Nodes/CrudController.cs @@ -0,0 +1,158 @@ +using System.Text; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Moonlight.Shared.Http.Requests; +using Moonlight.Shared.Http.Responses; +using MoonlightServers.Api.Infrastructure.Database; +using MoonlightServers.Api.Infrastructure.Database.Entities; +using MoonlightServers.Api.Infrastructure.Implementations.NodeToken; +using MoonlightServers.Shared; +using MoonlightServers.Shared.Admin.Nodes; + +namespace MoonlightServers.Api.Admin.Nodes; + +[ApiController] +[Route("api/admin/servers/nodes")] +public class CrudController : Controller +{ + private readonly DatabaseRepository DatabaseRepository; + private readonly HybridCache Cache; + + public CrudController( + DatabaseRepository databaseRepository, + HybridCache cache + ) + { + DatabaseRepository = databaseRepository; + Cache = cache; + } + + [HttpGet] + [Authorize(Policy = Permissions.Nodes.View)] + public async Task>> GetAsync( + [FromQuery] int startIndex, + [FromQuery] int length, + [FromQuery] FilterOptions? filterOptions + ) + { + // Validation + if (startIndex < 0) + return Problem("Invalid start index specified", statusCode: 400); + + if (length is < 1 or > 100) + return Problem("Invalid length specified"); + + // Query building + + var query = DatabaseRepository + .Query(); + + // Filters + if (filterOptions != null) + { + foreach (var filterOption in filterOptions.Filters) + { + query = filterOption.Key switch + { + nameof(Node.Name) => + query.Where(role => EF.Functions.ILike(role.Name, $"%{filterOption.Value}%")), + + _ => query + }; + } + } + + // Pagination + var data = await query + .OrderBy(x => x.Id) + .ProjectToDto() + .Skip(startIndex) + .Take(length) + .ToArrayAsync(); + + var total = await query.CountAsync(); + + return new PagedData(data, total); + } + + [HttpGet("{id:int}")] + [Authorize(Policy = Permissions.Nodes.View)] + public async Task> GetAsync([FromRoute] int id) + { + var node = await DatabaseRepository + .Query() + .FirstOrDefaultAsync(x => x.Id == id); + + if (node == null) + return Problem("No node with this id found", statusCode: 404); + + return NodeMapper.ToDto(node); + } + + [HttpPost] + [Authorize(Policy = Permissions.Nodes.Create)] + public async Task> CreateAsync([FromBody] CreateNodeDto request) + { + var node = NodeMapper.ToEntity(request); + + node.TokenId = GenerateString(10); + node.Token = GenerateString(64); + + var finalRole = await DatabaseRepository.AddAsync(node); + + return NodeMapper.ToDto(finalRole); + } + + [HttpPut("{id:int}")] + [Authorize(Policy = Permissions.Nodes.Edit)] + public async Task> UpdateAsync([FromRoute] int id, [FromBody] UpdateNodeDto request) + { + var node = await DatabaseRepository + .Query() + .FirstOrDefaultAsync(x => x.Id == id); + + if (node == null) + return Problem("No node with this id found", statusCode: 404); + + NodeMapper.Merge(node, request); + + await DatabaseRepository.UpdateAsync(node); + + return NodeMapper.ToDto(node); + } + + [HttpDelete("{id:int}")] + [Authorize(Policy = Permissions.Nodes.Delete)] + public async Task DeleteAsync([FromRoute] int id) + { + var node = await DatabaseRepository + .Query() + .FirstOrDefaultAsync(x => x.Id == id); + + if (node == null) + return Problem("No node with this id found", statusCode: 404); + + await DatabaseRepository.RemoveAsync(node); + + // Remove cache for node token auth scheme + await Cache.RemoveAsync(string.Format(NodeTokenSchemeHandler.CacheKeyFormat, node.TokenId)); + + return NoContent(); + } + + private static string GenerateString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var stringBuilder = new StringBuilder(); + var random = new Random(); + + for (var i = 0; i < length; i++) + { + stringBuilder.Append(chars[random.Next(chars.Length)]); + } + + return stringBuilder.ToString(); + } +} \ No newline at end of file diff --git a/MoonlightServers.Api/Admin/Nodes/HealthController.cs b/MoonlightServers.Api/Admin/Nodes/HealthController.cs new file mode 100644 index 0000000..1d35a0c --- /dev/null +++ b/MoonlightServers.Api/Admin/Nodes/HealthController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using MoonlightServers.Api.Infrastructure.Database; +using MoonlightServers.Api.Infrastructure.Database.Entities; +using MoonlightServers.Shared; +using MoonlightServers.Shared.Admin.Nodes; + +namespace MoonlightServers.Api.Admin.Nodes; + +[ApiController] +[Authorize(Policy = Permissions.Nodes.View)] +[Route("api/admin/servers/nodes/{id:int}/health")] +public class HealthController : Controller +{ + private readonly DatabaseRepository DatabaseRepository; + private readonly NodeService NodeService; + private readonly ILogger Logger; + + public HealthController( + DatabaseRepository databaseRepository, + NodeService nodeService, + ILogger logger + ) + { + DatabaseRepository = databaseRepository; + NodeService = nodeService; + Logger = logger; + } + + [HttpGet] + public async Task> GetAsync([FromRoute] int id) + { + var node = await DatabaseRepository + .Query() + .FirstOrDefaultAsync(x => x.Id == id); + + if (node == null) + return Problem("No node with this id found", statusCode: 404); + + var health = await NodeService.GetHealthAsync(node); + + return new NodeHealthDto() + { + StatusCode = health.StatusCode, + RemoteStatusCode = health.Dto?.RemoteStatusCode ?? 0, + IsHealthy = health is { StatusCode: >= 200 and <= 299, Dto.RemoteStatusCode: >= 200 and <= 299 } + }; + } +} \ No newline at end of file diff --git a/MoonlightServers.Api/Admin/Nodes/NodeMapper.cs b/MoonlightServers.Api/Admin/Nodes/NodeMapper.cs new file mode 100644 index 0000000..ee1f036 --- /dev/null +++ b/MoonlightServers.Api/Admin/Nodes/NodeMapper.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; +using MoonlightServers.Api.Infrastructure.Database.Entities; +using MoonlightServers.Shared.Admin.Nodes; +using Riok.Mapperly.Abstractions; + +namespace MoonlightServers.Api.Admin.Nodes; + +[Mapper] +[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")] +[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")] +public static partial class NodeMapper +{ + public static partial NodeDto ToDto(Node node); + public static partial IQueryable ProjectToDto(this IQueryable nodes); + public static partial Node ToEntity(CreateNodeDto dto); + public static partial void Merge([MappingTarget] Node node, UpdateNodeDto dto); +} \ No newline at end of file diff --git a/MoonlightServers.Api/Admin/Nodes/NodeService.cs b/MoonlightServers.Api/Admin/Nodes/NodeService.cs new file mode 100644 index 0000000..f5361cc --- /dev/null +++ b/MoonlightServers.Api/Admin/Nodes/NodeService.cs @@ -0,0 +1,132 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; +using MoonlightServers.Api.Infrastructure.Database.Entities; +using MoonlightServers.DaemonShared.Http; +using MoonlightServers.DaemonShared.Http.Daemon; + +namespace MoonlightServers.Api.Admin.Nodes; + +public class NodeService +{ + private readonly IHttpClientFactory ClientFactory; + private readonly ILogger Logger; + public NodeService(IHttpClientFactory clientFactory, ILogger logger) + { + ClientFactory = clientFactory; + Logger = logger; + } + + public async Task GetHealthAsync(Node node) + { + var client = ClientFactory.CreateClient(); + + var request = CreateBaseRequest(node, HttpMethod.Get, "api/health"); + + try + { + var response = await client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + return new NodeHealthStatus((int)response.StatusCode, null); + + try + { + var health = await response + .Content + .ReadFromJsonAsync(SerializationContext.Default.Options); + + return new NodeHealthStatus((int)response.StatusCode, health); + } + catch (Exception e) + { + Logger.LogTrace(e, "An unhandled error occured while processing health response of node {id}", node.Id); + + return new NodeHealthStatus((int)response.StatusCode, null); + } + } + catch (Exception e) + { + Logger.LogTrace(e, "An error occured while fetching health status of node {id}", node.Id); + return new NodeHealthStatus(0, null); + } + } + + private static HttpRequestMessage CreateBaseRequest( + Node node, + [StringSyntax(StringSyntaxAttribute.Uri)] + HttpMethod method, + string endpoint + ) + { + var request = new HttpRequestMessage(); + + request.Headers.Add(HeaderNames.Authorization, node.Token); + request.RequestUri = new Uri(new Uri(node.HttpEndpointUrl), endpoint); + request.Method = method; + + return request; + } + + private async Task EnsureSuccessAsync(HttpResponseMessage message) + { + if (message.IsSuccessStatusCode) + return; + + try + { + var problemDetails = await message.Content.ReadFromJsonAsync( + SerializationContext.Default.Options + ); + + if (problemDetails == null) + { + // If we cant handle problem details, we handle it natively + message.EnsureSuccessStatusCode(); + return; + } + + // Parse into exception + throw new NodeException( + problemDetails.Type, + problemDetails.Title, + problemDetails.Status, + problemDetails.Detail, + problemDetails.Errors + ); + } + catch (JsonException) + { + // If we cant handle problem details, we handle it natively + message.EnsureSuccessStatusCode(); + } + } +} + +public record NodeHealthStatus(int StatusCode, HealthDto? Dto); + +public class NodeException : Exception +{ + public string Type { get; } + public string Title { get; } + public int Status { get; } + public string? Detail { get; } + public Dictionary? Errors { get; } + + public NodeException( + string type, + string title, + int status, + string? detail = null, + Dictionary? errors = null) + : base(detail ?? title) + { + Type = type; + Title = title; + Status = status; + Detail = detail; + Errors = errors; + } +} \ No newline at end of file diff --git a/MoonlightServers.Api/Http/Controllers/FormController.cs b/MoonlightServers.Api/Http/Controllers/FormController.cs deleted file mode 100644 index 03c765c..0000000 --- a/MoonlightServers.Api/Http/Controllers/FormController.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using MoonlightServers.Shared.Http.Requests; -using MoonlightServers.Shared.Http.Responses; - -namespace MoonlightServers.Api.Http.Controllers; - -[Authorize] -[ApiController] -[Route("api/form")] -public class FormController : Controller -{ - [HttpPost] - public async Task> PostAsync([FromBody] FormSubmitDto dto) - { - return new FormResultDto() - { - Result = dto.TextField.Replace(" ", string.Empty) - }; - } -} \ No newline at end of file diff --git a/MoonlightServers.Api/Infrastructure/Configuration/NodeTokenOptions.cs b/MoonlightServers.Api/Infrastructure/Configuration/NodeTokenOptions.cs new file mode 100644 index 0000000..16c60a2 --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Configuration/NodeTokenOptions.cs @@ -0,0 +1,7 @@ +namespace MoonlightServers.Api.Infrastructure.Configuration; + +public class NodeTokenOptions +{ + public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3); +} \ No newline at end of file diff --git a/MoonlightServers.Api/Infrastructure/Database/DataContext.cs b/MoonlightServers.Api/Infrastructure/Database/DataContext.cs new file mode 100644 index 0000000..3c6d96e --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Database/DataContext.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Moonlight.Api.Configuration; +using MoonlightServers.Api.Infrastructure.Database.Entities; + +namespace MoonlightServers.Api.Infrastructure.Database; + +public class DataContext : DbContext +{ + public DbSet Nodes { get; set; } + + private readonly IOptions Options; + public DataContext(IOptions options) + { + Options = options; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (optionsBuilder.IsConfigured) + return; + + optionsBuilder.UseNpgsql( + $"Host={Options.Value.Host};" + + $"Port={Options.Value.Port};" + + $"Username={Options.Value.Username};" + + $"Password={Options.Value.Password};" + + $"Database={Options.Value.Database}" + ); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("servers"); + + base.OnModelCreating(modelBuilder); + } +} \ No newline at end of file diff --git a/MoonlightServers.Api/Infrastructure/Database/DatabaseRepository.cs b/MoonlightServers.Api/Infrastructure/Database/DatabaseRepository.cs new file mode 100644 index 0000000..a2e0c73 --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Database/DatabaseRepository.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using MoonlightServers.Api.Infrastructure.Database.Interfaces; + +namespace MoonlightServers.Api.Infrastructure.Database; + +public class DatabaseRepository where T : class +{ + private readonly DataContext DataContext; + private readonly DbSet Set; + + public DatabaseRepository(DataContext dataContext) + { + DataContext = dataContext; + Set = DataContext.Set(); + } + + public IQueryable Query() => Set; + + public async Task AddAsync(T entity) + { + if (entity is IActionTimestamps actionTimestamps) + { + actionTimestamps.CreatedAt = DateTimeOffset.UtcNow; + actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow; + } + + var final = Set.Add(entity); + await DataContext.SaveChangesAsync(); + return final.Entity; + } + + public async Task UpdateAsync(T entity) + { + if (entity is IActionTimestamps actionTimestamps) + actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow; + + Set.Update(entity); + await DataContext.SaveChangesAsync(); + } + + public async Task RemoveAsync(T entity) + { + Set.Remove(entity); + await DataContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/MoonlightServers.Api/Infrastructure/Database/DbMigrationService.cs b/MoonlightServers.Api/Infrastructure/Database/DbMigrationService.cs new file mode 100644 index 0000000..31ae3db --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Database/DbMigrationService.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MoonlightServers.Api.Infrastructure.Database; + +public class DbMigrationService : IHostedLifecycleService +{ + private readonly ILogger Logger; + private readonly IServiceProvider ServiceProvider; + + public DbMigrationService(ILogger logger, IServiceProvider serviceProvider) + { + Logger = logger; + ServiceProvider = serviceProvider; + } + + public async Task StartingAsync(CancellationToken cancellationToken) + { + Logger.LogTrace("Checking for pending migrations"); + + await using var scope = ServiceProvider.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var pendingMigrations = await context.Database.GetPendingMigrationsAsync(cancellationToken); + var migrationNames = pendingMigrations.ToArray(); + + if (migrationNames.Length == 0) + { + Logger.LogDebug("No pending migrations found"); + return; + } + + Logger.LogInformation("Pending migrations: {names}", string.Join(", ", migrationNames)); + Logger.LogInformation("Migration started"); + + await context.Database.MigrateAsync(cancellationToken); + + Logger.LogInformation("Migration complete"); + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} \ No newline at end of file diff --git a/MoonlightServers.Api/Infrastructure/Database/Entities/Node.cs b/MoonlightServers.Api/Infrastructure/Database/Entities/Node.cs new file mode 100644 index 0000000..93d236e --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Database/Entities/Node.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using MoonlightServers.Api.Infrastructure.Database.Interfaces; + +namespace MoonlightServers.Api.Infrastructure.Database.Entities; + +public class Node : IActionTimestamps +{ + public int Id { get; set; } + + [MaxLength(50)] + public string Name { get; set; } + + [MaxLength(100)] + public string HttpEndpointUrl { get; set; } + + [MaxLength(10)] + public string TokenId { get; set; } + + [MaxLength(64)] + public string Token { get; set; } + + // Action timestamps + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Api/Infrastructure/Database/Interfaces/IActionTimestamps.cs b/MoonlightServers.Api/Infrastructure/Database/Interfaces/IActionTimestamps.cs new file mode 100644 index 0000000..6fa3054 --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Database/Interfaces/IActionTimestamps.cs @@ -0,0 +1,7 @@ +namespace MoonlightServers.Api.Infrastructure.Database.Interfaces; + +internal interface IActionTimestamps +{ + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Api/Infrastructure/Database/Migrations/20260305104238_AddedBasicNodeEntity.Designer.cs b/MoonlightServers.Api/Infrastructure/Database/Migrations/20260305104238_AddedBasicNodeEntity.Designer.cs new file mode 100644 index 0000000..68101ae --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Database/Migrations/20260305104238_AddedBasicNodeEntity.Designer.cs @@ -0,0 +1,70 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MoonlightServers.Api.Infrastructure.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MoonlightServers.Api.Infrastructure.Database.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260305104238_AddedBasicNodeEntity")] + partial class AddedBasicNodeEntity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("servers") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HttpEndpointUrl") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Nodes", "servers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoonlightServers.Api/Infrastructure/Database/Migrations/20260305104238_AddedBasicNodeEntity.cs b/MoonlightServers.Api/Infrastructure/Database/Migrations/20260305104238_AddedBasicNodeEntity.cs new file mode 100644 index 0000000..634a2fa --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Database/Migrations/20260305104238_AddedBasicNodeEntity.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MoonlightServers.Api.Infrastructure.Database.Migrations +{ + /// + public partial class AddedBasicNodeEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "servers"); + + migrationBuilder.CreateTable( + name: "Nodes", + schema: "servers", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + HttpEndpointUrl = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + TokenId = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + Token = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Nodes", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Nodes", + schema: "servers"); + } + } +} diff --git a/MoonlightServers.Api/Infrastructure/Database/Migrations/DataContextModelSnapshot.cs b/MoonlightServers.Api/Infrastructure/Database/Migrations/DataContextModelSnapshot.cs new file mode 100644 index 0000000..6090722 --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Database/Migrations/DataContextModelSnapshot.cs @@ -0,0 +1,67 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MoonlightServers.Api.Infrastructure.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MoonlightServers.Api.Infrastructure.Database.Migrations +{ + [DbContext(typeof(DataContext))] + partial class DataContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("servers") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HttpEndpointUrl") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Nodes", "servers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoonlightServers.Api/Infrastructure/Implementations/NodeToken/NodeTokenSchemeHandler.cs b/MoonlightServers.Api/Infrastructure/Implementations/NodeToken/NodeTokenSchemeHandler.cs new file mode 100644 index 0000000..3df82ec --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Implementations/NodeToken/NodeTokenSchemeHandler.cs @@ -0,0 +1,101 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using MoonlightServers.Api.Infrastructure.Database; +using MoonlightServers.Api.Infrastructure.Database.Entities; + +namespace MoonlightServers.Api.Infrastructure.Implementations.NodeToken; + +public class NodeTokenSchemeHandler : AuthenticationHandler +{ + public const string SchemeName = "MoonlightServers.NodeToken"; + public const string CacheKeyFormat = $"MoonlightServers.{nameof(NodeTokenSchemeHandler)}.{{0}}"; + + private readonly DatabaseRepository DatabaseRepository; + private readonly HybridCache Cache; + + public NodeTokenSchemeHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + DatabaseRepository databaseRepository, + HybridCache cache + ) : base(options, logger, encoder) + { + DatabaseRepository = databaseRepository; + Cache = cache; + } + + protected override async Task HandleAuthenticateAsync() + { + // Basic format validation + + if (!Context.Request.Headers.TryGetValue(HeaderNames.Authorization, out var authHeaderValues)) + return AuthenticateResult.Fail("No authorization header present"); + + if (authHeaderValues.Count != 1) + return AuthenticateResult.Fail("No authorization value present"); + + var authHeaderValue = authHeaderValues[0]; + + if (string.IsNullOrEmpty(authHeaderValue)) + return AuthenticateResult.Fail("No authorization value present"); + + var authHeaderParts = authHeaderValue.Split( + ' ', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries + ); + + // Validate parts + if (authHeaderParts.Length < 2) + return AuthenticateResult.Fail("Malformed authorization header"); + + var tokenId = authHeaderParts[0]; + var token = authHeaderParts[1]; + + if (tokenId.Length != 10 && token.Length != 64) + return AuthenticateResult.Fail("Malformed authorization header"); + + // Real validation + + var cacheKey = string.Format(CacheKeyFormat, tokenId); + + var session = await Cache.GetOrCreateAsync(cacheKey, async cancellationToken => + { + return await DatabaseRepository + .Query() + .Where(x => x.TokenId == tokenId) + .Select(x => new NodeTokenSession(x.Id, x.Token)) + .FirstOrDefaultAsync(cancellationToken: cancellationToken); + }, + new HybridCacheEntryOptions() + { + LocalCacheExpiration = Options.LookupCacheL1Expiry, + Expiration = Options.LookupCacheL2Expiry + } + ); + + if(session == null || token != session.Token) + return AuthenticateResult.Fail("Invalid authorization header"); + + // All checks have passed, create auth ticket + return AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal( + new ClaimsIdentity( + [ + new Claim("NodeId", session.Id.ToString()) + ], + SchemeName + ) + ), + SchemeName + )); + } + + private record NodeTokenSession(int Id, string Token); +} \ No newline at end of file diff --git a/MoonlightServers.Api/Infrastructure/Implementations/NodeToken/NodeTokenSchemeOptions.cs b/MoonlightServers.Api/Infrastructure/Implementations/NodeToken/NodeTokenSchemeOptions.cs new file mode 100644 index 0000000..b121e77 --- /dev/null +++ b/MoonlightServers.Api/Infrastructure/Implementations/NodeToken/NodeTokenSchemeOptions.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authentication; + +namespace MoonlightServers.Api.Infrastructure.Implementations.NodeToken; + +public class NodeTokenSchemeOptions : AuthenticationSchemeOptions +{ + public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3); +} \ No newline at end of file diff --git a/MoonlightServers.Api/MoonlightServers.Api.csproj b/MoonlightServers.Api/MoonlightServers.Api.csproj index 578ffbd..71a6c36 100644 --- a/MoonlightServers.Api/MoonlightServers.Api.csproj +++ b/MoonlightServers.Api/MoonlightServers.Api.csproj @@ -18,18 +18,11 @@ - - - - - - - - - + + diff --git a/MoonlightServers.Api/Remote/Nodes/PingController.cs b/MoonlightServers.Api/Remote/Nodes/PingController.cs new file mode 100644 index 0000000..0b11a75 --- /dev/null +++ b/MoonlightServers.Api/Remote/Nodes/PingController.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MoonlightServers.Api.Infrastructure.Implementations.NodeToken; + +namespace MoonlightServers.Api.Remote.Nodes; + +[ApiController] +[Route("api/remote/servers/nodes/ping")] +[Authorize(AuthenticationSchemes = NodeTokenSchemeHandler.SchemeName)] +public class PingController : Controller +{ + [HttpGet] + public ActionResult Get() => NoContent(); +} \ No newline at end of file diff --git a/MoonlightServers.Api/Startup.cs b/MoonlightServers.Api/Startup.cs index f3b2532..6feb9bd 100644 --- a/MoonlightServers.Api/Startup.cs +++ b/MoonlightServers.Api/Startup.cs @@ -1,6 +1,12 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Moonlight.Api; +using MoonlightServers.Api.Admin.Nodes; +using MoonlightServers.Api.Infrastructure.Configuration; +using MoonlightServers.Api.Infrastructure.Database; +using MoonlightServers.Api.Infrastructure.Implementations.NodeToken; using SimplePlugin.Abstractions; using SerializationContext = MoonlightServers.Shared.SerializationContext; @@ -17,5 +23,28 @@ public class Startup : MoonlightPlugin { options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default); }); + + builder.Services.AddScoped(typeof(DatabaseRepository<>)); + + builder.Services.AddDbContext(); + builder.Services.AddHostedService(); + + builder.Services.AddSingleton(); + + var nodeTokenOptions = new NodeTokenOptions(); + builder.Configuration.Bind("Moonlight:Servers:NodeToken", nodeTokenOptions); + + builder.Services + .AddAuthentication() + .AddScheme(NodeTokenSchemeHandler.SchemeName, options => + { + options.LookupCacheL1Expiry = nodeTokenOptions.LookupCacheL1Expiry; + options.LookupCacheL2Expiry = nodeTokenOptions.LookupCacheL2Expiry; + }); + + builder.Logging.AddFilter( + "MoonlightServers.Api.Infrastructure.Implementations.NodeToken.NodeTokenSchemeHandler", + LogLevel.Warning + ); } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Configuration/RemoteOptions.cs b/MoonlightServers.Daemon/Configuration/RemoteOptions.cs new file mode 100644 index 0000000..ac05835 --- /dev/null +++ b/MoonlightServers.Daemon/Configuration/RemoteOptions.cs @@ -0,0 +1,8 @@ +namespace MoonlightServers.Daemon.Configuration; + +public class RemoteOptions +{ + public string EndpointUrl { get; set; } + public string TokenId { get; set; } + public string Token { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/HealthController.cs b/MoonlightServers.Daemon/Http/Controllers/HealthController.cs new file mode 100644 index 0000000..1926e13 --- /dev/null +++ b/MoonlightServers.Daemon/Http/Controllers/HealthController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MoonlightServers.Daemon.Services; +using MoonlightServers.DaemonShared.Http.Daemon; + +namespace MoonlightServers.Daemon.Http.Controllers; + +[Authorize] +[ApiController] +[Route("api/health")] +public class HealthController : Controller +{ + private readonly RemoteService RemoteService; + + public HealthController(RemoteService remoteService) + { + RemoteService = remoteService; + } + + [HttpGet] + public async Task> GetAsync() + { + var remoteStatusCode = await RemoteService.CheckReachabilityAsync(); + + return new HealthDto() + { + RemoteStatusCode = remoteStatusCode + }; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Implementations/TokenScheme/TokenSchemeHandler.cs b/MoonlightServers.Daemon/Implementations/TokenScheme/TokenSchemeHandler.cs new file mode 100644 index 0000000..88fcf94 --- /dev/null +++ b/MoonlightServers.Daemon/Implementations/TokenScheme/TokenSchemeHandler.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace MoonlightServers.Daemon.Implementations.TokenScheme; + +public class TokenSchemeHandler : AuthenticationHandler +{ + public const string SchemeName = "MoonlightServers.Token"; + + public TokenSchemeHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder + ) : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + if (!Context.Request.Headers.TryGetValue(HeaderNames.Authorization, out var authHeaderValues)) + return Task.FromResult(AuthenticateResult.Fail("No authorization header present")); + + if (authHeaderValues.Count != 1) + return Task.FromResult(AuthenticateResult.Fail("No authorization value present")); + + var authHeaderValue = authHeaderValues[0]; + + if (string.IsNullOrEmpty(authHeaderValue)) + return Task.FromResult(AuthenticateResult.Fail("No authorization value present")); + + if (authHeaderValue != Options.Token) + return Task.FromResult(AuthenticateResult.Fail("Invalid token provided")); + + return Task.FromResult( + AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity([], nameof(TokenSchemeHandler))), + nameof(TokenSchemeHandler) + )) + ); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Implementations/TokenScheme/TokenSchemeOptions.cs b/MoonlightServers.Daemon/Implementations/TokenScheme/TokenSchemeOptions.cs new file mode 100644 index 0000000..5dcbbdf --- /dev/null +++ b/MoonlightServers.Daemon/Implementations/TokenScheme/TokenSchemeOptions.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authentication; + +namespace MoonlightServers.Daemon.Implementations.TokenScheme; + +public class TokenSchemeOptions : AuthenticationSchemeOptions +{ + public string Token { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj index aa8139d..1541759 100644 --- a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj +++ b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj @@ -28,4 +28,8 @@ + + + + diff --git a/MoonlightServers.Daemon/Program.cs b/MoonlightServers.Daemon/Program.cs index b068d23..8efd5c7 100644 --- a/MoonlightServers.Daemon/Program.cs +++ b/MoonlightServers.Daemon/Program.cs @@ -1,9 +1,12 @@ using Microsoft.Extensions.Logging.Console; +using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.Helpers; +using MoonlightServers.Daemon.Implementations.TokenScheme; using MoonlightServers.Daemon.ServerSystem; using MoonlightServers.Daemon.ServerSystem.Implementations.Docker; using MoonlightServers.Daemon.ServerSystem.Implementations.Local; using MoonlightServers.Daemon.Services; +using MoonlightServers.DaemonShared.Http; var builder = WebApplication.CreateBuilder(args); @@ -18,7 +21,28 @@ builder.Services.AddSingleton(); builder.Services.AddDockerServices(); builder.Services.AddLocalServices(); -builder.Services.AddControllers(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); + +builder.Services.AddOptions().BindConfiguration("Moonlight:Remote"); + +var remoteOptions = new RemoteOptions(); +builder.Configuration.Bind("Moonlight:Remote", remoteOptions); + +builder.Services.AddControllers() + .AddApplicationPart(typeof(Program).Assembly) + .AddJsonOptions(options => + { + options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default); + }); + +builder.Services.AddAuthentication(TokenSchemeHandler.SchemeName) + .AddScheme(TokenSchemeHandler.SchemeName, options => + { + options.Token = remoteOptions!.Token; + }); + +builder.Logging.AddFilter("MoonlightServers.Daemon.Implementations.TokenScheme.TokenSchemeHandler", LogLevel.Warning); var app = builder.Build(); @@ -50,8 +74,6 @@ Task.Run(async () => await server.StopAsync(); Console.ReadLine(); - - await serverService.DeleteAsync("a0e3ddb4-2c72-4f4c-bc49-35650a4bc5c0"); } catch (Exception e) { diff --git a/MoonlightServers.Daemon/Services/RemoteService.cs b/MoonlightServers.Daemon/Services/RemoteService.cs new file mode 100644 index 0000000..a12ffa1 --- /dev/null +++ b/MoonlightServers.Daemon/Services/RemoteService.cs @@ -0,0 +1,117 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using MoonlightServers.Daemon.Configuration; +using MoonlightServers.DaemonShared.Http; + +namespace MoonlightServers.Daemon.Services; + +public class RemoteService +{ + private readonly IOptions Options; + private readonly ILogger Logger; + private readonly IHttpClientFactory ClientFactory; + + public RemoteService( + IOptions options, + IHttpClientFactory clientFactory, + ILogger logger + ) + { + Options = options; + ClientFactory = clientFactory; + Logger = logger; + } + + public async Task CheckReachabilityAsync() + { + try + { + var client = ClientFactory.CreateClient(); + + var request = CreateBaseRequest(HttpMethod.Get, "api/remote/servers/nodes/ping"); + var response = await client.SendAsync(request); + + return (int)response.StatusCode; + } + catch (Exception e) + { + Logger.LogTrace(e, "An error occured while checking if remote is reachable"); + return 0; + } + } + + private HttpRequestMessage CreateBaseRequest( + [StringSyntax(StringSyntaxAttribute.Uri)] + HttpMethod method, + string endpoint + ) + { + var request = new HttpRequestMessage(); + + request.Headers.Add(HeaderNames.Authorization, $"{Options.Value.TokenId} {Options.Value.Token}"); + request.RequestUri = new Uri(new Uri(Options.Value.EndpointUrl), endpoint); + request.Method = method; + + return request; + } + + private async Task EnsureSuccessAsync(HttpResponseMessage message) + { + if (message.IsSuccessStatusCode) + return; + + try + { + var problemDetails = await message.Content.ReadFromJsonAsync( + SerializationContext.Default.Options + ); + + if (problemDetails == null) + { + // If we cant handle problem details, we handle it natively + message.EnsureSuccessStatusCode(); + return; + } + + // Parse into exception + throw new RemoteException( + problemDetails.Type, + problemDetails.Title, + problemDetails.Status, + problemDetails.Detail, + problemDetails.Errors + ); + } + catch (JsonException) + { + // If we cant handle problem details, we handle it natively + message.EnsureSuccessStatusCode(); + } + } +} + +public class RemoteException : Exception +{ + public string Type { get; } + public string Title { get; } + public int Status { get; } + public string? Detail { get; } + public Dictionary? Errors { get; } + + public RemoteException( + string type, + string title, + int status, + string? detail = null, + Dictionary? errors = null) + : base(detail ?? title) + { + Type = type; + Title = title; + Status = status; + Detail = detail; + Errors = errors; + } +} \ No newline at end of file diff --git a/MoonlightServers.DaemonShared/Http/Daemon/HealthDto.cs b/MoonlightServers.DaemonShared/Http/Daemon/HealthDto.cs new file mode 100644 index 0000000..1621095 --- /dev/null +++ b/MoonlightServers.DaemonShared/Http/Daemon/HealthDto.cs @@ -0,0 +1,6 @@ +namespace MoonlightServers.DaemonShared.Http.Daemon; + +public class HealthDto +{ + public int RemoteStatusCode { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.DaemonShared/Http/ProblemDetails.cs b/MoonlightServers.DaemonShared/Http/ProblemDetails.cs new file mode 100644 index 0000000..98ac978 --- /dev/null +++ b/MoonlightServers.DaemonShared/Http/ProblemDetails.cs @@ -0,0 +1,14 @@ +namespace MoonlightServers.DaemonShared.Http; + +public class ProblemDetails +{ + public string Type { get; set; } + + public string Title { get; set; } + + public int Status { get; set; } + + public string? Detail { get; set; } + + public Dictionary? Errors { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.DaemonShared/Http/SerializationContext.cs b/MoonlightServers.DaemonShared/Http/SerializationContext.cs new file mode 100644 index 0000000..a002a25 --- /dev/null +++ b/MoonlightServers.DaemonShared/Http/SerializationContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MoonlightServers.DaemonShared.Http.Daemon; + +namespace MoonlightServers.DaemonShared.Http; + +[JsonSerializable(typeof(HealthDto))] +[JsonSerializable(typeof(ProblemDetails))] + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +public partial class SerializationContext : JsonSerializerContext +{ + +} \ No newline at end of file diff --git a/MoonlightServers.DaemonShared/MoonlightServers.DaemonShared.csproj b/MoonlightServers.DaemonShared/MoonlightServers.DaemonShared.csproj index 237d661..ff5b973 100644 --- a/MoonlightServers.DaemonShared/MoonlightServers.DaemonShared.csproj +++ b/MoonlightServers.DaemonShared/MoonlightServers.DaemonShared.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/MoonlightServers.Frontend/Admin/Nodes/Create.razor b/MoonlightServers.Frontend/Admin/Nodes/Create.razor new file mode 100644 index 0000000..158fd62 --- /dev/null +++ b/MoonlightServers.Frontend/Admin/Nodes/Create.razor @@ -0,0 +1,104 @@ +@page "/admin/servers/nodes/create" + +@using LucideBlazor +@using Moonlight.Frontend.Helpers +@using MoonlightServers.Shared +@using MoonlightServers.Shared.Admin.Nodes +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Cards +@using ShadcnBlazor.Extras.Forms +@using ShadcnBlazor.Extras.Toasts +@using ShadcnBlazor.Fields +@using ShadcnBlazor.Inputs + +@inject HttpClient HttpClient +@inject NavigationManager Navigation +@inject ToastService ToastService + + + +
+
+

Create Node

+
+ Create a new node +
+
+
+ + + + Continue + +
+
+ +
+ + + + + + + +
+ +
+ + Name + + + + + HTTP Endpoint + + +
+
+
+
+
+
+
+ +
+ +@code +{ + private CreateNodeDto Request = new(); + + private async Task OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore) + { + var response = await HttpClient.PostAsJsonAsync( + "/api/admin/servers/nodes", + Request, + SerializationContext.Default.Options + ); + + if (!response.IsSuccessStatusCode) + { + await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore); + return false; + } + + await ToastService.SuccessAsync( + "Node Creation", + $"Successfully created node {Request.Name}" + ); + + Navigation.NavigateTo("/admin/servers/nodes"); + return true; + } +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Admin/Nodes/Edit.razor b/MoonlightServers.Frontend/Admin/Nodes/Edit.razor new file mode 100644 index 0000000..e6d10a9 --- /dev/null +++ b/MoonlightServers.Frontend/Admin/Nodes/Edit.razor @@ -0,0 +1,154 @@ +@page "/admin/servers/nodes/{Id:int}" + +@using System.Net +@using LucideBlazor +@using Moonlight.Frontend.Helpers +@using MoonlightServers.Shared +@using MoonlightServers.Shared.Admin.Nodes +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Cards +@using ShadcnBlazor.Emptys +@using ShadcnBlazor.Extras.Common +@using ShadcnBlazor.Extras.Forms +@using ShadcnBlazor.Extras.Toasts +@using ShadcnBlazor.Fields +@using ShadcnBlazor.Inputs + +@inject HttpClient HttpClient +@inject NavigationManager Navigation +@inject ToastService ToastService + + + @if (Node == null) + { + + + + + + Node not found + + A node with this id cannot be found + + + + } + else + { + + +
+
+

Update Node

+
+ Update node @Node.Name +
+
+
+ + + + Continue + +
+
+ +
+ + + + + + + +
+ +
+ + Name + + + + + HTTP Endpoint + + +
+
+
+
+
+
+
+ +
+ } +
+ +@code +{ + [Parameter] public int Id { get; set; } + + private UpdateNodeDto Request; + private NodeDto? Node; + + private async Task LoadAsync(LazyLoader _) + { + var response = await HttpClient.GetAsync($"api/admin/servers/nodes/{Id}"); + + if (!response.IsSuccessStatusCode) + { + if(response.StatusCode == HttpStatusCode.NotFound) + return; + + response.EnsureSuccessStatusCode(); + return; + } + + Node = await response.Content.ReadFromJsonAsync(SerializationContext.Default.Options); + + if(Node == null) + return; + + Request = new UpdateNodeDto() + { + Name = Node.Name, + HttpEndpointUrl = Node.HttpEndpointUrl + }; + } + + private async Task OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore) + { + var response = await HttpClient.PutAsJsonAsync( + $"/api/admin/servers/nodes/{Id}", + Request, + SerializationContext.Default.Options + ); + + if (!response.IsSuccessStatusCode) + { + await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore); + return false; + } + + await ToastService.SuccessAsync( + "Node Update", + $"Successfully updated node {Request.Name}" + ); + + Navigation.NavigateTo("/admin/servers/nodes"); + return true; + } +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor b/MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor new file mode 100644 index 0000000..cb20afe --- /dev/null +++ b/MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor @@ -0,0 +1,109 @@ +@using Microsoft.Extensions.Logging +@using MoonlightServers.Shared.Admin.Nodes +@using ShadcnBlazor.Tooltips + +@inject HttpClient HttpClient +@inject ILogger Logger + +@if (IsLoading) +{ + Loading +} +else +{ + if (IsHealthy) + { + + + + Healthy + + + + @TooltipText + + + } + else + { + + + + Unhealthy + + + + @TooltipText + + + } +} + +@code +{ + [Parameter] public NodeDto Node { get; set; } + + private bool IsLoading = true; + + private string TooltipText = "An unknown error has occured. Check logs"; + private bool IsHealthy; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + try + { + var result = await HttpClient.GetFromJsonAsync($"api/admin/servers/nodes/{Node.Id}/health"); + + if(result == null) + return; + + IsHealthy = result.IsHealthy; + + if (IsHealthy) + TooltipText = "Version: v2.1.0"; // TODO: Add version loading + else + { + if (result.StatusCode != 0) + { + if (result.StatusCode is >= 200 and <= 299) + { + if (result.RemoteStatusCode != 0) + { + TooltipText = result.RemoteStatusCode switch + { + 401 => "Daemon is unable to authenticate against the panel", + 404 => "Daemon is unable to request the panel's endpoint", + 500 => "Panel encountered an internal server error", + _ => $"Panel returned {result.RemoteStatusCode}" + }; + } + else + TooltipText = "Daemon is unable to reach the panel"; + } + else + { + TooltipText = result.StatusCode switch + { + 401 => "Panel is unable to authenticate against the node", + 404 => "Panel is unable to request the daemon's endpoint", + 500 => "Daemon encountered an internal server error", + _ => $"Daemon returned {result.StatusCode}" + }; + } + } + else + TooltipText = "Moonlight is unable to reach the node"; + } + } + catch (Exception e) + { + Logger.LogError(e, "An unhandled error occured while fetching the node health status"); + } + + IsLoading = false; + await InvokeAsync(StateHasChanged); + } +} diff --git a/MoonlightServers.Frontend/Admin/Nodes/Overview.razor b/MoonlightServers.Frontend/Admin/Nodes/Overview.razor new file mode 100644 index 0000000..9b780b2 --- /dev/null +++ b/MoonlightServers.Frontend/Admin/Nodes/Overview.razor @@ -0,0 +1,158 @@ +@page "/admin/servers/nodes" + +@using LucideBlazor +@using Moonlight.Shared.Http.Requests +@using Moonlight.Shared.Http.Responses +@using MoonlightServers.Shared +@using MoonlightServers.Shared.Admin.Nodes +@using ShadcnBlazor.DataGrids +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Dropdowns +@using ShadcnBlazor.Extras.AlertDialogs +@using ShadcnBlazor.Extras.Dialogs +@using ShadcnBlazor.Extras.Toasts +@using ShadcnBlazor.Tabels + +@inject HttpClient HttpClient +@inject AlertDialogService AlertDialogService +@inject DialogService DialogService +@inject ToastService ToastService +@inject NavigationManager NavigationManager +@inject IAuthorizationService AuthorizationService + +
+
+

Nodes

+
+ Manage nodes +
+
+
+ +
+
+ +
+ + + + + + + @context.Name + + + + + + + + + + + + + + + +
+ + + + + + + + + Edit + + + + + + Delete + + + + + + +
+
+
+
+
+
+ +@code +{ + [CascadingParameter] public Task AuthState { get; set; } + + private DataGrid Grid; + + private AuthorizationResult EditAccess; + private AuthorizationResult DeleteAccess; + private AuthorizationResult CreateAccess; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthState; + + EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Nodes.Edit); + DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Nodes.Delete); + CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Nodes.Create); + } + + private async Task> LoadAsync(DataGridRequest request) + { + var query = $"?startIndex={request.StartIndex}&length={request.Length}"; + var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null; + + var response = await HttpClient.GetFromJsonAsync>( + $"api/admin/servers/nodes{query}&filterOptions={filterOptions}", + SerializationContext.Default.Options + ); + + return new DataGridResponse(response!.Data, response.TotalLength); + } + + private void Edit(NodeDto context) => NavigationManager.NavigateTo($"/admin/servers/nodes/{context.Id}"); + + private async Task DeleteAsync(NodeDto context) + { + await AlertDialogService.ConfirmDangerAsync( + "Node Deletion", + $"Do you really want to delete the node {context.Name}? This cannot be undone.", + async () => + { + var response = await HttpClient.DeleteAsync($"api/admin/servers/nodes/{context.Id}"); + response.EnsureSuccessStatusCode(); + + await Grid.RefreshAsync(); + + await ToastService.SuccessAsync( + "Node Deletion", + $"Successfully deleted node {context.Name}" + ); + + } + ); + } +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Implementations/PermissionProvider.cs b/MoonlightServers.Frontend/Implementations/PermissionProvider.cs deleted file mode 100644 index b99cbff..0000000 --- a/MoonlightServers.Frontend/Implementations/PermissionProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -using LucideBlazor; -using Moonlight.Frontend.Interfaces; -using Moonlight.Frontend.Models; - -namespace MoonlightServers.Frontend.Implementations; - -public class PermissionProvider : IPermissionProvider -{ - public Task GetPermissionsAsync() - { - return Task.FromResult([ - new PermissionCategory("Demo", typeof(SparklesIcon), [ - new Permission("Permissions:Demo", "Demo", "Access to demo page") - ]) - ]); - } -} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Implementations/SidebarProvider.cs b/MoonlightServers.Frontend/Implementations/SidebarProvider.cs deleted file mode 100644 index fdf4d98..0000000 --- a/MoonlightServers.Frontend/Implementations/SidebarProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -using LucideBlazor; -using Moonlight.Frontend.Interfaces; -using Moonlight.Frontend.Models; - -namespace MoonlightServers.Frontend.Implementations; - -public sealed class SidebarProvider : ISidebarProvider -{ - public Task GetItemsAsync() - { - return Task.FromResult([ - new SidebarItem() - { - Group = "Demo", - Name = "Demo", - IconType = typeof(SparklesIcon), - Path = "/demo" - } - ]); - } -} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Infrastructure/PermissionProvider.cs b/MoonlightServers.Frontend/Infrastructure/PermissionProvider.cs new file mode 100644 index 0000000..ada49c5 --- /dev/null +++ b/MoonlightServers.Frontend/Infrastructure/PermissionProvider.cs @@ -0,0 +1,21 @@ +using LucideBlazor; +using Moonlight.Frontend.Interfaces; +using Moonlight.Frontend.Models; +using MoonlightServers.Shared; + +namespace MoonlightServers.Frontend.Infrastructure; + +public class PermissionProvider : IPermissionProvider +{ + public Task GetPermissionsAsync() + { + return Task.FromResult([ + new PermissionCategory("Servers - Nodes", typeof(ServerIcon), [ + new Permission(Permissions.Nodes.View, "View", "Viewing all nodes"), + new Permission(Permissions.Nodes.Create, "Create", "Creating new nodes"), + new Permission(Permissions.Nodes.Edit, "Edit", "Editing nodes"), + new Permission(Permissions.Nodes.Delete, "Delete", "Deleting nodes"), + ]) + ]); + } +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/Infrastructure/SidebarProvider.cs b/MoonlightServers.Frontend/Infrastructure/SidebarProvider.cs new file mode 100644 index 0000000..62290c5 --- /dev/null +++ b/MoonlightServers.Frontend/Infrastructure/SidebarProvider.cs @@ -0,0 +1,29 @@ +using LucideBlazor; +using Moonlight.Frontend.Interfaces; +using Moonlight.Frontend.Models; +using MoonlightServers.Shared; + +namespace MoonlightServers.Frontend.Infrastructure; + +public sealed class SidebarProvider : ISidebarProvider +{ + public Task GetItemsAsync() + { + return Task.FromResult([ + new SidebarItem() + { + Name = "Servers", + IconType = typeof(ServerIcon), + Path = "/servers" + }, + new SidebarItem() + { + Group = "Admin", + Name = "Servers", + IconType = typeof(ServerIcon), + Path = "/admin/servers", + Policy = Permissions.Nodes.View + } + ]); + } +} \ No newline at end of file diff --git a/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj b/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj index ee4b9d9..c3acca0 100644 --- a/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj +++ b/MoonlightServers.Frontend/MoonlightServers.Frontend.csproj @@ -16,6 +16,7 @@ + diff --git a/MoonlightServers.Frontend/Startup.cs b/MoonlightServers.Frontend/Startup.cs index f810c25..a4b7225 100644 --- a/MoonlightServers.Frontend/Startup.cs +++ b/MoonlightServers.Frontend/Startup.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Moonlight.Frontend; using Moonlight.Frontend.Configuration; using Moonlight.Frontend.Interfaces; -using MoonlightServers.Frontend.Implementations; +using MoonlightServers.Frontend.Infrastructure; using SimplePlugin.Abstractions; namespace MoonlightServers.Frontend; diff --git a/MoonlightServers.Frontend/UI/Components/FormDialog.razor b/MoonlightServers.Frontend/UI/Components/FormDialog.razor deleted file mode 100644 index 14c356e..0000000 --- a/MoonlightServers.Frontend/UI/Components/FormDialog.razor +++ /dev/null @@ -1,74 +0,0 @@ -@using Moonlight.Frontend.Helpers -@using MoonlightServers.Shared -@using MoonlightServers.Shared.Http.Requests -@using MoonlightServers.Shared.Http.Responses -@using ShadcnBlazor.Buttons -@using ShadcnBlazor.Dialogs -@using ShadcnBlazor.Extras.AlertDialogs -@using ShadcnBlazor.Extras.Forms -@using ShadcnBlazor.Fields -@using ShadcnBlazor.Inputs - -@inherits ShadcnBlazor.Extras.Dialogs.DialogBase - -@inject HttpClient HttpClient -@inject AlertDialogService AlertDialogService - - - Example Form - This forms removes all spaces from the input - - - - - -
- - - - - Form Input - - Input you want to remove the spaces from - - -
-
- - - - - - -@code -{ - private FormSubmitDto Dto = new(); - private EnhancedEditForm Form; - - private async Task OnSubmit(EditContext editContext, ValidationMessageStore validationMessageStore) - { - var response = await HttpClient.PostAsJsonAsync( - "api/form", - Dto, - SerializationContext.Default.Options - ); - - if (response.IsSuccessStatusCode) - { - var data = await response.Content.ReadFromJsonAsync( - SerializationContext.Default.Options - ); - - if (data == null) - return true; - - await AlertDialogService.InfoAsync("Result", data.Result); - - await CloseAsync(); - return true; - } - - await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Dto, validationMessageStore); - return false; - } -} \ No newline at end of file diff --git a/MoonlightServers.Frontend/UI/Views/Demo.razor b/MoonlightServers.Frontend/UI/Views/Demo.razor deleted file mode 100644 index 56c98dd..0000000 --- a/MoonlightServers.Frontend/UI/Views/Demo.razor +++ /dev/null @@ -1,43 +0,0 @@ -@page "/demo" -@using LucideBlazor -@using MoonlightServers.Frontend.UI.Components -@using ShadcnBlazor.Buttons -@using ShadcnBlazor.Cards -@using ShadcnBlazor.Extras.Dialogs - -@inject DialogService DialogService - -
- - - Demo - A cool demo page - - - You successfully used the plugin template to create your moonlight plugin :) - - - - - - - - - - -
- -@code -{ - private async Task LaunchFormAsync() - => await DialogService.LaunchAsync(); -} \ No newline at end of file diff --git a/MoonlightServers.Frontend/UI/_Imports.razor b/MoonlightServers.Frontend/UI/_Imports.razor deleted file mode 100644 index 3516d7e..0000000 --- a/MoonlightServers.Frontend/UI/_Imports.razor +++ /dev/null @@ -1,6 +0,0 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Components.Web.Virtualization \ No newline at end of file diff --git a/MoonlightServers.Frontend/_Imports.razor b/MoonlightServers.Frontend/_Imports.razor new file mode 100644 index 0000000..ea3242b --- /dev/null +++ b/MoonlightServers.Frontend/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization \ No newline at end of file diff --git a/MoonlightServers.Shared/Admin/Nodes/CreateNodeDto.cs b/MoonlightServers.Shared/Admin/Nodes/CreateNodeDto.cs new file mode 100644 index 0000000..257978b --- /dev/null +++ b/MoonlightServers.Shared/Admin/Nodes/CreateNodeDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace MoonlightServers.Shared.Admin.Nodes; + +public class CreateNodeDto +{ + [Required] + [MaxLength(50)] + public string Name { get; set; } + + [Required] + [MaxLength(100)] + public string HttpEndpointUrl { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Shared/Admin/Nodes/NodeDto.cs b/MoonlightServers.Shared/Admin/Nodes/NodeDto.cs new file mode 100644 index 0000000..1754e7e --- /dev/null +++ b/MoonlightServers.Shared/Admin/Nodes/NodeDto.cs @@ -0,0 +1,9 @@ +namespace MoonlightServers.Shared.Admin.Nodes; + +public record NodeDto( + int Id, + string Name, + string HttpEndpointUrl, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt +); \ No newline at end of file diff --git a/MoonlightServers.Shared/Admin/Nodes/NodeHealthDto.cs b/MoonlightServers.Shared/Admin/Nodes/NodeHealthDto.cs new file mode 100644 index 0000000..2d0c5b5 --- /dev/null +++ b/MoonlightServers.Shared/Admin/Nodes/NodeHealthDto.cs @@ -0,0 +1,8 @@ +namespace MoonlightServers.Shared.Admin.Nodes; + +public class NodeHealthDto +{ + public int RemoteStatusCode { get; set; } + public int StatusCode { get; set; } + public bool IsHealthy { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Shared/Admin/Nodes/UpdateNodeDto.cs b/MoonlightServers.Shared/Admin/Nodes/UpdateNodeDto.cs new file mode 100644 index 0000000..63e973f --- /dev/null +++ b/MoonlightServers.Shared/Admin/Nodes/UpdateNodeDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace MoonlightServers.Shared.Admin.Nodes; + +public class UpdateNodeDto +{ + [Required] + [MaxLength(50)] + public string Name { get; set; } + + [Required] + [MaxLength(100)] + public string HttpEndpointUrl { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Shared/Http/Requests/FormSubmitDto.cs b/MoonlightServers.Shared/Http/Requests/FormSubmitDto.cs deleted file mode 100644 index 6f38103..0000000 --- a/MoonlightServers.Shared/Http/Requests/FormSubmitDto.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace MoonlightServers.Shared.Http.Requests; - -public class FormSubmitDto -{ - [Required] [MaxLength(32)] public string TextField { get; set; } -} \ No newline at end of file diff --git a/MoonlightServers.Shared/Http/Responses/FormResultDto.cs b/MoonlightServers.Shared/Http/Responses/FormResultDto.cs deleted file mode 100644 index a508c8e..0000000 --- a/MoonlightServers.Shared/Http/Responses/FormResultDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace MoonlightServers.Shared.Http.Responses; - -public class FormResultDto -{ - public string Result { get; set; } -} \ No newline at end of file diff --git a/MoonlightServers.Shared/MoonlightServers.Shared.csproj b/MoonlightServers.Shared/MoonlightServers.Shared.csproj index 237d661..a92341f 100644 --- a/MoonlightServers.Shared/MoonlightServers.Shared.csproj +++ b/MoonlightServers.Shared/MoonlightServers.Shared.csproj @@ -6,4 +6,12 @@ enable + + + + + + + + diff --git a/MoonlightServers.Shared/Permissions.cs b/MoonlightServers.Shared/Permissions.cs new file mode 100644 index 0000000..c9bce26 --- /dev/null +++ b/MoonlightServers.Shared/Permissions.cs @@ -0,0 +1,15 @@ +namespace MoonlightServers.Shared; + +public static class Permissions +{ + public const string Prefix = "Permissions:Servers."; + public static class Nodes + { + private const string Section = "Nodes"; + + public const string View = $"{Prefix}{Section}.{nameof(View)}"; + public const string Edit = $"{Prefix}{Section}.{nameof(Edit)}"; + public const string Create = $"{Prefix}{Section}.{nameof(Create)}"; + public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}"; + } +} \ No newline at end of file diff --git a/MoonlightServers.Shared/SerializationContext.cs b/MoonlightServers.Shared/SerializationContext.cs index c28d28d..fcad10e 100644 --- a/MoonlightServers.Shared/SerializationContext.cs +++ b/MoonlightServers.Shared/SerializationContext.cs @@ -1,13 +1,21 @@ using System.Text.Json; using System.Text.Json.Serialization; -using MoonlightServers.Shared.Http.Requests; -using MoonlightServers.Shared.Http.Responses; +using Moonlight.Shared.Http.Responses; +using MoonlightServers.Shared.Admin.Nodes; namespace MoonlightServers.Shared; -[JsonSerializable(typeof(FormSubmitDto))] -[JsonSerializable(typeof(FormResultDto))] +// Admin + +// - Node +[JsonSerializable(typeof(CreateNodeDto))] +[JsonSerializable(typeof(UpdateNodeDto))] +[JsonSerializable(typeof(NodeDto))] +[JsonSerializable(typeof(PagedData))] + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] public partial class SerializationContext : JsonSerializerContext { + } \ No newline at end of file