2 Commits

142 changed files with 947 additions and 3549 deletions

View File

@@ -34,7 +34,7 @@ jobs:
# Publish frontend
# We need to build it first so the class list files generate
- name: Build project
run: dotnet build Hosts/Moonlight.Frontend.Host --configuration Debug
run: dotnet build Moonlight.Frontend --configuration Debug
- name: Build tailwind styles and extract class list
working-directory: Hosts/Moonlight.Frontend.Host/Styles

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -7,13 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/>
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="SimplePlugin" Version="1.0.2" />
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
</ItemGroup>
<ItemGroup>
@@ -27,5 +29,4 @@
</Content>
</ItemGroup>
<Import Project="Api.props"/>
</Project>

View File

@@ -1,9 +1,22 @@
using Moonlight.Api;
using SimplePlugin.Generated;
using Moonlight.Api.Host;
var plugins = PluginRegistry
.Modules
.OfType<MoonlightPlugin>()
.ToArray();
var appLoader = new AppStartupLoader();
appLoader.Initialize();
await StartupHandler.RunAsync(args, plugins);
var builder = WebApplication.CreateBuilder(args);
appLoader.PreBuild(builder);
var app = builder.Build();
appLoader.PostBuild(app);
appLoader.PostMiddleware(app);
if (app.Environment.IsDevelopment())
app.UseWebAssemblyDebugging();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
await app.RunAsync();

View File

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

View File

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

View File

@@ -14,13 +14,11 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all"/>
<PackageReference Include="SimplePlugin" Version="1.0.2" />
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.9"/>
<PackageReference Include="MoonCore.PluginFramework.Generator" Version="1.0.3"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Moonlight.Frontend\Moonlight.Frontend.csproj" />
</ItemGroup>
<Import Project="Frontend.props"/>
</Project>

View File

@@ -1,9 +1,15 @@
using Moonlight.Frontend;
using SimplePlugin.Generated;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Moonlight.Frontend.Host;
var plugins = PluginRegistry
.Modules
.OfType<MoonlightPlugin>()
.ToArray();
var appLoader = new AppStartupLoader();
appLoader.Initialize();
await StartupHandler.RunAsync(args, plugins);
var builder = WebAssemblyHostBuilder.CreateDefault(args);
appLoader.PreBuild(builder);
var app = builder.Build();
appLoader.PostBuild(app);
await app.RunAsync();

View File

@@ -1,11 +1,11 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../bin/ShadcnBlazor/scrollbar.css";
@import "../bin/ShadcnBlazor/default-theme.css";
@import "../../../Moonlight.Frontend/bin/ShadcnBlazor/scrollbar.css";
@import "../../../Moonlight.Frontend/bin/ShadcnBlazor/default-theme.css";
@import "./theme.css";
@source "../bin/ShadcnBlazor/ShadcnBlazor.map";
@source "../../../Moonlight.Frontend/bin/ShadcnBlazor/ShadcnBlazor.map";
@source "../../../Moonlight.Api/**/*.razor";
@source "../../../Moonlight.Api/**/*.cs";

View File

@@ -2,6 +2,5 @@ namespace Moonlight.Api.Configuration;
public class ApiOptions
{
public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
public int LookupCacheMinutes { get; set; } = 3;
}

View File

@@ -1,6 +0,0 @@
namespace Moonlight.Api.Configuration;
public class CacheOptions
{
public bool EnableLayer2 { get; set; }
}

View File

@@ -1,7 +0,0 @@
namespace Moonlight.Api.Configuration;
public class ContainerHelperOptions
{
public bool IsEnabled { get; set; }
public string Url { get; set; } = "http://helper:8080";
}

View File

@@ -4,7 +4,6 @@ public class OidcOptions
{
public string Authority { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public bool DisableHttpsOnlyCookies { get; set; }
public string ResponseType { get; set; } = "code";
public string[]? Scopes { get; set; }
public string ClientId { get; set; }

View File

@@ -1,7 +0,0 @@
namespace Moonlight.Api.Configuration;
public class RedisOptions
{
public bool Enable { get; set; }
public string ConnectionString { get; set; }
}

View File

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

View File

@@ -1,7 +0,0 @@
namespace Moonlight.Api.Configuration;
public class SettingsOptions
{
public TimeSpan LookupL1CacheTime { get; set; } = TimeSpan.FromMinutes(1);
public TimeSpan LookupL2CacheTime { get; set; } = TimeSpan.FromMinutes(5);
}

View File

@@ -1,7 +0,0 @@
namespace Moonlight.Api.Configuration;
public class UserOptions
{
public TimeSpan ValidationCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan ValidationCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
}

View File

@@ -1,6 +0,0 @@
namespace Moonlight.Api.Constants;
public class FrontendSettingConstants
{
public const string Name = "Moonlight.Frontend.Name";
}

View File

@@ -14,7 +14,6 @@ public class ApiKey : IActionTimestamps
public required string Description { get; set; }
public string[] Permissions { get; set; } = [];
public DateTimeOffset ValidUntil { get; set; }
[MaxLength(32)]
public string Key { get; set; }

View File

@@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Moonlight.Api.Database.Entities;
@@ -11,6 +10,5 @@ public class SettingsOption
public required string Key { get; set; }
[MaxLength(4096)]
[Column(TypeName = "jsonb")]
public required string ValueJson { get; set; }
public required string Value { get; set; }
}

View File

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

View File

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

View File

@@ -1,254 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Moonlight.Api.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20260209114238_AddedValidUntilToApiKeys")]
partial class AddedValidUntilToApiKeys
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("core")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ValidUntil")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Roles", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RoleId");
b.HasIndex("UserId");
b.ToTable("RoleMembers", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("CssContent")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.HasKey("Id");
b.ToTable("Themes", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Users", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.HasOne("Moonlight.Api.Database.Entities.Role", "Role")
.WithMany("Members")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Moonlight.Api.Database.Entities.User", "User")
.WithMany("RoleMemberships")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b =>
{
b.Navigation("RoleMemberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,32 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedValidUntilToApiKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ValidUntil",
schema: "core",
table: "ApiKeys",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ValidUntil",
schema: "core",
table: "ApiKeys");
}
}
}

View File

@@ -56,9 +56,6 @@ namespace Moonlight.Api.Database.Migrations
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ValidUntil")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
@@ -139,10 +136,10 @@ namespace Moonlight.Api.Database.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ValueJson")
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("jsonb");
.HasColumnType("character varying(4096)");
b.HasKey("Id");

View File

@@ -1,16 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Implementations.ApiKeyScheme;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
namespace Moonlight.Api.Http.Controllers.Admin;
@@ -20,12 +18,10 @@ namespace Moonlight.Api.Http.Controllers.Admin;
public class ApiKeyController : Controller
{
private readonly DatabaseRepository<ApiKey> KeyRepository;
private readonly HybridCache HybridCache;
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository, HybridCache hybridCache)
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository)
{
KeyRepository = keyRepository;
HybridCache = hybridCache;
}
[HttpGet]
@@ -118,8 +114,6 @@ public class ApiKeyController : Controller
ApiKeyMapper.Merge(apiKey, request);
await KeyRepository.UpdateAsync(apiKey);
await HybridCache.RemoveAsync(string.Format(ApiKeySchemeHandler.CacheKeyFormat, apiKey.Key));
return ApiKeyMapper.ToDto(apiKey);
}
@@ -135,9 +129,6 @@ public class ApiKeyController : Controller
return Problem("No API key with this id found", statusCode: 404);
await KeyRepository.RemoveAsync(apiKey);
await HybridCache.RemoveAsync(string.Format(ApiKeySchemeHandler.CacheKeyFormat, apiKey.Key));
return NoContent();
}
}

View File

