32 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
96 changed files with 1674 additions and 434 deletions

View File

@@ -34,7 +34,7 @@ jobs:
# Publish frontend
# We need to build it first so the class list files generate
- 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
working-directory: Hosts/Moonlight.Frontend.Host/Styles

2
.gitignore vendored
View File

@@ -400,8 +400,10 @@ FodyWeavers.xsd
# Style builds
**/style.min.css
**/package-lock.json
**/bun.lock
# Secrets
**/.env
**/appsettings.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
FROM cgr.dev/chainguard/aspnet-runtime:latest AS base
FROM git.battlestati.one/moonlight-panel/app_base:moonlight AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# Build dependencies
RUN apt-get update; apt-get install nodejs npm -y; apt-get clean
# Install required packages
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
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"]
RUN npm install
RUN bun install
# Restore nuget packages
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.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.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
WORKDIR "/src/Hosts/Moonlight.Frontend.Host/Styles"
RUN npm run build
RUN bun run build
# Build projects
WORKDIR "/src/Hosts/Moonlight.Api.Host"
@@ -67,4 +72,6 @@ WORKDIR /app
COPY --from=publish /app/publish-api .
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"]

View File

@@ -7,15 +7,13 @@
</PropertyGroup>
<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">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="SimplePlugin" Version="1.0.2" />
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
</ItemGroup>
<ItemGroup>
@@ -29,4 +27,5 @@
</Content>
</ItemGroup>
<Import Project="Api.props"/>
</Project>

View File

@@ -1,22 +1,9 @@
using Moonlight.Api.Host;
using Moonlight.Api;
using SimplePlugin.Generated;
var appLoader = new AppStartupLoader();
appLoader.Initialize();
var plugins = PluginRegistry
.Modules
.OfType<MoonlightPlugin>()
.ToArray();
var builder = WebApplication.CreateBuilder(args);
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();
await StartupHandler.RunAsync(args, plugins);

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>
<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="MoonCore.PluginFramework" Version="1.0.9"/>
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/>
<PackageReference Include="SimplePlugin" Version="1.0.2" />
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Moonlight.Frontend\Moonlight.Frontend.csproj" />
</ItemGroup>
<Import Project="Frontend.props"/>
</Project>

View File

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

View File

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

View File

@@ -2,5 +2,6 @@ namespace Moonlight.Api.Configuration;
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 bool RequireHttpsMetadata { get; set; } = true;
public bool DisableHttpsOnlyCookies { get; set; }
public string ResponseType { get; set; } = "code";
public string[]? Scopes { 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

@@ -2,5 +2,6 @@
public class SettingsOptions
{
public int CacheMinutes { get; set; } = 3;
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,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 string[] Permissions { get; set; } = [];
public DateTimeOffset ValidUntil { get; set; }
[MaxLength(32)]
public string Key { get; set; }

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")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ValidUntil")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");

View File

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

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

@@ -81,6 +81,8 @@ public class SetupController : Controller
Permissions.System.Info,
Permissions.System.Diagnose,
Permissions.System.Versions,
Permissions.System.Instance,
]
});
}

View File

