47 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
6d854d82d3 Made username table cell clickable to open edit screen and removed the unnecessary text-left 2026-02-11 20:46:59 +01:00
ac1c28d20d Implemented user logout and deletion service. Added Auth, Deletion and Logout hook. Restructed controllers 2026-02-11 20:46:53 +01:00
5efe591f85 Started implementing api key expiration 2026-02-09 16:12:11 +01:00
4daf986f3e Added option for oidc to disable https only cookies for deployments using an ip 2026-02-09 12:22:03 +01:00
cc7f55c988 Added versions and instance permissions to default admin perms 2026-02-09 12:03:29 +01:00
11a2f9818a Added buns lockfile to gitignore 2026-02-09 12:02:58 +01:00
6a151394a7 Added type hints in display name of version selector 2026-02-09 10:27:36 +01:00
178ac5ac20 Added permissions for container helper. Updated rebuild version selection to fetch the available versions from moonlights version api 2026-02-09 09:14:38 +01:00
91944a5ef6 Updated setup page to use the latest shadcnblazor input fields 2026-02-09 08:22:58 +01:00
affdadf3aa Merge branch 'feat/ContainerHelper' into v2.1
Some checks failed
Dev Publish: Nuget / Publish Dev Packages (push) Failing after 31s
# Conflicts:
#	Moonlight.Api/Services/ApplicationService.cs
#	Moonlight.Api/Startup/Startup.Base.cs
#	Moonlight.Shared/Http/SerializationContext.cs
2026-02-09 08:18:56 +01:00
8d9a7bb8b3 Added validation to setup dto 2026-02-09 06:51:38 +00:00
1f631be1c7 Updated settings service to support generic types with JSON serialization/deserialization, adjusted setup wizard checks and modified database schema for proper JSONB storage. 2026-02-09 06:51:38 +00:00
5b4959771c Started implementing setup wizard backend for initial instance configuration. Adjusted ui 2026-02-09 06:51:38 +00:00
b8e1bbb28c Added setup wizard component for initial installation flow and integrated it into app routing. 2026-02-09 06:51:38 +00:00
c8fe11bd2b Implemented version fetching from source control git server. Added self version detection and update checks 2026-02-01 14:47:32 +01:00
09b11cc4ad Improved update instance model text design 2026-01-29 14:07:02 +01:00
660319afec Renamed SharedSerializationContext to SerializationContext. Added error handling and no build cache functionality 2026-01-29 13:59:24 +01:00
8181404f0c Moved request and responses dtos to correct namespace 2026-01-29 12:45:09 +01:00
239 changed files with 3828 additions and 1531 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

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -7,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,22 +1,22 @@
<!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>
window.frontendConfig = { window.frontendConfig = {
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);
@@ -43,8 +43,8 @@
console.error('Failed to load initial theme:', error); console.error('Failed to load initial theme:', error);
} }
}, },
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