@@ -1,56 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests.Admin.ContainerHelper;
using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/ch")]
[Authorize(Policy = Permissions.System.Instance)]
public class ContainerHelperController : Controller
{
private readonly ContainerHelperService ContainerHelperService;
private readonly IOptions<ContainerHelperOptions> Options;
public ContainerHelperController(ContainerHelperService containerHelperService, IOptions<ContainerHelperOptions> options)
{
ContainerHelperService = containerHelperService;
Options = options;
}
[HttpGet("status")]
public async Task<ActionResult<ContainerHelperStatusDto>> GetStatusAsync()
{
if (!Options.Value.IsEnabled)
return new ContainerHelperStatusDto(false, false);
var status = await ContainerHelperService.CheckConnectionAsync();
return new ContainerHelperStatusDto(true, status);
}
[HttpPost("rebuild")]
public Task<IResult> RebuildAsync([FromBody] RequestRebuildDto request)
{
var result = ContainerHelperService.RebuildAsync(request.NoBuildCache);
var mappedResult = result.Select(ContainerHelperMapper.ToDto);
return Task.FromResult<IResult>(
TypedResults.ServerSentEvents(mappedResult)
);
}
[HttpPost("version")]
public async Task<ActionResult> SetVersionAsync([FromBody] SetVersionDto request)
{
await ContainerHelperService.SetVersionAsync(request.Version);
return NoContent();
}
}

View File

@@ -6,7 +6,7 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.Users;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Api.Http.Controllers.Admin;

View File

@@ -6,7 +6,7 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin;

View File

@@ -1,49 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Constants;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests.Admin.Settings;
using Moonlight.Shared.Http.Responses.Admin.Settings;
namespace Moonlight.Api.Http.Controllers.Admin.Settings;
[ApiController]
[Authorize(Policy = Permissions.System.Settings)]
[Route("api/admin/system/settings/whiteLabeling")]
public class WhiteLabelingController : Controller
{
private readonly SettingsService SettingsService;
private readonly FrontendService FrontendService;
public WhiteLabelingController(SettingsService settingsService, FrontendService frontendService)
{
SettingsService = settingsService;
FrontendService = frontendService;
}
[HttpGet]
public async Task<ActionResult<WhiteLabelingDto>> GetAsync()
{
var dto = new WhiteLabelingDto
{
Name = await SettingsService.GetValueAsync<string>(FrontendSettingConstants.Name) ?? "Moonlight"
};
return dto;
}
[HttpPost]
public async Task<ActionResult<WhiteLabelingDto>> PostAsync([FromBody] SetWhiteLabelingDto request)
{
await SettingsService.SetValueAsync(FrontendSettingConstants.Name, request.Name);
await FrontendService.ResetCacheAsync();
var dto = new WhiteLabelingDto
{
Name = await SettingsService.GetValueAsync<string>(FrontendSettingConstants.Name) ?? "Moonlight"
};
return dto;
}
}

View File

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

View File

@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Api.Models;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using VYaml.Serialization;
namespace Moonlight.Api.Http.Controllers.Admin.Themes;
[ApiController]
[Route("api/admin/themes")]
[Authorize(Policy = Permissions.Themes.View)]
public class TransferController : Controller
{
private readonly DatabaseRepository<Theme> ThemeRepository;
public TransferController(DatabaseRepository<Theme> themeRepository)
{
ThemeRepository = themeRepository;
}
[HttpGet("{id:int}/export")]
public async Task<ActionResult> ExportAsync([FromRoute] int id)
{
var theme = await ThemeRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (theme == null)
return Problem("No theme with that id found", statusCode: 404);
var yml = YamlSerializer.Serialize(new ThemeTransferModel()
{
Name = theme.Name,
Author = theme.Author,
CssContent = theme.CssContent,
Version = theme.Version
});
return File(yml.ToArray(), "text/yaml", $"{theme.Name}.yml");
}
[HttpPost("import")]
public async Task<ActionResult<ThemeDto>> ImportAsync()
{
var themeToImport = await YamlSerializer.DeserializeAsync<ThemeTransferModel>(Request.Body);
var existingTheme = await ThemeRepository
.Query()
.FirstOrDefaultAsync(x => x.Name == themeToImport.Name && x.Author == themeToImport.Author);
if (existingTheme == null)
{
var finalTheme = await ThemeRepository.AddAsync(new Theme()
{
Name = themeToImport.Name,
Author = themeToImport.Author,
CssContent = themeToImport.CssContent,
Version = themeToImport.Version
});
return ThemeMapper.ToDto(finalTheme);
}
existingTheme.CssContent = themeToImport.CssContent;
existingTheme.Version = themeToImport.Version;
await ThemeRepository.UpdateAsync(existingTheme);
return ThemeMapper.ToDto(existingTheme);
}
}

View File

@@ -7,11 +7,11 @@ using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Moonlight.Shared.Http.Responses.Themes;
namespace Moonlight.Api.Http.Controllers.Admin.Themes;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/themes")]

View File

@@ -6,9 +6,9 @@ using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Admin.Users;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Api.Http.Controllers.Admin.Users;

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Moonlight.Shared.Http.Responses.Admin.Auth;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.Api.Http.Controllers;

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers;
using Moonlight.Api.Services;
using Moonlight.Shared.Http.Responses.Admin.Frontend;
using Moonlight.Shared.Http.Responses.Frontend;
namespace Moonlight.Api.Http.Controllers;

View File

@@ -1,13 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Moonlight.Api.Http.Controllers;
[ApiController]
[Route("api/ping")]
public class PingController : Controller
{
[HttpGet]
[AllowAnonymous]
public IActionResult Get() => Ok("Pong");
}

View File

@@ -1,20 +0,0 @@
using System.Text.Json.Serialization;
namespace Moonlight.Api.Http.Services.ContainerHelper.Events;
public struct RebuildEventDto
{
[JsonPropertyName("type")]
public RebuildEventType Type { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
}
public enum RebuildEventType
{
Log = 0,
Failed = 1,
Succeeded = 2,
Step = 3
}

View File

@@ -1,10 +0,0 @@
namespace Moonlight.Api.Http.Services.ContainerHelper;
public class ProblemDetails
{
public string Type { get; set; }
public string Title { get; set; }
public int Status { get; set; }
public string? Detail { get; set; }
public Dictionary<string, string[]>? Errors { get; set; }
}

View File

@@ -1,3 +0,0 @@
namespace Moonlight.Api.Http.Services.ContainerHelper.Requests;
public record RequestRebuildDto(bool NoBuildCache);

View File

@@ -1,3 +0,0 @@
namespace Moonlight.Api.Http.Services.ContainerHelper.Requests;
public record SetVersionDto(string Version);

View File

@@ -1,17 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Moonlight.Api.Http.Services.ContainerHelper.Events;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
namespace Moonlight.Api.Http.Services.ContainerHelper;
[JsonSerializable(typeof(SetVersionDto))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(RebuildEventDto))]
[JsonSerializable(typeof(RequestRebuildDto))]
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
public partial class SerializationContext : JsonSerializerContext
{
}

View File

@@ -2,7 +2,7 @@ using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Database;
@@ -14,20 +14,18 @@ namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
{
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly HybridCache HybridCache;
public const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
private readonly IMemoryCache MemoryCache;
public ApiKeySchemeHandler(
IOptionsMonitor<ApiKeySchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
DatabaseRepository<ApiKey> apiKeyRepository,
HybridCache hybridCache
IMemoryCache memoryCache
) : base(options, logger, encoder)
{
ApiKeyRepository = apiKeyRepository;
HybridCache = hybridCache;
MemoryCache = memoryCache;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
@@ -40,30 +38,24 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
if (authHeaderValue.Length > 32)
return AuthenticateResult.Fail("Invalid api key specified");
var cacheKey = string.Format(CacheKeyFormat, authHeaderValue);
var apiKey = await HybridCache.GetOrCreateAsync<ApiKeySession?>(
cacheKey,
async ct =>
if (!MemoryCache.TryGetValue<ApiKeySession>(authHeaderValue, out var apiKey))
{
return await ApiKeyRepository
apiKey = await ApiKeyRepository
.Query()
.Where(x => x.Key == authHeaderValue)
.Select(x => new ApiKeySession(x.Permissions, x.ValidUntil))
.FirstOrDefaultAsync(cancellationToken: ct);
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.LookupL1CacheTime,
Expiration = Options.LookupL2CacheTime
}
);
.Select(x => new ApiKeySession(x.Permissions))
.FirstOrDefaultAsync();
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
if (DateTimeOffset.UtcNow > apiKey.ValidUntil)
return AuthenticateResult.Fail("Api key expired");
MemoryCache.Set(authHeaderValue, apiKey, Options.LookupCacheTime);
}
else
{
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
}
return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(
@@ -75,5 +67,5 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
));
}
private record ApiKeySession(string[] Permissions, DateTimeOffset ValidUntil);
private record ApiKeySession(string[] Permissions);
}