@@ -11,7 +11,7 @@ using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.Themes;
namespace Moonlight.Api.Http.Controllers.Admin;
namespace Moonlight.Api.Http.Controllers.Admin.Themes;
[ApiController]
[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

@@ -10,7 +10,7 @@ using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Http.Controllers.Admin;
namespace Moonlight.Api.Http.Controllers.Admin.Users;
[Authorize]
[ApiController]
@@ -117,19 +117,4 @@ public class UsersController : Controller
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

@@ -9,21 +9,9 @@ namespace Moonlight.Api.Http.Services.ContainerHelper;
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(RebuildEventDto))]
[JsonSerializable(typeof(RequestRebuildDto))]
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
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 Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Database;
@@ -14,18 +14,20 @@ namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
{
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly IMemoryCache MemoryCache;
private readonly HybridCache HybridCache;
public const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
public ApiKeySchemeHandler(
IOptionsMonitor<ApiKeySchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
DatabaseRepository<ApiKey> apiKeyRepository,
IMemoryCache memoryCache
HybridCache hybridCache
) : base(options, logger, encoder)
{
ApiKeyRepository = apiKeyRepository;
MemoryCache = memoryCache;
HybridCache = hybridCache;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
@@ -38,24 +40,30 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
if (authHeaderValue.Length > 32)
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()
.Where(x => x.Key == authHeaderValue)
.Select(x => new ApiKeySession(x.Permissions))
.FirstOrDefaultAsync();
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
MemoryCache.Set(authHeaderValue, apiKey, Options.LookupCacheTime);
}
else
.Select(x => new ApiKeySession(x.Permissions, x.ValidUntil))
.FirstOrDefaultAsync(cancellationToken: ct);
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.LookupL1CacheTime,
Expiration = Options.LookupL2CacheTime
}
);
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
}
if (DateTimeOffset.UtcNow > apiKey.ValidUntil)
return AuthenticateResult.Fail("Api key expired");
return AuthenticateResult.Success(new AuthenticationTicket(
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 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

@@ -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>
<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.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="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>

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,5 +1,4 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Net.Http.Json;
using System.Text.Json;
using Moonlight.Api.Http.Services.ContainerHelper;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
@@ -42,7 +41,7 @@ public class ContainerHelperService
request.Content = JsonContent.Create(
new RequestRebuildDto(noBuildCache),
null,
SerializationContext.TunedOptions
SerializationContext.Default.Options
);
var response = await client.SendAsync(
@@ -77,7 +76,7 @@ public class ContainerHelperService
continue;
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;
@@ -100,14 +99,14 @@ public class ContainerHelperService
var response = await client.PostAsJsonAsync(
"api/configuration/version",
new SetVersionDto(version),
SerializationContext.TunedOptions
SerializationContext.Default.Options
);
if (response.IsSuccessStatusCode)
return;
var problemDetails =
await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.TunedOptions);
await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.Default.Options);
if (problemDetails == null)
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.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Constants;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Models;
@@ -13,14 +14,16 @@ public class FrontendService
private readonly IMemoryCache Cache;
private readonly DatabaseRepository<Theme> ThemeRepository;
private readonly IOptions<FrontendOptions> Options;
private readonly SettingsService SettingsService;
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;
ThemeRepository = themeRepository;
Options = options;
SettingsService = settingsService;
}
public async Task<FrontendConfiguration> GetConfigurationAsync()
@@ -35,7 +38,9 @@ public class FrontendService
.Query()
.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));

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Database;
@@ -12,18 +12,18 @@ public class SettingsService
{
private readonly DatabaseRepository<SettingsOption> Repository;
private readonly IOptions<SettingsOptions> Options;
private readonly IMemoryCache Cache;
private readonly HybridCache HybridCache;
private const string CacheKey = "Moonlight.Api.SettingsService.{0}";
public SettingsService(
DatabaseRepository<SettingsOption> repository,
IOptions<SettingsOptions> options,
IMemoryCache cache
HybridCache hybridCache
)
{
Repository = repository;
Cache = cache;
HybridCache = hybridCache;
Options = options;
}
@@ -31,24 +31,26 @@ public class SettingsService
{
var cacheKey = string.Format(CacheKey, key);
if (Cache.TryGetValue<string>(cacheKey, out var value))
return JsonSerializer.Deserialize<T>(value!);
value = await Repository
var value = await HybridCache.GetOrCreateAsync<string?>(
cacheKey,
async ct =>
{
return await Repository
.Query()
.Where(x => x.Key == key)
.Select(o => o.ValueJson)
.FirstOrDefaultAsync();
if(string.IsNullOrEmpty(value))
return default;
Cache.Set(
cacheKey,
value,
TimeSpan.FromMinutes(Options.Value.CacheMinutes)
.FirstOrDefaultAsync(cancellationToken: ct);
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.Value.LookupL1CacheTime,
Expiration = Options.Value.LookupL2CacheTime
}
);
if (string.IsNullOrWhiteSpace(value))
return default;
return JsonSerializer.Deserialize<T>(value);
}
@@ -78,6 +80,6 @@ public class SettingsService
await Repository.AddAsync(option);
}
Cache.Remove(cacheKey);
await HybridCache.RemoveAsync(cacheKey);
}
}

View File

