30 Commits

Author SHA1 Message Date
0cc35300f1 Updated mooncore styles. Adjusted theme editor and theme loading. Changed versions Upgraded mooncore.blazor.flyonui. Made moonlight flyonui/daisyui compatible 2025-10-27 08:23:02 +00:00
2f21806bea Upgraded mooncore. Regenerated mappings. Updated versions 2025-10-20 20:42:54 +02:00
c5d75a8710 Updated package versions 2025-10-20 17:30:00 +00:00
f3dd37f649 Removed pull request trigger from nuget build action 2025-10-20 17:27:59 +00:00
b035dd6b76 Added filter for csproj files for nuget package publish action 2025-10-20 17:26:46 +00:00
34c4bb8cb7 Updated theme and styles 2025-10-20 19:19:11 +02:00
de5c9f4ea1 Upgraded mooncore deendencies 2025-10-20 19:19:11 +02:00
9ab69ffef5 Upgraded mooncore versions. Cleaned up code, especially startup code. Changed versions 2025-10-05 16:07:27 +00:00
d2ef59d171 Merge pull request #457
Updated dependencies. Refactored to async scheme
2025-09-21 19:24:11 +02:00
8e2b333f47 Updated Moonlight nuget versions. Regenrated mappings 2025-09-21 17:20:33 +00:00
594fb3073f Updated to latest mooncore version for xml docs 2025-09-21 17:17:21 +00:00
3e87d5c140 Switched to database scheme seperation from MoonCores SingleDb. Updated mooncore versions. Updating to correct Async naming 2025-09-21 16:44:01 +00:00
86bec7f2ee Updated to latest mooncore version. Cleaned up some crud controllers and replaced DataTable with the new DataGrid component 2025-09-16 12:09:20 +00:00
8e242dc8da Merge pull request #456
Implemented SignalR (+ Scaling)
2025-09-16 10:04:20 +02:00
efca9cf5d8 Implemented SignalR scaling using redis. Improved diagnose report generator. Added SignalR debug card in Diagnose page 2025-09-16 08:02:53 +00:00
8573fffaa2 Updated dependencies. Changed version. Fixed small file manager archive format issue 2025-09-06 18:42:28 +02:00
51aeb67ad6 Improved paged endpoint rage validation. Fixed smaller request model validation issues 2025-08-26 01:52:43 +02:00
5e371edf2b Updated versions 2025-08-26 01:30:45 +02:00
d46ad72cb6 Merge pull request #454 from Moonlight-Panel/v2.1_FileManager
Extended file manager to support the new interfaces for downloading via url. Improved the handling of compressing and decompressing. Separated file manager controllers. Updated mooncore versions
2025-08-26 01:09:17 +02:00
a6ae2aacfb Extended file manager to support the new interfaces for downloading via url. Improved the handling of compressing and decompressing. Seperated file manager controllers. Updated mooncore versions 2025-08-26 01:07:59 +02:00
dc862e4b3c Removed unused pwa build option from razor class library project 2025-08-24 11:59:00 +02:00
e56c5edfb4 Merge pull request #453
Implemented theme importing and exporting
2025-08-23 23:43:06 +02:00
70b310adef Implemented theme importing and exporting 2025-08-23 23:39:56 +02:00
6748288f3c Merge pull request #452 from Moonlight-Panel/v2.1_OpenTelemetry
Added open telemetry exporter to existing metric system. Improved config section for metrics
2025-08-23 22:57:19 +02:00
2c5d45e9c2 Added open telemetry exporter to existing metric system. Improved config section for metrics 2025-08-23 22:09:00 +02:00
c02c13bf90 Bumped versions 2025-08-23 20:25:08 +02:00
902ca114c1 Merge pull request #451 from Moonlight-Panel/v2.1_ImproveAuth
Improved authentication
2025-08-20 17:20:10 +02:00
17cd039c9b Improved design of login method selection screen 2025-08-20 17:16:19 +02:00
26f955fce2 Added extendability to the sign-in / sync, the session validation and the frontend claims transfer calls 2025-08-20 17:01:42 +02:00
3cc48fb8f7 Updated MoonCore dependencies. Switched to asp.net core native authentication scheme abstractions. Updated claim usage in frontend 2025-08-20 16:16:31 +02:00
147 changed files with 3863 additions and 3809 deletions

View File

@@ -3,6 +3,9 @@ name: Build and Publish NuGet Package
on: on:
push: push:
branches: [ v2_ChangeArchitecture,v2.1 ] branches: [ v2_ChangeArchitecture,v2.1 ]
paths:
- '**.csproj'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@@ -13,9 +13,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.8" /> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9" />
</ItemGroup> </ItemGroup>
<Import Project="Plugins.props" /> <Import Project="Plugins.props" />

View File

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

View File

@@ -1,24 +1,37 @@
using MoonCore.Helpers; using MoonCore.Helpers;
using Moonlight.ApiServer.Implementations.LocalAuth;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
namespace Moonlight.ApiServer.Configuration; namespace Moonlight.ApiServer.Configuration;
public record AppConfiguration public record AppConfiguration
{ {
[YamlMember(Description = "The public url your instance should be accessible through")] [YamlMember(Description = "Moonlight configuration\n\n\nThe public url your instance should be accessible through")]
public string PublicUrl { get; set; } = "http://localhost:5165"; public string PublicUrl { get; set; } = "http://localhost:5165";
[YamlMember(Description = "The credentials of the postgres which moonlight should use")] [YamlMember(Description = "\nThe credentials of the postgres which moonlight should use")]
public DatabaseConfig Database { get; set; } = new(); public DatabaseConfig Database { get; set; } = new();
[YamlMember(Description = "Settings regarding authentication")] [YamlMember(Description = "\nSettings regarding authentication")]
public AuthenticationConfig Authentication { get; set; } = new(); public AuthenticationConfig Authentication { get; set; } = new();
[YamlMember(Description = "These options are only meant for development purposes")] [YamlMember(Description = "\nThese options are only meant for development purposes")]
public DevelopmentConfig Development { get; set; } = new(); public DevelopmentConfig Development { get; set; } = new();
[YamlMember(Description = "\nSettings for hosting the frontend")]
public FrontendData Frontend { get; set; } = new(); public FrontendData Frontend { get; set; } = new();
[YamlMember(Description = "\nSettings for the internal web server moonlight is running in")]
public KestrelConfig Kestrel { get; set; } = new(); public KestrelConfig Kestrel { get; set; } = new();
public MetricsData Metrics { 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() public static AppConfiguration CreateEmpty()
{ {
@@ -29,10 +42,29 @@ public record AppConfiguration
Kestrel = new() Kestrel = new()
{ {
AllowedOrigins = [] 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 public record FrontendData
{ {
[YamlMember(Description = "Enable the hosting of the frontend. Disable this if you only want to run the api server")] [YamlMember(Description = "Enable the hosting of the frontend. Disable this if you only want to run the api server")]
@@ -54,26 +86,21 @@ public record AppConfiguration
{ {
[YamlMember(Description = "The secret token to use for creating jwts and encrypting things. This needs to be at least 32 characters long")] [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); public string Secret { get; set; } = Formatter.GenerateString(32);
[YamlMember(Description = "The lifespan of generated user tokens in hours")]
public int TokenDuration { get; set; } = 24 * 10;
[YamlMember(Description = "This enables the use of the local oauth2 provider, so moonlight will use itself as an oauth2 provider")] [YamlMember(Description = "Settings for the user sessions")]
public bool EnableLocalOAuth2 { get; set; } = true; public SessionsConfig Sessions { get; set; } = new();
public OAuth2Data OAuth2 { get; set; } = new();
public record OAuth2Data [YamlMember(Description = "This specifies if the first registered/synced user will become an admin automatically")]
{ public bool FirstUserAdmin { get; set; } = true;
public string Secret { get; set; } = Formatter.GenerateString(32);
public string ClientId { get; set; } = Formatter.GenerateString(8);
public string ClientSecret { get; set; } = Formatter.GenerateString(32);
public string? AuthorizationEndpoint { get; set; }
public string? AccessEndpoint { get; set; }
public string? AuthorizationRedirect { get; set; }
[YamlMember(Description = "This specifies if the first registered user will become an admin automatically. This only works when using local oauth2")] [YamlMember(Description = "This specifies the authentication schemes the frontend should be able to challenge")]
public bool FirstUserAdmin { get; set; } = true; 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 public record DevelopmentConfig
@@ -91,12 +118,37 @@ public record AppConfiguration
public string[] AllowedOrigins { get; set; } = ["*"]; public string[] AllowedOrigins { get; set; } = ["*"];
} }
public record MetricsData public record OpenTelemetryData
{ {
[YamlMember(Description = "This enables the collecting of metrics and allows access to the /metrics endpoint")] [YamlMember(Description = "This enables open telemetry for moonlight")]
public bool Enable { get; set; } = false; 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")] [YamlMember(Description = "The interval in which metrics are created, specified in seconds")]
public int Interval { get; set; } = 15; 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,34 +1,47 @@
using Hangfire.EntityFrameworkCore; using Hangfire.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoonCore.Extended.SingleDb;
using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Models; using Moonlight.ApiServer.Models;
namespace Moonlight.ApiServer.Database; namespace Moonlight.ApiServer.Database;
public class CoreDataContext : DatabaseContext public class CoreDataContext : DbContext
{ {
public override string Prefix { get; } = "Core"; private readonly AppConfiguration Configuration;
public DbSet<User> Users { get; set; } public DbSet<User> Users { get; set; }
public DbSet<ApiKey> ApiKeys { get; set; } public DbSet<ApiKey> ApiKeys { get; set; }
public DbSet<Theme> Themes { get; set; } public DbSet<Theme> Themes { get; set; }
public CoreDataContext(AppConfiguration configuration) public CoreDataContext(AppConfiguration configuration)
{ {
Options = new() 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 =>
{ {
Host = configuration.Database.Host, builder.MigrationsHistoryTable("MigrationsHistory", "core");
Port = configuration.Database.Port, });
Username = configuration.Database.Username,
Password = configuration.Database.Password,
Database = configuration.Database.Database
};
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Model.SetDefaultSchema("core");
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.OnHangfireModelCreating(); modelBuilder.OnHangfireModelCreating();

View File

@@ -1,90 +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("20250226080942_AddedUsersAndApiKey")]
partial class AddedUsersAndApiKey
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Secret")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Core_ApiKeys", (string)null);
});
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.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("TokenValidTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Core_Users", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,59 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.ApiServer.Database.Migrations
{
/// <inheritdoc />
public partial class AddedUsersAndApiKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Core_ApiKeys",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Secret = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
PermissionsJson = table.Column<string>(type: "jsonb", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Core_ApiKeys", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Core_Users",
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<DateTime>(type: "timestamp with time zone", nullable: false),
PermissionsJson = table.Column<string>(type: "jsonb", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Core_Users", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Core_ApiKeys");
migrationBuilder.DropTable(
name: "Core_Users");
}
}
}

View File

@@ -1,89 +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("20250314095412_ModifiedApiKeyEntity")]
partial class ModifiedApiKeyEntity
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("Core_ApiKeys", (string)null);
});
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.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("TokenValidTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Core_Users", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,41 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.ApiServer.Database.Migrations
{
/// <inheritdoc />
public partial class ModifiedApiKeyEntity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Secret",
table: "Core_ApiKeys");
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "Core_ApiKeys",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "Core_ApiKeys");
migrationBuilder.AddColumn<string>(
name: "Secret",
table: "Core_ApiKeys",
type: "text",
nullable: false,
defaultValue: "");
}
}
}

View File

@@ -1,393 +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("20250405172522_AddedHangfireTables")]
partial class AddedHangfireTables
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("Core_ApiKeys", (string)null);
});
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.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("TokenValidTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Core_Users", (string)null);
});
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("Hangfire.EntityFrameworkCore.HangfireJob", b =>
{
b.Navigation("Parameters");
b.Navigation("QueuedJobs");
b.Navigation("States");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,393 +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("20250712202608_SwitchedToPgArraysForPermissions")]
partial class SwitchedToPgArraysForPermissions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.7")
.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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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");
});
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("Core_ApiKeys", (string)null);
});
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("Core_Users", (string)null);
});
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("Hangfire.EntityFrameworkCore.HangfireJob", b =>
{
b.Navigation("Parameters");
b.Navigation("QueuedJobs");
b.Navigation("States");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,62 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.ApiServer.Database.Migrations
{
/// <inheritdoc />
public partial class SwitchedToPgArraysForPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PermissionsJson",
table: "Core_Users");
migrationBuilder.DropColumn(
name: "PermissionsJson",
table: "Core_ApiKeys");
migrationBuilder.AddColumn<string[]>(
name: "Permissions",
table: "Core_Users",
type: "text[]",
nullable: false,
defaultValue: new string[0]);
migrationBuilder.AddColumn<string[]>(
name: "Permissions",
table: "Core_ApiKeys",
type: "text[]",
nullable: false,
defaultValue: new string[0]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Permissions",
table: "Core_Users");
migrationBuilder.DropColumn(
name: "Permissions",
table: "Core_ApiKeys");
migrationBuilder.AddColumn<string>(
name: "PermissionsJson",
table: "Core_Users",
type: "jsonb",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "PermissionsJson",
table: "Core_ApiKeys",
type: "jsonb",
nullable: false,
defaultValue: "");
}
}
}

View File

@@ -1,41 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.ApiServer.Database.Migrations
{
/// <inheritdoc />
public partial class AddedThemes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Core_Themes",
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_Core_Themes", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Core_Themes");
}
}
}

View File

@@ -12,15 +12,16 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Moonlight.ApiServer.Database.Migrations namespace Moonlight.ApiServer.Database.Migrations
{ {
[DbContext(typeof(CoreDataContext))] [DbContext(typeof(CoreDataContext))]
[Migration("20250720203346_AddedThemes")] [Migration("20250919201409_RecreatedMigrationsForChangeOfSchema")]
partial class AddedThemes partial class RecreatedMigrationsForChangeOfSchema
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.7") .HasDefaultSchema("core")
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -50,7 +51,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("Key", "Value"); b.HasIndex("Key", "Value");
b.ToTable("HangfireCounter"); b.ToTable("HangfireCounter", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b =>
@@ -73,7 +74,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("ExpireAt"); b.HasIndex("ExpireAt");
b.ToTable("HangfireHash"); b.ToTable("HangfireHash", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
@@ -109,7 +110,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("StateName"); b.HasIndex("StateName");
b.ToTable("HangfireJob"); b.ToTable("HangfireJob", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b =>
@@ -126,7 +127,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("JobId", "Name"); b.HasKey("JobId", "Name");
b.ToTable("HangfireJobParameter"); b.ToTable("HangfireJobParameter", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b =>
@@ -148,7 +149,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("ExpireAt"); b.HasIndex("ExpireAt");
b.ToTable("HangfireList"); b.ToTable("HangfireList", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b =>
@@ -162,7 +163,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("HangfireLock"); b.ToTable("HangfireLock", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b =>
@@ -191,7 +192,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("Queue", "FetchedAt"); b.HasIndex("Queue", "FetchedAt");
b.ToTable("HangfireQueuedJob"); b.ToTable("HangfireQueuedJob", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireServer", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireServer", b =>
@@ -217,7 +218,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("Heartbeat"); b.HasIndex("Heartbeat");
b.ToTable("HangfireServer"); b.ToTable("HangfireServer", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b =>
@@ -242,7 +243,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("Key", "Score"); b.HasIndex("Key", "Score");
b.ToTable("HangfireSet"); b.ToTable("HangfireSet", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b =>
@@ -275,7 +276,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("JobId"); b.HasIndex("JobId");
b.ToTable("HangfireState"); b.ToTable("HangfireState", "core");
}); });
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b => modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b =>
@@ -302,7 +303,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Core_ApiKeys", (string)null); b.ToTable("ApiKeys", "core");
}); });
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.Theme", b => modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.Theme", b =>
@@ -336,7 +337,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Core_Themes", (string)null); b.ToTable("Themes", "core");
}); });
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b => modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
@@ -368,7 +369,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Core_Users", (string)null); b.ToTable("Users", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
@@ -515,11 +516,11 @@ namespace Moonlight.ApiServer.Database.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b1.Property<float>("Depth") b1.Property<int>("Depth")
.HasColumnType("real"); .HasColumnType("integer");
b1.Property<float>("Noise") b1.Property<int>("Noise")
.HasColumnType("real"); .HasColumnType("integer");
b1.Property<float>("RadiusBox") b1.Property<float>("RadiusBox")
.HasColumnType("real"); .HasColumnType("real");
@@ -538,7 +539,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b1.HasKey("ThemeId"); b1.HasKey("ThemeId");
b1.ToTable("Core_Themes"); b1.ToTable("Themes", "core");
b1.ToJson("Content"); b1.ToJson("Content");

View File

