44 Commits

Author SHA1 Message Date
3dff8c8f6d Fixed IL trimming removing used icons from build output in system settings tab 2026-02-21 22:46:34 +01:00
95a848e571 Merge pull request 'Implemented extendable system settings tab. Started implementing white labeling settings' (#21) from feat/SystemSettings into v2.1
Reviewed-on: #21
2026-02-21 21:22:06 +00:00
9d557eea4e Implemented extendable system settings tab. Started implementing white labeling settings 2026-02-21 22:20:51 +01:00
94c1aac0ac Merge pull request 'Added plugins hooks for layout related options' (#20) from feat/LayoutMiddleware into v2.1
Reviewed-on: #20
2026-02-20 15:28:24 +00:00
3bddd64d91 Added page hooks for main layout 2026-02-20 16:25:01 +01:00
5ad7a6db7b Added hook option for plugins to inject into the main layout before the router 2026-02-20 12:28:22 +01:00
9b9272cd6e Fixed nuget package build failing after changing shadcnblazor class list location 2026-02-20 09:41:35 +01:00
31cf34ed04 Merge pull request 'Improved css build and initialization of frontend plugins' (#19) from feat/PluginImprovements into v2.1
Some checks failed
Dev Publish: Nuget / Publish Dev Packages (push) Failing after 40s
Reviewed-on: #19
2026-02-20 08:38:27 +00:00
a9b0020131 Upgraded to shadcnblazor 1.0.13. Added transitive mapping copying and prefixed target to stop any collisions 2026-02-20 09:35:43 +01:00
e3b432aae6 Removed unused startup interface. Added plugin list to frontend plugin initialization 2026-02-20 09:20:29 +01:00
06f27605ba Merge pull request 'Switched from self created static constant json options to a source generator options' (#18) from feat/ImproveJsonSerialization into v2.1
Reviewed-on: #18
2026-02-19 07:50:03 +00:00
0bd138df63 Switched from self created static constant json options to a source generator options 2026-02-19 08:49:23 +01:00
d7b725f541 Merge pull request 'Improved plugin loading and handling' (#17) from feat/ImprovePluginLoading into v2.1
All checks were successful
Dev Publish: Nuget / Publish Dev Packages (push) Successful in 51s
Reviewed-on: #17
2026-02-19 07:36:06 +00:00
0f26aaf803 Added options for navigation assemblies for the router 2026-02-19 08:32:32 +01:00
c45e177001 Improved handling of moonlight plugins during startup, minimized host project code and moved startup handling to core 2026-02-18 15:36:45 +01:00
627e9bb161 Merge pull request 'Switched to SimplePlugin plugin loader' (#16) from feat/SwitchPluginLoader into v2.1
All checks were successful
Dev Publish: Nuget / Publish Dev Packages (push) Successful in 50s
Reviewed-on: #16
2026-02-18 12:57:22 +00:00
1fc33ebf03 Switched to SimplePlugin plugin loader 2026-02-18 13:21:15 +01:00
64e4d7201e Removed test code from ApiKeySchemeHandler 2026-02-14 15:31:47 +01:00
816aa01319 Implemented plugin referencing. Added healthcheck and custom base docker image 2026-02-13 08:38:33 +01:00
5627e78843 Merge pull request 'Implemented theme import and export' (#15) from feat/ThemeExportImport into v2.1
All checks were successful
Dev Publish: Nuget / Publish Dev Packages (push) Successful in 50s
Reviewed-on: #15
2026-02-12 14:47:50 +00:00
795cec149f Switched from nodejs to bun for building tailwindcss 2026-02-12 15:46:05 +01:00
83fcb4a921 Merge pull request 'Implemented hybrid cache with redis support' (#14) from feat/HybridCache into v2.1
All checks were successful
Dev Publish: Nuget / Publish Dev Packages (push) Successful in 53s
Reviewed-on: #14
2026-02-12 14:30:21 +00:00
741a60adc6 Implemented hybrid cache for user sessions, api keys and database provided settings. Cleaned up startup and adjusted caching option models for features 2026-02-12 15:29:35 +01:00
6f941a220c Implemented theme import and export 2026-02-12 11:09:38 +01:00
dd44e5bb86 Merge pull request 'Added api key expiry' (#13) from feat/ApiKeys into v2.1
Reviewed-on: #13
2026-02-12 09:11:45 +00:00
7b38662f8f Added cache key format for api key validation 2026-02-12 09:59:47 +01:00
6d854d82d3 Made username table cell clickable to open edit screen and removed the unnecessary text-left 2026-02-11 20:46:59 +01:00
ac1c28d20d Implemented user logout and deletion service. Added Auth, Deletion and Logout hook. Restructed controllers 2026-02-11 20:46:53 +01:00
5efe591f85 Started implementing api key expiration 2026-02-09 16:12:11 +01:00
4daf986f3e Added option for oidc to disable https only cookies for deployments using an ip 2026-02-09 12:22:03 +01:00
cc7f55c988 Added versions and instance permissions to default admin perms 2026-02-09 12:03:29 +01:00
11a2f9818a Added buns lockfile to gitignore 2026-02-09 12:02:58 +01:00
6a151394a7 Added type hints in display name of version selector 2026-02-09 10:27:36 +01:00
178ac5ac20 Added permissions for container helper. Updated rebuild version selection to fetch the available versions from moonlights version api 2026-02-09 09:14:38 +01:00
91944a5ef6 Updated setup page to use the latest shadcnblazor input fields 2026-02-09 08:22:58 +01:00
affdadf3aa Merge branch 'feat/ContainerHelper' into v2.1
Some checks failed
Dev Publish: Nuget / Publish Dev Packages (push) Failing after 31s
# Conflicts:
#	Moonlight.Api/Services/ApplicationService.cs
#	Moonlight.Api/Startup/Startup.Base.cs
#	Moonlight.Shared/Http/SerializationContext.cs
2026-02-09 08:18:56 +01:00
8d9a7bb8b3 Added validation to setup dto 2026-02-09 06:51:38 +00:00
1f631be1c7 Updated settings service to support generic types with JSON serialization/deserialization, adjusted setup wizard checks and modified database schema for proper JSONB storage. 2026-02-09 06:51:38 +00:00
5b4959771c Started implementing setup wizard backend for initial instance configuration. Adjusted ui 2026-02-09 06:51:38 +00:00
b8e1bbb28c Added setup wizard component for initial installation flow and integrated it into app routing. 2026-02-09 06:51:38 +00:00
c8fe11bd2b Implemented version fetching from source control git server. Added self version detection and update checks 2026-02-01 14:47:32 +01:00
09b11cc4ad Improved update instance model text design 2026-01-29 14:07:02 +01:00
660319afec Renamed SharedSerializationContext to SerializationContext. Added error handling and no build cache functionality 2026-01-29 13:59:24 +01:00
8181404f0c Moved request and responses dtos to correct namespace 2026-01-29 12:45:09 +01:00
142 changed files with 2891 additions and 577 deletions

View File

@@ -34,7 +34,7 @@ jobs:
# Publish frontend # Publish frontend
# We need to build it first so the class list files generate # We need to build it first so the class list files generate
- name: Build project - name: Build project
run: dotnet build Moonlight.Frontend --configuration Debug run: dotnet build Hosts/Moonlight.Frontend.Host --configuration Debug
- name: Build tailwind styles and extract class list - name: Build tailwind styles and extract class list
working-directory: Hosts/Moonlight.Frontend.Host/Styles working-directory: Hosts/Moonlight.Frontend.Host/Styles

2
.gitignore vendored
View File

@@ -400,8 +400,10 @@ FodyWeavers.xsd
# Style builds # Style builds
**/style.min.css **/style.min.css
**/package-lock.json **/package-lock.json
**/bun.lock
# Secrets # Secrets
**/.env **/.env
**/appsettings.json **/appsettings.json
**/appsettings.Development.json **/appsettings.Development.json
**/storage

View File

@@ -0,0 +1,6 @@
<Project>
<ItemGroup>
<!-- Put your plugin references here -->
<!-- E.g. <PackageReference Include="MoonlightServers.Api" Version="2.1.0" /> -->
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
using MoonCore.PluginFramework;
using Moonlight.Api.Startup;
namespace Moonlight.Api.Host;
[PluginLoader]
public partial class AppStartupLoader : IAppStartup
{
}

View File

@@ -1,11 +1,13 @@
# Base image # Base image
FROM cgr.dev/chainguard/aspnet-runtime:latest AS base FROM git.battlestati.one/moonlight-panel/app_base:moonlight AS base
WORKDIR /app WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# Build dependencies # Install required packages
RUN apt-get update; apt-get install nodejs npm -y; apt-get clean RUN apt-get update; apt-get install unzip -y; apt-get clean
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
# Build options # Build options
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
@@ -15,7 +17,7 @@ WORKDIR /src/Hosts/Moonlight.Frontend.Host/Styles
COPY ["Hosts/Moonlight.Frontend.Host/Styles/package.json", "package.json"] COPY ["Hosts/Moonlight.Frontend.Host/Styles/package.json", "package.json"]
RUN npm install RUN bun install
# Restore nuget packages # Restore nuget packages
WORKDIR /src WORKDIR /src
@@ -27,6 +29,9 @@ COPY ["Moonlight.Shared/Moonlight.Shared.csproj", "Moonlight.Shared/"]
COPY ["Hosts/Moonlight.Frontend.Host/Moonlight.Frontend.Host.csproj", "Hosts/Moonlight.Frontend.Host/"] COPY ["Hosts/Moonlight.Frontend.Host/Moonlight.Frontend.Host.csproj", "Hosts/Moonlight.Frontend.Host/"]
COPY ["Hosts/Moonlight.Api.Host/Moonlight.Api.Host.csproj", "Hosts/Moonlight.Api.Host/"] COPY ["Hosts/Moonlight.Api.Host/Moonlight.Api.Host.csproj", "Hosts/Moonlight.Api.Host/"]
COPY ["Hosts/Moonlight.Frontend.Host/Frontend.props", "Hosts/Moonlight.Frontend.Host/"]
COPY ["Hosts/Moonlight.Api.Host/Api.props", "Hosts/Moonlight.Api.Host/"]
RUN dotnet restore "Hosts/Moonlight.Api.Host/Moonlight.Api.Host.csproj" RUN dotnet restore "Hosts/Moonlight.Api.Host/Moonlight.Api.Host.csproj"
RUN dotnet restore "Hosts/Moonlight.Frontend.Host/Moonlight.Frontend.Host.csproj" RUN dotnet restore "Hosts/Moonlight.Frontend.Host/Moonlight.Frontend.Host.csproj"
@@ -39,7 +44,7 @@ WORKDIR "/src/Hosts/Moonlight.Frontend.Host"
RUN dotnet build "./Moonlight.Frontend.Host.csproj" -c $BUILD_CONFIGURATION -o /app/build-frontend RUN dotnet build "./Moonlight.Frontend.Host.csproj" -c $BUILD_CONFIGURATION -o /app/build-frontend
WORKDIR "/src/Hosts/Moonlight.Frontend.Host/Styles" WORKDIR "/src/Hosts/Moonlight.Frontend.Host/Styles"
RUN npm run build RUN bun run build
# Build projects # Build projects
WORKDIR "/src/Hosts/Moonlight.Api.Host" WORKDIR "/src/Hosts/Moonlight.Api.Host"
@@ -67,4 +72,6 @@ WORKDIR /app
COPY --from=publish /app/publish-api . COPY --from=publish /app/publish-api .
COPY --from=publish /app/publish-frontend/wwwroot ./wwwroot COPY --from=publish /app/publish-frontend/wwwroot ./wwwroot
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD ["/usr/bin/curl", "-sf", "-o", "/dev/null", "http://localhost:8080/"]
ENTRYPOINT ["dotnet", "Moonlight.Api.Host.dll"] ENTRYPOINT ["dotnet", "Moonlight.Api.Host.dll"]

View File

@@ -7,15 +7,13 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/>
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="SimplePlugin" Version="1.0.2" />
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -29,4 +27,5 @@
</Content> </Content>
</ItemGroup> </ItemGroup>
<Import Project="Api.props"/>
</Project> </Project>

View File

@@ -1,22 +1,9 @@
using Moonlight.Api.Host; using Moonlight.Api;
using SimplePlugin.Generated;
var appLoader = new AppStartupLoader(); var plugins = PluginRegistry
appLoader.Initialize(); .Modules
.OfType<MoonlightPlugin>()
.ToArray();
var builder = WebApplication.CreateBuilder(args); await StartupHandler.RunAsync(args, plugins);
appLoader.PreBuild(builder);
var app = builder.Build();
appLoader.PostBuild(app);
appLoader.PostMiddleware(app);
if (app.Environment.IsDevelopment())
app.UseWebAssemblyDebugging();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
await app.RunAsync();

View File

@@ -1,10 +0,0 @@
using MoonCore.PluginFramework;
using Moonlight.Frontend.Startup;
namespace Moonlight.Frontend.Host;
[PluginLoader]
public partial class AppStartupLoader : IAppStartup
{
}

View File

@@ -0,0 +1,6 @@
<Project>
<ItemGroup>
<!-- Put your plugin references here -->
<!-- E.g. <PackageReference Include="MoonlightServers.Frontend" Version="2.1.0" /> -->
</ItemGroup>
</Project>

View File

@@ -14,11 +14,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all"/>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/> <PackageReference Include="SimplePlugin" Version="1.0.2" />
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/> <PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Moonlight.Frontend\Moonlight.Frontend.csproj" /> <ProjectReference Include="..\..\Moonlight.Frontend\Moonlight.Frontend.csproj" />
</ItemGroup> </ItemGroup>
<Import Project="Frontend.props"/>
</Project> </Project>

View File

@@ -1,15 +1,9 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Moonlight.Frontend;
using Moonlight.Frontend.Host; using SimplePlugin.Generated;
var appLoader = new AppStartupLoader(); var plugins = PluginRegistry
appLoader.Initialize(); .Modules
.OfType<MoonlightPlugin>()
.ToArray();
var builder = WebAssemblyHostBuilder.CreateDefault(args); await StartupHandler.RunAsync(args, plugins);
appLoader.PreBuild(builder);
var app = builder.Build();
appLoader.PostBuild(app);
await app.RunAsync();

View File

@@ -1,11 +1,11 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "../../../Moonlight.Frontend/bin/ShadcnBlazor/scrollbar.css"; @import "../bin/ShadcnBlazor/scrollbar.css";
@import "../../../Moonlight.Frontend/bin/ShadcnBlazor/default-theme.css"; @import "../bin/ShadcnBlazor/default-theme.css";
@import "./theme.css"; @import "./theme.css";
@source "../../../Moonlight.Frontend/bin/ShadcnBlazor/ShadcnBlazor.map"; @source "../bin/ShadcnBlazor/ShadcnBlazor.map";
@source "../../../Moonlight.Api/**/*.razor"; @source "../../../Moonlight.Api/**/*.razor";
@source "../../../Moonlight.Api/**/*.cs"; @source "../../../Moonlight.Api/**/*.cs";

View File

@@ -2,5 +2,6 @@ namespace Moonlight.Api.Configuration;
public class ApiOptions public class ApiOptions
{ {
public int LookupCacheMinutes { get; set; } = 3; public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
} }

View File

@@ -0,0 +1,6 @@
namespace Moonlight.Api.Configuration;
public class CacheOptions
{
public bool EnableLayer2 { get; set; }
}

View File

@@ -4,6 +4,7 @@ public class OidcOptions
{ {
public string Authority { get; set; } public string Authority { get; set; }
public bool RequireHttpsMetadata { get; set; } = true; public bool RequireHttpsMetadata { get; set; } = true;
public bool DisableHttpsOnlyCookies { get; set; }
public string ResponseType { get; set; } = "code"; public string ResponseType { get; set; } = "code";
public string[]? Scopes { get; set; } public string[]? Scopes { get; set; }
public string ClientId { get; set; } public string ClientId { get; set; }

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class RedisOptions
{
public bool Enable { get; set; }
public string ConnectionString { get; set; }
}

View File

@@ -1,6 +0,0 @@
namespace Moonlight.Api.Configuration;
public class SessionOptions
{
public int ValidationCacheMinutes { get; set; } = 3;
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class SettingsOptions
{
public TimeSpan LookupL1CacheTime { get; set; } = TimeSpan.FromMinutes(1);
public TimeSpan LookupL2CacheTime { get; set; } = TimeSpan.FromMinutes(5);
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class UserOptions
{
public TimeSpan ValidationCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan ValidationCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class VersionOptions
{
public bool OfflineMode { get; set; }
public string? CurrentOverride { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace Moonlight.Api.Constants;
public class FrontendSettingConstants
{
public const string Name = "Moonlight.Frontend.Name";
}

View File

@@ -14,6 +14,7 @@ public class ApiKey : IActionTimestamps
public required string Description { get; set; } public required string Description { get; set; }
public string[] Permissions { get; set; } = []; public string[] Permissions { get; set; } = [];
public DateTimeOffset ValidUntil { get; set; }
[MaxLength(32)] [MaxLength(32)]
public string Key { get; set; } public string Key { get; set; }

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Moonlight.Api.Database.Entities; namespace Moonlight.Api.Database.Entities;
@@ -10,5 +11,6 @@ public class SettingsOption
public required string Key { get; set; } public required string Key { get; set; }
[MaxLength(4096)] [MaxLength(4096)]
public required string Value { get; set; } [Column(TypeName = "jsonb")]
public required string ValueJson { get; set; }
} }

View File

@@ -0,0 +1,251 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Roles", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("CssContent")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class SwitchedToJsonForSettingsOption : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Value",
schema: "core",
table: "SettingsOptions");
migrationBuilder.AddColumn<string>(
name: "ValueJson",
schema: "core",
table: "SettingsOptions",
type: "jsonb",
maxLength: 4096,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ValueJson",
schema: "core",
table: "SettingsOptions");
migrationBuilder.AddColumn<string>(
name: "Value",
schema: "core",
table: "SettingsOptions",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
defaultValue: "");
}
}
}

View File

@@ -0,0 +1,254 @@
// <auto-generated />
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("20260209114238_AddedValidUntilToApiKeys")]
partial class AddedValidUntilToApiKeys
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ValidUntil")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Roles", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("CssContent")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,32 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedValidUntilToApiKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ValidUntil",
schema: "core",
table: "ApiKeys",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ValidUntil",
schema: "core",
table: "ApiKeys");
}
}
}

View File

@@ -56,6 +56,9 @@ namespace Moonlight.Api.Database.Migrations
b.Property<DateTimeOffset>("UpdatedAt") b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ValidUntil")
.HasColumnType("timestamp with time zone");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("ApiKeys", "core"); b.ToTable("ApiKeys", "core");
@@ -136,10 +139,10 @@ namespace Moonlight.Api.Database.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
b.Property<string>("Value") b.Property<string>("ValueJson")
.IsRequired() .IsRequired()
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)"); .HasColumnType("jsonb");
b.HasKey("Id"); b.HasKey("Id");

View File

@@ -1,14 +1,16 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Api.Implementations.ApiKeyScheme;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.ApiKeys; using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.ApiKeys; using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Http.Controllers.Admin;
@@ -18,10 +20,12 @@ namespace Moonlight.Api.Http.Controllers.Admin;
public class ApiKeyController : Controller public class ApiKeyController : Controller
{ {
private readonly DatabaseRepository<ApiKey> KeyRepository; private readonly DatabaseRepository<ApiKey> KeyRepository;
private readonly HybridCache HybridCache;
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository) public ApiKeyController(DatabaseRepository<ApiKey> keyRepository, HybridCache hybridCache)
{ {
KeyRepository = keyRepository; KeyRepository = keyRepository;
HybridCache = hybridCache;
} }
[HttpGet] [HttpGet]
@@ -114,6 +118,8 @@ public class ApiKeyController : Controller
ApiKeyMapper.Merge(apiKey, request); ApiKeyMapper.Merge(apiKey, request);
await KeyRepository.UpdateAsync(apiKey); await KeyRepository.UpdateAsync(apiKey);
await HybridCache.RemoveAsync(string.Format(ApiKeySchemeHandler.CacheKeyFormat, apiKey.Key));
return ApiKeyMapper.ToDto(apiKey); return ApiKeyMapper.ToDto(apiKey);
} }
@@ -129,6 +135,9 @@ public class ApiKeyController : Controller
return Problem("No API key with this id found", statusCode: 404); return Problem("No API key with this id found", statusCode: 404);
await KeyRepository.RemoveAsync(apiKey); await KeyRepository.RemoveAsync(apiKey);
await HybridCache.RemoveAsync(string.Format(ApiKeySchemeHandler.CacheKeyFormat, apiKey.Key));
return NoContent(); return NoContent();
} }
} }

