3 Commits

Author SHA1 Message Date
Masu Baumgartner
2e4e9b12c4 Fixed invalid link in readme 2024-11-18 15:33:00 +01:00
Masu Baumgartner
2e8cf0e8e7 Update readme with latest information (#439) 2024-11-18 15:31:26 +01:00
Kre0lidge
2186c181b5 Update README file (#438)
* Update README.md

* Update README.md
2024-11-13 07:06:44 +01:00
980 changed files with 56212 additions and 14114 deletions

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
**/.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
LICENSE
README.md

35
.github/workflows/development-build.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Development Build
on:
workflow_dispatch:
pull_request:
types:
- closed
branches: [ "v2" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login into docker hub
run: docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PW }}
- name: Build and Push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Moonlight/Dockerfile
push: true
tags: moonlightpanel/moonlight:dev
platforms: linux/amd64,linux/arm64
build-args: |
"BUILD_CHANNEL=${{ github.ref_name }}"
"BUILD_COMMIT_HASH=${{ github.sha }}"
"BUILD_NAME=devbuild ${{ steps.date.outputs.date }}"
"BUILD_VERSION=unknown"

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

35
.github/workflows/release-build.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Release Build
on:
workflow_dispatch:
inputs:
codeName:
description: 'Code Name'
required: true
versionName:
description: 'Version Name'
required: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login into docker hub
run: docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PW }}
- name: Build and Push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Moonlight/Dockerfile
push: true
tags: moonlightpanel/moonlight:latest
platforms: linux/amd64,linux/arm64
build-args: |
"BUILD_CHANNEL=${{ github.ref_name }}"
"BUILD_COMMIT_HASH=${{ github.sha }}"
"BUILD_NAME=${{ github.event.inputs.codeName }}"
"BUILD_VERSION=${{ github.event.inputs.versionName }}"

38
.gitignore vendored
View File

