From 3cbdd3b203cbcd5a5dbfe5c7edccdcd18f2a902c Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Sun, 18 Jan 2026 23:31:01 +0100 Subject: [PATCH] Implemented theme crud and basic theme loading --- .../wwwroot/index.html | 41 +++ .../Configuration/FrontendOptions.cs | 7 + Moonlight.Api/Database/DataContext.cs | 1 + Moonlight.Api/Database/Entities/Theme.cs | 21 ++ .../20260118005634_AddedThemes.Designer.cs | 251 ++++++++++++++++++ .../Migrations/20260118005634_AddedThemes.cs | 41 +++ .../Migrations/DataContextModelSnapshot.cs | 36 +++ .../Controllers/Admin/ThemesController.cs | 145 ++++++++++ .../Http/Controllers/FrontendController.cs | 25 ++ Moonlight.Api/Mappers/FrontendConfigMapper.cs | 14 + Moonlight.Api/Mappers/ThemeMapper.cs | 21 ++ Moonlight.Api/Models/FrontendConfiguration.cs | 3 + Moonlight.Api/Moonlight.Api.csproj | 2 +- Moonlight.Api/Services/FrontendService.cs | 50 ++++ Moonlight.Api/Startup/Startup.Base.cs | 11 +- .../Implementations/PermissionProvider.cs | 6 + Moonlight.Frontend/Mappers/ThemeMapper.cs | 14 + .../UI/Admin/Views/Sys/Index.razor | 26 +- .../UI/Admin/Views/Sys/Themes/Create.razor | 134 ++++++++++ .../UI/Admin/Views/Sys/Themes/Index.razor | 148 +++++++++++ .../UI/Admin/Views/Sys/Themes/Update.razor | 147 ++++++++++ .../Http/Requests/Themes/CreateThemeDto.cs | 23 ++ .../Http/Requests/Themes/UpdateThemeDto.cs | 23 ++ .../Responses/Frontend/FrontendConfigDto.cs | 3 + .../Http/Responses/Themes/ThemeDto.cs | 3 + Moonlight.Shared/Http/SerializationContext.cs | 26 +- Moonlight.Shared/Permissions.cs | 10 + 27 files changed, 1218 insertions(+), 14 deletions(-) create mode 100644 Moonlight.Api/Configuration/FrontendOptions.cs create mode 100644 Moonlight.Api/Database/Entities/Theme.cs create mode 100644 Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.Designer.cs create mode 100644 Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.cs create mode 100644 Moonlight.Api/Http/Controllers/Admin/ThemesController.cs create mode 100644 Moonlight.Api/Http/Controllers/FrontendController.cs create mode 100644 Moonlight.Api/Mappers/FrontendConfigMapper.cs create mode 100644 Moonlight.Api/Mappers/ThemeMapper.cs create mode 100644 Moonlight.Api/Models/FrontendConfiguration.cs create mode 100644 Moonlight.Api/Services/FrontendService.cs create mode 100644 Moonlight.Frontend/Mappers/ThemeMapper.cs create mode 100644 Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Create.razor create mode 100644 Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Index.razor create mode 100644 Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Update.razor create mode 100644 Moonlight.Shared/Http/Requests/Themes/CreateThemeDto.cs create mode 100644 Moonlight.Shared/Http/Requests/Themes/UpdateThemeDto.cs create mode 100644 Moonlight.Shared/Http/Responses/Frontend/FrontendConfigDto.cs create mode 100644 Moonlight.Shared/Http/Responses/Themes/ThemeDto.cs diff --git a/Hosts/Moonlight.Frontend.Host/wwwroot/index.html b/Hosts/Moonlight.Frontend.Host/wwwroot/index.html index 83af45b2..38cfa01e 100644 --- a/Hosts/Moonlight.Frontend.Host/wwwroot/index.html +++ b/Hosts/Moonlight.Frontend.Host/wwwroot/index.html @@ -10,6 +10,45 @@ + + @@ -52,6 +91,8 @@ + + diff --git a/Moonlight.Api/Configuration/FrontendOptions.cs b/Moonlight.Api/Configuration/FrontendOptions.cs new file mode 100644 index 00000000..7f417f0c --- /dev/null +++ b/Moonlight.Api/Configuration/FrontendOptions.cs @@ -0,0 +1,7 @@ +namespace Moonlight.Api.Configuration; + +public class FrontendOptions +{ + public bool Enabled { get; set; } = true; + public int CacheMinutes { get; set; } = 3; +} \ No newline at end of file diff --git a/Moonlight.Api/Database/DataContext.cs b/Moonlight.Api/Database/DataContext.cs index ffbd2ad3..36c7d758 100644 --- a/Moonlight.Api/Database/DataContext.cs +++ b/Moonlight.Api/Database/DataContext.cs @@ -12,6 +12,7 @@ public class DataContext : DbContext public DbSet Roles { get; set; } public DbSet RoleMembers { get; set; } public DbSet ApiKeys { get; set; } + public DbSet Themes { get; set; } private readonly IOptions Options; diff --git a/Moonlight.Api/Database/Entities/Theme.cs b/Moonlight.Api/Database/Entities/Theme.cs new file mode 100644 index 00000000..bbaf6e32 --- /dev/null +++ b/Moonlight.Api/Database/Entities/Theme.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Api.Database.Entities; + +public class Theme +{ + public int Id { get; set; } + + [MaxLength(30)] + public required string Name { get; set; } + + [MaxLength(30)] + public required string Version { get; set; } + + [MaxLength(30)] + public required string Author { get; set; } + public bool IsEnabled { get; set; } + + [MaxLength(20_000)] + public required string CssContent { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.Designer.cs b/Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.Designer.cs new file mode 100644 index 00000000..6b189b36 --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.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("20260118005634_AddedThemes")] + partial class AddedThemes + { + /// + 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("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + 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/20260118005634_AddedThemes.cs b/Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.cs new file mode 100644 index 00000000..cf7a5b4c --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260118005634_AddedThemes.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + /// + public partial class AddedThemes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Themes", + schema: "core", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Version = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Author = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + CssContent = table.Column(type: "character varying(20000)", maxLength: 20000, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Themes", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Themes", + schema: "core"); + } + } +} diff --git a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs index ff2a5ef4..57359491 100644 --- a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs +++ b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs @@ -146,6 +146,42 @@ namespace Moonlight.Api.Database.Migrations 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") diff --git a/Moonlight.Api/Http/Controllers/Admin/ThemesController.cs b/Moonlight.Api/Http/Controllers/Admin/ThemesController.cs new file mode 100644 index 00000000..0c46ae39 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/ThemesController.cs @@ -0,0 +1,145 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moonlight.Api.Database; +using Moonlight.Api.Database.Entities; +using Moonlight.Api.Mappers; +using Moonlight.Api.Services; +using Moonlight.Shared; +using Moonlight.Shared.Http.Requests; +using Moonlight.Shared.Http.Requests.Themes; +using Moonlight.Shared.Http.Responses; +using Moonlight.Shared.Http.Responses.Themes; + +namespace Moonlight.Api.Http.Controllers.Admin; + +[ApiController] +[Route("api/admin/themes")] +public class ThemesController : Controller +{ + private readonly DatabaseRepository ThemeRepository; + private readonly FrontendService FrontendService; + + public ThemesController(DatabaseRepository themeRepository, FrontendService frontendService) + { + ThemeRepository = themeRepository; + FrontendService = frontendService; + } + + [HttpGet] + [Authorize(Policy = Permissions.Themes.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 = ThemeRepository + .Query(); + + // Filters + if (filterOptions != null) + { + foreach (var filterOption in filterOptions.Filters) + { + query = filterOption.Key switch + { + nameof(Theme.Name) => + query.Where(user => EF.Functions.ILike(user.Name, $"%{filterOption.Value}%")), + + nameof(Theme.Version) => + query.Where(user => EF.Functions.ILike(user.Version, $"%{filterOption.Value}%")), + + nameof(Theme.Author) => + query.Where(user => EF.Functions.ILike(user.Author, $"%{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.Themes.View)] + public async Task> GetAsync([FromRoute] int id) + { + var item = await ThemeRepository + .Query() + .FirstOrDefaultAsync(x => x.Id == id); + + if (item == null) + return Problem("No theme with this id", statusCode: 404); + + return ThemeMapper.ToDto(item); + } + + [HttpPost] + [Authorize(Policy = Permissions.Themes.Create)] + public async Task> CreateAsync([FromBody] CreateThemeDto request) + { + var theme = ThemeMapper.ToEntity(request); + + var finalTheme = await ThemeRepository.AddAsync(theme); + await FrontendService.ResetCacheAsync(); + + return ThemeMapper.ToDto(finalTheme); + } + + [HttpPatch("{id:int}")] + [Authorize(Policy = Permissions.Themes.Edit)] + public async Task> UpdateAsync([FromRoute] int id, [FromBody] UpdateThemeDto request) + { + var theme = await ThemeRepository + .Query() + .FirstOrDefaultAsync(x => x.Id == id); + + if (theme == null) + return Problem("No theme with this id found", statusCode: 404); + + ThemeMapper.Merge(theme, request); + await ThemeRepository.UpdateAsync(theme); + + await FrontendService.ResetCacheAsync(); + + return ThemeMapper.ToDto(theme); + } + + [HttpDelete("{id:int}")] + [Authorize(Policy = Permissions.Themes.Delete)] + public async Task DeleteAsync([FromRoute] int id) + { + var theme = await ThemeRepository + .Query() + .FirstOrDefaultAsync(x => x.Id == id); + + if (theme == null) + return Problem("No theme with this id found", statusCode: 404); + + await ThemeRepository.RemoveAsync(theme); + + await FrontendService.ResetCacheAsync(); + + return NoContent(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/FrontendController.cs b/Moonlight.Api/Http/Controllers/FrontendController.cs new file mode 100644 index 00000000..cdf201ee --- /dev/null +++ b/Moonlight.Api/Http/Controllers/FrontendController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using Moonlight.Api.Mappers; +using Moonlight.Api.Services; +using Moonlight.Shared.Http.Responses.Frontend; + +namespace Moonlight.Api.Http.Controllers; + +[ApiController] +[Route("api/frontend")] +public class FrontendController : Controller +{ + private readonly FrontendService FrontendService; + + public FrontendController(FrontendService frontendService) + { + FrontendService = frontendService; + } + + [HttpGet("config")] + public async Task> GetConfigAsync() + { + var configuration = await FrontendService.GetConfigurationAsync(); + return FrontendConfigMapper.ToDto(configuration); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Mappers/FrontendConfigMapper.cs b/Moonlight.Api/Mappers/FrontendConfigMapper.cs new file mode 100644 index 00000000..85118685 --- /dev/null +++ b/Moonlight.Api/Mappers/FrontendConfigMapper.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; +using Moonlight.Api.Models; +using Moonlight.Shared.Http.Responses.Frontend; +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 FrontendConfigMapper +{ + public static partial FrontendConfigDto ToDto(FrontendConfiguration configuration); +} \ No newline at end of file diff --git a/Moonlight.Api/Mappers/ThemeMapper.cs b/Moonlight.Api/Mappers/ThemeMapper.cs new file mode 100644 index 00000000..ad266092 --- /dev/null +++ b/Moonlight.Api/Mappers/ThemeMapper.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; +using Moonlight.Api.Database.Entities; +using Moonlight.Shared.Http.Requests.Themes; +using Moonlight.Shared.Http.Responses.Themes; +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 ThemeMapper +{ + public static partial IQueryable ProjectToDto(this IQueryable themes); + + public static partial ThemeDto ToDto(Theme theme); + + public static partial void Merge([MappingTarget] Theme theme, UpdateThemeDto request); + + public static partial Theme ToEntity(CreateThemeDto request); +} \ No newline at end of file diff --git a/Moonlight.Api/Models/FrontendConfiguration.cs b/Moonlight.Api/Models/FrontendConfiguration.cs new file mode 100644 index 00000000..853bf545 --- /dev/null +++ b/Moonlight.Api/Models/FrontendConfiguration.cs @@ -0,0 +1,3 @@ +namespace Moonlight.Api.Models; + +public record FrontendConfiguration(string Name, string? ThemeCss); \ No newline at end of file diff --git a/Moonlight.Api/Moonlight.Api.csproj b/Moonlight.Api/Moonlight.Api.csproj index 57440d3d..67333982 100644 --- a/Moonlight.Api/Moonlight.Api.csproj +++ b/Moonlight.Api/Moonlight.Api.csproj @@ -31,7 +31,7 @@ - + diff --git a/Moonlight.Api/Services/FrontendService.cs b/Moonlight.Api/Services/FrontendService.cs new file mode 100644 index 00000000..11fd625a --- /dev/null +++ b/Moonlight.Api/Services/FrontendService.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Moonlight.Api.Configuration; +using Moonlight.Api.Database; +using Moonlight.Api.Database.Entities; +using Moonlight.Api.Models; + +namespace Moonlight.Api.Services; + +public class FrontendService +{ + private readonly IMemoryCache Cache; + private readonly DatabaseRepository ThemeRepository; + private readonly IOptions Options; + + private const string CacheKey = $"Moonlight.{nameof(FrontendService)}.{nameof(GetConfigurationAsync)}"; + + public FrontendService(IMemoryCache cache, DatabaseRepository themeRepository, IOptions options) + { + Cache = cache; + ThemeRepository = themeRepository; + Options = options; + } + + public async Task GetConfigurationAsync() + { + if (Cache.TryGetValue(CacheKey, out FrontendConfiguration? value)) + { + if (value != null) + return value; + } + + var theme = await ThemeRepository + .Query() + .FirstOrDefaultAsync(x => x.IsEnabled); + + var config = new FrontendConfiguration("Moonlight", theme?.CssContent); + + Cache.Set(CacheKey, config, TimeSpan.FromMinutes(Options.Value.CacheMinutes)); + + return config; + } + + public Task ResetCacheAsync() + { + Cache.Remove(CacheKey); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Base.cs b/Moonlight.Api/Startup/Startup.Base.cs index b97b9dad..147f6a48 100644 --- a/Moonlight.Api/Startup/Startup.Base.cs +++ b/Moonlight.Api/Startup/Startup.Base.cs @@ -2,11 +2,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Console; +using Microsoft.Extensions.Options; +using Moonlight.Api.Configuration; using Moonlight.Shared.Http; using Moonlight.Api.Helpers; using Moonlight.Api.Implementations; using Moonlight.Api.Interfaces; using Moonlight.Api.Services; +using SessionOptions = Microsoft.AspNetCore.Builder.SessionOptions; namespace Moonlight.Api.Startup; @@ -32,6 +35,9 @@ public partial class Startup builder.Services.AddMemoryCache(); builder.Services.AddOptions().BindConfiguration("Moonlight:Session"); + + builder.Services.AddOptions().BindConfiguration("Moonlight:Frontend"); + builder.Services.AddScoped(); } private static void UseBase(WebApplication application) @@ -43,6 +49,9 @@ public partial class Startup { application.MapControllers(); - application.MapFallbackToFile("index.html"); + var options = application.Services.GetRequiredService>(); + + if(options.Value.Enabled) + application.MapFallbackToFile("index.html"); } } \ No newline at end of file diff --git a/Moonlight.Frontend/Implementations/PermissionProvider.cs b/Moonlight.Frontend/Implementations/PermissionProvider.cs index 14735bee..83a3a93f 100644 --- a/Moonlight.Frontend/Implementations/PermissionProvider.cs +++ b/Moonlight.Frontend/Implementations/PermissionProvider.cs @@ -34,6 +34,12 @@ public sealed class PermissionProvider : IPermissionProvider new Permission(Permissions.ApiKeys.Edit, "Edit", "Edit API key details"), new Permission(Permissions.ApiKeys.Delete, "Delete", "Delete API keys"), ]), + new PermissionCategory("Themes", typeof(PaintRollerIcon), [ + new Permission(Permissions.Themes.Create, "Create", "Create new theme"), + new Permission(Permissions.Themes.View, "View", "View all themes"), + new Permission(Permissions.Themes.Edit, "Edit", "Edit themes"), + new Permission(Permissions.Themes.Delete, "Delete", "Delete themes"), + ]), ]); } } \ No newline at end of file diff --git a/Moonlight.Frontend/Mappers/ThemeMapper.cs b/Moonlight.Frontend/Mappers/ThemeMapper.cs new file mode 100644 index 00000000..8ddff698 --- /dev/null +++ b/Moonlight.Frontend/Mappers/ThemeMapper.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; +using Moonlight.Shared.Http.Requests.Themes; +using Moonlight.Shared.Http.Responses.Themes; +using Riok.Mapperly.Abstractions; + +namespace Moonlight.Frontend.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 partial class ThemeMapper +{ + public static partial UpdateThemeDto ToUpdate(ThemeDto theme); +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor index 3c0b76de..6bc70e12 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor @@ -13,22 +13,26 @@ @inject NavigationManager Navigation @inject IAuthorizationService AuthorizationService - + - - - Customization + + + Settings + + + + Themes - + API & API Keys - + Diagnose - +
@@ -55,6 +59,12 @@ } + @if (ThemesAccess.Succeeded) + { + + + + } @code @@ -66,12 +76,14 @@ [CascadingParameter] public Task AuthState { get; set; } private AuthorizationResult ApiKeyAccess; + private AuthorizationResult ThemesAccess; protected override async Task OnInitializedAsync() { var authState = await AuthState; ApiKeyAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.View); + ThemesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.View); } private void OnTabChanged(string name) diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Create.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Create.razor new file mode 100644 index 00000000..66b3a14a --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Create.razor @@ -0,0 +1,134 @@ +@page "/admin/system/themes/create" + +@using Microsoft.AspNetCore.Authorization +@using Moonlight.Shared +@using LucideBlazor +@using Moonlight.Shared.Http.Requests.Themes +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Labels +@using ShadcnBlazor.Cards +@using ShadcnBlazor.Checkboxes +@using ShadcnBlazor.Extras.Editors +@using ShadcnBlazor.Extras.FormHandlers +@using ShadcnBlazor.Extras.Toasts +@using ShadcnBlazor.Inputs + +@attribute [Authorize(Policy = Permissions.Themes.Create)] + +@inject HttpClient HttpClient +@inject NavigationManager Navigation +@inject ToastService ToastService + +
+
+