View File

@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration; using Moonlight.Api.Configuration;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Api.Services; using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests.Admin.ContainerHelper; using Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;
@@ -11,6 +13,7 @@ namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController] [ApiController]
[Route("api/admin/ch")] [Route("api/admin/ch")]
[Authorize(Policy = Permissions.System.Instance)]
public class ContainerHelperController : Controller public class ContainerHelperController : Controller
{ {
private readonly ContainerHelperService ContainerHelperService; private readonly ContainerHelperService ContainerHelperService;
@@ -34,9 +37,9 @@ public class ContainerHelperController : Controller
} }
[HttpPost("rebuild")] [HttpPost("rebuild")]
public Task<IResult> RebuildAsync() public Task<IResult> RebuildAsync([FromBody] RequestRebuildDto request)
{ {
var result = ContainerHelperService.RebuildAsync(); var result = ContainerHelperService.RebuildAsync(request.NoBuildCache);
var mappedResult = result.Select(ContainerHelperMapper.ToDto); var mappedResult = result.Select(ContainerHelperMapper.ToDto);
return Task.FromResult<IResult>( return Task.FromResult<IResult>(

View File

@@ -6,7 +6,7 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users; using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -6,7 +6,7 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;

View File

@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Constants;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests.Admin.Settings;
using Moonlight.Shared.Http.Responses.Admin.Settings;
namespace Moonlight.Api.Http.Controllers.Admin.Settings;
[ApiController]
[Authorize(Policy = Permissions.System.Settings)]
[Route("api/admin/system/settings/whiteLabeling")]
public class WhiteLabelingController : Controller
{
private readonly SettingsService SettingsService;
private readonly FrontendService FrontendService;
public WhiteLabelingController(SettingsService settingsService, FrontendService frontendService)
{
SettingsService = settingsService;
FrontendService = frontendService;
}
[HttpGet]
public async Task<ActionResult<WhiteLabelingDto>> GetAsync()
{
var dto = new WhiteLabelingDto
{
Name = await SettingsService.GetValueAsync<string>(FrontendSettingConstants.Name) ?? "Moonlight"
};
return dto;
}
[HttpPost]
public async Task<ActionResult<WhiteLabelingDto>> PostAsync([FromBody] SetWhiteLabelingDto request)
{
await SettingsService.SetValueAsync(FrontendSettingConstants.Name, request.Name);
await FrontendService.ResetCacheAsync();
var dto = new WhiteLabelingDto
{
Name = await SettingsService.GetValueAsync<string>(FrontendSettingConstants.Name) ?? "Moonlight"
};
return dto;
}
}

View File

@@ -0,0 +1,129 @@
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<User> UsersRepository;
private readonly DatabaseRepository<Role> RolesRepository;
private const string StateSettingsKey = "Moonlight.Api.Setup.State";
public SetupController(
SettingsService settingsService,
DatabaseRepository<User> usersRepository,
DatabaseRepository<Role> rolesRepository
)
{
SettingsService = settingsService;
UsersRepository = usersRepository;
RolesRepository = rolesRepository;
}
[HttpGet]
[AllowAnonymous]
public async Task<ActionResult> GetSetupAsync()
{
var hasBeenSetup = await SettingsService.GetValueAsync<bool>(StateSettingsKey);
if (hasBeenSetup)
return Problem("This instance is already configured", statusCode: 405);
return NoContent();
}
[HttpPost]
[AllowAnonymous]
public async Task<ActionResult> 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,
Permissions.System.Versions,
Permissions.System.Instance,
]
});
}
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();
}
}