@@ -7,13 +7,34 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Moonlight.ApiServer.Database.Migrations namespace Moonlight.ApiServer.Database.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddedHangfireTables : Migration public partial class RecreatedMigrationsForChangeOfSchema : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) 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( migrationBuilder.CreateTable(
name: "HangfireCounter", name: "HangfireCounter",
schema: "core",
columns: table => new columns: table => new
{ {
Id = table.Column<long>(type: "bigint", nullable: false) Id = table.Column<long>(type: "bigint", nullable: false)
@@ -29,6 +50,7 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "HangfireHash", name: "HangfireHash",
schema: "core",
columns: table => new columns: table => new
{ {
Key = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), Key = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
@@ -43,6 +65,7 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "HangfireList", name: "HangfireList",
schema: "core",
columns: table => new columns: table => new
{ {
Key = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), Key = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
@@ -57,6 +80,7 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "HangfireLock", name: "HangfireLock",
schema: "core",
columns: table => new columns: table => new
{ {
Id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), Id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
@@ -69,6 +93,7 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "HangfireServer", name: "HangfireServer",
schema: "core",
columns: table => new columns: table => new
{ {
Id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), Id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
@@ -84,6 +109,7 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "HangfireSet", name: "HangfireSet",
schema: "core",
columns: table => new columns: table => new
{ {
Key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), Key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
@@ -96,8 +122,47 @@ namespace Moonlight.ApiServer.Database.Migrations
table.PrimaryKey("PK_HangfireSet", x => new { x.Key, x.Value }); 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( migrationBuilder.CreateTable(
name: "HangfireJob", name: "HangfireJob",
schema: "core",
columns: table => new columns: table => new
{ {
Id = table.Column<long>(type: "bigint", nullable: false) Id = table.Column<long>(type: "bigint", nullable: false)
@@ -115,6 +180,7 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "HangfireJobParameter", name: "HangfireJobParameter",
schema: "core",
columns: table => new columns: table => new
{ {
JobId = table.Column<long>(type: "bigint", nullable: false), JobId = table.Column<long>(type: "bigint", nullable: false),
@@ -127,6 +193,7 @@ namespace Moonlight.ApiServer.Database.Migrations
table.ForeignKey( table.ForeignKey(
name: "FK_HangfireJobParameter_HangfireJob_JobId", name: "FK_HangfireJobParameter_HangfireJob_JobId",
column: x => x.JobId, column: x => x.JobId,
principalSchema: "core",
principalTable: "HangfireJob", principalTable: "HangfireJob",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
@@ -134,6 +201,7 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "HangfireQueuedJob", name: "HangfireQueuedJob",
schema: "core",
columns: table => new columns: table => new
{ {
Id = table.Column<long>(type: "bigint", nullable: false) Id = table.Column<long>(type: "bigint", nullable: false)
@@ -148,6 +216,7 @@ namespace Moonlight.ApiServer.Database.Migrations
table.ForeignKey( table.ForeignKey(
name: "FK_HangfireQueuedJob_HangfireJob_JobId", name: "FK_HangfireQueuedJob_HangfireJob_JobId",
column: x => x.JobId, column: x => x.JobId,
principalSchema: "core",
principalTable: "HangfireJob", principalTable: "HangfireJob",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
@@ -155,6 +224,7 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "HangfireState", name: "HangfireState",
schema: "core",
columns: table => new columns: table => new
{ {
Id = table.Column<long>(type: "bigint", nullable: false) Id = table.Column<long>(type: "bigint", nullable: false)
@@ -171,6 +241,7 @@ namespace Moonlight.ApiServer.Database.Migrations
table.ForeignKey( table.ForeignKey(
name: "FK_HangfireState_HangfireJob_JobId", name: "FK_HangfireState_HangfireJob_JobId",
column: x => x.JobId, column: x => x.JobId,
principalSchema: "core",
principalTable: "HangfireJob", principalTable: "HangfireJob",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
@@ -178,73 +249,88 @@ namespace Moonlight.ApiServer.Database.Migrations
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireCounter_ExpireAt", name: "IX_HangfireCounter_ExpireAt",
schema: "core",
table: "HangfireCounter", table: "HangfireCounter",
column: "ExpireAt"); column: "ExpireAt");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireCounter_Key_Value", name: "IX_HangfireCounter_Key_Value",
schema: "core",
table: "HangfireCounter", table: "HangfireCounter",
columns: new[] { "Key", "Value" }); columns: new[] { "Key", "Value" });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireHash_ExpireAt", name: "IX_HangfireHash_ExpireAt",
schema: "core",
table: "HangfireHash", table: "HangfireHash",
column: "ExpireAt"); column: "ExpireAt");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireJob_ExpireAt", name: "IX_HangfireJob_ExpireAt",
schema: "core",
table: "HangfireJob", table: "HangfireJob",
column: "ExpireAt"); column: "ExpireAt");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireJob_StateId", name: "IX_HangfireJob_StateId",
schema: "core",
table: "HangfireJob", table: "HangfireJob",
column: "StateId"); column: "StateId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireJob_StateName", name: "IX_HangfireJob_StateName",
schema: "core",
table: "HangfireJob", table: "HangfireJob",
column: "StateName"); column: "StateName");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireList_ExpireAt", name: "IX_HangfireList_ExpireAt",
schema: "core",
table: "HangfireList", table: "HangfireList",
column: "ExpireAt"); column: "ExpireAt");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireQueuedJob_JobId", name: "IX_HangfireQueuedJob_JobId",
schema: "core",
table: "HangfireQueuedJob", table: "HangfireQueuedJob",
column: "JobId"); column: "JobId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireQueuedJob_Queue_FetchedAt", name: "IX_HangfireQueuedJob_Queue_FetchedAt",
schema: "core",
table: "HangfireQueuedJob", table: "HangfireQueuedJob",
columns: new[] { "Queue", "FetchedAt" }); columns: new[] { "Queue", "FetchedAt" });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireServer_Heartbeat", name: "IX_HangfireServer_Heartbeat",
schema: "core",
table: "HangfireServer", table: "HangfireServer",
column: "Heartbeat"); column: "Heartbeat");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireSet_ExpireAt", name: "IX_HangfireSet_ExpireAt",
schema: "core",
table: "HangfireSet", table: "HangfireSet",
column: "ExpireAt"); column: "ExpireAt");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireSet_Key_Score", name: "IX_HangfireSet_Key_Score",
schema: "core",
table: "HangfireSet", table: "HangfireSet",
columns: new[] { "Key", "Score" }); columns: new[] { "Key", "Score" });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_HangfireState_JobId", name: "IX_HangfireState_JobId",
schema: "core",
table: "HangfireState", table: "HangfireState",
column: "JobId"); column: "JobId");
migrationBuilder.AddForeignKey( migrationBuilder.AddForeignKey(
name: "FK_HangfireJob_HangfireState_StateId", name: "FK_HangfireJob_HangfireState_StateId",
schema: "core",
table: "HangfireJob", table: "HangfireJob",
column: "StateId", column: "StateId",
principalSchema: "core",
principalTable: "HangfireState", principalTable: "HangfireState",
principalColumn: "Id"); principalColumn: "Id");
} }
@@ -254,37 +340,60 @@ namespace Moonlight.ApiServer.Database.Migrations
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
name: "FK_HangfireJob_HangfireState_StateId", name: "FK_HangfireJob_HangfireState_StateId",
schema: "core",
table: "HangfireJob"); table: "HangfireJob");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireCounter"); name: "ApiKeys",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireHash"); name: "HangfireCounter",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireJobParameter"); name: "HangfireHash",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireList"); name: "HangfireJobParameter",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireLock"); name: "HangfireList",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireQueuedJob"); name: "HangfireLock",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireServer"); name: "HangfireQueuedJob",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireSet"); name: "HangfireServer",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireState"); name: "HangfireSet",
schema: "core");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "HangfireJob"); name: "Themes",
schema: "core");
migrationBuilder.DropTable(
name: "Users",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireState",
schema: "core");
migrationBuilder.DropTable(
name: "HangfireJob",
schema: "core");
} }
} }
} }

View File

@@ -17,7 +17,8 @@ namespace Moonlight.ApiServer.Database.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.7") .HasDefaultSchema("core")
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -47,7 +48,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("Key", "Value"); b.HasIndex("Key", "Value");
b.ToTable("HangfireCounter"); b.ToTable("HangfireCounter", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b =>
@@ -70,7 +71,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("ExpireAt"); b.HasIndex("ExpireAt");
b.ToTable("HangfireHash"); b.ToTable("HangfireHash", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
@@ -106,7 +107,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("StateName"); b.HasIndex("StateName");
b.ToTable("HangfireJob"); b.ToTable("HangfireJob", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJobParameter", b =>
@@ -123,7 +124,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("JobId", "Name"); b.HasKey("JobId", "Name");
b.ToTable("HangfireJobParameter"); b.ToTable("HangfireJobParameter", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b =>
@@ -145,7 +146,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("ExpireAt"); b.HasIndex("ExpireAt");
b.ToTable("HangfireList"); b.ToTable("HangfireList", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b =>
@@ -159,7 +160,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("HangfireLock"); b.ToTable("HangfireLock", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b =>
@@ -188,7 +189,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("Queue", "FetchedAt"); b.HasIndex("Queue", "FetchedAt");
b.ToTable("HangfireQueuedJob"); b.ToTable("HangfireQueuedJob", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireServer", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireServer", b =>
@@ -214,7 +215,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("Heartbeat"); b.HasIndex("Heartbeat");
b.ToTable("HangfireServer"); b.ToTable("HangfireServer", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b =>
@@ -239,7 +240,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("Key", "Score"); b.HasIndex("Key", "Score");
b.ToTable("HangfireSet"); b.ToTable("HangfireSet", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireState", b =>
@@ -272,7 +273,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasIndex("JobId"); b.HasIndex("JobId");
b.ToTable("HangfireState"); b.ToTable("HangfireState", "core");
}); });
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b => modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b =>
@@ -299,7 +300,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Core_ApiKeys", (string)null); b.ToTable("ApiKeys", "core");
}); });
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.Theme", b => modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.Theme", b =>
@@ -333,7 +334,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Core_Themes", (string)null); b.ToTable("Themes", "core");
}); });
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b => modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
@@ -365,7 +366,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Core_Users", (string)null); b.ToTable("Users", "core");
}); });
modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b =>
@@ -512,11 +513,11 @@ namespace Moonlight.ApiServer.Database.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b1.Property<float>("Depth") b1.Property<int>("Depth")
.HasColumnType("real"); .HasColumnType("integer");
b1.Property<float>("Noise") b1.Property<int>("Noise")
.HasColumnType("real"); .HasColumnType("integer");
b1.Property<float>("RadiusBox") b1.Property<float>("RadiusBox")
.HasColumnType("real"); .HasColumnType("real");
@@ -535,7 +536,7 @@ namespace Moonlight.ApiServer.Database.Migrations
b1.HasKey("ThemeId"); b1.HasKey("ThemeId");
b1.ToTable("Core_Themes"); b1.ToTable("Themes", "core");
b1.ToJson("Content"); b1.ToJson("Content");

View File

@@ -5,7 +5,7 @@ namespace Moonlight.ApiServer.Extensions;
public static class ZipArchiveExtensions public static class ZipArchiveExtensions
{ {
public static async Task AddBinary(this ZipArchive archive, string name, byte[] bytes) public static async Task AddBinaryAsync(this ZipArchive archive, string name, byte[] bytes)
{ {
var entry = archive.CreateEntry(name); var entry = archive.CreateEntry(name);
await using var dataStream = entry.Open(); await using var dataStream = entry.Open();
@@ -14,13 +14,13 @@ public static class ZipArchiveExtensions
await dataStream.FlushAsync(); await dataStream.FlushAsync();
} }
public static async Task AddText(this ZipArchive archive, string name, string content) public static async Task AddTextAsync(this ZipArchive archive, string name, string content)
{ {
var data = Encoding.UTF8.GetBytes(content); var data = Encoding.UTF8.GetBytes(content);
await archive.AddBinary(name, data); await archive.AddBinaryAsync(name, data);
} }
public static async Task AddFile(this ZipArchive archive, string name, string path) public static async Task AddFileAsync(this ZipArchive archive, string name, string path)
{ {
var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);

View File

@@ -0,0 +1,25 @@
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,11 +1,10 @@
using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoonCore.Exceptions; using MoonCore.Common;
using MoonCore.Extended.Abstractions; using MoonCore.Extended.Abstractions;
using MoonCore.Models;
using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Mappers;
using Moonlight.ApiServer.Services; using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys; using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys; using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
@@ -27,72 +26,82 @@ public class ApiKeysController : Controller
[HttpGet] [HttpGet]
[Authorize(Policy = "permissions:admin.apikeys.get")] [Authorize(Policy = "permissions:admin.apikeys.get")]
public async Task<IPagedData<ApiKeyResponse>> Get( public async Task<ActionResult<CountedData<ApiKeyResponse>>> GetAsync(
[FromQuery] [Range(0, int.MaxValue)] int page, [FromQuery] int startIndex,
[FromQuery] [Range(1, 100)] int pageSize [FromQuery] int count,
[FromQuery] string? orderBy,
[FromQuery] string? filter,
[FromQuery] string orderByDir = "asc"
) )
{ {
var count = await ApiKeyRepository.Get().CountAsync(); if (count > 100)
return Problem("You cannot fetch more items than 100 at a time", statusCode: 400);
IQueryable<ApiKey> query = ApiKeyRepository.Get();
var apiKeys = await ApiKeyRepository query = orderBy switch
.Get() {
.OrderBy(x => x.Id) nameof(ApiKey.Id) => orderByDir == "desc"
.Skip(page * pageSize) ? query.OrderByDescending(x => x.Id)
.Take(pageSize) : 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(); .ToArrayAsync();
var mappedApiKey = apiKeys return new CountedData<ApiKeyResponse>()
.Select(x => new ApiKeyResponse()
{
Id = x.Id,
Permissions = x.Permissions,
Description = x.Description,
ExpiresAt = x.ExpiresAt
})
.ToArray();
return new PagedData<ApiKeyResponse>()
{ {
CurrentPage = page, Items = items,
Items = mappedApiKey, TotalCount = totalCount
PageSize = pageSize,
TotalItems = count,
TotalPages = count == 0 ? 0 : (count - 1) / pageSize
}; };
} }
[HttpGet("{id}")] [HttpGet("{id:int}")]
[Authorize(Policy = "permissions:admin.apikeys.get")] [Authorize(Policy = "permissions:admin.apikeys.get")]
public async Task<ApiKeyResponse> GetSingle(int id) public async Task<ActionResult<ApiKeyResponse>> GetSingleAsync(int id)
{ {
var apiKey = await ApiKeyRepository var apiKey = await ApiKeyRepository
.Get() .Get()
.AsNoTracking()
.ProjectToResponse()
.FirstOrDefaultAsync(x => x.Id == id); .FirstOrDefaultAsync(x => x.Id == id);
if (apiKey == null) if (apiKey == null)
throw new HttpApiException("No api key with that id found", 404); return Problem("No api key with that id found", statusCode: 404);
return new ApiKeyResponse() return apiKey;
{
Id = apiKey.Id,
Permissions = apiKey.Permissions,
Description = apiKey.Description,
ExpiresAt = apiKey.ExpiresAt
};
} }
[HttpPost] [HttpPost]
[Authorize(Policy = "permissions:admin.apikeys.create")] [Authorize(Policy = "permissions:admin.apikeys.create")]
public async Task<CreateApiKeyResponse> Create([FromBody] CreateApiKeyRequest request) public async Task<CreateApiKeyResponse> CreateAsync([FromBody] CreateApiKeyRequest request)
{ {
var apiKey = new ApiKey() var apiKey = ApiKeyMapper.ToApiKey(request);
{
Description = request.Description, var finalApiKey = await ApiKeyRepository.AddAsync(apiKey);
Permissions = request.Permissions,
ExpiresAt = request.ExpiresAt
};
var finalApiKey = await ApiKeyRepository.Add(apiKey);
var response = new CreateApiKeyResponse var response = new CreateApiKeyResponse
{ {
@@ -106,41 +115,36 @@ public class ApiKeysController : Controller
return response; return response;
} }
[HttpPatch("{id}")] [HttpPatch("{id:int}")]
[Authorize(Policy = "permissions:admin.apikeys.update")] [Authorize(Policy = "permissions:admin.apikeys.update")]
public async Task<ApiKeyResponse> Update([FromRoute] int id, [FromBody] UpdateApiKeyRequest request) public async Task<ActionResult<ApiKeyResponse>> UpdateAsync([FromRoute] int id, [FromBody] UpdateApiKeyRequest request)
{ {
var apiKey = await ApiKeyRepository var apiKey = await ApiKeyRepository
.Get() .Get()
.FirstOrDefaultAsync(x => x.Id == id); .FirstOrDefaultAsync(x => x.Id == id);
if (apiKey == null) if (apiKey == null)
throw new HttpApiException("No api key with that id found", 404); return Problem("No api key with that id found", statusCode: 404);
apiKey.Description = request.Description; ApiKeyMapper.Merge(apiKey, request);
await ApiKeyRepository.Update(apiKey); await ApiKeyRepository.UpdateAsync(apiKey);
return new ApiKeyResponse() return ApiKeyMapper.ToResponse(apiKey);
{
Id = apiKey.Id,
Description = apiKey.Description,
Permissions = apiKey.Permissions,
ExpiresAt = apiKey.ExpiresAt
};
} }
[HttpDelete("{id}")] [HttpDelete("{id:int}")]
[Authorize(Policy = "permissions:admin.apikeys.delete")] [Authorize(Policy = "permissions:admin.apikeys.delete")]
public async Task Delete([FromRoute] int id) public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{ {
var apiKey = await ApiKeyRepository var apiKey = await ApiKeyRepository
.Get() .Get()
.FirstOrDefaultAsync(x => x.Id == id); .FirstOrDefaultAsync(x => x.Id == id);
if (apiKey == null) if (apiKey == null)
throw new HttpApiException("No api key with that id found", 404); return Problem("No api key with that id found", statusCode: 404);
await ApiKeyRepository.Remove(apiKey); await ApiKeyRepository.RemoveAsync(apiKey);
return NoContent();
} }
} }

View File

@@ -19,9 +19,9 @@ public class AdvancedController : Controller
[HttpGet("frontend")] [HttpGet("frontend")]
[Authorize(Policy = "permissions:admin.system.advanced.frontend")] [Authorize(Policy = "permissions:admin.system.advanced.frontend")]
public async Task Frontend() public async Task FrontendAsync()
{ {
var stream = await FrontendService.GenerateZip(); var stream = await FrontendService.GenerateZipAsync();
await Results.File(stream, fileDownloadName: "frontend.zip").ExecuteAsync(HttpContext); await Results.File(stream, fileDownloadName: "frontend.zip").ExecuteAsync(HttpContext);
} }
} }

View File

@@ -1,10 +1,8 @@
using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoonCore.Exceptions; using MoonCore.Common;
using MoonCore.Extended.Abstractions; using MoonCore.Extended.Abstractions;
using MoonCore.Models;
using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Mappers; using Moonlight.ApiServer.Mappers;
using Moonlight.Shared.Http.Requests.Admin.Sys.Theme; using Moonlight.Shared.Http.Requests.Admin.Sys.Theme;
@@ -25,68 +23,96 @@ public class ThemesController : Controller
[HttpGet] [HttpGet]
[Authorize(Policy = "permissions:admin.system.customisation.themes.read")] [Authorize(Policy = "permissions:admin.system.customisation.themes.read")]
public async Task<PagedData<ThemeResponse>> Get( public async Task<ActionResult<CountedData<ThemeResponse>>> GetAsync(
[FromQuery] [Range(0, int.MaxValue)] int page, [FromQuery] int startIndex,
[FromQuery] [Range(1, 100)] int pageSize [FromQuery] int count,
[FromQuery] string? orderBy,
[FromQuery] string? filter,
[FromQuery] string orderByDir = "asc"
) )
{ {
var count = await ThemeRepository.Get().CountAsync(); if (count > 100)
return Problem("You cannot fetch more items than 100 at a time", statusCode: 400);
var items = await ThemeRepository IQueryable<Theme> query = ThemeRepository.Get();
.Get()
.Skip(page * pageSize) query = orderBy switch
.Take(pageSize)
.ToArrayAsync();
var mappedItems = items
.Select(ThemeMapper.ToResponse)
.ToArray();
return new PagedData<ThemeResponse>()
{ {
CurrentPage = page, nameof(Theme.Id) => orderByDir == "desc"
Items = mappedItems, ? query.OrderByDescending(x => x.Id)
PageSize = pageSize, : query.OrderBy(x => x.Id),
TotalItems = count,
TotalPages = count == 0 ? 0 : (count - 1) / pageSize 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}")] [HttpGet("{id:int}")]
[Authorize(Policy = "permissions:admin.system.customisation.themes.read")] [Authorize(Policy = "permissions:admin.system.customisation.themes.read")]
public async Task<ThemeResponse> GetSingle([FromRoute] int id) public async Task<ActionResult<ThemeResponse>> GetSingleAsync([FromRoute] int id)
{ {
var theme = await ThemeRepository var theme = await ThemeRepository
.Get() .Get()
.AsNoTracking()
.ProjectToResponse()
.FirstOrDefaultAsync(t => t.Id == id); .FirstOrDefaultAsync(t => t.Id == id);
if (theme == null) if (theme == null)
throw new HttpApiException("Theme with this id not found", 404); return Problem("Theme with this id not found", statusCode: 404);
return ThemeMapper.ToResponse(theme); return theme;
} }
[HttpPost] [HttpPost]
[Authorize(Policy = "permissions:admin.system.customisation.themes.write")] [Authorize(Policy = "permissions:admin.system.customisation.themes.write")]
public async Task<ThemeResponse> Create([FromBody] CreateThemeRequest request) public async Task<ActionResult<ThemeResponse>> CreateAsync([FromBody] CreateThemeRequest request)
{ {
var theme = ThemeMapper.ToTheme(request); var theme = ThemeMapper.ToTheme(request);
var finalTheme = await ThemeRepository.Add(theme); var finalTheme = await ThemeRepository.AddAsync(theme);
return ThemeMapper.ToResponse(finalTheme); return ThemeMapper.ToResponse(finalTheme);
} }
[HttpPatch("{id:int}")] [HttpPatch("{id:int}")]
[Authorize(Policy = "permissions:admin.system.customisation.themes.write")] [Authorize(Policy = "permissions:admin.system.customisation.themes.write")]
public async Task<ThemeResponse> Update([FromRoute] int id, [FromBody] UpdateThemeRequest request) public async Task<ActionResult<ThemeResponse>> UpdateAsync([FromRoute] int id, [FromBody] UpdateThemeRequest request)
{ {
var theme = await ThemeRepository var theme = await ThemeRepository
.Get() .Get()
.FirstOrDefaultAsync(t => t.Id == id); .FirstOrDefaultAsync(t => t.Id == id);
if (theme == null) if (theme == null)
throw new HttpApiException("Theme with this id not found", 404); return Problem("Theme with this id not found", statusCode: 404);
// Disable all other enabled themes if we are enabling the current theme. // Disable all other enabled themes if we are enabling the current theme.
// This ensures only one theme is enabled at the time // This ensures only one theme is enabled at the time
@@ -100,29 +126,28 @@ public class ThemesController : Controller
foreach (var otherTheme in otherThemes) foreach (var otherTheme in otherThemes)
otherTheme.IsEnabled = false; otherTheme.IsEnabled = false;
await ThemeRepository.RunTransaction(set => await ThemeRepository.RunTransactionAsync(set => { set.UpdateRange(otherThemes); });
{
set.UpdateRange(otherThemes);
});
} }
ThemeMapper.Merge(theme, request); ThemeMapper.Merge(theme, request);
await ThemeRepository.Update(theme);
await ThemeRepository.UpdateAsync(theme);
return ThemeMapper.ToResponse(theme); return ThemeMapper.ToResponse(theme);
} }
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[Authorize(Policy = "permissions:admin.system.customisation.themes.write")] [Authorize(Policy = "permissions:admin.system.customisation.themes.write")]
public async Task Delete([FromRoute] int id) public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{ {
var theme = await ThemeRepository var theme = await ThemeRepository
.Get() .Get()
.FirstOrDefaultAsync(x => x.Id == id); .FirstOrDefaultAsync(x => x.Id == id);
if (theme == null) if (theme == null)
throw new HttpApiException("Theme with this id not found", 404); return Problem("Theme with this id not found", statusCode: 404);
await ThemeRepository.Remove(theme); await ThemeRepository.RemoveAsync(theme);
return NoContent();
} }
} }

View File

@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Moonlight.ApiServer.Services; using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Requests.Admin.Sys; using Moonlight.Shared.Http.Requests.Admin.Sys;
@@ -21,21 +20,16 @@ public class DiagnoseController : Controller
} }
[HttpPost] [HttpPost]
public async Task Diagnose([FromBody] GenerateDiagnoseRequest request) public async Task<ActionResult> DiagnoseAsync([FromBody] GenerateDiagnoseRequest request)
{ {
var stream = await DiagnoseService.GenerateDiagnose(request.Providers); var stream = await DiagnoseService.GenerateDiagnoseAsync(request.Providers);
await Results.Stream( return File(stream, "application/zip", "diagnose.zip");
stream,
contentType: "application/zip",
fileDownloadName: "diagnose.zip"
)
.ExecuteAsync(HttpContext);
} }
[HttpGet("providers")] [HttpGet("providers")]
public async Task<DiagnoseProvideResponse[]> GetProviders() public async Task<ActionResult<DiagnoseProvideResponse[]>> GetProvidersAsync()
{ {
return await DiagnoseService.GetProviders(); return await DiagnoseService.GetProvidersAsync();
} }
} }