View File

@@ -4,6 +4,5 @@ namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeOptions : AuthenticationSchemeOptions
{
public TimeSpan LookupL1CacheTime { get; set; }
public TimeSpan LookupL2CacheTime { get; set; }
public TimeSpan LookupCacheTime { get; set; }
}

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

View File

@@ -1,13 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Events;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class ContainerHelperMapper
{
public static partial RebuildEventDto ToDto(Http.Services.ContainerHelper.Events.RebuildEventDto rebuildEventDto);
}

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Models;
using Moonlight.Shared.Http.Responses.Admin.Frontend;
using Moonlight.Shared.Http.Responses.Frontend;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Responses.Themes;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Api.Mappers;

View File

@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using Riok.Mapperly.Abstractions;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Api.Database.Entities;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
namespace Moonlight.Api.Mappers;

View File

@@ -1,12 +0,0 @@
using VYaml.Annotations;
namespace Moonlight.Api.Models;
[YamlObject]
public partial class ThemeTransferModel
{
public string Name { get; set; }
public string Version { get; set; }
public string Author { get; set; }
public string CssContent { get; set; }
}

View File

@@ -25,14 +25,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1"/>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
<PackageReference Include="VYaml" Version="1.2.0" />
</ItemGroup>
<ItemGroup>
@@ -40,8 +35,4 @@
<Visible Condition="'%(NuGetItemType)' == 'Content'">false</Visible>
</Content>
</ItemGroup>
<ItemGroup>
<Folder Include="Http\Services\ContainerHelper\Responses\" />
</ItemGroup>
</Project>

View File

@@ -1,28 +0,0 @@
using System.Reflection;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using SimplePlugin.Abstractions;
namespace Moonlight.Api;
public abstract class MoonlightPlugin : IPluginModule
{
protected MoonlightPlugin[] Plugins { get; private set; }
public void Initialize(MoonlightPlugin[] plugins)
{
Plugins = plugins;
}
public virtual void PreBuild(WebApplicationBuilder builder)
{
}
public virtual void PostBuild(WebApplication application)
{
}
public virtual void PostMiddleware(WebApplication application)
{
}
}

View File

@@ -1,116 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
using Moonlight.Api.Http.Services.ContainerHelper;
using Moonlight.Api.Http.Services.ContainerHelper.Requests;
using Moonlight.Api.Http.Services.ContainerHelper.Events;
namespace Moonlight.Api.Services;
public class ContainerHelperService
{
private readonly IHttpClientFactory HttpClientFactory;
public ContainerHelperService(IHttpClientFactory httpClientFactory)
{
HttpClientFactory = httpClientFactory;
}
public async Task<bool> CheckConnectionAsync()
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
try
{
var response = await client.GetAsync("api/ping");
response.EnsureSuccessStatusCode();
return true;
}
catch (Exception)
{
return false;
}
}
public async IAsyncEnumerable<RebuildEventDto> RebuildAsync(bool noBuildCache)
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
var request = new HttpRequestMessage(HttpMethod.Post, "api/rebuild");
request.Content = JsonContent.Create(
new RequestRebuildDto(noBuildCache),
null,
SerializationContext.Default.Options
);
var response = await client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead
);
if (!response.IsSuccessStatusCode)
{
var responseText = await response.Content.ReadAsStringAsync();
yield return new RebuildEventDto()
{
Type = RebuildEventType.Failed,
Data = responseText
};
yield break;
}
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var streamReader = new StreamReader(responseStream);
do
{
var line = await streamReader.ReadLineAsync();
if (line == null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.Default.Options);
yield return deserializedData;
// Exit if service will go down for a clean exit
if (deserializedData is { Type: RebuildEventType.Step, Data: "ServiceDown" })
yield break;
} while (true);
yield return new RebuildEventDto()
{
Type = RebuildEventType.Succeeded,
Data = string.Empty
};
}
public async Task SetVersionAsync(string version)
{
var client = HttpClientFactory.CreateClient("ContainerHelper");
var response = await client.PostAsJsonAsync(
"api/configuration/version",
new SetVersionDto(version),
SerializationContext.Default.Options
);
if (response.IsSuccessStatusCode)
return;
var problemDetails =
await response.Content.ReadFromJsonAsync<ProblemDetails>(SerializationContext.Default.Options);
if (problemDetails == null)
throw new HttpRequestException($"Failed to set version: {response.ReasonPhrase}");
throw new HttpRequestException($"Failed to set version: {problemDetails.Detail ?? problemDetails.Title}");
}
}

View File

@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Constants;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Models;
@@ -14,16 +13,14 @@ public class FrontendService
private readonly IMemoryCache Cache;
private readonly DatabaseRepository<Theme> ThemeRepository;
private readonly IOptions<FrontendOptions> Options;
private readonly SettingsService SettingsService;
private const string CacheKey = $"Moonlight.{nameof(FrontendService)}.{nameof(GetConfigurationAsync)}";
public FrontendService(IMemoryCache cache, DatabaseRepository<Theme> themeRepository, IOptions<FrontendOptions> options, SettingsService settingsService)
public FrontendService(IMemoryCache cache, DatabaseRepository<Theme> themeRepository, IOptions<FrontendOptions> options)
{
Cache = cache;
ThemeRepository = themeRepository;
Options = options;
SettingsService = settingsService;
}
public async Task<FrontendConfiguration> GetConfigurationAsync()
@@ -38,9 +35,7 @@ public class FrontendService
.Query()
.FirstOrDefaultAsync(x => x.IsEnabled);
var name = await SettingsService.GetValueAsync<string>(FrontendSettingConstants.Name);
var config = new FrontendConfiguration(name ?? "Moonlight", theme?.CssContent);
var config = new FrontendConfiguration("Moonlight", theme?.CssContent);
Cache.Set(CacheKey, config, TimeSpan.FromMinutes(Options.Value.CacheMinutes));

View File

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

View File

@@ -1,6 +1,6 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
@@ -14,10 +14,10 @@ namespace Moonlight.Api.Services;
public class UserAuthService
{
private readonly DatabaseRepository<User> UserRepository;
private readonly IMemoryCache Cache;
private readonly ILogger<UserAuthService> Logger;
private readonly IOptions<UserOptions> Options;
private readonly IOptions<SessionOptions> Options;
private readonly IEnumerable<IUserAuthHook> Hooks;
private readonly HybridCache HybridCache;
private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt";
@@ -27,16 +27,15 @@ public class UserAuthService
public UserAuthService(
DatabaseRepository<User> userRepository,
ILogger<UserAuthService> logger,
IOptions<UserOptions> options,
IEnumerable<IUserAuthHook> hooks,
HybridCache hybridCache
IMemoryCache cache, IOptions<SessionOptions> options,
IEnumerable<IUserAuthHook> hooks
)
{
UserRepository = userRepository;
Logger = logger;
Cache = cache;
Options = options;
Hooks = hooks;
HybridCache = hybridCache;
}
public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
@@ -81,7 +80,7 @@ public class UserAuthService
foreach (var hook in Hooks)
{
// Run every hook, and if any returns false, we return false as well
// Run every hook and if any returns false we return false as well
if(!await hook.SyncAsync(principal, user))
return false;
}
@@ -102,11 +101,9 @@ public class UserAuthService
var cacheKey = string.Format(CacheKeyPattern, userId);
var user = await HybridCache.GetOrCreateAsync<UserSession?>(
cacheKey,
async ct =>
if (!Cache.TryGetValue<UserSession>(cacheKey, out var user))
{
return await UserRepository
user = await UserRepository
.Query()
.AsNoTracking()
.Where(u => u.Id == userId)
@@ -114,18 +111,23 @@ public class UserAuthService
u.InvalidateTimestamp,
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
)
.FirstOrDefaultAsync(cancellationToken: ct);
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.Value.ValidationCacheL1Expiry,
Expiration = Options.Value.ValidationCacheL2Expiry
}
);
.FirstOrDefaultAsync();
if (user == null)
return false;
Cache.Set(
cacheKey,
user,
TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes)
);
}
else
{
if (user == null)
return false;
}
var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
if (!long.TryParse(issuedAtString, out var issuedAtUnix))
@@ -147,7 +149,7 @@ public class UserAuthService
foreach (var hook in Hooks)
{
// Run every hook, and if any returns false we return false as well
// Run every hook and if any returns false we return false as well
if(!await hook.ValidateAsync(principal, userId))
return false;
}