View File

@@ -7,11 +7,11 @@ using Moonlight.Api.Mappers;
using Moonlight.Api.Services; using Moonlight.Api.Services;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Themes; using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Themes; using Moonlight.Shared.Http.Responses.Admin.Themes;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Http.Controllers.Admin.Themes;
[ApiController] [ApiController]
[Route("api/admin/themes")] [Route("api/admin/themes")]

View File

@@ -0,0 +1,76 @@
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.Models;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using VYaml.Serialization;
namespace Moonlight.Api.Http.Controllers.Admin.Themes;
[ApiController]
[Route("api/admin/themes")]
[Authorize(Policy = Permissions.Themes.View)]
public class TransferController : Controller
{
private readonly DatabaseRepository<Theme> ThemeRepository;
public TransferController(DatabaseRepository<Theme> themeRepository)
{
ThemeRepository = themeRepository;
}
[HttpGet("{id:int}/export")]
public async Task<ActionResult> ExportAsync([FromRoute] int id)
{
var theme = await ThemeRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (theme == null)
return Problem("No theme with that id found", statusCode: 404);
var yml = YamlSerializer.Serialize(new ThemeTransferModel()
{
Name = theme.Name,
Author = theme.Author,
CssContent = theme.CssContent,
Version = theme.Version
});
return File(yml.ToArray(), "text/yaml", $"{theme.Name}.yml");
}
[HttpPost("import")]
public async Task<ActionResult<ThemeDto>> ImportAsync()
{
var themeToImport = await YamlSerializer.DeserializeAsync<ThemeTransferModel>(Request.Body);
var existingTheme = await ThemeRepository
.Query()
.FirstOrDefaultAsync(x => x.Name == themeToImport.Name && x.Author == themeToImport.Author);
if (existingTheme == null)
{
var finalTheme = await ThemeRepository.AddAsync(new Theme()
{
Name = themeToImport.Name,
Author = themeToImport.Author,
CssContent = themeToImport.CssContent,
Version = themeToImport.Version
});
return ThemeMapper.ToDto(finalTheme);
}
existingTheme.CssContent = themeToImport.CssContent;
existingTheme.Version = themeToImport.Version;
await ThemeRepository.UpdateAsync(existingTheme);
return ThemeMapper.ToDto(existingTheme);
}
}

View File

@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Services;
using Moonlight.Shared;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/users/{id:int}")]
public class UserActionsController : Controller
{
// Consider building a service for deletion and logout or actions in general
private readonly DatabaseRepository<User> UsersRepository;
private readonly IMemoryCache Cache;
public UserActionsController(DatabaseRepository<User> usersRepository, IMemoryCache cache)
{
UsersRepository = usersRepository;
Cache = cache;
}
[HttpPost("logout")]
[Authorize(Policy = Permissions.Users.Logout)]
public async Task<ActionResult> LogoutAsync([FromRoute] int id)
{
var user = await UsersRepository
.Query()
.FirstOrDefaultAsync(u => u.Id == id);
if(user == null)
return Problem("User not found", statusCode: 404);
user.InvalidateTimestamp = DateTimeOffset.UtcNow;
await UsersRepository.UpdateAsync(user);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, id));
return NoContent();
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Frozen;
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;
namespace Moonlight.Api.Http.Controllers.Admin.Users;
[ApiController]
[Route("api/admin/users")]
[Authorize(Policy = Permissions.Users.Delete)]
public class UserDeletionController : Controller
{
private readonly UserDeletionService UserDeletionService;
private readonly DatabaseRepository<User> Repository;
public UserDeletionController(UserDeletionService userDeletionService, DatabaseRepository<User> repository)
{
UserDeletionService = userDeletionService;
Repository = repository;
}
[HttpDelete("{id:int}")]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var userExists = await Repository
.Query()
.AnyAsync(user => user.Id == id);
if (!userExists)
return Problem("No user with this id found", statusCode: 404);
var validationResult = await UserDeletionService.ValidateAsync(id);
if (!validationResult.IsValid)
{
return ValidationProblem(
new ValidationProblemDetails(
new Dictionary<string, string[]>()
{
{
string.Empty,
validationResult.ErrorMessages.ToArray()
}
}
)
);
}
await UserDeletionService.DeleteAsync(id);
return NoContent();
}
}

View File

@@ -0,0 +1,41 @@
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;
namespace Moonlight.Api.Http.Controllers.Admin.Users;
[ApiController]
[Route("api/admin/users/{id:int}/logout")]
[Authorize(Policy = Permissions.Users.Logout)]
public class UserLogoutController : Controller
{
private readonly UserLogoutService LogoutService;
private readonly DatabaseRepository<User> Repository;
public UserLogoutController(
UserLogoutService logoutService,
DatabaseRepository<User> repository
)
{
LogoutService = logoutService;
Repository = repository;
}
[HttpPost]
public async Task<ActionResult> LogoutAsync([FromRoute] int id)
{
var userExists = await Repository
.Query()
.AnyAsync(user => user.Id == id);
if (!userExists)
return Problem("No user with this id found", statusCode: 404);
await LogoutService.LogoutAsync(id);
return NoContent();
}
}

View File

@@ -6,11 +6,11 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users; using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Http.Controllers.Admin.Users;
[Authorize] [Authorize]
[ApiController] [ApiController]
@@ -117,19 +117,4 @@ public class UsersController : Controller
return UserMapper.ToDto(user); return UserMapper.ToDto(user);
} }
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Users.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var user = await UserRepository
.Query()
.FirstOrDefaultAsync(user => user.Id == id);
if (user == null)
return Problem("No user with this id found", statusCode: 404);
await UserRepository.RemoveAsync(user);
return NoContent();
}
} }

View File

@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/versions")]
[Authorize(Policy = Permissions.System.Versions)]
public class VersionsController : Controller
{
private readonly VersionService VersionService;
public VersionsController(VersionService versionService)
{
VersionService = versionService;
}
[HttpGet]
public async Task<ActionResult<VersionDto[]>> GetAsync()
{
var versions = await VersionService.GetVersionsAsync();
return VersionMapper.ToDtos(versions).ToArray();
}
[HttpGet("instance")]
public async Task<ActionResult<VersionDto>> GetInstanceAsync()
{
var version = await VersionService.GetInstanceVersionAsync();
return VersionMapper.ToDto(version);
}
[HttpGet("latest")]
public async Task<ActionResult<VersionDto>> GetLatestAsync()
{
var version = await VersionService.GetLatestVersionAsync();
if(version == null)
return Problem("Unable to retrieve latest version", statusCode: 404);
return VersionMapper.ToDto(version);
}
}

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Moonlight.Shared.Http.Responses.Auth; using Moonlight.Shared.Http.Responses.Admin.Auth;
namespace Moonlight.Api.Http.Controllers; namespace Moonlight.Api.Http.Controllers;

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Api.Services; using Moonlight.Api.Services;
using Moonlight.Shared.Http.Responses.Frontend; using Moonlight.Shared.Http.Responses.Admin.Frontend;
namespace Moonlight.Api.Http.Controllers; namespace Moonlight.Api.Http.Controllers;

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Api.Http.Services.ContainerHelper.Requests;
public record RequestRebuildDto(bool NoBuildCache);