@@ -0,0 +1,128 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Admin.Sys.Settings;
using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Infrastructure.Database.Entities;
using Moonlight.Shared;
using Moonlight.Shared.Admin.Setup;
namespace Moonlight.Api.Admin.Setup;
[ApiController]
[Route("api/admin/setup")]
public class SetupController : Controller
{
private const string StateSettingsKey = "Moonlight.Api.Setup.State";
private readonly DatabaseRepository<Role> RolesRepository;
private readonly SettingsService SettingsService;
private readonly DatabaseRepository<User> UsersRepository;
public SetupController(
SettingsService settingsService,
DatabaseRepository<User> usersRepository,
DatabaseRepository<Role> rolesRepository
)
{
SettingsService = settingsService;
UsersRepository = usersRepository;
RolesRepository = rolesRepository;
}
[HttpGet]
[AllowAnonymous]
public async Task<ActionResult> GetSetupAsync()
{
var hasBeenSetup = await SettingsService.GetValueAsync<bool>(StateSettingsKey);
if (hasBeenSetup)
return Problem("This instance is already configured", statusCode: 405);
return NoContent();
}
[HttpPost]
[AllowAnonymous]
public async Task<ActionResult> ApplySetupAsync([FromBody] ApplySetupDto dto)
{
var adminRole = await RolesRepository
.Query()
.FirstOrDefaultAsync(x => x.Name == "Administrators");
if (adminRole == null)
adminRole = await RolesRepository.AddAsync(new Role
{
Name = "Administrators",
Description = "Automatically generated group for full administrator permissions",
Permissions =
[
Permissions.ApiKeys.View,
Permissions.ApiKeys.Create,
Permissions.ApiKeys.Edit,
Permissions.ApiKeys.Delete,
Permissions.Roles.View,
Permissions.Roles.Create,
Permissions.Roles.Edit,
Permissions.Roles.Delete,
Permissions.Roles.Members,
Permissions.Users.View,
Permissions.Users.Create,
Permissions.Users.Edit,
Permissions.Users.Delete,
Permissions.Users.Logout,
Permissions.Themes.View,
Permissions.Themes.Create,
Permissions.Themes.Edit,
Permissions.Themes.Delete,
Permissions.System.Info,
Permissions.System.Diagnose,
Permissions.System.Versions,
Permissions.System.Instance
]
});
var user = await UsersRepository
.Query()
.FirstOrDefaultAsync(u => u.Email == dto.AdminEmail);
if (user == null)
{
await UsersRepository.AddAsync(new User
{
Email = dto.AdminEmail,
Username = dto.AdminUsername,
RoleMemberships =
[
new RoleMember
{
Role = adminRole,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
}
],
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
}
else
{
user.RoleMemberships.Add(new RoleMember
{
Role = adminRole,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
await UsersRepository.UpdateAsync(user);
}
await SettingsService.SetValueAsync(StateSettingsKey, true);
return NoContent();
}
}

View File

@@ -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.ApiKeys; using Moonlight.Shared.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.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
@@ -92,7 +89,7 @@ public class ApiKeyController : Controller
public async Task<ActionResult<ApiKeyDto>> CreateAsync([FromBody] CreateApiKeyDto request) public async Task<ActionResult<ApiKeyDto>> CreateAsync([FromBody] CreateApiKeyDto request)
{ {
var apiKey = ApiKeyMapper.ToEntity(request); var apiKey = ApiKeyMapper.ToEntity(request);
apiKey.Key = Guid.NewGuid().ToString("N").Substring(0, 32); apiKey.Key = Guid.NewGuid().ToString("N").Substring(0, 32);
var finalKey = await KeyRepository.AddAsync(apiKey); var finalKey = await KeyRepository.AddAsync(apiKey);
@@ -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.ApiKeys; using Moonlight.Shared.Admin.Sys.ApiKeys;
using Moonlight.Shared.Http.Responses.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

@@ -0,0 +1,89 @@
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using VersionService = Moonlight.Api.Admin.Sys.Versions.VersionService;
namespace Moonlight.Api.Admin.Sys;
public class ApplicationService : IHostedService
{
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 string VersionName { get; private set; } = "N/A";
public bool IsUpToDate { get; set; } = true;
public string OperatingSystem { get; private set; } = "N/A";
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)
{
return Task.CompletedTask;
}
public Task<long> GetMemoryUsageAsync()
{
using var currentProcess = Process.GetCurrentProcess();
return Task.FromResult(currentProcess.WorkingSet64);
}
public async Task<double> GetCpuUsageAsync()
{
using var currentProcess = Process.GetCurrentProcess();
// Get initial values
var startCpuTime = currentProcess.TotalProcessorTime;
var startTime = DateTime.UtcNow;
// Wait a bit to calculate the diff
await Task.Delay(500);
// New values
var endCpuTime = currentProcess.TotalProcessorTime;
var endTime = DateTime.UtcNow;
// Calculate CPU usage
var cpuUsedMs = (endCpuTime - startCpuTime).TotalMilliseconds;
var totalMsPassed = (endTime - startTime).TotalMilliseconds;
var cpuUsagePercent = cpuUsedMs / (Environment.ProcessorCount * totalMsPassed) * 100;
return Math.Round(cpuUsagePercent, 2);
}
}

View File

@@ -1,22 +1,22 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration; using Moonlight.Shared;
using Moonlight.Api.Mappers; using Moonlight.Shared.Admin.Sys.ContainerHelper;
using Moonlight.Api.Services;
using Moonlight.Shared.Http.Requests.Admin.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")]
[Authorize(Policy = Permissions.System.Instance)]
public class ContainerHelperController : Controller 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;
@@ -34,9 +34,9 @@ public class ContainerHelperController : Controller
} }
[HttpPost("rebuild")] [HttpPost("rebuild")]
public Task<IResult> RebuildAsync() public Task<IResult> RebuildAsync([FromBody] RequestRebuildDto request)
{ {
var result = ContainerHelperService.RebuildAsync(); var result = ContainerHelperService.RebuildAsync(request.NoBuildCache);
var mappedResult = result.Select(ContainerHelperMapper.ToDto); var mappedResult = result.Select(ContainerHelperMapper.ToDto);
return Task.FromResult<IResult>( return Task.FromResult<IResult>(

View File

@@ -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,10 +1,10 @@
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
{ {
@@ -32,17 +32,28 @@ public class ContainerHelperService
} }
} }
public async IAsyncEnumerable<RebuildEventDto> RebuildAsync() public async IAsyncEnumerable<RebuildEventDto> RebuildAsync(bool noBuildCache)
{ {
var client = HttpClientFactory.CreateClient("ContainerHelper"); var client = HttpClientFactory.CreateClient("ContainerHelper");
var response = await client.GetAsync("api/rebuild", HttpCompletionOption.ResponseHeadersRead); var request = new HttpRequestMessage(HttpMethod.Post, "api/rebuild");
request.Content = JsonContent.Create(
new RequestRebuildDto(noBuildCache),
null,
SerializationContext.Default.Options
);
var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead
);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
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
@@ -65,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;
@@ -74,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
@@ -88,17 +100,18 @@ public class ContainerHelperService
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
"api/configuration/version", "api/configuration/version",
new SetVersionDto(version), new SetVersionDto(version),
SerializationContext.TunedOptions SerializationContext.Default.Options
); );
if(response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
return; return;
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.TunedOptions); var problemDetails =
await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.Default.Options);
if(problemDetails == null)
if (problemDetails == null)
throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}"); throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}");
throw new HttpRequestException($"Failed to set version: {problemDetails.Detail ?? problemDetails.Title}"); throw new HttpRequestException($"Failed to set version: {problemDetails.Detail ?? problemDetails.Title}");
} }
} }

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

