36 Commits

Author SHA1 Message Date
826714962e Made username table cell clickable to open edit screen and removed the unnecessary text-left 2026-02-01 16:37:19 +01:00
58c882603c Implemented user logout and deletion service. Added Auth, Deletion and Logout hook. Restructed controllers 2026-02-01 16:26:11 +01: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
76a8a72e83 Moved the applying of selected permissions to the correct place in the role dialogs 2026-01-19 11:06:48 +01:00
3e302198a2 Removed unused launchSettings.json and unnecessary folder reference from project file to clean up configuration.
All checks were successful
Dev Publish: Nuget / Publish Dev Packages (push) Successful in 45s
2026-01-19 10:58:55 +01:00
4187f9da4e Implemented frontend configuration service and dynamic theme reloading integration. Updated sidebar to display dynamic application name.
All checks were successful
Dev Publish: Nuget / Publish Dev Packages (push) Successful in 48s
2026-01-19 10:55:34 +01:00
d85b07bde7 Upgraded ShadcnBlazor and Extras to 1.0.9. Replaced Checkbox with Switch for "Is Enabled" fields and updated CSS editor language to Css. 2026-01-19 10:24:05 +01:00
3cbdd3b203 Implemented theme crud and basic theme loading 2026-01-18 23:31:01 +01:00
56b14f60f1 Implementing api key authentication scheme and validation. Added default value in dtos 2026-01-17 21:05:20 +01:00
01c86406dc Implemented API key management with permission checks, database schema, and frontend integration. Adjusted string lengths for Role and API key attributes. 2026-01-16 15:06:45 +01:00
a28b8aca7a Added permission checks to all controllers. Added role permission loading. Added frontend permission checks. Implemented user logout in admin panel. 2026-01-16 13:07:19 +01:00
bee381702b Added session caching for user validation to reduce db calls and introduced configurable session options. 2026-01-16 09:19:15 +01:00
10cd0f0b09 Implemented member management of roles. Moved users controller 2026-01-15 14:52:29 +01:00
fcaa0dcd07 Migrated user management views to modal-based dialogs, restructured roles, and user pages under /admin/users. Simplified navigation paths and improved tabbed interface. 2026-01-15 12:26:49 +01:00
ea35ddb0dc Added build step for frontend in NuGet publishing workflow to ensure class list files generate properly 2026-01-15 11:45:06 +01:00
b4536ca6e9 Upgraded ShadcnBlazor and Extras to 1.0.8, adjusted styles import paths, and adjusted frontend build process.
Some checks failed
Dev Publish: Nuget / Publish Dev Packages (push) Failing after 23s
2026-01-15 10:59:40 +01:00
7f482fd6c3 Refactored response and request models to dto naming. Adjusted mapper naming 2026-01-14 19:19:45 +01:00
1d1dfc2c1c Adjusted sideoffset of dropdown menu on users page 2026-01-14 19:08:04 +01:00
a197d7d980 Improved diagnose accordium UIs 2026-01-14 19:05:42 +01:00
06063b94b3 Implemented roles and action timestamps. Added oermissions selector and interfaces 2026-01-14 19:03:17 +01:00
43d43a6d7d Adjusted compose file from template to match moonlight 2026-01-14 19:01:05 +01:00
1849cda2f5 Added npm install command to nuget publishing action 2026-01-14 16:19:37 +01:00
f836657603 Implemented Tailwind CSS class list extraction and integrated it into the build process of the frontend package
Some checks failed
Dev Publish: Nuget / Publish Dev Packages (push) Failing after 16s
2026-01-14 16:10:06 +01:00
7e0b427137 Added Gitea workflow for publishing NuGet packages and configured project files for NuGet packaging
All checks were successful
Dev Publish: Nuget / Publish Dev Packages (push) Successful in 41s
2026-01-14 13:36:36 +01:00
b79c8fe476 Added current tab as query parameter to system page 2025-12-30 16:07:08 +01:00
f71bad3da3 Upgraded ShadcnBlazor to 1.0.5 2025-12-30 16:06:33 +01:00
170cac2091 Minor design improvements to user table and diagnose page 2025-12-30 16:06:18 +01:00
ba942b2f8f Made sidebar item collection extendable via interface. Refactored settings to system 2025-12-27 23:54:48 +01:00
05c05f1b72 Adjusted config options for oidc and database to use the correct section 2025-12-27 23:34:00 +01:00
ec6782160c Added update model with progress animations. Backend not implemented 2025-12-27 23:33:33 +01:00
1581276854 Adjusted dockerfile for library-host architecture 2025-12-27 23:32:54 +01:00
e1c0645428 Added diagnose frontend and backend implementation 2025-12-27 23:32:36 +01:00
be3cdb8235 Added settings option model. Recreated migrations 2025-12-25 22:02:12 +01:00
bfc7d9993a Refactored pages to correct locations 2025-12-25 22:00:59 +01:00
ca69d410f2 Added small system overview 2025-12-25 21:55:46 +01:00
a2d4edc0e5 Recreated solution with web app template. Improved theme. Switched to ShadcnBlazor library 2025-12-25 19:16:53 +01:00
373 changed files with 8651 additions and 14451 deletions

30
.dockerignore Normal file
View File

@@ -0,0 +1,30 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/appsettings.json
**/appsettings.Development.json
**/appsettings*
**/compose.*
**/.env.example
LICENSE
README.md

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=user
DATABASE_PASSWORD=user
DATABASE_DATABASE=user
OIDC_AUTHORITY=http://localhost:8092
OIDC_AUTHORITY=http://localhost:8092
OIDC_CLIENT_ID=client_id
OIDC_CLIENT_SECRET=client_secret
OIDC_REQUIRE_HTTPS_METADATA=false

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto

View File