Create theme

+
+ Create a new theme +
+
+
+ + +
+
+ +
+ + + +
+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+ + +
+
+
+
+
+
+ +@code +{ + private CreateThemeDto Request = new() + { + CssContent = "/* Define your css here */" + }; + + private FormHandler Form; + private Editor Editor; + + private async Task SubmitAsync() + { + Request.CssContent = await Editor.GetValueAsync(); + await Form.SubmitAsync(); + } + + private async Task OnSubmitAsync() + { + await HttpClient.PostAsJsonAsync( + "/api/admin/themes", + Request, + Constants.SerializerOptions + ); + + await ToastService.SuccessAsync( + "Theme creation", + $"Successfully created theme {Request.Name}" + ); + + Navigation.NavigateTo("/admin/system?tab=themes"); + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Index.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Index.razor new file mode 100644 index 00000000..17208173 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Index.razor @@ -0,0 +1,148 @@ +@using LucideBlazor +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Moonlight.Shared +@using Moonlight.Shared.Http.Requests +@using Moonlight.Shared.Http.Responses +@using Moonlight.Shared.Http.Responses.Themes +@using ShadcnBlazor.DataGrids +@using ShadcnBlazor.Dropdowns +@using ShadcnBlazor.Extras.AlertDialogs +@using ShadcnBlazor.Tabels +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Extras.Toasts + +@inject ToastService ToastService +@inject NavigationManager Navigation +@inject AlertDialogService AlertDialogService +@inject IAuthorizationService AuthorizationService +@inject HttpClient HttpClient + +
+
+

Themes

+
+ Manage themes for your instance +
+
+
+ +
+
+ +
+ + + + + + + @context.Name + + @if (context.IsEnabled) + { + + + + } + + + + + + + + + +
+ + + + + + + + + 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.Themes.Edit); + DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.Delete); + CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.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/themes{query}&filterOptions={filterOptions}", + Constants.SerializerOptions + ); + + return new DataGridResponse(response!.Data, response.TotalLength); + } + + private void CreateAsync() => Navigation.NavigateTo("/admin/system/themes/create"); + + private void EditAsync(ThemeDto theme) => Navigation.NavigateTo($"/admin/system/themes/{theme.Id}"); + + private async Task DeleteAsync(ThemeDto theme) + { + await AlertDialogService.ConfirmDangerAsync( + $"Deletion of theme {theme.Name}", + "Do you really want to delete this theme? This action cannot be undone", + async () => + { + var response = await HttpClient.DeleteAsync($"api/admin/themes/{theme.Id}"); + response.EnsureSuccessStatusCode(); + + await ToastService.SuccessAsync("Theme deletion", $"Successfully deleted theme {theme.Name}"); + + await Grid.RefreshAsync(); + } + ); + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Update.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Update.razor new file mode 100644 index 00000000..df0b4a33 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Themes/Update.razor @@ -0,0 +1,147 @@ +@page "/admin/system/themes/{Id:int}" + +@using Microsoft.AspNetCore.Authorization +@using Moonlight.Shared +@using LucideBlazor +@using Moonlight.Frontend.Mappers +@using Moonlight.Shared.Http.Requests.Themes +@using Moonlight.Shared.Http.Responses.Themes +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Labels +@using ShadcnBlazor.Cards +@using ShadcnBlazor.Checkboxes +@using ShadcnBlazor.Extras.Common +@using ShadcnBlazor.Extras.Editors +@using ShadcnBlazor.Extras.FormHandlers +@using ShadcnBlazor.Extras.Toasts +@using ShadcnBlazor.Inputs + +@attribute [Authorize(Policy = Permissions.Themes.Edit)] + +@inject HttpClient HttpClient +@inject NavigationManager Navigation +@inject ToastService ToastService + +
+
+