View File

@@ -8,21 +8,10 @@ namespace Moonlight.Api.Http.Services.ContainerHelper;
[JsonSerializable(typeof(SetVersionDto))] [JsonSerializable(typeof(SetVersionDto))]
[JsonSerializable(typeof(ProblemDetails))] [JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(RebuildEventDto))] [JsonSerializable(typeof(RebuildEventDto))]
[JsonSerializable(typeof(RequestRebuildDto))]
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
public partial class SerializationContext : JsonSerializerContext 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;
}
}
} }

View File

@@ -2,7 +2,7 @@ using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moonlight.Api.Database; using Moonlight.Api.Database;
@@ -14,18 +14,20 @@ namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions> public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
{ {
private readonly DatabaseRepository<ApiKey> ApiKeyRepository; private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly IMemoryCache MemoryCache; private readonly HybridCache HybridCache;
public const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
public ApiKeySchemeHandler( public ApiKeySchemeHandler(
IOptionsMonitor<ApiKeySchemeOptions> options, IOptionsMonitor<ApiKeySchemeOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
DatabaseRepository<ApiKey> apiKeyRepository, DatabaseRepository<ApiKey> apiKeyRepository,
IMemoryCache memoryCache HybridCache hybridCache
) : base(options, logger, encoder) ) : base(options, logger, encoder)
{ {
ApiKeyRepository = apiKeyRepository; ApiKeyRepository = apiKeyRepository;
MemoryCache = memoryCache; HybridCache = hybridCache;
} }
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
@@ -38,24 +40,30 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
if (authHeaderValue.Length > 32) if (authHeaderValue.Length > 32)
return AuthenticateResult.Fail("Invalid api key specified"); return AuthenticateResult.Fail("Invalid api key specified");
if (!MemoryCache.TryGetValue<ApiKeySession>(authHeaderValue, out var apiKey)) var cacheKey = string.Format(CacheKeyFormat, authHeaderValue);
var apiKey = await HybridCache.GetOrCreateAsync<ApiKeySession?>(
cacheKey,
async ct =>
{ {
apiKey = await ApiKeyRepository return await ApiKeyRepository
.Query() .Query()
.Where(x => x.Key == authHeaderValue) .Where(x => x.Key == authHeaderValue)
.Select(x => new ApiKeySession(x.Permissions)) .Select(x => new ApiKeySession(x.Permissions, x.ValidUntil))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync(cancellationToken: ct);
},
if (apiKey == null) new HybridCacheEntryOptions()
return AuthenticateResult.Fail("Invalid api key specified");
MemoryCache.Set(authHeaderValue, apiKey, Options.LookupCacheTime);
}
else
{ {
LocalCacheExpiration = Options.LookupL1CacheTime,
Expiration = Options.LookupL2CacheTime
}
);
if (apiKey == null) if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified"); return AuthenticateResult.Fail("Invalid api key specified");
}
if (DateTimeOffset.UtcNow > apiKey.ValidUntil)
return AuthenticateResult.Fail("Api key expired");
return AuthenticateResult.Success(new AuthenticationTicket( return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal( new ClaimsPrincipal(
@@ -67,5 +75,5 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
)); ));
} }
private record ApiKeySession(string[] Permissions); private record ApiKeySession(string[] Permissions, DateTimeOffset ValidUntil);
} }

View File

@@ -4,5 +4,6 @@ namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeOptions : AuthenticationSchemeOptions public class ApiKeySchemeOptions : AuthenticationSchemeOptions
{ {
public TimeSpan LookupCacheTime { get; set; } public TimeSpan LookupL1CacheTime { get; set; }
public TimeSpan LookupL2CacheTime { get; set; }
} }

View File

@@ -0,0 +1,13 @@
using System.Security.Claims;
using Moonlight.Api.Database.Entities;
namespace Moonlight.Api.Interfaces;
public interface IUserAuthHook
{
public Task<bool> SyncAsync(ClaimsPrincipal principal, User trackedUser);
// Every implementation of this function should execute as fast as possible
// as this directly impacts every api call
public Task<bool> ValidateAsync(ClaimsPrincipal principal, int userId);
}

View File

@@ -0,0 +1,9 @@
using Moonlight.Api.Database.Entities;
namespace Moonlight.Api.Interfaces;
public interface IUserDeletionHook
{
public Task<bool> ValidateAsync(User user, List<string> errors);
public Task ExecuteAsync(User user);
}

View File

@@ -0,0 +1,8 @@
using Moonlight.Api.Database.Entities;
namespace Moonlight.Api.Interfaces;
public interface IUserLogoutHook
{
public Task ExecuteAsync(User user);
}

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.ApiKeys; using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys; using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Mappers;

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models; using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Frontend; using Moonlight.Shared.Http.Responses.Admin.Frontend;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Mappers;

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Themes; using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses.Themes; using Moonlight.Shared.Http.Responses.Admin.Themes;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Mappers;

View File

@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Mappers;

View File

@@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")]
[SuppressMessage("Mapper", "RMG012:Source member was not found for target member")]
public static partial class VersionMapper
{
public static partial IEnumerable<VersionDto> ToDtos(IEnumerable<MoonlightVersion> versions);
public static partial VersionDto ToDto(MoonlightVersion version);
}

View File

@@ -0,0 +1,12 @@
namespace Moonlight.Api.Models;
// Notes:
// Identifier - This needs to be the branch to clone to build this version if
// you want to use the container helper
public record MoonlightVersion(
string Identifier,
bool IsPreRelease,
bool IsDevelopment,
DateTimeOffset CreatedAt
);

View File

@@ -0,0 +1,12 @@
using VYaml.Annotations;
namespace Moonlight.Api.Models;
[YamlObject]
public partial class ThemeTransferModel
{
public string Name { get; set; }
public string Version { get; set; }
public string Author { get; set; }
public string CssContent { get; set; }
}

View File

@@ -25,9 +25,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.1"/> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1"/> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1"/>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/> <PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
<PackageReference Include="VYaml" Version="1.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,28 @@
using System.Reflection;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using SimplePlugin.Abstractions;
namespace Moonlight.Api;
public abstract class MoonlightPlugin : IPluginModule
{
protected MoonlightPlugin[] Plugins { get; private set; }
public void Initialize(MoonlightPlugin[] plugins)
{
Plugins = plugins;
}
public virtual void PreBuild(WebApplicationBuilder builder)
{
}
public virtual void PostBuild(WebApplication application)
{
}
public virtual void PostMiddleware(WebApplication application)
{
}
}

View File

@@ -1,16 +1,26 @@
using System.Diagnostics; using System.Diagnostics;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Moonlight.Api.Helpers; using Moonlight.Api.Helpers;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Services;
public class ApplicationService : IHostedLifecycleService public class ApplicationService : IHostedService
{ {
private readonly VersionService VersionService;
private readonly ILogger<ApplicationService> Logger;
public DateTimeOffset StartedAt { get; private set; } public DateTimeOffset StartedAt { get; private set; }
public string VersionName { get; private set; } = "N/A"; public string VersionName { get; private set; } = "N/A";
public bool IsUpToDate { get; set; } = true; public bool IsUpToDate { get; set; } = true;
public string OperatingSystem { get; private set; } = "N/A"; public string OperatingSystem { get; private set; } = "N/A";
public ApplicationService(VersionService versionService, ILogger<ApplicationService> logger)
{
VersionService = versionService;
Logger = logger;
}
public Task<long> GetMemoryUsageAsync() public Task<long> GetMemoryUsageAsync()
{ {
using var currentProcess = Process.GetCurrentProcess(); using var currentProcess = Process.GetCurrentProcess();
@@ -40,25 +50,36 @@ public class ApplicationService : IHostedLifecycleService
return Math.Round(cpuUsagePercent, 2); return Math.Round(cpuUsagePercent, 2);
} }
public async Task StartedAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
StartedAt = DateTimeOffset.UtcNow; StartedAt = DateTimeOffset.UtcNow;
// TODO: Update / version check
VersionName = "v2.1.0 (a2d4edc0e5)";
IsUpToDate = false;
OperatingSystem = OsHelper.GetName(); OperatingSystem = OsHelper.GetName();
try
{
var currentVersion = await VersionService.GetInstanceVersionAsync();
var latestVersion = await VersionService.GetLatestVersionAsync();
VersionName = currentVersion.Identifier;
IsUpToDate = latestVersion == null || currentVersion.Identifier == latestVersion.Identifier;
Logger.LogInformation("Running Moonlight Panel {version} on {operatingSystem}", VersionName, OperatingSystem);
if (!IsUpToDate)
Logger.LogWarning("Your instance is not up-to-date");
if (currentVersion.IsDevelopment)
Logger.LogWarning("Your instance is running a development version");
if (currentVersion.IsPreRelease)
Logger.LogWarning("Your instance is running a pre-release version");
}
catch (Exception e)
{
Logger.LogError(e, "An unhandled exception occurred while fetching version details");
}
} }
#region Unused
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
#endregion
} }

View File