View File

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

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

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

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

@@ -0,0 +1,192 @@
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,475 +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 MoonCore.Exceptions;
using MoonCore.Helpers;
using Moonlight.Shared.Http.Requests.Admin.Sys.Files;
using Moonlight.Shared.Http.Responses.Admin.Sys;
namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys;
[ApiController]
[Route("api/admin/system/files")]
[Authorize(Policy = "permissions:admin.system.files")]
public class FilesController : Controller
{
private readonly string BaseDirectory = "storage";
private readonly long MaxChunkSize = ByteConverter.FromMegaBytes(20).Bytes;
[HttpPost("touch")]
public async Task CreateFile([FromQuery] string path)
{
var safePath = 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 CreateFolder([FromQuery] string path)
{
var safePath = 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[]> List([FromQuery] string path)
{
var safePath = 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("upload")]
public async Task Upload([FromQuery] string path, [FromQuery] long chunkSize, [FromQuery] long totalSize, [FromQuery] int chunkId)
{
if (Request.Form.Files.Count != 1)
throw new HttpApiException("You need to provide exactly one file", 400);
var file = Request.Form.Files[0];
if (file.Length > chunkSize)
throw new HttpApiException("The provided data exceeds the chunk size limit", 400);
var chunks = totalSize / chunkSize;
chunks += totalSize % chunkSize > 0 ? 1 : 0;
if (chunkId > chunks)
throw new HttpApiException("Invalid chunk id: Out of bounds", 400);
var positionToSkipTo = chunkSize * chunkId;
var safePath = SanitizePath(path);
var physicalPath = Path.Combine(BaseDirectory, safePath);
var baseDir = Path.GetDirectoryName(physicalPath);
if (!string.IsNullOrEmpty(baseDir))
Directory.CreateDirectory(baseDir);
await using var fs = System.IO.File.Open(physicalPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
// This resizes the file to the correct size so we can handle the chunk if it didnt exist
if (fs.Length != totalSize)
fs.SetLength(totalSize);
fs.Position = positionToSkipTo;
var dataStream = file.OpenReadStream();
await dataStream.CopyToAsync(fs);
await fs.FlushAsync();
fs.Close();
}
[HttpPost("move")]
public Task Move([FromQuery] string oldPath, [FromQuery] string newPath)
{
var oldSafePath = SanitizePath(oldPath);
var newSafePath = 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 Delete([FromQuery] string path)
{
var safePath = 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;
}
[HttpGet("download")]
public async Task Download([FromQuery] string path)
{
var safePath = 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();
}
[HttpPost("compress")]
public async Task Compress([FromBody] CompressRequest request)
{
if (request.Type == "tar.gz")
await CompressTarGz(request.Path, request.ItemsToCompress);
else if (request.Type == "zip")
await CompressZip(request.Path, request.ItemsToCompress);
}
#region Tar Gz
private async Task CompressTarGz(string path, string[] itemsToCompress)
{
var safePath = SanitizePath(path);
var destination = Path.Combine(BaseDirectory, safePath);
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 itemName in itemsToCompress)
{
var safeFilePath = SanitizePath(itemName);
var filePath = Path.Combine(BaseDirectory, safeFilePath);
var fi = new FileInfo(filePath);
if (fi.Exists)
await AddFileToTarGz(tarStream, filePath);
else
{
var safeDirePath = SanitizePath(itemName);
var dirPath = Path.Combine(BaseDirectory, safeDirePath);
await AddDirectoryToTarGz(tarStream, dirPath);
}
}
await tarStream.FlushAsync();
await gzoStream.FlushAsync();
await outStream.FlushAsync();
tarStream.Close();
gzoStream.Close();
outStream.Close();
}
private async Task AddDirectoryToTarGz(TarOutputStream tarOutputStream, string root)
{
foreach (var file in Directory.GetFiles(root))
await AddFileToTarGz(tarOutputStream, file);
foreach (var directory in Directory.GetDirectories(root))
await AddDirectoryToTarGz(tarOutputStream, directory);
}
private async Task AddFileToTarGz(TarOutputStream tarOutputStream, string file)
{
// Open file stream
var fs = System.IO.File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
// Meta
var entry = TarEntry.CreateTarEntry(file);
// Fix path
entry.Name = Formatter
.ReplaceStart(entry.Name, BaseDirectory, "")
.TrimStart('/');
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();
}
#endregion
#region ZIP
private async Task CompressZip(string path, string[] itemsToCompress)
{
var safePath = SanitizePath(path);
var destination = Path.Combine(BaseDirectory, safePath);
await using var outStream = System.IO.File.Create(destination);
await using var zipOutputStream = new ZipOutputStream(outStream);
foreach (var itemName in itemsToCompress)
{
var safeFilePath = SanitizePath(itemName);
var filePath = Path.Combine(BaseDirectory, safeFilePath);
var fi = new FileInfo(filePath);
if (fi.Exists)
await AddFileToZip(zipOutputStream, filePath);
else
{
var safeDirePath = SanitizePath(itemName);
var dirPath = Path.Combine(BaseDirectory, safeDirePath);
await AddDirectoryToZip(zipOutputStream, dirPath);
}
}
await zipOutputStream.FlushAsync();
await outStream.FlushAsync();
zipOutputStream.Close();
outStream.Close();
}
private async Task AddFileToZip(ZipOutputStream zipOutputStream, string path)
{
// Open file stream
var fs = System.IO.File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
// Fix path
var name = Formatter
.ReplaceStart(path, BaseDirectory, "")
.TrimStart('/');
// Meta
var entry = new ZipEntry(name);
entry.Size = fs.Length;
// Write entry
await zipOutputStream.PutNextEntryAsync(entry, CancellationToken.None);
// Copy file content to tar stream
await fs.CopyToAsync(zipOutputStream);
fs.Close();
// Close the entry
zipOutputStream.CloseEntry();
}
private async Task AddDirectoryToZip(ZipOutputStream zipOutputStream, string root)
{
foreach (var file in Directory.GetFiles(root))
await AddFileToZip(zipOutputStream, file);
foreach (var directory in Directory.GetDirectories(root))
await AddDirectoryToZip(zipOutputStream, directory);
}
#endregion
[HttpPost("decompress")]
public async Task Decompress([FromBody] DecompressRequest request)
{
if (request.Type == "tar.gz")
await DecompressTarGz(request.Path, request.Destination);
else if (request.Type == "zip")
await DecompressZip(request.Path, request.Destination);
}
#region Tar Gz
private async Task DecompressTarGz(string path, string destination)
{
var safeDestination = SanitizePath(destination);
var safeArchivePath = SanitizePath(path);
var archivePath = Path.Combine(BaseDirectory, safeArchivePath);
await using var fs = System.IO.File.Open(archivePath, 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 = SanitizePath(entry.Name);
var fileDestination = Path.Combine(BaseDirectory, safeDestination, 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 DecompressZip(string path, string destination)
{
var safeDestination = SanitizePath(destination);
var safeArchivePath = SanitizePath(path);
var archivePath = Path.Combine(BaseDirectory, safeArchivePath);
await using var fs = System.IO.File.Open(archivePath, 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 = SanitizePath(entry.Name);
var fileDestination = Path.Combine(BaseDirectory, safeDestination, 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
private 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

@@ -18,7 +18,7 @@ public class HangfireController : Controller
} }
[HttpGet("stats")] [HttpGet("stats")]
public Task<HangfireStatsResponse> GetStats() public Task<HangfireStatsResponse> GetStatsAsync()
{ {
var statistics = JobStorage.GetMonitoringApi().GetStatistics(); var statistics = JobStorage.GetMonitoringApi().GetStatistics();

View File

@@ -18,21 +18,21 @@ public class SystemController : Controller
[HttpGet] [HttpGet]
[Authorize(Policy = "permissions:admin.system.overview")] [Authorize(Policy = "permissions:admin.system.overview")]
public async Task<SystemOverviewResponse> GetOverview() public async Task<SystemOverviewResponse> GetOverviewAsync()
{ {
return new() return new()
{ {
Uptime = await ApplicationService.GetUptime(), Uptime = await ApplicationService.GetUptimeAsync(),
CpuUsage = await ApplicationService.GetCpuUsage(), CpuUsage = await ApplicationService.GetCpuUsageAsync(),
MemoryUsage = await ApplicationService.GetMemoryUsage(), MemoryUsage = await ApplicationService.GetMemoryUsageAsync(),
OperatingSystem = await ApplicationService.GetOsName() OperatingSystem = await ApplicationService.GetOsNameAsync()
}; };
} }
[HttpPost("shutdown")] [HttpPost("shutdown")]
[Authorize(Policy = "permissions:admin.system.shutdown")] [Authorize(Policy = "permissions:admin.system.shutdown")]
public async Task Shutdown() public async Task ShutdownAsync()
{ {
await ApplicationService.Shutdown(); await ApplicationService.ShutdownAsync();
} }
} }

View File

@@ -1,14 +1,13 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MoonCore.Exceptions; using MoonCore.Common;
using MoonCore.Extended.Abstractions; using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers; using MoonCore.Extended.Helpers;
using MoonCore.Models;
using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Services; using Moonlight.ApiServer.Services;
using Moonlight.ApiServer.Mappers;
using Moonlight.Shared.Http.Requests.Admin.Users; using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users; using Moonlight.Shared.Http.Responses.Admin.Users;
@@ -27,63 +26,78 @@ public class UsersController : Controller
[HttpGet] [HttpGet]
[Authorize(Policy = "permissions:admin.users.get")] [Authorize(Policy = "permissions:admin.users.get")]
public async Task<IPagedData<UserResponse>> Get( public async Task<ActionResult<CountedData<UserResponse>>> GetAsync(
[FromQuery] [Range(0, int.MaxValue)] int page, [FromQuery] int startIndex,
[FromQuery] [Range(1, 100)] int pageSize [FromQuery] int count,
[FromQuery] string? orderBy,
[FromQuery] string? filter,
[FromQuery] string orderByDir = "asc"
) )
{ {
var count = await UserRepository.Get().CountAsync(); if (count > 100)
return Problem("You cannot fetch more items than 100 at a time", statusCode: 400);
IQueryable<User> query = UserRepository.Get();
var users = await UserRepository query = orderBy switch
.Get() {
.OrderBy(x => x.Id) nameof(Database.Entities.User.Id) => orderByDir == "desc"
.Skip(page * pageSize) ? query.OrderByDescending(x => x.Id)
.Take(pageSize) : 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(); .ToArrayAsync();
var mappedUsers = users return new CountedData<UserResponse>()
.Select(x => new UserResponse()
{
Id = x.Id,
Email = x.Email,
Username = x.Username,
Permissions = x.Permissions
})
.ToArray();
return new PagedData<UserResponse>()
{ {
CurrentPage = page, Items = items,
Items = mappedUsers, TotalCount = totalCount
PageSize = pageSize,
TotalItems = count,
TotalPages = count == 0 ? 0 : (count - 1) / pageSize
}; };
} }
[HttpGet("{id}")] [HttpGet("{id}")]
[Authorize(Policy = "permissions:admin.users.get")] [Authorize(Policy = "permissions:admin.users.get")]
public async Task<UserResponse> GetSingle(int id) public async Task<ActionResult<UserResponse>> GetSingleAsync(int id)
{ {
var user = await UserRepository var user = await UserRepository
.Get() .Get()
.ProjectToResponse()
.FirstOrDefaultAsync(x => x.Id == id); .FirstOrDefaultAsync(x => x.Id == id);
if (user == null) if (user == null)
throw new HttpApiException("No user with that id found", 404); return Problem("No user with that id found", statusCode: 404);
return new UserResponse() return user;
{
Id = user.Id,
Email = user.Email,
Username = user.Username,
Permissions = user.Permissions
};
} }
[HttpPost] [HttpPost]
[Authorize(Policy = "permissions:admin.users.create")] [Authorize(Policy = "permissions:admin.users.create")]
public async Task<UserResponse> Create([FromBody] CreateUserRequest request) public async Task<ActionResult<UserResponse>> CreateAsync([FromBody] CreateUserRequest request)
{ {
// Reformat values // Reformat values
request.Username = request.Username.ToLower().Trim(); request.Username = request.Username.ToLower().Trim();
@@ -91,10 +105,10 @@ public class UsersController : Controller
// Check for users with the same values // Check for users with the same values
if (UserRepository.Get().Any(x => x.Username == request.Username)) if (UserRepository.Get().Any(x => x.Username == request.Username))
throw new HttpApiException("A user with that username already exists", 400); return Problem("A user with that username already exists", statusCode: 400);
if (UserRepository.Get().Any(x => x.Email == request.Email)) if (UserRepository.Get().Any(x => x.Email == request.Email))
throw new HttpApiException("A user with that email address already exists", 400); return Problem("A user with that email address already exists", statusCode: 400);
var hashedPassword = HashHelper.Hash(request.Password); var hashedPassword = HashHelper.Hash(request.Password);
@@ -106,27 +120,21 @@ public class UsersController : Controller
Permissions = request.Permissions Permissions = request.Permissions
}; };
var finalUser = await UserRepository.Add(user); var finalUser = await UserRepository.AddAsync(user);
return new UserResponse() return UserMapper.ToResponse(finalUser);
{
Id = finalUser.Id,
Email = finalUser.Email,
Username = finalUser.Username,
Permissions = finalUser.Permissions
};
} }
[HttpPatch("{id}")] [HttpPatch("{id}")]
[Authorize(Policy = "permissions:admin.users.update")] [Authorize(Policy = "permissions:admin.users.update")]
public async Task<UserResponse> Update([FromRoute] int id, [FromBody] UpdateUserRequest request) public async Task<ActionResult<UserResponse>> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserRequest request)
{ {
var user = await UserRepository var user = await UserRepository
.Get() .Get()
.FirstOrDefaultAsync(x => x.Id == id); .FirstOrDefaultAsync(x => x.Id == id);
if (user == null) if (user == null)
throw new HttpApiException("No user with that id found", 404); return Problem("No user with that id found", statusCode: 404);
// Reformat values // Reformat values
request.Username = request.Username.ToLower().Trim(); request.Username = request.Username.ToLower().Trim();
@@ -134,10 +142,10 @@ public class UsersController : Controller
// Check for users with the same values // Check for users with the same values
if (UserRepository.Get().Any(x => x.Username == request.Username && x.Id != user.Id)) if (UserRepository.Get().Any(x => x.Username == request.Username && x.Id != user.Id))
throw new HttpApiException("A user with that username already exists", 400); return Problem("Another user with that username already exists", statusCode: 400);
if (UserRepository.Get().Any(x => x.Email == request.Email && x.Id != user.Id)) if (UserRepository.Get().Any(x => x.Email == request.Email && x.Id != user.Id))
throw new HttpApiException("A user with that email address already exists", 400); return Problem("Another user with that email address already exists", statusCode: 400);
// Perform hashing the password if required // Perform hashing the password if required
if (!string.IsNullOrEmpty(request.Password)) if (!string.IsNullOrEmpty(request.Password))
@@ -155,38 +163,33 @@ public class UsersController : Controller
user.Email = request.Email; user.Email = request.Email;
user.Username = request.Username; user.Username = request.Username;
await UserRepository.Update(user); await UserRepository.UpdateAsync(user);
return new UserResponse() return UserMapper.ToResponse(user);
{
Id = user.Id,
Email = user.Email,
Username = user.Username,
Permissions = user.Permissions
};
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize(Policy = "permissions:admin.users.delete")] [Authorize(Policy = "permissions:admin.users.delete")]
public async Task Delete([FromRoute] int id, [FromQuery] bool force = false) public async Task<ActionResult> DeleteAsync([FromRoute] int id, [FromQuery] bool force = false)
{ {
var user = await UserRepository var user = await UserRepository
.Get() .Get()
.FirstOrDefaultAsync(x => x.Id == id); .FirstOrDefaultAsync(x => x.Id == id);
if (user == null) if (user == null)
throw new HttpApiException("No user with that id found", 404); return Problem("No user with that id found", statusCode: 404);
var deletionService = HttpContext.RequestServices.GetRequiredService<UserDeletionService>(); var deletionService = HttpContext.RequestServices.GetRequiredService<UserDeletionService>();
if (!force) if (!force)
{ {
var validationResult = await deletionService.Validate(user); var validationResult = await deletionService.ValidateAsync(user);
if (!validationResult.IsAllowed) if (!validationResult.IsAllowed)
throw new HttpApiException($"Unable to delete user", 400, validationResult.Reason); return Problem("Unable to delete user", statusCode: 400, title: validationResult.Reason);
} }
await deletionService.Delete(user, force); await deletionService.DeleteAsync(user, force);
return NoContent();
} }
} }

View File

@@ -1,16 +1,10 @@
using System.IdentityModel.Tokens.Jwt; using System.Security.Claims;
using System.Security.Claims; using Microsoft.AspNetCore.Authentication;
using System.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions;
using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Interfaces; using Moonlight.ApiServer.Interfaces;
using Moonlight.Shared.Http.Requests.Auth;
using Moonlight.Shared.Http.Responses.Auth; using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.ApiServer.Http.Controllers.Auth; namespace Moonlight.ApiServer.Http.Controllers.Auth;
@@ -19,93 +13,116 @@ namespace Moonlight.ApiServer.Http.Controllers.Auth;
[Route("api/auth")] [Route("api/auth")]
public class AuthController : Controller public class AuthController : Controller
{ {
private readonly IAuthenticationSchemeProvider SchemeProvider;
private readonly IEnumerable<IAuthCheckExtension> Extensions;
private readonly AppConfiguration Configuration; private readonly AppConfiguration Configuration;
private readonly DatabaseRepository<User> UserRepository;
private readonly IOAuth2Provider OAuth2Provider;
public AuthController( public AuthController(
AppConfiguration configuration, IAuthenticationSchemeProvider schemeProvider,
DatabaseRepository<User> userRepository, IEnumerable<IAuthCheckExtension> extensions,
IOAuth2Provider oAuth2Provider AppConfiguration configuration
) )
{ {
UserRepository = userRepository; SchemeProvider = schemeProvider;
OAuth2Provider = oAuth2Provider; Extensions = extensions;
Configuration = configuration; Configuration = configuration;
} }
[AllowAnonymous] [HttpGet]
[HttpGet("start")] public async Task<AuthSchemeResponse[]> GetSchemesAsync()
public async Task<LoginStartResponse> Start()
{ {
var url = await OAuth2Provider.Start(); var schemes = await SchemeProvider.GetAllSchemesAsync();
return new LoginStartResponse() var allowedSchemes = Configuration.Authentication.EnabledSchemes;
{
Url = url return schemes
}; .Where(x => allowedSchemes.Contains(x.Name))
.Select(scheme => new AuthSchemeResponse()
{
DisplayName = scheme.DisplayName ?? scheme.Name,
Identifier = scheme.Name
})
.ToArray();
} }
[AllowAnonymous] [HttpGet("{identifier:alpha}")]
[HttpPost("complete")] public async Task StartSchemeAsync([FromRoute] string identifier)
public async Task<LoginCompleteResponse> Complete([FromBody] LoginCompleteRequest request)
{ {
var user = await OAuth2Provider.Complete(request.Code); // Validate identifier against our enable list
var allowedSchemes = Configuration.Authentication.EnabledSchemes;
if (user == null) if (!allowedSchemes.Contains(identifier))
throw new HttpApiException("Unable to load user data", 500);
// Generate token
var securityTokenDescriptor = new SecurityTokenDescriptor()
{ {
Expires = DateTime.Now.AddHours(Configuration.Authentication.TokenDuration), await Results
IssuedAt = DateTime.Now, .Problem(
NotBefore = DateTime.Now.AddMinutes(-1), "Invalid scheme identifier provided",
Claims = new Dictionary<string, object>() 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 = "/"
"userId", }
user.Id );
},
{
"permissions",
string.Join(";", user.Permissions)
}
},
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration.Authentication.Secret)
),
SecurityAlgorithms.HmacSha256
),
Issuer = Configuration.PublicUrl,
Audience = Configuration.PublicUrl
};
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
var securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor);
var jwt = jwtSecurityTokenHandler.WriteToken(securityToken);
return new()
{
AccessToken = jwt
};
} }
[Authorize] [Authorize]
[HttpGet("check")] [HttpGet("check")]
public async Task<CheckResponse> Check() public async Task<AuthClaimResponse[]> CheckAsync()
{ {
var userIdStr = User.FindFirstValue("userId")!; var username = User.FindFirstValue(ClaimTypes.Name)!;
var userId = int.Parse(userIdStr); var id = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var user = await UserRepository.Get().FirstAsync(x => x.Id == userId); var email = User.FindFirstValue(ClaimTypes.Email)!;
var userId = User.FindFirstValue("UserId")!;
var permissions = User.FindFirstValue("Permissions")!;
return new() // Create basic set of claims used by the frontend
var claims = new List<AuthClaimResponse>()
{ {
Email = user.Email, new(ClaimTypes.Name, username),
Username = user.Username, new(ClaimTypes.NameIdentifier, id),
Permissions = user.Permissions 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

@@ -18,13 +18,13 @@ public class FrontendController : Controller
} }
[HttpGet("frontend.json")] [HttpGet("frontend.json")]
public async Task<FrontendConfiguration> GetConfiguration() public async Task<FrontendConfiguration> GetConfigurationAsync()
=> await FrontendService.GetConfiguration(); => await FrontendService.GetConfigurationAsync();
[HttpGet] [HttpGet]
public async Task<IResult> Index() public async Task<IResult> IndexAsync()
{ {
var content = await FrontendService.GenerateIndexHtml(); var content = await FrontendService.GenerateIndexHtmlAsync();
return Results.Text(content, "text/html", Encoding.UTF8); return Results.Text(content, "text/html", Encoding.UTF8);
} }

View File

@@ -1,8 +1,7 @@
@using Moonlight.ApiServer.Database.Entities @using Moonlight.ApiServer.Database.Entities
@using Moonlight.Shared.Misc
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="bg-background text-base-content font-inter"> <html lang="en" class="bg-base-200 text-base-content font-inter">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
@@ -23,48 +22,45 @@
{ {
<style> <style>
:root { :root {
--mooncore-color-background: @(Theme.Content.ColorBackground); --color-base-100: @(Theme.Content.ColorBase100);
--mooncore-color-base-100: @(Theme.Content.ColorBase100); --color-base-200: @(Theme.Content.ColorBase200);
--mooncore-color-base-150: @(Theme.Content.ColorBase150); --color-base-300: @(Theme.Content.ColorBase300);
--mooncore-color-base-200: @(Theme.Content.ColorBase200); --color-base-content: @(Theme.Content.ColorBaseContent);
--mooncore-color-base-250: @(Theme.Content.ColorBase250);
--mooncore-color-base-300: @(Theme.Content.ColorBase300);
--mooncore-color-base-content: @(Theme.Content.ColorBaseContent);
--mooncore-color-primary: @(Theme.Content.ColorPrimary); --color-primary: @(Theme.Content.ColorPrimary);
--mooncore-color-primary-content: @(Theme.Content.ColorPrimaryContent); --color-primary-content: @(Theme.Content.ColorPrimaryContent);
--mooncore-color-secondary: @(Theme.Content.ColorSecondary); --color-secondary: @(Theme.Content.ColorSecondary);
--mooncore-color-secondary-content: @(Theme.Content.ColorSecondaryContent); --color-secondary-content: @(Theme.Content.ColorSecondaryContent);
--mooncore-color-accent: @(Theme.Content.ColorAccent); --color-accent: @(Theme.Content.ColorAccent);
--mooncore-color-accent-content: @(Theme.Content.ColorAccentContent); --color-accent-content: @(Theme.Content.ColorAccentContent);
--mooncore-color-neutral: @(Theme.Content.ColorNeutral); --color-neutral: @(Theme.Content.ColorNeutral);
--mooncore-color-neutral-content: @(Theme.Content.ColorNeutralContent); --color-neutral-content: @(Theme.Content.ColorNeutralContent);
--mooncore-color-info: @(Theme.Content.ColorInfo); --color-info: @(Theme.Content.ColorInfo);
--mooncore-color-info-content: @(Theme.Content.ColorInfoContent); --color-info-content: @(Theme.Content.ColorInfoContent);
--mooncore-color-success: @(Theme.Content.ColorSuccess); --color-success: @(Theme.Content.ColorSuccess);
--mooncore-color-success-content: @(Theme.Content.ColorSuccessContent); --color-success-content: @(Theme.Content.ColorSuccessContent);
--mooncore-color-warning: @(Theme.Content.ColorWarning); --color-warning: @(Theme.Content.ColorWarning);
--mooncore-color-warning-content: @(Theme.Content.ColorWarningContent); --color-warning-content: @(Theme.Content.ColorWarningContent);
--mooncore-color-error: @(Theme.Content.ColorError); --color-error: @(Theme.Content.ColorError);
--mooncore-color-error-content: @(Theme.Content.ColorErrorContent); --color-error-content: @(Theme.Content.ColorErrorContent);
--mooncore-radius-selector: @(Theme.Content.RadiusSelector)rem; --radius-selector: @(Theme.Content.RadiusSelector)rem;
--mooncore-radius-field: @(Theme.Content.RadiusField)rem; --radius-field: @(Theme.Content.RadiusField)rem;
--mooncore-radius-box: @(Theme.Content.RadiusBox)rem; --radius-box: @(Theme.Content.RadiusBox)rem;
--mooncore-size-selector: @(Theme.Content.SizeSelector)rem; --size-selector: @(Theme.Content.SizeSelector)rem;
--mooncore-size-field: @(Theme.Content.SizeField)rem; --size-field: @(Theme.Content.SizeField)rem;
--mooncore-border: @(Theme.Content.Border)px; --border: @(Theme.Content.Border)px;
--mooncore-depth: @(Theme.Content.Depth); --depth: @(Theme.Content.Depth);
--mooncore-noise: @(Theme.Content.Noise); --noise: @(Theme.Content.Noise);
} }
</style> </style>
} }

View File

@@ -0,0 +1,202 @@
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,4 +1,4 @@
<html lang="en" class="h-full bg-background"> <html lang="en" class="h-full bg-base-200">
<head> <head>
<title>Login into your account</title> <title>Login into your account</title>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
@@ -9,8 +9,7 @@
<div class="flex h-auto min-h-screen items-center justify-center overflow-x-hidden py-10"> <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="relative flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div <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">
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"> <div class="flex justify-center items-center gap-3">
<img src="/_content/Moonlight.Client/svg/logo.svg" class="size-12" alt="brand-logo"/> <img src="/_content/Moonlight.Client/svg/logo.svg" class="size-12" alt="brand-logo"/>
</div> </div>
@@ -42,8 +41,7 @@
</form> </form>
<p class="text-base-content/80 mb-4 text-center"> <p class="text-base-content/80 mb-4 text-center">
No account? No account?
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=register" <a href="/api/localAuth/register" class="link link-animated link-primary font-normal">Create an account</a>
class="link link-animated link-primary font-normal">Create an account</a>
</p> </p>
</div> </div>
</div> </div>
@@ -55,8 +53,5 @@
@code @code
{ {
[Parameter] public string ClientId { get; set; }
[Parameter] public string RedirectUri { get; set; }
[Parameter] public string ResponseType { get; set; }
[Parameter] public string? ErrorMessage { get; set; } [Parameter] public string? ErrorMessage { get; set; }
} }

View File

@@ -1,4 +1,4 @@
<html lang="en" class="h-full bg-background"> <html lang="en" class="h-full bg-base-200">
<head> <head>
<title>Register a new account</title> <title>Register a new account</title>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
@@ -9,8 +9,7 @@
<div class="flex h-auto min-h-screen items-center justify-center overflow-x-hidden py-10"> <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="relative flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div <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">
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"> <div class="flex justify-center items-center gap-3">
<img src="/_content/Moonlight.Client/svg/logo.svg" class="size-12" alt="brand-logo"/> <img src="/_content/Moonlight.Client/svg/logo.svg" class="size-12" alt="brand-logo"/>
</div> </div>
@@ -47,8 +46,7 @@
</form> </form>
<p class="text-base-content/80 mb-4 text-center"> <p class="text-base-content/80 mb-4 text-center">
Already registered? Already registered?
<a href="?client_id=@(ClientId)&redirect_uri=@(RedirectUri)&response_type=@(ResponseType)&view=login" <a href="/api/localAuth/login" class="link link-animated link-primary font-normal">Login into your account</a>
class="link link-animated link-primary font-normal">Login into your account</a>
</p> </p>
</div> </div>
</div> </div>
@@ -59,8 +57,5 @@
@code @code
{ {
[Parameter] public string ClientId { get; set; }
[Parameter] public string RedirectUri { get; set; }
[Parameter] public string ResponseType { get; set; }
[Parameter] public string? ErrorMessage { get; set; } [Parameter] public string? ErrorMessage { get; set; }
} }

View File

@@ -1,317 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.Shared.Http.Responses.OAuth2;
namespace Moonlight.ApiServer.Http.Controllers.OAuth2;
[ApiController]
[Route("oauth2")]
public partial class OAuth2Controller : Controller
{
private readonly AppConfiguration Configuration;
private readonly DatabaseRepository<User> UserRepository;
private readonly string ExpectedRedirectUri;
public OAuth2Controller(AppConfiguration configuration, DatabaseRepository<User> userRepository)
{
Configuration = configuration;
UserRepository = userRepository;
ExpectedRedirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect)
? Configuration.PublicUrl
: Configuration.Authentication.OAuth2.AuthorizationRedirect;
}
[AllowAnonymous]
[HttpGet("authorize")]
public async Task Authorize(
[FromQuery(Name = "client_id")] string clientId,
[FromQuery(Name = "redirect_uri")] string redirectUri,
[FromQuery(Name = "response_type")] string responseType,
[FromQuery(Name = "view")] string view = "login"
)
{
if (!Configuration.Authentication.EnableLocalOAuth2)
throw new HttpApiException("Local OAuth2 has been disabled", 403);
if (Configuration.Authentication.OAuth2.ClientId != clientId ||
redirectUri != ExpectedRedirectUri ||
responseType != "code")
{
throw new HttpApiException("Invalid oauth2 request", 400);
}
string html;
if (view == "register")
{
html = await ComponentHelper.RenderComponent<Register>(HttpContext.RequestServices, parameters =>
{
parameters.Add("ClientId", clientId);
parameters.Add("RedirectUri", redirectUri);
parameters.Add("ResponseType", responseType);
});
}
else
{
html = await ComponentHelper.RenderComponent<Login>(HttpContext.RequestServices, parameters =>
{
parameters.Add("ClientId", clientId);
parameters.Add("RedirectUri", redirectUri);
parameters.Add("ResponseType", responseType);
});
}
await Results
.Text(html, "text/html", Encoding.UTF8)
.ExecuteAsync(HttpContext);
}
[AllowAnonymous]
[HttpPost("authorize")]
public async Task AuthorizePost(
[FromQuery(Name = "client_id")] string clientId,
[FromQuery(Name = "redirect_uri")] string redirectUri,
[FromQuery(Name = "response_type")] string responseType,
[FromForm(Name = "email")] [EmailAddress(ErrorMessage = "You need to provide a valid email address")] string email,
[FromForm(Name = "password")] string password,
[FromForm(Name = "username")] string username = "",
[FromQuery(Name = "view")] string view = "login"
)
{
if (!Configuration.Authentication.EnableLocalOAuth2)
throw new HttpApiException("Local OAuth2 has been disabled", 403);
if (Configuration.Authentication.OAuth2.ClientId != clientId ||
redirectUri != ExpectedRedirectUri ||
responseType != "code")
{
throw new HttpApiException("Invalid oauth2 request", 400);
}
if (view == "register" && string.IsNullOrEmpty(username))
throw new HttpApiException("You need to provide a username", 400);
string? errorMessage = null;
try
{
if (view == "register")
{
var user = await Register(username, email, password);
var code = await GenerateCode(user);
Response.Redirect($"{redirectUri}?code={code}");
}
else
{
var user = await Login(email, password);
var code = await GenerateCode(user);
Response.Redirect($"{redirectUri}?code={code}");
}
}
catch (HttpApiException e)
{
errorMessage = e.Title;
string html;
if (view == "register")
{
html = await ComponentHelper.RenderComponent<Register>(HttpContext.RequestServices, parameters =>
{
parameters.Add("ClientId", clientId);
parameters.Add("RedirectUri", redirectUri);
parameters.Add("ResponseType", responseType);
parameters.Add("ErrorMessage", errorMessage!);
});
}
else
{
html = await ComponentHelper.RenderComponent<Login>(HttpContext.RequestServices, parameters =>
{
parameters.Add("ClientId", clientId);
parameters.Add("RedirectUri", redirectUri);
parameters.Add("ResponseType", responseType);
parameters.Add("ErrorMessage", errorMessage!);
});
}
await Results
.Text(html, "text/html", Encoding.UTF8)
.ExecuteAsync(HttpContext);
}
}
[AllowAnonymous]
[HttpPost("handle")]
public async Task<OAuth2HandleResponse> Handle(
[FromForm(Name = "grant_type")] string grantType,
[FromForm(Name = "code")] string code,
[FromForm(Name = "redirect_uri")] string redirectUri,
[FromForm(Name = "client_id")] string clientId
)
{
if (!Configuration.Authentication.EnableLocalOAuth2)
throw new HttpApiException("Local OAuth2 has been disabled", 403);
// Check header
if (!Request.Headers.ContainsKey("Authorization"))
throw new HttpApiException("You are missing the Authorization header", 400);
var authorizationHeaderValue = Request.Headers["Authorization"].FirstOrDefault() ?? "";
if (authorizationHeaderValue != $"Basic {Configuration.Authentication.OAuth2.ClientSecret}")
throw new HttpApiException("Invalid Authorization header value", 400);
// Check form
if (grantType != "authorization_code")
throw new HttpApiException("Invalid grant type provided", 400);
if (clientId != Configuration.Authentication.OAuth2.ClientId)
throw new HttpApiException("Invalid client id provided", 400);
if (redirectUri != ExpectedRedirectUri)
throw new HttpApiException("Invalid redirect uri provided", 400);
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
ClaimsPrincipal? codeData;
try
{
codeData = jwtSecurityTokenHandler.ValidateToken(code, new TokenValidationParameters()
{
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
Configuration.Authentication.OAuth2.Secret
)),
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
ValidateAudience = false,
ValidateIssuer = false
}, out _);
}
catch (SecurityTokenException)
{
throw new HttpApiException("Invalid code provided", 400);
}
if (codeData == null)
throw new HttpApiException("Invalid code provided", 400);
var userIdClaim = codeData.Claims.FirstOrDefault(x => x.Type == "id");
if (userIdClaim == null)
throw new HttpApiException("Malformed code provided", 400);
if (!int.TryParse(userIdClaim.Value, out var userId))
throw new HttpApiException("Malformed code provided", 400);
var user = UserRepository
.Get()
.FirstOrDefault(x => x.Id == userId);
if (user == null)
throw new HttpApiException("Malformed code provided", 400);
return new()
{
UserId = user.Id
};
}
private Task<string> GenerateCode(User user)
{
var securityTokenDescriptor = new SecurityTokenDescriptor()
{
Expires = DateTime.Now.AddMinutes(1),
IssuedAt = DateTime.Now,
NotBefore = DateTime.Now.AddMinutes(-1),
Claims = new Dictionary<string, object>()
{
{
"id",
user.Id
}
},
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration.Authentication.OAuth2.Secret)
),
SecurityAlgorithms.HmacSha256
)
};
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
var securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor);
return Task.FromResult(
jwtSecurityTokenHandler.WriteToken(securityToken)
);
}
private async Task<User> Register(string username, string email, string password)
{
if (await UserRepository.Get().AnyAsync(x => x.Username == username))
throw new HttpApiException("A account with that username already exists", 400);
if (await UserRepository.Get().AnyAsync(x => x.Email == email))
throw new HttpApiException("A account with that email already exists", 400);
if (!UsernameRegex().IsMatch(username))
throw new HttpApiException("The username is only allowed to be contained out of small characters and numbers", 400);
var user = new User()
{
Username = username,
Email = email,
Password = HashHelper.Hash(password),
};
if (Configuration.Authentication.OAuth2.FirstUserAdmin)
{
var userCount = await UserRepository.Get().CountAsync();
if (userCount == 0)
user.Permissions = ["*"];
}
return await UserRepository.Add(user);
}
private async Task<User> Login(string email, string password)
{
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(x => x.Email == email);
if (user == null)
throw new HttpApiException("Invalid combination of email and password", 400);
if (!HashHelper.Verify(password, user.Password))
throw new HttpApiException("Invalid combination of email and password", 400);
return user;
}
[GeneratedRegex("^[a-z][a-z0-9]*$")]
private static partial Regex UsernameRegex();
}