View File

@@ -1,5 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
@@ -10,17 +10,13 @@ public class UserDeletionService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserDeletionHook> Hooks;
private readonly HybridCache HybridCache;
private readonly IMemoryCache Cache;
public UserDeletionService(
DatabaseRepository<User> repository,
IEnumerable<IUserDeletionHook> hooks,
HybridCache hybridCache
)
public UserDeletionService(DatabaseRepository<User> repository, IEnumerable<IUserDeletionHook> hooks, IMemoryCache cache)
{
Repository = repository;
Hooks = hooks;
HybridCache = hybridCache;
Cache = cache;
}
public async Task<UserDeletionValidationResult> ValidateAsync(int userId)
@@ -58,8 +54,7 @@ public class UserDeletionService
await hook.ExecuteAsync(user);
await Repository.RemoveAsync(user);
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
}
}

View File

@@ -1,5 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
@@ -10,17 +10,17 @@ public class UserLogoutService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserLogoutHook> Hooks;
private readonly HybridCache HybridCache;
private readonly IMemoryCache Cache;
public UserLogoutService(
DatabaseRepository<User> repository,
IEnumerable<IUserLogoutHook> hooks,
HybridCache hybridCache
IMemoryCache cache
)
{
Repository = repository;
Hooks = hooks;
HybridCache = hybridCache;
Cache = cache;
}
public async Task LogoutAsync(int userId)
@@ -38,6 +38,6 @@ public class UserLogoutService
user.InvalidateTimestamp = DateTimeOffset.UtcNow;
await Repository.UpdateAsync(user);
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
}
}

View File

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

View File

@@ -17,25 +17,19 @@ public partial class Startup
{
private static void AddAuth(WebApplicationBuilder builder)
{
// OIDC
var oidcOptions = new OidcOptions();
builder.Configuration.GetSection("Moonlight:Oidc").Bind(oidcOptions);
// API Key
var apiKeyOptions = new ApiOptions();
builder.Configuration.GetSection("Moonlight:Api").Bind(apiKeyOptions);
builder.Services.AddOptions<ApiOptions>().BindConfiguration("Moonlight:Api");
// Session
builder.Services.AddOptions<UserOptions>().BindConfiguration("Moonlight:User");
builder.Services.AddScoped<UserAuthService>();
// Authentication
builder.Services.AddAuthentication("Main")
.AddPolicyScheme("Main", null,
options =>
.AddPolicyScheme("Main", null, options =>
{
options.ForwardDefaultSelector += context =>
context.Request.Headers.Authorization.Count > 0 ? "ApiKey" : "Session";
options.ForwardDefaultSelector += context => context.Request.Headers.Authorization.Count > 0 ? "ApiKey" : "Session";
})
.AddCookie("Session", null, options =>
{
@@ -80,12 +74,6 @@ public partial class Startup
options.Authority = oidcOptions.Authority;
options.RequireHttpsMetadata = oidcOptions.RequireHttpsMetadata;
if (oidcOptions.DisableHttpsOnlyCookies)
{
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
}
var scopes = oidcOptions.Scopes ?? ["openid", "email", "profile"];
options.Scope.Clear();
@@ -103,26 +91,15 @@ public partial class Startup
options.GetClaimsFromUserInfoEndpoint = true;
})
.AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>("ApiKey", null,
options =>
.AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>("ApiKey", null, options =>
{
options.LookupL1CacheTime = apiKeyOptions.LookupCacheL1Expiry;
options.LookupL2CacheTime = apiKeyOptions.LookupCacheL2Expiry;
options.LookupCacheTime = TimeSpan.FromMinutes(apiKeyOptions.LookupCacheMinutes);
});
// Authorization
builder.Services.AddAuthorization();
// Reduce log noise
builder.Logging.AddFilter("Moonlight.Api.Implementations.ApiKeyScheme.ApiKeySchemeHandler", LogLevel.Warning);
// Custom permission handling using named policies
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
builder.Services.AddScoped<UserAuthService>();
}
private static void UseAuth(WebApplication application)

View File

@@ -1,6 +1,4 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
@@ -11,73 +9,43 @@ using Moonlight.Api.Helpers;
using Moonlight.Api.Implementations;
using Moonlight.Api.Interfaces;
using Moonlight.Api.Services;
using SessionOptions = Moonlight.Api.Configuration.SessionOptions;
namespace Moonlight.Api.Startup;
public partial class Startup
{
private void AddBase(WebApplicationBuilder builder)
private static void AddBase(WebApplicationBuilder builder)
{
// Create the base directory
Directory.CreateDirectory("storage");
// Hook up source-generated serialization and add controllers
builder.Services
.AddControllers()
.AddApplicationPart(typeof(Startup).Assembly)
.AddJsonOptions(options =>
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
});
// Configure logging
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); });
builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>();
// Application service
builder.Services.AddSingleton<ApplicationService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ApplicationService>());
// Diagnose
builder.Services.AddSingleton<DiagnoseService>();
builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>();
// Frontend
builder.Services.AddMemoryCache();
builder.Services.AddOptions<SessionOptions>().BindConfiguration("Moonlight:Session");
builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
builder.Services.AddScoped<FrontendService>();
// HTTP Client
builder.Services.AddHttpClient();
// Version fetching configuration
builder.Services.AddOptions<VersionOptions>().BindConfiguration("Moonlight:Version");
builder.Services.AddSingleton<VersionService>();
// Container Helper Options
builder.Configuration.GetSection("Moonlight:ContainerHelper").Bind(builder.Configuration);
builder.Services.AddOptions<ContainerHelperOptions>().BindConfiguration("Moonlight:ContainerHelper");
builder.Services.AddSingleton<ContainerHelperService>();
builder.Services.AddHttpClient("ContainerHelper", (provider, client) =>
{
var options = provider.GetRequiredService<IOptions<ContainerHelperOptions>>();
client.BaseAddress =
new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid");
});
// User management services
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
// Settings options
builder.Services.AddOptions<SettingsOptions>().BindConfiguration("Moonlight:Settings");
builder.Services.AddScoped<SettingsService>();
// Setup key loading
var keysDirectory = new DirectoryInfo(Path.Combine("storage", "keys"));
builder.Services.AddDataProtection().PersistKeysToFileSystem(keysDirectory);
}
private static void UseBase(WebApplication application)

View File

@@ -1,34 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Api.Configuration;
namespace Moonlight.Api.Startup;
public partial class Startup
{
private static void AddCache(WebApplicationBuilder builder)
{
// Load cache options
var cacheOptions = new CacheOptions();
builder.Configuration.GetSection("Moonlight:Cache").Bind(cacheOptions);
builder.Services.AddMemoryCache();
builder.Services.AddHybridCache();
if (!cacheOptions.EnableLayer2)
return;
var redisOptions = new RedisOptions();
builder.Configuration.GetSection("Moonlight:Redis").Bind(redisOptions);
if(!redisOptions.Enable)
return;
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisOptions.ConnectionString;
options.InstanceName = "Moonlight:";
});
}
}

View File

