30 Commits

Author SHA1 Message Date
6d1e6e1690 Cleaned up using in project. Improved prohect structure and refactored page names. Upgraded dependencies 2026-03-13 08:53:04 +01:00
1257e8b950 Refactored project to module structure 2026-03-12 22:50:15 +01:00
93de9c5d00 Upgraded dependencies to latest version
All checks were successful
Dev Publish: Nuget / Publish Dev Packages (push) Successful in 1m0s
2026-03-09 10:47:25 +01:00
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
5efe591f85 Started implementing api key expiration 2026-02-09 16:12:11 +01:00
240 changed files with 2715 additions and 1703 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

1
.gitignore vendored
View File

@@ -406,3 +406,4 @@ FodyWeavers.xsd
**/.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,20 +7,18 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<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> <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>
<ProjectReference Include="..\..\Moonlight.Api\Moonlight.Api.csproj"/> <ProjectReference Include="..\..\Moonlight.Api\Moonlight.Api.csproj"/>
<ProjectReference Include="..\Moonlight.Frontend.Host\Moonlight.Frontend.Host.csproj" /> <ProjectReference Include="..\Moonlight.Frontend.Host\Moonlight.Frontend.Host.csproj"/>
</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

@@ -12,13 +12,17 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all"/> <PackageReference Include="Microsoft.DotNet.HotReload.WebAssembly.Browser" Version="10.0.201" />
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/> <PackageReference Include="Microsoft.NET.ILLink.Tasks" Version="10.0.5" />
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/> <PackageReference Include="Microsoft.NET.Sdk.WebAssembly.Pack" Version="10.0.5" />
<PackageReference Include="SimplePlugin" Version="1.0.2"/>
<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

@@ -15,7 +15,7 @@ export default function extractTailwindClasses(opts = {}) {
}, },
OnceExit() { OnceExit() {
const classArray = Array.from(classSet).sort(); const classArray = Array.from(classSet).sort();
fs.mkdirSync('../../../Moonlight.Frontend/Styles', { recursive: true }); fs.mkdirSync('../../../Moonlight.Frontend/Styles', {recursive: true});
fs.writeFileSync('../../../Moonlight.Frontend/Styles/Moonlight.Frontend.map', classArray.join('\n')); fs.writeFileSync('../../../Moonlight.Frontend/Styles/Moonlight.Frontend.map', classArray.join('\n'));
console.log(`Extracted classes ${classArray.length}`); console.log(`Extracted classes ${classArray.length}`);
} }

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";
@@ -25,6 +25,7 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }

View File

@@ -1,14 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="dark"> <html class="dark" lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Moonlight</title> <title>Moonlight</title>
<base href="/" /> <base href="/"/>
<link rel="preload" id="webassembly" /> <link id="webassembly" rel="preload"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=fallback" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=fallback" rel="stylesheet"/>
<link rel="stylesheet" href="style.min.css" /> <link href="style.min.css" rel="stylesheet"/>
<script type="importmap"></script> <script type="importmap"></script>
<script> <script>
@@ -16,7 +16,7 @@
STYLE_TAG_ID: 'theme-variables', STYLE_TAG_ID: 'theme-variables',
configuration: {}, configuration: {},
applyTheme: function(cssContent) { applyTheme: function (cssContent) {
// Find or create the style tag // Find or create the style tag
let styleTag = document.getElementById(this.STYLE_TAG_ID); let styleTag = document.getElementById(this.STYLE_TAG_ID);
@@ -30,7 +30,7 @@
styleTag.textContent = cssContent; styleTag.textContent = cssContent;
}, },
reloadConfiguration: function (){ reloadConfiguration: function () {
try { try {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/frontend/config', false); xhr.open('GET', '/api/frontend/config', false);
@@ -44,7 +44,7 @@
} }
}, },
getConfiguration: function (){ getConfiguration: function () {
return this.configuration; return this.configuration;
}, },
@@ -61,48 +61,48 @@
</head> </head>
<body class="bg-background text-foreground"> <body class="bg-background text-foreground">
<div id="app"> <div id="app">
<div class="h-screen w-full flex items-center justify-center"> <div class="h-screen w-full flex items-center justify-center">
<div class="flex min-w-0 flex-1 flex-col items-center justify-center gap-3 rounded-lg border-dashed p-6 text-center text-balance md:p-12"> <div class="flex min-w-0 flex-1 flex-col items-center justify-center gap-3 rounded-lg border-dashed p-6 text-center text-balance md:p-12">
<div class="flex max-w-sm flex-col items-center gap-2 text-center"> <div class="flex max-w-sm flex-col items-center gap-2 text-center">
<div class="flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0 bg-muted text-foreground size-10 rounded-lg [&_svg:not([class*='size-'])]:size-6"> <div class="flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0 bg-muted text-foreground size-10 rounded-lg [&_svg:not([class*='size-'])]:size-6">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" <svg class="lucide lucide-zap-icon lucide-zap size-6" fill="none" height="24" stroke="currentColor" stroke-linecap="round"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24"
class="lucide lucide-zap-icon lucide-zap size-6"> xmlns="http://www.w3.org/2000/svg">
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/> <path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>
</svg> </svg>
</div>
</div>
<div class="text-lg font-medium tracking-tight">
Loading application
</div>
<div class="flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance">
<div class="bg-primary/20 w-full relative h-2 overflow-hidden rounded-full">
<div class="bg-primary h-full w-[var(--blazor-load-percentage,0)] flex-1 transition-all">
</div>
</div>
</div> </div>
</div> </div>
<div class="text-lg font-medium tracking-tight">
Loading application
</div>
<div class="flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance">
<div class="bg-primary/20 w-full relative h-2 overflow-hidden rounded-full">
<div class="bg-primary h-full w-[var(--blazor-load-percentage,0)] flex-1 transition-all">
</div>
</div>
</div>
</div> </div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div> </div>
</div>
<script src="/_content/ShadcnBlazor/interop.js" defer></script> <div id="blazor-error-ui">
<script src="/_content/ShadcnBlazor.Extras/interop.js" defer></script> An unhandled error has occurred.
<script src="/_content/ShadcnBlazor.Extras/codemirror-bundle.js" defer></script> <a class="reload" href=".">Reload</a>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script> <span class="dismiss">🗙</span>
</div>
<script defer src="/_content/ShadcnBlazor/interop.js"></script>
<script defer src="/_content/ShadcnBlazor.Extras/interop.js"></script>
<script defer src="/_content/ShadcnBlazor.Extras/codemirror-bundle.js"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body> </body>
</html> </html>

View File