@@ -1,11 +1,12 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
using Moonlight.Shared;
namespace Moonlight.Api.Services;
@@ -13,9 +14,10 @@ namespace Moonlight.Api.Services;
public class UserAuthService
{
private readonly DatabaseRepository<User> UserRepository;
private readonly IMemoryCache Cache;
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 IssuedAtClaim = "IssuedAt";
@@ -25,12 +27,16 @@ public class UserAuthService
public UserAuthService(
DatabaseRepository<User> userRepository,
ILogger<UserAuthService> logger,
IMemoryCache cache, IOptions<SessionOptions> options)
IOptions<UserOptions> options,
IEnumerable<IUserAuthHook> hooks,
HybridCache hybridCache
)
{
UserRepository = userRepository;
Logger = logger;
Cache = cache;
Options = options;
Hooks = hooks;
HybridCache = hybridCache;
}
public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
@@ -50,7 +56,6 @@ public class UserAuthService
// We use email as the primary identifier here
var user = await UserRepository
.Query()
.AsNoTracking()
.FirstOrDefaultAsync(user => user.Email == email);
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())
]);
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;
}
@@ -90,9 +102,11 @@ public class UserAuthService
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()
.AsNoTracking()
.Where(u => u.Id == userId)
@@ -100,22 +114,17 @@ public class UserAuthService
u.InvalidateTimestamp,
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
)
.FirstOrDefaultAsync();
if (user == null)
return false;
Cache.Set(
cacheKey,
user,
TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes)
);
}
else
.FirstOrDefaultAsync(cancellationToken: ct);
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.Value.ValidationCacheL1Expiry,
Expiration = Options.Value.ValidationCacheL2Expiry
}
);
if (user == null)
return false;
}
var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
@@ -131,10 +140,18 @@ public class UserAuthService
if (issuedAt < user.InvalidateTimestamp)
return false;
// Load every permission as claim
principal.Identities.First().AddClaims(
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;
}

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

@@ -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)
{
// OIDC
var oidcOptions = new OidcOptions();
builder.Configuration.GetSection("Moonlight:Oidc").Bind(oidcOptions);
// API Key
var apiKeyOptions = new ApiOptions();
builder.Configuration.GetSection("Moonlight:Api").Bind(apiKeyOptions);
builder.Services.AddOptions<ApiOptions>().BindConfiguration("Moonlight:Api");
builder.Services.AddScoped<UserAuthService>();
// Session
builder.Services.AddOptions<UserOptions>().BindConfiguration("Moonlight:User");
// Authentication
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 =>
{
@@ -74,6 +80,12 @@ public partial class Startup
options.Authority = oidcOptions.Authority;
options.RequireHttpsMetadata = oidcOptions.RequireHttpsMetadata;
if (oidcOptions.DisableHttpsOnlyCookies)
{
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
}
var scopes = oidcOptions.Scopes ?? ["openid", "email", "profile"];
options.Scope.Clear();
@@ -91,18 +103,26 @@ public partial class Startup
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);
// Custom permission handling using named policies
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddOptions<SettingsOptions>().BindConfiguration("Moonlight:Settings");
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
builder.Services.AddScoped<UserAuthService>();
}
private static void UseAuth(WebApplication application)

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
@@ -9,49 +11,73 @@ using Moonlight.Api.Helpers;
using Moonlight.Api.Implementations;
using Moonlight.Api.Interfaces;
using Moonlight.Api.Services;
using SessionOptions = Microsoft.AspNetCore.Builder.SessionOptions;
namespace Moonlight.Api.Startup;
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(SerializationContext.Default);
});
// Configure logging
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); });
builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>();
// Application service
builder.Services.AddSingleton<ApplicationService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ApplicationService>());
// Diagnose
builder.Services.AddSingleton<DiagnoseService>();
builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>();
builder.Services.AddMemoryCache();
builder.Services.AddOptions<SessionOptions>().BindConfiguration("Moonlight:Session");
// Frontend
builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
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.AddSingleton<ContainerHelperService>();
builder.Services.AddHttpClient("ContainerHelper", (provider, client) =>
{
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)

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;
public partial class Startup : IAppStartup
[PluginModule]
public partial class Startup : MoonlightPlugin
{
public void PreBuild(WebApplicationBuilder builder)
public override void PreBuild(WebApplicationBuilder builder)
{
AddBase(builder);
AddAuth(builder);
AddDatabase(builder);
AddCache(builder);
}
public void PostBuild(WebApplication application)
public override void PostBuild(WebApplication application)
{
UseBase(application);
UseAuth(application);
}
public void PostMiddleware(WebApplication application)
public override void PostMiddleware(WebApplication 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(SerializationContext.Default);
return InternalOptions;
}
}
private static JsonSerializerOptions? InternalOptions;
}

View File

@@ -29,6 +29,7 @@ public sealed class PermissionProvider : IPermissionProvider
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 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

