From 1f631be1c779e6b47b53e47cb6ce1b345681eeb4 Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Thu, 29 Jan 2026 14:47:56 +0100 Subject: [PATCH] Updated settings service to support generic types with JSON serialization/deserialization, adjusted setup wizard checks and modified database schema for proper JSONB storage. --- .../Database/Entities/SettingsOption.cs | 4 +- ...witchedToJsonForSettingsOption.Designer.cs | 251 ++++++++++++++++++ ...9134620_SwitchedToJsonForSettingsOption.cs | 46 ++++ .../Migrations/DataContextModelSnapshot.cs | 4 +- .../Http/Controllers/Admin/SetupController.cs | 8 +- Moonlight.Api/Services/SettingsService.cs | 46 ++-- 6 files changed, 331 insertions(+), 28 deletions(-) create mode 100644 Moonlight.Api/Database/Migrations/20260129134620_SwitchedToJsonForSettingsOption.Designer.cs create mode 100644 Moonlight.Api/Database/Migrations/20260129134620_SwitchedToJsonForSettingsOption.cs diff --git a/Moonlight.Api/Database/Entities/SettingsOption.cs b/Moonlight.Api/Database/Entities/SettingsOption.cs index 5e35d194..74cf210a 100644 --- a/Moonlight.Api/Database/Entities/SettingsOption.cs +++ b/Moonlight.Api/Database/Entities/SettingsOption.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace Moonlight.Api.Database.Entities; @@ -10,5 +11,6 @@ public class SettingsOption public required string Key { get; set; } [MaxLength(4096)] - public required string Value { get; set; } + [Column(TypeName = "jsonb")] + public required string ValueJson { get; set; } } \ No newline at end of file diff --git a/Moonlight.Api/Database/Migrations/20260129134620_SwitchedToJsonForSettingsOption.Designer.cs b/Moonlight.Api/Database/Migrations/20260129134620_SwitchedToJsonForSettingsOption.Designer.cs new file mode 100644 index 00000000..97ae800d --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260129134620_SwitchedToJsonForSettingsOption.Designer.cs @@ -0,0 +1,251 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Moonlight.Api.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260129134620_SwitchedToJsonForSettingsOption")] + partial class SwitchedToJsonForSettingsOption + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("core") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ApiKeys", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Roles", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("RoleMembers", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ValueJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("SettingsOptions", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CssContent") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.HasKey("Id"); + + b.ToTable("Themes", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("InvalidateTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Users", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.HasOne("Moonlight.Api.Database.Entities.Role", "Role") + .WithMany("Members") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.Api.Database.Entities.User", "User") + .WithMany("RoleMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b => + { + b.Navigation("RoleMemberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight.Api/Database/Migrations/20260129134620_SwitchedToJsonForSettingsOption.cs b/Moonlight.Api/Database/Migrations/20260129134620_SwitchedToJsonForSettingsOption.cs new file mode 100644 index 00000000..376d6285 --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260129134620_SwitchedToJsonForSettingsOption.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + /// + public partial class SwitchedToJsonForSettingsOption : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Value", + schema: "core", + table: "SettingsOptions"); + + migrationBuilder.AddColumn( + name: "ValueJson", + schema: "core", + table: "SettingsOptions", + type: "jsonb", + maxLength: 4096, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ValueJson", + schema: "core", + table: "SettingsOptions"); + + migrationBuilder.AddColumn( + name: "Value", + schema: "core", + table: "SettingsOptions", + type: "character varying(4096)", + maxLength: 4096, + nullable: false, + defaultValue: ""); + } + } +} diff --git a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs index 57359491..53294dcf 100644 --- a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs +++ b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs @@ -136,10 +136,10 @@ namespace Moonlight.Api.Database.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); - b.Property("Value") + b.Property("ValueJson") .IsRequired() .HasMaxLength(4096) - .HasColumnType("character varying(4096)"); + .HasColumnType("jsonb"); b.HasKey("Id"); diff --git a/Moonlight.Api/Http/Controllers/Admin/SetupController.cs b/Moonlight.Api/Http/Controllers/Admin/SetupController.cs index 56d2a479..eb1bb8c5 100644 --- a/Moonlight.Api/Http/Controllers/Admin/SetupController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/SetupController.cs @@ -34,12 +34,12 @@ public class SetupController : Controller [AllowAnonymous] public async Task GetSetupAsync() { - var hasBeenSetup = await SettingsService.GetValueAsync(StateSettingsKey); + var hasBeenSetup = await SettingsService.GetValueAsync(StateSettingsKey); - if (!string.IsNullOrWhiteSpace(hasBeenSetup) && hasBeenSetup.Equals("true", StringComparison.OrdinalIgnoreCase)) + if (hasBeenSetup) return Problem("This instance is already configured", statusCode: 405); - return Ok(); + return NoContent(); } [HttpPost] @@ -120,7 +120,7 @@ public class SetupController : Controller await UsersRepository.UpdateAsync(user); } - await SettingsService.SetValueAsync(StateSettingsKey, "true"); + await SettingsService.SetValueAsync(StateSettingsKey, true); return NoContent(); } diff --git a/Moonlight.Api/Services/SettingsService.cs b/Moonlight.Api/Services/SettingsService.cs index 1d0e7a63..f6361fbb 100644 --- a/Moonlight.Api/Services/SettingsService.cs +++ b/Moonlight.Api/Services/SettingsService.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Moonlight.Api.Configuration; @@ -26,31 +27,32 @@ public class SettingsService Options = options; } - public async Task GetValueAsync(string key) + public async Task GetValueAsync(string key) { var cacheKey = string.Format(CacheKey, key); - if (!Cache.TryGetValue(cacheKey, out var value)) - { - var retrievedValue = await Repository - .Query() - .Where(x => x.Key == key) - .Select(o => o.Value) - .FirstOrDefaultAsync(); + if (Cache.TryGetValue(cacheKey, out var value)) + return JsonSerializer.Deserialize(value!); + + value = await Repository + .Query() + .Where(x => x.Key == key) + .Select(o => o.ValueJson) + .FirstOrDefaultAsync(); + + if(string.IsNullOrEmpty(value)) + return default; - Cache.Set( - cacheKey, - retrievedValue, - TimeSpan.FromMinutes(Options.Value.CacheMinutes) - ); + Cache.Set( + cacheKey, + value, + TimeSpan.FromMinutes(Options.Value.CacheMinutes) + ); - return retrievedValue; - } - - return value; + return JsonSerializer.Deserialize(value); } - public async Task SetValueAsync(string key, string value) + public async Task SetValueAsync(string key, T value) { var cacheKey = string.Format(CacheKey, key); @@ -58,9 +60,11 @@ public class SettingsService .Query() .FirstOrDefaultAsync(x => x.Key == key); + var json = JsonSerializer.Serialize(value); + if (option != null) { - option.Value = value; + option.ValueJson = json; await Repository.UpdateAsync(option); } else @@ -68,7 +72,7 @@ public class SettingsService option = new SettingsOption() { Key = key, - Value = value + ValueJson = json }; await Repository.AddAsync(option);