@@ -1,29 +1,23 @@
using System.Reflection;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Moonlight.Shared.Http;
using SimplePlugin.Abstractions;
using Microsoft.AspNetCore.Builder;
namespace Moonlight.Api.Startup;
[PluginModule]
public partial class Startup : MoonlightPlugin
public partial class Startup : IAppStartup
{
public override void PreBuild(WebApplicationBuilder builder)
public void PreBuild(WebApplicationBuilder builder)
{
AddBase(builder);
AddAuth(builder);
AddDatabase(builder);
AddCache(builder);
}
public override void PostBuild(WebApplication application)
public void PostBuild(WebApplication application)
{
UseBase(application);
UseAuth(application);
}
public override void PostMiddleware(WebApplication application)
public void PostMiddleware(WebApplication application)
{
MapBase(application);
}

View File

@@ -1,42 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;
namespace Moonlight.Api;
public static class StartupHandler
{
public static async Task RunAsync(string[] args, MoonlightPlugin[] plugins)
{
Console.WriteLine($"Starting with: {string.Join(", ", plugins.Select(x => x.GetType().FullName))}");
var builder = WebApplication.CreateBuilder(args);
// Setting up context
foreach (var plugin in plugins)
plugin.Initialize(plugins);
// Stage 1: Pre Build
foreach (var startup in plugins)
startup.PreBuild(builder);
var app = builder.Build();
// Stage 2: Post Build
foreach (var startup in plugins)
startup.PostBuild(app);
// Stage 3: Post Middleware
foreach (var startup in plugins)
startup.PostMiddleware(app);
// Frontend debugging
if (app.Environment.IsDevelopment())
app.UseWebAssemblyDebugging();
// Frontend hosting
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
await app.RunAsync();
}
}

View File

@@ -1,26 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Frontend.Interfaces;
namespace Moonlight.Frontend.Configuration;
public class LayoutMiddlewareOptions
{
public IReadOnlyList<Type> Components => InnerComponents;
private readonly List<Type> InnerComponents = new();
public void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() where T : LayoutMiddlewareBase
{
InnerComponents.Add(typeof(T));
}
public void Insert<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(int index) where T : LayoutMiddlewareBase
{
InnerComponents.Insert(index, typeof(T));
}
public void Remove<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() where T : LayoutMiddlewareBase
{
InnerComponents.Remove(typeof(T));
}
}

View File

@@ -1,33 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
namespace Moonlight.Frontend.Configuration;
public class LayoutPageOptions
{
public IReadOnlyList<LayoutPageComponent> Components => InnerComponents;
private readonly List<LayoutPageComponent> InnerComponents = new();
public void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(LayoutPageSlot slot, int order)
where T : ComponentBase
=> Add(typeof(T), slot, order);
public void Add(Type componentType, LayoutPageSlot slot, int order)
=> InnerComponents.Add(new LayoutPageComponent(componentType, order, slot));
public void Remove<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>()
where T : ComponentBase
=> Remove(typeof(T));
public void Remove(Type componentType)
=> InnerComponents.RemoveAll(x => x.ComponentType == componentType);
}
public record LayoutPageComponent(Type ComponentType, int Order, LayoutPageSlot Slot);
public enum LayoutPageSlot
{
Header = 0,
Footer = 1
}

View File

@@ -1,8 +0,0 @@
using System.Reflection;
namespace Moonlight.Frontend.Configuration;
public class NavigationAssemblyOptions
{
public List<Assembly> Assemblies { get; private set; } = new();
}

View File

@@ -1,35 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
namespace Moonlight.Frontend.Configuration;
public class SystemSettingsOptions
{
public IReadOnlyList<SystemSettingsPage> Components => InnerComponents;
private readonly List<SystemSettingsPage> InnerComponents = new();
public void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TIcon,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>(string name, string description,
int order)
where TIcon : ComponentBase where TComponent : ComponentBase
=> Add(name, description, order, typeof(TIcon), typeof(TComponent));
public void Add(string name, string description, int order, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type iconComponent, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type component)
=> InnerComponents.Add(new SystemSettingsPage(name, description, order, iconComponent, component));
public void Remove<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>()
where TComponent : ComponentBase
=> Remove(typeof(TComponent));
public void Remove([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType)
=> InnerComponents.RemoveAll(x => x.ComponentType == componentType);
}
public record SystemSettingsPage(
string Name,
string Description,
int Order,
Type IconComponentType,
Type ComponentType
);

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
using Moonlight.Shared.Http;
namespace Moonlight.Frontend;
public static class Constants
{
public static JsonSerializerOptions SerializerOptions
{
get
{
if (InternalOptions != null)
return InternalOptions;
InternalOptions = new()
{
PropertyNameCaseInsensitive = true
};
// Add source generated options from shared project
InternalOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
return InternalOptions;
}
}
private static JsonSerializerOptions? InternalOptions;
}

View File

@@ -1,30 +0,0 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Components.Forms;
using Moonlight.Shared.Http.Responses;
namespace Moonlight.Frontend.Helpers;
public static class ProblemDetailsHelper
{
public static async Task HandleProblemDetailsAsync(HttpResponseMessage response, object model, ValidationMessageStore validationMessageStore)
{
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();
if (problemDetails == null)
response.EnsureSuccessStatusCode(); // Trigger exception when unable to parse
else
{
if(!string.IsNullOrEmpty(problemDetails.Detail))
validationMessageStore.Add(new FieldIdentifier(model, string.Empty), problemDetails.Detail);
if (problemDetails.Errors != null)
{
foreach (var error in problemDetails.Errors)
{
foreach (var message in error.Value)
validationMessageStore.Add(new FieldIdentifier(model, error.Key), message);
}
}
}
}
}

View File

@@ -28,8 +28,6 @@ public sealed class PermissionProvider : IPermissionProvider
new Permission(Permissions.System.Info, "Info", "View system info"),
new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"),
new Permission(Permissions.System.Versions, "Versions", "Look at the available versions"),
new Permission(Permissions.System.Instance, "Instance", "Update the moonlight instance and add plugins"),
new Permission(Permissions.System.Settings, "Settings", "Change settings of the instance"),
]),
new PermissionCategory("API Keys", typeof(KeyIcon), [
new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"),

View File

@@ -1,8 +0,0 @@
using Microsoft.AspNetCore.Components;
namespace Moonlight.Frontend.Interfaces;
public abstract class LayoutMiddlewareBase : ComponentBase
{
[Parameter] public RenderFragment ChildContent { get; set; }
}

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
using Moonlight.Shared.Http.Requests.ApiKeys;
using Moonlight.Shared.Http.Responses.ApiKeys;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Frontend.Mappers;

View File

@@ -1,5 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Admin.Roles;
using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses.Admin;
using Riok.Mapperly.Abstractions;

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Admin.Themes;
using Moonlight.Shared.Http.Responses.Admin.Themes;
using Moonlight.Shared.Http.Requests.Themes;
using Moonlight.Shared.Http.Responses.Themes;
using Riok.Mapperly.Abstractions;
namespace Moonlight.Frontend.Mappers;

View File

@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Moonlight.Shared.Http.Requests.Admin.Users;
using Moonlight.Shared.Http.Responses.Admin.Users;
using Riok.Mapperly.Abstractions;
using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Frontend.Mappers;

View File

@@ -24,9 +24,8 @@
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
<PackageReference Include="ShadcnBlazor" Version="1.0.13" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.13" />
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2" />
<PackageReference Include="ShadcnBlazor" Version="1.0.9" />
<PackageReference Include="ShadcnBlazor.Extras" Version="1.0.9" />
</ItemGroup>
<ItemGroup>
@@ -36,6 +35,5 @@
<ItemGroup>
<None Include="Styles/*" Pack="true" PackagePath="Styles/" />
<None Include="Moonlight.Frontend.targets" Pack="true" PackagePath="build\Moonlight.Frontend.targets" />
<None Include="Moonlight.Frontend.targets" Pack="true" PackagePath="buildTransitive\Moonlight.Frontend.targets" />
</ItemGroup>
</Project>

View File

@@ -5,7 +5,7 @@
</MoonlightCssClassDir>
</PropertyGroup>
<Target Name="Moonlight_CopyContents" BeforeTargets="Build">
<Target Name="CopyContents" BeforeTargets="Build">
<ItemGroup>
<Files Include="$(MSBuildThisFileDirectory)..\Styles\**\*" />
</ItemGroup>

View File

@@ -1,17 +0,0 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using SimplePlugin.Abstractions;
namespace Moonlight.Frontend;
public abstract class MoonlightPlugin : IPluginModule
{
protected MoonlightPlugin[] Plugins { get; private set; }
public void Initialize(MoonlightPlugin[] plugins)
{
Plugins = plugins;
}
public virtual void PreBuild(WebAssemblyHostBuilder builder){}
public virtual void PostBuild(WebAssemblyHost application){}
}

View File

@@ -3,9 +3,7 @@ using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using Microsoft.VisualBasic;
using Moonlight.Shared.Http;
using Moonlight.Shared.Http.Responses.Admin.Auth;
using Moonlight.Shared.Http.Responses.Auth;
namespace Moonlight.Frontend.Services;
@@ -25,7 +23,7 @@ public class RemoteAuthProvider : AuthenticationStateProvider
try
{
var claimResponses = await HttpClient.GetFromJsonAsync<ClaimDto[]>(
"api/auth/claims", SerializationContext.Default.Options
"api/auth/claims", Constants.SerializerOptions
);
var claims = claimResponses!.Select(claim => new Claim(claim.Type, claim.Value));

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
namespace Moonlight.Frontend.Startup;
public interface IAppStartup
{
public void PreBuild(WebAssemblyHostBuilder builder);
public void PostBuild(WebAssemblyHost application);
}

View File

@@ -1,13 +1,10 @@
using LucideBlazor;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Frontend.Configuration;
using Moonlight.Frontend.Implementations;
using Moonlight.Frontend.Interfaces;
using Moonlight.Frontend.Services;
using Moonlight.Frontend.UI;
using Moonlight.Frontend.UI.Admin.Settings;
using ShadcnBlazor;
using ShadcnBlazor.Extras;
@@ -28,19 +25,5 @@ public partial class Startup
builder.Services.AddSingleton<ISidebarProvider, SidebarProvider>();
builder.Services.AddScoped<FrontendService>();
builder.Services.Configure<NavigationAssemblyOptions>(options =>
{
options.Assemblies.Add(typeof(Startup).Assembly);
});
builder.Services.Configure<SystemSettingsOptions>(options =>
{
options.Add<TextCursorInputIcon, WhiteLabelingSetting>(
"White Labeling",
"Settings for white labeling your moonlight instance",
0
);
});
}
}

View File

@@ -1,18 +1,16 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using SimplePlugin.Abstractions;
namespace Moonlight.Frontend.Startup;
[PluginModule]
public partial class Startup : MoonlightPlugin
public partial class Startup : IAppStartup
{
public override void PreBuild(WebAssemblyHostBuilder builder)
public void PreBuild(WebAssemblyHostBuilder builder)
{
AddBase(builder);
AddAuth(builder);
}
public override void PostBuild(WebAssemblyHost application)
public void PostBuild(WebAssemblyHost application)
{
}

View File

@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
namespace Moonlight.Frontend;
public static class StartupHandler
{
public static async Task RunAsync(string[] args, MoonlightPlugin[] plugins)
{
Console.WriteLine($"Starting with: {string.Join(", ", plugins.Select(x => x.GetType().FullName))}");
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Setting up context
foreach (var plugin in plugins)
plugin.Initialize(plugins);
// Stage 1: Pre Build
foreach (var plugin in plugins)
plugin.PreBuild(builder);
var app = builder.Build();
// Stage 2: Post Build
foreach(var plugin in plugins)
plugin.PostBuild(app);
await app.RunAsync();
}
}

View File

@@ -1,19 +1,13 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.ApiKeys
@using Moonlight.Shared.Http.Responses
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http.Requests.ApiKeys
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>Create new API key</DialogTitle>
<DialogDescription>
@@ -21,80 +15,56 @@
</DialogDescription>
</DialogHeader>
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<DataAnnotationsValidator/>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary />
<FieldSet>
<Field>
<FieldLabel for="keyName">Name</FieldLabel>
<TextInputField @bind-Value="Request.Name" id="keyName" placeholder="My API key"/>
</Field>
<Field>
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field>
<Field>
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil" />
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
<div class="grid gap-2">
<Label for="keyName">Name</Label>
<InputField @bind-Value="Request.Name" id="keyName" placeholder="My API key" />
</div>
<div class="grid gap-2">
<Label for="keyDescription">Description</Label>
<textarea
@bind="Request.Description"
id="keyDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="What this key is for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="() => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<Task> OnSubmit { get; set; }
[Parameter] public Func<CreateApiKeyDto, Task> OnSubmit { get; set; }
private CreateApiKeyDto Request;
private FormHandler FormHandler;
private List<string> Permissions = new();
protected override void OnInitialized()
{
Request = new()
{
Permissions = [],
ValidUntil = DateTimeOffset.UtcNow
};
Request = new();
}
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
private async Task SubmitAsync()
{
Request.Permissions = Permissions.ToArray();
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys",
Request,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"API Key creation",
$"Successfully created API key {Request.Name}"
);
await OnSubmit.Invoke();
await OnSubmit.Invoke(Request);
await CloseAsync();
return true;
}
}

