diff --git a/Moonlight.Api/Configuration/SettingsOptions.cs b/Moonlight.Api/Configuration/SettingsOptions.cs new file mode 100644 index 00000000..6ba1021e --- /dev/null +++ b/Moonlight.Api/Configuration/SettingsOptions.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Api.Configuration; + +public class SettingsOptions +{ + public int CacheMinutes { get; set; } = 3; +} \ No newline at end of file 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 new file mode 100644 index 00000000..eb1bb8c5 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/SetupController.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moonlight.Api.Database; +using Moonlight.Api.Database.Entities; +using Moonlight.Api.Services; +using Moonlight.Shared; +using Moonlight.Shared.Http.Requests.Seup; + +namespace Moonlight.Api.Http.Controllers.Admin; + +[ApiController] +[Route("api/admin/setup")] +public class SetupController : Controller +{ + private readonly SettingsService SettingsService; + private readonly DatabaseRepository UsersRepository; + private readonly DatabaseRepository RolesRepository; + + private const string StateSettingsKey = "Moonlight.Api.Setup.State"; + + public SetupController( + SettingsService settingsService, + DatabaseRepository usersRepository, + DatabaseRepository rolesRepository + ) + { + SettingsService = settingsService; + UsersRepository = usersRepository; + RolesRepository = rolesRepository; + } + + [HttpGet] + [AllowAnonymous] + public async Task GetSetupAsync() + { + var hasBeenSetup = await SettingsService.GetValueAsync(StateSettingsKey); + + if (hasBeenSetup) + return Problem("This instance is already configured", statusCode: 405); + + return NoContent(); + } + + [HttpPost] + [AllowAnonymous] + public async Task ApplySetupAsync([FromBody] ApplySetupDto dto) + { + var adminRole = await RolesRepository + .Query() + .FirstOrDefaultAsync(x => x.Name == "Administrators"); + + if (adminRole == null) + { + adminRole = await RolesRepository.AddAsync(new Role() + { + Name = "Administrators", + Description = "Automatically generated group for full administrator permissions", + Permissions = [ + Permissions.ApiKeys.View, + Permissions.ApiKeys.Create, + Permissions.ApiKeys.Edit, + Permissions.ApiKeys.Delete, + + Permissions.Roles.View, + Permissions.Roles.Create, + Permissions.Roles.Edit, + Permissions.Roles.Delete, + Permissions.Roles.Members, + + Permissions.Users.View, + Permissions.Users.Create, + Permissions.Users.Edit, + Permissions.Users.Delete, + Permissions.Users.Logout, + + Permissions.Themes.View, + Permissions.Themes.Create, + Permissions.Themes.Edit, + Permissions.Themes.Delete, + + Permissions.System.Info, + Permissions.System.Diagnose, + ] + }); + } + + + var user = await UsersRepository + .Query() + .FirstOrDefaultAsync(u => u.Email == dto.AdminEmail); + + if (user == null) + { + await UsersRepository.AddAsync(new User() + { + Email = dto.AdminEmail, + Username = dto.AdminUsername, + RoleMemberships = [ + new RoleMember() + { + Role = adminRole, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + } + ], + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + } + else + { + user.RoleMemberships.Add(new RoleMember() + { + Role = adminRole, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + + await UsersRepository.UpdateAsync(user); + } + + await SettingsService.SetValueAsync(StateSettingsKey, true); + + return NoContent(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Services/SettingsService.cs b/Moonlight.Api/Services/SettingsService.cs new file mode 100644 index 00000000..f6361fbb --- /dev/null +++ b/Moonlight.Api/Services/SettingsService.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +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; + +namespace Moonlight.Api.Services; + +public class SettingsService +{ + private readonly DatabaseRepository Repository; + private readonly IOptions Options; + private readonly IMemoryCache Cache; + + private const string CacheKey = "Moonlight.Api.SettingsService.{0}"; + + public SettingsService( + DatabaseRepository repository, + IOptions options, + IMemoryCache cache + ) + { + Repository = repository; + Cache = cache; + Options = options; + } + + public async Task GetValueAsync(string key) + { + var cacheKey = string.Format(CacheKey, key); + + 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, + value, + TimeSpan.FromMinutes(Options.Value.CacheMinutes) + ); + + return JsonSerializer.Deserialize(value); + } + + public async Task SetValueAsync(string key, T value) + { + var cacheKey = string.Format(CacheKey, key); + + var option = await Repository + .Query() + .FirstOrDefaultAsync(x => x.Key == key); + + var json = JsonSerializer.Serialize(value); + + if (option != null) + { + option.ValueJson = json; + await Repository.UpdateAsync(option); + } + else + { + option = new SettingsOption() + { + Key = key, + ValueJson = json + }; + + await Repository.AddAsync(option); + } + + Cache.Remove(cacheKey); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Auth.cs b/Moonlight.Api/Startup/Startup.Auth.cs index fba19bd3..03560c09 100644 --- a/Moonlight.Api/Startup/Startup.Auth.cs +++ b/Moonlight.Api/Startup/Startup.Auth.cs @@ -100,6 +100,9 @@ public partial class Startup builder.Services.AddSingleton(); builder.Services.AddSingleton(); + + builder.Services.AddOptions().BindConfiguration("Moonlight:Settings"); + builder.Services.AddScoped(); } private static void UseAuth(WebApplication application) diff --git a/Moonlight.Frontend/UI/App.razor b/Moonlight.Frontend/UI/App.razor index f9c663dd..edadd0bd 100644 --- a/Moonlight.Frontend/UI/App.razor +++ b/Moonlight.Frontend/UI/App.razor @@ -2,10 +2,13 @@ @using LucideBlazor @using Microsoft.AspNetCore.Components.Authorization @using Moonlight.Frontend.UI.Shared +@using Moonlight.Frontend.UI.Shared.Components @using ShadcnBlazor.Emptys @using Moonlight.Frontend.UI.Shared.Components.Auth @using Moonlight.Frontend.UI.Shared.Partials +@inject NavigationManager Navigation + @@ -30,33 +33,42 @@ } else { - + var uri = new Uri(Navigation.Uri); + + if (uri.LocalPath.StartsWith("/setup")) + { + + } + else + { + + } } - @if (context is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized }) - { - - } - else - { -
- - - - - - - Critical Application Error - - - @context.ToString() - - - -
- } + @if (context is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized }) + { + + } + else + { +
+ + + + + + + Critical Application Error + + + @context.ToString() + + + +
+ }
\ No newline at end of file diff --git a/Moonlight.Frontend/UI/Shared/Components/Setup.razor b/Moonlight.Frontend/UI/Shared/Components/Setup.razor new file mode 100644 index 00000000..20fc8365 --- /dev/null +++ b/Moonlight.Frontend/UI/Shared/Components/Setup.razor @@ -0,0 +1,172 @@ +@using LucideBlazor +@using Moonlight.Shared.Http.Requests.Seup +@using ShadcnBlazor.Cards +@using ShadcnBlazor.Spinners +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Inputs +@using ShadcnBlazor.Labels + +@inject HttpClient HttpClient +@inject NavigationManager Navigation + +
+ + @if (IsLoaded) + { +
+ @if (CurrentStep == 0) + { +
+ Moonlight Logo +
+ +
+

Welcome to Moonlight Panel

+

+ You successfully installed moonlight. Now you are ready to perform some initial steps to complete your installation +

+
+ } + else if (CurrentStep == 1) + { +
+ +
+ +
+

Admin Account Creation

+

+ To continue please fill in the account details of the user you want to use as the initial administrator account. + If you use an external OIDC provider, these details need to match with your desired OIDC account +

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ } + else if (CurrentStep == 2) + { +
+ +
+ +
+

You are all set!

+

+ You are now ready to finish the initial setup. + The configured options will be applied to the instance. + You will be redirected to the login after changes have been applied successfully +

+
+ } +
+
+ @for (var step = 0; step < StepCount; step++) + { + if (step == CurrentStep) + { +
+
+ } + else + { +
+
+ } + } +
+
+ @if (CurrentStep > 0) + { + + } + @if (CurrentStep != StepCount - 1) + { + + } + else + { + + } +
+
+
+ } + else + { +
+ +
+ } +
+
+ +@code +{ + private bool IsLoaded; + + private int CurrentStep = 0; + private int StepCount = 3; + + private ApplySetupDto SetupDto = new(); + + private void Navigate(int diff) => CurrentStep += diff; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if(!firstRender) + return; + + var response = await HttpClient.GetAsync("api/admin/setup"); + + if (!response.IsSuccessStatusCode) + { + Navigation.NavigateTo("/", true); + return; + } + + IsLoaded = true; + await InvokeAsync(StateHasChanged); + } + + private async Task ApplyAsync() + { + await HttpClient.PostAsJsonAsync("api/admin/setup", SetupDto); + Navigation.NavigateTo("/", true); + } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Seup/ApplySetupDto.cs b/Moonlight.Shared/Http/Requests/Seup/ApplySetupDto.cs new file mode 100644 index 00000000..28c29239 --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Seup/ApplySetupDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.Seup; + +public class ApplySetupDto +{ + [Required] + [MinLength(3)] + [MaxLength(32)] + public string AdminUsername { get; set; } + + [Required] + [EmailAddress] + public string AdminEmail { get; set; } + + [Required] + [MinLength(8)] + [MaxLength(64)] + public string AdminPassword { get; set; } +} \ No newline at end of file