@@ -24,8 +24,9 @@
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" 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="ShadcnBlazor" Version="1.0.11" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.11" />
<PackageReference Include="ShadcnBlazor" Version="1.0.13" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.13" />
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
</ItemGroup>
<ItemGroup>
@@ -35,5 +36,6 @@
<ItemGroup>
<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="buildTransitive\Moonlight.Frontend.targets" />
</ItemGroup>
</Project>

View File

@@ -5,7 +5,7 @@
</MoonlightCssClassDir>
</PropertyGroup>
<Target Name="CopyContents" BeforeTargets="Build">
<Target Name="Moonlight_CopyContents" BeforeTargets="Build">
<ItemGroup>
<Files Include="$(MSBuildThisFileDirectory)..\Styles\**\*" />
</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,6 +3,8 @@ using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using Microsoft.VisualBasic;
using Moonlight.Shared.Http;
using Moonlight.Shared.Http.Responses.Admin.Auth;
namespace Moonlight.Frontend.Services;
@@ -23,7 +25,7 @@ public class RemoteAuthProvider : AuthenticationStateProvider
try
{
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));

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.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Frontend.Configuration;
using Moonlight.Frontend.Implementations;
using Moonlight.Frontend.Interfaces;
using Moonlight.Frontend.Services;
using Moonlight.Frontend.UI;
using Moonlight.Frontend.UI.Admin.Settings;
using ShadcnBlazor;
using ShadcnBlazor.Extras;
@@ -25,5 +28,19 @@ public partial class Startup
builder.Services.AddSingleton<ISidebarProvider, SidebarProvider>();
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 SimplePlugin.Abstractions;
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);
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,5 +1,6 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.ApiKeys
@using Moonlight.Shared.Http.Responses
@using ShadcnBlazor.Dialogs
@@ -35,6 +36,10 @@
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field>
<Field>
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil" />
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
@@ -60,18 +65,20 @@
{
Request = new()
{
Permissions = []
Permissions = [],
ValidUntil = DateTimeOffset.UtcNow
};
}
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys",
Request,
Constants.SerializerOptions
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)

View File

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

View File

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

View File

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

View File