View File

@@ -24,7 +24,7 @@ public class SwaggerController : Controller
[HttpGet] [HttpGet]
[Authorize] [Authorize]
public async Task<ActionResult> Get() public async Task<ActionResult> GetAsync()
{ {
if (!Configuration.Development.EnableApiDocs) if (!Configuration.Development.EnableApiDocs)
return BadRequest("Api docs are disabled"); return BadRequest("Api docs are disabled");
@@ -32,7 +32,7 @@ public class SwaggerController : Controller
var options = new ApiDocsOptions(); var options = new ApiDocsOptions();
var optionsJson = JsonSerializer.Serialize(options); var optionsJson = JsonSerializer.Serialize(options);
var html = await ComponentHelper.RenderComponent<SwaggerPage>( var html = await ComponentHelper.RenderToHtmlAsync<SwaggerPage>(
ServiceProvider, ServiceProvider,
parameters => parameters =>
{ {

View File

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

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

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO.Compression; using System.IO.Compression;
using System.Text.Json; using MoonCore.Yaml;
using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Extensions; using Moonlight.ApiServer.Extensions;
using Moonlight.ApiServer.Interfaces; using Moonlight.ApiServer.Interfaces;
@@ -8,11 +9,11 @@ namespace Moonlight.ApiServer.Implementations.Diagnose;
public class CoreConfigDiagnoseProvider : IDiagnoseProvider public class CoreConfigDiagnoseProvider : IDiagnoseProvider
{ {
private readonly AppConfiguration Config; private readonly AppConfiguration Configuration;
public CoreConfigDiagnoseProvider(AppConfiguration config) public CoreConfigDiagnoseProvider(AppConfiguration configuration)
{ {
Config = config; Configuration = configuration;
} }
private string CheckForNullOrEmpty(string? content) private string CheckForNullOrEmpty(string? content)
@@ -22,36 +23,25 @@ public class CoreConfigDiagnoseProvider : IDiagnoseProvider
: "ISNOTEMPTY"; : "ISNOTEMPTY";
} }
public async Task ModifyZipArchive(ZipArchive archive) public async Task ModifyZipArchiveAsync(ZipArchive archive)
{ {
var json = JsonSerializer.Serialize(Config); try
var config = JsonSerializer.Deserialize<AppConfiguration>(json);
if (config == null)
{ {
await archive.AddText("core/config.txt", "Could not fetch config."); var configString = YamlSerializer.Serialize(Configuration);
return; 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()}");
} }
config.Database.Password = CheckForNullOrEmpty(config.Database.Password);
config.Authentication.OAuth2.ClientSecret = CheckForNullOrEmpty(config.Authentication.OAuth2.ClientSecret);
config.Authentication.OAuth2.Secret = CheckForNullOrEmpty(config.Authentication.OAuth2.Secret);
config.Authentication.Secret = CheckForNullOrEmpty(config.Authentication.Secret);
config.Authentication.OAuth2.ClientId = CheckForNullOrEmpty(config.Authentication.OAuth2.ClientId);
await archive.AddText(
"core/config.txt",
JsonSerializer.Serialize(
config,
new JsonSerializerOptions()
{
WriteIndented = true
}
)
);
} }
} }

View File

@@ -6,17 +6,16 @@ namespace Moonlight.ApiServer.Implementations.Diagnose;
public class LogsDiagnoseProvider : IDiagnoseProvider public class LogsDiagnoseProvider : IDiagnoseProvider
{ {
public async Task ModifyZipArchive(ZipArchive archive) public async Task ModifyZipArchiveAsync(ZipArchive archive)
{ {
var path = Path.Combine("storage", "logs", "latest.log"); var path = Path.Combine("storage", "logs", "moonlight.log");
if (!File.Exists(path)) if (File.Exists(path))
{ {
await archive.AddText("logs.txt", "Logs file latest.log has not been found"); var logsContent = await File.ReadAllTextAsync(path);
return; await archive.AddTextAsync("logs.txt", logsContent);
} }
else
var logsContent = await File.ReadAllTextAsync(path); await archive.AddTextAsync("logs.txt", "Logs file moonlight.log has not been found");
await archive.AddText("logs.txt", logsContent);
} }
} }

View File

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

View File

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

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

View File

@@ -1,106 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MoonCore.Exceptions;
using MoonCore.Extended.Abstractions;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Interfaces;
using Moonlight.Shared.Http.Responses.OAuth2;
namespace Moonlight.ApiServer.Implementations;
public class LocalOAuth2Provider : IOAuth2Provider
{
private readonly AppConfiguration Configuration;
private readonly ILogger<LocalOAuth2Provider> Logger;
private readonly DatabaseRepository<User> UserRepository;
public LocalOAuth2Provider(
AppConfiguration configuration,
ILogger<LocalOAuth2Provider> logger,
DatabaseRepository<User> userRepository
)
{
UserRepository = userRepository;
Configuration = configuration;
Logger = logger;
}
public Task<string> Start()
{
var redirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect)
? Configuration.PublicUrl
: Configuration.Authentication.OAuth2.AuthorizationRedirect;
var endpoint = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationEndpoint)
? Configuration.PublicUrl + "/oauth2/authorize"
: Configuration.Authentication.OAuth2.AuthorizationEndpoint;
var clientId = Configuration.Authentication.OAuth2.ClientId;
var url = $"{endpoint}" +
$"?client_id={clientId}" +
$"&redirect_uri={redirectUri}" +
$"&response_type=code";
return Task.FromResult(url);
}
public async Task<User?> Complete(string code)
{
// Create http client to call the auth provider
var httpClient = new HttpClient();
using var httpApiClient = new HttpApiClient(httpClient);
httpClient.DefaultRequestHeaders.Add("Authorization",
$"Basic {Configuration.Authentication.OAuth2.ClientSecret}");
// Build access endpoint
var accessEndpoint = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AccessEndpoint)
? $"{Configuration.PublicUrl}/oauth2/handle"
: Configuration.Authentication.OAuth2.AccessEndpoint;
// Build redirect uri
var redirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect)
? Configuration.PublicUrl
: Configuration.Authentication.OAuth2.AuthorizationRedirect;
// Call the auth provider
OAuth2HandleResponse handleData;
try
{
handleData = await httpApiClient.PostJson<OAuth2HandleResponse>(accessEndpoint, new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("grant_type", "authorization_code"),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("redirect_uri", redirectUri),
new KeyValuePair<string, string>("client_id", Configuration.Authentication.OAuth2.ClientId)
]
));
}
catch (HttpApiException e)
{
if (e.Status == 400)
Logger.LogTrace("The auth server returned an error: {e}", e);
else
Logger.LogCritical("The auth server returned an error: {e}", e);
throw new HttpApiException("Unable to request user data", 500);
}
// Notice: We just look up the user id here
// which works as our oauth2 provider is using the same db.
// a real oauth2 provider would create a user here
// Handle the returned data
var userId = handleData.UserId;
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == userId);
return user;
}
}