View File

@@ -1,18 +1,14 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Roles
@using Moonlight.Shared.Http.Requests.Roles
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>
Create new role
@@ -22,43 +18,49 @@
</DialogDescription>
</DialogHeader>
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="OnSubmitAsync">
<div class="flex flex-col gap-6">
<FieldSet>
<Field>
<FieldLabel for="roleName">Name</FieldLabel>
<TextInputField
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="roleName">Name</Label>
<InputField
@bind-Value="Request.Name"
id="roleName"
placeholder="My fancy role"/>
</Field>
<Field>
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription"
placeholder="Describe what the role should be used for"/>
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
</div>
<div class="grid gap-2">
<Label for="roleDescription">Description</Label>
<textarea
@bind="Request.Description"
id="roleDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="Describe what the role should be used for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="SubmitAsync">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<Task> OnSubmit { get; set; }
[Parameter] public Func<CreateRoleDto, Task> OnSubmit { get; set; }
private CreateRoleDto Request;
private List<string> Permissions;
private FormHandler FormHandler;
protected override void OnInitialized()
{
@@ -70,27 +72,15 @@
Permissions = new();
}
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
private async Task SubmitAsync()
{
Request.Permissions = Permissions.ToArray();
await FormHandler.SubmitAsync();
}
var response = await HttpClient.PostAsJsonAsync(
"api/admin/roles",
Request,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
private async Task OnSubmitAsync()
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync("Role creation", $"Role {Request.Name} has been successfully created");
await OnSubmit.Invoke();
await OnSubmit.Invoke(Request);
await CloseAsync();
return true;
}
}

View File

@@ -1,18 +1,12 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Users
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Requests.Users
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>
Create new user
@@ -22,67 +16,50 @@
</DialogDescription>
</DialogHeader>
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator />
<FieldSet>
<Field>
<FieldLabel for="username">Username</FieldLabel>
<TextInputField
<div class="grid gap-2">
<Label for="username">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
placeholder="Name of the user"/>
</Field>
<Field>
<FieldLabel for="emailAddress">Email Address</FieldLabel>
<TextInputField
</div>
<div class="grid gap-2">
<Label for="emailAddress">Email Address</Label>
<InputField
@bind-Value="Request.Email"
id="emailAddress"
Type="email"
placeholder="email@of.user"/>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<Task> OnCompleted { get; set; }
[Parameter] public Func<CreateUserDto, Task> OnSubmit { get; set; }
private CreateUserDto Request;
private FormHandler FormHandler;
protected override void OnInitialized()
{
Request = new();
}
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
private async Task SubmitAsync()
{
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/users",
Request,
SerializationContext.Default.Options
);
await OnSubmit.Invoke(Request);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"User creation",
$"Successfully created user {Request.Username}"
);
await OnCompleted.Invoke();
await CloseAsync();
return true;
}
}

View File

@@ -1,7 +1,7 @@
@using LucideBlazor
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin
@using Moonlight.Shared.Http.Responses.Admin.Users
@using Moonlight.Shared.Http.Responses.Users
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dialogs
@@ -32,9 +32,9 @@
SearchPlaceholder="Search user"
ValueSelector="dto => dto.Username"
Source="LoadUsersAsync"/>
<WButton OnClick="AddAsync" Variant="ButtonVariant.Outline" Size="ButtonSize.Icon">
<WButtom OnClick="AddAsync" Variant="ButtonVariant.Outline" Size="ButtonSize.Icon">
<PlusIcon/>
</WButton>
</WButtom>
</div>
</div>
<div class="mt-3">
@@ -50,9 +50,9 @@
<CellTemplate>
<TableCell>
<div class="flex justify-end me-1.5">
<WButton OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive" Size="ButtonSize.Icon">
<WButtom OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive" Size="ButtonSize.Icon">
<TrashIcon/>
</WButton>
</WButtom>
</div>
</TableCell>
</CellTemplate>

View File