@@ -0,0 +1,66 @@
name: "Dev Publish: Nuget"
on:
workflow_dispatch:
push:
branches:
- v2.1
paths:
- 'Moonlight.*/*.csproj'
jobs:
publish:
name: Publish Dev Packages
runs-on: linux_amd64
steps:
- name: Check out repository code
uses: actions/checkout@v4
# Publish api server
- name: Build project
run: dotnet build Moonlight.Api --configuration Debug
- name: Publish NuGet package
run: dotnet pack Moonlight.Api --configuration Debug --output ./artifacts
- name: Push nuget package
run: dotnet nuget push ./artifacts/*.nupkg --skip-duplicate --source https://git.battlestati.one/api/packages/Moonlight-Panel/nuget/index.json --api-key ${{ secrets.ACCESS_TOKEN }}
- name: Remove artifacts
run: rm -rf ./artifacts
# Publish frontend
# We need to build it first so the class list files generate
- name: Build project
run: dotnet build Moonlight.Frontend --configuration Debug
- name: Build tailwind styles and extract class list
working-directory: Hosts/Moonlight.Frontend.Host/Styles
run: npm install && npm run build
- name: Build project
run: dotnet build Moonlight.Frontend --configuration Debug
- name: Publish NuGet package
run: dotnet pack Moonlight.Frontend --configuration Debug --output ./artifacts
- name: Push nuget package
run: dotnet nuget push ./artifacts/*.nupkg --skip-duplicate --source https://git.battlestati.one/api/packages/Moonlight-Panel/nuget/index.json --api-key ${{ secrets.ACCESS_TOKEN }}
- name: Remove artifacts
run: rm -rf ./artifacts
# Publish shared
- name: Build project
run: dotnet build Moonlight.Shared --configuration Debug
- name: Publish NuGet package
run: dotnet pack Moonlight.Shared --configuration Debug --output ./artifacts
- name: Push nuget package
run: dotnet nuget push ./artifacts/*.nupkg --skip-duplicate --source https://git.battlestati.one/api/packages/Moonlight-Panel/nuget/index.json --api-key ${{ secrets.ACCESS_TOKEN }}
- name: Remove artifacts
run: rm -rf ./artifacts

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
# These are supported funding model platforms
ko_fi: masuowo

View File

@@ -1,87 +0,0 @@
name: Bug Report
description: Something isn't working quite right in the software.
labels: [not confirmed]
body:
- type: markdown
attributes:
value: |
Bug reports should only be used for reporting issues with how the software works. For assistance installing this software, as well as debugging issues with dependencies, please use our [Discord server](https://discord.gg/TJaspT7A8p).
- type: textarea
attributes:
label: Current Behavior
description: Please provide a clear & concise description of the issue.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: Please describe what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
description: Please be as detailed as possible when providing steps to reproduce, failure to provide steps will result in this issue being closed.
validations:
required: true
- type: input
id: panel-version
attributes:
label: Panel Version
description: Version number of your Panel (latest is not a version)
placeholder: v2 EA
validations:
required: true
- type: input
id: daemon-version
attributes:
label: Daemon Version
description: Version number of your Daemon (latest is not a version)
placeholder: v2 EA
validations:
required: true
- type: input
id: image-details
attributes:
label: Games and/or Images Affected
description: Please include the specific game(s) or Image(s) you are running into this bug with.
placeholder: Minecraft (Paper), Minecraft (Forge)
- type: input
id: docker-image
attributes:
label: Docker Image
description: The specific Docker image you are using for the game(s) above.
placeholder: ghcr.io/xxx/yolks:java_17
- type: textarea
id: panel-logs
attributes:
label: Error Logs
description: |
Run the following command to collect logs on your system.
Panel: `docker logs moonlight`
Wings: `sudo wings diagnostics`
placeholder: logs here
render: bash
validations:
required: false
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please [search here](https://github.com/Moonlight-Panel/Moonlight/issues) to see if an issue already exists for your problem.
options:
- label: I have searched the existing issues before opening this issue.
required: true
- label: I have provided all relevant details, including the specific game and Docker images I am using if this issue is related to running a server.
required: true
- label: I have checked in the Discord server and believe this is a bug with the software, and not a configuration issue with my specific system.
required: true

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Installation Help
url: https://discord.gg/TJaspT7A8p
about: Please visit our Discord for help with your installation.
- name: General Question
url: https://discord.gg/TJaspT7A8p
about: Please visit our Discord for general questions about Moonlight Panel.

View File

@@ -1,32 +0,0 @@
name: Feature Request
description: Suggest a new feature or improvement for the software.
labels: [feature request]
body:
- type: checkboxes
attributes:
label: Is there an existing feature request for this?
description: Please [search here](https://github.com/Moonlight-Panel/Moonlight/issues?q=is%3Aissue) to see if someone else has already suggested this.
options:
- label: I have searched the existing issues before opening this feature request.
required: true
- type: textarea
attributes:
label: Describe the feature you would like to see.
description: "A clear & concise description of the feature you'd like to have added, and what issues it would solve."
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like.
description: "You must explain how you'd like to see this feature implemented. Technical implementation details are not necessary, rather an idea of how you'd like to see this feature used."
validations:
required: true
- type: textarea
attributes:
label: Additional context to this request.
description: "Add any other context or screenshots about the feature request."
validations:
required: false

View File

@@ -1,42 +0,0 @@
name: Build and Publish NuGet Package
on:
push:
branches: [ v2_ChangeArchitecture,v2.1 ]
paths:
- '**.csproj'
workflow_dispatch:
jobs:
publish:
runs-on: debian-12
strategy:
matrix:
project:
- Moonlight.Client
- Moonlight.ApiServer
- Moonlight.Shared
steps:
# Step 1: Clean environment
- name: Clean up Environment
run: |
rm -rf ./*
rm -rf ./.??*
# Step 2: Checkout the code
- name: Checkout code
uses: actions/checkout@v3
# Step 3: Build project
- name: "Build project"
run: dotnet build --configuration Debug ${{ matrix.project }}/${{ matrix.project }}.csproj
# Step 4: Pack project
- name: "Pack project"
run: dotnet pack --configuration Debug --no-build --output . ${{ matrix.project }}/${{ matrix.project }}.csproj
# Step 5: Publish on package registry
- name: Publish on package registry"
run: dotnet nuget push "*.nupkg" --skip-duplicate --api-key ${{secrets.GH_PACKAGES_READWRITE}} --source https://nuget.pkg.github.com/Moonlight-Panel/index.json

43
.gitignore vendored
View File

@@ -1,4 +1,4 @@
## Ignore Visual Studio temporary files, build results, and ## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons. ## files generated by popular Visual Studio add-ons.
## ##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
@@ -395,40 +395,13 @@ FodyWeavers.xsd
*.msp *.msp
# JetBrains Rider # JetBrains Rider
*.sln.iml
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Moonlight
storage/
**/.idea/** **/.idea/**
style.min.css
# Build script for nuget packages # Style builds
finalPackages/ **/style.min.css
nupkgs/ **/package-lock.json
# Scripts # Secrets
**/bin/** **/.env
**/obj/** **/appsettings.json
**/appsettings.Development.json

View File

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

View File

@@ -0,0 +1,70 @@
# Base image
FROM cgr.dev/chainguard/aspnet-runtime:latest AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# Build dependencies
RUN apt-get update; apt-get install nodejs npm -y; apt-get clean
# Build options
ARG BUILD_CONFIGURATION=Release
# Download npm packages
WORKDIR /src/Hosts/Moonlight.Frontend.Host/Styles
COPY ["Hosts/Moonlight.Frontend.Host/Styles/package.json", "package.json"]
RUN npm install
# Restore nuget packages
WORKDIR /src
COPY ["Moonlight.Api/Moonlight.Api.csproj", "Moonlight.Api/"]
COPY ["Moonlight.Frontend/Moonlight.Frontend.csproj", "Moonlight.Frontend/"]
COPY ["Moonlight.Shared/Moonlight.Shared.csproj", "Moonlight.Shared/"]
COPY ["Hosts/Moonlight.Frontend.Host/Moonlight.Frontend.Host.csproj", "Hosts/Moonlight.Frontend.Host/"]
COPY ["Hosts/Moonlight.Api.Host/Moonlight.Api.Host.csproj", "Hosts/Moonlight.Api.Host/"]
RUN dotnet restore "Hosts/Moonlight.Api.Host/Moonlight.Api.Host.csproj"
RUN dotnet restore "Hosts/Moonlight.Frontend.Host/Moonlight.Frontend.Host.csproj"
# Copy over the whole sources
COPY . .
# Build styles
# We need to build it before, so the class lists get generated
WORKDIR "/src/Hosts/Moonlight.Frontend.Host"
RUN dotnet build "./Moonlight.Frontend.Host.csproj" -c $BUILD_CONFIGURATION -o /app/build-frontend
WORKDIR "/src/Hosts/Moonlight.Frontend.Host/Styles"
RUN npm run build
# Build projects
WORKDIR "/src/Hosts/Moonlight.Api.Host"
RUN dotnet build "./Moonlight.Api.Host.csproj" -c $BUILD_CONFIGURATION -o /app/build-api
WORKDIR "/src/Hosts/Moonlight.Frontend.Host"
RUN dotnet build "./Moonlight.Frontend.Host.csproj" -c $BUILD_CONFIGURATION -o /app/build-frontend
FROM build AS publish
# Publish options
ARG BUILD_CONFIGURATION=Release
# Publish applications
WORKDIR "/src/Hosts/Moonlight.Api.Host"
RUN dotnet publish "./Moonlight.Api.Host.csproj" -c $BUILD_CONFIGURATION -o /app/publish-api /p:UseAppHost=false
WORKDIR "/src/Hosts/Moonlight.Frontend.Host"
RUN dotnet publish "./Moonlight.Frontend.Host.csproj" -c $BUILD_CONFIGURATION -o /app/publish-frontend /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish-api .
COPY --from=publish /app/publish-frontend/wwwroot ./wwwroot
ENTRYPOINT ["dotnet", "Moonlight.Api.Host.dll"]

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/>
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Moonlight.Api\Moonlight.Api.csproj"/>
<ProjectReference Include="..\Moonlight.Frontend.Host\Moonlight.Frontend.Host.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
using Moonlight.Api.Host;
var appLoader = new AppStartupLoader();
appLoader.Initialize();
var builder = WebApplication.CreateBuilder(args);
appLoader.PreBuild(builder);
var app = builder.Build();
appLoader.PostBuild(app);
appLoader.PostMiddleware(app);
if (app.Environment.IsDevelopment())
app.UseWebAssemblyDebugging();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
await app.RunAsync();

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5240",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

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

View File

@@ -1,28 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<NoDefaultLaunchSettingsFile>True</NoDefaultLaunchSettingsFile>
<StaticWebAssetsEnabled>True</StaticWebAssetsEnabled>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
<WasmEnableSIMD>true</WasmEnableSIMD>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Moonlight.Client\Moonlight.Client.csproj"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
</ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all"/>
<ItemGroup>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/> <PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/>
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/> <PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.9" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="wwwroot\css\" /> <ProjectReference Include="..\..\Moonlight.Frontend\Moonlight.Frontend.csproj" />
</ItemGroup> </ItemGroup>
<Import Project="Plugins.props" />
</Project> </Project>

View File

@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Moonlight.Frontend.Host;
var appLoader = new AppStartupLoader();
appLoader.Initialize();
var builder = WebAssemblyHostBuilder.CreateDefault(args);
appLoader.PreBuild(builder);
var app = builder.Build();
appLoader.PostBuild(app);
await app.RunAsync();

View File