@@ -1,23 +1,22 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Admin.Sys.Settings;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Services; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests.Seup; using Moonlight.Shared.Admin.Setup;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Setup;
[ApiController] [ApiController]
[Route("api/admin/setup")] [Route("api/admin/setup")]
public class SetupController : Controller public class SetupController : Controller
{ {
private const string StateSettingsKey = "Moonlight.Api.Setup.State";
private readonly DatabaseRepository<Role> RolesRepository;
private readonly SettingsService SettingsService; private readonly SettingsService SettingsService;
private readonly DatabaseRepository<User> UsersRepository; private readonly DatabaseRepository<User> UsersRepository;
private readonly DatabaseRepository<Role> RolesRepository;
private const string StateSettingsKey = "Moonlight.Api.Setup.State";
public SetupController( public SetupController(
SettingsService settingsService, SettingsService settingsService,
@@ -51,12 +50,12 @@ public class SetupController : Controller
.FirstOrDefaultAsync(x => x.Name == "Administrators"); .FirstOrDefaultAsync(x => x.Name == "Administrators");
if (adminRole == null) if (adminRole == null)
{ adminRole = await RolesRepository.AddAsync(new Role
adminRole = await RolesRepository.AddAsync(new Role()
{ {
Name = "Administrators", Name = "Administrators",
Description = "Automatically generated group for full administrator permissions", Description = "Automatically generated group for full administrator permissions",
Permissions = [ Permissions =
[
Permissions.ApiKeys.View, Permissions.ApiKeys.View,
Permissions.ApiKeys.Create, Permissions.ApiKeys.Create,
Permissions.ApiKeys.Edit, Permissions.ApiKeys.Edit,
@@ -82,10 +81,9 @@ public class SetupController : Controller
Permissions.System.Info, Permissions.System.Info,
Permissions.System.Diagnose, Permissions.System.Diagnose,
Permissions.System.Versions, Permissions.System.Versions,
Permissions.System.Instance, Permissions.System.Instance
] ]
}); });
}
var user = await UsersRepository var user = await UsersRepository
@@ -94,12 +92,13 @@ public class SetupController : Controller
if (user == null) if (user == null)
{ {
await UsersRepository.AddAsync(new User() await UsersRepository.AddAsync(new User
{ {
Email = dto.AdminEmail, Email = dto.AdminEmail,
Username = dto.AdminUsername, Username = dto.AdminUsername,
RoleMemberships = [ RoleMemberships =
new RoleMember() [
new RoleMember
{ {
Role = adminRole, Role = adminRole,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -112,7 +111,7 @@ public class SetupController : Controller
} }
else else
{ {
user.RoleMemberships.Add(new RoleMember() user.RoleMemberships.Add(new RoleMember
{ {
Role = adminRole, Role = adminRole,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,

View File

@@ -1,27 +1,28 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Admin.Sys.ApiKeys.Scheme;
using Moonlight.Api.Mappers; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Admin.Sys.ApiKeys;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys; using Moonlight.Shared.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Sys.ApiKeys;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Route("api/admin/apiKeys")] [Route("api/admin/apiKeys")]
public class ApiKeyController : Controller public class ApiKeyController : Controller
{ {
private readonly HybridCache HybridCache;
private readonly DatabaseRepository<ApiKey> KeyRepository; private readonly DatabaseRepository<ApiKey> KeyRepository;
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository) public ApiKeyController(DatabaseRepository<ApiKey> keyRepository, HybridCache hybridCache)
{ {
KeyRepository = keyRepository; KeyRepository = keyRepository;
HybridCache = hybridCache;
} }
[HttpGet] [HttpGet]
@@ -44,9 +45,7 @@ public class ApiKeyController : Controller
// Filters // Filters
if (filterOptions != null) if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters) foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch query = filterOption.Key switch
{ {
nameof(ApiKey.Name) => nameof(ApiKey.Name) =>
@@ -57,8 +56,6 @@ public class ApiKeyController : Controller
_ => query _ => query
}; };
}
}
// Pagination // Pagination
var data = await query var data = await query
@@ -114,6 +111,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 +128,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,10 +1,9 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys; using Moonlight.Shared.Admin.Sys.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Admin.Sys.ApiKeys;
[Mapper] [Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")] [SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Admin.Sys.ApiKeys;
public class ApiOptions
{
public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
}

View File

@@ -2,30 +2,31 @@ 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.Infrastructure.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared; using Moonlight.Shared;
namespace Moonlight.Api.Implementations.ApiKeyScheme; namespace Moonlight.Api.Admin.Sys.ApiKeys.Scheme;
public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions> public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
{ {
public const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
private readonly DatabaseRepository<ApiKey> ApiKeyRepository; private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly IMemoryCache MemoryCache; private readonly HybridCache HybridCache;
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 +39,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);
{
apiKey = await ApiKeyRepository
.Query()
.Where(x => x.Key == authHeaderValue)
.Select(x => new ApiKeySession(x.Permissions))
.FirstOrDefaultAsync();
if (apiKey == null) var apiKey = await HybridCache.GetOrCreateAsync<ApiKeySession?>(
return AuthenticateResult.Fail("Invalid api key specified"); cacheKey,
async ct =>
{
return await ApiKeyRepository
.Query()
.Where(x => x.Key == authHeaderValue)
.Select(x => new ApiKeySession(x.Permissions, x.ValidUntil))
.FirstOrDefaultAsync(ct);
},
new HybridCacheEntryOptions
{
LocalCacheExpiration = Options.LookupL1CacheTime,
Expiration = Options.LookupL2CacheTime
}
);
MemoryCache.Set(authHeaderValue, apiKey, Options.LookupCacheTime); if (apiKey == null)
} return AuthenticateResult.Fail("Invalid api key specified");
else
{ if (DateTimeOffset.UtcNow > apiKey.ValidUntil)
if (apiKey == null) return AuthenticateResult.Fail("Api key expired");
return AuthenticateResult.Fail("Invalid api key specified");
}
return AuthenticateResult.Success(new AuthenticationTicket( return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal( new ClaimsPrincipal(
@@ -67,5 +74,5 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
)); ));
} }
private record ApiKeySession(string[] Permissions); private record ApiKeySession(string[] Permissions, DateTimeOffset ValidUntil);
} }

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Authentication;
namespace Moonlight.Api.Admin.Sys.ApiKeys.Scheme;
public class ApiKeySchemeOptions : AuthenticationSchemeOptions
{
public TimeSpan LookupL1CacheTime { get; set; }
public TimeSpan LookupL2CacheTime { get; set; }
}

View File

@@ -1,24 +1,61 @@
using System.Diagnostics; using System.Diagnostics;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moonlight.Api.Helpers; using VersionService = Moonlight.Api.Admin.Sys.Versions.VersionService;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Admin.Sys;
public class ApplicationService : IHostedService public class ApplicationService : IHostedService
{ {
private readonly VersionService VersionService;
private readonly ILogger<ApplicationService> Logger; private readonly ILogger<ApplicationService> Logger;
private readonly VersionService VersionService;
public ApplicationService(VersionService versionService, ILogger<ApplicationService> logger)
{
VersionService = versionService;
Logger = 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) public async Task StartAsync(CancellationToken cancellationToken)
{ {
VersionService = versionService; StartedAt = DateTimeOffset.UtcNow;
Logger = logger;
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");
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
} }
public Task<long> GetMemoryUsageAsync() public Task<long> GetMemoryUsageAsync()
@@ -45,41 +82,8 @@ public class ApplicationService : IHostedService
// Calculate CPU usage // Calculate CPU usage
var cpuUsedMs = (endCpuTime - startCpuTime).TotalMilliseconds; var cpuUsedMs = (endCpuTime - startCpuTime).TotalMilliseconds;
var totalMsPassed = (endTime - startTime).TotalMilliseconds; var totalMsPassed = (endTime - startTime).TotalMilliseconds;
var cpuUsagePercent = (cpuUsedMs / (Environment.ProcessorCount * totalMsPassed)) * 100; var cpuUsagePercent = cpuUsedMs / (Environment.ProcessorCount * totalMsPassed) * 100;
return Math.Round(cpuUsagePercent, 2); return Math.Round(cpuUsagePercent, 2);
} }
public async Task StartAsync(CancellationToken cancellationToken)
{
StartedAt = DateTimeOffset.UtcNow;
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");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }

View File

@@ -2,14 +2,10 @@
using Microsoft.AspNetCore.Http; 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.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests.Admin.ContainerHelper; using Moonlight.Shared.Admin.Sys.ContainerHelper;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Sys.ContainerHelper;
[ApiController] [ApiController]
[Route("api/admin/ch")] [Route("api/admin/ch")]
@@ -19,7 +15,8 @@ public class ContainerHelperController : Controller
private readonly ContainerHelperService ContainerHelperService; private readonly ContainerHelperService ContainerHelperService;
private readonly IOptions<ContainerHelperOptions> Options; private readonly IOptions<ContainerHelperOptions> Options;
public ContainerHelperController(ContainerHelperService containerHelperService, IOptions<ContainerHelperOptions> options) public ContainerHelperController(ContainerHelperService containerHelperService,
IOptions<ContainerHelperOptions> options)
{ {
ContainerHelperService = containerHelperService; ContainerHelperService = containerHelperService;
Options = options; Options = options;

View File

@@ -1,13 +1,13 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Events; using Moonlight.Shared.Admin.Sys.ContainerHelper;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Admin.Sys.ContainerHelper;
[Mapper] [Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")] [SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")] [SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class ContainerHelperMapper public static partial class ContainerHelperMapper
{ {
public static partial RebuildEventDto ToDto(Http.Services.ContainerHelper.Events.RebuildEventDto rebuildEventDto); public static partial RebuildEventDto ToDto(Models.Events.RebuildEventDto rebuildEventDto);
} }

View File

@@ -1,4 +1,4 @@
namespace Moonlight.Api.Configuration; namespace Moonlight.Api.Admin.Sys.ContainerHelper;
public class ContainerHelperOptions public class ContainerHelperOptions
{ {

View File

@@ -1,11 +1,10 @@
using System.Net.Http.Headers; using System.Net.Http.Json;
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Moonlight.Api.Http.Services.ContainerHelper; using Moonlight.Api.Admin.Sys.ContainerHelper.Models;
using Moonlight.Api.Http.Services.ContainerHelper.Requests; using Moonlight.Api.Admin.Sys.ContainerHelper.Models.Events;
using Moonlight.Api.Http.Services.ContainerHelper.Events; using Moonlight.Api.Admin.Sys.ContainerHelper.Models.Requests;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Admin.Sys.ContainerHelper;
public class ContainerHelperService public class ContainerHelperService
{ {
@@ -42,7 +41,7 @@ public class ContainerHelperService
request.Content = JsonContent.Create( request.Content = JsonContent.Create(
new RequestRebuildDto(noBuildCache), new RequestRebuildDto(noBuildCache),
null, null,
SerializationContext.TunedOptions SerializationContext.Default.Options
); );
var response = await client.SendAsync( var response = await client.SendAsync(
@@ -54,7 +53,7 @@ public class ContainerHelperService
{ {
var responseText = await response.Content.ReadAsStringAsync(); var responseText = await response.Content.ReadAsStringAsync();
yield return new RebuildEventDto() yield return new RebuildEventDto
{ {
Type = RebuildEventType.Failed, Type = RebuildEventType.Failed,
Data = responseText Data = responseText
@@ -77,7 +76,8 @@ 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;
@@ -86,7 +86,7 @@ public class ContainerHelperService
yield break; yield break;
} while (true); } while (true);
yield return new RebuildEventDto() yield return new RebuildEventDto
{ {
Type = RebuildEventType.Succeeded, Type = RebuildEventType.Succeeded,
Data = string.Empty Data = string.Empty
@@ -100,14 +100,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 = var problemDetails =
await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.TunedOptions); 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

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models.Events;
public struct RebuildEventDto
{
[JsonPropertyName("type")] public RebuildEventType Type { get; set; }
[JsonPropertyName("data")] public string Data { get; set; }
}
public enum RebuildEventType
{
Log = 0,
Failed = 1,
Succeeded = 2,
Step = 3
}

View File

@@ -1,4 +1,4 @@
namespace Moonlight.Api.Http.Services.ContainerHelper; namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models;
public class ProblemDetails public class ProblemDetails
{ {

View File

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

View File

@@ -0,0 +1,3 @@
namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models.Requests;
public record SetVersionDto(string Version);

View File

@@ -0,0 +1,15 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Moonlight.Api.Admin.Sys.ContainerHelper.Models.Events;
using Moonlight.Api.Admin.Sys.ContainerHelper.Models.Requests;
namespace Moonlight.Api.Admin.Sys.ContainerHelper.Models;
[JsonSerializable(typeof(SetVersionDto))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(RebuildEventDto))]
[JsonSerializable(typeof(RequestRebuildDto))]
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
public partial class SerializationContext : JsonSerializerContext
{
}

View File

@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Admin.Sys.Diagnose;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Sys.Diagnose;
[ApiController] [ApiController]
[Authorize(Policy = Permissions.System.Diagnose)] [Authorize(Policy = Permissions.System.Diagnose)]

View File

@@ -0,0 +1,17 @@
namespace Moonlight.Api.Admin.Sys.Diagnose;
public record DiagnoseResult(
DiagnoseLevel Level,
string Title,
string[] Tags,
string? Message,
string? StackStrace,
string? SolutionUrl,
string? ReportUrl);
public enum DiagnoseLevel
{
Error = 0,
Warning = 1,
Healthy = 2
}

View File

@@ -1,9 +1,8 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models; using Moonlight.Shared.Admin.Sys.Diagnose;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Admin.Sys.Diagnose;
[Mapper] [Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")] [SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]

View File

@@ -1,13 +1,12 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moonlight.Api.Interfaces; using Moonlight.Api.Infrastructure.Hooks;
using Moonlight.Api.Models;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Admin.Sys.Diagnose;
public class DiagnoseService public class DiagnoseService
{ {
private readonly IEnumerable<IDiagnoseProvider> Providers;
private readonly ILogger<DiagnoseService> Logger; private readonly ILogger<DiagnoseService> Logger;
private readonly IEnumerable<IDiagnoseProvider> Providers;
public DiagnoseService(IEnumerable<IDiagnoseProvider> providers, ILogger<DiagnoseService> logger) public DiagnoseService(IEnumerable<IDiagnoseProvider> providers, ILogger<DiagnoseService> logger)
{ {
@@ -20,7 +19,6 @@ public class DiagnoseService
var results = new List<DiagnoseResult>(); var results = new List<DiagnoseResult>();
foreach (var provider in Providers) foreach (var provider in Providers)
{
try try
{ {
results.AddRange( results.AddRange(
@@ -31,7 +29,6 @@ public class DiagnoseService
{ {
Logger.LogError(e, "An unhandled error occured while processing provider"); Logger.LogError(e, "An unhandled error occured while processing provider");
} }
}
return results.ToArray(); return results.ToArray();
} }

View File

@@ -1,6 +1,6 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace Moonlight.Api.Helpers; namespace Moonlight.Api.Admin.Sys;
public class OsHelper public class OsHelper
{ {
@@ -53,17 +53,12 @@ public class OsHelper
string? version = null; string? version = null;
foreach (var line in lines) foreach (var line in lines)
{
if (line.StartsWith("NAME=")) if (line.StartsWith("NAME="))
name = line.Substring(5).Trim('"'); name = line.Substring(5).Trim('"');
else if (line.StartsWith("VERSION_ID=")) else if (line.StartsWith("VERSION_ID="))
version = line.Substring(11).Trim('"'); version = line.Substring(11).Trim('"');
}
if (!string.IsNullOrEmpty(name)) if (!string.IsNullOrEmpty(name)) return string.IsNullOrEmpty(version) ? name : $"{name} {version}";
{
return string.IsNullOrEmpty(version) ? name : $"{name} {version}";
}
} }
//If for some weird reason it still uses lsb release //If for some weird reason it still uses lsb release
@@ -74,17 +69,12 @@ public class OsHelper
string? version = null; string? version = null;
foreach (var line in lines) foreach (var line in lines)
{
if (line.StartsWith("DISTRIB_ID=")) if (line.StartsWith("DISTRIB_ID="))
name = line.Substring(11); name = line.Substring(11);
else if (line.StartsWith("DISTRIB_RELEASE=")) else if (line.StartsWith("DISTRIB_RELEASE="))
version = line.Substring(16); version = line.Substring(16);
}
if (!string.IsNullOrEmpty(name)) if (!string.IsNullOrEmpty(name)) return string.IsNullOrEmpty(version) ? name : $"{name} {version}";
{
return string.IsNullOrEmpty(version) ? name : $"{name} {version}";
}
} }
} }
catch catch

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Shared.Frontend;
using Moonlight.Shared;
using Moonlight.Shared.Admin.Sys.Settings;
namespace Moonlight.Api.Admin.Sys.Settings;
[ApiController]
[Authorize(Policy = Permissions.System.Settings)]
[Route("api/admin/system/settings/whiteLabeling")]
public class WhiteLabelingController : Controller
{
private readonly FrontendService FrontendService;
private readonly SettingsService SettingsService;
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

@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Services;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Admin.Sys;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Sys;
[ApiController] [ApiController]
[Route("api/admin/system")] [Route("api/admin/system")]

View File

@@ -1,10 +1,9 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Themes; using Moonlight.Shared.Admin.Sys.Themes;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Admin.Sys.Themes;
[Mapper] [Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")] [SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]

View File

@@ -0,0 +1,12 @@
using VYaml.Annotations;
namespace Moonlight.Api.Admin.Sys.Themes;
[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

@@ -1,24 +1,21 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Api.Mappers; using Moonlight.Api.Shared.Frontend;
using Moonlight.Api.Services;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Admin.Sys.Themes;
using Moonlight.Shared.Http.Requests.Admin.Themes; using Moonlight.Shared.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.Themes;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Sys.Themes;
[ApiController] [ApiController]
[Route("api/admin/themes")] [Route("api/admin/themes")]
public class ThemesController : Controller public class ThemesController : Controller
{ {
private readonly DatabaseRepository<Theme> ThemeRepository;
private readonly FrontendService FrontendService; private readonly FrontendService FrontendService;
private readonly DatabaseRepository<Theme> ThemeRepository;
public ThemesController(DatabaseRepository<Theme> themeRepository, FrontendService frontendService) public ThemesController(DatabaseRepository<Theme> themeRepository, FrontendService frontendService)
{ {
@@ -48,9 +45,7 @@ public class ThemesController : Controller
// Filters // Filters
if (filterOptions != null) if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters) foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch query = filterOption.Key switch
{ {
nameof(Theme.Name) => nameof(Theme.Name) =>
@@ -64,8 +59,6 @@ public class ThemesController : Controller
_ => query _ => query
}; };
}
}
// Pagination // Pagination
var data = await query var data = await query

View File

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared;
using Moonlight.Shared.Admin.Sys.Themes;
using VYaml.Serialization;
namespace Moonlight.Api.Admin.Sys.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,4 +1,4 @@
namespace Moonlight.Api.Configuration; namespace Moonlight.Api.Admin.Sys.Versions;
public class FrontendOptions public class FrontendOptions
{ {

View File

@@ -1,4 +1,4 @@
namespace Moonlight.Api.Models; namespace Moonlight.Api.Admin.Sys.Versions;
// Notes: // Notes:
// Identifier - This needs to be the branch to clone to build this version if // Identifier - This needs to be the branch to clone to build this version if

View File

@@ -1,9 +1,8 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models; using Moonlight.Shared.Admin.Sys.Versions;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Admin.Sys.Versions;
[Mapper] [Mapper]
[SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")] [SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")]

View File

@@ -1,4 +1,4 @@
namespace Moonlight.Api.Configuration; namespace Moonlight.Api.Admin.Sys.Versions;
public class VersionOptions public class VersionOptions
{ {

View File

@@ -1,22 +1,16 @@
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Models;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Admin.Sys.Versions;
public partial class VersionService public partial class VersionService
{ {
private readonly IOptions<VersionOptions> Options;
private readonly IHttpClientFactory HttpClientFactory;
private const string VersionPath = "/app/version"; private const string VersionPath = "/app/version";
private const string GiteaServer = "https://git.battlestati.one"; private const string GiteaServer = "https://git.battlestati.one";
private const string GiteaRepository = "Moonlight-Panel/Moonlight"; private const string GiteaRepository = "Moonlight-Panel/Moonlight";
private readonly IHttpClientFactory HttpClientFactory;
[GeneratedRegex(@"^v(?!1(\.|$))\d+\.[A-Za-z0-9]+(\.[A-Za-z0-9]+)*$")] private readonly IOptions<VersionOptions> Options;
private static partial Regex RegexFilter();
public VersionService( public VersionService(
IOptions<VersionOptions> options, IOptions<VersionOptions> options,
@@ -27,6 +21,9 @@ public partial class VersionService
HttpClientFactory = httpClientFactory; HttpClientFactory = httpClientFactory;
} }
[GeneratedRegex(@"^v(?!1(\.|$))\d+\.[A-Za-z0-9]+(\.[A-Za-z0-9]+)*$")]
private static partial Regex RegexFilter();
public async Task<MoonlightVersion[]> GetVersionsAsync() public async Task<MoonlightVersion[]> GetVersionsAsync()
{ {
if (Options.Value.OfflineMode) if (Options.Value.OfflineMode)
@@ -42,7 +39,6 @@ public partial class VersionService
var tagsJson = await JsonNode.ParseAsync(tagsJsonStream); var tagsJson = await JsonNode.ParseAsync(tagsJsonStream);
if (tagsJson != null) if (tagsJson != null)
{
foreach (var node in tagsJson.AsArray()) foreach (var node in tagsJson.AsArray())
{ {
if (node == null) if (node == null)
@@ -51,7 +47,7 @@ public partial class VersionService
var name = node["name"]?.GetValue<string>() ?? "N/A"; var name = node["name"]?.GetValue<string>() ?? "N/A";
var createdAt = node["createdAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue; var createdAt = node["createdAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue;
if(!RegexFilter().IsMatch(name)) if (!RegexFilter().IsMatch(name))
continue; continue;
versions.Add(new MoonlightVersion( versions.Add(new MoonlightVersion(
@@ -61,7 +57,6 @@ public partial class VersionService
createdAt createdAt
)); ));
} }
}
// Branches // Branches
const string branchesPath = $"{GiteaServer}/api/v1/repos/{GiteaRepository}/branches"; const string branchesPath = $"{GiteaServer}/api/v1/repos/{GiteaRepository}/branches";
@@ -70,7 +65,6 @@ public partial class VersionService
var branchesJson = await JsonNode.ParseAsync(branchesJsonStream); var branchesJson = await JsonNode.ParseAsync(branchesJsonStream);
if (branchesJson != null) if (branchesJson != null)
{
foreach (var node in branchesJson.AsArray()) foreach (var node in branchesJson.AsArray())
{ {
if (node == null) if (node == null)
@@ -84,7 +78,7 @@ public partial class VersionService
var createdAt = commit["timestamp"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue; var createdAt = commit["timestamp"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.MinValue;
if(!RegexFilter().IsMatch(name)) if (!RegexFilter().IsMatch(name))
continue; continue;
versions.Add(new MoonlightVersion( versions.Add(new MoonlightVersion(
@@ -94,7 +88,6 @@ public partial class VersionService
createdAt createdAt
)); ));
} }
}
return versions.ToArray(); return versions.ToArray();
} }
@@ -106,7 +99,9 @@ public partial class VersionService
string versionIdentifier; string versionIdentifier;
if (!string.IsNullOrWhiteSpace(Options.Value.CurrentOverride)) if (!string.IsNullOrWhiteSpace(Options.Value.CurrentOverride))
{
versionIdentifier = Options.Value.CurrentOverride; versionIdentifier = Options.Value.CurrentOverride;
}
else else
{ {
if (File.Exists(VersionPath)) if (File.Exists(VersionPath))

View File

@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Admin.Sys.Versions;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Sys.Versions;
[ApiController] [ApiController]
[Route("api/admin/versions")] [Route("api/admin/versions")]
@@ -38,7 +36,7 @@ public class VersionsController : Controller
{ {
var version = await VersionService.GetLatestVersionAsync(); var version = await VersionService.GetLatestVersionAsync();
if(version == null) if (version == null)
return Problem("Unable to retrieve latest version", statusCode: 404); return Problem("Unable to retrieve latest version", statusCode: 404);
return VersionMapper.ToDto(version); return VersionMapper.ToDto(version);

View File

@@ -1,10 +1,9 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Roles; using Moonlight.Shared.Admin.Users.Roles;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers; namespace Moonlight.Api.Admin.Users.Roles;
[Mapper] [Mapper]
[SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")] [SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")]
@@ -13,6 +12,7 @@ public static partial class RoleMapper
{ {
[MapProperty([nameof(Role.Members), nameof(Role.Members.Count)], nameof(RoleDto.MemberCount))] [MapProperty([nameof(Role.Members), nameof(Role.Members.Count)], nameof(RoleDto.MemberCount))]
public static partial RoleDto ToDto(Role role); public static partial RoleDto ToDto(Role role);
public static partial Role ToEntity(CreateRoleDto request); public static partial Role ToEntity(CreateRoleDto request);
public static partial void Merge([MappingTarget] Role role, UpdateRoleDto request); public static partial void Merge([MappingTarget] Role role, UpdateRoleDto request);

View File

@@ -1,23 +1,23 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Admin.Users.Users;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Mappers; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Admin.Users.Users;
using Moonlight.Shared.Http.Responses.Admin.Users; using Moonlight.Shared.Shared;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Users.Roles;
[ApiController] [ApiController]
[Authorize(Policy = Permissions.Roles.Members)] [Authorize(Policy = Permissions.Roles.Members)]
[Route("api/admin/roles/{roleId:int}/members")] [Route("api/admin/roles/{roleId:int}/members")]
public class RoleMembersController : Controller public class RoleMembersController : Controller
{ {
private readonly DatabaseRepository<User> UsersRepository;
private readonly DatabaseRepository<Role> RolesRepository;
private readonly DatabaseRepository<RoleMember> RoleMembersRepository; private readonly DatabaseRepository<RoleMember> RoleMembersRepository;
private readonly DatabaseRepository<Role> RolesRepository;
private readonly DatabaseRepository<User> UsersRepository;
public RoleMembersController( public RoleMembersController(
DatabaseRepository<User> usersRepository, DatabaseRepository<User> usersRepository,
@@ -53,19 +53,16 @@ public class RoleMembersController : Controller
// Filtering // Filtering
if (!string.IsNullOrWhiteSpace(searchTerm)) if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(x => query = query.Where(x =>
EF.Functions.ILike(x.Username, $"%{searchTerm}%") || EF.Functions.ILike(x.Username, $"%{searchTerm}%") ||
EF.Functions.ILike(x.Email, $"%{searchTerm}%") EF.Functions.ILike(x.Email, $"%{searchTerm}%")
); );
}
// Pagination // Pagination
var items = query var items = UserMapper.ProjectToDto(query
.OrderBy(x => x.Id) .OrderBy(x => x.Id)
.Skip(startIndex) .Skip(startIndex)
.Take(length) .Take(length))
.ProjectToDto()
.ToArray(); .ToArray();
var totalCount = await query.CountAsync(); var totalCount = await query.CountAsync();
@@ -95,19 +92,16 @@ public class RoleMembersController : Controller
// Filtering // Filtering
if (!string.IsNullOrWhiteSpace(searchTerm)) if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(x => query = query.Where(x =>
EF.Functions.ILike(x.Username, $"%{searchTerm}%") || EF.Functions.ILike(x.Username, $"%{searchTerm}%") ||
EF.Functions.ILike(x.Email, $"%{searchTerm}%") EF.Functions.ILike(x.Email, $"%{searchTerm}%")
); );
}
// Pagination // Pagination
var items = query var items = UserMapper.ProjectToDto(query
.OrderBy(x => x.Id) .OrderBy(x => x.Id)
.Skip(startIndex) .Skip(startIndex)
.Take(length) .Take(length))
.ProjectToDto()
.ToArray(); .ToArray();
var totalCount = await query.CountAsync(); var totalCount = await query.CountAsync();

View File

@@ -1,16 +1,13 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Admin.Users.Roles;
using Moonlight.Shared.Http.Requests.Admin.Roles; using Moonlight.Shared.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Users.Roles;
[ApiController] [ApiController]
[Route("api/admin/roles")] [Route("api/admin/roles")]
@@ -45,9 +42,7 @@ public class RolesController : Controller
// Filters // Filters
if (filterOptions != null) if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters) foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch query = filterOption.Key switch
{ {
nameof(Role.Name) => nameof(Role.Name) =>
@@ -55,8 +50,6 @@ public class RolesController : Controller
_ => query _ => query
}; };
}
}
// Pagination // Pagination
var data = await query var data = await query

View File

@@ -1,41 +1,41 @@
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.Infrastructure.Database;
using Moonlight.Api.Database; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Hooks;
using Moonlight.Api.Interfaces;
using Moonlight.Shared; using Moonlight.Shared;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Admin.Users.Users;
public class UserAuthService public class UserAuthService
{ {
private readonly DatabaseRepository<User> UserRepository; public const string CacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}";
private readonly IMemoryCache Cache;
private readonly ILogger<UserAuthService> Logger;
private readonly IOptions<SessionOptions> Options;
private readonly IEnumerable<IUserAuthHook> Hooks;
private const string UserIdClaim = "UserId"; private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt"; private const string IssuedAtClaim = "IssuedAt";
public const string CacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}"; private readonly IEnumerable<IUserAuthHook> Hooks;
private readonly HybridCache HybridCache;
private readonly ILogger<UserAuthService> Logger;
private readonly IOptions<UserOptions> Options;
private readonly DatabaseRepository<User> UserRepository;
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 IEnumerable<IUserAuthHook> hooks,
HybridCache hybridCache
) )
{ {
UserRepository = userRepository; UserRepository = userRepository;
Logger = logger; Logger = logger;
Cache = cache;
Options = options; Options = options;
Hooks = hooks; Hooks = hooks;
HybridCache = hybridCache;
} }
public async Task<bool> SyncAsync(ClaimsPrincipal? principal) public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
@@ -59,7 +59,7 @@ public class UserAuthService
if (user == null) // Sync user if not already existing in the database if (user == null) // Sync user if not already existing in the database
{ {
user = await UserRepository.AddAsync(new User() user = await UserRepository.AddAsync(new User
{ {
Username = username, Username = username,
Email = email, Email = email,
@@ -79,11 +79,9 @@ public class UserAuthService
]); ]);
foreach (var hook in Hooks) foreach (var hook in Hooks)
{ // Run every hook, and if any returns false, we return false as well
// Run every hook and if any returns false we return false as well if (!await hook.SyncAsync(principal, user))
if(!await hook.SyncAsync(principal, user))
return false; return false;
}
return true; return true;
} }
@@ -101,32 +99,29 @@ 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,
user = await UserRepository async ct =>
.Query() {
.AsNoTracking() return await UserRepository
.Where(u => u.Id == userId) .Query()
.Select(u => new UserSession( .AsNoTracking()
u.InvalidateTimestamp, .Where(u => u.Id == userId)
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray()) .Select(u => new UserSession(
) u.InvalidateTimestamp,
.FirstOrDefaultAsync(); u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
)
.FirstOrDefaultAsync(ct);
},
new HybridCacheEntryOptions
{
LocalCacheExpiration = Options.Value.ValidationCacheL1Expiry,
Expiration = Options.Value.ValidationCacheL2Expiry
}
);
if (user == null) if (user == null)
return false; return false;
Cache.Set(
cacheKey,
user,
TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes)
);
}
else
{
if (user == null)
return false;
}
var issuedAtString = principal.FindFirstValue(IssuedAtClaim); var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
@@ -148,11 +143,9 @@ public class UserAuthService
); );
foreach (var hook in Hooks) foreach (var hook in Hooks)
{ // Run every hook, and if any returns false we return false as well
// Run every hook and if any returns false we return false as well if (!await hook.ValidateAsync(principal, userId))
if(!await hook.ValidateAsync(principal, userId))
return false; return false;
}
return true; return true;
} }

View File

@@ -1,21 +1,19 @@
using System.Collections.Frozen;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Api.Services;
using Moonlight.Shared; using Moonlight.Shared;
namespace Moonlight.Api.Http.Controllers.Admin.Users; namespace Moonlight.Api.Admin.Users.Users;
[ApiController] [ApiController]
[Route("api/admin/users")] [Route("api/admin/users")]
[Authorize(Policy = Permissions.Users.Delete)] [Authorize(Policy = Permissions.Users.Delete)]
public class UserDeletionController : Controller public class UserDeletionController : Controller
{ {
private readonly UserDeletionService UserDeletionService;
private readonly DatabaseRepository<User> Repository; private readonly DatabaseRepository<User> Repository;
private readonly UserDeletionService UserDeletionService;
public UserDeletionController(UserDeletionService userDeletionService, DatabaseRepository<User> repository) public UserDeletionController(UserDeletionService userDeletionService, DatabaseRepository<User> repository)
{ {
@@ -39,7 +37,7 @@ public class UserDeletionController : Controller
{ {
return ValidationProblem( return ValidationProblem(
new ValidationProblemDetails( new ValidationProblemDetails(
new Dictionary<string, string[]>() new Dictionary<string, string[]>
{ {
{ {
string.Empty, string.Empty,

View File

@@ -1,22 +1,26 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Api.Interfaces; using Moonlight.Api.Infrastructure.Hooks;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Admin.Users.Users;
public class UserDeletionService public class UserDeletionService
{ {
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserDeletionHook> Hooks; private readonly IEnumerable<IUserDeletionHook> Hooks;
private readonly IMemoryCache Cache; private readonly HybridCache HybridCache;
private readonly DatabaseRepository<User> Repository;
public UserDeletionService(DatabaseRepository<User> repository, IEnumerable<IUserDeletionHook> hooks, IMemoryCache cache) public UserDeletionService(
DatabaseRepository<User> repository,
IEnumerable<IUserDeletionHook> hooks,
HybridCache hybridCache
)
{ {
Repository = repository; Repository = repository;
Hooks = hooks; Hooks = hooks;
Cache = cache; HybridCache = hybridCache;
} }
public async Task<UserDeletionValidationResult> ValidateAsync(int userId) public async Task<UserDeletionValidationResult> ValidateAsync(int userId)
@@ -25,7 +29,7 @@ public class UserDeletionService
.Query() .Query()
.FirstOrDefaultAsync(x => x.Id == userId); .FirstOrDefaultAsync(x => x.Id == userId);
if(user == null) if (user == null)
throw new AggregateException($"User with id {userId} not found"); throw new AggregateException($"User with id {userId} not found");
var errorMessages = new List<string>(); var errorMessages = new List<string>();
@@ -47,14 +51,15 @@ public class UserDeletionService
.Query() .Query()
.FirstOrDefaultAsync(x => x.Id == userId); .FirstOrDefaultAsync(x => x.Id == userId);
if(user == null) if (user == null)
throw new AggregateException($"User with id {userId} not found"); throw new AggregateException($"User with id {userId} not found");
foreach (var hook in Hooks) foreach (var hook in Hooks)
await hook.ExecuteAsync(user); await hook.ExecuteAsync(user);
await Repository.RemoveAsync(user); await Repository.RemoveAsync(user);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
} }
} }

View File

@@ -1,12 +1,11 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Api.Services;
using Moonlight.Shared; using Moonlight.Shared;
namespace Moonlight.Api.Http.Controllers.Admin.Users; namespace Moonlight.Api.Admin.Users.Users;
[ApiController] [ApiController]
[Route("api/admin/users/{id:int}/logout")] [Route("api/admin/users/{id:int}/logout")]

View File

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

View File

@@ -1,10 +1,9 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared.Admin.Users.Users;
using Riok.Mapperly.Abstractions; using Riok.Mapperly.Abstractions;
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.Admin.Users.Users;
[Mapper] [Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")] [SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]

View File

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

View File

@@ -1,16 +1,13 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared; using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Admin.Users.Users;
using Moonlight.Shared.Http.Requests.Admin.Users; using Moonlight.Shared.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Http.Controllers.Admin.Users; namespace Moonlight.Api.Admin.Users.Users;
[Authorize] [Authorize]
[ApiController] [ApiController]
@@ -51,10 +48,10 @@ public class UsersController : Controller
{ {
query = filterOption.Key switch query = filterOption.Key switch
{ {
nameof(Database.Entities.User.Email) => nameof(Infrastructure.Database.Entities.User.Email) =>
query.Where(user => EF.Functions.ILike(user.Email, $"%{filterOption.Value}%")), query.Where(user => EF.Functions.ILike(user.Email, $"%{filterOption.Value}%")),
nameof(Database.Entities.User.Username) => nameof(Infrastructure.Database.Entities.User.Username) =>
query.Where(user => EF.Functions.ILike(user.Username, $"%{filterOption.Value}%")), query.Where(user => EF.Functions.ILike(user.Username, $"%{filterOption.Value}%")),
_ => query _ => query

View File

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

View File

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

View File

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

View File

@@ -1,24 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database.Entities;
public class ApiKey : IActionTimestamps
{
public int Id { get; set; }
[MaxLength(30)]
public required string Name { get; set; }
[MaxLength(300)]
public required string Description { get; set; }
public string[] Permissions { get; set; } = [];
[MaxLength(32)]
public string Key { get; set; }
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Api.Database.Entities;
public class Theme
{
public int Id { get; set; }
[MaxLength(30)]
public required string Name { get; set; }
[MaxLength(30)]
public required string Version { get; set; }
[MaxLength(30)]
public required string Author { get; set; }
public bool IsEnabled { get; set; }
[MaxLength(20_000)]
public required string CssContent { get; set; }
}

View File

@@ -1,20 +0,0 @@
using System.Text.Json.Serialization;
namespace Moonlight.Api.Http.Services.ContainerHelper.Events;
public struct RebuildEventDto
{
[JsonPropertyName("type")]
public RebuildEventType Type { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
}
public enum RebuildEventType
{
Log = 0,
Failed = 1,
Succeeded = 2,
Step = 3
}

View File

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

View File

@@ -1,3 +0,0 @@
namespace Moonlight.Api.Http.Services.ContainerHelper.Requests;
public record SetVersionDto(string Version);

View File

@@ -1,29 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Moonlight.Api.Http.Services.ContainerHelper.Events;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
namespace Moonlight.Api.Http.Services.ContainerHelper;
[JsonSerializable(typeof(SetVersionDto))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(RebuildEventDto))]
[JsonSerializable(typeof(RequestRebuildDto))]
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

@@ -1,8 +0,0 @@
using Microsoft.AspNetCore.Authentication;
namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeOptions : AuthenticationSchemeOptions
{
public TimeSpan LookupCacheTime { get; set; }
}

View File

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

View File

@@ -1,4 +1,4 @@
namespace Moonlight.Api.Configuration; namespace Moonlight.Api.Infrastructure.Configuration;
public class OidcOptions public class OidcOptions
{ {

View File

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

View File

@@ -1,19 +1,11 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration; using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Api.Database.Entities;
namespace Moonlight.Api.Database; namespace Moonlight.Api.Infrastructure.Database;
public class DataContext : DbContext public class DataContext : DbContext
{ {
public DbSet<User> Users { get; set; }
public DbSet<SettingsOption> SettingsOptions { get; set; }
public DbSet<Role> Roles { get; set; }
public DbSet<RoleMember> RoleMembers { get; set; }
public DbSet<ApiKey> ApiKeys { get; set; }
public DbSet<Theme> Themes { get; set; }
private readonly IOptions<DatabaseOptions> Options; private readonly IOptions<DatabaseOptions> Options;
public DataContext(IOptions<DatabaseOptions> options) public DataContext(IOptions<DatabaseOptions> options)
@@ -21,6 +13,13 @@ public class DataContext : DbContext
Options = options; Options = options;
} }
public DbSet<User> Users { get; set; }
public DbSet<SettingsOption> SettingsOptions { get; set; }
public DbSet<Role> Roles { get; set; }
public DbSet<RoleMember> RoleMembers { get; set; }
public DbSet<ApiKey> ApiKeys { get; set; }
public DbSet<Theme> Themes { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
if (optionsBuilder.IsConfigured) if (optionsBuilder.IsConfigured)
@@ -31,7 +30,12 @@ public class DataContext : DbContext
$"Port={Options.Value.Port};" + $"Port={Options.Value.Port};" +
$"Username={Options.Value.Username};" + $"Username={Options.Value.Username};" +
$"Password={Options.Value.Password};" + $"Password={Options.Value.Password};" +
$"Database={Options.Value.Database}" $"Database={Options.Value.Database}",
builder =>
{
builder.MigrationsAssembly(typeof(DataContext).Assembly);
builder.MigrationsHistoryTable("MigrationsHistory", "core");
}
); );
} }

View File

@@ -1,4 +1,4 @@
namespace Moonlight.Api.Configuration; namespace Moonlight.Api.Infrastructure.Database;
public class DatabaseOptions public class DatabaseOptions
{ {

View File

@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database.Interfaces; using Moonlight.Api.Infrastructure.Database.Interfaces;
namespace Moonlight.Api.Database; namespace Moonlight.Api.Infrastructure.Database;
public class DatabaseRepository<T> where T : class public class DatabaseRepository<T> where T : class
{ {
@@ -14,7 +14,10 @@ public class DatabaseRepository<T> where T : class
Set = DataContext.Set<T>(); Set = DataContext.Set<T>();
} }
public IQueryable<T> Query() => Set; public IQueryable<T> Query()
{
return Set;
}
public async Task<T> AddAsync(T entity) public async Task<T> AddAsync(T entity)
{ {

View File

@@ -2,9 +2,8 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moonlight.Api.Database;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Infrastructure.Database;
public class DbMigrationService : IHostedLifecycleService public class DbMigrationService : IHostedLifecycleService
{ {
@@ -29,7 +28,7 @@ public class DbMigrationService : IHostedLifecycleService
if (migrationNames.Length == 0) if (migrationNames.Length == 0)
{ {
Logger.LogDebug("No pending migrations found"); Logger.LogTrace("No pending migrations found");
return; return;
} }
@@ -41,9 +40,28 @@ public class DbMigrationService : IHostedLifecycleService
Logger.LogInformation("Migration complete"); Logger.LogInformation("Migration complete");
} }
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartAsync(CancellationToken cancellationToken)
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; {
public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; return Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; }
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StartedAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StoppedAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StoppingAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
} }

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Infrastructure.Database.Interfaces;
namespace Moonlight.Api.Infrastructure.Database.Entities;
public class ApiKey : IActionTimestamps
{
public int Id { get; set; }
[MaxLength(30)] public required string Name { get; set; }
[MaxLength(300)] public required string Description { get; set; }
public string[] Permissions { get; set; } = [];
public DateTimeOffset ValidUntil { get; set; }
[MaxLength(32)] public string Key { get; set; }
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -1,17 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Database.Interfaces; using Moonlight.Api.Infrastructure.Database.Interfaces;
namespace Moonlight.Api.Database.Entities; namespace Moonlight.Api.Infrastructure.Database.Entities;
public class Role : IActionTimestamps public class Role : IActionTimestamps
{ {
public int Id { get; set; } public int Id { get; set; }
[MaxLength(30)] [MaxLength(30)] public required string Name { get; set; }
public required string Name { get; set; }
[MaxLength(300)] [MaxLength(300)] public required string Description { get; set; }
public required string Description { get; set; }
public string[] Permissions { get; set; } = []; public string[] Permissions { get; set; } = [];

View File

@@ -1,6 +1,6 @@
using Moonlight.Api.Database.Interfaces; using Moonlight.Api.Infrastructure.Database.Interfaces;
namespace Moonlight.Api.Database.Entities; namespace Moonlight.Api.Infrastructure.Database.Entities;
public class RoleMember : IActionTimestamps public class RoleMember : IActionTimestamps
{ {

View File

@@ -1,14 +1,13 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace Moonlight.Api.Database.Entities; namespace Moonlight.Api.Infrastructure.Database.Entities;
public class SettingsOption public class SettingsOption
{ {
public int Id { get; set; } public int Id { get; set; }
[MaxLength(256)] [MaxLength(256)] public required string Key { get; set; }
public required string Key { get; set; }
[MaxLength(4096)] [MaxLength(4096)]
[Column(TypeName = "jsonb")] [Column(TypeName = "jsonb")]

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Api.Infrastructure.Database.Entities;
public class Theme
{
public int Id { get; set; }
[MaxLength(30)] public required string Name { get; set; }
[MaxLength(30)] public required string Version { get; set; }
[MaxLength(30)] public required string Author { get; set; }
public bool IsEnabled { get; set; }
[MaxLength(20_000)] public required string CssContent { get; set; }
}

View File

@@ -1,18 +1,16 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Database.Interfaces; using Moonlight.Api.Infrastructure.Database.Interfaces;
namespace Moonlight.Api.Database.Entities; namespace Moonlight.Api.Infrastructure.Database.Entities;
public class User : IActionTimestamps public class User : IActionTimestamps
{ {
public int Id { get; set; } public int Id { get; set; }
// Base information // Base information
[MaxLength(50)] [MaxLength(50)] public required string Username { get; set; }
public required string Username { get; set; }
[MaxLength(254)] [MaxLength(254)] public required string Email { get; set; }
public required string Email { get; set; }
// Authentication // Authentication
public DateTimeOffset InvalidateTimestamp { get; set; } public DateTimeOffset InvalidateTimestamp { get; set; }

View File

@@ -1,4 +1,4 @@
namespace Moonlight.Api.Database.Interfaces; namespace Moonlight.Api.Infrastructure.Database.Interfaces;
internal interface IActionTimestamps internal interface IActionTimestamps
{ {

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Infrastructure.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Infrastructure.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Infrastructure.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Infrastructure.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Infrastructure.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Infrastructure.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable

View File

@@ -0,0 +1,255 @@
// <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 Moonlight.Api.Infrastructure.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

@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Infrastructure.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
@@ -56,6 +57,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");

View File

@@ -2,7 +2,7 @@
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Logging.Console;
namespace Moonlight.Api.Helpers; namespace Moonlight.Api.Infrastructure.Helpers;
public class AppConsoleFormatter : ConsoleFormatter public class AppConsoleFormatter : ConsoleFormatter
{ {
@@ -58,7 +58,9 @@ public class AppConsoleFormatter : ConsoleFormatter
textWriter.WriteLine(logEntry.Exception.ToString()); textWriter.WriteLine(logEntry.Exception.ToString());
} }
else else
{
textWriter.WriteLine(); textWriter.WriteLine();
}
} }
private static (string text, string color) GetLevelInfo(LogLevel logLevel) private static (string text, string color) GetLevelInfo(LogLevel logLevel)

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