@@ -124,7 +124,7 @@ else
await HttpClient.PostAsJsonAsync("api/admin/ch/version", new SetVersionDto()
{
Version = Version
}, SerializationContext.TunedOptions);
}, SerializationContext.Default.Options);
// Starting rebuild task
CurrentStep = 2;
@@ -136,7 +136,7 @@ else
request.Content = JsonContent.Create(
new RequestRebuildDto(NoBuildCache),
null,
SerializationContext.TunedOptions
SerializationContext.Default.Options
);
var response = await HttpClient.SendAsync(
@@ -160,7 +160,7 @@ else
continue;
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)
{

View File

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

View File

@@ -0,0 +1,75 @@
@using LucideBlazor
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Services
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Settings
@using Moonlight.Shared.Http.Responses.Admin.Settings
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@inject HttpClient HttpClient
@inject ToastService ToastService
@inject FrontendService FrontendService
<LazyLoader Load="LoadAsync">
<EnhancedEditForm Model="Request" OnValidSubmit="OnValidSubmit">
<DataAnnotationsValidator />
<FieldSet>
<FormValidationSummary />
<FieldGroup>
<Field>
<FieldLabel>Name</FieldLabel>
<TextInputField @bind-Value="Request.Name"/>
</Field>
</FieldGroup>
</FieldSet>
<SubmitButton ClassName="mt-3">
<SaveIcon/>
Save
</SubmitButton>
</EnhancedEditForm>
</LazyLoader>
@code
{
private SetWhiteLabelingDto Request;
private async Task LoadAsync(LazyLoader _)
{
var dto = await HttpClient.GetFromJsonAsync<WhiteLabelingDto>(
"api/admin/system/settings/whiteLabeling",
SerializationContext.Default.Options
);
Request = new SetWhiteLabelingDto()
{
Name = dto!.Name
};
}
private async Task<bool> OnValidSubmit(EditContext editContext, ValidationMessageStore validationMessageStore)
{
var response = await HttpClient.PostAsJsonAsync(
"api/admin/system/settings/whiteLabeling",
Request,
SerializationContext.Default.Options
);
if (response.IsSuccessStatusCode)
{
await FrontendService.ReloadAsync();
await ToastService.SuccessAsync("Setting", "Successfully updated white labeling settings");
return true;
}
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
}

View File

@@ -1,6 +1,7 @@
@page "/admin"
@using LucideBlazor
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards
@@ -155,7 +156,7 @@
if(!firstRender)
return;
InfoResponse = await HttpClient.GetFromJsonAsync<SystemInfoDto>("api/admin/system/info", Constants.SerializerOptions);
InfoResponse = await HttpClient.GetFromJsonAsync<SystemInfoDto>("api/admin/system/info", SerializationContext.Default.Options);
IsInfoLoading = false;
await InvokeAsync(StateHasChanged);

View File

@@ -3,6 +3,7 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin.ApiKeys
@@ -48,7 +49,21 @@
</CellTemplate>
</TemplateColumn>
<PropertyColumn IsFilterable="true"
Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description"/>
Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description" HeadClassName="hidden lg:table-cell" CellClassName="hidden lg:table-cell" />
<TemplateColumn Identifier="@nameof(ApiKeyDto.ValidUntil)" Title="Valid until" HeadClassName="hidden lg:table-cell">
<CellTemplate>
<TableCell ClassName="hidden lg:table-cell">
@{
var diff = context.ValidUntil - DateTimeOffset.UtcNow;
var text = string.Format(diff.TotalSeconds < 0 ? "Expired since {0}" : "Expires in {0}", Formatter.FormatDuration(diff));
}
<span>
@text
</span>
</TableCell>
</CellTemplate>
</TemplateColumn>
<TemplateColumn>
<CellTemplate>
<TableCell>
@@ -112,7 +127,7 @@
var response = await HttpClient.GetFromJsonAsync<PagedData<ApiKeyDto>>(
$"api/admin/apiKeys{query}&filterOptions={filterOptions}",
Constants.SerializerOptions
SerializationContext.Default.Options
);
return new DataGridResponse<ApiKeyDto>(response!.Data, response.TotalLength);

View File

@@ -4,18 +4,14 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Shared
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Tab
@using ShadcnBlazor.Labels
@inject NavigationManager Navigation
@inject IAuthorizationService AuthorizationService
<Tabs DefaultValue="@(Tab ?? "settings")" OnValueChanged="OnTabChanged">
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger Value="settings">
<TabsTrigger Value="settings" Disabled="@(!SettingsResult.Succeeded)">
<CogIcon />
Settings
</TabsTrigger>
@@ -27,7 +23,7 @@
<KeyIcon/>
API & API Keys
</TabsTrigger>
<TabsTrigger Value="diagnose">
<TabsTrigger Value="diagnose" Disabled="@(!DiagnoseResult.Succeeded)">
<HeartPulseIcon/>
Diagnose
</TabsTrigger>
@@ -36,19 +32,18 @@
Instance
</TabsTrigger>
</TabsList>
@if (SettingsResult.Succeeded)
{
<TabsContent Value="settings">
<Card ClassName="mt-5">
<CardFooter ClassName="justify-end">
<Button>
<SaveIcon />
Save changes
</Button>
</CardFooter>
</Card>
<Settings />
</TabsContent>
}
@if (DiagnoseResult.Succeeded)
{
<TabsContent Value="diagnose">
<Diagnose />
</TabsContent>
}
@if (ApiKeyAccess.Succeeded)
{
<TabsContent Value="apiKeys">
@@ -81,6 +76,8 @@
private AuthorizationResult ThemesAccess;
private AuthorizationResult InstanceResult;
private AuthorizationResult VersionsResult;
private AuthorizationResult SettingsResult;
private AuthorizationResult DiagnoseResult;
protected override async Task OnInitializedAsync()
{
@@ -90,6 +87,8 @@
ThemesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Themes.View);
InstanceResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Versions);
VersionsResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Instance);
SettingsResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Settings);
DiagnoseResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Diagnose);
}
private void OnTabChanged(string name)

View File