@@ -1,11 +1,12 @@
{ {
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": { "profiles": {
"http": { "http": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5165", "applicationUrl": "http://localhost:5250",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View File

@@ -0,0 +1,25 @@
import fs from 'fs';
import selectorParser from 'postcss-selector-parser';
export default function extractTailwindClasses(opts = {}) {
const classSet = new Set();
return {
postcssPlugin: 'extract-tailwind-classes',
Rule(rule) {
selectorParser(selectors => {
selectors.walkClasses(node => {
classSet.add(node.value);
});
}).processSync(rule.selector);
},
OnceExit() {
const classArray = Array.from(classSet).sort();
fs.mkdirSync('../../../Moonlight.Frontend/Styles', { recursive: true });
fs.writeFileSync('../../../Moonlight.Frontend/Styles/Moonlight.Frontend.map', classArray.join('\n'));
console.log(`Extracted classes ${classArray.length}`);
}
};
}
extractTailwindClasses.postcss = true;

View File

@@ -0,0 +1,19 @@
{
"scripts": {
"dev": "npx postcss styles.css -o ../wwwroot/style.min.css --watch",
"dev-build": "npx postcss styles.css -o ../wwwroot/style.min.css",
"build": "cross-env EXTRACT_CLASSES=true npx postcss styles.css -o ../wwwroot/style.min.css"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.18",
"cssnano": "^7.1.2",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-selector-parser": "^7.1.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"cross-env": "^10.1.0"
}
}

View File

@@ -0,0 +1,18 @@
import tailwindcss from '@tailwindcss/postcss';
import cssnano from 'cssnano';
import extractTailwindClasses from './extract-classes.mjs';
const plugins = [
tailwindcss,
cssnano({
preset: 'default'
})
];
if (process.env.EXTRACT_CLASSES === "true") {
plugins.push(extractTailwindClasses());
}
export default {
plugins
};

View File

@@ -0,0 +1,31 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../../../Moonlight.Frontend/bin/ShadcnBlazor/scrollbar.css";
@import "../../../Moonlight.Frontend/bin/ShadcnBlazor/default-theme.css";
@import "./theme.css";
@source "../../../Moonlight.Frontend/bin/ShadcnBlazor/ShadcnBlazor.map";
@source "../../../Moonlight.Api/**/*.razor";
@source "../../../Moonlight.Api/**/*.cs";
@source "../../../Moonlight.Api/**/*.html";
@source "../../../Moonlight.Frontend/**/*.razor";
@source "../../../Moonlight.Frontend/**/*.cs";
@source "../../../Moonlight.Frontend/**/*.html";
@custom-variant dark (&:is(.dark *));
#blazor-error-ui {
display: none;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,132 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: var(--background);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
--font-sans: Inter, sans-serif;
--font-serif: Georgia, serif;
}
.dark {
/* Deep blue-slate background with purple undertones */
--background: oklch(0.16 0.028 260);
--foreground: oklch(0.98 0.008 260);
/* Cards with slightly lighter blue-slate */
--card: oklch(0.21 0.032 260);
--card-foreground: oklch(0.98 0.008 260);
/* Popovers with medium depth */
--popover: oklch(0.24 0.035 260);
--popover-foreground: oklch(0.98 0.008 260);
/* Vibrant blue-purple primary */
--primary: oklch(0.62 0.18 270);
--primary-foreground: oklch(0.99 0.005 260);
/* Secondary with blue-slate tone */
--secondary: oklch(0.27 0.038 260);
--secondary-foreground: oklch(0.98 0.008 260);
/* Muted elements */
--muted: oklch(0.25 0.035 260);
--muted-foreground: oklch(0.66 0.025 260);
/* Accent with purple-blue blend */
--accent: oklch(0.36 0.065 268);
--accent-foreground: oklch(0.98 0.008 260);
/* Destructive red with good contrast */
--destructive: oklch(0.62 0.22 25);
--destructive-foreground: oklch(0.99 0.005 260);
/* Subtle borders and inputs */
--border: oklch(0.32 0.025 260);
--input: oklch(0.30 0.030 260);
--ring: oklch(0.62 0.18 270);
/* Chart colors with blue-purple harmony */
--chart-1: oklch(0.58 0.18 270);
--chart-2: oklch(0.62 0.16 245);
--chart-3: oklch(0.68 0.15 290);
--chart-4: oklch(0.60 0.20 260);
--chart-5: oklch(0.65 0.14 280);
/* Sidebar with slightly different depth */
--sidebar: oklch(0.18 0.030 260);
--sidebar-foreground: oklch(0.97 0.008 260);
--sidebar-primary: oklch(0.60 0.17 270);
--sidebar-primary-foreground: oklch(0.99 0.005 260);
--sidebar-accent: oklch(0.26 0.038 260);
--sidebar-accent-foreground: oklch(0.98 0.008 260);
--sidebar-border: oklch(0.30 0.025 260);
--sidebar-ring: oklch(0.58 0.17 270);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Moonlight</title>
<base href="/" />
<link rel="preload" id="webassembly" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=fallback" />
<link rel="stylesheet" href="style.min.css" />
<script type="importmap"></script>
<script>
window.frontendConfig = {
STYLE_TAG_ID: 'theme-variables',
configuration: {},
applyTheme: function(cssContent) {
// Find or create the style tag
let styleTag = document.getElementById(this.STYLE_TAG_ID);
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = this.STYLE_TAG_ID;
document.head.appendChild(styleTag);
}
// Update the style tag content
styleTag.textContent = cssContent;
},
reloadConfiguration: function (){
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/frontend/config', false);
xhr.send(null);
if (xhr.status === 200) {
this.configuration = JSON.parse(xhr.responseText);
}
} catch (error) {
console.error('Failed to load initial theme:', error);
}
},
getConfiguration: function (){
return this.configuration;
},
reload: function () {
this.reloadConfiguration();
document.title = this.configuration.name;
this.applyTheme(this.configuration.themeCss);
}
};
window.frontendConfig.reload();
</script>
</head>
<body class="bg-background text-foreground">
<div id="app">
<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 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">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-zap-icon lucide-zap size-6">
<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>
</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>
<script src="/_content/ShadcnBlazor/interop.js" defer></script>
<script src="/_content/ShadcnBlazor.Extras/interop.js" defer></script>
<script src="/_content/ShadcnBlazor.Extras/codemirror-bundle.js" defer></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>
</html>

121
LICENSE
View File

@@ -1,121 +0,0 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

View File

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

View File

@@ -0,0 +1,10 @@
namespace Moonlight.Api.Configuration;
public class DatabaseOptions
{
public string Host { get; set; }
public int Port { get; set; } = 5432;
public string Username { get; set; }
public string Password { get; set; }
public string Database { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class FrontendOptions
{
public bool Enabled { get; set; } = true;
public int CacheMinutes { get; set; } = 3;
}

View File

@@ -0,0 +1,11 @@
namespace Moonlight.Api.Configuration;
public class OidcOptions
{
public string Authority { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public string ResponseType { get; set; } = "code";
public string[]? Scopes { get; set; }
public string ClientId { get; set; }
public string ClientSecret { get; set; }
}

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Database.Entities;
namespace Moonlight.Api.Database;
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;
public DataContext(IOptions<DatabaseOptions> options)
{
Options = options;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured)
return;
optionsBuilder.UseNpgsql(
$"Host={Options.Value.Host};" +
$"Port={Options.Value.Port};" +
$"Username={Options.Value.Username};" +
$"Password={Options.Value.Password};" +
$"Database={Options.Value.Database}"
);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("core");
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database;
public class DatabaseRepository<T> where T : class
{
private readonly DataContext DataContext;
private readonly DbSet<T> Set;
public DatabaseRepository(DataContext dataContext)
{
DataContext = dataContext;
Set = DataContext.Set<T>();
}
public IQueryable<T> Query() => Set;
public async Task<T> AddAsync(T entity)
{
if (entity is IActionTimestamps actionTimestamps)
{
actionTimestamps.CreatedAt = DateTimeOffset.UtcNow;
actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow;
}
var final = Set.Add(entity);
await DataContext.SaveChangesAsync();
return final.Entity;
}
public async Task UpdateAsync(T entity)
{
if (entity is IActionTimestamps actionTimestamps)
actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow;
Set.Update(entity);
await DataContext.SaveChangesAsync();
}
public async Task RemoveAsync(T entity)
{
Set.Remove(entity);
await DataContext.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database.Entities;
public class Role : 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; } = [];
// Relations
public List<RoleMember> Members { get; set; } = [];
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,15 @@
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database.Entities;
public class RoleMember : IActionTimestamps
{
public int Id { get; set; }
public Role Role { get; set; }
public User User { get; set; }
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Moonlight.Api.Database.Interfaces;
namespace Moonlight.Api.Database.Entities;
public class User : IActionTimestamps
{
public int Id { get; set; }
// Base information
[MaxLength(50)]
public required string Username { get; set; }
[MaxLength(254)]
public required string Email { get; set; }
// Authentication
public DateTimeOffset InvalidateTimestamp { get; set; }
// Relations
public List<RoleMember> RoleMemberships { get; set; } = [];
// Action timestamps
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Database.Interfaces;
internal interface IActionTimestamps
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,80 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20251225202335_AddedUsersAndSettings")]
partial class AddedUsersAndSettings
{
/// <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.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>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Users", "core");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedUsersAndSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "core");
migrationBuilder.CreateTable(
name: "SettingsOptions",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Value = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SettingsOptions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Username = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Email = table.Column<string>(type: "character varying(254)", maxLength: 254, nullable: false),
InvalidateTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SettingsOptions",
schema: "core");
migrationBuilder.DropTable(
name: "Users",
schema: "core");
}
}
}

View File

@@ -0,0 +1,177 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20251230200748_AddedRolesAndActionTimestamps")]
partial class AddedRolesAndActionTimestamps
{
/// <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.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(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("character varying(15)");
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>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.HasKey("Id");
b.ToTable("SettingsOptions", "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,115 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedRolesAndActionTimestamps : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedAt",
schema: "core",
table: "Users",
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)));
migrationBuilder.AddColumn<DateTimeOffset>(
name: "UpdatedAt",
schema: "core",
table: "Users",
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)));
migrationBuilder.CreateTable(
name: "Roles",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(15)", maxLength: 15, nullable: false),
Description = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Permissions = table.Column<string[]>(type: "text[]", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RoleMembers",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<int>(type: "integer", nullable: false),
UserId = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RoleMembers", x => x.Id);
table.ForeignKey(
name: "FK_RoleMembers_Roles_RoleId",
column: x => x.RoleId,
principalSchema: "core",
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RoleMembers_Users_UserId",
column: x => x.UserId,
principalSchema: "core",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RoleMembers_RoleId",
schema: "core",
table: "RoleMembers",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_RoleMembers_UserId",
schema: "core",
table: "RoleMembers",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RoleMembers",
schema: "core");
migrationBuilder.DropTable(
name: "Roles",
schema: "core");
migrationBuilder.DropColumn(
name: "CreatedAt",
schema: "core",
table: "Users");
migrationBuilder.DropColumn(
name: "UpdatedAt",
schema: "core",
table: "Users");
}
}
}

View File

@@ -0,0 +1,215 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20260116133404_AddedApiKeys")]
partial class AddedApiKeys
{
/// <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(100)
.HasColumnType("character varying(100)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("character varying(15)");
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(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("character varying(15)");
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>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.HasKey("Id");
b.ToTable("SettingsOptions", "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,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedApiKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ApiKeys",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(15)", maxLength: 15, nullable: false),
Description = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Permissions = table.Column<string[]>(type: "text[]", nullable: false),
Key = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKeys", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApiKeys",
schema: "core");
}
}
}

View File

@@ -0,0 +1,215 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings")]
partial class AdjustedLenghtsOfRoleAndApiKeyStrings
{
/// <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>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.HasKey("Id");
b.ToTable("SettingsOptions", "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,106 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AdjustedLenghtsOfRoleAndApiKeyStrings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: "core",
table: "Roles",
type: "character varying(30)",
maxLength: 30,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(15)",
oldMaxLength: 15);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "core",
table: "Roles",
type: "character varying(300)",
maxLength: 300,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100);
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: "core",
table: "ApiKeys",
type: "character varying(30)",
maxLength: 30,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(15)",
oldMaxLength: 15);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "core",
table: "ApiKeys",
type: "character varying(300)",
maxLength: 300,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: "core",
table: "Roles",
type: "character varying(15)",
maxLength: 15,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(30)",
oldMaxLength: 30);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "core",
table: "Roles",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(300)",
oldMaxLength: 300);
migrationBuilder.AlterColumn<string>(
name: "Name",
schema: "core",
table: "ApiKeys",
type: "character varying(15)",
maxLength: 15,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(30)",
oldMaxLength: 30);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "core",
table: "ApiKeys",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(300)",
oldMaxLength: 300);
}
}
}