@@ -32,11 +32,22 @@ public class ContainerHelperService
} }
} }
public async IAsyncEnumerable<RebuildEventDto> RebuildAsync() public async IAsyncEnumerable<RebuildEventDto> RebuildAsync(bool noBuildCache)
{ {
var client = HttpClientFactory.CreateClient("ContainerHelper"); var client = HttpClientFactory.CreateClient("ContainerHelper");
var response = await client.GetAsync("api/rebuild", HttpCompletionOption.ResponseHeadersRead); var request = new HttpRequestMessage(HttpMethod.Post, "api/rebuild");
request.Content = JsonContent.Create(
new RequestRebuildDto(noBuildCache),
null,
SerializationContext.Default.Options
);
var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead
);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@@ -65,7 +76,7 @@ public class ContainerHelperService
continue; continue;
var data = line.Trim("data: "); var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.TunedOptions); var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.Default.Options);
yield return deserializedData; yield return deserializedData;
@@ -88,13 +99,14 @@ public class ContainerHelperService
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
"api/configuration/version", "api/configuration/version",
new SetVersionDto(version), new SetVersionDto(version),
SerializationContext.TunedOptions SerializationContext.Default.Options
); );
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
return; return;
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.TunedOptions); var problemDetails =
await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.Default.Options);
if (problemDetails == null) if (problemDetails == null)
throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}"); throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}");

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration; using Moonlight.Api.Configuration;
using Moonlight.Api.Constants;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Api.Models; using Moonlight.Api.Models;
@@ -13,14 +14,16 @@ public class FrontendService
private readonly IMemoryCache Cache; private readonly IMemoryCache Cache;
private readonly DatabaseRepository<Theme> ThemeRepository; private readonly DatabaseRepository<Theme> ThemeRepository;
private readonly IOptions<FrontendOptions> Options; private readonly IOptions<FrontendOptions> Options;
private readonly SettingsService SettingsService;
private const string CacheKey = $"Moonlight.{nameof(FrontendService)}.{nameof(GetConfigurationAsync)}"; private const string CacheKey = $"Moonlight.{nameof(FrontendService)}.{nameof(GetConfigurationAsync)}";
public FrontendService(IMemoryCache cache, DatabaseRepository<Theme> themeRepository, IOptions<FrontendOptions> options) public FrontendService(IMemoryCache cache, DatabaseRepository<Theme> themeRepository, IOptions<FrontendOptions> options, SettingsService settingsService)
{ {
Cache = cache; Cache = cache;
ThemeRepository = themeRepository; ThemeRepository = themeRepository;
Options = options; Options = options;
SettingsService = settingsService;
} }
public async Task<FrontendConfiguration> GetConfigurationAsync() public async Task<FrontendConfiguration> GetConfigurationAsync()
@@ -35,7 +38,9 @@ public class FrontendService
.Query() .Query()
.FirstOrDefaultAsync(x => x.IsEnabled); .FirstOrDefaultAsync(x => x.IsEnabled);
var config = new FrontendConfiguration("Moonlight", theme?.CssContent); var name = await SettingsService.GetValueAsync<string>(FrontendSettingConstants.Name);
var config = new FrontendConfiguration(name ?? "Moonlight", theme?.CssContent);
Cache.Set(CacheKey, config, TimeSpan.FromMinutes(Options.Value.CacheMinutes)); Cache.Set(CacheKey, config, TimeSpan.FromMinutes(Options.Value.CacheMinutes));

View File

@@ -0,0 +1,85 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
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<SettingsOption> Repository;
private readonly IOptions<SettingsOptions> Options;
private readonly HybridCache HybridCache;
private const string CacheKey = "Moonlight.Api.SettingsService.{0}";
public SettingsService(
DatabaseRepository<SettingsOption> repository,
IOptions<SettingsOptions> options,
HybridCache hybridCache
)
{
Repository = repository;
HybridCache = hybridCache;
Options = options;
}
public async Task<T?> GetValueAsync<T>(string key)
{
var cacheKey = string.Format(CacheKey, key);
var value = await HybridCache.GetOrCreateAsync<string?>(
cacheKey,
async ct =>
{
return await Repository
.Query()
.Where(x => x.Key == key)
.Select(o => o.ValueJson)
.FirstOrDefaultAsync(cancellationToken: ct);
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.Value.LookupL1CacheTime,
Expiration = Options.Value.LookupL2CacheTime
}
);
if (string.IsNullOrWhiteSpace(value))
return default;
return JsonSerializer.Deserialize<T>(value);
}
public async Task SetValueAsync<T>(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);
}
await HybridCache.RemoveAsync(cacheKey);
}
}

View File

@@ -1,11 +1,12 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration; using Moonlight.Api.Configuration;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
using Moonlight.Shared; using Moonlight.Shared;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Services;
@@ -13,9 +14,10 @@ namespace Moonlight.Api.Services;
public class UserAuthService public class UserAuthService
{ {
private readonly DatabaseRepository<User> UserRepository; private readonly DatabaseRepository<User> UserRepository;
private readonly IMemoryCache Cache;
private readonly ILogger<UserAuthService> Logger; private readonly ILogger<UserAuthService> Logger;
private readonly IOptions<SessionOptions> Options; private readonly IOptions<UserOptions> Options;
private readonly IEnumerable<IUserAuthHook> Hooks;
private readonly HybridCache HybridCache;
private const string UserIdClaim = "UserId"; private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt"; private const string IssuedAtClaim = "IssuedAt";
@@ -25,12 +27,16 @@ public class UserAuthService
public UserAuthService( public UserAuthService(
DatabaseRepository<User> userRepository, DatabaseRepository<User> userRepository,
ILogger<UserAuthService> logger, ILogger<UserAuthService> logger,
IMemoryCache cache, IOptions<SessionOptions> options) IOptions<UserOptions> options,
IEnumerable<IUserAuthHook> hooks,
HybridCache hybridCache
)
{ {
UserRepository = userRepository; UserRepository = userRepository;
Logger = logger; Logger = logger;
Cache = cache;
Options = options; Options = options;
Hooks = hooks;
HybridCache = hybridCache;
} }
public async Task<bool> SyncAsync(ClaimsPrincipal? principal) public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
@@ -50,7 +56,6 @@ public class UserAuthService
// We use email as the primary identifier here // We use email as the primary identifier here
var user = await UserRepository var user = await UserRepository
.Query() .Query()
.AsNoTracking()
.FirstOrDefaultAsync(user => user.Email == email); .FirstOrDefaultAsync(user => user.Email == email);
if (user == null) // Sync user if not already existing in the database if (user == null) // Sync user if not already existing in the database
@@ -74,6 +79,13 @@ public class UserAuthService
new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()) new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())
]); ]);
foreach (var hook in Hooks)
{
// Run every hook, and if any returns false, we return false as well
if (!await hook.SyncAsync(principal, user))
return false;
}
return true; return true;
} }
@@ -90,9 +102,11 @@ public class UserAuthService
var cacheKey = string.Format(CacheKeyPattern, userId); var cacheKey = string.Format(CacheKeyPattern, userId);
if (!Cache.TryGetValue<UserSession>(cacheKey, out var user)) var user = await HybridCache.GetOrCreateAsync<UserSession?>(
cacheKey,
async ct =>
{ {
user = await UserRepository return await UserRepository
.Query() .Query()
.AsNoTracking() .AsNoTracking()
.Where(u => u.Id == userId) .Where(u => u.Id == userId)
@@ -100,22 +114,17 @@ public class UserAuthService
u.InvalidateTimestamp, u.InvalidateTimestamp,
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray()) u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
) )
.FirstOrDefaultAsync(); .FirstOrDefaultAsync(cancellationToken: ct);
},
if (user == null) new HybridCacheEntryOptions()
return false;
Cache.Set(
cacheKey,
user,
TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes)
);
}
else
{ {
LocalCacheExpiration = Options.Value.ValidationCacheL1Expiry,
Expiration = Options.Value.ValidationCacheL2Expiry
}
);
if (user == null) if (user == null)
return false; return false;
}
var issuedAtString = principal.FindFirstValue(IssuedAtClaim); var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
@@ -131,10 +140,18 @@ public class UserAuthService
if (issuedAt < user.InvalidateTimestamp) if (issuedAt < user.InvalidateTimestamp)
return false; return false;
// Load every permission as claim
principal.Identities.First().AddClaims( principal.Identities.First().AddClaims(
user.Permissions.Select(x => new Claim(Permissions.ClaimType, x)) user.Permissions.Select(x => new Claim(Permissions.ClaimType, x))
); );
foreach (var hook in Hooks)
{
// Run every hook, and if any returns false we return false as well
if (!await hook.ValidateAsync(principal, userId))
return false;
}
return true; return true;
} }

View File

@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
namespace Moonlight.Api.Services;
public class UserDeletionService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserDeletionHook> Hooks;
private readonly HybridCache HybridCache;
public UserDeletionService(
DatabaseRepository<User> repository,
IEnumerable<IUserDeletionHook> hooks,
HybridCache hybridCache
)
{
Repository = repository;
Hooks = hooks;
HybridCache = hybridCache;
}
public async Task<UserDeletionValidationResult> ValidateAsync(int userId)
{
var user = await Repository
.Query()
.FirstOrDefaultAsync(x => x.Id == userId);
if(user == null)
throw new AggregateException($"User with id {userId} not found");
var errorMessages = new List<string>();
foreach (var hook in Hooks)
{
if (await hook.ValidateAsync(user, errorMessages))
continue;
return new UserDeletionValidationResult(false, errorMessages);
}
return new UserDeletionValidationResult(true, []);
}
public async Task DeleteAsync(int userId)
{
var user = await Repository
.Query()
.FirstOrDefaultAsync(x => x.Id == userId);
if(user == null)
throw new AggregateException($"User with id {userId} not found");
foreach (var hook in Hooks)
await hook.ExecuteAsync(user);
await Repository.RemoveAsync(user);
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
}
}
public record UserDeletionValidationResult(bool IsValid, IEnumerable<string> ErrorMessages);

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
namespace Moonlight.Api.Services;
public class UserLogoutService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserLogoutHook> Hooks;
private readonly HybridCache HybridCache;
public UserLogoutService(
DatabaseRepository<User> repository,
IEnumerable<IUserLogoutHook> hooks,
HybridCache hybridCache
)
{
Repository = repository;
Hooks = hooks;
HybridCache = hybridCache;
}
public async Task LogoutAsync(int userId)
{
var user = await Repository
.Query()
.FirstOrDefaultAsync(x => x.Id == userId);
if (user == null)
throw new AggregateException($"User with id {userId} not found");
foreach (var hook in Hooks)
await hook.ExecuteAsync(user);
user.InvalidateTimestamp = DateTimeOffset.UtcNow;
await Repository.UpdateAsync(user);
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
}
}

View File