Update theme

+
+ Update the theme +
+
+
+ + +
+
+ +
+ + + + +
+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+ + +
+
+
+
+
+
+
+ +@code +{ + [Parameter] public int Id { get; set; } + + private UpdateThemeDto Request; + private ThemeDto Theme; + + private FormHandler Form; + private Editor Editor; + + private async Task LoadAsync(LazyLoader _) + { + var theme = await HttpClient.GetFromJsonAsync($"api/admin/themes/{Id}"); + + Theme = theme!; + Request = ThemeMapper.ToUpdate(Theme); + } + + private async Task SubmitAsync() + { + Request.CssContent = await Editor.GetValueAsync(); + await Form.SubmitAsync(); + } + + private async Task OnSubmitAsync() + { + await HttpClient.PatchAsJsonAsync( + $"/api/admin/themes/{Theme.Id}", + Request, + Constants.SerializerOptions + ); + + await ToastService.SuccessAsync( + "Theme update", + $"Successfully updated theme {Request.Name}" + ); + + Navigation.NavigateTo("/admin/system?tab=themes"); + } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Themes/CreateThemeDto.cs b/Moonlight.Shared/Http/Requests/Themes/CreateThemeDto.cs new file mode 100644 index 00000000..01ebf5b0 --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Themes/CreateThemeDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.Themes; + +public class CreateThemeDto +{ + [Required] + [MaxLength(30)] + public string Name { get; set; } + + [Required] + [MaxLength(30)] + public string Version { get; set; } + + [Required] + [MaxLength(30)] + public string Author { get; set; } + public bool IsEnabled { get; set; } + + [Required] + [MaxLength(20_000)] + public string CssContent { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Themes/UpdateThemeDto.cs b/Moonlight.Shared/Http/Requests/Themes/UpdateThemeDto.cs new file mode 100644 index 00000000..0d22d223 --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Themes/UpdateThemeDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.Themes; + +public class UpdateThemeDto +{ + [Required] + [MaxLength(30)] + public string Name { get; set; } + + [Required] + [MaxLength(30)] + public string Version { get; set; } + + [Required] + [MaxLength(30)] + public string Author { get; set; } + public bool IsEnabled { get; set; } + + [Required] + [MaxLength(20_000)] + public string CssContent { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Frontend/FrontendConfigDto.cs b/Moonlight.Shared/Http/Responses/Frontend/FrontendConfigDto.cs new file mode 100644 index 00000000..747e5d39 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Frontend/FrontendConfigDto.cs @@ -0,0 +1,3 @@ +namespace Moonlight.Shared.Http.Responses.Frontend; + +public record FrontendConfigDto(string Name, string? ThemeCss); \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Themes/ThemeDto.cs b/Moonlight.Shared/Http/Responses/Themes/ThemeDto.cs new file mode 100644 index 00000000..adc167dd --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Themes/ThemeDto.cs @@ -0,0 +1,3 @@ +namespace Moonlight.Shared.Http.Responses.Themes; + +public record ThemeDto(int Id, string Name, string Author, string Version, string CssContent, bool IsEnabled); \ No newline at end of file diff --git a/Moonlight.Shared/Http/SerializationContext.cs b/Moonlight.Shared/Http/SerializationContext.cs index 2de40ed1..e3739a64 100644 --- a/Moonlight.Shared/Http/SerializationContext.cs +++ b/Moonlight.Shared/Http/SerializationContext.cs @@ -1,32 +1,48 @@ using System.Text.Json.Serialization; using Moonlight.Shared.Http.Requests.ApiKeys; using Moonlight.Shared.Http.Requests.Roles; +using Moonlight.Shared.Http.Requests.Themes; using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.ApiKeys; using Moonlight.Shared.Http.Responses.Auth; +using Moonlight.Shared.Http.Responses.Themes; using Moonlight.Shared.Http.Responses.Users; namespace Moonlight.Shared.Http; +// Users [JsonSerializable(typeof(CreateUserDto))] [JsonSerializable(typeof(UpdateUserDto))] +[JsonSerializable(typeof(PagedData))] +[JsonSerializable(typeof(UserDto))] + +// Auth [JsonSerializable(typeof(ClaimDto[]))] [JsonSerializable(typeof(SchemeDto[]))] + +// System [JsonSerializable(typeof(DiagnoseResultDto[]))] -[JsonSerializable(typeof(UserDto))] [JsonSerializable(typeof(SystemInfoDto))] -[JsonSerializable(typeof(PagedData))] -[JsonSerializable(typeof(PagedData))] -[JsonSerializable(typeof(RoleDto))] + +// Roles [JsonSerializable(typeof(CreateRoleDto))] [JsonSerializable(typeof(UpdateRoleDto))] +[JsonSerializable(typeof(PagedData))] +[JsonSerializable(typeof(RoleDto))] + +// API Keys [JsonSerializable(typeof(CreateApiKeyDto))] [JsonSerializable(typeof(UpdateApiKeyDto))] -[JsonSerializable(typeof(UpdateApiKeyDto))] [JsonSerializable(typeof(PagedData))] [JsonSerializable(typeof(ApiKeyDto))] + +// Themes +[JsonSerializable(typeof(CreateThemeDto))] +[JsonSerializable(typeof(UpdateThemeDto))] +[JsonSerializable(typeof(PagedData))] +[JsonSerializable(typeof(ThemeDto))] public partial class SerializationContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Moonlight.Shared/Permissions.cs b/Moonlight.Shared/Permissions.cs index 2d96d25d..aa565ca2 100644 --- a/Moonlight.Shared/Permissions.cs +++ b/Moonlight.Shared/Permissions.cs @@ -37,6 +37,16 @@ public static class Permissions public const string Members = $"{Prefix}{Section}.{nameof(Members)}"; } + public static class Themes + { + private const string Section = "Themes"; + + 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)}"; + } + public static class System { private const string Section = "System";