View File

@@ -0,0 +1,251 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20260118005634_AddedThemes")]
partial class AddedThemes
{
/// <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>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
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,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedThemes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Themes",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
Version = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
Author = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
CssContent = table.Column<string>(type: "character varying(20000)", maxLength: 20000, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Themes", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Themes",
schema: "core");
}
}
}

View File

@@ -0,0 +1,248 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
partial class DataContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
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,77 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Console;
namespace Moonlight.Api.Helpers;
public class AppConsoleFormatter : ConsoleFormatter
{
private const string TimestampColor = "\e[38;2;148;148;148m";
private const string CategoryColor = "\e[38;2;198;198;198m";
private const string MessageColor = "\e[38;2;255;255;255m";
private const string Bold = "\e[1m";
// Pre-computed ANSI color codes for each log level
private const string CriticalColor = "\e[38;2;255;0;0m";
private const string ErrorColor = "\e[38;2;255;0;0m";
private const string WarningColor = "\e[38;2;215;215;0m";
private const string InfoColor = "\e[38;2;135;215;255m";
private const string DebugColor = "\e[38;2;198;198;198m";
private const string TraceColor = "\e[38;2;68;68;68m";
public AppConsoleFormatter() : base(nameof(AppConsoleFormatter))
{
}
public override void Write<TState>(
in LogEntry<TState> logEntry,
IExternalScopeProvider? scopeProvider,
TextWriter textWriter)
{
var message = logEntry.Formatter(logEntry.State, logEntry.Exception);
// Timestamp
textWriter.Write(TimestampColor);
textWriter.Write(DateTime.Now.ToString("dd.MM.yy HH:mm:ss"));
textWriter.Write(' ');
// Log level with color and bold
var (levelText, levelColor) = GetLevelInfo(logEntry.LogLevel);
textWriter.Write(levelColor);
textWriter.Write(Bold);
textWriter.Write(levelText);
textWriter.Write(' ');
// Category
textWriter.Write(CategoryColor);
textWriter.Write(logEntry.Category);
// Message
textWriter.Write(MessageColor);
textWriter.Write(": ");
textWriter.Write(message);
// Exception
if (logEntry.Exception != null)
{
textWriter.Write(MessageColor);
textWriter.WriteLine(logEntry.Exception.ToString());
}
else
textWriter.WriteLine();
}
private static (string text, string color) GetLevelInfo(LogLevel logLevel)
{
return logLevel switch
{
LogLevel.Critical => ("CRIT", CriticalColor),
LogLevel.Error => ("ERRO", ErrorColor),
LogLevel.Warning => ("WARN", WarningColor),
LogLevel.Information => ("INFO", InfoColor),
LogLevel.Debug => ("DEBG", DebugColor),
LogLevel.Trace => ("TRCE", TraceColor),
_ => ("NONE", "")
};
}
}

View File

@@ -0,0 +1,98 @@
using System.Runtime.InteropServices;
namespace Moonlight.Api.Helpers;
public class OsHelper
{
public static string GetName()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return GetWindowsVersion();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return GetLinuxVersion();
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return "Goofy OS";
return "Unknown OS";
}
private static string GetWindowsVersion()
{
var version = Environment.OSVersion.Version;
// Windows 11 is version 10.0 build 22000+
if (version.Major == 10 && version.Build >= 22000)
return $"Windows 11 ({version.Build})";
if (version.Major == 10)
return $"Windows 10 ({version.Build})";
if (version.Major == 6 && version.Minor == 3)
return "Windows 8.1";
if (version.Major == 6 && version.Minor == 2)
return "Windows 8";
if (version.Major == 6 && version.Minor == 1)
return "Windows 7";
return $"Windows {version.Major}.{version.Minor}";
}
private static string GetLinuxVersion()
{
try
{
// Read /etc/os-release, should work everywhere
if (File.Exists("/etc/os-release"))
{
var lines = File.ReadAllLines("/etc/os-release");
string? name = null;
string? version = null;
foreach (var line in lines)
{
if (line.StartsWith("NAME="))
name = line.Substring(5).Trim('"');
else if (line.StartsWith("VERSION_ID="))
version = line.Substring(11).Trim('"');
}
if (!string.IsNullOrEmpty(name))
{
return string.IsNullOrEmpty(version) ? name : $"{name} {version}";
}
}
//If for some weird reason it still uses lsb release
if (File.Exists("/etc/lsb-release"))
{
var lines = File.ReadAllLines("/etc/lsb-release");
string? name = null;
string? version = null;
foreach (var line in lines)
{
if (line.StartsWith("DISTRIB_ID="))
name = line.Substring(11);
else if (line.StartsWith("DISTRIB_RELEASE="))
version = line.Substring(16);
}
if (!string.IsNullOrEmpty(name))
{
return string.IsNullOrEmpty(version) ? name : $"{name} {version}";
}
}
}
catch
{
// Ignore
}
// Fallback
return $"Linux {Environment.OSVersion.Version}";
}
}

View File