@@ -0,0 +1,52 @@
@using Microsoft.Extensions.Options
@using Moonlight.Frontend.Configuration
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Sidebars
@inject IOptions<SystemSettingsOptions> Options
<div class="mt-5 flex flex-col md:flex-row gap-5">
<Card ClassName="flex py-2 grow-0 min-w-56 max-h-[65vh] md:min-h-[65vh]">
<CardContent ClassName="px-2 flex flex-col gap-y-1 h-full max-h-[65vh] overflow-y-auto scrollbar-thin">
@foreach (var menuPage in Pages)
{
<SidebarMenuButton @onclick="() => Navigate(menuPage)" IsActive="@(CurrentPage == menuPage)" ClassName="overflow-visible">
<DynamicComponent Type="@menuPage.IconComponentType" />
<span>@menuPage.Name</span>
</SidebarMenuButton>
}
</CardContent>
</Card>
@if (CurrentPage != null)
{
<Card ClassName="flex grow">
<CardHeader>
<CardTitle>@CurrentPage.Name</CardTitle>
<CardDescription>@CurrentPage.Description</CardDescription>
</CardHeader>
<CardContent>
<DynamicComponent Type="@CurrentPage.ComponentType" />
</CardContent>
</Card>
}
</div>
@code
{
private SystemSettingsPage[] Pages;
private SystemSettingsPage? CurrentPage;
protected override void OnInitialized()
{
Pages = Options
.Value
.Components
.OrderBy(x => x.Order)
.ToArray();
CurrentPage = Pages.FirstOrDefault();
}
private void Navigate(SystemSettingsPage page)
=> CurrentPage = page;
}

View File

@@ -5,6 +5,7 @@
@using LucideBlazor
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Services
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Themes
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards
@@ -122,7 +123,7 @@
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/themes",
Request,
Constants.SerializerOptions
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)

View File

@@ -2,6 +2,7 @@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Shared
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin.Themes
@@ -18,6 +19,8 @@
@inject IAuthorizationService AuthorizationService
@inject HttpClient HttpClient
<InputFile OnChange="OnFileSelectedAsync" id="import-theme" class="hidden" multiple accept=".yml"/>
<div class="flex flex-row justify-between mt-5">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Themes</h1>
@@ -26,7 +29,15 @@
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button @onclick="CreateAsync" disabled="@(!CreateAccess.Succeeded)">
<Button Variant="ButtonVariant.Outline">
<Slot>
<label for="import-theme" @attributes="context">
<HardDriveUploadIcon/>
Import
</label>
</Slot>
</Button>
<Button @onclick="Create" disabled="@(!CreateAccess.Succeeded)">
<PlusIcon/>
Create
</Button>
@@ -39,7 +50,8 @@
<TemplateColumn Identifier="@nameof(ThemeDto.Name)" IsFilterable="true" Title="Name">
<CellTemplate>
<TableCell>
<a class="text-primary flex flex-row items-center" href="#" @onclick="() => EditAsync(context)" @onclick:preventDefault>
<a class="text-primary flex flex-row items-center" href="#" @onclick="() => Edit(context)"
@onclick:preventDefault>
@context.Name
@if (context.IsEnabled)
@@ -70,7 +82,13 @@
</Slot>
</DropdownMenuTrigger>
<DropdownMenuContent SideOffset="2">
<DropdownMenuItem OnClick="() => EditAsync(context)" Disabled="@(!EditAccess.Succeeded)">
<DropdownMenuItem OnClick="() => Download(context)">
Download
<DropdownMenuShortcut>
<HardDriveDownloadIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem OnClick="() => Edit(context)" Disabled="@(!EditAccess.Succeeded)">
Edit
<DropdownMenuShortcut>
<PenIcon/>
@@ -119,15 +137,17 @@
var response = await HttpClient.GetFromJsonAsync<PagedData<ThemeDto>>(
$"api/admin/themes{query}&filterOptions={filterOptions}",
Constants.SerializerOptions
SerializationContext.Default.Options
);
return new DataGridResponse<ThemeDto>(response!.Data, response.TotalLength);
}
private void CreateAsync() => Navigation.NavigateTo("/admin/system/themes/create");
private void Create() => Navigation.NavigateTo("/admin/system/themes/create");
private void EditAsync(ThemeDto theme) => Navigation.NavigateTo($"/admin/system/themes/{theme.Id}");
private void Edit(ThemeDto theme) => Navigation.NavigateTo($"/admin/system/themes/{theme.Id}");
private void Download(ThemeDto theme) => Navigation.NavigateTo($"api/admin/themes/{theme.Id}/export", true);
private async Task DeleteAsync(ThemeDto theme)
{
@@ -145,4 +165,31 @@
}
);
}
private async Task OnFileSelectedAsync(InputFileChangeEventArgs eventArgs)
{
var files = eventArgs.GetMultipleFiles();
foreach (var browserFile in files)
{
await using var contentStream = browserFile.OpenReadStream(browserFile.Size);
var response = await HttpClient.PostAsync(
"api/admin/themes/import",
new StreamContent(contentStream)
);
response.EnsureSuccessStatusCode();
var importedTheme = await response
.Content
.ReadFromJsonAsync<ThemeDto>(SerializationContext.Default.Options);
if (importedTheme == null)
continue;
await Grid.RefreshAsync();
await ToastService.SuccessAsync("Theme Import", $"Successfully imported theme {importedTheme.Name}");
}
}
}