@@ -397,38 +397,8 @@ FodyWeavers.xsd
# 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/**
style.min.css
# Build script for nuget packages
finalPackages/
nupkgs/
# Scripts
**/bin/**
**/obj/**
.idea/.idea.Moonlight/.idea/dataSources.xml
Moonlight/Assets/Core/css/theme.css
Moonlight/Assets/Core/css/theme.css.map
.idea/.idea.Moonlight/.idea/discord.xml

13
.idea/.idea.Moonlight/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/modules.xml
/contentModel.xml
/projectSettingsUpdater.xml
/.idea.Moonlight.iml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/.idea.Moonlight/.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="theme" value="material" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
</component>
</project>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="52a374ed:18c1029d858:-8000" />
<option name="version" value="8.13.2" />
</MTProjectMetadataState>
</option>
</component>
</project>

6
.idea/.idea.Moonlight/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

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>

View File

@@ -1,10 +0,0 @@
using MoonCore.PluginFramework;
using Moonlight.ApiServer.Plugins;
namespace Moonlight.ApiServer.Runtime;
[PluginLoader]
public partial class PluginLoader : IPluginStartup
{
}

View File

@@ -1,4 +0,0 @@
<Project>
<ItemGroup>
</ItemGroup>
</Project>

View File

@@ -1,34 +0,0 @@
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Runtime;
using Moonlight.ApiServer.Startup;
var pluginLoader = new PluginLoader();
pluginLoader.Initialize();
var builder = WebApplication.CreateBuilder(args);
builder.AddMoonlight(pluginLoader.Instances);
var app = builder.Build();
app.UseMoonlight(pluginLoader.Instances);
// Add frontend
var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
// Handle setup of wasm app hosting in the runtime
// so the Moonlight.ApiServer doesn't need the wasm package
if (configuration.Frontend.EnableHosting)
{
if (app.Environment.IsDevelopment())
app.UseWebAssemblyDebugging();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
}
app.MapMoonlight(pluginLoader.Instances);
await app.RunAsync();

View File

@@ -1,29 +0,0 @@
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5165",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"HTTP_PROXY": "",
"HTTPS_PROXY": ""
},
"hotReloadEnabled": true
},
"WASM Debug": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5165",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"HTTP_PROXY": "",
"HTTPS_PROXY": ""
},
"hotReloadEnabled": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}"
}
}
}

View File

@@ -1,154 +0,0 @@
using MoonCore.Helpers;
using Moonlight.ApiServer.Implementations.LocalAuth;
using YamlDotNet.Serialization;
namespace Moonlight.ApiServer.Configuration;
public record AppConfiguration
{
[YamlMember(Description = "Moonlight configuration\n\n\nThe public url your instance should be accessible through")]
public string PublicUrl { get; set; } = "http://localhost:5165";
[YamlMember(Description = "\nThe credentials of the postgres which moonlight should use")]
public DatabaseConfig Database { get; set; } = new();
[YamlMember(Description = "\nSettings regarding authentication")]
public AuthenticationConfig Authentication { get; set; } = new();
[YamlMember(Description = "\nThese options are only meant for development purposes")]
public DevelopmentConfig Development { get; set; } = new();
[YamlMember(Description = "\nSettings for hosting the frontend")]
public FrontendData Frontend { get; set; } = new();
[YamlMember(Description = "\nSettings for the internal web server moonlight is running in")]
public KestrelConfig Kestrel { get; set; } = new();
[YamlMember(Description = "\nSettings for the internal file manager for moonlights storage access")]
public FilesData Files { get; set; } = new();
[YamlMember(Description = "\nSettings for open telemetry")]
public OpenTelemetryData OpenTelemetry { get; set; } = new();
[YamlMember(Description = "\nConfiguration for the realtime communication solution SignalR")]
public SignalRData SignalR { get; set; } = new();
public static AppConfiguration CreateEmpty()
{
return new AppConfiguration()
{
// Set arrays as empty here
Kestrel = new()
{
AllowedOrigins = []
},
Authentication = new()
{
EnabledSchemes = []
}
};
}
public record SignalRData
{
[YamlMember(Description =
"\nWhether to use redis (or any other redis compatible solution) to scale out SignalR hubs. This is required when using multiple api server replicas")]
public bool UseRedis { get; set; } = false;
public string RedisConnectionString { get; set; } = "";
}
public record FilesData
{
[YamlMember(Description = "The maximum file size limit a combine operation is allowed to process")]
public double CombineLimit { get; set; } = ByteConverter.FromGigaBytes(5).MegaBytes;
}
public record FrontendData
{
[YamlMember(Description = "Enable the hosting of the frontend. Disable this if you only want to run the api server")]
public bool EnableHosting { get; set; } = true;
}
public record DatabaseConfig
{
public string Host { get; set; } = "your-database-host.name";
public int Port { get; set; } = 5432;
public string Username { get; set; } = "db_user";
public string Password { get; set; } = "db_password";
public string Database { get; set; } = "db_name";
}
public record AuthenticationConfig
{
[YamlMember(Description = "The secret token to use for creating jwts and encrypting things. This needs to be at least 32 characters long")]
public string Secret { get; set; } = Formatter.GenerateString(32);
[YamlMember(Description = "Settings for the user sessions")]
public SessionsConfig Sessions { get; set; } = new();
[YamlMember(Description = "This specifies if the first registered/synced user will become an admin automatically")]
public bool FirstUserAdmin { get; set; } = true;
[YamlMember(Description = "This specifies the authentication schemes the frontend should be able to challenge")]
public string[] EnabledSchemes { get; set; } = [LocalAuthConstants.AuthenticationScheme];
}
public record SessionsConfig
{
public string CookieName { get; set; } = "session";
public int ExpiresIn { get; set; } = 10;
}
public record DevelopmentConfig
{
[YamlMember(Description = "This toggles the availability of the api docs via /api/swagger")]
public bool EnableApiDocs { get; set; } = false;
}
public record KestrelConfig
{
[YamlMember(Description = "The upload limit in megabytes for the api server")]
public int UploadLimit { get; set; } = 100;
[YamlMember(Description = "The allowed origins for the api server. Use * to allow all origins (which is not advised)")]
public string[] AllowedOrigins { get; set; } = ["*"];
}
public record OpenTelemetryData
{
[YamlMember(Description = "This enables open telemetry for moonlight")]
public bool Enable { get; set; } = false;
public OpenTelemetryMetricsData Metrics { get; set; } = new();
public OpenTelemetryTracesData Traces { get; set; } = new();
public OpenTelemetryLogsData Logs { get; set; } = new();
}
public record OpenTelemetryMetricsData
{
[YamlMember(Description = "This enables the exporting of metrics")]
public bool Enable { get; set; } = true;
[YamlMember(Description = "Enables the /metrics exporter for prometheus")]
public bool EnablePrometheus { get; set; } = false;
[YamlMember(Description = "The interval in which metrics are created, specified in seconds")]
public int Interval { get; set; } = 15;
}
public record OpenTelemetryTracesData
{
[YamlMember(Description = "This enables the exporting of traces")]
public bool Enable { get; set; } = true;
}
public record OpenTelemetryLogsData
{
[YamlMember(Description = "This enables the exporting of logs")]
public bool Enable { get; set; } = true;
}
}

View File

@@ -1,55 +0,0 @@
using Hangfire.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Models;
namespace Moonlight.ApiServer.Database;
public class CoreDataContext : DbContext
{
private readonly AppConfiguration Configuration;
public DbSet<User> Users { get; set; }
public DbSet<ApiKey> ApiKeys { get; set; }
public DbSet<Theme> Themes { get; set; }
public CoreDataContext(AppConfiguration configuration)
{
Configuration = configuration;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if(optionsBuilder.IsConfigured)
return;
var database = Configuration.Database;
var connectionString = $"Host={database.Host};" +
$"Port={database.Port};" +
$"Database={database.Database};" +
$"Username={database.Username};" +
$"Password={database.Password}";
optionsBuilder.UseNpgsql(connectionString, builder =>
{
builder.MigrationsHistoryTable("MigrationsHistory", "core");
});
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Model.SetDefaultSchema("core");
base.OnModelCreating(modelBuilder);
modelBuilder.OnHangfireModelCreating();
modelBuilder.Ignore<ApplicationTheme>();
modelBuilder.Entity<Theme>()
.OwnsOne(x => x.Content, builder =>
{
builder.ToJson();
});
}
}

View File

@@ -1,14 +0,0 @@
namespace Moonlight.ApiServer.Database.Entities;
public class ApiKey
{
public int Id { get; set; }
public string Description { get; set; }
public string[] Permissions { get; set; } = [];
public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@@ -1,19 +0,0 @@
using Moonlight.ApiServer.Models;
namespace Moonlight.ApiServer.Database.Entities;
public class Theme
{
public int Id { get; set; }
public bool IsEnabled { get; set; }
public string Name { get; set; }
public string Author { get; set; }
public string Version { get; set; }
public string? UpdateUrl { get; set; }
public string? DonateUrl { get; set; }
public ApplicationTheme Content { get; set; }
}

View File

@@ -1,12 +0,0 @@
namespace Moonlight.ApiServer.Database.Entities;
public class User
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public DateTimeOffset TokenValidTimestamp { get; set; } = DateTimeOffset.MinValue;
public string[] Permissions { get; set; } = [];
}

View File

@@ -1,565 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.ApiServer.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.ApiServer.Database.Migrations
{
[DbContext(typeof(CoreDataContext))]
[Migration("20250919201409_RecreatedMigrationsForChangeOfSchema")]
partial class RecreatedMigrationsForChangeOfSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("core")
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireCounter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long>("Value")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ExpireAt");
b.HasIndex("Key", "Value");
b.ToTable("HangfireCounter", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b =>
{
b.Property<string>("Key")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Field")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key", "Field");
b.HasIndex("ExpireAt");
b.ToTable("HangfireHash", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InvocationData")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("StateId")
.HasColumnType("bigint");
b.Property<string>("StateName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("ExpireAt");
b.HasIndex("StateId");
b.HasIndex("StateName");
b.ToTable("HangfireJob", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b =>
{
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("JobId", "Name");
b.ToTable("HangfireJobParameter", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b =>
{
b.Property<string>("Key")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key", "Position");
b.HasIndex("ExpireAt");
b.ToTable("HangfireList", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b =>
{
b.Property<string>("Id")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("HangfireLock", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("FetchedAt")
.IsConcurrencyToken()
.HasColumnType("timestamp with time zone");
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<string>("Queue")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("Queue", "FetchedAt");
b.ToTable("HangfireQueuedJob", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireServer", b =>
{
b.Property<string>("Id")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("Heartbeat")
.HasColumnType("timestamp with time zone");
b.Property<string>("Queues")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("WorkerCount")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Heartbeat");
b.ToTable("HangfireServer", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b =>
{
b.Property<string>("Key")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Value")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<double>("Score")
.HasColumnType("double precision");
b.HasKey("Key", "Value");
b.HasIndex("ExpireAt");
b.HasIndex("Key", "Score");
b.ToTable("HangfireSet", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Data")
.IsRequired()
.HasColumnType("text");
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Reason")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("JobId");
b.ToTable("HangfireState", "core");
});
modelBuilder.Entity("Moonlight.ApiServer.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()
.HasColumnType("text");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DonateUrl")
.HasColumnType("text");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UpdateUrl")
.HasColumnType("text");
b.Property<string>("Version")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Themes", "core");
});
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("TokenValidTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
{
b.HasOne("Hangfire.EntityFrameworkCore.HangfireState", "State")
.WithMany()
.HasForeignKey("StateId");
b.Navigation("State");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b =>
{
b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job")
.WithMany("Parameters")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Job");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b =>
{
b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job")
.WithMany("QueuedJobs")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Job");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b =>
{
b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job")
.WithMany("States")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Job");
});
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.Theme", b =>
{
b.OwnsOne("Moonlight.ApiServer.Models.ApplicationTheme", "Content", b1 =>
{
b1.Property<int>("ThemeId")
.HasColumnType("integer");
b1.Property<float>("Border")
.HasColumnType("real");
b1.Property<string>("ColorAccent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorAccentContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBackground")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase100")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase150")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase200")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase250")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase300")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBaseContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorError")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorErrorContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorInfo")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorInfoContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorNeutral")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorNeutralContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorPrimary")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorPrimaryContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorSecondary")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorSecondaryContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorSuccess")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorSuccessContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorWarning")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorWarningContent")
.IsRequired()
.HasColumnType("text");
b1.Property<int>("Depth")
.HasColumnType("integer");
b1.Property<int>("Noise")
.HasColumnType("integer");
b1.Property<float>("RadiusBox")
.HasColumnType("real");
b1.Property<float>("RadiusField")
.HasColumnType("real");
b1.Property<float>("RadiusSelector")
.HasColumnType("real");
b1.Property<float>("SizeField")
.HasColumnType("real");
b1.Property<float>("SizeSelector")
.HasColumnType("real");
b1.HasKey("ThemeId");
b1.ToTable("Themes", "core");
b1.ToJson("Content");
b1.WithOwner()
.HasForeignKey("ThemeId");
});
b.Navigation("Content")
.IsRequired();
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
{
b.Navigation("Parameters");
b.Navigation("QueuedJobs");
b.Navigation("States");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,399 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.ApiServer.Database.Migrations
{
/// <inheritdoc />
public partial class RecreatedMigrationsForChangeOfSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "core");
migrationBuilder.CreateTable(
name: "ApiKeys",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Description = table.Column<string>(type: "text", nullable: false),
Permissions = table.Column<string[]>(type: "text[]", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKeys", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HangfireCounter",
schema: "core",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Value = table.Column<long>(type: "bigint", nullable: false),
ExpireAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireCounter", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HangfireHash",
schema: "core",
columns: table => new
{
Key = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Field = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Value = table.Column<string>(type: "text", nullable: true),
ExpireAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireHash", x => new { x.Key, x.Field });
});
migrationBuilder.CreateTable(
name: "HangfireList",
schema: "core",
columns: table => new
{
Key = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Value = table.Column<string>(type: "text", nullable: true),
ExpireAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireList", x => new { x.Key, x.Position });
});
migrationBuilder.CreateTable(
name: "HangfireLock",
schema: "core",
columns: table => new
{
Id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
AcquiredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireLock", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HangfireServer",
schema: "core",
columns: table => new
{
Id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Heartbeat = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
WorkerCount = table.Column<int>(type: "integer", nullable: false),
Queues = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireServer", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HangfireSet",
schema: "core",
columns: table => new
{
Key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Value = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Score = table.Column<double>(type: "double precision", nullable: false),
ExpireAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireSet", x => new { x.Key, x.Value });
});
migrationBuilder.CreateTable(
name: "Themes",
schema: "core",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Author = table.Column<string>(type: "text", nullable: false),
Version = table.Column<string>(type: "text", nullable: false),
UpdateUrl = table.Column<string>(type: "text", nullable: true),
DonateUrl = table.Column<string>(type: "text", nullable: true),
Content = table.Column<string>(type: "jsonb", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Themes", 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: "text", nullable: false),
Email = table.Column<string>(type: "text", nullable: false),
Password = table.Column<string>(type: "text", nullable: false),
TokenValidTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Permissions = table.Column<string[]>(type: "text[]", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HangfireJob",
schema: "core",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
StateId = table.Column<long>(type: "bigint", nullable: true),
StateName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ExpireAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
InvocationData = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireJob", x => x.Id);
});
migrationBuilder.CreateTable(
name: "HangfireJobParameter",
schema: "core",
columns: table => new
{
JobId = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireJobParameter", x => new { x.JobId, x.Name });
table.ForeignKey(
name: "FK_HangfireJobParameter_HangfireJob_JobId",
column: x => x.JobId,
principalSchema: "core",
principalTable: "HangfireJob",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HangfireQueuedJob",
schema: "core",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
JobId = table.Column<long>(type: "bigint", nullable: false),
Queue = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
FetchedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireQueuedJob", x => x.Id);
table.ForeignKey(
name: "FK_HangfireQueuedJob_HangfireJob_JobId",
column: x => x.JobId,
principalSchema: "core",
principalTable: "HangfireJob",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HangfireState",
schema: "core",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
JobId = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Reason = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Data = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HangfireState", x => x.Id);
table.ForeignKey(
name: "FK_HangfireState_HangfireJob_JobId",
column: x => x.JobId,
principalSchema: "core",
principalTable: "HangfireJob",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_HangfireCounter_ExpireAt",
schema: "core",
table: "HangfireCounter",
column: "ExpireAt");
migrationBuilder.CreateIndex(
name: "IX_HangfireCounter_Key_Value",
schema: "core",
table: "HangfireCounter",
columns: new[] { "Key", "Value" });
migrationBuilder.CreateIndex(
name: "IX_HangfireHash_ExpireAt",
schema: "core",
table: "HangfireHash",
column: "ExpireAt");
migrationBuilder.CreateIndex(
name: "IX_HangfireJob_ExpireAt",
schema: "core",
table: "HangfireJob",
column: "ExpireAt");
migrationBuilder.CreateIndex(
name: "IX_HangfireJob_StateId",
schema: "core",
table: "HangfireJob",
column: "StateId");
migrationBuilder.CreateIndex(
name: "IX_HangfireJob_StateName",
schema: "core",
table: "HangfireJob",
column: "StateName");
migrationBuilder.CreateIndex(
name: "IX_HangfireList_ExpireAt",
schema: "core",
table: "HangfireList",
column: "ExpireAt");
migrationBuilder.CreateIndex(
name: "IX_HangfireQueuedJob_JobId",
schema: "core",
table: "HangfireQueuedJob",
column: "JobId");
migrationBuilder.CreateIndex(
name: "IX_HangfireQueuedJob_Queue_FetchedAt",
schema: "core",
table: "HangfireQueuedJob",
columns: new[] { "Queue", "FetchedAt" });
migrationBuilder.CreateIndex(
name: "IX_HangfireServer_Heartbeat",
schema: "core",
table: "HangfireServer",
column: "Heartbeat");
migrationBuilder.CreateIndex(
name: "IX_HangfireSet_ExpireAt",
schema: "core",
table: "HangfireSet",
column: "ExpireAt");
migrationBuilder.CreateIndex(
name: "IX_HangfireSet_Key_Score",
schema: "core",
table: "HangfireSet",
columns: new[] { "Key", "Score" });
migrationBuilder.CreateIndex(
name: "IX_HangfireState_JobId",
schema: "core",
table: "HangfireState",
column: "JobId");
migrationBuilder.AddForeignKey(
name: "FK_HangfireJob_HangfireState_StateId",
schema: "core",
table: "HangfireJob",
column: "StateId",
principalSchema: "core",
principalTable: "HangfireState",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_HangfireJob_HangfireState_StateId",
schema: "core",
table: "HangfireJob");
migrationBuilder.DropTable(
name: "ApiKeys",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireCounter",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireHash",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireJobParameter",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireList",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireLock",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireQueuedJob",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireServer",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireSet",
schema: "core");
migrationBuilder.DropTable(
name: "Themes",
schema: "core");
migrationBuilder.DropTable(
name: "Users",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireState",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireJob",
schema: "core");
}
}
}

View File

@@ -1,562 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.ApiServer.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.ApiServer.Database.Migrations
{
[DbContext(typeof(CoreDataContext))]
partial class CoreDataContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("core")
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireCounter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long>("Value")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ExpireAt");
b.HasIndex("Key", "Value");
b.ToTable("HangfireCounter", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b =>
{
b.Property<string>("Key")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Field")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key", "Field");
b.HasIndex("ExpireAt");
b.ToTable("HangfireHash", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InvocationData")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("StateId")
.HasColumnType("bigint");
b.Property<string>("StateName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("ExpireAt");
b.HasIndex("StateId");
b.HasIndex("StateName");
b.ToTable("HangfireJob", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b =>
{
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("JobId", "Name");
b.ToTable("HangfireJobParameter", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b =>
{
b.Property<string>("Key")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key", "Position");
b.HasIndex("ExpireAt");
b.ToTable("HangfireList", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b =>
{
b.Property<string>("Id")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("HangfireLock", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("FetchedAt")
.IsConcurrencyToken()
.HasColumnType("timestamp with time zone");
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<string>("Queue")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("Queue", "FetchedAt");
b.ToTable("HangfireQueuedJob", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireServer", b =>
{
b.Property<string>("Id")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("Heartbeat")
.HasColumnType("timestamp with time zone");
b.Property<string>("Queues")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("WorkerCount")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Heartbeat");
b.ToTable("HangfireServer", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b =>
{
b.Property<string>("Key")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Value")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("ExpireAt")
.HasColumnType("timestamp with time zone");
b.Property<double>("Score")
.HasColumnType("double precision");
b.HasKey("Key", "Value");
b.HasIndex("ExpireAt");
b.HasIndex("Key", "Score");
b.ToTable("HangfireSet", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Data")
.IsRequired()
.HasColumnType("text");
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Reason")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("JobId");
b.ToTable("HangfireState", "core");
});
modelBuilder.Entity("Moonlight.ApiServer.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()
.HasColumnType("text");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DonateUrl")
.HasColumnType("text");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UpdateUrl")
.HasColumnType("text");
b.Property<string>("Version")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Themes", "core");
});
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("TokenValidTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users", "core");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
{
b.HasOne("Hangfire.EntityFrameworkCore.HangfireState", "State")
.WithMany()
.HasForeignKey("StateId");
b.Navigation("State");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b =>
{
b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job")
.WithMany("Parameters")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Job");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b =>
{
b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job")
.WithMany("QueuedJobs")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Job");
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b =>
{
b.HasOne("Hangfire.EntityFrameworkCore.HangfireJob", "Job")
.WithMany("States")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Job");
});
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.Theme", b =>
{
b.OwnsOne("Moonlight.ApiServer.Models.ApplicationTheme", "Content", b1 =>
{
b1.Property<int>("ThemeId")
.HasColumnType("integer");
b1.Property<float>("Border")
.HasColumnType("real");
b1.Property<string>("ColorAccent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorAccentContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBackground")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase100")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase150")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase200")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase250")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBase300")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorBaseContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorError")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorErrorContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorInfo")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorInfoContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorNeutral")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorNeutralContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorPrimary")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorPrimaryContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorSecondary")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorSecondaryContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorSuccess")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorSuccessContent")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorWarning")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("ColorWarningContent")
.IsRequired()
.HasColumnType("text");
b1.Property<int>("Depth")
.HasColumnType("integer");
b1.Property<int>("Noise")
.HasColumnType("integer");
b1.Property<float>("RadiusBox")
.HasColumnType("real");
b1.Property<float>("RadiusField")
.HasColumnType("real");
b1.Property<float>("RadiusSelector")
.HasColumnType("real");
b1.Property<float>("SizeField")
.HasColumnType("real");
b1.Property<float>("SizeSelector")
.HasColumnType("real");
b1.HasKey("ThemeId");
b1.ToTable("Themes", "core");
b1.ToJson("Content");
b1.WithOwner()
.HasForeignKey("ThemeId");
});
b.Navigation("Content")
.IsRequired();
});
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
{
b.Navigation("Parameters");
b.Navigation("QueuedJobs");
b.Navigation("States");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,25 +0,0 @@
namespace Moonlight.ApiServer.Helpers;
public class FilePathHelper
{
public static string SanitizePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
// Normalize separators
path = path.Replace('\\', '/');
// Remove ".." and "."
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Where(part => part != ".." && part != ".");
var sanitized = string.Join("/", parts);
// Ensure it does not start with a slash
if (sanitized.StartsWith('/'))
sanitized = sanitized.TrimStart('/');
return sanitized;
}
}

View File

@@ -1,150 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoonCore.Common;
using MoonCore.Extended.Abstractions;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Mappers;
using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
namespace Moonlight.ApiServer.Http.Controllers.Admin.ApiKeys;
[ApiController]
[Route("api/admin/apikeys")]
public class ApiKeysController : Controller
{
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly ApiKeyService ApiKeyService;
public ApiKeysController(DatabaseRepository<ApiKey> apiKeyRepository, ApiKeyService apiKeyService)
{
ApiKeyRepository = apiKeyRepository;
ApiKeyService = apiKeyService;
}
[HttpGet]
[Authorize(Policy = "permissions:admin.apikeys.get")]
public async Task<ActionResult<CountedData<ApiKeyResponse>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int count,
[FromQuery] string? orderBy,
[FromQuery] string? filter,
[FromQuery] string orderByDir = "asc"
)
{
if (count > 100)
return Problem("You cannot fetch more items than 100 at a time", statusCode: 400);
IQueryable<ApiKey> query = ApiKeyRepository.Get();
query = orderBy switch
{
nameof(ApiKey.Id) => orderByDir == "desc"
? query.OrderByDescending(x => x.Id)
: query.OrderBy(x => x.Id),
nameof(ApiKey.ExpiresAt) => orderByDir == "desc"
? query.OrderByDescending(x => x.ExpiresAt)
: query.OrderBy(x => x.ExpiresAt),
nameof(ApiKey.CreatedAt) => orderByDir == "desc"
? query.OrderByDescending(x => x.CreatedAt)
: query.OrderBy(x => x.CreatedAt),
_ => query.OrderBy(x => x.Id)
};
if (!string.IsNullOrEmpty(filter))
{
query = query.Where(x =>
EF.Functions.ILike(x.Description, $"%{filter}%")
);
}
var totalCount = await query.CountAsync();
var items = await query
.Skip(startIndex)
.Take(count)
.AsNoTracking()
.ProjectToResponse()
.ToArrayAsync();
return new CountedData<ApiKeyResponse>()
{
Items = items,
TotalCount = totalCount
};
}
[HttpGet("{id:int}")]
[Authorize(Policy = "permissions:admin.apikeys.get")]
public async Task<ActionResult<ApiKeyResponse>> GetSingleAsync(int id)
{
var apiKey = await ApiKeyRepository
.Get()
.AsNoTracking()
.ProjectToResponse()
.FirstOrDefaultAsync(x => x.Id == id);
if (apiKey == null)
return Problem("No api key with that id found", statusCode: 404);
return apiKey;
}
[HttpPost]
[Authorize(Policy = "permissions:admin.apikeys.create")]
public async Task<CreateApiKeyResponse> CreateAsync([FromBody] CreateApiKeyRequest request)
{
var apiKey = ApiKeyMapper.ToApiKey(request);
var finalApiKey = await ApiKeyRepository.AddAsync(apiKey);
var response = new CreateApiKeyResponse
{
Id = finalApiKey.Id,
Permissions = finalApiKey.Permissions,
Description = finalApiKey.Description,
ExpiresAt = finalApiKey.ExpiresAt,
Secret = ApiKeyService.GenerateJwt(finalApiKey)
};
return response;
}
[HttpPatch("{id:int}")]
[Authorize(Policy = "permissions:admin.apikeys.update")]
public async Task<ActionResult<ApiKeyResponse>> UpdateAsync([FromRoute] int id, [FromBody] UpdateApiKeyRequest request)
{
var apiKey = await ApiKeyRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == id);
if (apiKey == null)
return Problem("No api key with that id found", statusCode: 404);
ApiKeyMapper.Merge(apiKey, request);
await ApiKeyRepository.UpdateAsync(apiKey);
return ApiKeyMapper.ToResponse(apiKey);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = "permissions:admin.apikeys.delete")]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var apiKey = await ApiKeyRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == id);
if (apiKey == null)
return Problem("No api key with that id found", statusCode: 404);
await ApiKeyRepository.RemoveAsync(apiKey);
return NoContent();
}
}

View File

@@ -1,27 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moonlight.ApiServer.Services;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys;
[Authorize]
[ApiController]
[Route("api/admin/system/advanced")]
public class AdvancedController : Controller
{
private readonly FrontendService FrontendService;
public AdvancedController(FrontendService frontendService)
{
FrontendService = frontendService;
}
[HttpGet("frontend")]
[Authorize(Policy = "permissions:admin.system.advanced.frontend")]
public async Task FrontendAsync()
{
var stream = await FrontendService.GenerateZipAsync();
await Results.File(stream, fileDownloadName: "frontend.zip").ExecuteAsync(HttpContext);
}
}

View File

@@ -1,153 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoonCore.Common;
using MoonCore.Extended.Abstractions;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Mappers;
using Moonlight.Shared.Http.Requests.Admin.Sys.Theme;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Customisation;
[ApiController]
[Route("api/admin/system/customisation/themes")]
public class ThemesController : Controller
{
private readonly DatabaseRepository<Theme> ThemeRepository;
public ThemesController(DatabaseRepository<Theme> themeRepository)
{
ThemeRepository = themeRepository;
}
[HttpGet]
[Authorize(Policy = "permissions:admin.system.customisation.themes.read")]
public async Task<ActionResult<CountedData<ThemeResponse>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int count,
[FromQuery] string? orderBy,
[FromQuery] string? filter,
[FromQuery] string orderByDir = "asc"
)
{
if (count > 100)
return Problem("You cannot fetch more items than 100 at a time", statusCode: 400);
IQueryable<Theme> query = ThemeRepository.Get();
query = orderBy switch
{
nameof(Theme.Id) => orderByDir == "desc"
? query.OrderByDescending(x => x.Id)
: query.OrderBy(x => x.Id),
nameof(Theme.Name) => orderByDir == "desc"
? query.OrderByDescending(x => x.Name)
: query.OrderBy(x => x.Name),
nameof(Theme.Version) => orderByDir == "desc"
? query.OrderByDescending(x => x.Version)
: query.OrderBy(x => x.Version),
_ => query.OrderBy(x => x.Id)
};
if (!string.IsNullOrEmpty(filter))
{
query = query.Where(x =>
EF.Functions.ILike(x.Name, $"%{filter}%")
);
}
var totalCount = await query.CountAsync();
var items = await query
.Skip(startIndex)
.Take(count)
.AsNoTracking()
.ProjectToResponse()
.ToArrayAsync();
return new CountedData<ThemeResponse>()
{
Items = items,
TotalCount = totalCount
};
}
[HttpGet("{id:int}")]
[Authorize(Policy = "permissions:admin.system.customisation.themes.read")]
public async Task<ActionResult<ThemeResponse>> GetSingleAsync([FromRoute] int id)
{
var theme = await ThemeRepository
.Get()
.AsNoTracking()
.ProjectToResponse()
.FirstOrDefaultAsync(t => t.Id == id);
if (theme == null)
return Problem("Theme with this id not found", statusCode: 404);
return theme;
}
[HttpPost]
[Authorize(Policy = "permissions:admin.system.customisation.themes.write")]
public async Task<ActionResult<ThemeResponse>> CreateAsync([FromBody] CreateThemeRequest request)
{
var theme = ThemeMapper.ToTheme(request);
var finalTheme = await ThemeRepository.AddAsync(theme);
return ThemeMapper.ToResponse(finalTheme);
}
[HttpPatch("{id:int}")]
[Authorize(Policy = "permissions:admin.system.customisation.themes.write")]
public async Task<ActionResult<ThemeResponse>> UpdateAsync([FromRoute] int id, [FromBody] UpdateThemeRequest request)
{
var theme = await ThemeRepository
.Get()
.FirstOrDefaultAsync(t => t.Id == id);
if (theme == null)
return Problem("Theme with this id not found", statusCode: 404);
// Disable all other enabled themes if we are enabling the current theme.
// This ensures only one theme is enabled at the time
if (request.IsEnabled)
{
var otherThemes = await ThemeRepository
.Get()
.Where(x => x.IsEnabled && x.Id != id)
.ToArrayAsync();
foreach (var otherTheme in otherThemes)
otherTheme.IsEnabled = false;
await ThemeRepository.RunTransactionAsync(set => { set.UpdateRange(otherThemes); });
}
ThemeMapper.Merge(theme, request);
await ThemeRepository.UpdateAsync(theme);
return ThemeMapper.ToResponse(theme);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = "permissions:admin.system.customisation.themes.write")]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var theme = await ThemeRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == id);
if (theme == null)
return Problem("Theme with this id not found", statusCode: 404);
await ThemeRepository.RemoveAsync(theme);
return NoContent();
}
}

View File

@@ -1,35 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Requests.Admin.Sys;
using Moonlight.Shared.Http.Responses.Admin.Sys;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys;
[ApiController]
[Route("api/admin/system/diagnose")]
[Authorize(Policy = "permissions:admin.system.diagnose")]
public class DiagnoseController : Controller
{
private readonly DiagnoseService DiagnoseService;
public DiagnoseController(DiagnoseService diagnoseService)
{
DiagnoseService = diagnoseService;
}
[HttpPost]
public async Task<ActionResult> DiagnoseAsync([FromBody] GenerateDiagnoseRequest request)
{
var stream = await DiagnoseService.GenerateDiagnoseAsync(request.Providers);
return File(stream, "application/zip", "diagnose.zip");
}
[HttpGet("providers")]
public async Task<ActionResult<DiagnoseProvideResponse[]>> GetProvidersAsync()
{
return await DiagnoseService.GetProvidersAsync();
}
}

View File

@@ -1,81 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Helpers;
using Moonlight.Shared.Http.Requests.Admin.Sys.Files;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files;
[ApiController]
[Route("api/admin/system/files")]
[Authorize(Policy = "permissions:admin.system.files")]
public class CombineController : Controller
{
private readonly AppConfiguration Configuration;
private const string BaseDirectory = "storage";
public CombineController(AppConfiguration configuration)
{
Configuration = configuration;
}
[HttpPost("combine")]
public async Task<IResult> CombineAsync([FromBody] CombineRequest request)
{
// Validate file lenght
if (request.Files.Length < 2)
return Results.Problem("At least two files are required", statusCode: 400);
// Resolve the physical paths
var destination = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Destination));
var files = request.Files
.Select(path => Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(path)))
.ToArray();
// Validate max file size
long combinedSize = 0;
foreach (var file in files)
{
var fi = new FileInfo(file);
combinedSize += fi.Length;
}
if (ByteConverter.FromBytes(combinedSize).MegaBytes > Configuration.Files.CombineLimit)
{
return Results.Problem("The combine operation exceeds the maximum file size", statusCode: 400);
}
// Combine files
await using var destinationFs = System.IO.File.Open(
destination,
FileMode.Create,
FileAccess.ReadWrite,
FileShare.Read
);
foreach (var file in files)
{
await using var fs = System.IO.File.Open(
file,
FileMode.Open,
FileAccess.ReadWrite
);
await fs.CopyToAsync(destinationFs);
await destinationFs.FlushAsync();
fs.Close();
}
await destinationFs.FlushAsync();
destinationFs.Close();
return Results.Ok();
}
}

View File

@@ -1,183 +0,0 @@
using System.Text;
using ICSharpCode.SharpZipLib.GZip;
using ICSharpCode.SharpZipLib.Tar;
using ICSharpCode.SharpZipLib.Zip;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using MoonCore.Helpers;
using Moonlight.ApiServer.Helpers;
using Moonlight.Shared.Http.Requests.Admin.Sys.Files;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files;
[ApiController]
[Route("api/admin/system/files")]
[Authorize(Policy = "permissions:admin.system.files")]
public class CompressController : Controller
{
private const string BaseDirectory = "storage";
[HttpPost("compress")]
public async Task<IResult> CompressAsync([FromBody] CompressRequest request)
{
// Validate item length
if (request.Items.Length == 0)
{
return Results.Problem(
"At least one item is required",
statusCode: 400
);
}
// Build paths
var destinationPath = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Destination));
var rootPath = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Root));
// Resolve the relative to the root item paths to absolute paths
var itemsPaths = request.Items.Select(item =>
Path.Combine(
BaseDirectory,
FilePathHelper.SanitizePath(
UnixPath.Combine(request.Root, item)
)
)
);
// Execute request
switch (request.Format)
{
case "tar.gz":
await CompressTarGzAsync(destinationPath, itemsPaths, rootPath);
break;
case "zip":
await CompressZipAsync(destinationPath, itemsPaths, rootPath);
break;
default:
return Results.Problem("Unsupported archive format specified", statusCode: 400);
}
return Results.Ok();
}
#region Tar Gz
private async Task CompressTarGzAsync(string destination, IEnumerable<string> items, string root)
{
await using var outStream = System.IO.File.Create(destination);
await using var gzoStream = new GZipOutputStream(outStream);
await using var tarStream = new TarOutputStream(gzoStream, Encoding.UTF8);
foreach (var item in items)
await CompressItemToTarGzAsync(tarStream, item, root);
await tarStream.FlushAsync();
await gzoStream.FlushAsync();
await outStream.FlushAsync();
tarStream.Close();
gzoStream.Close();
outStream.Close();
}
private async Task CompressItemToTarGzAsync(TarOutputStream tarOutputStream, string item, string root)
{
if (System.IO.File.Exists(item))
{
// Open file stream
var fs = System.IO.File.Open(item, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
// Meta
var entry = TarEntry.CreateTarEntry(
Formatter
.ReplaceStart(item, root, "")
.TrimStart('/')
);
// Set size
entry.Size = fs.Length;
// Write entry
await tarOutputStream.PutNextEntryAsync(entry, CancellationToken.None);
// Copy file content to tar stream
await fs.CopyToAsync(tarOutputStream);
fs.Close();
// Close the entry
tarOutputStream.CloseEntry();
return;
}
if (Directory.Exists(item))
{
foreach (var fsEntry in Directory.EnumerateFileSystemEntries(item))
await CompressItemToTarGzAsync(tarOutputStream, fsEntry, root);
}
}
#endregion
#region ZIP
private async Task CompressZipAsync(string destination, IEnumerable<string> items, string root)
{
await using var outStream = System.IO.File.Create(destination);
await using var zipOutputStream = new ZipOutputStream(outStream);
foreach (var item in items)
await AddItemToZipAsync(zipOutputStream, item, root);
await zipOutputStream.FlushAsync();
await outStream.FlushAsync();
zipOutputStream.Close();
outStream.Close();
}
private async Task AddItemToZipAsync(ZipOutputStream outputStream, string item, string root)
{
if (System.IO.File.Exists(item))
{
// Open file stream
var fs = System.IO.File.Open(item, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
// Meta
var entry = new ZipEntry(
Formatter
.ReplaceStart(item, root, "")
.TrimStart('/')
);
entry.Size = fs.Length;
// Write entry
await outputStream.PutNextEntryAsync(entry, CancellationToken.None);
// Copy file content to tar stream
await fs.CopyToAsync(outputStream);
fs.Close();
// Close the entry
outputStream.CloseEntry();
// Flush caches
await outputStream.FlushAsync();
return;
}
if (Directory.Exists(item))
{
foreach (var subItem in Directory.EnumerateFileSystemEntries(item))
await AddItemToZipAsync(outputStream, subItem, root);
}
}
#endregion
}

View File

@@ -1,113 +0,0 @@
using System.Text;
using ICSharpCode.SharpZipLib.GZip;
using ICSharpCode.SharpZipLib.Tar;
using ICSharpCode.SharpZipLib.Zip;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.ApiServer.Helpers;
using Moonlight.Shared.Http.Requests.Admin.Sys.Files;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files;
[ApiController]
[Route("api/admin/system/files")]
[Authorize(Policy = "permissions:admin.system.files")]
public class DecompressController : Controller
{
private const string BaseDirectory = "storage";
[HttpPost("decompress")]
public async Task DecompressAsync([FromBody] DecompressRequest request)
{
var path = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Path));
var destination = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(request.Destination));
switch (request.Format)
{
case "tar.gz":
await DecompressTarGzAsync(path, destination);
break;
case "zip":
await DecompressZipAsync(path, destination);
break;
}
}
#region Tar Gz
private async Task DecompressTarGzAsync(string path, string destination)
{
await using var fs = System.IO.File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await using var gzipInputStream = new GZipInputStream(fs);
await using var tarInputStream = new TarInputStream(gzipInputStream, Encoding.UTF8);
while (true)
{
var entry = await tarInputStream.GetNextEntryAsync(CancellationToken.None);
if (entry == null)
break;
var safeFilePath = FilePathHelper.SanitizePath(entry.Name);
var fileDestination = Path.Combine(destination, safeFilePath);
var parentFolder = Path.GetDirectoryName(fileDestination);
// Ensure parent directory exists, if it's not the base directory
if (parentFolder != null && parentFolder != BaseDirectory)
Directory.CreateDirectory(parentFolder);
await using var fileDestinationFs =
System.IO.File.Open(fileDestination, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
await tarInputStream.CopyToAsync(fileDestinationFs, CancellationToken.None);
await fileDestinationFs.FlushAsync();
fileDestinationFs.Close();
}
tarInputStream.Close();
gzipInputStream.Close();
fs.Close();
}
#endregion
#region Zip
private async Task DecompressZipAsync(string path, string destination)
{
await using var fs = System.IO.File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await using var zipInputStream = new ZipInputStream(fs);
while (true)
{
var entry = zipInputStream.GetNextEntry();
if (entry == null)
break;
if (entry.IsDirectory)
continue;
var safeFilePath = FilePathHelper.SanitizePath(entry.Name);
var fileDestination = Path.Combine(destination, safeFilePath);
var parentFolder = Path.GetDirectoryName(fileDestination);
// Ensure parent directory exists, if it's not the base directory
if (parentFolder != null && parentFolder != BaseDirectory)
Directory.CreateDirectory(parentFolder);
await using var fileDestinationFs =
System.IO.File.Open(fileDestination, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
await zipInputStream.CopyToAsync(fileDestinationFs, CancellationToken.None);
await fileDestinationFs.FlushAsync();
fileDestinationFs.Close();
}
zipInputStream.Close();
fs.Close();
}
#endregion
}

View File

@@ -1,128 +0,0 @@
using ICSharpCode.SharpZipLib.Zip;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Helpers;
using Moonlight.Shared.Http.Responses.Admin.Sys;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files;
[ApiController]
[Route("api/admin/system/files/downloadUrl")]
[Authorize(Policy = "permissions:admin.system.files")]
public class DownloadUrlController : Controller
{
private readonly AppConfiguration Configuration;
private const string BaseDirectory = "storage";
public DownloadUrlController(AppConfiguration configuration)
{
Configuration = configuration;
}
[HttpGet]
public async Task GetAsync([FromQuery] string path)
{
var physicalPath = Path.Combine(BaseDirectory, FilePathHelper.SanitizePath(path));
var name = Path.GetFileName(physicalPath);
if (System.IO.File.Exists(physicalPath))
{
await using var fs = System.IO.File.Open(physicalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await Results.File(fs, fileDownloadName: name).ExecuteAsync(HttpContext);
}
else if(Directory.Exists(physicalPath))
{
// Without the base directory we would have the full path to the target folder
// inside the zip
var baseDirectory = Path.Combine(
BaseDirectory,
FilePathHelper.SanitizePath(Path.GetDirectoryName(path) ?? "")
);
Response.StatusCode = 200;
Response.ContentType = "application/zip";
Response.Headers["Content-Disposition"] = $"attachment; filename=\"{name}.zip\"";
try
{
await using var zipStream = new ZipOutputStream(Response.Body);
zipStream.IsStreamOwner = false;
await StreamFolderAsZipAsync(zipStream, physicalPath, baseDirectory, HttpContext.RequestAborted);
}
catch (ZipException)
{
// Ignored
}
catch (TaskCanceledException)
{
// Ignored
}
}
}
private async Task StreamFolderAsZipAsync(
ZipOutputStream zipStream,
string path, string rootPath,
CancellationToken cancellationToken
)
{
foreach (var file in Directory.EnumerateFiles(path))
{
if (HttpContext.RequestAborted.IsCancellationRequested)
return;
var fi = new FileInfo(file);
var filePath = Formatter.ReplaceStart(file, rootPath, "");
await zipStream.PutNextEntryAsync(new ZipEntry(filePath)
{
Size = fi.Length,
}, cancellationToken);
await using var fs = System.IO.File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await fs.CopyToAsync(zipStream, cancellationToken);
await fs.FlushAsync(cancellationToken);
fs.Close();
await zipStream.FlushAsync(cancellationToken);
}
foreach (var directory in Directory.EnumerateDirectories(path))
{
if (HttpContext.RequestAborted.IsCancellationRequested)
return;
await StreamFolderAsZipAsync(zipStream, directory, rootPath, cancellationToken);
}
}
// Yes I know we can just create that url on the client as the exist validation is done on both endpoints,
// but we leave it here for future modifications. E.g. using a distributed file provider or smth like that
[HttpPost]
public Task<DownloadUrlResponse> PostAsync([FromQuery] string path)
{
var safePath = FilePathHelper.SanitizePath(path);
var physicalPath = Path.Combine(BaseDirectory, safePath);
if (System.IO.File.Exists(physicalPath) || Directory.Exists(physicalPath))
{
return Task.FromResult(new DownloadUrlResponse()
{
Url = $"{Configuration.PublicUrl}/api/admin/system/files/downloadUrl?path={path}"
});
}
throw new HttpApiException("No such file or directory found", 404);
}
}

View File

@@ -1,192 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions;
using Moonlight.ApiServer.Helpers;
using Moonlight.Shared.Http.Responses.Admin.Sys;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys.Files;
[ApiController]
[Route("api/admin/system/files")]
[Authorize(Policy = "permissions:admin.system.files")]
public class FilesController : Controller
{
private const string BaseDirectory = "storage";
[HttpPost("touch")]
public async Task CreateFileAsync([FromQuery] string path)
{
var safePath = FilePathHelper.SanitizePath(path);
var physicalPath = Path.Combine(BaseDirectory, safePath);
if (System.IO.File.Exists(physicalPath))
throw new HttpApiException("A file already exists at that path", 400);
if (Directory.Exists(path))
throw new HttpApiException("A folder already exists at that path", 400);
await using var fs = System.IO.File.Create(physicalPath);
fs.Close();
}
[HttpPost("mkdir")]
public Task CreateFolderAsync([FromQuery] string path)
{
var safePath = FilePathHelper.SanitizePath(path);
var physicalPath = Path.Combine(BaseDirectory, safePath);
if (Directory.Exists(path))
throw new HttpApiException("A folder already exists at that path", 400);
if (System.IO.File.Exists(physicalPath))
throw new HttpApiException("A file already exists at that path", 400);
Directory.CreateDirectory(physicalPath);
return Task.CompletedTask;
}
[HttpGet("list")]
public Task<FileSystemEntryResponse[]> ListAsync([FromQuery] string path)
{
var safePath = FilePathHelper.SanitizePath(path);
var physicalPath = Path.Combine(BaseDirectory, safePath);
var entries = new List<FileSystemEntryResponse>();
var files = Directory.GetFiles(physicalPath);
foreach (var file in files)
{
var fi = new FileInfo(file);
entries.Add(new FileSystemEntryResponse()
{
Name = fi.Name,
Size = fi.Length,
CreatedAt = fi.CreationTimeUtc,
IsFolder = false,
UpdatedAt = fi.LastWriteTimeUtc
});
}
var directories = Directory.GetDirectories(physicalPath);
foreach (var directory in directories)
{
var di = new DirectoryInfo(directory);
entries.Add(new FileSystemEntryResponse()
{
Name = di.Name,
Size = 0,
CreatedAt = di.CreationTimeUtc,
UpdatedAt = di.LastWriteTimeUtc,
IsFolder = true
});
}
return Task.FromResult(
entries.ToArray()
);
}
[HttpPost("move")]
public Task MoveAsync([FromQuery] string oldPath, [FromQuery] string newPath)
{
var oldSafePath = FilePathHelper.SanitizePath(oldPath);
var newSafePath = FilePathHelper.SanitizePath(newPath);
var oldPhysicalDirPath = Path.Combine(BaseDirectory, oldSafePath);
if (Directory.Exists(oldPhysicalDirPath))
{
var newPhysicalDirPath = Path.Combine(BaseDirectory, newSafePath);
Directory.Move(
oldPhysicalDirPath,
newPhysicalDirPath
);
}
else
{
var oldPhysicalFilePath = Path.Combine(BaseDirectory, oldSafePath);
var newPhysicalFilePath = Path.Combine(BaseDirectory, newSafePath);
System.IO.File.Move(
oldPhysicalFilePath,
newPhysicalFilePath
);
}
return Task.CompletedTask;
}
[HttpDelete("delete")]
public Task DeleteAsync([FromQuery] string path)
{
var safePath = FilePathHelper.SanitizePath(path);
var physicalDirPath = Path.Combine(BaseDirectory, safePath);
if (Directory.Exists(physicalDirPath))
Directory.Delete(physicalDirPath, true);
else
{
var physicalFilePath = Path.Combine(BaseDirectory, safePath);
System.IO.File.Delete(physicalFilePath);
}
return Task.CompletedTask;
}
[HttpPost("upload")]
public async Task<IResult> UploadAsync([FromQuery] string path)
{
if (Request.Form.Files.Count != 1)
return Results.Problem("Only one file is allowed in the request", statusCode: 400);
var file = Request.Form.Files[0];
var safePath = FilePathHelper.SanitizePath(path);
var physicalPath = Path.Combine(BaseDirectory, safePath);
// Create directory which the new file should be put into
var baseDirectory = Path.GetDirectoryName(physicalPath);
if(!string.IsNullOrEmpty(baseDirectory))
Directory.CreateDirectory(baseDirectory);
// Create file from provided form
await using var dataStream = file.OpenReadStream();
await using var targetStream = System.IO.File.Open(
physicalPath,
FileMode.Create,
FileAccess.ReadWrite,
FileShare.Read
);
// Copy the content to the newly created file
await dataStream.CopyToAsync(targetStream);
await targetStream.FlushAsync();
// Close both streams
targetStream.Close();
dataStream.Close();
return Results.Ok();
}
[HttpGet("download")]
public async Task DownloadAsync([FromQuery] string path)
{
var safePath = FilePathHelper.SanitizePath(path);
var physicalPath = Path.Combine(BaseDirectory, safePath);
await using var fs = System.IO.File.Open(physicalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await fs.CopyToAsync(Response.Body);
fs.Close();
}
}

View File

@@ -1,40 +0,0 @@
using Hangfire;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Shared.Http.Responses.Admin.Hangfire;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys;
[ApiController]
[Route("api/admin/system/hangfire")]
[Authorize(Policy = "permissions:admin.system.hangfire")]
public class HangfireController : Controller
{
private readonly JobStorage JobStorage;
public HangfireController(JobStorage jobStorage)
{
JobStorage = jobStorage;
}
[HttpGet("stats")]
public Task<HangfireStatsResponse> GetStatsAsync()
{
var statistics = JobStorage.GetMonitoringApi().GetStatistics();
return Task.FromResult(new HangfireStatsResponse()
{
Awaiting = statistics.Awaiting,
Deleted = statistics.Deleted,
Enqueued = statistics.Enqueued,
Failed = statistics.Failed,
Processing = statistics.Processing,
Queues = statistics.Queues,
Recurring = statistics.Recurring,
Retries = statistics.Retries,
Scheduled = statistics.Scheduled,
Servers = statistics.Servers,
Succeeded = statistics.Succeeded
});
}
}

View File

@@ -1,38 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Responses.Admin.Sys;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys;
[ApiController]
[Route("api/admin/system")]
public class SystemController : Controller
{
private readonly ApplicationService ApplicationService;
public SystemController(ApplicationService applicationService)
{
ApplicationService = applicationService;
}
[HttpGet]
[Authorize(Policy = "permissions:admin.system.overview")]
public async Task<SystemOverviewResponse> GetOverviewAsync()
{
return new()
{
Uptime = await ApplicationService.GetUptimeAsync(),
CpuUsage = await ApplicationService.GetCpuUsageAsync(),
MemoryUsage = await ApplicationService.GetMemoryUsageAsync(),
OperatingSystem = await ApplicationService.GetOsNameAsync()
};
}
[HttpPost("shutdown")]
[Authorize(Policy = "permissions:admin.system.shutdown")]
public async Task ShutdownAsync()
{
await ApplicationService.ShutdownAsync();
}
}

View File

@@ -1,195 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MoonCore.Common;
using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Services;
using Moonlight.ApiServer.Mappers;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Users;
[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:admin.users.get")]
public async Task<ActionResult<CountedData<UserResponse>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int count,
[FromQuery] string? orderBy,
[FromQuery] string? filter,
[FromQuery] string orderByDir = "asc"
)
{
if (count > 100)
return Problem("You cannot fetch more items than 100 at a time", statusCode: 400);
IQueryable<User> query = UserRepository.Get();
query = orderBy switch
{
nameof(Database.Entities.User.Id) => orderByDir == "desc"
? query.OrderByDescending(x => x.Id)
: query.OrderBy(x => x.Id),
nameof(Database.Entities.User.Username) => orderByDir == "desc"
? query.OrderByDescending(x => x.Username)
: query.OrderBy(x => x.Username),
nameof(Database.Entities.User.Email) => orderByDir == "desc"
? query.OrderByDescending(x => x.Email)
: query.OrderBy(x => x.Email),
_ => query.OrderBy(x => x.Id)
};
if (!string.IsNullOrEmpty(filter))
{
query = query.Where(x =>
EF.Functions.ILike(x.Username, $"%{filter}%") ||
EF.Functions.ILike(x.Email, $"%{filter}%")
);
}
var totalCount = await query.CountAsync();
var items = await query
.Skip(startIndex)
.Take(count)
.AsNoTracking()
.ProjectToResponse()
.ToArrayAsync();
return new CountedData<UserResponse>()
{
Items = items,
TotalCount = totalCount
};
}
[HttpGet("{id}")]
[Authorize(Policy = "permissions:admin.users.get")]
public async Task<ActionResult<UserResponse>> GetSingleAsync(int id)
{
var user = await UserRepository
.Get()
.ProjectToResponse()
.FirstOrDefaultAsync(x => x.Id == id);
if (user == null)
return Problem("No user with that id found", statusCode: 404);
return user;
}
[HttpPost]
[Authorize(Policy = "permissions:admin.users.create")]
public async Task<ActionResult<UserResponse>> CreateAsync([FromBody] CreateUserRequest request)
{
// Reformat values
request.Username = request.Username.ToLower().Trim();
request.Email = request.Email.ToLower().Trim();
// Check for users with the same values
if (UserRepository.Get().Any(x => x.Username == request.Username))
return Problem("A user with that username already exists", statusCode: 400);
if (UserRepository.Get().Any(x => x.Email == request.Email))
return Problem("A user with that email address already exists", statusCode: 400);
var hashedPassword = HashHelper.Hash(request.Password);
var user = new User()
{
Email = request.Email,
Username = request.Username,
Password = hashedPassword,
Permissions = request.Permissions
};
var finalUser = await UserRepository.AddAsync(user);
return UserMapper.ToResponse(finalUser);
}
[HttpPatch("{id}")]
[Authorize(Policy = "permissions:admin.users.update")]
public async Task<ActionResult<UserResponse>> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserRequest request)
{
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == id);
if (user == null)
return Problem("No user with that id found", statusCode: 404);
// Reformat values
request.Username = request.Username.ToLower().Trim();
request.Email = request.Email.ToLower().Trim();
// Check for users with the same values
if (UserRepository.Get().Any(x => x.Username == request.Username && x.Id != user.Id))
return Problem("Another user with that username already exists", statusCode: 400);
if (UserRepository.Get().Any(x => x.Email == request.Email && x.Id != user.Id))
return Problem("Another user with that email address already exists", statusCode: 400);
// Perform hashing the password if required
if (!string.IsNullOrEmpty(request.Password))
{
user.Password = HashHelper.Hash(request.Password);
user.TokenValidTimestamp = DateTime.UtcNow; // Log out user after password change
}
if (request.Permissions.Any(x => !user.Permissions.Contains(x)))
{
user.Permissions = request.Permissions;
user.TokenValidTimestamp = DateTime.UtcNow; // Log out user after permission change
}
user.Email = request.Email;
user.Username = request.Username;
await UserRepository.UpdateAsync(user);
return UserMapper.ToResponse(user);
}
[HttpDelete("{id}")]
[Authorize(Policy = "permissions:admin.users.delete")]
public async Task<ActionResult> DeleteAsync([FromRoute] int id, [FromQuery] bool force = false)
{
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == id);
if (user == null)
return Problem("No user with that id found", statusCode: 404);
var deletionService = HttpContext.RequestServices.GetRequiredService<UserDeletionService>();
if (!force)
{
var validationResult = await deletionService.ValidateAsync(user);
if (!validationResult.IsAllowed)
return Problem("Unable to delete user", statusCode: 400, title: validationResult.Reason);
}
await deletionService.DeleteAsync(user, force);
return NoContent();
}
}

View File

@@ -1,128 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Interfaces;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.ApiServer.Http.Controllers.Auth;
[ApiController]
[Route("api/auth")]
public class AuthController : Controller
{
private readonly IAuthenticationSchemeProvider SchemeProvider;
private readonly IEnumerable<IAuthCheckExtension> Extensions;
private readonly AppConfiguration Configuration;
public AuthController(
IAuthenticationSchemeProvider schemeProvider,
IEnumerable<IAuthCheckExtension> extensions,
AppConfiguration configuration
)
{
SchemeProvider = schemeProvider;
Extensions = extensions;
Configuration = configuration;
}
[HttpGet]
public async Task<AuthSchemeResponse[]> GetSchemesAsync()
{
var schemes = await SchemeProvider.GetAllSchemesAsync();
var allowedSchemes = Configuration.Authentication.EnabledSchemes;
return schemes
.Where(x => allowedSchemes.Contains(x.Name))
.Select(scheme => new AuthSchemeResponse()
{
DisplayName = scheme.DisplayName ?? scheme.Name,
Identifier = scheme.Name
})
.ToArray();
}
[HttpGet("{identifier:alpha}")]
public async Task StartSchemeAsync([FromRoute] string identifier)
{
// Validate identifier against our enable list
var allowedSchemes = Configuration.Authentication.EnabledSchemes;
if (!allowedSchemes.Contains(identifier))
{
await Results
.Problem(
"Invalid scheme identifier provided",
statusCode: 404
)
.ExecuteAsync(HttpContext);
return;
}
// Now we can check if it even exists
var scheme = await SchemeProvider.GetSchemeAsync(identifier);
if (scheme == null)
{
await Results
.Problem(
"Invalid scheme identifier provided",
statusCode: 404
)
.ExecuteAsync(HttpContext);
return;
}
// Everything fine, challenge the frontend
await HttpContext.ChallengeAsync(
scheme.Name,
new AuthenticationProperties()
{
RedirectUri = "/"
}
);
}
[Authorize]
[HttpGet("check")]
public async Task<AuthClaimResponse[]> CheckAsync()
{
var username = User.FindFirstValue(ClaimTypes.Name)!;
var id = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var email = User.FindFirstValue(ClaimTypes.Email)!;
var userId = User.FindFirstValue("UserId")!;
var permissions = User.FindFirstValue("Permissions")!;
// Create basic set of claims used by the frontend
var claims = new List<AuthClaimResponse>()
{
new(ClaimTypes.Name, username),
new(ClaimTypes.NameIdentifier, id),
new(ClaimTypes.Email, email),
new("UserId", userId),
new("Permissions", permissions)
};
// Enrich the frontend claims by extensions (used by plugins)
foreach (var extension in Extensions)
{
claims.AddRange(
await extension.GetFrontendClaimsAsync(User)
);
}
return claims.ToArray();
}
[HttpGet("logout")]
public async Task LogoutAsync()
{
await HttpContext.SignOutAsync();
await Results.Redirect("/").ExecuteAsync(HttpContext);
}
}

View File

@@ -1,31 +0,0 @@
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moonlight.ApiServer.Services;
using Moonlight.Shared.Misc;
namespace Moonlight.ApiServer.Http.Controllers.Frontend;
[ApiController]
[Route("/")]
public class FrontendController : Controller
{
private readonly FrontendService FrontendService;
public FrontendController(FrontendService frontendService)
{
FrontendService = frontendService;
}
[HttpGet("frontend.json")]
public async Task<FrontendConfiguration> GetConfigurationAsync()
=> await FrontendService.GetConfigurationAsync();
[HttpGet]
public async Task<IResult> IndexAsync()
{
var content = await FrontendService.GenerateIndexHtmlAsync();
return Results.Text(content, "text/html", Encoding.UTF8);
}
}

View File

@@ -1,102 +0,0 @@
@using Moonlight.ApiServer.Database.Entities
<!DOCTYPE html>
<html lang="en" class="bg-base-200 text-base-content font-inter">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>@Title</title>
<base href="/"/>
@foreach (var style in Styles)
{
<link rel="stylesheet" href="@style"/>
}
<link href="manifest.webmanifest" rel="manifest"/>
<link rel="apple-touch-icon" sizes="512x512" href="/_content/Moonlight.Client/img/icon-512.png"/>
<link rel="apple-touch-icon" sizes="192x192" href="/_content/Moonlight.Client/img/icon-192.png"/>
@if (Theme != null)
{
<style>
:root {
--color-base-100: @(Theme.Content.ColorBase100);
--color-base-200: @(Theme.Content.ColorBase200);
--color-base-300: @(Theme.Content.ColorBase300);
--color-base-content: @(Theme.Content.ColorBaseContent);
--color-primary: @(Theme.Content.ColorPrimary);
--color-primary-content: @(Theme.Content.ColorPrimaryContent);
--color-secondary: @(Theme.Content.ColorSecondary);
--color-secondary-content: @(Theme.Content.ColorSecondaryContent);
--color-accent: @(Theme.Content.ColorAccent);
--color-accent-content: @(Theme.Content.ColorAccentContent);
--color-neutral: @(Theme.Content.ColorNeutral);
--color-neutral-content: @(Theme.Content.ColorNeutralContent);
--color-info: @(Theme.Content.ColorInfo);
--color-info-content: @(Theme.Content.ColorInfoContent);
--color-success: @(Theme.Content.ColorSuccess);
--color-success-content: @(Theme.Content.ColorSuccessContent);
--color-warning: @(Theme.Content.ColorWarning);
--color-warning-content: @(Theme.Content.ColorWarningContent);
--color-error: @(Theme.Content.ColorError);
--color-error-content: @(Theme.Content.ColorErrorContent);
--radius-selector: @(Theme.Content.RadiusSelector)rem;
--radius-field: @(Theme.Content.RadiusField)rem;
--radius-box: @(Theme.Content.RadiusBox)rem;
--size-selector: @(Theme.Content.SizeSelector)rem;
--size-field: @(Theme.Content.SizeField)rem;
--border: @(Theme.Content.Border)px;
--depth: @(Theme.Content.Depth);
--noise: @(Theme.Content.Noise);
}
</style>
}
</head>
<body>
<div id="app">
<div class="flex h-screen justify-center items-center">
<div class="sm:max-w-lg">
<div id="blazor-loader-label" class="text-center mb-2 text-lg font-semibold"></div>
<div class="flex flex-col gap-1">
<div class="progress h-3 min-w-sm md:min-w-md" role="progressbar" aria-valuemin="0" aria-valuemax="100">
<div id="blazor-loader-progress" class="progress-bar progress-primary"></div>
</div>
</div>
</div>
</div>
</div>
@foreach (var script in Scripts)
{
<script src="@script"></script>
}
<script src="/_framework/blazor.webassembly.js"></script>
<script>navigator.serviceWorker.register('service-worker.js');</script>
</body>
</html>
@code
{
[Parameter] public string Title { get; set; }
[Parameter] public string[] Scripts { get; set; }
[Parameter] public string[] Styles { get; set; }
[Parameter] public Theme? Theme { get; set; }
}

View File

@@ -1,202 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Implementations.LocalAuth;
namespace Moonlight.ApiServer.Http.Controllers.LocalAuth;
[ApiController]
[Route("api/localAuth")]
public class LocalAuthController : Controller
{
private readonly DatabaseRepository<User> UserRepository;
private readonly IServiceProvider ServiceProvider;
private readonly IAuthenticationService AuthenticationService;
private readonly IOptionsMonitor<LocalAuthOptions> Options;
private readonly ILogger<LocalAuthController> Logger;
private readonly AppConfiguration Configuration;
public LocalAuthController(
DatabaseRepository<User> userRepository,
IServiceProvider serviceProvider,
IAuthenticationService authenticationService,
IOptionsMonitor<LocalAuthOptions> options,
ILogger<LocalAuthController> logger,
AppConfiguration configuration
)
{
UserRepository = userRepository;
ServiceProvider = serviceProvider;
AuthenticationService = authenticationService;
Options = options;
Logger = logger;
Configuration = configuration;
}
[HttpGet]
[HttpGet("login")]
public async Task<ActionResult> LoginAsync()
{
var html = await ComponentHelper.RenderToHtmlAsync<Login>(ServiceProvider);
return Content(html, "text/html");
}
[HttpGet("register")]
public async Task<ActionResult> RegisterAsync()
{
var html = await ComponentHelper.RenderToHtmlAsync<Register>(ServiceProvider);
return Content(html, "text/html");
}
[HttpPost]
[HttpPost("login")]
public async Task<ActionResult> LoginAsync([FromForm] string email, [FromForm] string password)
{
try
{
// Perform login
var user = await InternalLoginAsync(email, password);
// Login user
var options = Options.Get(LocalAuthConstants.AuthenticationScheme);
await AuthenticationService.SignInAsync(HttpContext, options.SignInScheme, new ClaimsPrincipal(
new ClaimsIdentity(
[
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
],
LocalAuthConstants.AuthenticationScheme
)
), new AuthenticationProperties());
// Redirect back to wasm app
return Redirect("/");
}
catch (Exception e)
{
string errorMessage;
if (e is AggregateException aggregateException)
errorMessage = aggregateException.Message;
else
{
errorMessage = "An internal error occured";
Logger.LogError(e, "An unhandled error occured while logging in user");
}
var html = await ComponentHelper.RenderToHtmlAsync<Login>(ServiceProvider,
parameters => { parameters["ErrorMessage"] = errorMessage; });
return Content(html, "text/html");
}
}
[HttpPost("register")]
public async Task<ActionResult> RegisterAsync([FromForm] string email, [FromForm] string password, [FromForm] string username)
{
try
{
// Perform register
var user = await InternalRegisterAsync(username, email, password);
// Login user
var options = Options.Get(LocalAuthConstants.AuthenticationScheme);
await AuthenticationService.SignInAsync(HttpContext, options.SignInScheme, new ClaimsPrincipal(
new ClaimsIdentity(
[
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
],
LocalAuthConstants.AuthenticationScheme
)
), new AuthenticationProperties());
// Redirect back to wasm app
return Redirect("/");
}
catch (Exception e)
{
string errorMessage;
if (e is AggregateException aggregateException)
errorMessage = aggregateException.Message;
else
{
errorMessage = "An internal error occured";
Logger.LogError(e, "An unhandled error occured while logging in user");
}
var html = await ComponentHelper.RenderToHtmlAsync<Register>(ServiceProvider,
parameters => { parameters["ErrorMessage"] = errorMessage; });
return Content(html, "text/html");
}
}
private async Task<User> InternalRegisterAsync(string username, string email, string password)
{
email = email.ToLower();
username = username.ToLower();
if (await UserRepository.Get().AnyAsync(x => x.Username == username))
throw new AggregateException("A account with that username already exists");
if (await UserRepository.Get().AnyAsync(x => x.Email == email))
throw new AggregateException("A account with that email already exists");
string[] permissions = [];
if (Configuration.Authentication.FirstUserAdmin)
{
var count = await UserRepository
.Get()
.CountAsync();
if (count == 0)
permissions = ["*"];
}
var user = new User()
{
Username = username,
Email = email,
Password = HashHelper.Hash(password),
Permissions = permissions
};
var finalUser = await UserRepository.AddAsync(user);
return finalUser;
}
private async Task<User> InternalLoginAsync(string email, string password)
{
email = email.ToLower();
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(x => x.Email == email);
if (user == null)
throw new AggregateException("Invalid combination of email and password");
if (!HashHelper.Verify(password, user.Password))
throw new AggregateException("Invalid combination of email and password");
return user;
}
}

View File

@@ -1,57 +0,0 @@
<html lang="en" class="h-full bg-base-200">
<head>
<title>Login into your account</title>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="/css/style.min.css"/>
</head>
<body class="h-full">
<div class="flex h-auto min-h-screen items-center justify-center overflow-x-hidden py-10">
<div class="relative flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="bg-base-100 shadow-base-300/20 z-1 w-full space-y-6 rounded-xl p-6 shadow-md sm:min-w-md lg:p-8">
<div class="flex justify-center items-center gap-3">
<img src="/_content/Moonlight.Client/svg/logo.svg" class="size-12" alt="brand-logo"/>
</div>
<div class="text-center">
<h3 class="text-base-content mb-1.5 text-2xl font-semibold">Login into your account</h3>
<p class="text-base-content/80">After logging in you will be able to manage your services</p>
</div>
<div class="space-y-4">
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-error text-center">
@ErrorMessage
</div>
}
<form class="mb-4 space-y-4" method="post">
<div>
<label class="label-text" for="email">Email address</label>
<input type="email" name="email" placeholder="Enter your email address" class="input" id="email"
required/>
</div>
<div>
<label class="label-text" for="password">Password</label>
<input class="input" name="password" id="password" type="password" placeholder="············"
required/>
</div>
<button class="btn btn-lg btn-primary btn-gradient btn-block">Login</button>
</form>
<p class="text-base-content/80 mb-4 text-center">
No account?
<a href="/api/localAuth/register" class="link link-animated link-primary font-normal">Create an account</a>
</p>
</div>
</div>
</div>
</div>
</body>
</html>
@code
{
[Parameter] public string? ErrorMessage { get; set; }
}

View File

@@ -1,61 +0,0 @@
<html lang="en" class="h-full bg-base-200">
<head>
<title>Register a new account</title>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="/css/style.min.css"/>
</head>
<body class="h-full">
<div class="flex h-auto min-h-screen items-center justify-center overflow-x-hidden py-10">
<div class="relative flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="bg-base-100 shadow-base-300/20 z-1 w-full space-y-6 rounded-xl p-6 shadow-md sm:min-w-md lg:p-8">
<div class="flex justify-center items-center gap-3">
<img src="/_content/Moonlight.Client/svg/logo.svg" class="size-12" alt="brand-logo"/>
</div>
<div class="text-center">
<h3 class="text-base-content mb-1.5 text-2xl font-semibold">Register a new account</h3>
<p class="text-base-content/80">After signing up you will be able to manage your services</p>
</div>
<div class="space-y-4">
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-error text-center">
@ErrorMessage
</div>
}
<form class="mb-4 space-y-4" method="post">
<div>
<label class="label-text" for="username">Username</label>
<input type="text" name="username" placeholder="Enter your username" class="input" id="username"
required/>
</div>
<div>
<label class="label-text" for="email">Email address</label>
<input type="email" name="email" placeholder="Enter your email address" class="input" id="email"
required/>
</div>
<div>
<label class="label-text" for="password">Password</label>
<input class="input" name="password" id="password" type="password" placeholder="············"
required/>
</div>
<button class="btn btn-lg btn-primary btn-gradient btn-block">Register</button>
</form>
<p class="text-base-content/80 mb-4 text-center">
Already registered?
<a href="/api/localAuth/login" class="link link-animated link-primary font-normal">Login into your account</a>
</p>
</div>
</div>
</div>
</div>
</body>
</html>
@code
{
[Parameter] public string? ErrorMessage { get; set; }
}

View File

@@ -1,45 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Models;
namespace Moonlight.ApiServer.Http.Controllers.Swagger;
[Route("api/swagger")]
public class SwaggerController : Controller
{
private readonly AppConfiguration Configuration;
private readonly IServiceProvider ServiceProvider;
public SwaggerController(
AppConfiguration configuration,
IServiceProvider serviceProvider
)
{
Configuration = configuration;
ServiceProvider = serviceProvider;
}
[HttpGet]
[Authorize]
public async Task<ActionResult> GetAsync()
{
if (!Configuration.Development.EnableApiDocs)
return BadRequest("Api docs are disabled");
var options = new ApiDocsOptions();
var optionsJson = JsonSerializer.Serialize(options);
var html = await ComponentHelper.RenderToHtmlAsync<SwaggerPage>(
ServiceProvider,
parameters =>
{
parameters.Add("Options", optionsJson);
}
);
return Content(html, "text/html");
}
}

View File

@@ -1,92 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Moonlight Api Reference</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
</head>
<body>
<script id="api-reference" data-url="/api/swagger/main"></script>
<script>
const configuration = @(Options)
document.getElementById('api-reference').dataset.configuration = JSON.stringify(configuration)
</script>
<script src="https://cdn.jsdelivr.net/npm/@@scalar/api-reference"></script>
<style>.light-mode {
--scalar-background-1: #fff;
--scalar-background-2: #f8fafc;
--scalar-background-3: #e7e7e7;
--scalar-background-accent: #8ab4f81f;
--scalar-color-1: #000;
--scalar-color-2: #6b7280;
--scalar-color-3: #9ca3af;
--scalar-color-accent: #00c16a;
--scalar-border-color: #e5e7eb;
--scalar-color-green: #069061;
--scalar-color-red: #ef4444;
--scalar-color-yellow: #f59e0b;
--scalar-color-blue: #1d4ed8;
--scalar-color-orange: #fb892c;
--scalar-color-purple: #6d28d9;
--scalar-button-1: #000;
--scalar-button-1-hover: rgba(0, 0, 0, 0.9);
--scalar-button-1-color: #fff;
}
.dark-mode {
--scalar-background-1: #020420;
--scalar-background-2: #121a31;
--scalar-background-3: #1e293b;
--scalar-background-accent: #8ab4f81f;
--scalar-color-1: #fff;
--scalar-color-2: #cbd5e1;
--scalar-color-3: #94a3b8;
--scalar-color-accent: #00dc82;
--scalar-border-color: #1e293b;
--scalar-color-green: #069061;
--scalar-color-red: #f87171;
--scalar-color-yellow: #fde68a;
--scalar-color-blue: #60a5fa;
--scalar-color-orange: #fb892c;
--scalar-color-purple: #ddd6fe;
--scalar-button-1: hsla(0, 0%, 100%, 0.9);
--scalar-button-1-hover: hsla(0, 0%, 100%, 0.8);
--scalar-button-1-color: #000;
}
.dark-mode .t-doc__sidebar,
.light-mode .t-doc__sidebar {
--scalar-sidebar-background-1: var(--scalar-background-1);
--scalar-sidebar-color-1: var(--scalar-color-1);
--scalar-sidebar-color-2: var(--scalar-color-3);
--scalar-sidebar-border-color: var(--scalar-border-color);
--scalar-sidebar-item-hover-background: transparent;
--scalar-sidebar-item-hover-color: var(--scalar-color-1);
--scalar-sidebar-item-active-background: transparent;
--scalar-sidebar-color-active: var(--scalar-color-accent);
--scalar-sidebar-search-background: transparent;
--scalar-sidebar-search-color: var(--scalar-color-3);
--scalar-sidebar-search-border-color: var(--scalar-border-color);
--scalar-sidebar-indent-border: var(--scalar-border-color);
--scalar-sidebar-indent-border-hover: var(--scalar-color-1);
--scalar-sidebar-indent-border-active: var(--scalar-color-accent);
}
.scalar-card .request-card-footer {
--scalar-background-3: var(--scalar-background-2);
--scalar-button-1: #0f172a;
--scalar-button-1-hover: rgba(30, 41, 59, 0.5);
--scalar-button-1-color: #fff;
}
.scalar-card .show-api-client-button {
border: 1px solid #334155 !important;
}</style>
</body>
</html>
@code
{
[Parameter] public string Options { get; set; }
}

View File

@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Moonlight.ApiServer.Http.Hubs;
[Authorize(Policy = "permissions:admin.system.diagnose")]
public class DiagnoseHub : Hub
{
[HubMethodName("Ping")]
public async Task PingAsync()
{
await Clients.All.SendAsync("Pong");
}
}

View File

@@ -1,3 +0,0 @@
namespace Moonlight.ApiServer;
public interface IAssemblyMarker;

View File

@@ -1,47 +0,0 @@
using System.Diagnostics;
using System.IO.Compression;
using MoonCore.Yaml;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Extensions;
using Moonlight.ApiServer.Interfaces;
namespace Moonlight.ApiServer.Implementations.Diagnose;
public class CoreConfigDiagnoseProvider : IDiagnoseProvider
{
private readonly AppConfiguration Configuration;
public CoreConfigDiagnoseProvider(AppConfiguration configuration)
{
Configuration = configuration;
}
private string CheckForNullOrEmpty(string? content)
{
return string.IsNullOrEmpty(content)
? "ISEMPTY"
: "ISNOTEMPTY";
}
public async Task ModifyZipArchiveAsync(ZipArchive archive)
{
try
{
var configString = YamlSerializer.Serialize(Configuration);
var configuration = YamlSerializer.Deserialize<AppConfiguration>(configString);
configuration.Database.Password = CheckForNullOrEmpty(configuration.Database.Password);
configuration.Authentication.Secret = CheckForNullOrEmpty(configuration.Authentication.Secret);
configuration.SignalR.RedisConnectionString = CheckForNullOrEmpty(configuration.SignalR.RedisConnectionString);
await archive.AddTextAsync(
"core/config.txt",
YamlSerializer.Serialize(configuration)
);
}
catch (Exception e)
{
await archive.AddTextAsync("core/config.txt", $"Unable to load config: {e.ToStringDemystified()}");
}
}
}

View File

@@ -1,21 +0,0 @@
using System.IO.Compression;
using Moonlight.ApiServer.Extensions;
using Moonlight.ApiServer.Interfaces;
namespace Moonlight.ApiServer.Implementations.Diagnose;
public class LogsDiagnoseProvider : IDiagnoseProvider
{
public async Task ModifyZipArchiveAsync(ZipArchive archive)
{
var path = Path.Combine("storage", "logs", "moonlight.log");
if (File.Exists(path))
{
var logsContent = await File.ReadAllTextAsync(path);
await archive.AddTextAsync("logs.txt", logsContent);
}
else
await archive.AddTextAsync("logs.txt", "Logs file moonlight.log has not been found");
}
}

View File

@@ -1,6 +0,0 @@
namespace Moonlight.ApiServer.Implementations.LocalAuth;
public static class LocalAuthConstants
{
public const string AuthenticationScheme = "LocalAuth";
}

View File

@@ -1,32 +0,0 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Moonlight.ApiServer.Implementations.LocalAuth;
public class LocalAuthHandler : AuthenticationHandler<LocalAuthOptions>
{
public LocalAuthHandler(
IOptionsMonitor<LocalAuthOptions> options,
ILoggerFactory logger,
UrlEncoder encoder
) : base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
return Task.FromResult(
AuthenticateResult.Fail("Local authentication does not directly support AuthenticateAsync")
);
}
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
await Results
.Redirect("/api/localAuth")
.ExecuteAsync(Context);
}
}

View File

@@ -1,8 +0,0 @@
using Microsoft.AspNetCore.Authentication;
namespace Moonlight.ApiServer.Implementations.LocalAuth;
public class LocalAuthOptions : AuthenticationSchemeOptions
{
public string? SignInScheme { get; set; }
}

View File

@@ -1,36 +0,0 @@
using System.Diagnostics.Metrics;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.ApiServer.Interfaces;
using Moonlight.ApiServer.Services;
namespace Moonlight.ApiServer.Implementations.Metrics;
public class ApplicationMetric : IMetric
{
private Gauge<long> MemoryUsage;
private Gauge<int> CpuUsage;
private Gauge<double> Uptime;
public Task InitializeAsync(Meter meter)
{
MemoryUsage = meter.CreateGauge<long>("moonlight_memory_usage");
CpuUsage = meter.CreateGauge<int>("moonlight_cpu_usage");
Uptime = meter.CreateGauge<double>("moonlight_uptime");
return Task.CompletedTask;
}
public async Task RunAsync(IServiceProvider provider, CancellationToken cancellationToken)
{
var applicationService = provider.GetRequiredService<ApplicationService>();
var memory = await applicationService.GetMemoryUsageAsync();
MemoryUsage.Record(memory);
var uptime = await applicationService.GetUptimeAsync();
Uptime.Record(uptime.TotalSeconds);
var cpu = await applicationService.GetCpuUsageAsync();
CpuUsage.Record(cpu);
}
}

View File

@@ -1,28 +0,0 @@
using System.Diagnostics.Metrics;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MoonCore.Extended.Abstractions;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Interfaces;
namespace Moonlight.ApiServer.Implementations.Metrics;
public class UsersMetric : IMetric
{
private Gauge<int> Users;
public Task InitializeAsync(Meter meter)
{
Users = meter.CreateGauge<int>("moonlight_users");
return Task.CompletedTask;
}
public async Task RunAsync(IServiceProvider provider, CancellationToken cancellationToken)
{
var usersRepo = provider.GetRequiredService<DatabaseRepository<User>>();
var count = await usersRepo.Get().CountAsync(cancellationToken: cancellationToken);
Users.Record(count);
}
}

View File

@@ -1,164 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database;
using Moonlight.ApiServer.Implementations.Diagnose;
using Moonlight.ApiServer.Implementations.Metrics;
using Moonlight.ApiServer.Interfaces;
using Moonlight.ApiServer.Models;
using Moonlight.ApiServer.Plugins;
using Moonlight.ApiServer.Services;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Moonlight.ApiServer.Implementations.Startup;
public class CoreStartup : IPluginStartup
{
public void AddPlugin(WebApplicationBuilder builder)
{
var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
#region Api Docs
if (configuration.Development.EnableApiDocs)
{
builder.Services.AddEndpointsApiExplorer();
// Configure swagger api specification generator and set the document title for the api docs to use
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("main", new OpenApiInfo()
{
Title = "Moonlight API"
});
options.CustomSchemaIds(x => x.FullName);
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
});
}
#endregion
#region Database
builder.Services.AddDbContext<CoreDataContext>();
#endregion
#region Diagnose
builder.Services.AddSingleton<IDiagnoseProvider, CoreConfigDiagnoseProvider>();
builder.Services.AddSingleton<IDiagnoseProvider, LogsDiagnoseProvider>();
#endregion
#region Prometheus
if (configuration.OpenTelemetry.Enable)
{
var openTel = builder.Services.AddOpenTelemetry();
var openTelConfig = configuration.OpenTelemetry;
var resourceBuilder = ResourceBuilder.CreateDefault();
resourceBuilder.AddService(serviceName: "moonlight");
openTel.ConfigureResource(x => x.AddService(serviceName: "moonlight"));
if (openTelConfig.Metrics.Enable)
{
builder.Services.AddSingleton<MetricsBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MetricsBackgroundService>());
builder.Services.AddSingleton<IMetric, ApplicationMetric>();
builder.Services.AddSingleton<IMetric, UsersMetric>();
openTel.WithMetrics(providerBuilder =>
{
providerBuilder.AddAspNetCoreInstrumentation();
providerBuilder.AddOtlpExporter();
if (openTelConfig.Metrics.EnablePrometheus)
providerBuilder.AddPrometheusExporter();
providerBuilder.AddMeter("moonlight");
});
}
if (openTelConfig.Logs.Enable)
{
openTel.WithLogging();
builder.Logging.AddOpenTelemetry(options =>
{
options.SetResourceBuilder(resourceBuilder);
options.AddOtlpExporter();
});
}
if (openTelConfig.Traces.Enable)
{
openTel.WithTracing(providerBuilder =>
{
providerBuilder.AddAspNetCoreInstrumentation();
providerBuilder.AddOtlpExporter();
});
}
}
#endregion
#region Client / Frontend
if (configuration.Frontend.EnableHosting)
{
builder.Services.AddSingleton(new FrontendConfigurationOption()
{
Scripts =
[
"/_content/Moonlight.Client/js/moonlight.js", "/_content/MoonCore.Blazor.FlyonUi/moonCore.js",
"/_content/MoonCore.Blazor.FlyonUi/ace/ace.js"
],
Styles = ["/css/style.min.css"]
});
}
#endregion
}
public void UsePlugin(WebApplication app)
{
var configuration = AppConfiguration.CreateEmpty();
app.Configuration.Bind(configuration);
#region Prometheus
if (configuration.OpenTelemetry is { Enable: true, Metrics.EnablePrometheus: true })
app.UseOpenTelemetryPrometheusScrapingEndpoint();
#endregion
}
public void MapPlugin(WebApplication app)
{
var configuration = AppConfiguration.CreateEmpty();
app.Configuration.Bind(configuration);
if (configuration.Development.EnableApiDocs)
app.MapSwagger("/api/swagger/{documentName}");
}
}

View File

@@ -1,16 +0,0 @@
using System.Security.Claims;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.ApiServer.Interfaces;
public interface IAuthCheckExtension
{
/// <summary>
/// This function will be called by the frontend reaching out to the api server for claim information.
/// You can use this function to give your frontend plugins access to user specific data which is
/// static for the session. E.g. the avatar url of a user
/// </summary>
/// <param name="principal">The principal of the current signed-in user</param>
/// <returns>An array of claim responses which gets added to the list of claims to send to the frontend</returns>
public Task<AuthClaimResponse[]> GetFrontendClaimsAsync(ClaimsPrincipal principal);
}

View File

@@ -1,8 +0,0 @@
using System.IO.Compression;
namespace Moonlight.ApiServer.Interfaces;
public interface IDiagnoseProvider
{
public Task ModifyZipArchiveAsync(ZipArchive archive);
}

View File

@@ -1,9 +0,0 @@
using System.Diagnostics.Metrics;
namespace Moonlight.ApiServer.Interfaces;
public interface IMetric
{
public Task InitializeAsync(Meter meter);
public Task RunAsync(IServiceProvider provider, CancellationToken cancellationToken);
}

View File

@@ -1,25 +0,0 @@
using System.Security.Claims;
using Moonlight.ApiServer.Database.Entities;
namespace Moonlight.ApiServer.Interfaces;
public interface IUserAuthExtension
{
/// <summary>
/// This function is called on every sign-in. It should be used to synchronize additional user data from the principal
/// or extend the claims saved in the user session
/// </summary>
/// <param name="user">The current user this method is called for</param>
/// <param name="principal">The principal after being processed by moonlight itself</param>
/// <returns>The result of the synchronisation. Returning false will immediately invalidate the sign-in and no other extensions will be called</returns>
public Task<bool> SyncAsync(User user, ClaimsPrincipal principal);
/// <summary>
/// IMPORTANT: Please note that heavy operations should not occur in this method as it will be called for every request
/// of every user
/// </summary>
/// <param name="user">The current user this method is called for</param>
/// <param name="principal">The principal after being processed by moonlight itself</param>
/// <returns>The result of the validation. Returning false will immediately invalidate the users session and no other extensions will be called</returns>
public Task<bool> ValidateAsync(User user, ClaimsPrincipal principal);
}

View File

@@ -1,10 +0,0 @@
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Models;
namespace Moonlight.ApiServer.Interfaces;
public interface IUserDeleteHandler
{
public Task<UserDeleteValidationResult> ValidateAsync(User user);
public Task DeleteAsync(User user, bool force);
}

View File

@@ -1,19 +0,0 @@
using Moonlight.ApiServer.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.ApiServer.Mappers;
[Mapper]
public static partial class ApiKeyMapper
{
// Mappers
public static partial ApiKeyResponse ToResponse(ApiKey apiKey);
public static partial ApiKey ToApiKey(CreateApiKeyRequest request);
public static partial void Merge([MappingTarget] ApiKey apiKey, UpdateApiKeyRequest request);
// EF Relations
public static partial IQueryable<ApiKeyResponse> ProjectToResponse(this IQueryable<ApiKey> apiKeys);
}

View File

@@ -1,19 +0,0 @@
using Moonlight.ApiServer.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Sys.Theme;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;
namespace Moonlight.ApiServer.Mappers;
[Mapper]
public static partial class ThemeMapper
{
// Mappers
public static partial ThemeResponse ToResponse(Theme theme);
public static partial Theme ToTheme(CreateThemeRequest request);
public static partial void Merge([MappingTarget] Theme theme, UpdateThemeRequest request);
// EF Relations
public static partial IQueryable<ThemeResponse> ProjectToResponse(this IQueryable<Theme> themes);
}

View File

@@ -1,15 +0,0 @@
using Moonlight.ApiServer.Database.Entities;
using Moonlight.Shared.Http.Responses.Admin.Users;
using Riok.Mapperly.Abstractions;
namespace Moonlight.ApiServer.Mappers;
[Mapper]
public static partial class UserMapper
{
// Mappers
public static partial UserResponse ToResponse(User user);
// EF Relations
public static partial IQueryable<UserResponse> ProjectToResponse(this IQueryable<User> users);
}

View File

@@ -1,49 +0,0 @@
namespace Moonlight.ApiServer.Models;
public class ApplicationTheme
{
public string ColorBackground { get; set; }
public string ColorBase100 { get; set; }
public string ColorBase150 { get; set; }
public string ColorBase200 { get; set; }
public string ColorBase250 { get; set; }
public string ColorBase300 { get; set; }
public string ColorBaseContent { get; set; }
public string ColorPrimary { get; set; }
public string ColorPrimaryContent { get; set; }
public string ColorSecondary { get; set; }
public string ColorSecondaryContent { get; set; }
public string ColorAccent { get; set; }
public string ColorAccentContent { get; set; }
public string ColorNeutral { get; set; }
public string ColorNeutralContent { get; set; }
public string ColorInfo { get; set; }
public string ColorInfoContent { get; set; }
public string ColorSuccess { get; set; }
public string ColorSuccessContent { get; set; }
public string ColorWarning { get; set; }
public string ColorWarningContent { get; set; }
public string ColorError { get; set; }
public string ColorErrorContent { get; set; }
public float RadiusSelector { get; set; }
public float RadiusField { get; set; }
public float RadiusBox { get; set; }
public float SizeSelector { get; set; }
public float SizeField { get; set; }
public float Border { get; set; }
public int Depth { get; set; }
public int Noise { get; set; }
}

View File

@@ -1,7 +0,0 @@
namespace Moonlight.ApiServer.Models;
public class FrontendConfigurationOption
{
public string[] Scripts { get; set; } = [];
public string[] Styles { get; set; } = [];
}

View File

@@ -1,27 +0,0 @@
namespace Moonlight.ApiServer.Models;
public class UserDeleteValidationResult
{
public bool IsAllowed { get; set; }
public string Reason { get; set; }
public static UserDeleteValidationResult Allow()
{
return new UserDeleteValidationResult()
{
IsAllowed = true
};
}
public static UserDeleteValidationResult Deny()
=> Deny("No reason provided");
public static UserDeleteValidationResult Deny(string reason)
{
return new UserDeleteValidationResult()
{
IsAllowed = false,
Reason = reason
};
}
}

View File

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Moonlight.Shared\Moonlight.Shared.csproj"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Database\Migrations\"/>
</ItemGroup>
<PropertyGroup>
<PackageId>Moonlight.ApiServer</PackageId>
<Version>2.1.15</Version>
<Authors>Moonlight Panel</Authors>
<Description>A build of the api server for moonlight development</Description>
<PackageProjectUrl>https://github.com/Moonlight-Panel/Moonlight</PackageProjectUrl>
<DevelopmentDependency>true</DevelopmentDependency>
<PackageTags>apiserver</PackageTags>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.20"/>
<PackageReference Include="Hangfire.Core" Version="1.8.20"/>
<PackageReference Include="Hangfire.EntityFrameworkCore" Version="0.7.0"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.9" />
<PackageReference Include="MoonCore" Version="2.0.6" />
<PackageReference Include="MoonCore.Extended" Version="1.4.2" />
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.12.0-beta.1"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.0-next.3" />
<PackageReference Include="SharpZipLib" Version="1.4.2"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="Ben.Demystifier" Version="0.4.1"/>
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
using Microsoft.AspNetCore.Builder;
namespace Moonlight.ApiServer.Plugins;
public interface IPluginStartup
{
public void AddPlugin(WebApplicationBuilder builder);
public void UsePlugin(WebApplication app);
public void MapPlugin(WebApplication app);
}

View File

@@ -1,32 +0,0 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using MoonCore.Extended.Abstractions;
using Moonlight.ApiServer.Database.Entities;
namespace Moonlight.ApiServer.Services;
public class ApiKeyAuthService
{
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
public ApiKeyAuthService(DatabaseRepository<ApiKey> apiKeyRepository)
{
ApiKeyRepository = apiKeyRepository;
}
public async Task<bool> ValidateAsync(ClaimsPrincipal? principal)
{
// Ignore malformed claims principal
if (principal is not { Identity.IsAuthenticated: true })
return false;
var apiKeyIdStr = principal.FindFirstValue("ApiKeyId");
if (!int.TryParse(apiKeyIdStr, out var apiKeyId))
return false;
return await ApiKeyRepository
.Get()
.AnyAsync(x => x.Id == apiKeyId);
}
}

View File

@@ -1,53 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using MoonCore.Attributes;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
namespace Moonlight.ApiServer.Services;
[Singleton]
public class ApiKeyService
{
private readonly AppConfiguration Configuration;
public ApiKeyService(AppConfiguration configuration)
{
Configuration = configuration;
}
public string GenerateJwt(ApiKey apiKey)
{
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
var descriptor = new SecurityTokenDescriptor()
{
Expires = apiKey.ExpiresAt.UtcDateTime,
IssuedAt = DateTime.Now,
NotBefore = DateTime.Now.AddMinutes(-1),
Claims = new Dictionary<string, object>()
{
{
"ApiKeyId",
apiKey.Id
},
{
"Permissions",
string.Join(";", apiKey.Permissions)
}
},
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration.Authentication.Secret)
),
SecurityAlgorithms.HmacSha256
),
Issuer = Configuration.PublicUrl,
Audience = Configuration.PublicUrl
};
var securityToken = jwtSecurityTokenHandler.CreateJwtSecurityToken(descriptor);
return jwtSecurityTokenHandler.WriteToken(securityToken);
}
}

View File

@@ -1,120 +0,0 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MoonCore.Attributes;
using MoonCore.Helpers;
namespace Moonlight.ApiServer.Services;
[Singleton]
public class ApplicationService
{
private ILogger<ApplicationService> Logger;
private readonly IHost Host;
public ApplicationService(ILogger<ApplicationService> logger, IHost host)
{
Logger = logger;
Host = host;
}
public Task<string> GetOsNameAsync()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Windows platform detected
var osVersion = Environment.OSVersion.Version;
return Task.FromResult($"Windows {osVersion.Major}.{osVersion.Minor}.{osVersion.Build}");
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
var releaseRaw = File
.ReadAllLines("/etc/os-release")
.FirstOrDefault(x => x.StartsWith("PRETTY_NAME="));
if (string.IsNullOrEmpty(releaseRaw))
return Task.FromResult("Linux (unknown release)");
var release = releaseRaw
.Replace("PRETTY_NAME=", "")
.Replace("\"", "");
if(string.IsNullOrEmpty(release))
return Task.FromResult("Linux (unknown release)");
return Task.FromResult(release);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
// macOS platform detected
var osVersion = Environment.OSVersion.Version;
return Task.FromResult($"macOS {osVersion.Major}.{osVersion.Minor}.{osVersion.Build}");
}
// Unknown platform
return Task.FromResult("N/A");
}
public async Task<long> GetMemoryUsageAsync()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var process = Process.GetCurrentProcess();
return process.PrivateMemorySize64;
}
else
{
var lines = await File.ReadAllLinesAsync("/proc/self/smaps");
var kilobytes = 0;
foreach (var line in lines)
{
if(!line.StartsWith("pss:", StringComparison.InvariantCultureIgnoreCase))
continue;
var valueString = line
.Replace("pss:", "", StringComparison.InvariantCultureIgnoreCase)
.Replace("kb", "", StringComparison.InvariantCultureIgnoreCase)
.Trim();
kilobytes += int.Parse(valueString);
}
return ByteConverter.FromKiloBytes(kilobytes).Bytes;
}
}
public Task<TimeSpan> GetUptimeAsync()
{
var process = Process.GetCurrentProcess();
var uptime = DateTime.Now - process.StartTime;
return Task.FromResult(uptime);
}
public Task<int> GetCpuUsageAsync()
{
var process = Process.GetCurrentProcess();
var cpuTime = process.TotalProcessorTime;
var wallClockTime = DateTime.UtcNow - process.StartTime.ToUniversalTime();
var cpuUsage = (int)(100.0 * cpuTime.TotalMilliseconds / wallClockTime.TotalMilliseconds / Environment.ProcessorCount);
return Task.FromResult(cpuUsage);
}
public Task ShutdownAsync()
{
Logger.LogCritical("Restart of api server has been requested");
Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(1));
await Host.StopAsync(CancellationToken.None);
});
return Task.CompletedTask;
}
}

View File

@@ -1,96 +0,0 @@
using Moonlight.ApiServer.Interfaces;
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using MoonCore.Attributes;
using MoonCore.Exceptions;
using Moonlight.Shared.Http.Responses.Admin.Sys;
namespace Moonlight.ApiServer.Services;
[Scoped]
public class DiagnoseService
{
private readonly IEnumerable<IDiagnoseProvider> DiagnoseProviders;
private readonly ILogger<DiagnoseService> Logger;
public DiagnoseService(
IEnumerable<IDiagnoseProvider> diagnoseProviders,
ILogger<DiagnoseService> logger
)
{
DiagnoseProviders = diagnoseProviders;
Logger = logger;
}
public Task<DiagnoseProvideResponse[]> GetProvidersAsync()
{
var availableProviders = new List<DiagnoseProvideResponse>();
foreach (var diagnoseProvider in DiagnoseProviders)
{
var name = diagnoseProvider.GetType().Name;
var type = diagnoseProvider.GetType().FullName;
// The type name is null if the type is a generic type, unlikely, but still could happen
if (type == null)
continue;
availableProviders.Add(new DiagnoseProvideResponse()
{
Name = name,
Type = type
});
}
return Task.FromResult(
availableProviders.ToArray()
);
}
public async Task<MemoryStream> GenerateDiagnoseAsync(string[] requestedProviders)
{
IDiagnoseProvider[] providers;
if (requestedProviders.Length == 0)
providers = DiagnoseProviders.ToArray();
else
{
var foundProviders = new List<IDiagnoseProvider>();
foreach (var requestedProvider in requestedProviders)
{
var provider = DiagnoseProviders.FirstOrDefault(x => x.GetType().FullName == requestedProvider);
if (provider == null)
continue;
foundProviders.Add(provider);
}
providers = foundProviders.ToArray();
}
try
{
var outputStream = new MemoryStream();
var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
foreach (var provider in providers)
{
await provider.ModifyZipArchiveAsync(zipArchive);
}
zipArchive.Dispose();
outputStream.Position = 0;
return outputStream;
}
catch (Exception e)
{
Logger.LogError("An unhandled error occured while generated the diagnose file: {e}", e);
throw new HttpApiException("An unhandled error occured while generating the diagnose file", 500);
}
}
}

View File

@@ -1,168 +0,0 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using MoonCore.Attributes;
using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Http.Controllers.Frontend;
using Moonlight.ApiServer.Models;
using Moonlight.Shared.Misc;
namespace Moonlight.ApiServer.Services;
[Scoped]
public class FrontendService
{
private readonly AppConfiguration Configuration;
private readonly IWebHostEnvironment WebHostEnvironment;
private readonly IEnumerable<FrontendConfigurationOption> ConfigurationOptions;
private readonly IServiceProvider ServiceProvider;
private readonly DatabaseRepository<Theme> ThemeRepository;
public FrontendService(
AppConfiguration configuration,
IWebHostEnvironment webHostEnvironment,
IEnumerable<FrontendConfigurationOption> configurationOptions,
IServiceProvider serviceProvider,
DatabaseRepository<Theme> themeRepository
)
{
Configuration = configuration;
WebHostEnvironment = webHostEnvironment;
ConfigurationOptions = configurationOptions;
ServiceProvider = serviceProvider;
ThemeRepository = themeRepository;
}
public Task<FrontendConfiguration> GetConfigurationAsync()
{
var configuration = new FrontendConfiguration()
{
ApiUrl = Configuration.PublicUrl,
HostEnvironment = "ApiServer"
};
return Task.FromResult(configuration);
}
public async Task<string> GenerateIndexHtmlAsync() // TODO: Cache
{
// Load requested theme
var theme = await ThemeRepository
.Get()
.FirstOrDefaultAsync(x => x.IsEnabled);
// Load configured javascript files
var scripts = ConfigurationOptions
.SelectMany(x => x.Scripts)
.Distinct()
.ToArray();
// Load configured css files
var styles = ConfigurationOptions
.SelectMany(x => x.Styles)
.Distinct()
.ToArray();
return await ComponentHelper.RenderToHtmlAsync<FrontendPage>(
ServiceProvider,
parameters =>
{
parameters["Theme"] = theme!;
parameters["Styles"] = styles;
parameters["Scripts"] = scripts;
parameters["Title"] = "Moonlight"; // TODO: Config
}
);
}
public async Task<Stream> GenerateZipAsync() // TODO: Rework to be able to extract everything successfully
{
// We only allow the access to this function when we are actually hosting the frontend
if (!Configuration.Frontend.EnableHosting)
throw new HttpApiException("The hosting of the wasm client has been disabled", 400);
// Load and check wasm path
var wasmMainFile = WebHostEnvironment.WebRootFileProvider.GetFileInfo("index.html");
if (wasmMainFile is NotFoundFileInfo || string.IsNullOrEmpty(wasmMainFile.PhysicalPath))
throw new HttpApiException("Unable to find wasm location", 500);
var wasmPath = Path.GetDirectoryName(wasmMainFile.PhysicalPath)! + "/";
// Load and check the blazor framework files
var blazorFile = WebHostEnvironment.WebRootFileProvider.GetFileInfo("_framework/blazor.webassembly.js");
if (blazorFile is NotFoundFileInfo || string.IsNullOrEmpty(blazorFile.PhysicalPath))
throw new HttpApiException("Unable to find blazor location", 500);
var blazorPath = Path.GetDirectoryName(blazorFile.PhysicalPath)! + "/";
// Create zip
var memoryStream = new MemoryStream();
var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true);
// Add wasm application
await ArchiveFsItemAsync(zipArchive, wasmPath, wasmPath);
// Add blazor files
await ArchiveFsItemAsync(zipArchive, blazorPath, blazorPath, "_framework/");
// Add frontend.json
var frontendConfig = await GetConfigurationAsync();
frontendConfig.HostEnvironment = "Static";
var frontendJson = JsonSerializer.Serialize(frontendConfig);
await ArchiveTextAsync(zipArchive, "frontend.json", frontendJson);
// Finish zip archive and reset stream so the code calling this function can process it
zipArchive.Dispose();
await memoryStream.FlushAsync();
memoryStream.Position = 0;
return memoryStream;
}
private async Task ArchiveFsItemAsync(ZipArchive archive, string path, string prefixToRemove, string prefixToAdd = "")
{
if (File.Exists(path))
{
var entryName = prefixToAdd + Formatter.ReplaceStart(path, prefixToRemove, "");
var entry = archive.CreateEntry(entryName);
await using var entryStream = entry.Open();
await using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await fileStream.CopyToAsync(entryStream);
await entryStream.FlushAsync();
entryStream.Close();
}
else
{
foreach (var directoryItem in Directory.EnumerateFileSystemEntries(path))
await ArchiveFsItemAsync(archive, directoryItem, prefixToRemove, prefixToAdd);
}
}
private async Task ArchiveTextAsync(ZipArchive archive, string path, string content)
{
var data = Encoding.UTF8.GetBytes(content);
await ArchiveBytesAsync(archive, path, data);
}
private async Task ArchiveBytesAsync(ZipArchive archive, string path, byte[] bytes)
{
var entry = archive.CreateEntry(path);
await using var dataStream = entry.Open();
await dataStream.WriteAsync(bytes);
await dataStream.FlushAsync();
}
}

View File

@@ -1,81 +0,0 @@
using System.Diagnostics.Metrics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Interfaces;
namespace Moonlight.ApiServer.Services;
public class MetricsBackgroundService : BackgroundService
{
private readonly ILogger<MetricsBackgroundService> Logger;
private readonly IServiceProvider ServiceProvider;
private readonly AppConfiguration Configuration;
private readonly IMetric[] Metrics;
private readonly Meter Meter;
public MetricsBackgroundService(
IServiceProvider serviceProvider,
IMeterFactory meterFactory,
IEnumerable<IMetric> metrics,
ILogger<MetricsBackgroundService> logger,
AppConfiguration configuration
)
{
ServiceProvider = serviceProvider;
Logger = logger;
Configuration = configuration;
Meter = meterFactory.Create("moonlight");
Metrics = metrics.ToArray();
}
private async Task InitializeAsync()
{
Logger.LogDebug(
"Initializing metrics: {names}",
string.Join(", ", Metrics.Select(x => x.GetType().FullName))
);
foreach (var metric in Metrics)
await metric.InitializeAsync(Meter);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await InitializeAsync();
while (!stoppingToken.IsCancellationRequested)
{
using var scope = ServiceProvider.CreateScope();
foreach (var metric in Metrics)
{
try
{
await metric.RunAsync(scope.ServiceProvider, stoppingToken);
}
catch (TaskCanceledException)
{
// Ignored
}
catch (Exception e)
{
Logger.LogError(
"An unhandled error occured while collecting metric {name}: {e}",
metric.GetType().FullName,
e
);
}
}
await Task.Delay(
TimeSpan.FromSeconds(Configuration.OpenTelemetry.Metrics.Interval),
stoppingToken
);
}
}
}

View File

@@ -1,168 +0,0 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Interfaces;
namespace Moonlight.ApiServer.Services;
public class UserAuthService
{
private readonly ILogger<UserAuthService> Logger;
private readonly DatabaseRepository<User> UserRepository;
private readonly AppConfiguration Configuration;
private readonly IEnumerable<IUserAuthExtension> Extensions;
private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt";
public UserAuthService(
ILogger<UserAuthService> logger,
DatabaseRepository<User> userRepository,
AppConfiguration configuration,
IEnumerable<IUserAuthExtension> extensions
)
{
Logger = logger;
UserRepository = userRepository;
Configuration = configuration;
Extensions = extensions;
}
public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
{
// Ignore malformed claims principal
if (principal is not { Identity.IsAuthenticated: true })
return false;
// Search for email and username. We need both to create the user model if required.
// We do a ToLower here because external authentication provider might provide case-sensitive data
var email = principal.FindFirstValue(ClaimTypes.Email)?.ToLower();
var username = principal.FindFirstValue(ClaimTypes.Name)?.ToLower();
if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(username))
{
Logger.LogWarning(
"The authentication scheme {scheme} did not provide claim types: email, name. These are required to sync to user to the database",
principal.Identity.AuthenticationType
);
return false;
}
// If you plan to use multiple auth providers it can be a good idea
// to use an identifier in the user model which consists of the provider and the NameIdentifier
// instead of the email address. For simplicity, we just use the email as the identifier so multiple auth providers
// can lead to the same account when the email matches
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(u => u.Email == email);
if (user == null)
{
string[] permissions = [];
// Yes I know we handle the first user admin thing in the LocalAuth too,
// but this only works fo the local auth. So if a user uses an external auth scheme
// like oauth2 discord, the first user admin toggle would do nothing
if (Configuration.Authentication.FirstUserAdmin)
{
var count = await UserRepository
.Get()
.CountAsync();
if (count == 0)
permissions = ["*"];
}
user = await UserRepository.AddAsync(new User()
{
Email = email,
TokenValidTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1),
Username = username,
Password = HashHelper.Hash(Formatter.GenerateString(64)),
Permissions = permissions
});
}
// You can sync other properties here
if (user.Username != username)
{
user.Username = username;
await UserRepository.UpdateAsync(user);
}
// Enrich claims with required metadata
principal.Identities.First().AddClaims([
new Claim(UserIdClaim, user.Id.ToString()),
new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new Claim("Permissions", string.Join(';', user.Permissions))
]);
// Call extensions
foreach (var extension in Extensions)
{
var result = await extension.SyncAsync(user, principal);
if (!result) // Exit immediately if result is false
return false;
}
return true;
}
public async Task<bool> ValidateAsync(ClaimsPrincipal? principal)
{
// Ignore malformed claims principal
if (principal is not { Identity.IsAuthenticated: true })
return false;
// Validate if the user still exists, and then we want to validate the token issue time
// against the invalidation time
var userIdStr = principal.FindFirstValue(UserIdClaim);
if (!int.TryParse(userIdStr, out var userId))
return false;
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null)
return false;
// Token time validation
var issuedAtStr = principal.FindFirstValue(IssuedAtClaim);
if (!long.TryParse(issuedAtStr, 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.TokenValidTimestamp)
return false;
// Call extensions
foreach (var extension in Extensions)
{
var result = await extension.ValidateAsync(user, principal);
if (!result) // Exit immediately if result is false
return false;
}
return true;
}
}

View File

@@ -1,42 +0,0 @@
using MoonCore.Extended.Abstractions;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Interfaces;
using Moonlight.ApiServer.Models;
namespace Moonlight.ApiServer.Services;
public class UserDeletionService
{
private readonly IUserDeleteHandler[] Handlers;
private readonly DatabaseRepository<User> UserRepository;
public UserDeletionService(
IEnumerable<IUserDeleteHandler> handlers,
DatabaseRepository<User> userRepository
)
{
UserRepository = userRepository;
Handlers = handlers.ToArray();
}
public async Task<UserDeleteValidationResult> ValidateAsync(User user)
{
foreach (var handler in Handlers)
{
var result = await handler.ValidateAsync(user);
if (!result.IsAllowed)
return result;
}
return UserDeleteValidationResult.Allow();
}
public async Task DeleteAsync(User user, bool force)
{
foreach (var handler in Handlers)
await handler.DeleteAsync(user, force);
await UserRepository.RemoveAsync(user);
}
}

View File

@@ -1,189 +0,0 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using MoonCore.Permissions;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Implementations.LocalAuth;
using Moonlight.ApiServer.Services;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void AddAuth(this WebApplicationBuilder builder)
{
var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
builder.Services
.AddAuthentication(options => { options.DefaultScheme = "MainScheme"; })
.AddPolicyScheme("MainScheme", null, options =>
{
// If an api key is specified via the bearer auth header
// we want to use the ApiKey scheme for authenticating the request
options.ForwardDefaultSelector = context =>
{
var headers = context.Request.Headers;
// For regular api calls
if (headers.ContainsKey("Authorization"))
return "ApiKey";
// For websocket requests which cannot use the Authorization header
if (headers.Upgrade == "websocket" && headers.Connection == "Upgrade" && context.Request.Query.ContainsKey("access_token"))
return "ApiKey";
// Regular user traffic/auth
return "Session";
};
})
.AddJwtBearer("ApiKey", null, options =>
{
options.TokenValidationParameters = new()
{
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
configuration.Authentication.Secret
)),
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
ValidateAudience = true,
ValidAudience = configuration.PublicUrl,
ValidateIssuer = true,
ValidIssuer = configuration.PublicUrl
};
options.Events = new JwtBearerEvents()
{
OnTokenValidated = async context =>
{
var apiKeyAuthService = context
.HttpContext
.RequestServices
.GetRequiredService<ApiKeyAuthService>();
var result = await apiKeyAuthService.ValidateAsync(context.Principal);
if (!result)
context.Fail("API key has been deleted");
},
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken))
context.Token = accessToken;
return Task.CompletedTask;
}
};
})
.AddCookie("Session", null, options =>
{
options.ExpireTimeSpan = TimeSpan.FromDays(configuration.Authentication.Sessions.ExpiresIn);
options.Cookie = new CookieBuilder()
{
Name = configuration.Authentication.Sessions.CookieName,
Path = "/",
IsEssential = true,
SecurePolicy = CookieSecurePolicy.SameAsRequest
};
// As redirects won't work in our spa which uses API calls
// we need to customize the responses when certain actions happen
options.Events.OnRedirectToLogin = async context =>
{
await Results.Problem(
title: "Unauthenticated",
detail: "You need to authenticate yourself to use this endpoint",
statusCode: 401
)
.ExecuteAsync(context.HttpContext);
};
options.Events.OnRedirectToAccessDenied = async context =>
{
await Results.Problem(
title: "Permission denied",
detail: "You are missing the required permissions to access this endpoint",
statusCode: 403
)
.ExecuteAsync(context.HttpContext);
};
options.Events.OnSigningIn = async context =>
{
var userSyncService = context
.HttpContext
.RequestServices
.GetRequiredService<UserAuthService>();
var result = await userSyncService.SyncAsync(context.Principal);
if (!result)
context.Principal = new();
else
context.Properties.IsPersistent = true;
};
options.Events.OnValidatePrincipal = async context =>
{
var userSyncService = context
.HttpContext
.RequestServices
.GetRequiredService<UserAuthService>();
var result = await userSyncService.ValidateAsync(context.Principal);
if (!result)
context.RejectPrincipal();
};
})
.AddScheme<LocalAuthOptions, LocalAuthHandler>(LocalAuthConstants.AuthenticationScheme, "Local Auth", options =>
{
options.ForwardAuthenticate = "Session";
options.ForwardSignIn = "Session";
options.ForwardSignOut = "Session";
options.SignInScheme = "Session";
});
builder.Services.AddAuthorization();
builder.Services.AddAuthorizationPermissions(options =>
{
options.ClaimName = "Permissions";
options.Prefix = "permissions:";
});
builder.Services.AddScoped<UserAuthService>();
builder.Services.AddScoped<ApiKeyAuthService>();
// Setup data protection storage within storage folder
// so its persists in containers
var dpKeyPath = Path.Combine("storage", "dataProtectionKeys");
Directory.CreateDirectory(dpKeyPath);
builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(
new DirectoryInfo(dpKeyPath)
);
builder.Services.AddScoped<UserDeletionService>();
}
private static void UseAuth(this WebApplication application)
{
application.UseAuthentication();
application.UseAuthorization();
}
}

View File

@@ -1,62 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MoonCore.Extended.Extensions;
using MoonCore.Extensions;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Plugins;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void AddBase(this WebApplicationBuilder builder, IPluginStartup[] startups)
{
builder.Services.AutoAddServices<IAssemblyMarker>();
builder.Services.AddHttpClient();
builder.Services.AddApiExceptionHandler();
// Configure controllers
var mvcBuilder = builder.Services.AddControllers();
// Add plugin assemblies as application parts
foreach (var pluginStartup in startups.Select(x => x.GetType().Assembly).Distinct())
mvcBuilder.AddApplicationPart(pluginStartup.GetType().Assembly);
}
private static void UseBase(this WebApplication application)
{
application.UseRouting();
application.UseExceptionHandler();
}
private static void MapBase(this WebApplication application)
{
application.MapControllers();
// Frontend
var configuration = AppConfiguration.CreateEmpty();
application.Configuration.Bind(configuration);
if (configuration.Frontend.EnableHosting)
application.MapFallbackToController("Index", "Frontend");
}
private static void ConfigureKestrel(this WebApplicationBuilder builder)
{
var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
builder.WebHost.ConfigureKestrel(kestrelOptions =>
{
var maxUploadInBytes = ByteConverter
.FromMegaBytes(configuration.Kestrel.UploadLimit)
.Bytes;
kestrelOptions.Limits.MaxRequestBodySize = maxUploadInBytes;
});
}
}

View File

@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MoonCore.EnvConfiguration;
using MoonCore.Yaml;
using Moonlight.ApiServer.Configuration;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void AddConfiguration(this WebApplicationBuilder builder)
{
// Yaml
var yamlPath = Path.Combine("storage", "config.yml");
YamlDefaultGenerator.GenerateAsync<AppConfiguration>(yamlPath).Wait();
builder.Configuration.AddYamlFile(yamlPath);
// Env
builder.Configuration.AddEnvironmentVariables(prefix: "MOONLIGHT_", separator: "_");
var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
builder.Services.AddSingleton(configuration);
}
}

View File

@@ -1,17 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Extensions;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void AddDatabase(this WebApplicationBuilder builder)
{
builder.Services.AddDbAutoMigrations();
builder.Services.AddDatabaseMappings();
builder.Services.AddScoped(typeof(DatabaseRepository<>));
}
}

View File

@@ -1,45 +0,0 @@
using Hangfire;
using Hangfire.EntityFrameworkCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Moonlight.ApiServer.Database;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void AddMoonlightHangfire(this WebApplicationBuilder builder)
{
builder.Services.AddHangfire((provider, configuration) =>
{
configuration.SetDataCompatibilityLevel(CompatibilityLevel.Version_180);
configuration.UseSimpleAssemblyNameTypeSerializer();
configuration.UseRecommendedSerializerSettings();
configuration.UseEFCoreStorage(() =>
{
var scope = provider.CreateScope();
return scope.ServiceProvider.GetRequiredService<CoreDataContext>();
}, new EFCoreStorageOptions());
});
builder.Services.AddHangfireServer();
builder.Logging.AddFilter(
"Hangfire.Server.BackgroundServerProcess",
LogLevel.Warning
);
builder.Logging.AddFilter(
"Hangfire.BackgroundJobServer",
LogLevel.Warning
);
}
private static void UseMoonlightHangfire(this WebApplication application)
{
if (application.Environment.IsDevelopment())
application.UseHangfireDashboard();
}
}

View File

@@ -1,57 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Logging;
using MoonCore.Logging;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void AddLogging(this WebApplicationBuilder builder)
{
// Logging providers
builder.Logging.ClearProviders();
builder.Logging.AddAnsiConsole();
builder.Logging.AddFile(Path.Combine("storage", "logs", "moonlight.log"));
// Logging levels
var logConfigPath = Path.Combine("storage", "logConfig.json");
// Ensure default log levels exist
if (!File.Exists(logConfigPath))
{
var defaultLogLevels = new Dictionary<string, string>
{
{ "Default", "Information" },
{ "Microsoft.AspNetCore", "Warning" },
{ "System.Net.Http.HttpClient", "Warning" },
{ "Moonlight.ApiServer.Implementations.LocalAuth.LocalAuthHandler", "Warning" }
};
var logLevelsJson = JsonSerializer.Serialize(defaultLogLevels);
File.WriteAllText(logConfigPath, logLevelsJson);
}
// Read log levels
var logLevels = JsonSerializer.Deserialize<Dictionary<string, string>>(
File.ReadAllText(logConfigPath)
)!;
// Apply configured log levels
foreach (var level in logLevels)
builder.Logging.AddFilter(level.Key, Enum.Parse<LogLevel>(level.Value));
// Mute exception handler middleware
// https://github.com/dotnet/aspnetcore/issues/19740
builder.Logging.AddFilter(
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
LogLevel.Critical
);
builder.Logging.AddFilter(
"Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware",
LogLevel.Critical
);
}
}

View File

@@ -1,70 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.ApiServer.Configuration;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void PrintVersionAsync()
{
// Fancy start console output... yes very fancy :>
var rainbow = new Crayon.Rainbow(0.5);
foreach (var c in "Moonlight")
{
Console.Write(
rainbow
.Next()
.Bold()
.Text(c.ToString())
);
}
Console.WriteLine();
}
private static void CreateStorageAsync()
{
Directory.CreateDirectory("storage");
Directory.CreateDirectory(Path.Combine("storage", "logs"));
}
private static void AddMoonlightCors(this WebApplicationBuilder builder)
{
var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
var allowedOrigins = configuration.Kestrel.AllowedOrigins;
builder.Services.AddCors(options =>
{
var cors = new CorsPolicyBuilder();
if (allowedOrigins.Contains("*"))
{
cors.SetIsOriginAllowed(_ => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
}
else
{
cors.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
options.AddDefaultPolicy(
cors.Build()
);
});
}
private static void UseMoonlightCors(this WebApplication application)
{
application.UseCors();
}
}

View File

@@ -1,25 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Moonlight.ApiServer.Plugins;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void AddPlugins(this WebApplicationBuilder builder, IPluginStartup[] startups)
{
foreach (var startup in startups)
startup.AddPlugin(builder);
}
private static void UsePlugins(this WebApplication application, IPluginStartup[] startups)
{
foreach (var startup in startups)
startup.UsePlugin(application);
}
private static void MapPlugins(this WebApplication application, IPluginStartup[] startups)
{
foreach (var startup in startups)
startup.MapPlugin(application);
}
}

View File

@@ -1,26 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Http.Hubs;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
private static void AddMoonlightSignalR(this WebApplicationBuilder builder)
{
var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
var signalRBuilder = builder.Services.AddSignalR();
if (configuration.SignalR.UseRedis)
signalRBuilder.AddStackExchangeRedis(configuration.SignalR.RedisConnectionString);
}
private static void MapMoonlightSignalR(this WebApplication application)
{
application.MapHub<DiagnoseHub>("/api/admin/system/diagnose/ws");
}
}

View File

@@ -1,42 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Moonlight.ApiServer.Plugins;
namespace Moonlight.ApiServer.Startup;
public static partial class Startup
{
public static void AddMoonlight(this WebApplicationBuilder builder, IPluginStartup[] startups)
{
PrintVersionAsync();
CreateStorageAsync();
builder.AddConfiguration();
builder.AddLogging();
builder.ConfigureKestrel();
builder.AddBase(startups);
builder.AddDatabase();
builder.AddAuth();
builder.AddMoonlightCors();
builder.AddMoonlightHangfire();
builder.AddMoonlightSignalR();
builder.AddPlugins(startups);
}
public static void UseMoonlight(this WebApplication application, IPluginStartup[] startups)
{
application.UseBase();
application.UseMoonlightCors();
application.UseAuth();
application.UseMoonlightHangfire();
application.UsePlugins(startups);
}
public static void MapMoonlight(this WebApplication application, IPluginStartup[] startups)
{
application.MapBase();
application.MapMoonlightSignalR();
application.MapPlugins(startups);
}
}

View File

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

View File

@@ -1,10 +0,0 @@
using MoonCore.PluginFramework;
using Moonlight.Client.Plugins;
namespace Moonlight.Client.Runtime;
[PluginLoader]
public partial class PluginLoader : IPluginStartup
{
}

View File

@@ -1,4 +0,0 @@
<Project>
<ItemGroup>
</ItemGroup>
</Project>

View File

@@ -1,16 +0,0 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Moonlight.Client.Runtime;
using Moonlight.Client.Startup;
var pluginLoader = new PluginLoader();
pluginLoader.Initialize();
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.AddMoonlight(pluginLoader.Instances);
var app = builder.Build();
app.ConfigureMoonlight(pluginLoader.Instances);
await app.RunAsync();

View File

@@ -1,30 +0,0 @@
// extract-classes.js
const fs = require('fs');
module.exports = (opts = {}) => {
const classSet = new Set();
return {
postcssPlugin: 'extract-tailwind-classes',
Rule(rule) {
const selectorParser = require('postcss-selector-parser');
selectorParser(selectors => {
selectors.walkClasses(node => {
classSet.add(node.value);
});
}).processSync(rule.selector);
},
OnceExit() {
const classArray = Array.from(classSet).sort();
if (!fs.existsSync("../../Moonlight.Client/Styles/mappings")){
fs.mkdirSync("../../Moonlight.Client/Styles/mappings");
}
fs.writeFileSync('../../Moonlight.Client/Styles/mappings/classes.map', classArray.join('\n'));
console.log(`✅ Extracted ${classArray.length} Tailwind classes to tailwind-classes.txt`);
}
};
};
module.exports.postcss = true;

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +0,0 @@
{
"name": "styles",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"tailwind": "npx postcss styles.css -o ../wwwroot/css/style.min.css --watch",
"tailwind-build": "npx postcss styles.css -o ../wwwroot/css/style.min.css",
"mappings": "EXTRACT_CLASSES=true npx postcss styles.css -o ../wwwroot/css/style.min.css "
},
"author": "",
"license": "ISC",
"dependencies": {
"@tailwindcss/postcss": "^4.1.11",
"flyonui": "^2.2.0",
"tailwindcss": "^4.1.11",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-selector-parser": "^7.1.0"
},
"devDependencies": {
}
}

View File

@@ -1,11 +0,0 @@
const tailwindcss = require('@tailwindcss/postcss');
const extractClasses = require('./extract-classes');
module.exports = {
plugins: [
tailwindcss
],
};
if(process.env.EXTRACT_CLASSES === "true")
module.exports.plugins.push(extractClasses);

View File

@@ -1,90 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=fallback') layer(base);
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap') layer(base);
@import url("https://cdn.jsdelivr.net/npm/lucide-static/font/lucide.css") layer(base);
@import "tailwindcss";
@import "./node_modules/flyonui/variants.css";
@import "./theme.css";
@theme {
--font-inter: "Inter", var(--font-sans);
--font-scp: "Source Code Pro", var(--font-mono);
}
@plugin "flyonui" {
themes: moonlight --default;
}
@source "./node_modules/flyonui/dist/index.js";
@source "../**/*.razor";
@source "../**/*.cs";
@source "../**/*.html";
@source "**/*.map";
@source "../../Moonlight.Client/**/*.cs";
@source "../../Moonlight.Client/**/*.html";
@source "../../Moonlight.Client/**/*.razor";
@source "../../Moonlight.Client/Styles/**/*.map";
@source "../../Moonlight.ApiServer/**/*.razor";
#blazor-error-ui {
display: none;
}
#blazor-loader-label:after {
content: var(--blazor-load-percentage-text, "Loading");
}
#blazor-loader-progress {
width: var(--blazor-load-percentage, 0%);
}
@layer utilities {
.btn {
@apply text-sm font-medium inline-flex items-center justify-center;
}
.checkbox {
@apply border-base-content/30 bg-base-100;
}
.input {
@apply !border-base-content/20 border-2 ring-0! outline-0! focus:border-primary! focus-within:border-primary! bg-base-100/50;
}
.select {
@apply !border-base-content/20 border-2 ring-0! outline-0! focus:border-primary! focus-within:border-primary! bg-base-100/50;
}
.table {
:where(th, td) {
@apply py-1.5;
}
}
.dropdown-item {
@apply px-2.5 py-1.5 text-sm;
}
.advance-select-menu {
@apply !border-base-content/20 border-2 ring-0! outline-0! bg-base-200/50 !pt-0 !px-0 !py-0;
}
.advance-select-option {
@apply !rounded-none hover:!bg-primary;
}
.advance-select-option.selected {
@apply !bg-primary !text-primary-content;
}
.advance-select-toggle {
@apply !border-base-content/20 border-2 ring-0! outline-0! focus:border-primary! focus-within:border-primary! bg-base-200/50;
}
.table thead {
@apply !normal-case;
}
}

