diff --git a/.dockerignore b/.dockerignore index cd967fc3..2a8688fc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,5 +21,10 @@ **/obj **/secrets.dev.yaml **/values.dev.yaml +**/storage +**/compose.yml LICENSE -README.md \ No newline at end of file +README.md + +# For the people who run moonlight inside the main repo with the relative data path +data diff --git a/.gitignore b/.gitignore index a0eda3a6..62642574 100644 --- a/.gitignore +++ b/.gitignore @@ -397,8 +397,39 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Moonlight storage/ -.idea/.idea.Moonlight/.idea/dataSources.xml -Moonlight/Assets/Core/css/theme.css -Moonlight/Assets/Core/css/theme.css.map -.idea/.idea.Moonlight/.idea/discord.xml +**/.idea/** +style.min.css +core.min.css + +# Build script for nuget packages +finalPackages/ +nupkgs/ + +# Scripts +**/bin/** +**/obj/** \ No newline at end of file diff --git a/.idea/.idea.Moonlight/.idea/.gitignore b/.idea/.idea.Moonlight/.idea/.gitignore deleted file mode 100644 index efcbddd0..00000000 --- a/.idea/.idea.Moonlight/.idea/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Rider ignored files -/modules.xml -/contentModel.xml -/projectSettingsUpdater.xml -/.idea.Moonlight.iml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/.idea.Moonlight/.idea/discord.xml b/.idea/.idea.Moonlight/.idea/discord.xml deleted file mode 100644 index 3aef922e..00000000 --- a/.idea/.idea.Moonlight/.idea/discord.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/.idea.Moonlight/.idea/encodings.xml b/.idea/.idea.Moonlight/.idea/encodings.xml deleted file mode 100644 index df87cf95..00000000 --- a/.idea/.idea.Moonlight/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/.idea.Moonlight/.idea/indexLayout.xml b/.idea/.idea.Moonlight/.idea/indexLayout.xml deleted file mode 100644 index 7b08163c..00000000 --- a/.idea/.idea.Moonlight/.idea/indexLayout.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/.idea.Moonlight/.idea/material_theme_project_new.xml b/.idea/.idea.Moonlight/.idea/material_theme_project_new.xml deleted file mode 100644 index 1a83bdfe..00000000 --- a/.idea/.idea.Moonlight/.idea/material_theme_project_new.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/.idea.Moonlight/.idea/vcs.xml b/.idea/.idea.Moonlight/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/.idea.Moonlight/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Moonlight.ApiServer/Configuration/AppConfiguration.cs b/Moonlight.ApiServer/Configuration/AppConfiguration.cs new file mode 100644 index 00000000..22096878 --- /dev/null +++ b/Moonlight.ApiServer/Configuration/AppConfiguration.cs @@ -0,0 +1,62 @@ +using MoonCore.Helpers; + +namespace Moonlight.ApiServer.Configuration; + +public class AppConfiguration +{ + public string PublicUrl { get; set; } = "http://localhost:5165"; + + public DatabaseConfig Database { get; set; } = new(); + public AuthenticationConfig Authentication { get; set; } = new(); + public DevelopmentConfig Development { get; set; } = new(); + public ClientConfig Client { get; set; } = new(); + public KestrelConfig Kestrel { get; set; } = new(); + + public class ClientConfig + { + public bool Enable { get; set; } = true; + } + + public class DatabaseConfig + { + public string Host { get; set; } = "your-database-host.name"; + public int Port { get; set; } = 5432; + + public string Username { get; set; } = "db_user"; + public string Password { get; set; } = "db_password"; + + public string Database { get; set; } = "db_name"; + } + + public class AuthenticationConfig + { + public string Secret { get; set; } = Formatter.GenerateString(32); + public int TokenDuration { get; set; } = 24 * 10; + + public bool EnableLocalOAuth2 { get; set; } = true; + public OAuth2Data OAuth2 { get; set; } = new(); + + public class OAuth2Data + { + 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; } + + public bool FirstUserAdmin { get; set; } = true; + } + } + + public class DevelopmentConfig + { + public bool EnableApiDocs { get; set; } = false; + } + + public class KestrelConfig + { + public int UploadLimit { get; set; } = 100; + public string AllowedOrigins { get; set; } = "*"; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Database/CoreDataContext.cs b/Moonlight.ApiServer/Database/CoreDataContext.cs new file mode 100644 index 00000000..b9875047 --- /dev/null +++ b/Moonlight.ApiServer/Database/CoreDataContext.cs @@ -0,0 +1,33 @@ +using Hangfire.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using MoonCore.Extended.SingleDb; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Database.Entities; + +namespace Moonlight.ApiServer.Database; + +public class CoreDataContext : DatabaseContext +{ + public override string Prefix { get; } = "Core"; + + public DbSet Users { get; set; } + public DbSet ApiKeys { get; set; } + + public CoreDataContext(AppConfiguration configuration) + { + Options = new() + { + Host = configuration.Database.Host, + Port = configuration.Database.Port, + Username = configuration.Database.Username, + Password = configuration.Database.Password, + Database = configuration.Database.Database + }; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.OnHangfireModelCreating(); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Database/Entities/ApiKey.cs b/Moonlight.ApiServer/Database/Entities/ApiKey.cs new file mode 100644 index 00000000..afb869f4 --- /dev/null +++ b/Moonlight.ApiServer/Database/Entities/ApiKey.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Moonlight.ApiServer.Database.Entities; + +public class ApiKey +{ + public int Id { get; set; } + + public string Description { get; set; } + + [Column(TypeName="jsonb")] + public string PermissionsJson { get; set; } = "[]"; + + [Column(TypeName = "timestamp with time zone")] + public DateTime ExpiresAt { get; set; } + + [Column(TypeName = "timestamp with time zone")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Database/Entities/User.cs b/Moonlight.ApiServer/Database/Entities/User.cs new file mode 100644 index 00000000..13d6b135 --- /dev/null +++ b/Moonlight.ApiServer/Database/Entities/User.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Moonlight.ApiServer.Database.Entities; + +public class User +{ + public int Id { get; set; } + + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } + + [Column(TypeName="timestamp with time zone")] + public DateTime TokenValidTimestamp { get; set; } = DateTime.MinValue; + + [Column(TypeName="jsonb")] + public string PermissionsJson { get; set; } = "[]"; +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Database/Migrations/20250226080942_AddedUsersAndApiKey.Designer.cs b/Moonlight.ApiServer/Database/Migrations/20250226080942_AddedUsersAndApiKey.Designer.cs new file mode 100644 index 00000000..8b884463 --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/20250226080942_AddedUsersAndApiKey.Designer.cs @@ -0,0 +1,90 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Secret") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Core_ApiKeys", (string)null); + }); + + modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TokenValidTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Core_Users", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight.ApiServer/Database/Migrations/20250226080942_AddedUsersAndApiKey.cs b/Moonlight.ApiServer/Database/Migrations/20250226080942_AddedUsersAndApiKey.cs new file mode 100644 index 00000000..d6024fb0 --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/20250226080942_AddedUsersAndApiKey.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.ApiServer.Database.Migrations +{ + /// + public partial class AddedUsersAndApiKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Core_ApiKeys", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Secret = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + PermissionsJson = table.Column(type: "jsonb", nullable: false), + ExpiresAt = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Username = table.Column(type: "text", nullable: false), + Email = table.Column(type: "text", nullable: false), + Password = table.Column(type: "text", nullable: false), + TokenValidTimestamp = table.Column(type: "timestamp with time zone", nullable: false), + PermissionsJson = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Core_Users", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Core_ApiKeys"); + + migrationBuilder.DropTable( + name: "Core_Users"); + } + } +} diff --git a/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.Designer.cs b/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.Designer.cs new file mode 100644 index 00000000..6e3d6105 --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.Designer.cs @@ -0,0 +1,89 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("Core_ApiKeys", (string)null); + }); + + modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TokenValidTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Core_Users", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.cs b/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.cs new file mode 100644 index 00000000..b7d47632 --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Moonlight.ApiServer.Database.Migrations +{ + /// + public partial class ModifiedApiKeyEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Secret", + table: "Core_ApiKeys"); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Core_ApiKeys", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Core_ApiKeys"); + + migrationBuilder.AddColumn( + name: "Secret", + table: "Core_ApiKeys", + type: "text", + nullable: false, + defaultValue: ""); + } + } +} diff --git a/Moonlight.ApiServer/Database/Migrations/20250405172522_AddedHangfireTables.Designer.cs b/Moonlight.ApiServer/Database/Migrations/20250405172522_AddedHangfireTables.Designer.cs new file mode 100644 index 00000000..96b7445b --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/20250405172522_AddedHangfireTables.Designer.cs @@ -0,0 +1,393 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExpireAt"); + + b.HasIndex("Key", "Value"); + + b.ToTable("HangfireCounter"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Field") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key", "Field"); + + b.HasIndex("ExpireAt"); + + b.ToTable("HangfireHash"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvocationData") + .IsRequired() + .HasColumnType("text"); + + b.Property("StateId") + .HasColumnType("bigint"); + + b.Property("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("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("JobId", "Name"); + + b.ToTable("HangfireJobParameter"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key", "Position"); + + b.HasIndex("ExpireAt"); + + b.ToTable("HangfireList"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("HangfireLock"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FetchedAt") + .IsConcurrencyToken() + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Heartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("Queues") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Heartbeat"); + + b.ToTable("HangfireServer"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Reason") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("HangfireState"); + }); + + modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("Core_ApiKeys", (string)null); + }); + + modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TokenValidTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/Moonlight.ApiServer/Database/Migrations/20250405172522_AddedHangfireTables.cs b/Moonlight.ApiServer/Database/Migrations/20250405172522_AddedHangfireTables.cs new file mode 100644 index 00000000..9fd060d1 --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/20250405172522_AddedHangfireTables.cs @@ -0,0 +1,290 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.ApiServer.Database.Migrations +{ + /// + public partial class AddedHangfireTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "HangfireCounter", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Key = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Value = table.Column(type: "bigint", nullable: false), + ExpireAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireCounter", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "HangfireHash", + columns: table => new + { + Key = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Field = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Value = table.Column(type: "text", nullable: true), + ExpireAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireHash", x => new { x.Key, x.Field }); + }); + + migrationBuilder.CreateTable( + name: "HangfireList", + columns: table => new + { + Key = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Position = table.Column(type: "integer", nullable: false), + Value = table.Column(type: "text", nullable: true), + ExpireAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireList", x => new { x.Key, x.Position }); + }); + + migrationBuilder.CreateTable( + name: "HangfireLock", + columns: table => new + { + Id = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + AcquiredAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireLock", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "HangfireServer", + columns: table => new + { + Id = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + Heartbeat = table.Column(type: "timestamp with time zone", nullable: false), + WorkerCount = table.Column(type: "integer", nullable: false), + Queues = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireServer", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "HangfireSet", + columns: table => new + { + Key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Value = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Score = table.Column(type: "double precision", nullable: false), + ExpireAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireSet", x => new { x.Key, x.Value }); + }); + + migrationBuilder.CreateTable( + name: "HangfireJob", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + StateId = table.Column(type: "bigint", nullable: true), + StateName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExpireAt = table.Column(type: "timestamp with time zone", nullable: true), + InvocationData = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireJob", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "HangfireJobParameter", + columns: table => new + { + JobId = table.Column(type: "bigint", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireJobParameter", x => new { x.JobId, x.Name }); + table.ForeignKey( + name: "FK_HangfireJobParameter_HangfireJob_JobId", + column: x => x.JobId, + principalTable: "HangfireJob", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "HangfireQueuedJob", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + JobId = table.Column(type: "bigint", nullable: false), + Queue = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + FetchedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireQueuedJob", x => x.Id); + table.ForeignKey( + name: "FK_HangfireQueuedJob_HangfireJob_JobId", + column: x => x.JobId, + principalTable: "HangfireJob", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "HangfireState", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + JobId = table.Column(type: "bigint", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Reason = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Data = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_HangfireState", x => x.Id); + table.ForeignKey( + name: "FK_HangfireState_HangfireJob_JobId", + column: x => x.JobId, + principalTable: "HangfireJob", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_HangfireCounter_ExpireAt", + table: "HangfireCounter", + column: "ExpireAt"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireCounter_Key_Value", + table: "HangfireCounter", + columns: new[] { "Key", "Value" }); + + migrationBuilder.CreateIndex( + name: "IX_HangfireHash_ExpireAt", + table: "HangfireHash", + column: "ExpireAt"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireJob_ExpireAt", + table: "HangfireJob", + column: "ExpireAt"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireJob_StateId", + table: "HangfireJob", + column: "StateId"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireJob_StateName", + table: "HangfireJob", + column: "StateName"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireList_ExpireAt", + table: "HangfireList", + column: "ExpireAt"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireQueuedJob_JobId", + table: "HangfireQueuedJob", + column: "JobId"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireQueuedJob_Queue_FetchedAt", + table: "HangfireQueuedJob", + columns: new[] { "Queue", "FetchedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_HangfireServer_Heartbeat", + table: "HangfireServer", + column: "Heartbeat"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireSet_ExpireAt", + table: "HangfireSet", + column: "ExpireAt"); + + migrationBuilder.CreateIndex( + name: "IX_HangfireSet_Key_Score", + table: "HangfireSet", + columns: new[] { "Key", "Score" }); + + migrationBuilder.CreateIndex( + name: "IX_HangfireState_JobId", + table: "HangfireState", + column: "JobId"); + + migrationBuilder.AddForeignKey( + name: "FK_HangfireJob_HangfireState_StateId", + table: "HangfireJob", + column: "StateId", + principalTable: "HangfireState", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_HangfireJob_HangfireState_StateId", + table: "HangfireJob"); + + migrationBuilder.DropTable( + name: "HangfireCounter"); + + migrationBuilder.DropTable( + name: "HangfireHash"); + + migrationBuilder.DropTable( + name: "HangfireJobParameter"); + + migrationBuilder.DropTable( + name: "HangfireList"); + + migrationBuilder.DropTable( + name: "HangfireLock"); + + migrationBuilder.DropTable( + name: "HangfireQueuedJob"); + + migrationBuilder.DropTable( + name: "HangfireServer"); + + migrationBuilder.DropTable( + name: "HangfireSet"); + + migrationBuilder.DropTable( + name: "HangfireState"); + + migrationBuilder.DropTable( + name: "HangfireJob"); + } + } +} diff --git a/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs b/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs new file mode 100644 index 00000000..f1945795 --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs @@ -0,0 +1,390 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Moonlight.ApiServer.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.ApiServer.Database.Migrations +{ + [DbContext(typeof(CoreDataContext))] + partial class CoreDataContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireCounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExpireAt"); + + b.HasIndex("Key", "Value"); + + b.ToTable("HangfireCounter"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireHash", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Field") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key", "Field"); + + b.HasIndex("ExpireAt"); + + b.ToTable("HangfireHash"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvocationData") + .IsRequired() + .HasColumnType("text"); + + b.Property("StateId") + .HasColumnType("bigint"); + + b.Property("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("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("JobId", "Name"); + + b.ToTable("HangfireJobParameter"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireList", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key", "Position"); + + b.HasIndex("ExpireAt"); + + b.ToTable("HangfireList"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireLock", b => + { + b.Property("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("HangfireLock"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireQueuedJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FetchedAt") + .IsConcurrencyToken() + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Heartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("Queues") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Heartbeat"); + + b.ToTable("HangfireServer"); + }); + + modelBuilder.Entity("Hangfire.EntityFrameworkCore.HangfireSet", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Reason") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("HangfireState"); + }); + + modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("Core_ApiKeys", (string)null); + }); + + modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TokenValidTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/Moonlight.ApiServer/Dockerfile b/Moonlight.ApiServer/Dockerfile new file mode 100644 index 00000000..baa1e81f --- /dev/null +++ b/Moonlight.ApiServer/Dockerfile @@ -0,0 +1,58 @@ +# +# OUTDATED +# Use https://github.com/Moonlight-Panel/Deploy +# + +# Prepare runtime docker image +FROM mcr.microsoft.com/dotnet/aspnet:8.0-noble-chiseled AS base +WORKDIR /app + +# Prepare build image +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release + +# Install nodejs and npm so we can build tailwind +RUN apt-get update && apt-get install nodejs npm -y && apt-get clean + +# Copy package.json file +WORKDIR /src +COPY ["Moonlight.Client/Styles/package.json", "Moonlight.Client/Styles/"] + +# Install npm packages +WORKDIR /src/Moonlight.Client/Styles/ +RUN npm i + +# Copy project configuration files +WORKDIR /src +COPY ["Moonlight.ApiServer/Moonlight.ApiServer.csproj", "Moonlight.ApiServer/"] +COPY ["Moonlight.Client/Moonlight.Client.csproj", "Moonlight.Client/"] +COPY ["Moonlight.Shared/Moonlight.Shared.csproj", "Moonlight.Shared/"] + +# Restore the nuget packages +RUN dotnet restore "Moonlight.ApiServer/Moonlight.ApiServer.csproj" + +# Copy all files +COPY . . + +# Build tailwindcss +WORKDIR "/src/Moonlight.Client/Styles" +RUN npx tailwindcss -i style.css -o ../wwwroot/css/core.min.css --minify + +# Build ApiServer project +WORKDIR "/src/Moonlight.ApiServer" +RUN dotnet build "Moonlight.ApiServer.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# Publish application +FROM build AS publish + +ARG BUILD_CONFIGURATION=Release + +RUN dotnet publish "Moonlight.ApiServer.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Create final small image with the built content +FROM base AS final + +WORKDIR /app +COPY --from=publish /app/publish . + +ENTRYPOINT ["dotnet", "Moonlight.ApiServer.dll"] diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/ApiKeys/ApiKeysController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/ApiKeys/ApiKeysController.cs new file mode 100644 index 00000000..055fe9dd --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/ApiKeys/ApiKeysController.cs @@ -0,0 +1,146 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MoonCore.Exceptions; +using MoonCore.Extended.Abstractions; +using MoonCore.Extended.PermFilter; +using MoonCore.Models; +using Moonlight.ApiServer.Database.Entities; +using Moonlight.ApiServer.Services; +using Moonlight.Shared.Http.Requests.Admin.ApiKeys; +using Moonlight.Shared.Http.Responses.Admin.ApiKeys; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.ApiKeys; + +[ApiController] +[Route("api/admin/apikeys")] +public class ApiKeysController : Controller +{ + private readonly DatabaseRepository ApiKeyRepository; + private readonly ApiKeyService ApiKeyService; + + public ApiKeysController(DatabaseRepository apiKeyRepository, ApiKeyService apiKeyService) + { + ApiKeyRepository = apiKeyRepository; + ApiKeyService = apiKeyService; + } + + [HttpGet] + [RequirePermission("admin.apikeys.read")] + public async Task> Get( + [FromQuery] int page, + [FromQuery] [Range(1, 100)] int pageSize = 50 + ) + { + var count = await ApiKeyRepository.Get().CountAsync(); + + var apiKeys = await ApiKeyRepository + .Get() + .OrderBy(x => x.Id) + .Skip(page * pageSize) + .Take(pageSize) + .ToArrayAsync(); + + var mappedApiKey = apiKeys + .Select(x => new ApiKeyResponse() + { + Id = x.Id, + PermissionsJson = x.PermissionsJson, + Description = x.Description, + ExpiresAt = x.ExpiresAt + }) + .ToArray(); + + return new PagedData() + { + CurrentPage = page, + Items = mappedApiKey, + PageSize = pageSize, + TotalItems = count, + TotalPages = count == 0 ? 0 : (count - 1) / pageSize + }; + } + + [HttpGet("{id}")] + [RequirePermission("admin.apikeys.read")] + public async Task GetSingle(int id) + { + var apiKey = await ApiKeyRepository + .Get() + .FirstOrDefaultAsync(x => x.Id == id); + + if (apiKey == null) + throw new HttpApiException("No api key with that id found", 404); + + return new ApiKeyResponse() + { + Id = apiKey.Id, + PermissionsJson = apiKey.PermissionsJson, + Description = apiKey.Description, + ExpiresAt = apiKey.ExpiresAt + }; + } + + [HttpPost] + [RequirePermission("admin.apikeys.create")] + public async Task Create([FromBody] CreateApiKeyRequest request) + { + var apiKey = new ApiKey() + { + Description = request.Description, + PermissionsJson = request.PermissionsJson, + ExpiresAt = request.ExpiresAt + }; + + var finalApiKey = await ApiKeyRepository.Add(apiKey); + + var response = new CreateApiKeyResponse + { + Id = finalApiKey.Id, + PermissionsJson = finalApiKey.PermissionsJson, + Description = finalApiKey.Description, + ExpiresAt = finalApiKey.ExpiresAt, + Secret = ApiKeyService.GenerateJwt(finalApiKey) + }; + + return response; + } + + [HttpPatch("{id}")] + [RequirePermission("admin.apikeys.update")] + public async Task Update([FromRoute] int id, [FromBody] UpdateApiKeyRequest request) + { + var apiKey = await ApiKeyRepository + .Get() + .FirstOrDefaultAsync(x => x.Id == id); + + if (apiKey == null) + throw new HttpApiException("No api key with that id found", 404); + + apiKey.Description = request.Description; + + await ApiKeyRepository.Update(apiKey); + + return new ApiKeyResponse() + { + Id = apiKey.Id, + Description = apiKey.Description, + PermissionsJson = apiKey.PermissionsJson, + ExpiresAt = apiKey.ExpiresAt + }; + } + + [HttpDelete("{id}")] + [RequirePermission("admin.apikeys.delete")] + public async Task Delete([FromRoute] int id) + { + var apiKey = await ApiKeyRepository + .Get() + .FirstOrDefaultAsync(x => x.Id == id); + + if (apiKey == null) + throw new HttpApiException("No api key with that id found", 404); + + await ApiKeyRepository.Remove(apiKey); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/AdvancedController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/AdvancedController.cs new file mode 100644 index 00000000..90004044 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/AdvancedController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Extended.PermFilter; +using Moonlight.ApiServer.Services; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys; + +[Authorize] +[ApiController] +[Route("api/admin/system/advanced")] +public class AdvancedController : Controller +{ + private readonly FrontendService FrontendService; + + public AdvancedController(FrontendService frontendService) + { + FrontendService = frontendService; + } + + [HttpGet("frontend")] + [RequirePermission("admin.system.advanced.frontend")] + public async Task Frontend() + { + var stream = await FrontendService.GenerateZip(); + await Results.File(stream, fileDownloadName: "frontend.zip").ExecuteAsync(HttpContext); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs new file mode 100644 index 00000000..7d8035ea --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/FilesController.cs @@ -0,0 +1,435 @@ +using System.Text; +using ICSharpCode.SharpZipLib.GZip; +using ICSharpCode.SharpZipLib.Tar; +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Exceptions; +using MoonCore.Extended.PermFilter; +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")] +[RequirePermission("admin.system.files")] +public class FilesController : Controller +{ + private readonly string BaseDirectory = PathBuilder.Dir("storage"); + private readonly long ChunkSize = ByteConverter.FromMegaBytes(20).Bytes; + + [HttpGet("list")] + public Task List([FromQuery] string path) + { + var safePath = SanitizePath(path); + var physicalPath = PathBuilder.Dir(BaseDirectory, safePath); + + var entries = new List(); + + 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, + IsFile = true, + 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, + IsFile = false + }); + } + + return Task.FromResult( + entries.ToArray() + ); + } + + [HttpPost("upload")] + public async Task Upload([FromQuery] string path, [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 = PathBuilder.File(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 = PathBuilder.Dir(BaseDirectory, oldSafePath); + + if (Directory.Exists(oldPhysicalDirPath)) + { + var newPhysicalDirPath = PathBuilder.Dir(BaseDirectory, newSafePath); + + Directory.Move( + oldPhysicalDirPath, + newPhysicalDirPath + ); + } + else + { + var oldPhysicalFilePath = PathBuilder.File(BaseDirectory, oldSafePath); + var newPhysicalFilePath = PathBuilder.File(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 = PathBuilder.Dir(BaseDirectory, safePath); + + if (Directory.Exists(physicalDirPath)) + Directory.Delete(physicalDirPath, true); + else + { + var physicalFilePath = PathBuilder.File(BaseDirectory, safePath); + + System.IO.File.Delete(physicalFilePath); + } + + return Task.CompletedTask; + } + + [HttpPost("mkdir")] + public Task CreateDirectory([FromQuery] string path) + { + var safePath = SanitizePath(path); + var physicalPath = PathBuilder.Dir(BaseDirectory, safePath); + + Directory.CreateDirectory(physicalPath); + return Task.CompletedTask; + } + + [HttpGet("download")] + public async Task Download([FromQuery] string path) + { + var safePath = SanitizePath(path); + var physicalPath = PathBuilder.File(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 = PathBuilder.File(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 = PathBuilder.File(BaseDirectory, safeFilePath); + + var fi = new FileInfo(filePath); + + if (fi.Exists) + await AddFileToTarGz(tarStream, filePath); + else + { + var safeDirePath = SanitizePath(itemName); + var dirPath = PathBuilder.Dir(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 = PathBuilder.File(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 = PathBuilder.File(BaseDirectory, safeFilePath); + + var fi = new FileInfo(filePath); + + if (fi.Exists) + await AddFileToZip(zipOutputStream, filePath); + else + { + var safeDirePath = SanitizePath(itemName); + var dirPath = PathBuilder.Dir(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 = PathBuilder.File(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 = PathBuilder.File(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 = PathBuilder.File(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 = PathBuilder.File(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) + => path.Replace("..", ""); +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/HangfireController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/HangfireController.cs new file mode 100644 index 00000000..cc3bd93e --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/HangfireController.cs @@ -0,0 +1,40 @@ +using Hangfire; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Extended.PermFilter; +using Moonlight.Shared.Http.Responses.Admin.Hangfire; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys; + +[ApiController] +[Route("api/admin/system/hangfire")] +[RequirePermission("admin.system.hangfire")] +public class HangfireController : Controller +{ + private readonly JobStorage JobStorage; + + public HangfireController(JobStorage jobStorage) + { + JobStorage = jobStorage; + } + + [HttpGet("stats")] + public Task GetStats() + { + var statistics = JobStorage.GetMonitoringApi().GetStatistics(); + + return Task.FromResult(new HangfireStatsResponse() + { + Awaiting = statistics.Awaiting, + Deleted = statistics.Deleted, + Enqueued = statistics.Enqueued, + Failed = statistics.Failed, + Processing = statistics.Processing, + Queues = statistics.Queues, + Recurring = statistics.Recurring, + Retries = statistics.Retries, + Scheduled = statistics.Scheduled, + Servers = statistics.Servers, + Succeeded = statistics.Succeeded + }); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/SystemController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/SystemController.cs new file mode 100644 index 00000000..b0c9f8d2 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/SystemController.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; +using MoonCore.Attributes; +using Moonlight.ApiServer.Services; +using Moonlight.Shared.Http.Responses.Admin.Sys; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys; + +[ApiController] +[Route("api/admin/system")] +public class SystemController : Controller +{ + private readonly ApplicationService ApplicationService; + + public SystemController(ApplicationService applicationService) + { + ApplicationService = applicationService; + } + + [HttpGet] + [RequirePermission("admin.system.overview")] + public async Task GetOverview() + { + return new() + { + Uptime = await ApplicationService.GetUptime(), + CpuUsage = await ApplicationService.GetCpuUsage(), + MemoryUsage = await ApplicationService.GetMemoryUsage(), + OperatingSystem = await ApplicationService.GetOsName() + }; + } + + [HttpPost("shutdown")] + [RequirePermission("admin.system.shutdown")] + public async Task Shutdown() + { + await ApplicationService.Shutdown(); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/ThemeController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/ThemeController.cs new file mode 100644 index 00000000..81eaa4eb --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/ThemeController.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Extended.PermFilter; +using MoonCore.Helpers; +using Moonlight.Shared.Http.Requests.Admin.Sys; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys; + +[ApiController] +[Route("api/admin/system/theme")] +public class ThemeController : Controller +{ + [HttpPatch] + [RequirePermission("admin.system.theme.update")] + public async Task Patch([FromBody] UpdateThemeRequest request) + { + var themePath = PathBuilder.File("storage", "theme.json"); + + await System.IO.File.WriteAllTextAsync( + themePath, + JsonSerializer.Serialize(request.Variables) + ); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Users/UsersController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Users/UsersController.cs new file mode 100644 index 00000000..d583a5f0 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Users/UsersController.cs @@ -0,0 +1,180 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MoonCore.Exceptions; +using MoonCore.Extended.Abstractions; +using MoonCore.Extended.Helpers; +using MoonCore.Extended.PermFilter; +using MoonCore.Models; +using Moonlight.ApiServer.Database.Entities; +using Moonlight.Shared.Http.Requests.Admin.Users; +using Moonlight.Shared.Http.Responses.Admin.Users; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Users; + +[ApiController] +[Route("api/admin/users")] +public class UsersController : Controller +{ + private readonly DatabaseRepository UserRepository; + + public UsersController(DatabaseRepository userRepository) + { + UserRepository = userRepository; + } + + [HttpGet] + [RequirePermission("admin.users.read")] + public async Task> Get( + [FromQuery] int page, + [FromQuery] [Range(1, 100)] int pageSize = 50 + ) + { + var count = await UserRepository.Get().CountAsync(); + + var users = await UserRepository + .Get() + .OrderBy(x => x.Id) + .Skip(page * pageSize) + .Take(pageSize) + .ToArrayAsync(); + + var mappedUsers = users + .Select(x => new UserResponse() + { + Id = x.Id, + Email = x.Email, + Username = x.Username, + PermissionsJson = x.PermissionsJson + }) + .ToArray(); + + return new PagedData() + { + CurrentPage = page, + Items = mappedUsers, + PageSize = pageSize, + TotalItems = count, + TotalPages = count == 0 ? 0 : (count - 1) / pageSize + }; + } + + [HttpGet("{id}")] + [RequirePermission("admin.users.read")] + public async Task GetSingle(int id) + { + var user = await UserRepository + .Get() + .FirstOrDefaultAsync(x => x.Id == id); + + if (user == null) + throw new HttpApiException("No user with that id found", 404); + + return new UserResponse() + { + Id = user.Id, + Email = user.Email, + Username = user.Username, + PermissionsJson = user.PermissionsJson + }; + } + + [HttpPost] + [RequirePermission("admin.users.create")] + public async Task Create([FromBody] CreateUserRequest request) + { + // Reformat values + request.Username = request.Username.ToLower().Trim(); + request.Email = request.Email.ToLower().Trim(); + + // Check for users with the same values + if (UserRepository.Get().Any(x => x.Username == request.Username)) + throw new HttpApiException("A user with that username already exists", 400); + + if (UserRepository.Get().Any(x => x.Email == request.Email)) + throw new HttpApiException("A user with that email address already exists", 400); + + var hashedPassword = HashHelper.Hash(request.Password); + + var user = new User() + { + Email = request.Email, + Username = request.Username, + Password = hashedPassword, + PermissionsJson = request.PermissionsJson + }; + + var finalUser = await UserRepository.Add(user); + + return new UserResponse() + { + Id = finalUser.Id, + Email = finalUser.Email, + Username = finalUser.Username, + PermissionsJson = finalUser.PermissionsJson + }; + } + + [HttpPatch("{id}")] + [RequirePermission("admin.users.update")] + public async Task Update([FromRoute] int id, [FromBody] UpdateUserRequest request) + { + var user = await UserRepository + .Get() + .FirstOrDefaultAsync(x => x.Id == id); + + if (user == null) + throw new HttpApiException("No user with that id found", 404); + + // Reformat values + request.Username = request.Username.ToLower().Trim(); + request.Email = request.Email.ToLower().Trim(); + + // Check for users with the same values + if (UserRepository.Get().Any(x => x.Username == request.Username && x.Id != user.Id)) + throw new HttpApiException("A user with that username already exists", 400); + + 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); + + // Perform hashing the password if required + if (!string.IsNullOrEmpty(request.Password)) + { + user.Password = HashHelper.Hash(request.Password); + user.TokenValidTimestamp = DateTime.UtcNow; // Log out user after password change + } + + if (user.PermissionsJson != request.PermissionsJson) + { + user.PermissionsJson = request.PermissionsJson; + user.TokenValidTimestamp = DateTime.UtcNow; // Log out user after permission change + } + + user.Email = request.Email; + user.Username = request.Username; + + await UserRepository.Update(user); + + return new UserResponse() + { + Id = user.Id, + Email = user.Email, + Username = user.Username, + PermissionsJson = user.PermissionsJson + }; + } + + [HttpDelete("{id}")] + [RequirePermission("admin.users.delete")] + public async Task Delete([FromRoute] int id) + { + var user = await UserRepository + .Get() + .FirstOrDefaultAsync(x => x.Id == id); + + if (user == null) + throw new HttpApiException("No user with that id found", 404); + + await UserRepository.Remove(user); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs b/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs new file mode 100644 index 00000000..a705fc24 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs @@ -0,0 +1,134 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +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.Requests.Auth; +using Moonlight.Shared.Http.Responses.Auth; +using Moonlight.Shared.Http.Responses.OAuth2; + +namespace Moonlight.ApiServer.Http.Controllers.Auth; + +[ApiController] +[Route("api/auth")] +public class AuthController : Controller +{ + private readonly AppConfiguration Configuration; + private readonly ILogger Logger; + private readonly DatabaseRepository UserRepository; + private readonly IOAuth2Provider OAuth2Provider; + + private readonly string RedirectUri; + private readonly string EndpointUri; + + public AuthController( + AppConfiguration configuration, + ILogger logger, + DatabaseRepository userRepository, + IOAuth2Provider oAuth2Provider + ) + { + UserRepository = userRepository; + OAuth2Provider = oAuth2Provider; + Configuration = configuration; + Logger = logger; + + RedirectUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationRedirect) + ? Configuration.PublicUrl + : Configuration.Authentication.OAuth2.AuthorizationRedirect; + + EndpointUri = string.IsNullOrEmpty(Configuration.Authentication.OAuth2.AuthorizationEndpoint) + ? Configuration.PublicUrl + "/oauth2/authorize" + : Configuration.Authentication.OAuth2.AuthorizationEndpoint; + } + + [AllowAnonymous] + [HttpGet("start")] + public Task Start() + { + var response = new LoginStartResponse() + { + ClientId = Configuration.Authentication.OAuth2.ClientId, + RedirectUri = RedirectUri, + Endpoint = EndpointUri + }; + + return Task.FromResult(response); + } + + [AllowAnonymous] + [HttpPost("complete")] + public async Task Complete([FromBody] LoginCompleteRequest request) + { + var user = await OAuth2Provider.Sync(request.Code); + + if (user == null) + throw new HttpApiException("Unable to load user data", 500); + + // + var permissions = JsonSerializer.Deserialize(user.PermissionsJson) ?? []; + + // Generate token + var securityTokenDescriptor = new SecurityTokenDescriptor() + { + Expires = DateTime.Now.AddYears(Configuration.Authentication.TokenDuration), + IssuedAt = DateTime.Now, + NotBefore = DateTime.Now.AddMinutes(-1), + Claims = new Dictionary() + { + { + "userId", + user.Id + }, + { + "permissions", + string.Join(";", 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] + [HttpGet("check")] + public async Task Check() + { + var userIdClaim = User.Claims.First(x => x.Type == "userId"); + var userId = int.Parse(userIdClaim.Value); + var user = await UserRepository.Get().FirstAsync(x => x.Id == userId); + + var permissions = JsonSerializer.Deserialize(user.PermissionsJson) ?? []; + + return new() + { + Email = user.Email, + Username = user.Username, + Permissions = string.Join(";", permissions) + }; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/FrontendController.cs b/Moonlight.ApiServer/Http/Controllers/FrontendController.cs new file mode 100644 index 00000000..721ab6a6 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/FrontendController.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Exceptions; +using MoonCore.Helpers; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Services; +using Moonlight.Shared.Misc; + +namespace Moonlight.ApiServer.Http.Controllers; + +[ApiController] +public class FrontendController : Controller +{ + private readonly FrontendService FrontendService; + + public FrontendController(FrontendService frontendService) + { + FrontendService = frontendService; + } + + [HttpGet("frontend.json")] + public async Task GetConfiguration() + => await FrontendService.GetConfiguration(); +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/OAuth2/Login.razor b/Moonlight.ApiServer/Http/Controllers/OAuth2/Login.razor new file mode 100644 index 00000000..0cc98c8d --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/OAuth2/Login.razor @@ -0,0 +1,67 @@ + + + Login into your account + + + + + + + + + + Login into your account + + + + + + @if (!string.IsNullOrEmpty(ErrorMessage)) + { + + @ErrorMessage + + } + + + + Email address + + + + + + + Password + + + + + + + + Login + + + + + + + No account? + + Register + + + + + + + + +@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; } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs b/Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs new file mode 100644 index 00000000..7f2332a5 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/OAuth2/OAuth2Controller.cs @@ -0,0 +1,310 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authorization; +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 class OAuth2Controller : Controller +{ + private readonly AppConfiguration Configuration; + private readonly DatabaseRepository UserRepository; + + private readonly string ExpectedRedirectUri; + + public OAuth2Controller(AppConfiguration configuration, DatabaseRepository 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); + } + + Response.StatusCode = 200; + + if (view == "register") + { + var html = await ComponentHelper.RenderComponent(HttpContext.RequestServices, parameters => + { + parameters.Add("ClientId", clientId); + parameters.Add("RedirectUri", redirectUri); + parameters.Add("ResponseType", responseType); + }); + + await Response.WriteAsync(html); + } + else + { + var html = await ComponentHelper.RenderComponent(HttpContext.RequestServices, parameters => + { + parameters.Add("ClientId", clientId); + parameters.Add("RedirectUri", redirectUri); + parameters.Add("ResponseType", responseType); + }); + + await Response.WriteAsync(html); + } + } + + [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")] 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}"); + return; + } + else + { + var user = await Login(email, password); + var code = await GenerateCode(user); + + Response.Redirect($"{redirectUri}?code={code}"); + return; + } + } + catch (HttpApiException e) + { + errorMessage = e.Title; + } + + Response.StatusCode = 200; + + if (view == "register") + { + var html = await ComponentHelper.RenderComponent(HttpContext.RequestServices, parameters => + { + parameters.Add("ClientId", clientId); + parameters.Add("RedirectUri", redirectUri); + parameters.Add("ResponseType", responseType); + parameters.Add("ErrorMessage", errorMessage!); + }); + + await Response.WriteAsync(html); + } + else + { + var html = await ComponentHelper.RenderComponent(HttpContext.RequestServices, parameters => + { + parameters.Add("ClientId", clientId); + parameters.Add("RedirectUri", redirectUri); + parameters.Add("ResponseType", responseType); + parameters.Add("ErrorMessage", errorMessage!); + }); + + await Response.WriteAsync(html); + } + } + + [AllowAnonymous] + [HttpPost("handle")] + public async Task 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 GenerateCode(User user) + { + var securityTokenDescriptor = new SecurityTokenDescriptor() + { + Expires = DateTime.Now.AddMinutes(1), + IssuedAt = DateTime.Now, + NotBefore = DateTime.Now.AddMinutes(-1), + Claims = new Dictionary() + { + { + "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 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); + + 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.PermissionsJson = "[\"*\"]"; + + } + + return await UserRepository.Add(user); + } + + private async Task 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; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/OAuth2/Register.razor b/Moonlight.ApiServer/Http/Controllers/OAuth2/Register.razor new file mode 100644 index 00000000..79c16b2a --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/OAuth2/Register.razor @@ -0,0 +1,72 @@ + + + Register a new account + + + + + + + + + + Create your account + + + + + + @if (!string.IsNullOrEmpty(ErrorMessage)) + { + + @ErrorMessage + + } + + + + Username + + + + + + + Email address + + + + + + + Password + + + + + + + + Create your account + + + + + + + Already registered? + Login + + + + + + + +@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; } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerController.cs b/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerController.cs new file mode 100644 index 00000000..49e12005 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerController.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Helpers; +using MoonCore.Services; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Models; + +namespace Moonlight.ApiServer.Http.Controllers.Swagger; + +[AllowAnonymous] +[Route("api/swagger")] +public class SwaggerController : Controller +{ + private readonly AppConfiguration Configuration; + private readonly IServiceProvider ServiceProvider; + + public SwaggerController( + AppConfiguration configuration, + IServiceProvider serviceProvider + ) + { + Configuration = configuration; + ServiceProvider = serviceProvider; + } + + [HttpGet] + [Authorize] + public async Task Get() + { + if (!Configuration.Development.EnableApiDocs) + return BadRequest("Api docs are disabled"); + + var options = new ApiDocsOptions(); + var optionsJson = JsonSerializer.Serialize(options); + + var html = await ComponentHelper.RenderComponent( + ServiceProvider, + parameters => + { + parameters.Add("Options", optionsJson); + } + ); + + return Content(html, "text/html"); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerPage.razor b/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerPage.razor new file mode 100644 index 00000000..637daccc --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerPage.razor @@ -0,0 +1,92 @@ + + + + Moonlight Api Reference + + + + + + + + + + + +@code +{ + [Parameter] public string Options { get; set; } +} diff --git a/Moonlight.ApiServer/Implementations/LocalOAuth2Provider.cs b/Moonlight.ApiServer/Implementations/LocalOAuth2Provider.cs new file mode 100644 index 00000000..98a12200 --- /dev/null +++ b/Moonlight.ApiServer/Implementations/LocalOAuth2Provider.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +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 Logger; + private readonly DatabaseRepository UserRepository; + + public LocalOAuth2Provider( + AppConfiguration configuration, + ILogger logger, + DatabaseRepository userRepository + ) + { + UserRepository = userRepository; + Configuration = configuration; + Logger = logger; + } + + public async Task Sync(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(accessEndpoint, new FormUrlEncodedContent( + [ + new KeyValuePair("grant_type", "authorization_code"), + new KeyValuePair("code", code), + new KeyValuePair("redirect_uri", redirectUri), + new KeyValuePair("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); + } + + // Handle the returned data + var userId = handleData.UserId; + + var user = await UserRepository + .Get() + .FirstOrDefaultAsync(x => x.Id == userId); + + return user; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs b/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs new file mode 100644 index 00000000..b5293345 --- /dev/null +++ b/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs @@ -0,0 +1,66 @@ +using Microsoft.OpenApi.Models; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Database; +using Moonlight.ApiServer.Plugins; + +namespace Moonlight.ApiServer.Implementations.Startup; + +[PluginStartup] +public class CoreStartup : IPluginStartup +{ + public Task BuildApplication(IServiceProvider serviceProvider, IHostApplicationBuilder builder) + { + var configuration = serviceProvider.GetRequiredService(); + + #region Api Docs + + if (configuration.Development.EnableApiDocs) + { + builder.Services.AddEndpointsApiExplorer(); + + // Configure swagger api specification generator and set the document title for the api docs to use + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("main", new OpenApiInfo() + { + Title = "Moonlight API" + }); + + options.CustomSchemaIds(x => x.FullName); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + }); + } + + #endregion + + #region Database + + builder.Services.AddDbContext
+ No account? + + Register + +
+ Already registered? + Login +