View File

@@ -11,7 +11,7 @@ public class ApplicationMetric : IMetric
private Gauge<int> CpuUsage; private Gauge<int> CpuUsage;
private Gauge<double> Uptime; private Gauge<double> Uptime;
public Task Initialize(Meter meter) public Task InitializeAsync(Meter meter)
{ {
MemoryUsage = meter.CreateGauge<long>("moonlight_memory_usage"); MemoryUsage = meter.CreateGauge<long>("moonlight_memory_usage");
CpuUsage = meter.CreateGauge<int>("moonlight_cpu_usage"); CpuUsage = meter.CreateGauge<int>("moonlight_cpu_usage");
@@ -20,17 +20,17 @@ public class ApplicationMetric : IMetric
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task Run(IServiceProvider provider, CancellationToken cancellationToken) public async Task RunAsync(IServiceProvider provider, CancellationToken cancellationToken)
{ {
var applicationService = provider.GetRequiredService<ApplicationService>(); var applicationService = provider.GetRequiredService<ApplicationService>();
var memory = await applicationService.GetMemoryUsage(); var memory = await applicationService.GetMemoryUsageAsync();
MemoryUsage.Record(memory); MemoryUsage.Record(memory);
var uptime = await applicationService.GetUptime(); var uptime = await applicationService.GetUptimeAsync();
Uptime.Record(uptime.TotalSeconds); Uptime.Record(uptime.TotalSeconds);
var cpu = await applicationService.GetCpuUsage(); var cpu = await applicationService.GetCpuUsageAsync();
CpuUsage.Record(cpu); CpuUsage.Record(cpu);
} }
} }

View File