@@ -1,20 +1,15 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.ApiKeys
@using Moonlight.Shared.Http.Responses.Admin.ApiKeys
@using Moonlight.Shared.Http.Requests.ApiKeys
@using Moonlight.Shared.Http.Responses.ApiKeys
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>Update API key</DialogTitle>
<DialogDescription>
@@ -22,43 +17,45 @@
</DialogDescription>
</DialogHeader>
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary />
<DataAnnotationsValidator/>
<FieldSet>
<Field>
<FieldLabel for="keyName">Name</FieldLabel>
<TextInputField @bind-Value="Request.Name" id="keyName" placeholder="My API key"/>
</Field>
<Field>
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field>
<Field>
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil" />
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
<div class="grid gap-2">
<Label for="keyName">Name</Label>
<InputField @bind-Value="Request.Name" id="keyName" placeholder="My API key" />
</div>
<div class="grid gap-2">
<Label for="keyDescription">Description</Label>
<textarea
@bind="Request.Description"
id="keyDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="What this key is for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<Task> OnSubmit { get; set; }
[Parameter] public Func<UpdateApiKeyDto, Task> OnSubmit { get; set; }
[Parameter] public ApiKeyDto Key { get; set; }
private UpdateApiKeyDto Request;
private FormHandler FormHandler;
private List<string> Permissions = new();
protected override void OnInitialized()
@@ -67,31 +64,10 @@
Permissions = Key.Permissions.ToList();
}
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
private async Task SubmitAsync()
{
Request.Permissions = Permissions.ToArray();
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/apiKeys/{Key.Id}",
Request,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"API Key update",
$"Successfully updated API key {Request.Name}"
);
await OnSubmit.Invoke();
await OnSubmit.Invoke(Request);
await CloseAsync();
return true;
}
}

View File

@@ -1,38 +1,26 @@
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@using System.Text.Json
@using LucideBlazor
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Events
@using Moonlight.Shared.Http.Requests.Admin.ContainerHelper
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Progresses
@using ShadcnBlazor.Spinners
@inject HttpClient HttpClient
@inject AlertDialogService AlertService
<DialogHeader>
<DialogTitle>
Updating instance to @Version...
Updating...
</DialogTitle>
</DialogHeader>
<div class="grid grid-cols-1 xl:grid-cols-2 w-full gap-5">
<div class="text-base flex flex-col p-2 gap-y-1">
<div class="text-base flex flex-col p-2 gap-y-0.5">
@for (var i = 0; i < Steps.Length; i++)
{
if (CurrentStep == i)
{
<div class="flex flex-row items-center gap-x-1">
@if (IsFailed)
{
<CircleXIcon ClassName="text-red-500 size-5"/>
}
else
{
<Spinner ClassName="size-5"/>
}
<div class="flex flex-row items-center gap-x-2">
<Spinner ClassName="size-4" />
<span>
@Steps[i]
</span>
@@ -42,8 +30,8 @@
{
if (i < CurrentStep)
{
<div class="flex flex-row items-center gap-x-1 text-muted-foreground">
<CircleCheckIcon ClassName="text-green-500 size-5"/>
<div class="flex flex-row items-center gap-x-2">
<CheckIcon ClassName="text-green-500 size-4" />
<span>
@Steps[i]
</span>
@@ -51,194 +39,81 @@
}
else
{
<div class="text-muted-foreground flex flex-row items-center gap-x-1">
<span class="size-5"></span>
<div class="text-muted-foreground flex flex-row items-center gap-x-2">
<span class="size-4"></span>
@Steps[i]
</div>
}
}
}
</div>
<div class="bg-black text-white rounded-lg font-mono h-96 flex flex-col-reverse overflow-auto p-3 scrollbar-thin">
@for (var i = LogLines.Count - 1; i >= 0; i--)
{
<div>
@LogLines[i]
</div>
}
</div>
</div>
@if (CurrentStep == Steps.Length || IsFailed)
{
<DialogFooter ClassName="justify-end">
<Button Variant="ButtonVariant.Outline" @onclick="CloseAsync">Close</Button>
</DialogFooter>
}
else
{
<DialogFooter>
<Progress ClassName="my-1" Value="@Progress"></Progress>
<Progress Value="@Progress"></Progress>
</DialogFooter>
}
@code
{
[Parameter] public string Version { get; set; }
[Parameter] public bool NoBuildCache { get; set; }
private int Progress = 0;
private bool IsFailed;
private int Progress;
private int CurrentStep;
private readonly string[] Steps =
private string[] Steps =
[
"Checking", // 0
"Updating configuration files", // 1
"Starting rebuild task", // 2
"Building docker image", // 3
"Redeploying container instance", // 4
"Waiting for container instance to start up", // 5
"Update complete" // 6
"Preparing",
"Updating configuration files",
"Building docker image",
"Redeploying container instance",
"Waiting for container instance to start up",
"Update complete"
];
private readonly List<string?> LogLines = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
return;
// Checking
CurrentStep = 0;
Progress = 0;
await InvokeAsync(StateHasChanged);
await Task.Delay(2000);
// Update configuration
CurrentStep = 1;
Progress = 20;
await InvokeAsync(StateHasChanged);
await HttpClient.PostAsJsonAsync("api/admin/ch/version", new SetVersionDto()
{
Version = Version
}, SerializationContext.Default.Options);
await Task.Delay(6000);
// Starting rebuild task
CurrentStep = 2;
Progress = 30;
Progress = 40;
await InvokeAsync(StateHasChanged);
var request = new HttpRequestMessage(HttpMethod.Post, "api/admin/ch/rebuild");
request.Content = JsonContent.Create(
new RequestRebuildDto(NoBuildCache),
null,
SerializationContext.Default.Options
);
var response = await HttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead
);
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var streamReader = new StreamReader(responseStream);
do
{
try
{
var line = await streamReader.ReadLineAsync();
if (line == null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
var data = line.Trim("data: ");
var deserializedData = JsonSerializer.Deserialize<RebuildEventDto>(data, SerializationContext.Default.Options);
switch (deserializedData.Type)
{
case RebuildEventType.Log:
LogLines.Add(deserializedData.Data);
break;
case RebuildEventType.Step:
switch (deserializedData.Data)
{
case "BuildImage":
// Building docker image
await Task.Delay(2000);
CurrentStep = 3;
Progress = 40;
Progress = 60;
await InvokeAsync(StateHasChanged);
break;
case "ServiceDown":
// Redeploying container instance
await Task.Delay(4000);
CurrentStep = 4;
Progress = 60;
await InvokeAsync(StateHasChanged);
break;
}
break;
case RebuildEventType.Failed:
IsFailed = true;
Progress = 80;
await InvokeAsync(StateHasChanged);
return;
}
await InvokeAsync(StateHasChanged);
}
catch (Exception)
{
break;
}
} while (true);
// Waiting for container instance to start up
await Task.Delay(4000);
CurrentStep = 5;
Progress = 90;
await InvokeAsync(StateHasChanged);
// Wait some time for instance to shut down
await Task.Delay(TimeSpan.FromSeconds(5));
// Ping instance until its reachable again
while (true)
{
try
{
await HttpClient.GetStringAsync("api/ping");
break;
}
catch (Exception)
{
// Ignored
}
await Task.Delay(3000);
}
// Update complete
CurrentStep = 7;
Progress = 100;
await InvokeAsync(StateHasChanged);
await Task.Delay(1000);
await AlertService.SuccessAsync(
"Update completed",
"Update successfully completed. Please refresh the page to load new frontend changes"
);
await CloseAsync();
}
}

View File

@@ -1,20 +1,16 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers
@using Moonlight.Frontend.UI.Admin.Components
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Roles
@using Moonlight.Shared.Http.Requests.Roles
@using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>
Update @Role.Name
@@ -24,44 +20,50 @@
</DialogDescription>
</DialogHeader>
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="OnSubmitAsync">
<div class="flex flex-col gap-6">
<FieldSet>
<Field>
<FieldLabel for="roleName">Name</FieldLabel>
<TextInputField
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="roleName">Name</Label>
<InputField
@bind-Value="Request.Name"
id="roleName"
placeholder="My fancy role"/>
</Field>
<Field>
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription"
placeholder="Describe what the role should be used for"/>
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
</div>
<div class="grid gap-2">
<Label for="roleDescription">Description</Label>
<textarea
@bind="Request.Description"
id="roleDescription"
maxlength="100"
class="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
placeholder="Describe what the role should be used for">
</textarea>
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<PermissionSelector Permissions="Permissions" />
</FieldContent>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="SubmitAsync">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<Task> OnSubmit { get; set; }
[Parameter] public Func<UpdateRoleDto, Task> OnSubmit { get; set; }
[Parameter] public RoleDto Role { get; set; }
private UpdateRoleDto Request;
private List<string> Permissions;
private FormHandler FormHandler;
protected override void OnInitialized()
{
@@ -69,27 +71,15 @@
Permissions = Role.Permissions.ToList();
}
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
private async Task SubmitAsync()
{
Request.Permissions = Permissions.ToArray();
await FormHandler.SubmitAsync();
}
var response = await HttpClient.PatchAsJsonAsync(
$"api/admin/roles/{Role.Id}",
Request,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
private async Task OnSubmitAsync()
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync("Role update", $"Role {Request.Name} has been successfully updated");
await OnSubmit.Invoke();
await OnSubmit.Invoke(Request);
await CloseAsync();
return true;
}
}

