From e1207b8d9b38dd1b487223228e7be4b22c278143 Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Thu, 29 Jan 2026 11:23:07 +0100 Subject: [PATCH] Refactored container helper service. Cleaned up event models. Implemented version changing. Added security questions before rebuild --- ...roller.cs => ContainerHelperController.cs} | 16 +++- .../ContainerHelper/Events/RebuildEventDto.cs | 20 +++++ .../ContainerHelper/ProblemDetails.cs | 10 +++ .../ContainerHelper/Requests/SetVersionDto.cs | 3 + .../ContainerHelper/SerializationContext.cs | 28 +++++++ .../Mappers/ContainerHelperMapper.cs | 13 +++ Moonlight.Api/Moonlight.Api.csproj | 4 + .../Services/ContainerHelperService.cs | 74 ++++++++++------- Moonlight.Api/Startup/Startup.Base.cs | 2 +- Moonlight.Frontend/Constants.cs | 2 +- .../UI/Admin/Modals/UpdateInstanceModal.razor | 8 +- .../UI/Admin/Views/Sys/Instance.razor | 83 +++++++++++++++---- .../{RebuildEvent.cs => RebuildEventDto.cs} | 2 +- .../Admin/ContainerHelper/SetVersionDto.cs | 10 +++ ...ntext.cs => SharedSerializationContext.cs} | 22 ++++- 15 files changed, 242 insertions(+), 55 deletions(-) rename Moonlight.Api/Http/Controllers/Admin/{ChController.cs => ContainerHelperController.cs} (63%) create mode 100644 Moonlight.Api/Http/Services/ContainerHelper/Events/RebuildEventDto.cs create mode 100644 Moonlight.Api/Http/Services/ContainerHelper/ProblemDetails.cs create mode 100644 Moonlight.Api/Http/Services/ContainerHelper/Requests/SetVersionDto.cs create mode 100644 Moonlight.Api/Http/Services/ContainerHelper/SerializationContext.cs create mode 100644 Moonlight.Api/Mappers/ContainerHelperMapper.cs rename Moonlight.Shared/Http/Events/{RebuildEvent.cs => RebuildEventDto.cs} (91%) create mode 100644 Moonlight.Shared/Http/Requests/Admin/ContainerHelper/SetVersionDto.cs rename Moonlight.Shared/Http/{SerializationContext.cs => SharedSerializationContext.cs} (71%) diff --git a/Moonlight.Api/Http/Controllers/Admin/ChController.cs b/Moonlight.Api/Http/Controllers/Admin/ContainerHelperController.cs similarity index 63% rename from Moonlight.Api/Http/Controllers/Admin/ChController.cs rename to Moonlight.Api/Http/Controllers/Admin/ContainerHelperController.cs index cb501b41..599351a5 100644 --- a/Moonlight.Api/Http/Controllers/Admin/ChController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/ContainerHelperController.cs @@ -2,19 +2,21 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Moonlight.Api.Configuration; +using Moonlight.Api.Mappers; using Moonlight.Api.Services; +using Moonlight.Shared.Http.Requests.Admin.ContainerHelper; using Moonlight.Shared.Http.Responses.Admin; namespace Moonlight.Api.Http.Controllers.Admin; [ApiController] [Route("api/admin/ch")] -public class ChController : Controller +public class ContainerHelperController : Controller { private readonly ContainerHelperService ContainerHelperService; private readonly IOptions Options; - public ChController(ContainerHelperService containerHelperService, IOptions options) + public ContainerHelperController(ContainerHelperService containerHelperService, IOptions options) { ContainerHelperService = containerHelperService; Options = options; @@ -35,9 +37,17 @@ public class ChController : Controller public Task RebuildAsync() { var result = ContainerHelperService.RebuildAsync(); + var mappedResult = result.Select(ContainerHelperMapper.ToDto); return Task.FromResult( - TypedResults.ServerSentEvents(result) + TypedResults.ServerSentEvents(mappedResult) ); } + + [HttpPost("version")] + public async Task SetVersionAsync([FromBody] SetVersionDto request) + { + await ContainerHelperService.SetVersionAsync(request.Version); + return NoContent(); + } } \ No newline at end of file diff --git a/Moonlight.Api/Http/Services/ContainerHelper/Events/RebuildEventDto.cs b/Moonlight.Api/Http/Services/ContainerHelper/Events/RebuildEventDto.cs new file mode 100644 index 00000000..1742b643 --- /dev/null +++ b/Moonlight.Api/Http/Services/ContainerHelper/Events/RebuildEventDto.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Moonlight.Api.Http.Services.ContainerHelper.Events; + +public struct RebuildEventDto +{ + [JsonPropertyName("type")] + public RebuildEventType Type { get; set; } + + [JsonPropertyName("data")] + public string Data { get; set; } +} + +public enum RebuildEventType +{ + Log = 0, + Failed = 1, + Succeeded = 2, + Step = 3 +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Services/ContainerHelper/ProblemDetails.cs b/Moonlight.Api/Http/Services/ContainerHelper/ProblemDetails.cs new file mode 100644 index 00000000..925672c6 --- /dev/null +++ b/Moonlight.Api/Http/Services/ContainerHelper/ProblemDetails.cs @@ -0,0 +1,10 @@ +namespace Moonlight.Api.Http.Services.ContainerHelper; + +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/Moonlight.Api/Http/Services/ContainerHelper/Requests/SetVersionDto.cs b/Moonlight.Api/Http/Services/ContainerHelper/Requests/SetVersionDto.cs new file mode 100644 index 00000000..4c462fbc --- /dev/null +++ b/Moonlight.Api/Http/Services/ContainerHelper/Requests/SetVersionDto.cs @@ -0,0 +1,3 @@ +namespace Moonlight.Api.Http.Services.ContainerHelper.Requests; + +public record SetVersionDto(string Version); \ No newline at end of file diff --git a/Moonlight.Api/Http/Services/ContainerHelper/SerializationContext.cs b/Moonlight.Api/Http/Services/ContainerHelper/SerializationContext.cs new file mode 100644 index 00000000..182f44d1 --- /dev/null +++ b/Moonlight.Api/Http/Services/ContainerHelper/SerializationContext.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Moonlight.Api.Http.Services.ContainerHelper.Events; +using Moonlight.Api.Http.Services.ContainerHelper.Requests; + +namespace Moonlight.Api.Http.Services.ContainerHelper; + +[JsonSerializable(typeof(SetVersionDto))] +[JsonSerializable(typeof(ProblemDetails))] +[JsonSerializable(typeof(RebuildEventDto))] +public partial class SerializationContext : JsonSerializerContext +{ + private static JsonSerializerOptions? InternalTunedOptions; + + public static JsonSerializerOptions TunedOptions + { + get + { + if (InternalTunedOptions != null) + return InternalTunedOptions; + + InternalTunedOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + InternalTunedOptions.TypeInfoResolverChain.Add(Default); + + return InternalTunedOptions; + } + } +} \ No newline at end of file diff --git a/Moonlight.Api/Mappers/ContainerHelperMapper.cs b/Moonlight.Api/Mappers/ContainerHelperMapper.cs new file mode 100644 index 00000000..26cc39b4 --- /dev/null +++ b/Moonlight.Api/Mappers/ContainerHelperMapper.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; +using Moonlight.Shared.Http.Events; +using Riok.Mapperly.Abstractions; + +namespace Moonlight.Api.Mappers; + +[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 ContainerHelperMapper +{ + public static partial RebuildEventDto ToDto(Http.Services.ContainerHelper.Events.RebuildEventDto rebuildEventDto); +} \ No newline at end of file diff --git a/Moonlight.Api/Moonlight.Api.csproj b/Moonlight.Api/Moonlight.Api.csproj index dcf0ef3e..10a62a6c 100644 --- a/Moonlight.Api/Moonlight.Api.csproj +++ b/Moonlight.Api/Moonlight.Api.csproj @@ -35,4 +35,8 @@ false + + + + \ No newline at end of file diff --git a/Moonlight.Api/Services/ContainerHelperService.cs b/Moonlight.Api/Services/ContainerHelperService.cs index feb6a142..5467fe94 100644 --- a/Moonlight.Api/Services/ContainerHelperService.cs +++ b/Moonlight.Api/Services/ContainerHelperService.cs @@ -1,6 +1,8 @@ -using System.Text.Json; -using Moonlight.Shared.Http; -using Moonlight.Shared.Http.Events; +using System.Net.Http.Json; +using System.Text.Json; +using Moonlight.Api.Http.Services.ContainerHelper; +using Moonlight.Api.Http.Services.ContainerHelper.Requests; +using Moonlight.Api.Http.Services.ContainerHelper.Events; namespace Moonlight.Api.Services; @@ -30,61 +32,73 @@ public class ContainerHelperService } } - public async IAsyncEnumerable RebuildAsync() + public async IAsyncEnumerable RebuildAsync() { - var options = new JsonSerializerOptions() - { - PropertyNameCaseInsensitive = true - }; - - options.TypeInfoResolverChain.Add(SerializationContext.Default); - var client = HttpClientFactory.CreateClient("ContainerHelper"); - + var response = await client.GetAsync("api/rebuild", HttpCompletionOption.ResponseHeadersRead); if (!response.IsSuccessStatusCode) { var responseText = await response.Content.ReadAsStringAsync(); - - yield return new RebuildEvent() + + yield return new RebuildEventDto() { - Type = RebuildEventType.Failed, + Type = RebuildEventType.Failed, Data = responseText }; - + yield break; } await using var responseStream = await response.Content.ReadAsStreamAsync(); using var streamReader = new StreamReader(responseStream); - + do { var line = await streamReader.ReadLineAsync(); - - if(line == null) + + if (line == null) break; - - if(string.IsNullOrWhiteSpace(line)) + + if (string.IsNullOrWhiteSpace(line)) continue; - + var data = line.Trim("data: "); - - var deserializedData = JsonSerializer.Deserialize(data, options); - + var deserializedData = JsonSerializer.Deserialize(data, SerializationContext.TunedOptions); + yield return deserializedData; - + // Exit if service will go down for a clean exit - if(deserializedData is {Type: RebuildEventType.Step, Data: "ServiceDown"}) + if (deserializedData is { Type: RebuildEventType.Step, Data: "ServiceDown" }) yield break; - } while (true); - - yield return new RebuildEvent() + + yield return new RebuildEventDto() { Type = RebuildEventType.Succeeded, Data = string.Empty }; } + + public async Task SetVersionAsync(string version) + { + var client = HttpClientFactory.CreateClient("ContainerHelper"); + + var response = await client.PostAsJsonAsync( + "api/configuration/version", + new SetVersionDto(version), + SerializationContext.TunedOptions + ); + + if(response.IsSuccessStatusCode) + return; + + var problemDetails = await response.Content.ReadFromJsonAsync(SerializationContext.TunedOptions); + + if(problemDetails == null) + throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}"); + + throw new HttpRequestException($"Failed to set version: {problemDetails.Detail ?? problemDetails.Title}"); + } } \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Base.cs b/Moonlight.Api/Startup/Startup.Base.cs index 25c97ea6..a40e42b3 100644 --- a/Moonlight.Api/Startup/Startup.Base.cs +++ b/Moonlight.Api/Startup/Startup.Base.cs @@ -19,7 +19,7 @@ public partial class Startup { builder.Services.AddControllers().AddJsonOptions(options => { - options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default); + options.JsonSerializerOptions.TypeInfoResolverChain.Add(SharedSerializationContext.Default); }); builder.Logging.ClearProviders(); diff --git a/Moonlight.Frontend/Constants.cs b/Moonlight.Frontend/Constants.cs index 5c42627f..685cc859 100644 --- a/Moonlight.Frontend/Constants.cs +++ b/Moonlight.Frontend/Constants.cs @@ -18,7 +18,7 @@ public static class Constants }; // Add source generated options from shared project - InternalOptions.TypeInfoResolverChain.Add(SerializationContext.Default); + InternalOptions.TypeInfoResolverChain.Add(SharedSerializationContext.Default); return InternalOptions; } diff --git a/Moonlight.Frontend/UI/Admin/Modals/UpdateInstanceModal.razor b/Moonlight.Frontend/UI/Admin/Modals/UpdateInstanceModal.razor index a4f10c1c..7bbc6ffc 100644 --- a/Moonlight.Frontend/UI/Admin/Modals/UpdateInstanceModal.razor +++ b/Moonlight.Frontend/UI/Admin/Modals/UpdateInstanceModal.razor @@ -3,6 +3,7 @@ @using System.Text.Json @using LucideBlazor @using Moonlight.Shared.Http.Events +@using Moonlight.Shared.Http.Requests.Admin.ContainerHelper @using ShadcnBlazor.Buttons @using ShadcnBlazor.Dialogs @using ShadcnBlazor.Progresses @@ -110,7 +111,10 @@ else Progress = 20; await InvokeAsync(StateHasChanged); - await Task.Delay(2000); + await HttpClient.PostAsJsonAsync("api/admin/ch/version", new SetVersionDto() + { + Version = Version + }); // Starting rebuild task CurrentStep = 2; @@ -138,7 +142,7 @@ else continue; var data = line.Trim("data: "); - var deserializedData = JsonSerializer.Deserialize(data, Constants.SerializerOptions); + var deserializedData = JsonSerializer.Deserialize(data, Constants.SerializerOptions); switch (deserializedData.Type) { diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Instance.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Instance.razor index fd2c865a..13fc0438 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Sys/Instance.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Instance.razor @@ -1,3 +1,4 @@ +@using System.Text.RegularExpressions @using LucideBlazor @using Moonlight.Frontend.UI.Admin.Modals @using Moonlight.Shared.Http.Responses.Admin @@ -39,14 +40,15 @@ v2.1 - v2.1.1 + feat/ContainerHelper + - + @@ -116,20 +118,73 @@ private async Task ApplyAsync() { - await AlertDialogService.ConfirmDangerAsync( - "Moonlight Rebuild", - "If you continue the moonlight instance will become unavailable during the rebuild process. This will impact users on this instance", - async () => + await DialogService.LaunchAsync( + parameters => { parameters[nameof(UpdateInstanceModal.Version)] = SelectedVersion; }, + onConfigure: model => { - await DialogService.LaunchAsync( - parameters => { parameters[nameof(UpdateInstanceModal.Version)] = SelectedVersion; }, - onConfigure: model => - { - model.ShowCloseButton = false; - model.ClassName = "sm:max-w-4xl!"; - } - ); + model.ShowCloseButton = false; + model.ClassName = "sm:max-w-4xl!"; } ); } + + private async Task AskApplyAsync() + { + if(string.IsNullOrWhiteSpace(SelectedVersion)) + return; + + var shouldContinue = await ConfirmRiskyVersionAsync( + "Moonlight Rebuild", + "If you continue the moonlight instance will become unavailable during the rebuild process. This will impact users on this instance" + ); + + if(!shouldContinue) + return; + + if (!Regex.IsMatch(SelectedVersion, @"^v\d+(\.\d+)*b?$")) + { + shouldContinue = await ConfirmRiskyVersionAsync( + "Development Version", + "You are about to install development a version. This can break your instance. Continue at your own risk" + ); + } + else + { + if (SelectedVersion.EndsWith('b')) + { + shouldContinue = await ConfirmRiskyVersionAsync( + "Beta / Pre-Release Version", + "You are about to install a version marked as pre-release / beta. This can break your instance. Continue at your own risk" + ); + } + else + shouldContinue = true; + } + + if (!shouldContinue) + return; + + await ApplyAsync(); + } + + private async Task ConfirmRiskyVersionAsync(string title, string message) + { + var tcs = new TaskCompletionSource(); + var confirmed = false; + + await AlertDialogService.ConfirmDangerAsync( + title, + message, + () => + { + confirmed = true; + tcs.SetResult(); + return Task.CompletedTask; + } + ); + + await tcs.Task; + + return confirmed; + } } diff --git a/Moonlight.Shared/Http/Events/RebuildEvent.cs b/Moonlight.Shared/Http/Events/RebuildEventDto.cs similarity index 91% rename from Moonlight.Shared/Http/Events/RebuildEvent.cs rename to Moonlight.Shared/Http/Events/RebuildEventDto.cs index c3edab82..ecfdefb8 100644 --- a/Moonlight.Shared/Http/Events/RebuildEvent.cs +++ b/Moonlight.Shared/Http/Events/RebuildEventDto.cs @@ -2,7 +2,7 @@ namespace Moonlight.Shared.Http.Events; -public struct RebuildEvent +public struct RebuildEventDto { [JsonPropertyName("type")] public RebuildEventType Type { get; set; } diff --git a/Moonlight.Shared/Http/Requests/Admin/ContainerHelper/SetVersionDto.cs b/Moonlight.Shared/Http/Requests/Admin/ContainerHelper/SetVersionDto.cs new file mode 100644 index 00000000..fab46fd1 --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Admin/ContainerHelper/SetVersionDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.Admin.ContainerHelper; + +public class SetVersionDto +{ + [Required] + [RegularExpression(@"^(?!\/|.*\/\/|.*\.\.|.*\/$)[A-Za-z0-9._/-]+$", ErrorMessage = "Invalid version format")] + public string Version { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/SerializationContext.cs b/Moonlight.Shared/Http/SharedSerializationContext.cs similarity index 71% rename from Moonlight.Shared/Http/SerializationContext.cs rename to Moonlight.Shared/Http/SharedSerializationContext.cs index d070f7ff..3fa23a66 100644 --- a/Moonlight.Shared/Http/SerializationContext.cs +++ b/Moonlight.Shared/Http/SharedSerializationContext.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; using Moonlight.Shared.Http.Events; using Moonlight.Shared.Http.Requests.ApiKeys; using Moonlight.Shared.Http.Requests.Roles; @@ -46,13 +47,28 @@ namespace Moonlight.Shared.Http; [JsonSerializable(typeof(ThemeDto))] // Events -[JsonSerializable(typeof(RebuildEvent))] +[JsonSerializable(typeof(RebuildEventDto))] // Container Helper [JsonSerializable(typeof(ContainerHelperStatusDto))] // Misc [JsonSerializable(typeof(ProblemDetails))] -public partial class SerializationContext : JsonSerializerContext +public partial class SharedSerializationContext : JsonSerializerContext { + private static JsonSerializerOptions? InternalTunedOptions; + + public static JsonSerializerOptions TunedOptions + { + get + { + if (InternalTunedOptions != null) + return InternalTunedOptions; + + InternalTunedOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + InternalTunedOptions.TypeInfoResolverChain.Add(Default); + + return InternalTunedOptions; + } + } } \ No newline at end of file