@@ -0,0 +1,134 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.ApiKeys;
namespace Moonlight.Api.Http.Controllers.Admin;
[Authorize]
[ApiController]
[Route("api/admin/apiKeys")]
public class ApiKeyController : Controller
{
private readonly DatabaseRepository<ApiKey> KeyRepository;
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository)
{
KeyRepository = keyRepository;
}
[HttpGet]
[Authorize(Policy = Permissions.ApiKeys.View)]
public async Task<ActionResult<PagedData<ApiKeyDto>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int length,
[FromQuery] FilterOptions? filterOptions
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = KeyRepository.Query();
// Filters
if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch
{
nameof(ApiKey.Name) =>
query.Where(k => EF.Functions.ILike(k.Name, $"%{filterOption.Value}%")),
nameof(ApiKey.Description) =>
query.Where(k => EF.Functions.ILike(k.Description, $"%{filterOption.Value}%")),
_ => query
};
}
}
// Pagination
var data = await query
.OrderBy(k => k.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<ApiKeyDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.View)]
public async Task<ActionResult<ApiKeyDto>> GetAsync([FromRoute] int id)
{
var key = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (key == null)
return Problem("No API key with this id found", statusCode: 404);
return ApiKeyMapper.ToDto(key);
}
[HttpPost]
[Authorize(Policy = Permissions.ApiKeys.Create)]
public async Task<ActionResult<ApiKeyDto>> CreateAsync([FromBody] CreateApiKeyDto request)
{
var apiKey = ApiKeyMapper.ToEntity(request);
apiKey.Key = Guid.NewGuid().ToString("N").Substring(0, 32);
var finalKey = await KeyRepository.AddAsync(apiKey);
return ApiKeyMapper.ToDto(finalKey);
}
[HttpPatch("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.Edit)]
public async Task<ActionResult<ApiKeyDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateApiKeyDto request)
{
var apiKey = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (apiKey == null)
return Problem("No API key with this id found", statusCode: 404);
ApiKeyMapper.Merge(apiKey, request);
await KeyRepository.UpdateAsync(apiKey);
return ApiKeyMapper.ToDto(apiKey);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.ApiKeys.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var apiKey = await KeyRepository
.Query()
.FirstOrDefaultAsync(k => k.Id == id);
if (apiKey == null)
return Problem("No API key with this id found", statusCode: 404);
await KeyRepository.RemoveAsync(apiKey);
return NoContent();
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Authorize(Policy = Permissions.System.Diagnose)]
[Route("api/admin/system/diagnose")]
public class DiagnoseController : Controller
{
private readonly DiagnoseService DiagnoseService;
public DiagnoseController(DiagnoseService diagnoseService)
{
DiagnoseService = diagnoseService;
}
[HttpGet]
public async Task<ActionResult<DiagnoseResultDto[]>> GetAsync()
{
var results = await DiagnoseService.DiagnoseAsync();
return results
.OrderBy(x => x.Level)
.ToDto()
.ToArray();
}
}

View File

@@ -0,0 +1,171 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Authorize(Policy = Permissions.Roles.Members)]
[Route("api/admin/roles/{roleId:int}/members")]
public class RoleMembersController : Controller
{
private readonly DatabaseRepository<User> UsersRepository;
private readonly DatabaseRepository<Role> RolesRepository;
private readonly DatabaseRepository<RoleMember> RoleMembersRepository;
public RoleMembersController(
DatabaseRepository<User> usersRepository,
DatabaseRepository<Role> rolesRepository,
DatabaseRepository<RoleMember> roleMembersRepository
)
{
UsersRepository = usersRepository;
RolesRepository = rolesRepository;
RoleMembersRepository = roleMembersRepository;
}
[HttpGet]
public async Task<ActionResult<PagedData<UserDto>>> GetAsync(
[FromRoute] int roleId,
[FromQuery] int startIndex, [FromQuery] int length,
[FromQuery] string? searchTerm
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = RoleMembersRepository
.Query()
.Where(x => x.Role.Id == roleId)
.Select(x => x.User);
// Filtering
if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(x =>
EF.Functions.ILike(x.Username, $"%{searchTerm}%") ||
EF.Functions.ILike(x.Email, $"%{searchTerm}%")
);
}
// Pagination
var items = query
.OrderBy(x => x.Id)
.Skip(startIndex)
.Take(length)
.ProjectToDto()
.ToArray();
var totalCount = await query.CountAsync();
return new PagedData<UserDto>(items, totalCount);
}
[HttpGet("available")]
public async Task<ActionResult<PagedData<UserDto>>> GetAvailableAsync(
[FromRoute] int roleId,
[FromQuery] int startIndex, [FromQuery] int length,
[FromQuery] string? searchTerm
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = UsersRepository
.Query()
.Where(x => x.RoleMemberships.All(y => y.Role.Id != roleId));
// Filtering
if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(x =>
EF.Functions.ILike(x.Username, $"%{searchTerm}%") ||
EF.Functions.ILike(x.Email, $"%{searchTerm}%")
);
}
// Pagination
var items = query
.OrderBy(x => x.Id)
.Skip(startIndex)
.Take(length)
.ProjectToDto()
.ToArray();
var totalCount = await query.CountAsync();
return new PagedData<UserDto>(items, totalCount);
}
[HttpPut("{userId:int}")]
public async Task<ActionResult> AddMemberAsync([FromRoute] int roleId, [FromRoute] int userId)
{
// Check and load role
var role = await RolesRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == roleId);
if (role == null)
return Problem("Role not found", statusCode: 404);
// Check and load user
var user = await UsersRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == userId);
if (user == null)
return Problem("User not found", statusCode: 404);
// Check if a role member already exists with these details
var isUserInRole = await RoleMembersRepository
.Query()
.AnyAsync(x => x.Role.Id == roleId && x.User.Id == userId);
if (isUserInRole)
return Problem("User is already a member of this role", statusCode: 400);
var roleMember = new RoleMember
{
Role = role,
User = user
};
await RoleMembersRepository.AddAsync(roleMember);
return NoContent();
}
[HttpDelete("{userId:int}")]
public async Task<ActionResult> RemoveMemberAsync([FromRoute] int roleId, [FromRoute] int userId)
{
var roleMember = await RoleMembersRepository
.Query()
.FirstOrDefaultAsync(x => x.User.Id == userId && x.Role.Id == roleId);
if (roleMember == null)
return Problem("User is not a member of this role, the role does not exist or the user does not exist",
statusCode: 404);
await RoleMembersRepository.RemoveAsync(roleMember);
return NoContent();
}
}

View File