@@ -0,0 +1,83 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Options;
using Moonlight.Api.Infrastructure.Database;
using Moonlight.Api.Infrastructure.Database.Entities;
namespace Moonlight.Api.Admin.Sys.Settings;
public class SettingsService
{
private const string CacheKey = "Moonlight.Api.SettingsService.{0}";
private readonly HybridCache HybridCache;
private readonly IOptions<SettingsOptions> Options;
private readonly DatabaseRepository<SettingsOption> Repository;
public SettingsService(
DatabaseRepository<SettingsOption> repository,
IOptions<SettingsOptions> options,
HybridCache hybridCache
)
{
Repository = repository;
HybridCache = hybridCache;
Options = options;
}
public async Task<T?> GetValueAsync<T>(string key)
{
var cacheKey = string.Format(CacheKey, key);
var value = await HybridCache.GetOrCreateAsync<string?>(
cacheKey,
async ct =>
{
return await Repository
.Query()
.Where(x => x.Key == key)
.Select(o => o.ValueJson)
.FirstOrDefaultAsync(ct);
},
new HybridCacheEntryOptions
{
LocalCacheExpiration = Options.Value.LookupL1CacheTime,
Expiration = Options.Value.LookupL2CacheTime
}
);
if (string.IsNullOrWhiteSpace(value))
return default;
return JsonSerializer.Deserialize<T>(value);
}
public async Task SetValueAsync<T>(string key, T value)
{
var cacheKey = string.Format(CacheKey, key);
var option = await Repository
.Query()
.FirstOrDefaultAsync(x => x.Key == key);
var json = JsonSerializer.Serialize(value);
if (option != null)
{
option.ValueJson = json;
await Repository.UpdateAsync(option);
}
else
{
option = new SettingsOption
{
Key = key,
ValueJson = json
};
await Repository.AddAsync(option);
}
await HybridCache.RemoveAsync(cacheKey);
}
}

View File