@@ -0,0 +1,140 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Models;
namespace Moonlight.Api.Services;
public partial class VersionService
{
private readonly IOptions<VersionOptions> Options;
private readonly IHttpClientFactory HttpClientFactory;
private const string VersionPath = "/app/version";
private const string GiteaServer = "https://git.battlestati.one";
private const string GiteaRepository = "Moonlight-Panel/Moonlight";
[GeneratedRegex(@"^v(?!1(\.|$))\d+\.[A-Za-z0-9]+(\.[A-Za-z0-9]+)*$")]
private static partial Regex RegexFilter();
public VersionService(
IOptions<VersionOptions> options,
IHttpClientFactory httpClientFactory
)
{
Options = options;
HttpClientFactory = httpClientFactory;
}
public async Task<MoonlightVersion[]> GetVersionsAsync()
{
if (Options.Value.OfflineMode)
return [];
var versions = new List<MoonlightVersion>();
var httpClient = HttpClientFactory.CreateClient();
// Tags
const string tagsPath = $"{GiteaServer}/api/v1/repos/{GiteaRepository}/tags";
await using var tagsJsonStream = await httpClient.GetStreamAsync(tagsPath);
var tagsJson = await JsonNode.ParseAsync(tagsJsonStream);
if (tagsJson != null)
{
foreach (var node in tagsJson.AsArray())
{
if (node == null)
continue;
var name = node["name"]?.GetValue<string>() ?? "N/A";
var createdAt = node["createdAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue;
if(!RegexFilter().IsMatch(name))
continue;
versions.Add(new MoonlightVersion(
name,
name.EndsWith('b'),
false,
createdAt
));
}
}
// Branches
const string branchesPath = $"{GiteaServer}/api/v1/repos/{GiteaRepository}/branches";
await using var branchesJsonStream = await httpClient.GetStreamAsync(branchesPath);
var branchesJson = await JsonNode.ParseAsync(branchesJsonStream);
if (branchesJson != null)
{
foreach (var node in branchesJson.AsArray())
{
if (node == null)
continue;
var name = node["name"]?.GetValue<string>() ?? "N/A";
var commit = node["commit"];
if (commit == null)
continue;
var createdAt = commit["timestamp"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue;
if(!RegexFilter().IsMatch(name))
continue;
versions.Add(new MoonlightVersion(
name,
name.EndsWith('b'),
true,
createdAt
));
}
}
return versions.ToArray();
}
public async Task<MoonlightVersion> GetInstanceVersionAsync()
{
var knownVersions = await GetVersionsAsync();
string versionIdentifier;
if (!string.IsNullOrWhiteSpace(Options.Value.CurrentOverride))
versionIdentifier = Options.Value.CurrentOverride;
else
{
if (File.Exists(VersionPath))
versionIdentifier = await File.ReadAllTextAsync(VersionPath);
else
versionIdentifier = "unknown";
}
var version = knownVersions.FirstOrDefault(x => x.Identifier == versionIdentifier);
if (version != null)
return version;
return new MoonlightVersion(
versionIdentifier,
false,
false,
DateTimeOffset.UtcNow
);
}
public async Task<MoonlightVersion?> GetLatestVersionAsync()
{
var versions = await GetVersionsAsync();
return versions
.Where(x => x is { IsDevelopment: false, IsPreRelease: false })
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefault();
}
}

View File

@@ -1,10 +0,0 @@
using Microsoft.AspNetCore.Builder;
namespace Moonlight.Api.Startup;
public interface IAppStartup
{
public void PreBuild(WebApplicationBuilder builder);
public void PostBuild(WebApplication application);
public void PostMiddleware(WebApplication application);
}

View File

@@ -17,19 +17,25 @@ public partial class Startup
{ {
private static void AddAuth(WebApplicationBuilder builder) private static void AddAuth(WebApplicationBuilder builder)
{ {
// OIDC
var oidcOptions = new OidcOptions(); var oidcOptions = new OidcOptions();
builder.Configuration.GetSection("Moonlight:Oidc").Bind(oidcOptions); builder.Configuration.GetSection("Moonlight:Oidc").Bind(oidcOptions);
// API Key
var apiKeyOptions = new ApiOptions(); var apiKeyOptions = new ApiOptions();
builder.Configuration.GetSection("Moonlight:Api").Bind(apiKeyOptions); builder.Configuration.GetSection("Moonlight:Api").Bind(apiKeyOptions);
builder.Services.AddOptions<ApiOptions>().BindConfiguration("Moonlight:Api"); builder.Services.AddOptions<ApiOptions>().BindConfiguration("Moonlight:Api");
builder.Services.AddScoped<UserAuthService>(); // Session
builder.Services.AddOptions<UserOptions>().BindConfiguration("Moonlight:User");
// Authentication
builder.Services.AddAuthentication("Main") builder.Services.AddAuthentication("Main")
.AddPolicyScheme("Main", null, options => .AddPolicyScheme("Main", null,
options =>
{ {
options.ForwardDefaultSelector += context => context.Request.Headers.Authorization.Count > 0 ? "ApiKey" : "Session"; options.ForwardDefaultSelector += context =>
context.Request.Headers.Authorization.Count > 0 ? "ApiKey" : "Session";
}) })
.AddCookie("Session", null, options => .AddCookie("Session", null, options =>
{ {
@@ -74,6 +80,12 @@ public partial class Startup
options.Authority = oidcOptions.Authority; options.Authority = oidcOptions.Authority;
options.RequireHttpsMetadata = oidcOptions.RequireHttpsMetadata; options.RequireHttpsMetadata = oidcOptions.RequireHttpsMetadata;
if (oidcOptions.DisableHttpsOnlyCookies)
{
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
}
var scopes = oidcOptions.Scopes ?? ["openid", "email", "profile"]; var scopes = oidcOptions.Scopes ?? ["openid", "email", "profile"];
options.Scope.Clear(); options.Scope.Clear();
@@ -91,15 +103,26 @@ public partial class Startup
options.GetClaimsFromUserInfoEndpoint = true; options.GetClaimsFromUserInfoEndpoint = true;
}) })
.AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>("ApiKey", null, options => .AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>("ApiKey", null,
options =>
{ {
options.LookupCacheTime = TimeSpan.FromMinutes(apiKeyOptions.LookupCacheMinutes); options.LookupL1CacheTime = apiKeyOptions.LookupCacheL1Expiry;
options.LookupL2CacheTime = apiKeyOptions.LookupCacheL2Expiry;
}); });
// Authorization
builder.Services.AddAuthorization();
// Reduce log noise
builder.Logging.AddFilter("Moonlight.Api.Implementations.ApiKeyScheme.ApiKeySchemeHandler", LogLevel.Warning); builder.Logging.AddFilter("Moonlight.Api.Implementations.ApiKeyScheme.ApiKeySchemeHandler", LogLevel.Warning);
// Custom permission handling using named policies
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>(); builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>(); builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
builder.Services.AddScoped<UserAuthService>();
} }
private static void UseAuth(WebApplication application) private static void UseAuth(WebApplication application)

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Logging.Console;
@@ -9,44 +11,73 @@ using Moonlight.Api.Helpers;
using Moonlight.Api.Implementations; using Moonlight.Api.Implementations;
using Moonlight.Api.Interfaces; using Moonlight.Api.Interfaces;
using Moonlight.Api.Services; using Moonlight.Api.Services;
using SessionOptions = Microsoft.AspNetCore.Builder.SessionOptions;
namespace Moonlight.Api.Startup; namespace Moonlight.Api.Startup;
public partial class Startup public partial class Startup
{ {
private static void AddBase(WebApplicationBuilder builder) private void AddBase(WebApplicationBuilder builder)
{ {
builder.Services.AddControllers().AddJsonOptions(options => // Create the base directory
Directory.CreateDirectory("storage");
// Hook up source-generated serialization and add controllers
builder.Services
.AddControllers()
.AddApplicationPart(typeof(Startup).Assembly)
.AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SharedSerializationContext.Default); options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
}); });
// Configure logging
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); }); builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); });
builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>(); builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>();
// Application service
builder.Services.AddSingleton<ApplicationService>(); builder.Services.AddSingleton<ApplicationService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ApplicationService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<ApplicationService>());
// Diagnose
builder.Services.AddSingleton<DiagnoseService>(); builder.Services.AddSingleton<DiagnoseService>();
builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>(); builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>();
builder.Services.AddMemoryCache(); // Frontend
builder.Services.AddOptions<SessionOptions>().BindConfiguration("Moonlight:Session");
builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend"); builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
builder.Services.AddScoped<FrontendService>(); builder.Services.AddScoped<FrontendService>();
// HTTP Client
builder.Services.AddHttpClient();
// Version fetching configuration
builder.Services.AddOptions<VersionOptions>().BindConfiguration("Moonlight:Version");
builder.Services.AddSingleton<VersionService>();
// Container Helper Options
builder.Configuration.GetSection("Moonlight:ContainerHelper").Bind(builder.Configuration);
builder.Services.AddOptions<ContainerHelperOptions>().BindConfiguration("Moonlight:ContainerHelper"); builder.Services.AddOptions<ContainerHelperOptions>().BindConfiguration("Moonlight:ContainerHelper");
builder.Services.AddSingleton<ContainerHelperService>(); builder.Services.AddSingleton<ContainerHelperService>();
builder.Services.AddHttpClient("ContainerHelper", (provider, client) => builder.Services.AddHttpClient("ContainerHelper", (provider, client) =>
{ {
var options = provider.GetRequiredService<IOptions<ContainerHelperOptions>>(); var options = provider.GetRequiredService<IOptions<ContainerHelperOptions>>();
client.BaseAddress = new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid"); client.BaseAddress =
new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid");
}); });
// User management services
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
// Settings options
builder.Services.AddOptions<SettingsOptions>().BindConfiguration("Moonlight:Settings");
builder.Services.AddScoped<SettingsService>();
// Setup key loading
var keysDirectory = new DirectoryInfo(Path.Combine("storage", "keys"));
builder.Services.AddDataProtection().PersistKeysToFileSystem(keysDirectory);
} }
private static void UseBase(WebApplication application) private static void UseBase(WebApplication application)

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Api.Configuration;
namespace Moonlight.Api.Startup;
public partial class Startup
{
private static void AddCache(WebApplicationBuilder builder)
{
// Load cache options
var cacheOptions = new CacheOptions();
builder.Configuration.GetSection("Moonlight:Cache").Bind(cacheOptions);
builder.Services.AddMemoryCache();
builder.Services.AddHybridCache();
if (!cacheOptions.EnableLayer2)
return;
var redisOptions = new RedisOptions();
builder.Configuration.GetSection("Moonlight:Redis").Bind(redisOptions);
if(!redisOptions.Enable)
return;
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisOptions.ConnectionString;
options.InstanceName = "Moonlight:";
});
}
}

View File