@@ -11,14 +11,14 @@ public class UsersMetric : IMetric
{ {
private Gauge<int> Users; private Gauge<int> Users;
public Task Initialize(Meter meter) public Task InitializeAsync(Meter meter)
{ {
Users = meter.CreateGauge<int>("moonlight_users"); Users = meter.CreateGauge<int>("moonlight_users");
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task Run(IServiceProvider provider, CancellationToken cancellationToken) public async Task RunAsync(IServiceProvider provider, CancellationToken cancellationToken)
{ {
var usersRepo = provider.GetRequiredService<DatabaseRepository<User>>(); var usersRepo = provider.GetRequiredService<DatabaseRepository<User>>();
var count = await usersRepo.Get().CountAsync(cancellationToken: cancellationToken); var count = await usersRepo.Get().CountAsync(cancellationToken: cancellationToken);

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database; using Moonlight.ApiServer.Database;
@@ -11,17 +11,21 @@ using Moonlight.ApiServer.Interfaces;
using Moonlight.ApiServer.Models; using Moonlight.ApiServer.Models;
using Moonlight.ApiServer.Plugins; using Moonlight.ApiServer.Plugins;
using Moonlight.ApiServer.Services; using Moonlight.ApiServer.Services;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics; using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Moonlight.ApiServer.Implementations.Startup; namespace Moonlight.ApiServer.Implementations.Startup;
public class CoreStartup : IPluginStartup public class CoreStartup : IPluginStartup
{ {
public Task BuildApplication(IServiceProvider serviceProvider, IHostApplicationBuilder builder) public void AddPlugin(WebApplicationBuilder builder)
{ {
var configuration = serviceProvider.GetRequiredService<AppConfiguration>(); var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
#region Api Docs
#region Api Docs
if (configuration.Development.EnableApiDocs) if (configuration.Development.EnableApiDocs)
{ {
@@ -64,22 +68,56 @@ public class CoreStartup : IPluginStartup
#region Prometheus #region Prometheus
if (configuration.Metrics.Enable) if (configuration.OpenTelemetry.Enable)
{ {
builder.Services.AddSingleton<MetricsBackgroundService>(); var openTel = builder.Services.AddOpenTelemetry();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MetricsBackgroundService>()); var openTelConfig = configuration.OpenTelemetry;
builder.Services.AddSingleton<IMetric, ApplicationMetric>(); var resourceBuilder = ResourceBuilder.CreateDefault();
builder.Services.AddSingleton<IMetric, UsersMetric>(); resourceBuilder.AddService(serviceName: "moonlight");
builder.Services.AddOpenTelemetry() openTel.ConfigureResource(x => x.AddService(serviceName: "moonlight"));
.WithMetrics(providerBuilder =>
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.AddPrometheusExporter();
providerBuilder.AddAspNetCoreInstrumentation(); providerBuilder.AddAspNetCoreInstrumentation();
providerBuilder.AddOtlpExporter();
if (openTelConfig.Metrics.EnablePrometheus)
providerBuilder.AddPrometheusExporter();
providerBuilder.AddMeter("moonlight"); 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 #endregion
@@ -100,31 +138,27 @@ public class CoreStartup : IPluginStartup
} }
#endregion #endregion
return Task.CompletedTask;
} }
public Task ConfigureApplication(IServiceProvider serviceProvider, IApplicationBuilder app) public void UsePlugin(WebApplication app)
{ {
var configuration = serviceProvider.GetRequiredService<AppConfiguration>(); var configuration = AppConfiguration.CreateEmpty();
app.Configuration.Bind(configuration);
#region Prometheus #region Prometheus
if (configuration.Metrics.Enable) if (configuration.OpenTelemetry is { Enable: true, Metrics.EnablePrometheus: true })
app.UseOpenTelemetryPrometheusScrapingEndpoint(); app.UseOpenTelemetryPrometheusScrapingEndpoint();
#endregion #endregion
return Task.CompletedTask;
} }
public Task ConfigureEndpoints(IServiceProvider serviceProvider, IEndpointRouteBuilder routeBuilder) public void MapPlugin(WebApplication app)
{ {
var configuration = serviceProvider.GetRequiredService<AppConfiguration>(); var configuration = AppConfiguration.CreateEmpty();
app.Configuration.Bind(configuration);
if (configuration.Development.EnableApiDocs) if (configuration.Development.EnableApiDocs)
routeBuilder.MapSwagger("/api/swagger/{documentName}"); app.MapSwagger("/api/swagger/{documentName}");
return Task.CompletedTask;
} }
} }

View File

@@ -1,62 +0,0 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using MoonCore.Extended.Abstractions;
using MoonCore.Extended.JwtInvalidation;
using Moonlight.ApiServer.Database.Entities;
namespace Moonlight.ApiServer.Implementations;
public class UserAuthInvalidation : IJwtInvalidateHandler
{
private readonly DatabaseRepository<User> UserRepository;
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
public UserAuthInvalidation(
DatabaseRepository<User> userRepository,
DatabaseRepository<ApiKey> apiKeyRepository
)
{
UserRepository = userRepository;
ApiKeyRepository = apiKeyRepository;
}
public async Task<bool> Handle(ClaimsPrincipal principal)
{
var userIdClaim = principal.FindFirstValue("userId");
if (!string.IsNullOrEmpty(userIdClaim))
{
var userId = int.Parse(userIdClaim);
var user = await UserRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == userId);
if (user == null)
return true; // User is deleted, invalidate session
var iatStr = principal.FindFirstValue("iat")!;
var iat = DateTimeOffset.FromUnixTimeSeconds(long.Parse(iatStr));
// If the token has been issued before the token valid time, its expired, and we want to invalidate it
return user.TokenValidTimestamp > iat;
}
var apiKeyIdClaim = principal.FindFirstValue("apiKeyId");
if (!string.IsNullOrEmpty(apiKeyIdClaim))
{
var apiKeyId = int.Parse(apiKeyIdClaim);
var apiKey = await ApiKeyRepository
.Get()
.FirstOrDefaultAsync(x => x.Id == apiKeyId);
// If the api key exists, we don't want to invalidate the request.
// If it doesn't exist we want to invalidate the request
return apiKey == null;
}
return true;
}
}

View File

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

@@ -4,5 +4,5 @@ namespace Moonlight.ApiServer.Interfaces;
public interface IDiagnoseProvider public interface IDiagnoseProvider
{ {
public Task ModifyZipArchive(ZipArchive archive); public Task ModifyZipArchiveAsync(ZipArchive archive);
} }

View File

@@ -4,6 +4,6 @@ namespace Moonlight.ApiServer.Interfaces;
public interface IMetric public interface IMetric
{ {
public Task Initialize(Meter meter); public Task InitializeAsync(Meter meter);
public Task Run(IServiceProvider provider, CancellationToken cancellationToken); public Task RunAsync(IServiceProvider provider, CancellationToken cancellationToken);
} }

View File

@@ -1,10 +0,0 @@
using Moonlight.ApiServer.Database.Entities;
namespace Moonlight.ApiServer.Interfaces;
public interface IOAuth2Provider
{
public Task<string> Start();
public Task<User?> Complete(string code);
}

View File

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

@@ -5,6 +5,6 @@ namespace Moonlight.ApiServer.Interfaces;
public interface IUserDeleteHandler public interface IUserDeleteHandler
{ {
public Task<UserDeleteValidationResult> Validate(User user); public Task<UserDeleteValidationResult> ValidateAsync(User user);
public Task Delete(User user, bool force); public Task DeleteAsync(User user, bool force);
} }

View File

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

@@ -8,7 +8,12 @@ namespace Moonlight.ApiServer.Mappers;
[Mapper] [Mapper]
public static partial class ThemeMapper public static partial class ThemeMapper
{ {
// Mappers
public static partial ThemeResponse ToResponse(Theme theme); public static partial ThemeResponse ToResponse(Theme theme);
public static partial Theme ToTheme(CreateThemeRequest request); public static partial Theme ToTheme(CreateThemeRequest request);
public static partial void Merge([MappingTarget] Theme theme, UpdateThemeRequest 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

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

View File

@@ -1,12 +1,10 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Hosting;
namespace Moonlight.ApiServer.Plugins; namespace Moonlight.ApiServer.Plugins;
public interface IPluginStartup public interface IPluginStartup
{ {
public Task BuildApplication(IServiceProvider serviceProvider, IHostApplicationBuilder builder); public void AddPlugin(WebApplicationBuilder builder);
public Task ConfigureApplication(IServiceProvider serviceProvider, IApplicationBuilder app); public void UsePlugin(WebApplication app);
public Task ConfigureEndpoints(IServiceProvider serviceProvider, IEndpointRouteBuilder routeBuilder); public void MapPlugin(WebApplication app);
} }

View File

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

@@ -29,11 +29,11 @@ public class ApiKeyService
Claims = new Dictionary<string, object>() Claims = new Dictionary<string, object>()
{ {
{ {
"apiKeyId", "ApiKeyId",
apiKey.Id apiKey.Id
}, },
{ {
"permissions", "Permissions",
string.Join(";", apiKey.Permissions) string.Join(";", apiKey.Permissions)
} }
}, },

View File

@@ -19,7 +19,7 @@ public class ApplicationService
Host = host; Host = host;
} }
public Task<string> GetOsName() public Task<string> GetOsNameAsync()
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
@@ -58,7 +58,7 @@ public class ApplicationService
return Task.FromResult("N/A"); return Task.FromResult("N/A");
} }
public async Task<long> GetMemoryUsage() public async Task<long> GetMemoryUsageAsync()
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
@@ -87,14 +87,14 @@ public class ApplicationService
} }
} }
public Task<TimeSpan> GetUptime() public Task<TimeSpan> GetUptimeAsync()
{ {
var process = Process.GetCurrentProcess(); var process = Process.GetCurrentProcess();
var uptime = DateTime.Now - process.StartTime; var uptime = DateTime.Now - process.StartTime;
return Task.FromResult(uptime); return Task.FromResult(uptime);
} }
public Task<int> GetCpuUsage() public Task<int> GetCpuUsageAsync()
{ {
var process = Process.GetCurrentProcess(); var process = Process.GetCurrentProcess();
var cpuTime = process.TotalProcessorTime; var cpuTime = process.TotalProcessorTime;
@@ -105,7 +105,7 @@ public class ApplicationService
return Task.FromResult(cpuUsage); return Task.FromResult(cpuUsage);
} }
public Task Shutdown() public Task ShutdownAsync()
{ {
Logger.LogCritical("Restart of api server has been requested"); Logger.LogCritical("Restart of api server has been requested");

View File

@@ -22,7 +22,7 @@ public class DiagnoseService
Logger = logger; Logger = logger;
} }
public Task<DiagnoseProvideResponse[]> GetProviders() public Task<DiagnoseProvideResponse[]> GetProvidersAsync()
{ {
var availableProviders = new List<DiagnoseProvideResponse>(); var availableProviders = new List<DiagnoseProvideResponse>();
@@ -48,7 +48,7 @@ public class DiagnoseService
); );
} }
public async Task<MemoryStream> GenerateDiagnose(string[] requestedProviders) public async Task<MemoryStream> GenerateDiagnoseAsync(string[] requestedProviders)
{ {
IDiagnoseProvider[] providers; IDiagnoseProvider[] providers;
@@ -78,7 +78,7 @@ public class DiagnoseService
foreach (var provider in providers) foreach (var provider in providers)
{ {
await provider.ModifyZipArchive(zipArchive); await provider.ModifyZipArchiveAsync(zipArchive);
} }
zipArchive.Dispose(); zipArchive.Dispose();

View File

@@ -40,7 +40,7 @@ public class FrontendService
ThemeRepository = themeRepository; ThemeRepository = themeRepository;
} }
public Task<FrontendConfiguration> GetConfiguration() public Task<FrontendConfiguration> GetConfigurationAsync()
{ {
var configuration = new FrontendConfiguration() var configuration = new FrontendConfiguration()
{ {
@@ -51,7 +51,7 @@ public class FrontendService
return Task.FromResult(configuration); return Task.FromResult(configuration);
} }
public async Task<string> GenerateIndexHtml() // TODO: Cache public async Task<string> GenerateIndexHtmlAsync() // TODO: Cache
{ {
// Load requested theme // Load requested theme
var theme = await ThemeRepository var theme = await ThemeRepository
@@ -70,7 +70,7 @@ public class FrontendService
.Distinct() .Distinct()
.ToArray(); .ToArray();
return await ComponentHelper.RenderComponent<FrontendPage>( return await ComponentHelper.RenderToHtmlAsync<FrontendPage>(
ServiceProvider, ServiceProvider,
parameters => parameters =>
{ {
@@ -82,7 +82,7 @@ public class FrontendService
); );
} }
public async Task<Stream> GenerateZip() // TODO: Rework to be able to extract everything successfully 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 // We only allow the access to this function when we are actually hosting the frontend
if (!Configuration.Frontend.EnableHosting) if (!Configuration.Frontend.EnableHosting)
@@ -109,16 +109,16 @@ public class FrontendService
var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true); var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true);
// Add wasm application // Add wasm application
await ArchiveFsItem(zipArchive, wasmPath, wasmPath); await ArchiveFsItemAsync(zipArchive, wasmPath, wasmPath);
// Add blazor files // Add blazor files
await ArchiveFsItem(zipArchive, blazorPath, blazorPath, "_framework/"); await ArchiveFsItemAsync(zipArchive, blazorPath, blazorPath, "_framework/");
// Add frontend.json // Add frontend.json
var frontendConfig = await GetConfiguration(); var frontendConfig = await GetConfigurationAsync();
frontendConfig.HostEnvironment = "Static"; frontendConfig.HostEnvironment = "Static";
var frontendJson = JsonSerializer.Serialize(frontendConfig); var frontendJson = JsonSerializer.Serialize(frontendConfig);
await ArchiveText(zipArchive, "frontend.json", frontendJson); await ArchiveTextAsync(zipArchive, "frontend.json", frontendJson);
// Finish zip archive and reset stream so the code calling this function can process it // Finish zip archive and reset stream so the code calling this function can process it
zipArchive.Dispose(); zipArchive.Dispose();
@@ -128,7 +128,7 @@ public class FrontendService
return memoryStream; return memoryStream;
} }
private async Task ArchiveFsItem(ZipArchive archive, string path, string prefixToRemove, string prefixToAdd = "") private async Task ArchiveFsItemAsync(ZipArchive archive, string path, string prefixToRemove, string prefixToAdd = "")
{ {
if (File.Exists(path)) if (File.Exists(path))
{ {
@@ -147,17 +147,17 @@ public class FrontendService
else else
{ {
foreach (var directoryItem in Directory.EnumerateFileSystemEntries(path)) foreach (var directoryItem in Directory.EnumerateFileSystemEntries(path))
await ArchiveFsItem(archive, directoryItem, prefixToRemove, prefixToAdd); await ArchiveFsItemAsync(archive, directoryItem, prefixToRemove, prefixToAdd);
} }
} }
private async Task ArchiveText(ZipArchive archive, string path, string content) private async Task ArchiveTextAsync(ZipArchive archive, string path, string content)
{ {
var data = Encoding.UTF8.GetBytes(content); var data = Encoding.UTF8.GetBytes(content);
await ArchiveBytes(archive, path, data); await ArchiveBytesAsync(archive, path, data);
} }
private async Task ArchiveBytes(ZipArchive archive, string path, byte[] bytes) private async Task ArchiveBytesAsync(ZipArchive archive, string path, byte[] bytes)
{ {
var entry = archive.CreateEntry(path); var entry = archive.CreateEntry(path);
await using var dataStream = entry.Open(); await using var dataStream = entry.Open();

View File

@@ -33,7 +33,7 @@ public class MetricsBackgroundService : BackgroundService
Metrics = metrics.ToArray(); Metrics = metrics.ToArray();
} }
private async Task Initialize() private async Task InitializeAsync()
{ {
Logger.LogDebug( Logger.LogDebug(
"Initializing metrics: {names}", "Initializing metrics: {names}",
@@ -41,12 +41,12 @@ public class MetricsBackgroundService : BackgroundService
); );
foreach (var metric in Metrics) foreach (var metric in Metrics)
await metric.Initialize(Meter); await metric.InitializeAsync(Meter);
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
await Initialize(); await InitializeAsync();
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
@@ -56,7 +56,7 @@ public class MetricsBackgroundService : BackgroundService
{ {
try try
{ {
await metric.Run(scope.ServiceProvider, stoppingToken); await metric.RunAsync(scope.ServiceProvider, stoppingToken);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
@@ -73,7 +73,7 @@ public class MetricsBackgroundService : BackgroundService
} }
await Task.Delay( await Task.Delay(
TimeSpan.FromSeconds(Configuration.Metrics.Interval), TimeSpan.FromSeconds(Configuration.OpenTelemetry.Metrics.Interval),
stoppingToken stoppingToken
); );
} }

View File

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

@@ -19,11 +19,11 @@ public class UserDeletionService
Handlers = handlers.ToArray(); Handlers = handlers.ToArray();
} }
public async Task<UserDeleteValidationResult> Validate(User user) public async Task<UserDeleteValidationResult> ValidateAsync(User user)
{ {
foreach (var handler in Handlers) foreach (var handler in Handlers)
{ {
var result = await handler.Validate(user); var result = await handler.ValidateAsync(user);
if (!result.IsAllowed) if (!result.IsAllowed)
return result; return result;
@@ -32,11 +32,11 @@ public class UserDeletionService
return UserDeleteValidationResult.Allow(); return UserDeleteValidationResult.Allow();
} }
public async Task Delete(User user, bool force) public async Task DeleteAsync(User user, bool force)
{ {
foreach (var handler in Handlers) foreach (var handler in Handlers)
await Delete(user, force); await handler.DeleteAsync(user, force);
await UserRepository.Remove(user); await UserRepository.RemoveAsync(user);
} }
} }

View File

@@ -1,64 +1,189 @@
using System.Text; using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using MoonCore.Extended.JwtInvalidation;
using MoonCore.Permissions; using MoonCore.Permissions;
using Moonlight.ApiServer.Implementations; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Interfaces; using Moonlight.ApiServer.Implementations.LocalAuth;
using Moonlight.ApiServer.Services; using Moonlight.ApiServer.Services;
namespace Moonlight.ApiServer.Startup; namespace Moonlight.ApiServer.Startup;
public partial class Startup public static partial class Startup
{ {
private Task RegisterAuth() private static void AddAuth(this WebApplicationBuilder builder)
{ {
WebApplicationBuilder.Services var configuration = AppConfiguration.CreateEmpty();
.AddAuthentication("coreAuthentication") builder.Configuration.Bind(configuration);
.AddJwtBearer("coreAuthentication", options =>
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() options.TokenValidationParameters = new()
{ {
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
Configuration.Authentication.Secret configuration.Authentication.Secret
)), )),
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
ValidateLifetime = true, ValidateLifetime = true,
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
ValidateAudience = true, ValidateAudience = true,
ValidAudience = Configuration.PublicUrl, ValidAudience = configuration.PublicUrl,
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = Configuration.PublicUrl ValidIssuer = configuration.PublicUrl
}; };
});
WebApplicationBuilder.Services.AddJwtBearerInvalidation("coreAuthentication");
WebApplicationBuilder.Services.AddScoped<IJwtInvalidateHandler, UserAuthInvalidation>();
WebApplicationBuilder.Services.AddAuthorization(); 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();
WebApplicationBuilder.Services.AddAuthorizationPermissions(options => builder.Services.AddAuthorizationPermissions(options =>
{ {
options.ClaimName = "permissions"; options.ClaimName = "Permissions";
options.Prefix = "permissions:"; options.Prefix = "permissions:";
}); });
builder.Services.AddScoped<UserAuthService>();
builder.Services.AddScoped<ApiKeyAuthService>();
// Add local oauth2 provider if enabled // Setup data protection storage within storage folder
if (Configuration.Authentication.EnableLocalOAuth2) // so its persists in containers
WebApplicationBuilder.Services.AddScoped<IOAuth2Provider, LocalOAuth2Provider>(); var dpKeyPath = Path.Combine("storage", "dataProtectionKeys");
WebApplicationBuilder.Services.AddScoped<UserDeletionService>(); Directory.CreateDirectory(dpKeyPath);
return Task.CompletedTask; builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(
new DirectoryInfo(dpKeyPath)
);
builder.Services.AddScoped<UserDeletionService>();
} }
private Task UseAuth() private static void UseAuth(this WebApplication application)
{ {
WebApplication.UseAuthentication(); application.UseAuthentication();
application.UseAuthorization();
WebApplication.UseAuthorization();
return Task.CompletedTask;
} }
} }

View File

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

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MoonCore.EnvConfiguration; using MoonCore.EnvConfiguration;
@@ -6,30 +7,23 @@ using Moonlight.ApiServer.Configuration;
namespace Moonlight.ApiServer.Startup; namespace Moonlight.ApiServer.Startup;
public partial class Startup public static partial class Startup
{ {
private async Task SetupAppConfiguration() private static void AddConfiguration(this WebApplicationBuilder builder)
{ {
var configPath = Path.Combine("storage", "config.yml"); // Yaml
var yamlPath = Path.Combine("storage", "config.yml");
await YamlDefaultGenerator.Generate<AppConfiguration>(configPath); YamlDefaultGenerator.GenerateAsync<AppConfiguration>(yamlPath).Wait();
// Configure configuration (wow) builder.Configuration.AddYamlFile(yamlPath);
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.AddYamlFile(configPath);
configurationBuilder.AddEnvironmentVariables(prefix: "MOONLIGHT_", separator: "_");
var configurationRoot = configurationBuilder.Build();
// Retrieve configuration // Env
Configuration = AppConfiguration.CreateEmpty(); builder.Configuration.AddEnvironmentVariables(prefix: "MOONLIGHT_", separator: "_");
configurationRoot.Bind(Configuration);
} var configuration = AppConfiguration.CreateEmpty();
builder.Configuration.Bind(configuration);
private Task RegisterAppConfiguration()
{ builder.Services.AddSingleton(configuration);
WebApplicationBuilder.Services.AddSingleton(Configuration);
return Task.CompletedTask;
} }
} }

View File

@@ -1,25 +1,17 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MoonCore.Extended.Abstractions; using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Extensions; using MoonCore.Extended.Extensions;
namespace Moonlight.ApiServer.Startup; namespace Moonlight.ApiServer.Startup;
public partial class Startup public static partial class Startup
{ {
private Task RegisterDatabase() private static void AddDatabase(this WebApplicationBuilder builder)
{ {
WebApplicationBuilder.Services.AddDatabaseMappings(); builder.Services.AddDbAutoMigrations();
WebApplicationBuilder.Services.AddServiceCollectionAccessor(); builder.Services.AddDatabaseMappings();
WebApplicationBuilder.Services.AddScoped(typeof(DatabaseRepository<>)); builder.Services.AddScoped(typeof(DatabaseRepository<>));
return Task.CompletedTask;
}
private async Task PrepareDatabase()
{
await WebApplication.Services.EnsureDatabaseMigrated();
WebApplication.Services.GenerateDatabaseMappings();
} }
} }

View File

@@ -1,5 +1,6 @@
using Hangfire; using Hangfire;
using Hangfire.EntityFrameworkCore; using Hangfire.EntityFrameworkCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -7,11 +8,11 @@ using Moonlight.ApiServer.Database;
namespace Moonlight.ApiServer.Startup; namespace Moonlight.ApiServer.Startup;
public partial class Startup public static partial class Startup
{ {
private Task RegisterHangfire() private static void AddMoonlightHangfire(this WebApplicationBuilder builder)
{ {
WebApplicationBuilder.Services.AddHangfire((provider, configuration) => builder.Services.AddHangfire((provider, configuration) =>
{ {
configuration.SetDataCompatibilityLevel(CompatibilityLevel.Version_180); configuration.SetDataCompatibilityLevel(CompatibilityLevel.Version_180);
configuration.UseSimpleAssemblyNameTypeSerializer(); configuration.UseSimpleAssemblyNameTypeSerializer();
@@ -23,26 +24,22 @@ public partial class Startup
}, new EFCoreStorageOptions()); }, new EFCoreStorageOptions());
}); });
WebApplicationBuilder.Services.AddHangfireServer(); builder.Services.AddHangfireServer();
WebApplicationBuilder.Logging.AddFilter( builder.Logging.AddFilter(
"Hangfire.Server.BackgroundServerProcess", "Hangfire.Server.BackgroundServerProcess",
LogLevel.Warning LogLevel.Warning
); );
WebApplicationBuilder.Logging.AddFilter( builder.Logging.AddFilter(
"Hangfire.BackgroundJobServer", "Hangfire.BackgroundJobServer",
LogLevel.Warning LogLevel.Warning
); );
return Task.CompletedTask;
} }
private Task UseHangfire() private static void UseMoonlightHangfire(this WebApplication application)
{ {
if (WebApplication.Environment.IsDevelopment()) if (application.Environment.IsDevelopment())
WebApplication.UseHangfireDashboard(); application.UseHangfireDashboard();
return Task.CompletedTask;
} }
} }

View File