View File

@@ -1,35 +0,0 @@
@plugin "flyonui/theme" {
name: "moonlight";
prefersdark: true;
color-scheme: "dark";
--color-base-100: #192032;
--color-base-200: #101522;
--color-base-300: #070a14;
--color-base-content: #dde5f5;
--color-primary: oklch(.511 .262 276.966);
--color-primary-content: #dde5f5;
--color-secondary: oklch(37% 0.034 259.733);
--color-secondary-content: #dde5f5;
--color-accent: oklch(49.1% 0.27 292.581);
--color-accent-content: #dde5f5;
--color-neutral: oklch(27% 0.041 260.031);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(.546 .245 262.881);
--color-info-content: #dde5f5;
--color-success: oklch(.627 .194 149.214);
--color-success-content: #dde5f5;
--color-warning: oklch(.828 .189 84.429);
--color-warning-content: #dde5f5;
--color-error: oklch(.586 .253 17.585);
--color-error-content: #dde5f5;
--radius-selector: 0.25rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}

View File

@@ -1,22 +0,0 @@
{
"name": "Moonlight Client",
"short_name": "Moonlight.Client",
"id": "./",
"start_url": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#030b1f",
"prefer_related_applications": false,
"icons": [
{
"src": "img/icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "img/icon-192.png",
"type": "image/png",
"sizes": "192x192"
}
]
}