@@ -0,0 +1,131 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/roles")]
public class RolesController : Controller
{
private readonly DatabaseRepository<Role> RoleRepository;
public RolesController(DatabaseRepository<Role> roleRepository)
{
RoleRepository = roleRepository;
}
[HttpGet]
[Authorize(Policy = Permissions.Roles.View)]
public async Task<ActionResult<PagedData<RoleDto>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int length,
[FromQuery] FilterOptions? filterOptions
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = RoleRepository
.Query();
// Filters
if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch
{
nameof(Role.Name) =>
query.Where(role => EF.Functions.ILike(role.Name, $"%{filterOption.Value}%")),
_ => query
};
}
}
// Pagination
var data = await query
.OrderBy(x => x.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<RoleDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Roles.View)]
public async Task<ActionResult<RoleDto>> GetAsync([FromRoute] int id)
{
var role = await RoleRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (role == null)
return Problem("No role with this id found", statusCode: 404);
return RoleMapper.ToDto(role);
}
[HttpPost]
[Authorize(Policy = Permissions.Roles.Create)]
public async Task<ActionResult<RoleDto>> CreateAsync([FromBody] CreateRoleDto request)
{
var role = RoleMapper.ToEntity(request);
var finalRole = await RoleRepository.AddAsync(role);
return RoleMapper.ToDto(finalRole);
}
[HttpPatch("{id:int}")]
[Authorize(Policy = Permissions.Roles.Edit)]
public async Task<ActionResult<RoleDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateRoleDto request)
{
var role = await RoleRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (role == null)
return Problem("No role with this id found", statusCode: 404);
RoleMapper.Merge(role, request);
await RoleRepository.UpdateAsync(role);
return RoleMapper.ToDto(role);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Roles.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var role = await RoleRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (role == null)
return Problem("No role with this id found", statusCode: 404);
await RoleRepository.RemoveAsync(role);
return NoContent();
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/system")]
public class SystemController : Controller
{
private readonly ApplicationService ApplicationService;
public SystemController(ApplicationService applicationService)
{
ApplicationService = applicationService;
}
[HttpGet("info")]
[Authorize(Policy = Permissions.System.Info)]
public async Task<ActionResult<SystemInfoDto>> GetInfoAsync()
{
var cpuUsage = await ApplicationService.GetCpuUsageAsync();
var memoryUsage = await ApplicationService.GetMemoryUsageAsync();
return new SystemInfoDto(
cpuUsage,
memoryUsage,
ApplicationService.OperatingSystem,
DateTimeOffset.UtcNow - ApplicationService.StartedAt,
ApplicationService.VersionName,
ApplicationService.IsUpToDate
);
}
}

View File

@@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Themes;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/themes")]
public class ThemesController : Controller
{
private readonly DatabaseRepository<Theme> ThemeRepository;
private readonly FrontendService FrontendService;
public ThemesController(DatabaseRepository<Theme> themeRepository, FrontendService frontendService)
{
ThemeRepository = themeRepository;
FrontendService = frontendService;
}
[HttpGet]
[Authorize(Policy = Permissions.Themes.View)]
public async Task<ActionResult<PagedData<ThemeDto>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int length,
[FromQuery] FilterOptions? filterOptions
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = ThemeRepository
.Query();
// Filters
if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch
{
nameof(Theme.Name) =>
query.Where(user => EF.Functions.ILike(user.Name, $"%{filterOption.Value}%")),
nameof(Theme.Version) =>
query.Where(user => EF.Functions.ILike(user.Version, $"%{filterOption.Value}%")),
nameof(Theme.Author) =>
query.Where(user => EF.Functions.ILike(user.Author, $"%{filterOption.Value}%")),
_ => query
};
}
}
// Pagination
var data = await query
.OrderBy(x => x.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<ThemeDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Themes.View)]
public async Task<ActionResult<ThemeDto>> GetAsync([FromRoute] int id)
{
var item = await ThemeRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (item == null)
return Problem("No theme with this id", statusCode: 404);
return ThemeMapper.ToDto(item);
}
[HttpPost]
[Authorize(Policy = Permissions.Themes.Create)]
public async Task<ActionResult<ThemeDto>> CreateAsync([FromBody] CreateThemeDto request)
{
var theme = ThemeMapper.ToEntity(request);
var finalTheme = await ThemeRepository.AddAsync(theme);
await FrontendService.ResetCacheAsync();
return ThemeMapper.ToDto(finalTheme);
}
[HttpPatch("{id:int}")]
[Authorize(Policy = Permissions.Themes.Edit)]
public async Task<ActionResult<ThemeDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateThemeDto request)
{
var theme = await ThemeRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (theme == null)
return Problem("No theme with this id found", statusCode: 404);
ThemeMapper.Merge(theme, request);
await ThemeRepository.UpdateAsync(theme);
await FrontendService.ResetCacheAsync();
return ThemeMapper.ToDto(theme);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Themes.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var theme = await ThemeRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (theme == null)
return Problem("No theme with this id found", statusCode: 404);
await ThemeRepository.RemoveAsync(theme);
await FrontendService.ResetCacheAsync();
return NoContent();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Api.Http.Controllers.Admin.Users;
[Authorize]
[ApiController]
[Route("api/admin/users")]
public class UsersController : Controller
{
private readonly DatabaseRepository<User> UserRepository;
public UsersController(DatabaseRepository<User> userRepository)
{
UserRepository = userRepository;
}
[HttpGet]
[Authorize(Policy = Permissions.Users.View)]
public async Task<ActionResult<PagedData<UserDto>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int length,
[FromQuery] FilterOptions? filterOptions
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = UserRepository
.Query();
// Filters
if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch
{
nameof(Database.Entities.User.Email) =>
query.Where(user => EF.Functions.ILike(user.Email, $"%{filterOption.Value}%")),
nameof(Database.Entities.User.Username) =>
query.Where(user => EF.Functions.ILike(user.Username, $"%{filterOption.Value}%")),
_ => query
};
}
}
// Pagination
var data = await query
.OrderBy(x => x.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<UserDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Users.View)]
public async Task<ActionResult<UserDto>> GetAsync([FromRoute] int id)
{
var user = await UserRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (user == null)
return Problem("No user with this id found", statusCode: 404);
return UserMapper.ToDto(user);
}
[HttpPost]
[Authorize(Policy = Permissions.Users.Create)]
public async Task<ActionResult<UserDto>> CreateAsync([FromBody] CreateUserDto request)
{
var user = UserMapper.ToEntity(request);
user.InvalidateTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1);
var finalUser = await UserRepository.AddAsync(user);
return UserMapper.ToDto(finalUser);
}
[HttpPatch("{id:int}")]
[Authorize(Policy = Permissions.Users.Edit)]
public async Task<ActionResult<UserDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserDto request)
{
var user = await UserRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (user == null)
return Problem("No user with this id found", statusCode: 404);
UserMapper.Merge(user, request);
await UserRepository.UpdateAsync(user);
return UserMapper.ToDto(user);
}
}

View File

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

View File

@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.Api.Http.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : Controller
{
private readonly IAuthenticationSchemeProvider SchemeProvider;
public AuthController(IAuthenticationSchemeProvider schemeProvider)
{
SchemeProvider = schemeProvider;
}
[HttpGet]
public async Task<ActionResult<SchemeDto[]>> GetSchemesAsync()
{
var schemes = await SchemeProvider.GetAllSchemesAsync();
return schemes
.Where(scheme => !string.IsNullOrWhiteSpace(scheme.DisplayName))
.Select(scheme => new SchemeDto(scheme.Name, scheme.DisplayName!))
.ToArray();
}
[HttpGet("{schemeName:alpha}")]
public async Task<ActionResult> StartAsync([FromRoute] string schemeName)
{
var scheme = await SchemeProvider.GetSchemeAsync(schemeName);
if (scheme == null || string.IsNullOrWhiteSpace(scheme.DisplayName))
return Problem("Invalid authentication scheme name", statusCode: 400);
return Challenge(new AuthenticationProperties()
{
RedirectUri = "/"
}, scheme.Name);
}
[Authorize]
[HttpGet("claims")]
public Task<ActionResult<ClaimDto[]>> GetClaimsAsync()
{
var result = User.Claims
.Select(claim => new ClaimDto(claim.Type, claim.Value))
.ToArray();
return Task.FromResult<ActionResult<ClaimDto[]>>(result);
}
[HttpGet("logout")]
public Task<ActionResult> LogoutAsync()
{
return Task.FromResult<ActionResult>(
SignOut(new AuthenticationProperties()
{
RedirectUri = "/"
})
);
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared.Http.Responses.Frontend;
namespace Moonlight.Api.Http.Controllers;
[ApiController]
[Route("api/frontend")]
public class FrontendController : Controller
{
private readonly FrontendService FrontendService;
public FrontendController(FrontendService frontendService)
{
FrontendService = frontendService;
}
[HttpGet("config")]
public async Task<ActionResult<FrontendConfigDto>> GetConfigAsync()
{
var configuration = await FrontendService.GetConfigurationAsync();
return FrontendConfigMapper.ToDto(configuration);
}
}

View File

@@ -0,0 +1,71 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared;
namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
{
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly IMemoryCache MemoryCache;
public ApiKeySchemeHandler(
IOptionsMonitor<ApiKeySchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
DatabaseRepository<ApiKey> apiKeyRepository,
IMemoryCache memoryCache
) : base(options, logger, encoder)
{
ApiKeyRepository = apiKeyRepository;
MemoryCache = memoryCache;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authHeaderValue = Request.Headers.Authorization.FirstOrDefault() ?? null;
if (string.IsNullOrWhiteSpace(authHeaderValue))
return AuthenticateResult.NoResult();
if (authHeaderValue.Length > 32)
return AuthenticateResult.Fail("Invalid api key specified");
if (!MemoryCache.TryGetValue<ApiKeySession>(authHeaderValue, out var apiKey))
{
apiKey = await ApiKeyRepository
.Query()
.Where(x => x.Key == authHeaderValue)
.Select(x => new ApiKeySession(x.Permissions))
.FirstOrDefaultAsync();
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
MemoryCache.Set(authHeaderValue, apiKey, Options.LookupCacheTime);
}
else
{
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
}
return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(
new ClaimsIdentity(
apiKey.Permissions.Select(x => new Claim(Permissions.ClaimType, x)).ToArray()
)
),
Scheme.Name
));
}
private record ApiKeySession(string[] Permissions);
}

View File

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

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Moonlight.Shared;
namespace Moonlight.Api.Implementations;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var permissionClaim = context.User.FindFirst(x =>
x.Type.Equals(Permissions.ClaimType, StringComparison.OrdinalIgnoreCase) &&
x.Value.Equals(requirement.Identifier, StringComparison.OrdinalIgnoreCase)
);
if (permissionClaim == null)
{
context.Fail(new AuthorizationFailureReason(
this,
$"User does not have the requested permission '{requirement.Identifier}'"
));
return Task.CompletedTask;
}
context.Succeed(requirement);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using Moonlight.Shared;
namespace Moonlight.Api.Implementations;
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider FallbackProvider;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
FallbackProvider = new DefaultAuthorizationPolicyProvider(options);
}
public async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (!policyName.StartsWith(Permissions.Prefix, StringComparison.OrdinalIgnoreCase))
return await FallbackProvider.GetPolicyAsync(policyName);
var policy = new AuthorizationPolicyBuilder();
policy.AddRequirements(new PermissionRequirement(policyName));
return policy.Build();
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
=> FallbackProvider.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> FallbackProvider.GetFallbackPolicyAsync();
}
public class PermissionRequirement : IAuthorizationRequirement
{
public string Identifier { get; }
public PermissionRequirement(string identifier)
{
Identifier = identifier;
}
}