@@ -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.Themes; using Moonlight.Shared.Admin.Sys.Themes;
using Moonlight.Shared.Http.Responses.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.Themes; using Moonlight.Shared.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.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
@@ -116,7 +109,7 @@ public class ThemesController : Controller
if (theme == null) if (theme == null)
return Problem("No theme with this id found", statusCode: 404); return Problem("No theme with this id found", statusCode: 404);
ThemeMapper.Merge(theme, request); ThemeMapper.Merge(theme, request);
await ThemeRepository.UpdateAsync(theme); await ThemeRepository.UpdateAsync(theme);
@@ -137,9 +130,9 @@ public class ThemesController : Controller
return Problem("No theme with this id found", statusCode: 404); return Problem("No theme with this id found", statusCode: 404);
await ThemeRepository.RemoveAsync(theme); await ThemeRepository.RemoveAsync(theme);
await FrontendService.ResetCacheAsync(); await FrontendService.ResetCacheAsync();
return NoContent(); return NoContent();
} }
} }

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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.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.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.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")]
@@ -39,15 +36,13 @@ public class RolesController : Controller
return Problem("Invalid length specified"); return Problem("Invalid length specified");
// Query building // Query building
var query = RoleRepository var query = RoleRepository
.Query(); .Query();
// 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
@@ -106,7 +99,7 @@ public class RolesController : Controller
if (role == null) if (role == null)
return Problem("No role with this id found", statusCode: 404); return Problem("No role with this id found", statusCode: 404);
RoleMapper.Merge(role, request); RoleMapper.Merge(role, request);
await RoleRepository.UpdateAsync(role); await RoleRepository.UpdateAsync(role);

View File

@@ -1,36 +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.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 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,
HybridCache hybridCache
)
{ {
UserRepository = userRepository; UserRepository = userRepository;
Logger = logger; Logger = logger;
Cache = cache;
Options = options; Options = options;
Hooks = hooks;
HybridCache = hybridCache;
} }
public async Task<bool> SyncAsync(ClaimsPrincipal? principal) public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
@@ -50,12 +55,11 @@ public class UserAuthService
// We use email as the primary identifier here // We use email as the primary identifier here
var user = await UserRepository var user = await UserRepository
.Query() .Query()
.AsNoTracking()
.FirstOrDefaultAsync(user => user.Email == email); .FirstOrDefaultAsync(user => user.Email == email);
if (user == null) // Sync user if not already existing in the database if (user == null) // Sync user if not already existing in the database
{ {
user = await UserRepository.AddAsync(new User() user = await UserRepository.AddAsync(new User
{ {
Username = username, Username = username,
Email = email, Email = email,
@@ -74,6 +78,11 @@ public class UserAuthService
new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()) new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())
]); ]);
foreach (var hook in Hooks)
// Run every hook, and if any returns false, we return false as well
if (!await hook.SyncAsync(principal, user))
return false;
return true; return true;
} }
@@ -89,33 +98,30 @@ public class UserAuthService
return false; return false;
var cacheKey = string.Format(CacheKeyPattern, userId); var cacheKey = string.Format(CacheKeyPattern, userId);
if (!Cache.TryGetValue<UserSession>(cacheKey, out var user))
{
user = await UserRepository
.Query()
.AsNoTracking()
.Where(u => u.Id == userId)
.Select(u => new UserSession(
u.InvalidateTimestamp,
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
)
.FirstOrDefaultAsync();
if (user == null) var user = await HybridCache.GetOrCreateAsync<UserSession?>(
return false; cacheKey,
async ct =>
{
return await UserRepository
.Query()
.AsNoTracking()
.Where(u => u.Id == userId)
.Select(u => new UserSession(
u.InvalidateTimestamp,
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
)
.FirstOrDefaultAsync(ct);
},
new HybridCacheEntryOptions
{
LocalCacheExpiration = Options.Value.ValidationCacheL1Expiry,
Expiration = Options.Value.ValidationCacheL2Expiry
}
);
Cache.Set( if (user == null)
cacheKey, return false;
user,
TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes)
);
}
else
{
if (user == null)
return false;
}
var issuedAtString = principal.FindFirstValue(IssuedAtClaim); var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
@@ -131,10 +137,16 @@ public class UserAuthService
if (issuedAt < user.InvalidateTimestamp) if (issuedAt < user.InvalidateTimestamp)
return false; return false;
// Load every permission as claim
principal.Identities.First().AddClaims( principal.Identities.First().AddClaims(
user.Permissions.Select(x => new Claim(Permissions.ClaimType, x)) user.Permissions.Select(x => new Claim(Permissions.ClaimType, x))
); );
foreach (var hook in Hooks)
// Run every hook, and if any returns false we return false as well
if (!await hook.ValidateAsync(principal, userId))
return false;
return true; return true;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +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.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Api.Database.Entities;
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.Users; using Moonlight.Shared.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Admin.Users.Users;
[Authorize] [Authorize]
[ApiController] [ApiController]
@@ -40,7 +37,7 @@ public class UsersController : Controller
return Problem("Invalid length specified"); return Problem("Invalid length specified");
// Query building // Query building
var query = UserRepository var query = UserRepository
.Query(); .Query();
@@ -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
@@ -117,19 +114,4 @@ public class UsersController : Controller
return UserMapper.ToDto(user); return UserMapper.ToDto(user);
} }
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Users.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var user = await UserRepository
.Query()
.FirstOrDefaultAsync(user => user.Id == id);
if (user == null)
return Problem("No user with this id found", statusCode: 404);
await UserRepository.RemoveAsync(user);
return NoContent();
}
} }