View File

@@ -1,19 +1,14 @@
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Mappers
@using Moonlight.Shared.Http.Requests.Admin.Users
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin.Users
@using Moonlight.Frontend.Mappers
@using Moonlight.Shared.Http.Requests.Users
@using Moonlight.Shared.Http.Responses.Users
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>
Update @User.Username
@@ -23,66 +18,51 @@
</DialogDescription>
</DialogHeader>
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync">
<FieldGroup>
<FormValidationSummary/>
<DataAnnotationsValidator/>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FieldSet>
<Field>
<FieldLabel for="username">Username</FieldLabel>
<TextInputField
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="username">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
placeholder="Name of the user"/>
</Field>
<Field>
<FieldLabel for="emailAddress">Email Address</FieldLabel>
<TextInputField
</div>
<div class="grid gap-2">
<Label for="emailAddress">Email Address</Label>
<InputField
@bind-Value="Request.Email"
id="emailAddress"
Type="email"
placeholder="email@of.user"/>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<Task> OnCompleted { get; set; }
[Parameter] public Func<UpdateUserDto, Task> OnSubmit { get; set; }
[Parameter] public UserDto User { get; set; }
private UpdateUserDto Request;
private FormHandler FormHandler;
protected override void OnInitialized()
{
Request = UserMapper.ToUpdate(User);
}
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
private async Task SubmitAsync()
{
var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/users/{User.Id}",
Request
);
await OnSubmit.Invoke(Request);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"User update",
$"Successfully updated user {Request.Username}"
);
await OnCompleted.Invoke();
await CloseAsync();
return true;
}
}

View File

@@ -1,75 +0,0 @@
@using LucideBlazor
@using Moonlight.Frontend.Helpers
@using Moonlight.Frontend.Services
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests.Admin.Settings
@using Moonlight.Shared.Http.Responses.Admin.Settings
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@inject HttpClient HttpClient
@inject ToastService ToastService
@inject FrontendService FrontendService
<LazyLoader Load="LoadAsync">
<EnhancedEditForm Model="Request" OnValidSubmit="OnValidSubmit">
<DataAnnotationsValidator />
<FieldSet>
<FormValidationSummary />
<FieldGroup>
<Field>
<FieldLabel>Name</FieldLabel>
<TextInputField @bind-Value="Request.Name"/>
</Field>
</FieldGroup>
</FieldSet>
<SubmitButton ClassName="mt-3">
<SaveIcon/>
Save
</SubmitButton>
</EnhancedEditForm>
</LazyLoader>
@code
{
private SetWhiteLabelingDto Request;
private async Task LoadAsync(LazyLoader _)
{
var dto = await HttpClient.GetFromJsonAsync<WhiteLabelingDto>(
"api/admin/system/settings/whiteLabeling",
SerializationContext.Default.Options
);
Request = new SetWhiteLabelingDto()
{
Name = dto!.Name
};
}
private async Task<bool> OnValidSubmit(EditContext editContext, ValidationMessageStore validationMessageStore)
{
var response = await HttpClient.PostAsJsonAsync(
"api/admin/system/settings/whiteLabeling",
Request,
SerializationContext.Default.Options
);
if (response.IsSuccessStatusCode)
{
await FrontendService.ReloadAsync();
await ToastService.SuccessAsync("Setting", "Successfully updated white labeling settings");
return true;
}
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
}

View File

@@ -1,7 +1,6 @@
@page "/admin"
@using LucideBlazor
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards
@@ -134,11 +133,7 @@
{
<CardTitle ClassName="text-lg text-primary">Update available</CardTitle>
<CardAction ClassName="self-center">
<Button>
<Slot>
<a href="/admin/system?tab=instance" @attributes="context">Update</a>
</Slot>
</Button>
<Button @onclick="LaunchUpdateModalAsync">Update</Button>
</CardAction>
}
</CardHeader>
@@ -156,9 +151,14 @@
if(!firstRender)
return;
InfoResponse = await HttpClient.GetFromJsonAsync<SystemInfoDto>("api/admin/system/info", SerializationContext.Default.Options);
InfoResponse = await HttpClient.GetFromJsonAsync<SystemInfoDto>("api/admin/system/info", Constants.SerializerOptions);
IsInfoLoading = false;
await InvokeAsync(StateHasChanged);
}
private async Task LaunchUpdateModalAsync() => await DialogService.LaunchAsync<UpdateInstanceModal>(onConfigure: model =>
{
model.ShowCloseButton = false;
});
}

View File

@@ -1,12 +1,12 @@
@using LucideBlazor
@using Moonlight.Shared.Http.Requests.ApiKeys
@using Moonlight.Shared.Http.Responses.ApiKeys
@using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using Moonlight.Shared.Http
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin.ApiKeys
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Extras.AlertDialogs
@@ -49,21 +49,7 @@
</CellTemplate>
</TemplateColumn>
<PropertyColumn IsFilterable="true"
Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description" HeadClassName="hidden lg:table-cell" CellClassName="hidden lg:table-cell" />
<TemplateColumn Identifier="@nameof(ApiKeyDto.ValidUntil)" Title="Valid until" HeadClassName="hidden lg:table-cell">
<CellTemplate>
<TableCell ClassName="hidden lg:table-cell">
@{
var diff = context.ValidUntil - DateTimeOffset.UtcNow;
var text = string.Format(diff.TotalSeconds < 0 ? "Expired since {0}" : "Expires in {0}", Formatter.FormatDuration(diff));
}
<span>
@text
</span>
</TableCell>
</CellTemplate>
</TemplateColumn>
Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description"/>
<TemplateColumn>
<CellTemplate>
<TableCell>
@@ -127,7 +113,7 @@
var response = await HttpClient.GetFromJsonAsync<PagedData<ApiKeyDto>>(
$"api/admin/apiKeys{query}&filterOptions={filterOptions}",
SerializationContext.Default.Options
Constants.SerializerOptions
);
return new DataGridResponse<ApiKeyDto>(response!.Data, response.TotalLength);
@@ -137,8 +123,19 @@
{
await DialogService.LaunchAsync<CreateApiKeyDialog>(parameters =>
{
parameters[nameof(CreateApiKeyDialog.OnSubmit)] = async () =>
parameters[nameof(CreateApiKeyDialog.OnSubmit)] = async (CreateApiKeyDto dto) =>
{
await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys",
dto,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"API Key creation",
$"Successfully created API key {dto.Name}"
);
await Grid.RefreshAsync();
};
});
@@ -149,8 +146,19 @@
await DialogService.LaunchAsync<UpdateApiKeyDialog>(parameters =>
{
parameters[nameof(UpdateApiKeyDialog.Key)] = key;
parameters[nameof(UpdateApiKeyDialog.OnSubmit)] = async () =>
parameters[nameof(UpdateApiKeyDialog.OnSubmit)] = async (UpdateApiKeyDto dto) =>
{
await HttpClient.PatchAsJsonAsync(
$"/api/admin/apiKeys/{key.Id}",
dto,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"API Key update",
$"Successfully updated API key {dto.Name}"
);
await Grid.RefreshAsync();
};
});

View File

@@ -44,10 +44,10 @@
</Alert>
</CardContent>
<CardFooter ClassName="justify-end">
<WButton OnClick="DiagnoseAsync" disabled="@(!AccessResult.Succeeded)">
<WButtom OnClick="DiagnoseAsync" disabled="@(!AccessResult.Succeeded)">
<StethoscopeIcon/>
Start diagnostics
</WButton>
</WButtom>
</CardFooter>
</Card>
</div>

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