View File

@@ -6,6 +6,7 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.Services
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Themes
@using Moonlight.Shared.Http.Responses.Admin.Themes
@using ShadcnBlazor.Buttons
@@ -136,7 +137,7 @@
var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/themes/{Theme.Id}",
Request,
Constants.SerializerOptions
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)

View File

@@ -3,6 +3,7 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin
@@ -123,7 +124,7 @@
var response = await HttpClient.GetFromJsonAsync<PagedData<RoleDto>>(
$"api/admin/roles{query}&filterOptions={filterOptions}",
Constants.SerializerOptions
SerializationContext.Default.Options
);
return new DataGridResponse<RoleDto>(response!.Data, response.TotalLength);

View File

@@ -3,6 +3,7 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using Moonlight.Shared.Http
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dropdowns
@@ -37,10 +38,18 @@
<div class="mt-3">
<DataGrid @ref="Grid" TGridItem="UserDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" Field="u => u.Id"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
Identifier="@nameof(UserDto.Username)" Field="u => u.Username"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
<PropertyColumn HeadClassName="text-left" Field="u => u.Id"/>
<TemplateColumn IsFilterable="true" Identifier="@nameof(UserDto.Username)" Title="Username">
<CellTemplate>
<TableCell>
<a class="text-primary" href="#"
@onclick="() => EditAsync(context)" @onclick:preventDefault>
@context.Username
</a>
</TableCell>
</CellTemplate>
</TemplateColumn>
<PropertyColumn HeadClassName="text-left" IsFilterable="true"
Identifier="@nameof(UserDto.Email)" Field="u => u.Email"/>
<TemplateColumn>
<CellTemplate>
@@ -113,7 +122,7 @@
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
$"api/admin/users{query}&filterOptions={filterOptions}",
Constants.SerializerOptions
SerializationContext.Default.Options
);
return new DataGridResponse<UserDto>(response!.Data, response.TotalLength);

View File

@@ -1,19 +1,28 @@
@using System.Net
@using System.Reflection
@using LucideBlazor
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.Extensions.Options
@using Moonlight.Frontend.Configuration
@using Moonlight.Frontend.UI.Shared
@using Moonlight.Frontend.UI.Shared.Components
@using ShadcnBlazor.Emptys
@using Moonlight.Frontend.UI.Shared.Components.Auth
@using Moonlight.Frontend.UI.Shared.Partials
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Dialogs
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Portals
@inject NavigationManager Navigation
@inject IOptions<NavigationAssemblyOptions> NavigationOptions
<ErrorBoundary>
<ChildContent>
<AuthorizeView>
<ChildContent>
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(NotFound)">
<LayoutMiddleware>
<Router AppAssembly="@typeof(App).Assembly" AdditionalAssemblies="Assemblies" NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
<NotAuthorized Context="authRouteViewContext">
@@ -22,6 +31,13 @@
</AuthorizeRouteView>
</Found>
</Router>
</LayoutMiddleware>
<ToastLauncher/>
<DialogLauncher/>
<AlertDialogLauncher/>
<PortalOutlet />
</ChildContent>
<Authorizing>
<Authenticating/>
@@ -72,3 +88,13 @@
}
</ErrorContent>
</ErrorBoundary>
@code
{
private Assembly[] Assemblies;
protected override void OnInitialized()
{
Assemblies = NavigationOptions.Value.Assemblies.ToArray();
}
}

View File

@@ -1,4 +1,5 @@
@using Moonlight.Shared.Http.Responses.Admin.Auth
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Responses.Admin.Auth
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Spinners
@using ShadcnBlazor.Buttons
@@ -48,7 +49,7 @@
return;
var schemes = await HttpClient.GetFromJsonAsync<SchemeDto[]>(
"api/auth", Constants.SerializerOptions
"api/auth", SerializationContext.Default.Options
);
if (schemes == null)

View File

@@ -0,0 +1,34 @@
@using Microsoft.Extensions.Options
@using Moonlight.Frontend.Configuration
@using Moonlight.Frontend.Interfaces
@inject IOptions<LayoutMiddlewareOptions> Options
@Chain
@code
{
[Parameter] public RenderFragment ChildContent { get; set; }
private RenderFragment Chain;
protected override void OnInitialized()
{
Chain = ChildContent;
foreach (var component in Options.Value.Components)
{
// Capture current values
var currentChain = Chain;
var currentComponent = component;
Chain = builder =>
{
builder.OpenComponent(0, currentComponent);
builder.SetKey(component);
builder.AddComponentParameter(1, nameof(LayoutMiddlewareBase.ChildContent), currentChain);
builder.CloseComponent();
};
}
}
}