View File

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

View File

@@ -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 SetVersionDto(string Version);

View File

@@ -1,28 +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))]
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,9 +1,10 @@
namespace Moonlight.Api.Configuration; namespace Moonlight.Api.Infrastructure.Configuration;
public class OidcOptions public class OidcOptions
{ {
public string Authority { get; set; } public string Authority { get; set; }
public bool RequireHttpsMetadata { get; set; } = true; public bool RequireHttpsMetadata { get; set; } = true;
public bool DisableHttpsOnlyCookies { get; set; }
public string ResponseType { get; set; } = "code"; public string ResponseType { get; set; } = "code";
public string[]? Scopes { get; set; } public string[]? Scopes { get; set; }
public string ClientId { get; set; } public string ClientId { get; set; }

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.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,24 +13,36 @@ 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)
return; return;
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
$"Host={Options.Value.Host};" + $"Host={Options.Value.Host};" +
$"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");
}
); );
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.HasDefaultSchema("core"); modelBuilder.HasDefaultSchema("core");
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
} }
} }

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)
{ {
@@ -23,7 +26,7 @@ public class DatabaseRepository<T> where T : class
actionTimestamps.CreatedAt = DateTimeOffset.UtcNow; actionTimestamps.CreatedAt = DateTimeOffset.UtcNow;
actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow; actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow;
} }
var final = Set.Add(entity); var final = Set.Add(entity);
await DataContext.SaveChangesAsync(); await DataContext.SaveChangesAsync();
return final.Entity; return final.Entity;
@@ -33,7 +36,7 @@ public class DatabaseRepository<T> where T : class
{ {
if (entity is IActionTimestamps actionTimestamps) if (entity is IActionTimestamps actionTimestamps)
actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow; actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow;
Set.Update(entity); Set.Update(entity);
await DataContext.SaveChangesAsync(); await DataContext.SaveChangesAsync();
} }

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,23 +1,21 @@
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)] public required string Description { get; set; }
[MaxLength(300)]
public required string Description { get; set; }
public string[] Permissions { get; set; } = []; public string[] Permissions { get; set; } = [];
// Relations // Relations
public List<RoleMember> Members { get; set; } = []; public List<RoleMember> Members { get; set; } = [];
// Action timestamps // Action timestamps
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { 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
{ {
@@ -8,7 +8,7 @@ public class RoleMember : IActionTimestamps
public Role Role { get; set; } public Role Role { get; set; }
public User User { get; set; } public User User { get; set; }
// Action timestamps // Action timestamps
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }

View File

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

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,25 +1,23 @@
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)] public required string Email { get; set; }
[MaxLength(254)]
public required string Email { get; set; }
// Authentication // Authentication
public DateTimeOffset InvalidateTimestamp { get; set; } public DateTimeOffset InvalidateTimestamp { get; set; }
// Relations // Relations
public List<RoleMember> RoleMemberships { get; set; } = []; public List<RoleMember> RoleMemberships { get; set; } = [];
// Action timestamps // Action timestamps
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { 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

@@ -0,0 +1,252 @@
// <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("20260129134620_SwitchedToJsonForSettingsOption")]
partial class SwitchedToJsonForSettingsOption
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("core")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Roles", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RoleId");
b.HasIndex("UserId");
b.ToTable("RoleMembers", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("CssContent")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.HasKey("Id");
b.ToTable("Themes", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Users", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.HasOne("Moonlight.Api.Database.Entities.Role", "Role")
.WithMany("Members")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Moonlight.Api.Database.Entities.User", "User")
.WithMany("RoleMemberships")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Navigation("RoleMemberships");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -0,0 +1,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");
@@ -136,10 +140,10 @@ namespace Moonlight.Api.Database.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
b.Property<string>("Value") b.Property<string>("ValueJson")
.IsRequired() .IsRequired()
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)"); .HasColumnType("jsonb");
b.HasKey("Id"); b.HasKey("Id");

View File

@@ -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