@@ -1,23 +1,29 @@
using Microsoft.AspNetCore.Builder; using System.Reflection;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Moonlight.Shared.Http;
using SimplePlugin.Abstractions;
namespace Moonlight.Api.Startup; namespace Moonlight.Api.Startup;
public partial class Startup : IAppStartup [PluginModule]
public partial class Startup : MoonlightPlugin
{ {
public void PreBuild(WebApplicationBuilder builder) public override void PreBuild(WebApplicationBuilder builder)
{ {
AddBase(builder); AddBase(builder);
AddAuth(builder); AddAuth(builder);
AddDatabase(builder); AddDatabase(builder);
AddCache(builder);
} }
public void PostBuild(WebApplication application) public override void PostBuild(WebApplication application)
{ {
UseBase(application); UseBase(application);
UseAuth(application); UseAuth(application);
} }
public void PostMiddleware(WebApplication application) public override void PostMiddleware(WebApplication application)
{ {
MapBase(application); MapBase(application);
} }

View File

@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;
namespace Moonlight.Api;
public static class StartupHandler
{
public static async Task RunAsync(string[] args, MoonlightPlugin[] plugins)
{
Console.WriteLine($"Starting with: {string.Join(", ", plugins.Select(x => x.GetType().FullName))}");
var builder = WebApplication.CreateBuilder(args);
// Setting up context
foreach (var plugin in plugins)
plugin.Initialize(plugins);
// Stage 1: Pre Build
foreach (var startup in plugins)
startup.PreBuild(builder);
var app = builder.Build();
// Stage 2: Post Build
foreach (var startup in plugins)
startup.PostBuild(app);
// Stage 3: Post Middleware
foreach (var startup in plugins)
startup.PostMiddleware(app);
// Frontend debugging
if (app.Environment.IsDevelopment())
app.UseWebAssemblyDebugging();
// Frontend hosting
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
await app.RunAsync();
}
}

View File

@@ -0,0 +1,26 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Frontend.Interfaces;
namespace Moonlight.Frontend.Configuration;
public class LayoutMiddlewareOptions
{
public IReadOnlyList<Type> Components => InnerComponents;
private readonly List<Type> InnerComponents = new();
public void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() where T : LayoutMiddlewareBase
{
InnerComponents.Add(typeof(T));
}
public void Insert<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(int index) where T : LayoutMiddlewareBase
{
InnerComponents.Insert(index, typeof(T));
}
public void Remove<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() where T : LayoutMiddlewareBase
{
InnerComponents.Remove(typeof(T));
}
}

View File

@@ -0,0 +1,33 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
namespace Moonlight.Frontend.Configuration;
public class LayoutPageOptions
{
public IReadOnlyList<LayoutPageComponent> Components => InnerComponents;
private readonly List<LayoutPageComponent> InnerComponents = new();
public void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(LayoutPageSlot slot, int order)
where T : ComponentBase
=> Add(typeof(T), slot, order);
public void Add(Type componentType, LayoutPageSlot slot, int order)
=> InnerComponents.Add(new LayoutPageComponent(componentType, order, slot));
public void Remove<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
where T : ComponentBase
=> Remove(typeof(T));
public void Remove(Type componentType)
=> InnerComponents.RemoveAll(x => x.ComponentType == componentType);
}
public record LayoutPageComponent(Type ComponentType, int Order, LayoutPageSlot Slot);
public enum LayoutPageSlot
{
Header = 0,
Footer = 1
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace Moonlight.Frontend.Configuration;
public class NavigationAssemblyOptions
{
public List<Assembly> Assemblies { get; private set; } = new();
}

View File

@@ -0,0 +1,35 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
namespace Moonlight.Frontend.Configuration;
public class SystemSettingsOptions
{
public IReadOnlyList<SystemSettingsPage> Components => InnerComponents;
private readonly List<SystemSettingsPage> InnerComponents = new();
public void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TIcon,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>(string name, string description,
int order)
where TIcon : ComponentBase where TComponent : ComponentBase
=> Add(name, description, order, typeof(TIcon), typeof(TComponent));
public void Add(string name, string description, int order, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type iconComponent, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type component)
=> InnerComponents.Add(new SystemSettingsPage(name, description, order, iconComponent, component));
public void Remove<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>()
where TComponent : ComponentBase
=> Remove(typeof(TComponent));
public void Remove([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType)
=> InnerComponents.RemoveAll(x => x.ComponentType == componentType);
}
public record SystemSettingsPage(
string Name,
string Description,
int Order,
Type IconComponentType,
Type ComponentType
);

View File

@@ -1,28 +0,0 @@
using System.Text.Json;
using Moonlight.Shared.Http;
namespace Moonlight.Frontend;
public static class Constants
{
public static JsonSerializerOptions SerializerOptions
{
get
{
if (InternalOptions != null)
return InternalOptions;
InternalOptions = new()
{
PropertyNameCaseInsensitive = true
};
// Add source generated options from shared project
InternalOptions.TypeInfoResolverChain.Add(SharedSerializationContext.Default);
return InternalOptions;
}
}
private static JsonSerializerOptions? InternalOptions;
}

View File

@@ -27,6 +27,9 @@ public sealed class PermissionProvider : IPermissionProvider
new PermissionCategory("System", typeof(CogIcon), [ new PermissionCategory("System", typeof(CogIcon), [
new Permission(Permissions.System.Info, "Info", "View system info"), new Permission(Permissions.System.Info, "Info", "View system info"),
new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"), new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"),
new Permission(Permissions.System.Versions, "Versions", "Look at the available versions"),
new Permission(Permissions.System.Instance, "Instance", "Update the moonlight instance and add plugins"),
new Permission(Permissions.System.Settings, "Settings", "Change settings of the instance"),
]), ]),
new PermissionCategory("API Keys", typeof(KeyIcon), [ new PermissionCategory("API Keys", typeof(KeyIcon), [
new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"), new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"),

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Components;
namespace Moonlight.Frontend.Interfaces;
public abstract class LayoutMiddlewareBase : ComponentBase
{
[Parameter] public RenderFragment ChildContent { get; set; }
}

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.ApiKeys; using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys; using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Frontend.Mappers; namespace Moonlight.Frontend.Mappers;

View File

@@ -1,5 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Themes; using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses.Themes; using Moonlight.Shared.Http.Responses.Admin.Themes;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Frontend.Mappers; namespace Moonlight.Frontend.Mappers;

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Frontend.Mappers; namespace Moonlight.Frontend.Mappers;

View File

@@ -24,8 +24,9 @@
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1"/> <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/> <PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
<PackageReference Include="ShadcnBlazor" Version="1.0.11" /> <PackageReference Include="ShadcnBlazor" Version="1.0.13" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.11" /> <PackageReference Include="ShadcnBlazor.Extras" Version="1.0.13" />
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -35,5 +36,6 @@
<ItemGroup> <ItemGroup>
<None Include="Styles/*" Pack="true" PackagePath="Styles/" /> <None Include="Styles/*" Pack="true" PackagePath="Styles/" />
<None Include="Moonlight.Frontend.targets" Pack="true" PackagePath="build\Moonlight.Frontend.targets" /> <None Include="Moonlight.Frontend.targets" Pack="true" PackagePath="build\Moonlight.Frontend.targets" />
<None Include="Moonlight.Frontend.targets" Pack="true" PackagePath="buildTransitive\Moonlight.Frontend.targets" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -5,7 +5,7 @@
</MoonlightCssClassDir> </MoonlightCssClassDir>
</PropertyGroup> </PropertyGroup>
<Target Name="CopyContents" BeforeTargets="Build"> <Target Name="Moonlight_CopyContents" BeforeTargets="Build">
<ItemGroup> <ItemGroup>
<Files Include="$(MSBuildThisFileDirectory)..\Styles\**\*" /> <Files Include="$(MSBuildThisFileDirectory)..\Styles\**\*" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using SimplePlugin.Abstractions;
namespace Moonlight.Frontend;
public abstract class MoonlightPlugin : IPluginModule
{
protected MoonlightPlugin[] Plugins { get; private set; }
public void Initialize(MoonlightPlugin[] plugins)
{
Plugins = plugins;
}
public virtual void PreBuild(WebAssemblyHostBuilder builder){}
public virtual void PostBuild(WebAssemblyHost application){}
}

View File

@@ -3,7 +3,9 @@ using System.Net.Http.Json;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moonlight.Shared.Http.Responses.Auth; using Microsoft.VisualBasic;
using Moonlight.Shared.Http;
using Moonlight.Shared.Http.Responses.Admin.Auth;
namespace Moonlight.Frontend.Services; namespace Moonlight.Frontend.Services;
@@ -23,7 +25,7 @@ public class RemoteAuthProvider : AuthenticationStateProvider
try try
{ {
var claimResponses = await HttpClient.GetFromJsonAsync<ClaimDto[]>( var claimResponses = await HttpClient.GetFromJsonAsync<ClaimDto[]>(
"api/auth/claims", Constants.SerializerOptions "api/auth/claims", SerializationContext.Default.Options
); );
var claims = claimResponses!.Select(claim => new Claim(claim.Type, claim.Value)); var claims = claimResponses!.Select(claim => new Claim(claim.Type, claim.Value));

View File

@@ -1,9 +0,0 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
namespace Moonlight.Frontend.Startup;
public interface IAppStartup
{
public void PreBuild(WebAssemblyHostBuilder builder);
public void PostBuild(WebAssemblyHost application);
}

View File

@@ -1,10 +1,13 @@
using LucideBlazor;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Moonlight.Frontend.Configuration;
using Moonlight.Frontend.Implementations; using Moonlight.Frontend.Implementations;
using Moonlight.Frontend.Interfaces; using Moonlight.Frontend.Interfaces;
using Moonlight.Frontend.Services; using Moonlight.Frontend.Services;
using Moonlight.Frontend.UI; using Moonlight.Frontend.UI;
using Moonlight.Frontend.UI.Admin.Settings;
using ShadcnBlazor; using ShadcnBlazor;
using ShadcnBlazor.Extras; using ShadcnBlazor.Extras;
@@ -25,5 +28,19 @@ public partial class Startup
builder.Services.AddSingleton<ISidebarProvider, SidebarProvider>(); builder.Services.AddSingleton<ISidebarProvider, SidebarProvider>();
builder.Services.AddScoped<FrontendService>(); builder.Services.AddScoped<FrontendService>();
builder.Services.Configure<NavigationAssemblyOptions>(options =>
{
options.Assemblies.Add(typeof(Startup).Assembly);
});
builder.Services.Configure<SystemSettingsOptions>(options =>
{
options.Add<TextCursorInputIcon, WhiteLabelingSetting>(
"White Labeling",
"Settings for white labeling your moonlight instance",
0
);
});
} }
} }

View File

@@ -1,16 +1,18 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using SimplePlugin.Abstractions;
namespace Moonlight.Frontend.Startup; namespace Moonlight.Frontend.Startup;
public partial class Startup : IAppStartup [PluginModule]
public partial class Startup : MoonlightPlugin
{ {
public void PreBuild(WebAssemblyHostBuilder builder) public override void PreBuild(WebAssemblyHostBuilder builder)
{ {
AddBase(builder); AddBase(builder);
AddAuth(builder); AddAuth(builder);
} }
public void PostBuild(WebAssemblyHost application) public override void PostBuild(WebAssemblyHost application)
{ {
} }

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
namespace Moonlight.Frontend;
public static class StartupHandler
{
public static async Task RunAsync(string[] args, MoonlightPlugin[] plugins)
{
Console.WriteLine($"Starting with: {string.Join(", ", plugins.Select(x => x.GetType().FullName))}");
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Setting up context
foreach (var plugin in plugins)
plugin.Initialize(plugins);
// Stage 1: Pre Build
foreach (var plugin in plugins)
plugin.PreBuild(builder);
var app = builder.Build();
// Stage 2: Post Build
foreach(var plugin in plugins)
plugin.PostBuild(app);
await app.RunAsync();
}
}

View File

@@ -1,6 +1,7 @@
@using Moonlight.Frontend.Helpers @using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.UI.Admin.Components @using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.ApiKeys @using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.ApiKeys
@using Moonlight.Shared.Http.Responses @using Moonlight.Shared.Http.Responses
@using ShadcnBlazor.Dialogs @using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms @using ShadcnBlazor.Extras.Forms
@@ -35,6 +36,10 @@
<FieldLabel for="keyDescription">Description</FieldLabel> <FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/> <TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field> </Field>
<Field>
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil" />
</Field>
<Field> <Field>
<FieldLabel>Permissions</FieldLabel> <FieldLabel>Permissions</FieldLabel>
<FieldContent> <FieldContent>
@@ -60,18 +65,20 @@
{ {
Request = new() Request = new()
{ {
Permissions = [] Permissions = [],
ValidUntil = DateTimeOffset.UtcNow
}; };
} }
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore) private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{ {
Request.Permissions = Permissions.ToArray(); Request.Permissions = Permissions.ToArray();
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PostAsJsonAsync( var response = await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys", "/api/admin/apiKeys",
Request, Request,
Constants.SerializerOptions SerializationContext.Default.Options
); );
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)

View File

@@ -1,6 +1,7 @@
@using Moonlight.Frontend.Helpers @using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.UI.Admin.Components @using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.Roles @using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Roles
@using ShadcnBlazor.Dialogs @using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms @using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts @using ShadcnBlazor.Extras.Toasts
@@ -76,7 +77,7 @@
var response = await HttpClient.PostAsJsonAsync( var response = await HttpClient.PostAsJsonAsync(
"api/admin/roles", "api/admin/roles",
Request, Request,
Constants.SerializerOptions SerializationContext.Default.Options
); );
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)

View File

@@ -1,5 +1,6 @@
@using Moonlight.Frontend.Helpers @using Moonlight.Frontend.Helpers
@using Moonlight.Shared.Http.Requests.Users @using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Users
@using Moonlight.Shared.Http.Responses @using Moonlight.Shared.Http.Responses
@using ShadcnBlazor.Dialogs @using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms @using ShadcnBlazor.Extras.Forms
@@ -65,7 +66,7 @@
var response = await HttpClient.PostAsJsonAsync( var response = await HttpClient.PostAsJsonAsync(
"/api/admin/users", "/api/admin/users",
Request, Request,
Constants.SerializerOptions SerializationContext.Default.Options
); );
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)

View File

@@ -1,7 +1,7 @@
@using LucideBlazor @using LucideBlazor
@using Moonlight.Shared.Http.Responses @using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin @using Moonlight.Shared.Http.Responses.Admin
@using Moonlight.Shared.Http.Responses.Users @using Moonlight.Shared.Http.Responses.Admin.Users
@using ShadcnBlazor.Buttons @using ShadcnBlazor.Buttons
@using ShadcnBlazor.DataGrids @using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dialogs @using ShadcnBlazor.Dialogs

View File

@@ -1,8 +1,9 @@
@using Moonlight.Frontend.Helpers @using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers @using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.UI.Admin.Components @using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.ApiKeys @using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Responses.ApiKeys @using Moonlight.Shared.Http.Requests.Admin.ApiKeys
@using Moonlight.Shared.Http.Responses.Admin.ApiKeys
@using ShadcnBlazor.Dialogs @using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms @using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts @using ShadcnBlazor.Extras.Toasts
@@ -35,6 +36,10 @@
<FieldLabel for="keyDescription">Description</FieldLabel> <FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/> <TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field> </Field>
<Field>
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil" />
</Field>
<Field> <Field>
<FieldLabel>Permissions</FieldLabel> <FieldLabel>Permissions</FieldLabel>
<FieldContent> <FieldContent>
@@ -65,11 +70,12 @@
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore) private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{ {
Request.Permissions = Permissions.ToArray(); Request.Permissions = Permissions.ToArray();
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PatchAsJsonAsync( var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/apiKeys/{Key.Id}", $"/api/admin/apiKeys/{Key.Id}",
Request, Request,
Constants.SerializerOptions SerializationContext.Default.Options
); );
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)

View File

@@ -2,6 +2,7 @@
@using System.Text.Json @using System.Text.Json
@using LucideBlazor @using LucideBlazor
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Events @using Moonlight.Shared.Http.Events
@using Moonlight.Shared.Http.Requests.Admin.ContainerHelper @using Moonlight.Shared.Http.Requests.Admin.ContainerHelper
@using ShadcnBlazor.Buttons @using ShadcnBlazor.Buttons
@@ -18,13 +19,20 @@
</DialogHeader> </DialogHeader>
<div class="grid grid-cols-1 xl:grid-cols-2 w-full gap-5"> <div class="grid grid-cols-1 xl:grid-cols-2 w-full gap-5">
<div class="text-base flex flex-col p-2 gap-y-0.5"> <div class="text-base flex flex-col p-2 gap-y-1">
@for (var i = 0; i < Steps.Length; i++) @for (var i = 0; i < Steps.Length; i++)
{ {
if (CurrentStep == i) if (CurrentStep == i)
{ {
<div class="flex flex-row items-center gap-x-2"> <div class="flex flex-row items-center gap-x-1">
<Spinner ClassName="size-4"/> @if (IsFailed)
{
<CircleXIcon ClassName="text-red-500 size-5"/>
}
else
{
<Spinner ClassName="size-5"/>
}
<span> <span>
@Steps[i] @Steps[i]
</span> </span>
@@ -34,8 +42,8 @@
{ {
if (i < CurrentStep) if (i < CurrentStep)
{ {
<div class="flex flex-row items-center gap-x-2"> <div class="flex flex-row items-center gap-x-1 text-muted-foreground">
<CheckIcon ClassName="text-green-500 size-4"/> <CircleCheckIcon ClassName="text-green-500 size-5"/>
<span> <span>
@Steps[i] @Steps[i]
</span> </span>
@@ -43,8 +51,8 @@
} }
else else
{ {
<div class="text-muted-foreground flex flex-row items-center gap-x-2"> <div class="text-muted-foreground flex flex-row items-center gap-x-1">
<span class="size-4"></span> <span class="size-5"></span>
@Steps[i] @Steps[i]
</div> </div>
} }
@@ -61,7 +69,7 @@
</div> </div>
</div> </div>
@if (CurrentStep == Steps.Length) @if (CurrentStep == Steps.Length || IsFailed)
{ {
<DialogFooter ClassName="justify-end"> <DialogFooter ClassName="justify-end">
<Button Variant="ButtonVariant.Outline" @onclick="CloseAsync">Close</Button> <Button Variant="ButtonVariant.Outline" @onclick="CloseAsync">Close</Button>
@@ -77,7 +85,9 @@ else
@code @code
{ {
[Parameter] public string Version { get; set; } [Parameter] public string Version { get; set; }
[Parameter] public bool NoBuildCache { get; set; }
private bool IsFailed;
private int Progress; private int Progress;
private int CurrentStep; private int CurrentStep;
@@ -114,15 +124,23 @@ else
await HttpClient.PostAsJsonAsync("api/admin/ch/version", new SetVersionDto() await HttpClient.PostAsJsonAsync("api/admin/ch/version", new SetVersionDto()
{ {
Version = Version Version = Version
}); }, SerializationContext.Default.Options);
// Starting rebuild task // Starting rebuild task
CurrentStep = 2; CurrentStep = 2;
Progress = 30; Progress = 30;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
var request = new HttpRequestMessage(HttpMethod.Post, "api/admin/ch/rebuild");
request.Content = JsonContent.Create(
new RequestRebuildDto(NoBuildCache),
null,
SerializationContext.Default.Options
);
var response = await HttpClient.SendAsync( var response = await HttpClient.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "api/admin/ch/rebuild"), request,
HttpCompletionOption.ResponseHeadersRead HttpCompletionOption.ResponseHeadersRead
); );
@@ -142,7 +160,7 @@ else
continue; continue;
var data = line.Trim("data: "); var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, Constants.SerializerOptions); var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.Default.Options);
switch (deserializedData.Type) switch (deserializedData.Type)
{ {
@@ -176,6 +194,13 @@ else
} }
break; break;
case RebuildEventType.Failed:
IsFailed = true;
await InvokeAsync(StateHasChanged);
return;
} }
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);

View File

@@ -1,7 +1,8 @@
@using Moonlight.Frontend.Helpers @using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers @using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.UI.Admin.Components @using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.Roles @using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Roles
@using Moonlight.Shared.Http.Responses.Admin @using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Dialogs @using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms @using ShadcnBlazor.Extras.Forms
@@ -75,7 +76,7 @@
var response = await HttpClient.PatchAsJsonAsync( var response = await HttpClient.PatchAsJsonAsync(
$"api/admin/roles/{Role.Id}", $"api/admin/roles/{Role.Id}",
Request, Request,
Constants.SerializerOptions SerializationContext.Default.Options
); );
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)

Some files were not shown because too many files have changed in this diff Show More