@@ -1,61 +1,55 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MoonCore.Logging; using MoonCore.Logging;
namespace Moonlight.ApiServer.Startup; namespace Moonlight.ApiServer.Startup;
public partial class Startup public static partial class Startup
{ {
private Task SetupLogging() private static void AddLogging(this WebApplicationBuilder builder)
{ {
var loggerFactory = new LoggerFactory(); // Logging providers
loggerFactory.AddAnsiConsole(); builder.Logging.ClearProviders();
Logger = loggerFactory.CreateLogger<Startup>(); builder.Logging.AddAnsiConsole();
builder.Logging.AddFile(Path.Combine("storage", "logs", "moonlight.log"));
return Task.CompletedTask;
}
private async Task RegisterLogging()
{
// Configure application logging
WebApplicationBuilder.Logging.ClearProviders();
WebApplicationBuilder.Logging.AddAnsiConsole();
WebApplicationBuilder.Logging.AddFile(Path.Combine("storage", "logs", "moonlight.log"));
// Logging levels // Logging levels
var logConfigPath = Path.Combine("storage", "logConfig.json"); var logConfigPath = Path.Combine("storage", "logConfig.json");
// Ensure logging config, add a default one is missing // Ensure default log levels exist
if (!File.Exists(logConfigPath)) if (!File.Exists(logConfigPath))
{ {
var defaultLogLevels = new Dictionary<string, string> var defaultLogLevels = new Dictionary<string, string>
{ {
{ "Default", "Information" }, { "Default", "Information" },
{ "Microsoft.AspNetCore", "Warning" }, { "Microsoft.AspNetCore", "Warning" },
{ "System.Net.Http.HttpClient", "Warning" } { "System.Net.Http.HttpClient", "Warning" },
{ "Moonlight.ApiServer.Implementations.LocalAuth.LocalAuthHandler", "Warning" }
}; };
var logLevelsJson = JsonSerializer.Serialize(defaultLogLevels); var logLevelsJson = JsonSerializer.Serialize(defaultLogLevels);
await File.WriteAllTextAsync(logConfigPath, logLevelsJson); File.WriteAllText(logConfigPath, logLevelsJson);
} }
// Add logging configuration // Read log levels
var logLevels = JsonSerializer.Deserialize<Dictionary<string, string>>( var logLevels = JsonSerializer.Deserialize<Dictionary<string, string>>(
await File.ReadAllTextAsync(logConfigPath) File.ReadAllText(logConfigPath)
)!; )!;
// Apply configured log levels
foreach (var level in logLevels) foreach (var level in logLevels)
WebApplicationBuilder.Logging.AddFilter(level.Key, Enum.Parse<LogLevel>(level.Value)); builder.Logging.AddFilter(level.Key, Enum.Parse<LogLevel>(level.Value));
// Mute exception handler middleware // Mute exception handler middleware
// https://github.com/dotnet/aspnetcore/issues/19740 // https://github.com/dotnet/aspnetcore/issues/19740
WebApplicationBuilder.Logging.AddFilter( builder.Logging.AddFilter(
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware", "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
LogLevel.Critical LogLevel.Critical
); );
WebApplicationBuilder.Logging.AddFilter( builder.Logging.AddFilter(
"Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware", "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware",
LogLevel.Critical LogLevel.Critical
); );

View File

@@ -1,12 +1,14 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Moonlight.ApiServer.Configuration;
namespace Moonlight.ApiServer.Startup; namespace Moonlight.ApiServer.Startup;
public partial class Startup public static partial class Startup
{ {
private Task PrintVersion() private static void PrintVersionAsync()
{ {
// Fancy start console output... yes very fancy :> // Fancy start console output... yes very fancy :>
var rainbow = new Crayon.Rainbow(0.5); var rainbow = new Crayon.Rainbow(0.5);
@@ -21,23 +23,22 @@ public partial class Startup
} }
Console.WriteLine(); Console.WriteLine();
return Task.CompletedTask;
} }
private Task CreateStorage() private static void CreateStorageAsync()
{ {
Directory.CreateDirectory("storage"); Directory.CreateDirectory("storage");
Directory.CreateDirectory(Path.Combine("storage", "logs")); Directory.CreateDirectory(Path.Combine("storage", "logs"));
return Task.CompletedTask;
} }
private Task RegisterCors()
{
var allowedOrigins = Configuration.Kestrel.AllowedOrigins;
WebApplicationBuilder.Services.AddCors(options => 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(); var cors = new CorsPolicyBuilder();
@@ -60,14 +61,10 @@ public partial class Startup
cors.Build() cors.Build()
); );
}); });
return Task.CompletedTask;
} }
private Task UseCors() private static void UseMoonlightCors(this WebApplication application)
{ {
WebApplication.UseCors(); application.UseCors();
return Task.CompletedTask;
} }
} }

View File

@@ -1,87 +1,25 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Logging;
using MoonCore.Logging;
using Moonlight.ApiServer.Plugins; using Moonlight.ApiServer.Plugins;
namespace Moonlight.ApiServer.Startup; namespace Moonlight.ApiServer.Startup;
public partial class Startup public static partial class Startup
{ {
private IServiceProvider PluginLoadServiceProvider; private static void AddPlugins(this WebApplicationBuilder builder, IPluginStartup[] startups)
private IPluginStartup[] PluginStartups;
private Task InitializePlugins()
{ {
// Create service provider for starting up foreach (var startup in startups)
var serviceCollection = new ServiceCollection(); startup.AddPlugin(builder);
serviceCollection.AddSingleton(Configuration);
serviceCollection.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddAnsiConsole();
});
PluginLoadServiceProvider = serviceCollection.BuildServiceProvider();
return Task.CompletedTask;
} }
private async Task HookPluginBuild() private static void UsePlugins(this WebApplication application, IPluginStartup[] startups)
{ {
foreach (var pluginAppStartup in PluginStartups) foreach (var startup in startups)
{ startup.UsePlugin(application);
try
{
await pluginAppStartup.BuildApplication(PluginLoadServiceProvider, WebApplicationBuilder);
}
catch (Exception e)
{
Logger.LogError(
"An error occured while processing 'BuildApp' for '{name}': {e}",
pluginAppStartup.GetType().FullName,
e
);
}
}
} }
private async Task HookPluginConfigure() private static void MapPlugins(this WebApplication application, IPluginStartup[] startups)
{ {
foreach (var pluginAppStartup in PluginStartups) foreach (var startup in startups)
{ startup.MapPlugin(application);
try
{
await pluginAppStartup.ConfigureApplication(PluginLoadServiceProvider, WebApplication);
}
catch (Exception e)
{
Logger.LogError(
"An error occured while processing 'ConfigureApp' for '{name}': {e}",
pluginAppStartup.GetType().FullName,
e
);
}
}
}
private async Task HookPluginEndpoints()
{
foreach (var pluginEndpointStartup in PluginStartups)
{
try
{
await pluginEndpointStartup.ConfigureEndpoints(PluginLoadServiceProvider, WebApplication);
}
catch (Exception e)
{
Logger.LogError(
"An error occured while processing 'ConfigureEndpoints' for '{name}': {e}",
pluginEndpointStartup.GetType().FullName,
e
);
}
}
} }
} }

View File

@@ -0,0 +1,26 @@
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,67 +1,42 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Logging;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Plugins; using Moonlight.ApiServer.Plugins;
namespace Moonlight.ApiServer.Startup; namespace Moonlight.ApiServer.Startup;
public partial class Startup public static partial class Startup
{ {
private string[] Args; public static void AddMoonlight(this WebApplicationBuilder builder, IPluginStartup[] startups)
// Logger
public ILogger<Startup> Logger { get; private set; }
// Configuration
public AppConfiguration Configuration { get; private set; }
// WebApplication Stuff
public WebApplication WebApplication { get; private set; }
public WebApplicationBuilder WebApplicationBuilder { get; private set; }
public Task Initialize(string[] args, IPluginStartup[]? plugins = null)
{ {
Args = args; PrintVersionAsync();
PluginStartups = plugins ?? []; CreateStorageAsync();
return Task.CompletedTask; builder.AddConfiguration();
builder.AddLogging();
builder.ConfigureKestrel();
builder.AddBase(startups);
builder.AddDatabase();
builder.AddAuth();
builder.AddMoonlightCors();
builder.AddMoonlightHangfire();
builder.AddMoonlightSignalR();
builder.AddPlugins(startups);
} }
public async Task AddMoonlight(WebApplicationBuilder builder) public static void UseMoonlight(this WebApplication application, IPluginStartup[] startups)
{ {
WebApplicationBuilder = builder; application.UseBase();
application.UseMoonlightCors();
await PrintVersion(); application.UseAuth();
application.UseMoonlightHangfire();
await CreateStorage(); application.UsePlugins(startups);
await SetupAppConfiguration();
await SetupLogging();
await InitializePlugins();
await ConfigureKestrel();
await RegisterAppConfiguration();
await RegisterLogging();
await RegisterBase();
await RegisterDatabase();
await RegisterAuth();
await RegisterCors();
await RegisterHangfire();
await HookPluginBuild();
} }
public async Task AddMoonlight(WebApplication application) public static void MapMoonlight(this WebApplication application, IPluginStartup[] startups)
{ {
WebApplication = application; application.MapBase();
application.MapMoonlightSignalR();
await PrepareDatabase(); application.MapPlugins(startups);
await UseCors();
await UseBase();
await UseAuth();
await UseHangfire();
await HookPluginConfigure();
await MapBase();
await HookPluginEndpoints();
} }
} }

View File

@@ -12,11 +12,15 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.8" /> <PackageReference Include="MoonCore.PluginFramework" Version="1.0.9" />
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.2" /> <PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.5"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.5" PrivateAssets="all"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.9" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\css\" />
</ItemGroup> </ItemGroup>
<Import Project="Plugins.props" /> <Import Project="Plugins.props" />

View File

@@ -5,16 +5,12 @@ using Moonlight.Client.Startup;
var pluginLoader = new PluginLoader(); var pluginLoader = new PluginLoader();
pluginLoader.Initialize(); pluginLoader.Initialize();
var startup = new Startup(); var builder = WebAssemblyHostBuilder.CreateDefault(args);
await startup.Initialize(pluginLoader.Instances); builder.AddMoonlight(pluginLoader.Instances);
var wasmHostBuilder = WebAssemblyHostBuilder.CreateDefault(args); var app = builder.Build();
await startup.AddMoonlight(wasmHostBuilder); app.ConfigureMoonlight(pluginLoader.Instances);
var wasmApp = wasmHostBuilder.Build(); await app.RunAsync();
await startup.AddMoonlight(wasmApp);
await wasmApp.RunAsync();

View File

@@ -9,14 +9,10 @@
@theme { @theme {
--font-inter: "Inter", var(--font-sans); --font-inter: "Inter", var(--font-sans);
--font-scp: "Source Code Pro", var(--font-mono); --font-scp: "Source Code Pro", var(--font-mono);
--color-background: var(--mooncore-color-background);
--color-base-150: var(--mooncore-color-base-150);
--color-base-250: var(--mooncore-color-base-250);
} }
@plugin "flyonui" { @plugin "flyonui" {
themes: mooncore --default; themes: moonlight --default;
} }
@source "./node_modules/flyonui/dist/index.js"; @source "./node_modules/flyonui/dist/index.js";
@@ -45,73 +41,50 @@
width: var(--blazor-load-percentage, 0%); width: var(--blazor-load-percentage, 0%);
} }
@plugin "flyonui/theme" {
name: "mooncore";
default: true;
prefersdark: true;
color-scheme: "dark";
--color-base-100: var(--mooncore-color-base-100);
--color-base-200: var(--mooncore-color-base-200);
--color-base-300: var(--mooncore-color-base-300);
--color-base-content: var(--mooncore-color-base-content);
--color-primary: var(--mooncore-color-primary);
--color-primary-content: var(--mooncore-color-primary-content);
--color-secondary: var(--mooncore-color-secondary);
--color-secondary-content: var(--mooncore-color-secondary-content);
--color-accent: var(--mooncore-color-accent);
--color-accent-content: var(--mooncore-color-accent-content);
--color-neutral: var(--mooncore-color-neutral);
--color-neutral-content: var(--mooncore-color-neutral-content);
--color-info: var(--mooncore-color-info);
--color-info-content: var(--mooncore-color-info-content);
--color-success: var(--mooncore-color-success);
--color-success-content: var(--mooncore-color-success-content);
--color-warning: var(--mooncore-color-warning);
--color-warning-content: var(--mooncore-color-warning-content);
--color-error: var(--mooncore-color-error);
--color-error-content: var(--mooncore-color-error-content);
--radius-selector: var(--mooncore-radius-selector);
--radius-field: var(--mooncore-radius-field);
--radius-box: var(--mooncore-radius-box);
--size-selector: var(--mooncore-size-selector);
--size-field: var(--mooncore-size-field);
--border: var(--mooncore-border);
--depth: var(--mooncore-depth);
--noise: var(--mooncore-noise);
}
@layer utilities { @layer utilities {
.btn { .btn {
@apply text-sm font-medium inline-flex items-center justify-center; @apply text-sm font-medium inline-flex items-center justify-center;
} }
.checkbox { .checkbox {
@apply border-base-content/30 bg-base-100; @apply border-base-content/30 bg-base-100;
} }
.input { .input {
@apply !border-base-content/20 border-2 ring-0! outline-0! focus:border-primary! focus-within:border-primary! bg-base-200/50; @apply !border-base-content/20 border-2 ring-0! outline-0! focus:border-primary! focus-within:border-primary! bg-base-100/50;
}
.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;
} }
.select { .select {
@apply !border-base-content/20 border-2 ring-0! outline-0! focus:border-primary! focus-within:border-primary! bg-base-200/50; @apply !border-base-content/20 border-2 ring-0! outline-0! focus:border-primary! focus-within:border-primary! bg-base-100/50;
} }
.table { .table {
:where(th, td) { :where(th, td) {
@apply py-1.5; @apply py-1.5;
} }
} }
.dropdown-item { .dropdown-item {
@apply px-2.5 py-1.5 text-sm; @apply px-2.5 py-1.5 text-sm;
} }
.dropdown-menu { .advance-select-menu {
@apply bg-base-150; @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,33 +1,35 @@
@theme { @plugin "flyonui/theme" {
--mooncore-color-background: #0c0f18; name: "moonlight";
--mooncore-color-base-100: #1e2b47; prefersdark: true;
--mooncore-color-base-150: #1a2640; color-scheme: "dark";
--mooncore-color-base-200: #101a2e;
--mooncore-color-base-250: #0f1729; --color-base-100: #192032;
--mooncore-color-base-300: #0c1221; --color-base-200: #101522;
--mooncore-color-base-content: #dde5f5; --color-base-300: #070a14;
--mooncore-color-primary: oklch(.511 .262 276.966);
--mooncore-color-primary-content: #dde5f5; --color-base-content: #dde5f5;
--mooncore-color-secondary: oklch(37% 0.034 259.733); --color-primary: oklch(.511 .262 276.966);
--mooncore-color-secondary-content: #dde5f5; --color-primary-content: #dde5f5;
--mooncore-color-accent: oklch(.627 .265 303.9); --color-secondary: oklch(37% 0.034 259.733);
--mooncore-color-accent-content: #dde5f5; --color-secondary-content: #dde5f5;
--mooncore-color-neutral: #dde5f5; --color-accent: oklch(49.1% 0.27 292.581);
--mooncore-color-neutral-content: oklch(14% 0.005 285.823); --color-accent-content: #dde5f5;
--mooncore-color-info: oklch(.546 .245 262.881); --color-neutral: oklch(27% 0.041 260.031);
--mooncore-color-info-content: #dde5f5; --color-neutral-content: oklch(92% 0.004 286.32);
--mooncore-color-success: oklch(.627 .194 149.214); --color-info: oklch(.546 .245 262.881);
--mooncore-color-success-content: #dde5f5; --color-info-content: #dde5f5;
--mooncore-color-warning: oklch(.828 .189 84.429); --color-success: oklch(.627 .194 149.214);
--mooncore-color-warning-content: #dde5f5; --color-success-content: #dde5f5;
--mooncore-color-error: oklch(.586 .253 17.585); --color-warning: oklch(.828 .189 84.429);
--mooncore-color-error-content: #dde5f5; --color-warning-content: #dde5f5;
--mooncore-radius-selector: 0.25rem; --color-error: oklch(.586 .253 17.585);
--mooncore-radius-field: 0.5rem; --color-error-content: #dde5f5;
--mooncore-radius-box: 0.5rem; --radius-selector: 0.25rem;
--mooncore-size-selector: 0.25rem; --radius-field: 0.5rem;
--mooncore-size-field: 0.25rem; --radius-box: 0.5rem;
--mooncore-border: 1px; --size-selector: 0.25rem;
--mooncore-depth: 0; --size-field: 0.25rem;
--mooncore-noise: 0; --border: 1px;
--depth: 0;
--noise: 0;
} }

View File

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

View File

@@ -7,14 +7,14 @@ namespace Moonlight.Client.Implementations;
public class CoreStartup : IPluginStartup public class CoreStartup : IPluginStartup
{ {
public Task BuildApplication(IServiceProvider serviceProvider, WebAssemblyHostBuilder builder) public void AddPlugin(WebAssemblyHostBuilder builder)
{ {
builder.Services.AddSingleton<ISidebarItemProvider, DefaultSidebarItemProvider>(); builder.Services.AddSingleton<ISidebarItemProvider, DefaultSidebarItemProvider>();
builder.Services.AddSingleton<IOverviewElementProvider, DefaultOverviewElementProvider>(); builder.Services.AddSingleton<IOverviewElementProvider, DefaultOverviewElementProvider>();
return Task.CompletedTask;
} }
public Task ConfigureApplication(IServiceProvider serviceProvider, WebAssemblyHost app) public void ConfigurePlugin(WebAssemblyHost app)
=> Task.CompletedTask; {
}
} }

View File

@@ -0,0 +1,19 @@
using MoonCore.Blazor.FlyonUi.Exceptions;
namespace Moonlight.Client.Implementations;
public class LogErrorFilter : IGlobalErrorFilter
{
private readonly ILogger<LogErrorFilter> Logger;
public LogErrorFilter(ILogger<LogErrorFilter> logger)
{
Logger = logger;
}
public Task<bool> HandleExceptionAsync(Exception ex)
{
Logger.LogError(ex, "Global error processed");
return Task.FromResult(false);
}
}

View File

@@ -1,14 +1,16 @@
using MoonCore.Blazor.FlyonUi.Files; using MoonCore.Blazor.FlyonUi.Files;
using MoonCore.Blazor.FlyonUi.Files.Manager; using MoonCore.Blazor.FlyonUi.Files.Manager;
using MoonCore.Blazor.FlyonUi.Files.Manager.Abstractions;
using MoonCore.Helpers; using MoonCore.Helpers;
using Moonlight.Shared.Http.Requests.Admin.Sys.Files;
using Moonlight.Shared.Http.Responses.Admin.Sys; using Moonlight.Shared.Http.Responses.Admin.Sys;
namespace Moonlight.Client.Implementations; namespace Moonlight.Client.Implementations;
public class SystemFsAccess : IFsAccess public class SystemFsAccess : IFsAccess, ICombineAccess, IArchiveAccess, IDownloadUrlAccess
{ {
private readonly HttpApiClient ApiClient; private readonly HttpApiClient ApiClient;
private const string BaseApiUrl = "api/admin/system/files"; private const string BaseApiUrl = "api/admin/system/files";
public SystemFsAccess(HttpApiClient apiClient) public SystemFsAccess(HttpApiClient apiClient)
@@ -16,21 +18,21 @@ public class SystemFsAccess : IFsAccess
ApiClient = apiClient; ApiClient = apiClient;
} }
public async Task CreateFile(string path) public async Task CreateFileAsync(string path)
{ {
await ApiClient.Post( await ApiClient.Post(
$"{BaseApiUrl}/touch?path={path}" $"{BaseApiUrl}/touch?path={path}"
); );
} }
public async Task CreateDirectory(string path) public async Task CreateDirectoryAsync(string path)
{ {
await ApiClient.Post( await ApiClient.Post(
$"{BaseApiUrl}/mkdir?path={path}" $"{BaseApiUrl}/mkdir?path={path}"
); );
} }
public async Task<FsEntry[]> List(string path) public async Task<FsEntry[]> ListAsync(string path)
{ {
var entries = await ApiClient.GetJson<FileSystemEntryResponse[]>( var entries = await ApiClient.GetJson<FileSystemEntryResponse[]>(
$"{BaseApiUrl}/list?path={path}" $"{BaseApiUrl}/list?path={path}"
@@ -46,43 +48,107 @@ public class SystemFsAccess : IFsAccess
}).ToArray(); }).ToArray();
} }
public async Task Move(string oldPath, string newPath) public async Task MoveAsync(string oldPath, string newPath)
{ {
await ApiClient.Post( await ApiClient.Post(
$"{BaseApiUrl}/move?oldPath={oldPath}&newPath={newPath}" $"{BaseApiUrl}/move?oldPath={oldPath}&newPath={newPath}"
); );
} }
public Task Read(string path, Func<Stream, Task> onHandleData) public async Task ReadAsync(string path, Func<Stream, Task> onHandleData)
{ {
throw new NotImplementedException(); await using var stream = await ApiClient.GetStream(
$"{BaseApiUrl}/download?path={path}"
);
await onHandleData.Invoke(stream);
stream.Close();
} }
public Task Write(string path, Stream dataStream) public async Task WriteAsync(string path, Stream dataStream)
{ {
throw new NotImplementedException(); using var multiPartForm = new MultipartFormDataContent();
multiPartForm.Add(new StreamContent(dataStream), "file", "file");
await ApiClient.Post(
$"{BaseApiUrl}/upload?path={path}",
multiPartForm
);
} }
public async Task Delete(string path) public async Task DeleteAsync(string path)
{ {
await ApiClient.Delete( await ApiClient.Delete(
$"{BaseApiUrl}/delete?path={path}" $"{BaseApiUrl}/delete?path={path}"
); );
} }
public async Task UploadChunk(string path, int chunkId, long chunkSize, long totalSize, byte[] data) public async Task CombineAsync(string destination, string[] files)
{ {
using var formContent = new MultipartFormDataContent();
formContent.Add(new ByteArrayContent(data), "file", "file");
await ApiClient.Post( await ApiClient.Post(
$"{BaseApiUrl}/upload?path={path}&chunkId={chunkId}&chunkSize={chunkSize}&totalSize={totalSize}", $"{BaseApiUrl}/combine",
formContent new CombineRequest()
{
Destination = destination,
Files = files
}
); );
} }
public Task<byte[]> DownloadChunk(string path, int chunkId, long chunkSize) public ArchiveFormat[] ArchiveFormats { get; } =
[
new("zip", ["zip"], "Zip Archive"),
new("tar.gz", ["tar.gz"], "Tar.gz Archive")
];
public async Task ArchiveAsync(
string destination,
ArchiveFormat format,
string root,
FsEntry[] files,
Func<string, Task>? onProgress = null
)
{ {
throw new NotImplementedException(); await ApiClient.Post($"{BaseApiUrl}/compress", new CompressRequest()
{
Destination = destination,
Items = files.Select(x => x.Name).ToArray(),
Root = root,
Format = format.Identifier
});
}
public async Task UnarchiveAsync(
string path,
ArchiveFormat format,
string destination,
Func<string, Task>? onProgress = null)
{
await ApiClient.Post(
$"{BaseApiUrl}/decompress",
new DecompressRequest()
{
Format = format.Identifier,
Destination = destination,
Path = path
}
);
}
public async Task<string> GetFileUrlAsync(string path)
=> await GetDownloadUrlAsync(path);
public async Task<string> GetFolderUrlAsync(string path)
=> await GetDownloadUrlAsync(path);
private async Task<string> GetDownloadUrlAsync(string path)
{
var response = await ApiClient.PostJson<DownloadUrlResponse>(
$"{BaseApiUrl}/downloadUrl?path={path}"
);
return response.Url;
} }
} }