View File

@@ -1,4 +0,0 @@
// In development, always fetch from the network and do not enable offline support.
// This is because caching would make development more difficult (changes would not
// be reflected on the first load after each change).
self.addEventListener('fetch', () => { });

View File

@@ -1,66 +0,0 @@
// Caution! Be sure you understand the caveats before publishing an application with
// offline support. See https://aka.ms/blazor-offline-considerations
self.importScripts('./service-worker-assets.js');
self.addEventListener('install', event => event.waitUntil(onInstall(event)));
self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
const offlineAssetsInclude = [/\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/];
const offlineAssetsExclude = [/^service-worker\.js$/];
// Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'.
const base = "/";
const baseUrl = new URL(base, self.origin);
const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href);
// Add assets which are only available on the server and should not be cached here
const serverOnlyResources = [
"/api",
"/oauth2"
];
async function onInstall(event) {
console.info('Service worker: Install');
// Fetch and cache all matching items from the assets manifest
const assetsRequests = self.assetsManifest.assets
.filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
.filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
.map(asset => new Request(asset.url, {integrity: asset.hash, cache: 'no-cache'}));
await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
}
async function onActivate(event) {
console.info('Service worker: Activate');
// Delete unused caches
const cacheKeys = await caches.keys();
await Promise.all(cacheKeys
.filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
.map(key => caches.delete(key)));
}
async function onFetch(event) {
let cachedResponse = null;
if (event.request.method === 'GET') {
// For all navigation requests, try to serve index.html from cache,
// unless that request is for an offline resource.
const path = new URL(event.request.url).pathname;
const shouldServeIndexHtml =
event.request.mode === 'navigate' &&
!manifestUrlList.some(url => url === event.request.url) &&
!serverOnlyResources.some(url => path.startsWith(url)); // Check for server side assets
const request = shouldServeIndexHtml ? 'index.html' : event.request;
const cache = await caches.open(cacheName);
cachedResponse = await cache.match(request);
}
return cachedResponse || fetch(event.request);
}

View File

@@ -1,6 +0,0 @@
// Global using directives
global using Microsoft.AspNetCore.Components.Web;
global using Microsoft.JSInterop;
global using Microsoft.Extensions.Logging;
global using MoonCore.Blazor.FlyonUi.Components;

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