View File

@@ -0,0 +1,33 @@
using Moonlight.Api.Interfaces;
using Moonlight.Api.Models;
using Moonlight.Api.Services;
namespace Moonlight.Api.Implementations;
public sealed class UpdateDiagnoseProvider : IDiagnoseProvider
{
private readonly ApplicationService ApplicationService;
public UpdateDiagnoseProvider(ApplicationService applicationService)
{
ApplicationService = applicationService;
}
public Task<DiagnoseResult[]> DiagnoseAsync()
{
if (ApplicationService.IsUpToDate)
return Task.FromResult<DiagnoseResult[]>([]);
return Task.FromResult<DiagnoseResult[]>([
new DiagnoseResult(
DiagnoseLevel.Warning,
"Instance is not up-to-date",
["Moonlight", "Update Check"],
"Update your moonlight instance to receive bug fixes, new features and security patches. Update button can be found in the overview",
null,
"/admin",
null
)
]);
}
}

View File

@@ -0,0 +1,8 @@
using Moonlight.Api.Models;
namespace Moonlight.Api.Interfaces;
public interface IDiagnoseProvider
{
public Task<DiagnoseResult[]> DiagnoseAsync();
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class ApiKeyMapper
{
public static partial IQueryable<ApiKeyDto> ProjectToDto(this IQueryable<ApiKey> apiKeys);
public static partial ApiKeyDto ToDto(ApiKey apiKey);
public static partial void Merge([MappingTarget] ApiKey apiKey, UpdateApiKeyDto request);
public static partial ApiKey ToEntity(CreateApiKeyDto request);
}

View File

@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class DiagnoseResultMapper
{
public static partial IEnumerable<DiagnoseResultDto> ToDto(this IEnumerable<DiagnoseResult> results);
}

View File

@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Frontend;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class FrontendConfigMapper
{
public static partial FrontendConfigDto ToDto(FrontendConfiguration configuration);
}

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:Source member is not mapped to any target member")]
[SuppressMessage("Mapper", "RMG012:Source member was not found for target member")]
public static partial class RoleMapper
{
[MapProperty([nameof(Role.Members), nameof(Role.Members.Count)], nameof(RoleDto.MemberCount))]
public static partial RoleDto ToDto(Role role);
public static partial Role ToEntity(CreateRoleDto request);
public static partial void Merge([MappingTarget] Role role, UpdateRoleDto request);
public static partial IQueryable<RoleDto> ProjectToDto(this IQueryable<Role> roles);
}

View File

@@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Responses.Themes;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class ThemeMapper
{
public static partial IQueryable<ThemeDto> ProjectToDto(this IQueryable<Theme> themes);
public static partial ThemeDto ToDto(Theme theme);
public static partial void Merge([MappingTarget] Theme theme, UpdateThemeDto request);
public static partial Theme ToEntity(CreateThemeDto request);
}

View File

@@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
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;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class UserMapper
{
public static partial IQueryable<UserDto> ProjectToDto(this IQueryable<User> users);
public static partial UserDto ToDto(User user);
public static partial void Merge([MappingTarget] User user, UpdateUserDto request);
public static partial User ToEntity(CreateUserDto request);
}

View File

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

View File

@@ -0,0 +1,10 @@
namespace Moonlight.Api.Models;
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

@@ -0,0 +1,3 @@
namespace Moonlight.Api.Models;
public record FrontendConfiguration(string Name, string? ThemeCss);

View File

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

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<PropertyGroup Label="Nuget Package Settings">
<Version>2.1.0</Version>
<Title>Moonlight.Api</Title>
<Authors>Moonlight Panel</Authors>
<Description>Development package of Moonlight.Api</Description>
<Copyright>Moonlight Panel</Copyright>
<PackageProjectUrl>https://git.battlestati.one/Moonlight-Panel/Moonlight</PackageProjectUrl>
<PackageLicenseUrl>https://git.battlestati.one/Moonlight-Panel/Moonlight/src/branch/v2.1/LICENSE</PackageLicenseUrl>
<RepositoryUrl>https://git.battlestati.one/Moonlight-Panel/Moonlight</RepositoryUrl>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Moonlight.Shared\Moonlight.Shared.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
</ItemGroup>
<ItemGroup>
<Content Update="@(Content)">
<Visible Condition="'%(NuGetItemType)' == 'Content'">false</Visible>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,85 @@
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Moonlight.Api.Helpers;
namespace Moonlight.Api.Services;
public class ApplicationService : IHostedService
{
private readonly VersionService VersionService;
private readonly ILogger<ApplicationService> 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 ApplicationService(VersionService versionService, ILogger<ApplicationService> logger)
{
VersionService = versionService;
Logger = logger;
}
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);
}
public async Task StartAsync(CancellationToken cancellationToken)
{
StartedAt = DateTimeOffset.UtcNow;
OperatingSystem = OsHelper.GetName();
try
{
var currentVersion = await VersionService.GetInstanceVersionAsync();
var latestVersion = await VersionService.GetLatestVersionAsync();
VersionName = currentVersion.Identifier;
IsUpToDate = latestVersion == null || currentVersion.Identifier == latestVersion.Identifier;
Logger.LogInformation("Running Moonlight Panel {version} on {operatingSystem}", VersionName, OperatingSystem);
if (!IsUpToDate)
Logger.LogWarning("Your instance is not up-to-date");
if (currentVersion.IsDevelopment)
Logger.LogWarning("Your instance is running a development version");
if (currentVersion.IsPreRelease)
Logger.LogWarning("Your instance is running a pre-release version");
}
catch (Exception e)
{
Logger.LogError(e, "An unhandled exception occurred while fetching version details");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Moonlight.Api.Database;
namespace Moonlight.Api.Services;
public class DbMigrationService : IHostedLifecycleService
{
private readonly ILogger<DbMigrationService> Logger;
private readonly IServiceProvider ServiceProvider;
public DbMigrationService(ILogger<DbMigrationService> logger, IServiceProvider serviceProvider)
{
Logger = logger;
ServiceProvider = serviceProvider;
}
public async Task StartingAsync(CancellationToken cancellationToken)
{
Logger.LogTrace("Checking for pending migrations");
await using var scope = ServiceProvider.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<DataContext>();
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(cancellationToken);
var migrationNames = pendingMigrations.ToArray();
if (migrationNames.Length == 0)
{
Logger.LogDebug("No pending migrations found");
return;
}
Logger.LogInformation("Pending migrations: {names}", string.Join(", ", migrationNames));
Logger.LogInformation("Migration started");
await context.Database.MigrateAsync(cancellationToken);
Logger.LogInformation("Migration complete");
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.Logging;
using Moonlight.Api.Interfaces;
using Moonlight.Api.Models;
namespace Moonlight.Api.Services;
public class DiagnoseService
{
private readonly IEnumerable<IDiagnoseProvider> Providers;
private readonly ILogger<DiagnoseService> Logger;
public DiagnoseService(IEnumerable<IDiagnoseProvider> providers, ILogger<DiagnoseService> logger)
{
Providers = providers;
Logger = logger;
}
public async Task<DiagnoseResult[]> DiagnoseAsync()
{
var results = new List<DiagnoseResult>();
foreach (var provider in Providers)
{
try
{
results.AddRange(
await provider.DiagnoseAsync()
);
}
catch (Exception e)
{
Logger.LogError(e, "An unhandled error occured while processing provider");
}
}
return results.ToArray();
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Models;
namespace Moonlight.Api.Services;
public class FrontendService
{
private readonly IMemoryCache Cache;
private readonly DatabaseRepository<Theme> ThemeRepository;
private readonly IOptions<FrontendOptions> Options;
private const string CacheKey = $"Moonlight.{nameof(FrontendService)}.{nameof(GetConfigurationAsync)}";
public FrontendService(IMemoryCache cache, DatabaseRepository<Theme> themeRepository, IOptions<FrontendOptions> options)
{
Cache = cache;
ThemeRepository = themeRepository;
Options = options;
}
public async Task<FrontendConfiguration> GetConfigurationAsync()
{
if (Cache.TryGetValue(CacheKey, out FrontendConfiguration? value))
{
if (value != null)
return value;
}
var theme = await ThemeRepository
.Query()
.FirstOrDefaultAsync(x => x.IsEnabled);
var config = new FrontendConfiguration("Moonlight", theme?.CssContent);
Cache.Set(CacheKey, config, TimeSpan.FromMinutes(Options.Value.CacheMinutes));
return config;
}
public Task ResetCacheAsync()
{
Cache.Remove(CacheKey);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,163 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
using Moonlight.Shared;
namespace Moonlight.Api.Services;
public class UserAuthService
{
private readonly DatabaseRepository<User> UserRepository;
private readonly IMemoryCache Cache;
private readonly ILogger<UserAuthService> Logger;
private readonly IOptions<SessionOptions> Options;
private readonly IEnumerable<IUserAuthHook> Hooks;
private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt";
public const string CacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}";
public UserAuthService(
DatabaseRepository<User> userRepository,
ILogger<UserAuthService> logger,
IMemoryCache cache, IOptions<SessionOptions> options,
IEnumerable<IUserAuthHook> hooks
)
{
UserRepository = userRepository;
Logger = logger;
Cache = cache;
Options = options;
Hooks = hooks;
}
public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
{
if (principal is null)
return false;
var username = principal.FindFirstValue(ClaimTypes.Name);
var email = principal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(email))
{
Logger.LogWarning("Unable to sync user to database as name and/or email claims are missing");
return false;
}
// We use email as the primary identifier here
var user = await UserRepository
.Query()
.FirstOrDefaultAsync(user => user.Email == email);
if (user == null) // Sync user if not already existing in the database
{
user = await UserRepository.AddAsync(new User()
{
Username = username,
Email = email,
InvalidateTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1)
});
}
else // Update properties of existing user
{
user.Username = username;
await UserRepository.UpdateAsync(user);
}
principal.Identities.First().AddClaims([
new Claim(UserIdClaim, user.Id.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;
}
public async Task<bool> ValidateAsync(ClaimsPrincipal? principal)
{
// Ignore malformed claims principal
if (principal is not { Identity.IsAuthenticated: true })
return false;
var userIdString = principal.FindFirstValue(UserIdClaim);
if (!int.TryParse(userIdString, out var userId))
return false;
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)
return false;
Cache.Set(
cacheKey,
user,
TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes)
);
}
else
{
if (user == null)
return false;
}
var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
if (!long.TryParse(issuedAtString, out var issuedAtUnix))
return false;
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedAtUnix).ToUniversalTime();
// If the issued at timestamp is greater than the token validation timestamp,
// everything is fine. If not, it means that the token should be invalidated
// as it is too old
if (issuedAt < user.InvalidateTimestamp)
return false;
// Load every permission as claim
principal.Identities.First().AddClaims(
user.Permissions.Select(x => new Claim(Permissions.ClaimType, x))
);
foreach (var hook in Hooks)
{
// Run every hook and if any returns false we return false as well
if(!await hook.ValidateAsync(principal, userId))
return false;
}
return true;
}
// A small model which contains data queried per session validation after the defined cache time.
// Used for projection
private record UserSession(DateTimeOffset InvalidateTimestamp, string[] Permissions);
}

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
namespace Moonlight.Api.Services;
public class UserDeletionService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserDeletionHook> Hooks;
private readonly IMemoryCache Cache;
public UserDeletionService(DatabaseRepository<User> repository, IEnumerable<IUserDeletionHook> hooks, IMemoryCache cache)
{
Repository = repository;
Hooks = hooks;
Cache = cache;
}
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);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
}
}
public record UserDeletionValidationResult(bool IsValid, IEnumerable<string> ErrorMessages);

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
namespace Moonlight.Api.Services;
public class UserLogoutService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserLogoutHook> Hooks;
private readonly IMemoryCache Cache;
public UserLogoutService(
DatabaseRepository<User> repository,
IEnumerable<IUserLogoutHook> hooks,
IMemoryCache cache
)
{
Repository = repository;
Hooks = hooks;
Cache = cache;
}
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);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moonlight.Api.Configuration;
using Moonlight.Api.Implementations;
using Moonlight.Api.Implementations.ApiKeyScheme;
using Moonlight.Api.Services;
namespace Moonlight.Api.Startup;
public partial class Startup
{
private static void AddAuth(WebApplicationBuilder builder)
{
var oidcOptions = new OidcOptions();
builder.Configuration.GetSection("Moonlight:Oidc").Bind(oidcOptions);
var apiKeyOptions = new ApiOptions();
builder.Configuration.GetSection("Moonlight:Api").Bind(apiKeyOptions);
builder.Services.AddOptions<ApiOptions>().BindConfiguration("Moonlight:Api");
builder.Services.AddScoped<UserAuthService>();
builder.Services.AddAuthentication("Main")
.AddPolicyScheme("Main", null, options =>
{
options.ForwardDefaultSelector += context => context.Request.Headers.Authorization.Count > 0 ? "ApiKey" : "Session";
})
.AddCookie("Session", null, options =>
{
options.Events.OnSigningIn += async context =>
{
var authService = context
.HttpContext
.RequestServices
.GetRequiredService<UserAuthService>();
var result = await authService.SyncAsync(context.Principal);
if (result)
context.Properties.IsPersistent = true;
else
context.Principal = new ClaimsPrincipal();
};
options.Events.OnValidatePrincipal += async context =>
{
var authService = context
.HttpContext
.RequestServices
.GetRequiredService<UserAuthService>();
var result = await authService.ValidateAsync(context.Principal);
if (!result)
context.RejectPrincipal();
};
options.Cookie = new CookieBuilder()
{
Name = "token",
Path = "/",
IsEssential = true,
SecurePolicy = CookieSecurePolicy.SameAsRequest
};
})
.AddOpenIdConnect("OIDC", "OpenID Connect", options =>
{
options.Authority = oidcOptions.Authority;
options.RequireHttpsMetadata = oidcOptions.RequireHttpsMetadata;
var scopes = oidcOptions.Scopes ?? ["openid", "email", "profile"];
options.Scope.Clear();
foreach (var scope in scopes)
options.Scope.Add(scope);
options.ResponseType = oidcOptions.ResponseType;
options.ClientId = oidcOptions.ClientId;
options.ClientSecret = oidcOptions.ClientSecret;
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "preferred_username");
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
options.GetClaimsFromUserInfoEndpoint = true;
})
.AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>("ApiKey", null, options =>
{
options.LookupCacheTime = TimeSpan.FromMinutes(apiKeyOptions.LookupCacheMinutes);
});
builder.Logging.AddFilter("Moonlight.Api.Implementations.ApiKeyScheme.ApiKeySchemeHandler", LogLevel.Warning);
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
}
private static void UseAuth(WebApplication application)
{
application.UseAuthentication();
application.UseAuthorization();
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Shared.Http;
using Moonlight.Api.Helpers;
using Moonlight.Api.Implementations;
using Moonlight.Api.Interfaces;
using Moonlight.Api.Services;
using SessionOptions = Moonlight.Api.Configuration.SessionOptions;
namespace Moonlight.Api.Startup;
public partial class Startup
{
private static void AddBase(WebApplicationBuilder builder)
{
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
});
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); });
builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>();
builder.Services.AddSingleton<ApplicationService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ApplicationService>());
builder.Services.AddSingleton<DiagnoseService>();
builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>();
builder.Services.AddMemoryCache();
builder.Services.AddOptions<SessionOptions>().BindConfiguration("Moonlight:Session");
builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
builder.Services.AddScoped<FrontendService>();
builder.Services.AddHttpClient();
builder.Services.AddOptions<VersionOptions>().BindConfiguration("Moonlight:Version");
builder.Services.AddSingleton<VersionService>();
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
}
private static void UseBase(WebApplication application)
{
application.UseRouting();
}
private static void MapBase(WebApplication application)
{
application.MapControllers();
var options = application.Services.GetRequiredService<IOptions<FrontendOptions>>();
if(options.Value.Enabled)
application.MapFallbackToFile("index.html");
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Api.Configuration;
using Moonlight.Api.Database;
using Moonlight.Api.Services;
namespace Moonlight.Api.Startup;
public partial class Startup
{
private static void AddDatabase(WebApplicationBuilder builder)
{
builder.Services.AddOptions<DatabaseOptions>().BindConfiguration("Moonlight:Database");
builder.Services.AddDbContext<DataContext>();
builder.Services.AddScoped(typeof(DatabaseRepository<>));
builder.Services.AddHostedService<DbMigrationService>();
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Builder;
namespace Moonlight.Api.Startup;
public partial class Startup : IAppStartup
{
public void PreBuild(WebApplicationBuilder builder)
{
AddBase(builder);
AddAuth(builder);
AddDatabase(builder);
}
public void PostBuild(WebApplication application)
{
UseBase(application);
UseAuth(application);
}
public void PostMiddleware(WebApplication application)
{
MapBase(application);
}
}

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoDefaultLaunchSettingsFile>True</NoDefaultLaunchSettingsFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Moonlight.ApiServer\Moonlight.ApiServer.csproj" />
<ProjectReference Include="..\Moonlight.Client.Runtime\Moonlight.Client.Runtime.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9" />
</ItemGroup>
<Import Project="Plugins.props" />
</Project>

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