View File

@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Components;
using MoonCore.Blazor.FlyonUi.Exceptions;
using MoonCore.Blazor.FlyonUi.Toasts;
using MoonCore.Exceptions;
namespace Moonlight.Client.Implementations;
public class UnauthenticatedErrorFilter : IGlobalErrorFilter
{
private readonly NavigationManager Navigation;
private readonly ToastService ToastService;
public UnauthenticatedErrorFilter(
NavigationManager navigation,
ToastService toastService
)
{
Navigation = navigation;
ToastService = toastService;
}
public async Task<bool> HandleExceptionAsync(Exception ex)
{
if (ex is not HttpApiException { Status: 401 })
return false;
await ToastService.InfoAsync("Session expired", "Your session has expired. Reloading..");
Navigation.NavigateTo("/api/auth/logout", true);
return true;
}
}

View File

@@ -0,0 +1,14 @@
using Moonlight.Shared.Misc;
namespace Moonlight.Client.Models;
public class ThemeTransferModel
{
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; } = new();
}

View File

@@ -12,7 +12,7 @@
<PropertyGroup> <PropertyGroup>
<PackageTags>frontend</PackageTags> <PackageTags>frontend</PackageTags>
<PackageId>Moonlight.Client</PackageId> <PackageId>Moonlight.Client</PackageId>
<Version>2.1.7</Version> <Version>2.1.15</Version>
<Authors>Moonlight Panel</Authors> <Authors>Moonlight Panel</Authors>
<Description>A build of the client for moonlight development</Description> <Description>A build of the client for moonlight development</Description>
<PackageProjectUrl>https://github.com/Moonlight-Panel/Moonlight</PackageProjectUrl> <PackageProjectUrl>https://github.com/Moonlight-Panel/Moonlight</PackageProjectUrl>
@@ -21,26 +21,20 @@
<CompressionEnabled>false</CompressionEnabled> <CompressionEnabled>false</CompressionEnabled>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Blazor-ApexCharts" Version="6.0.0" /> <PackageReference Include="Blazor-ApexCharts" Version="6.0.2" />
<PackageReference Include="MoonCore" Version="1.9.2" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.9" />
<PackageReference Include="MoonCore.Blazor" Version="1.3.1" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.9" />
<PackageReference Include="MoonCore.Blazor.FlyonUi" Version="1.0.9" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
<PackageReference Include="MoonCore" Version="2.0.6" />
<PackageReference Include="MoonCore.Blazor.FlyonUi" Version="1.3.4" />
<PackageReference Include="System.Security.Claims" Version="4.3.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Remove="storage\**\*" /> <Compile Remove="storage\**\*" />
<Content Remove="storage\**\*" /> <Content Remove="storage\**\*" />
<None Remove="storage\**\*" /> <None Remove="storage\**\*" />
</ItemGroup> </ItemGroup>
<!--
Specify the /p:BuildPWA=true flag to build moonlight as a PWA.
This flag is by default disabled to allow nuget package generation
-->
<PropertyGroup Condition="'$(BuildPWA)' == 'true'">
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
</PropertyGroup>
<ItemGroup Condition="'$(BuildPWA)' == 'true'">
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Moonlight.Shared\Moonlight.Shared.csproj" /> <ProjectReference Include="..\Moonlight.Shared\Moonlight.Shared.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -4,6 +4,6 @@ namespace Moonlight.Client.Plugins;
public interface IPluginStartup public interface IPluginStartup
{ {
public Task BuildApplication(IServiceProvider serviceProvider, WebAssemblyHostBuilder builder); public void AddPlugin(WebAssemblyHostBuilder builder);
public Task ConfigureApplication(IServiceProvider serviceProvider, WebAssemblyHost app); public void ConfigurePlugin(WebAssemblyHost app);
} }

View File

@@ -1,119 +0,0 @@
using System.Security.Claims;
using System.Web;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using MoonCore.Blazor.FlyonUi.Auth;
using MoonCore.Blazor.Services;
using MoonCore.Exceptions;
using MoonCore.Helpers;
using Moonlight.Shared.Http.Requests.Auth;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.Client.Services;
public class RemoteAuthStateManager : AuthenticationStateManager
{
private readonly NavigationManager NavigationManager;
private readonly HttpApiClient HttpApiClient;
private readonly LocalStorageService LocalStorageService;
private readonly ILogger<RemoteAuthStateManager> Logger;
public RemoteAuthStateManager(
HttpApiClient httpApiClient,
LocalStorageService localStorageService,
NavigationManager navigationManager,
ILogger<RemoteAuthStateManager> logger
)
{
HttpApiClient = httpApiClient;
LocalStorageService = localStorageService;
NavigationManager = navigationManager;
Logger = logger;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
=> await LoadAuthState();
public override async Task HandleLogin()
{
var uri = new Uri(NavigationManager.Uri);
var codeParam = HttpUtility.ParseQueryString(uri.Query).Get("code");
if (string.IsNullOrEmpty(codeParam)) // If this is true, we need to log in the user
{
await StartLogin();
}
else
{
try
{
var loginCompleteData = await HttpApiClient.PostJson<LoginCompleteResponse>(
"api/auth/complete",
new LoginCompleteRequest()
{
Code = codeParam
}
);
await LocalStorageService.SetString("AccessToken", loginCompleteData.AccessToken);
NavigationManager.NavigateTo("/");
NotifyAuthenticationStateChanged(LoadAuthState());
}
catch (HttpApiException e)
{
Logger.LogError("Unable to complete login: {e}", e);
await StartLogin();
}
}
}
public override async Task Logout()
{
if (await LocalStorageService.ContainsKey("AccessToken"))
await LocalStorageService.SetString("AccessToken", "");
NotifyAuthenticationStateChanged(LoadAuthState());
}
#region Utilities
private async Task StartLogin()
{
var loginStartData = await HttpApiClient.GetJson<LoginStartResponse>("api/auth/start");
NavigationManager.NavigateTo(loginStartData.Url, true);
}
private async Task<AuthenticationState> LoadAuthState()
{
AuthenticationState newState;
try
{
var checkData = await HttpApiClient.GetJson<CheckResponse>("api/auth/check");
newState = new(new ClaimsPrincipal(
new ClaimsIdentity(
[
new Claim("username", checkData.Username),
new Claim("email", checkData.Email),
new Claim("permissions", string.Join(";", checkData.Permissions))
],
"RemoteAuthStateManager"
)
));
}
catch (HttpApiException)
{
newState = new(new ClaimsPrincipal(
new ClaimsIdentity()
));
}
return newState;
}
#endregion
}

View File

@@ -0,0 +1,45 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using MoonCore.Exceptions;
using MoonCore.Helpers;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.Client.Services;
public class RemoteAuthStateProvider : AuthenticationStateProvider
{
private readonly HttpApiClient ApiClient;
public RemoteAuthStateProvider(HttpApiClient apiClient)
{
ApiClient = apiClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
ClaimsPrincipal principal;
try
{
var claims = await ApiClient.GetJson<AuthClaimResponse[]>(
"api/auth/check"
);
principal = new ClaimsPrincipal(
new ClaimsIdentity(
claims.Select(x => new Claim(x.Type, x.Value)),
"RemoteAuthentication"
)
);
}
catch (HttpApiException e)
{
if (e.Status != 401 && e.Status != 403)
throw;
principal = new ClaimsPrincipal();
}
return new AuthenticationState(principal);
}
}

View File

@@ -1,9 +1,7 @@
using MoonCore.Attributes; using MoonCore.Attributes;
using MoonCore.Helpers; using MoonCore.Helpers;
using MoonCore.Models;
using Moonlight.Shared.Http.Requests.Admin.Sys.Theme; using Moonlight.Shared.Http.Requests.Admin.Sys.Theme;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;
using Moonlight.Shared.Misc;
namespace Moonlight.Client.Services; namespace Moonlight.Client.Services;
@@ -17,21 +15,14 @@ public class ThemeService
ApiClient = apiClient; ApiClient = apiClient;
} }
public async Task<PagedData<ThemeResponse>> Get(int page, int pageSize) public async Task<ThemeResponse> GetAsync(int id)
{
return await ApiClient.GetJson<PagedData<ThemeResponse>>(
$"api/admin/system/customisation/themes?page={page}&pageSize={pageSize}"
);
}
public async Task<ThemeResponse> Get(int id)
{ {
return await ApiClient.GetJson<ThemeResponse>( return await ApiClient.GetJson<ThemeResponse>(
$"api/admin/system/customisation/themes/{id}" $"api/admin/system/customisation/themes/{id}"
); );
} }
public async Task<ThemeResponse> Create(CreateThemeRequest request) public async Task<ThemeResponse> CreateAsync(CreateThemeRequest request)
{ {
return await ApiClient.PostJson<ThemeResponse>( return await ApiClient.PostJson<ThemeResponse>(
"api/admin/system/customisation/themes", "api/admin/system/customisation/themes",
@@ -39,7 +30,7 @@ public class ThemeService
); );
} }
public async Task<ThemeResponse> Update(int id, UpdateThemeRequest request) public async Task<ThemeResponse> UpdateAsync(int id, UpdateThemeRequest request)
{ {
return await ApiClient.PatchJson<ThemeResponse>( return await ApiClient.PatchJson<ThemeResponse>(
$"api/admin/system/customisation/themes/{id}", $"api/admin/system/customisation/themes/{id}",
@@ -47,7 +38,7 @@ public class ThemeService
); );
} }
public async Task Delete(int id) public async Task DeleteAsync(int id)
{ {
await ApiClient.Delete( await ApiClient.Delete(
$"api/admin/system/customisation/themes/{id}" $"api/admin/system/customisation/themes/{id}"

View File

@@ -9,9 +9,9 @@ public class WindowService
JsRuntime = jsRuntime; JsRuntime = jsRuntime;
} }
public async Task Open(string url, string title, int height, int width) public async Task OpenAsync(string url, string title, int height, int width)
=> await JsRuntime.InvokeVoidAsync("moonlight.window.open", url, title, height, width); => await JsRuntime.InvokeVoidAsync("moonlight.window.open", url, title, height, width);
public async Task Close() public async Task CloseAsync()
=> await JsRuntime.InvokeVoidAsync("moonlight.window.closeCurrent"); => await JsRuntime.InvokeVoidAsync("moonlight.window.closeCurrent");
} }

View File

@@ -1,25 +1,27 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MoonCore.Blazor.FlyonUi.Auth; using MoonCore.Blazor.FlyonUi.Exceptions;
using MoonCore.Permissions; using MoonCore.Permissions;
using Moonlight.Client.Implementations;
using Moonlight.Client.Services; using Moonlight.Client.Services;
namespace Moonlight.Client.Startup; namespace Moonlight.Client.Startup;
public partial class Startup public static partial class Startup
{ {
private Task RegisterAuthentication() private static void AddAuth(this WebAssemblyHostBuilder builder)
{ {
WebAssemblyHostBuilder.Services.AddAuthorizationCore(); builder.Services.AddAuthorizationCore();
WebAssemblyHostBuilder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
WebAssemblyHostBuilder.Services.AddAuthenticationStateManager<RemoteAuthStateManager>(); builder.Services.AddScoped<AuthenticationStateProvider, RemoteAuthStateProvider>();
builder.Services.AddScoped<IGlobalErrorFilter, UnauthenticatedErrorFilter>();
WebAssemblyHostBuilder.Services.AddAuthorizationPermissions(options => builder.Services.AddAuthorizationPermissions(options =>
{ {
options.ClaimName = "permissions"; options.ClaimName = "Permissions";
options.Prefix = "permissions:"; options.Prefix = "permissions:";
}); });
return Task.CompletedTask;
} }
} }

View File

@@ -1,56 +1,42 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MoonCore.Blazor.FlyonUi; using MoonCore.Blazor.FlyonUi;
using MoonCore.Blazor.Services; using MoonCore.Blazor.FlyonUi.Exceptions;
using MoonCore.Extensions; using MoonCore.Extensions;
using MoonCore.Helpers; using MoonCore.Helpers;
using Moonlight.Client.Implementations;
using Moonlight.Client.Services; using Moonlight.Client.Services;
using Moonlight.Client.UI; using Moonlight.Client.UI;
namespace Moonlight.Client.Startup; namespace Moonlight.Client.Startup;
public partial class Startup public static partial class Startup
{ {
private Task RegisterBase() private static void AddBase(this WebAssemblyHostBuilder builder)
{ {
WebAssemblyHostBuilder.RootComponents.Add<App>("#app"); builder.RootComponents.Add<App>("#app");
WebAssemblyHostBuilder.RootComponents.Add<HeadOutlet>("head::after"); builder.RootComponents.Add<HeadOutlet>("head::after");
WebAssemblyHostBuilder.Services.AddScoped(_ => builder.Services.AddScoped(_ =>
new HttpClient new HttpClient
{ {
BaseAddress = new Uri(Configuration.ApiUrl) BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
} }
); );
WebAssemblyHostBuilder.Services.AddScoped(sp => builder.Services.AddScoped(sp =>
{ {
var httpClient = sp.GetRequiredService<HttpClient>(); var httpClient = sp.GetRequiredService<HttpClient>();
var httpApiClient = new HttpApiClient(httpClient); return new HttpApiClient(httpClient);
var localStorageService = sp.GetRequiredService<LocalStorageService>();
httpApiClient.OnConfigureRequest += async request =>
{
var accessToken = await localStorageService.GetString("AccessToken");
if (string.IsNullOrEmpty(accessToken))
return;
request.Headers.Add("Authorization", $"Bearer {accessToken}");
};
return httpApiClient;
}); });
WebAssemblyHostBuilder.Services.AddScoped<WindowService>(); builder.Services.AddFileManagerOperations();
WebAssemblyHostBuilder.Services.AddFileManagerOperations(); builder.Services.AddFlyonUiServices();
WebAssemblyHostBuilder.Services.AddFlyonUiServices();
WebAssemblyHostBuilder.Services.AddScoped<LocalStorageService>();
WebAssemblyHostBuilder.Services.AddScoped<ThemeService>(); builder.Services.AddScoped<ThemeService>();
WebAssemblyHostBuilder.Services.AutoAddServices<Startup>(); builder.Services.AutoAddServices<IAssemblyMarker>();
return Task.CompletedTask; builder.Services.AddScoped<IGlobalErrorFilter, LogErrorFilter>();
} }
} }

View File

@@ -1,24 +1,13 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MoonCore.Logging; using MoonCore.Logging;
namespace Moonlight.Client.Startup; namespace Moonlight.Client.Startup;
public partial class Startup public static partial class Startup
{ {
private Task SetupLogging() private static void AddLogging(this WebAssemblyHostBuilder builder)
{ {
var loggerFactory = new LoggerFactory(); builder.Logging.ClearProviders();
loggerFactory.AddAnsiConsole(); builder.Logging.AddAnsiConsole();
Logger = loggerFactory.CreateLogger<Startup>();
return Task.CompletedTask;
}
private Task RegisterLogging()
{
WebAssemblyHostBuilder.Logging.ClearProviders();
WebAssemblyHostBuilder.Logging.AddAnsiConsole();
return Task.CompletedTask;
} }
} }

View File

@@ -1,12 +1,8 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Shared.Misc;
namespace Moonlight.Client.Startup; namespace Moonlight.Client.Startup;
public partial class Startup public static partial class Startup
{ {
private Task PrintVersion() private static void PrintVersion()
{ {
// Fancy start console output... yes very fancy :> // Fancy start console output... yes very fancy :>
Console.Write("Running "); Console.Write("Running ");
@@ -23,30 +19,5 @@ public partial class Startup
} }
Console.WriteLine(); Console.WriteLine();
return Task.CompletedTask;
}
private async Task LoadConfiguration()
{
try
{
using var httpClient = new HttpClient();
httpClient.BaseAddress = new Uri(WebAssemblyHostBuilder.HostEnvironment.BaseAddress);
var jsonText = await httpClient.GetStringAsync("frontend.json");
Configuration = JsonSerializer.Deserialize<FrontendConfiguration>(jsonText, new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = true
})!;
WebAssemblyHostBuilder.Services.AddSingleton(Configuration);
}
catch (Exception e)
{
Logger.LogCritical("Unable to load configuration. Unable to continue: {e}", e);
throw;
}
} }
} }

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