View File

@@ -1,28 +1,53 @@
@using ShadcnBlazor.Extras.AlertDialogs
@using Microsoft.Extensions.Options
@using Moonlight.Frontend.Configuration
@using ShadcnBlazor.Extras.Alerts
@using ShadcnBlazor.Extras.Dialogs
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Portals
@using ShadcnBlazor.Sidebars
@inherits LayoutComponentBase
@inject IOptions<LayoutPageOptions> LayoutPageOptions
<SidebarProvider DefaultOpen="true">
<AppSidebar/>
<SidebarInset>
<AppHeader/>
@foreach (var headerComponent in HeaderComponents)
{
<DynamicComponent Type="headerComponent" />
}
<div class="mx-8 my-8 max-w-full">
<AlertLauncher/>
@Body
</div>
<ToastLauncher/>
<DialogLauncher/>
<AlertDialogLauncher/>
<PortalOutlet />
@foreach (var footerComponent in FooterComponents)
{
<DynamicComponent Type="footerComponent" />
}
</SidebarInset>
</SidebarProvider>
@code
{
private Type[] HeaderComponents;
private Type[] FooterComponents;
protected override void OnInitialized()
{
HeaderComponents = LayoutPageOptions.Value.Components
.Where(x => x.Slot == LayoutPageSlot.Header)
.OrderBy(x => x.Order)
.Select(x => x.ComponentType)
.ToArray();
FooterComponents = LayoutPageOptions.Value.Components
.Where(x => x.Slot == LayoutPageSlot.Footer)
.OrderBy(x => x.Order)
.Select(x => x.ComponentType)
.ToArray();
}
}

View File

@@ -4,13 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Admin.ApiKeys;
public class CreateApiKeyDto
{
[Required]
[MaxLength(30)]
public string Name { get; set; }
[Required] [MaxLength(30)] public string Name { get; set; }
[MaxLength(300)] public string Description { get; set; } = "";
[Required]
public string[] Permissions { get; set; }
[Required] public DateTimeOffset ValidUntil { get; set; }
[Required] public string[] Permissions { get; set; }
}

View File

@@ -4,12 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Admin.ApiKeys;
public class UpdateApiKeyDto
{
[Required]
[MaxLength(30)]
public string Name { get; set; }
[Required] [MaxLength(30)] public string Name { get; set; }
[MaxLength(300)] public string Description { get; set; } = "";
[Required]
public string[] Permissions { get; set; }
[Required] public DateTimeOffset ValidUntil { get; set; }
[Required] public string[] Permissions { get; set; }
}

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Admin.Settings;
public class SetWhiteLabelingDto
{
[Required]
public string Name { get; set; }
}

View File

@@ -1,3 +1,3 @@
namespace Moonlight.Shared.Http.Responses.Admin.ApiKeys;
public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, DateTimeOffset ValidUntil, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);

View File

@@ -0,0 +1,6 @@
namespace Moonlight.Shared.Http.Responses.Admin.Settings;
public class WhiteLabelingDto
{
public string Name { get; set; }
}

View File

@@ -4,12 +4,14 @@ using Moonlight.Shared.Http.Events;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Requests.Admin.Settings;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.Auth;
using Moonlight.Shared.Http.Responses.Admin.Settings;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Moonlight.Shared.Http.Responses.Admin.Users;
@@ -58,21 +60,13 @@ namespace Moonlight.Shared.Http;
//Misc
[JsonSerializable(typeof(VersionDto))]
[JsonSerializable(typeof(ProblemDetails))]
// Settings - White Labeling
[JsonSerializable(typeof(WhiteLabelingDto))]
[JsonSerializable(typeof(SetWhiteLabelingDto))]
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
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

@@ -55,5 +55,6 @@ public static class Permissions
public const string Diagnose = $"{Prefix}{Section}.{nameof(Diagnose)}";
public const string Versions = $"{Prefix}{Section}.{nameof(Versions)}";
public const string Instance = $"{Prefix}{Section}.{nameof(Instance)}";
public const string Settings = $"{Prefix}{Section}.{nameof(Settings)}";
}
}