Implemented frontend hosting file generation helper

This commit is contained in:
2025-03-16 22:03:01 +01:00
parent 1238095f09
commit 75f037da02
6 changed files with 296 additions and 60 deletions

View File

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

View File

@@ -11,71 +11,18 @@ namespace Moonlight.ApiServer.Http.Controllers;
[ApiController] [ApiController]
public class FrontendController : Controller public class FrontendController : Controller
{ {
private readonly AppConfiguration Configuration; private readonly FrontendService FrontendService;
private readonly PluginService PluginService; private readonly PluginService PluginService;
public FrontendController( public FrontendController(FrontendService frontendService, PluginService pluginService)
AppConfiguration configuration,
PluginService pluginService
)
{ {
Configuration = configuration; FrontendService = frontendService;
PluginService = pluginService; PluginService = pluginService;
} }
[HttpGet("frontend.json")] [HttpGet("frontend.json")]
public async Task<FrontendConfiguration> GetConfiguration() public async Task<FrontendConfiguration> GetConfiguration()
{ => await FrontendService.GetConfiguration();
var configuration = new FrontendConfiguration()
{
Title = "Moonlight",
ApiUrl = Configuration.PublicUrl,
HostEnvironment = "ApiServer"
};
#region Load theme.json if it exists
var themePath = PathBuilder.File("storage", "theme.json");
if (System.IO.File.Exists(themePath))
{
var variablesJson = await System.IO.File.ReadAllTextAsync(themePath);
configuration.Theme.Variables =
JsonSerializer.Deserialize<Dictionary<string, string>>(variablesJson) ?? new();
}
#endregion
// Collect assemblies for the 'client' section
configuration.Assemblies = PluginService
.GetAssemblies("client")
.Keys
.ToArray();
// Collect scripts to execute
configuration.Scripts = PluginService
.LoadedPlugins
.Keys
.SelectMany(x => x.Scripts)
.ToArray();
// Collect styles
var styles = new List<string>();
styles.AddRange(
PluginService
.LoadedPlugins
.Keys
.SelectMany(x => x.Styles)
);
// Add bundle css
styles.Add("css/bundle.min.css");
configuration.Styles = styles.ToArray();
return configuration;
}
[HttpGet("plugins/{assemblyName}")] [HttpGet("plugins/{assemblyName}")]
public async Task GetPluginAssembly(string assemblyName) public async Task GetPluginAssembly(string assemblyName)

View File

@@ -0,0 +1,195 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.FileProviders;
using MoonCore.Attributes;
using MoonCore.Exceptions;
using MoonCore.Helpers;
using Moonlight.ApiServer.Configuration;
using Moonlight.Shared.Misc;
namespace Moonlight.ApiServer.Services;
[Scoped]
public class FrontendService
{
private readonly AppConfiguration Configuration;
private readonly PluginService PluginService;
private readonly IWebHostEnvironment WebHostEnvironment;
public FrontendService(
AppConfiguration configuration,
PluginService pluginService,
IWebHostEnvironment webHostEnvironment
)
{
Configuration = configuration;
PluginService = pluginService;
WebHostEnvironment = webHostEnvironment;
}
public async Task<FrontendConfiguration> GetConfiguration()
{
var configuration = new FrontendConfiguration()
{
Title = "Moonlight",
ApiUrl = Configuration.PublicUrl,
HostEnvironment = "ApiServer"
};
// Load theme.json if it exists
var themePath = Path.Combine("storage", "theme.json");
if (File.Exists(themePath))
{
var variablesJson = await File.ReadAllTextAsync(themePath);
configuration.Theme.Variables = JsonSerializer
.Deserialize<Dictionary<string, string>>(variablesJson) ?? new();
}
// Collect assemblies for the 'client' section
configuration.Assemblies = PluginService
.GetAssemblies("client")
.Keys
.ToArray();
// Collect scripts to execute
configuration.Scripts = PluginService
.LoadedPlugins
.Keys
.SelectMany(x => x.Scripts)
.ToArray();
// Collect styles
var styles = new List<string>();
styles.AddRange(
PluginService
.LoadedPlugins
.Keys
.SelectMany(x => x.Styles)
);
// Add bundle css
styles.Add("css/bundle.min.css");
configuration.Styles = styles.ToArray();
return configuration;
}
public async Task<Stream> GenerateZip()
{
// We only allow the access to this function when we are actually hosting the frontend
if (!Configuration.Client.Enable)
throw new HttpApiException("The hosting of the wasm client has been disabled", 400);
// Load and check wasm path
var wasmMainFile = WebHostEnvironment.WebRootFileProvider.GetFileInfo("index.html");
if (wasmMainFile is NotFoundFileInfo || string.IsNullOrEmpty(wasmMainFile.PhysicalPath))
throw new HttpApiException("Unable to find wasm location", 500);
var wasmPath = Path.GetDirectoryName(wasmMainFile.PhysicalPath)!;
// Load and check the blazor framework files
var blazorFile = WebHostEnvironment.WebRootFileProvider.GetFileInfo("_framework/blazor.webassembly.js");
if (blazorFile is NotFoundFileInfo || string.IsNullOrEmpty(blazorFile.PhysicalPath))
throw new HttpApiException("Unable to find blazor location", 500);
var blazorPath = Path.GetDirectoryName(blazorFile.PhysicalPath)!;
// Create zip
var memoryStream = new MemoryStream();
var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true);
// Add wasm application
await ArchiveFsItem(zipArchive, wasmPath, wasmPath);
// Add blazor files
await ArchiveFsItem(zipArchive, blazorPath, blazorPath, "_framework/");
// Add bundle.css
var bundleContent = await File.ReadAllBytesAsync(Path.Combine("storage", "tmp", "bundle.css"));
await ArchiveBytes(zipArchive, "css/bundle.css", bundleContent);
// Add frontend.json
var frontendConfig = await GetConfiguration();
frontendConfig.HostEnvironment = "Static";
var frontendJson = JsonSerializer.Serialize(frontendConfig);
await ArchiveText(zipArchive, "frontend.json", frontendJson);
// Add plugin wwwroot files
foreach (var pluginPath in PluginService.LoadedPlugins.Values)
{
var wwwRootPluginPath = Path.Combine(pluginPath, "wwwroot");
if (!Directory.Exists(wwwRootPluginPath))
continue;
await ArchiveFsItem(zipArchive, wwwRootPluginPath, wwwRootPluginPath);
}
// Add plugin assemblies (TODO: Test this thing)
var assembliesMap = PluginService.GetAssemblies("client");
foreach (var assemblyName in assembliesMap.Keys)
{
var path = assembliesMap[assemblyName];
await ArchiveFsItem(
zipArchive,
path,
path,
$"plugins/{assemblyName}"
);
}
// Finish zip archive and reset stream so the code calling this function can process it
zipArchive.Dispose();
await memoryStream.FlushAsync();
memoryStream.Position = 0;
return memoryStream;
}
private async Task ArchiveFsItem(ZipArchive archive, string path, string prefixToRemove, string prefixToAdd = "")
{
if (File.Exists(path))
{
var entryName = prefixToAdd + Formatter.ReplaceStart(path, prefixToRemove, "");
var entry = archive.CreateEntry(entryName);
await using var entryStream = entry.Open();
await using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await fileStream.CopyToAsync(entryStream);
await entryStream.FlushAsync();
entryStream.Close();
}
else
{
foreach (var directoryItem in Directory.EnumerateFileSystemEntries(path))
await ArchiveFsItem(archive, directoryItem, prefixToRemove, prefixToAdd);
}
}
private async Task ArchiveText(ZipArchive archive, string path, string content)
{
var data = Encoding.UTF8.GetBytes(content);
await ArchiveBytes(archive, path, data);
}
private async Task ArchiveBytes(ZipArchive archive, string path, byte[] bytes)
{
var entry = archive.CreateEntry(path);
await using var dataStream = entry.Open();
await dataStream.WriteAsync(bytes);
await dataStream.FlushAsync();
}
}

View File

@@ -80,11 +80,13 @@ public class Startup
await RegisterAuth(); await RegisterAuth();
await HookPluginBuild(); await HookPluginBuild();
await RegisterPluginAssets(); await RegisterPluginAssets();
await RegisterCors();
await BuildWebApplication(); await BuildWebApplication();
await PrepareDatabase(); await PrepareDatabase();
await UseCors();
await UsePluginAssets(); // We need to move the plugin assets to the top to allow plugins to override content await UsePluginAssets(); // We need to move the plugin assets to the top to allow plugins to override content
await UseBase(); await UseBase();
await UseAuth(); await UseAuth();
@@ -593,4 +595,28 @@ public class Startup
} }
#endregion #endregion
#region Cors
private Task RegisterCors()
{
WebApplicationBuilder.Services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
{
builder.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin().Build();
});
});
return Task.CompletedTask;
}
private Task UseCors()
{
WebApplication.UseCors();
return Task.CompletedTask;
}
#endregion
} }

View File

@@ -0,0 +1,41 @@
@page "/admin/system/advanced"
@using MoonCore.Attributes
@using MoonCore.Helpers
@attribute [RequirePermission("admin.system.advanced")]
@inject HttpApiClient ApiClient
@inject DownloadService DownloadService
<div class="mb-3">
<NavTabs Index="3" Names="UiConstants.AdminNavNames" Links="UiConstants.AdminNavLinks" />
</div>
<div class="grid grid-cols-2">
<div class="col-span-2 md:col-span-1 card">
<div class="card-header">
<span class="card-title">Frontend hosting files</span>
</div>
<div class="card-body">
<p>
If you want to host your moonlight frontend on a static web server instead of it being hosted by the api server
(useful for hosting on a cdn for high availability) you can use this helper. By pressing the button below moonlight
will generate a zip file containing everything you need to host the frontend. Next to the WASM app itself it automatically
includes your installed theme and plugins. For more information, have a look at <a class="text-primary-500" href="https://help.moonlightpanel.xyz">our docs</a>
</p>
<WButton OnClick="GenerateFrontend" CssClasses="btn btn-primary mt-5">Generate frontend.zip</WButton>
</div>
</div>
</div>
@code
{
private async Task GenerateFrontend(WButton _)
{
var stream = await ApiClient.GetStream("api/admin/system/advanced/frontend");
await DownloadService.DownloadStream("frontend.zip", stream);
}
}

View File

@@ -2,6 +2,6 @@ namespace Moonlight.Client;
public static class UiConstants public static class UiConstants
{ {
public static readonly string[] AdminNavNames = ["Overview", "Theme", "Files"]; public static readonly string[] AdminNavNames = ["Overview", "Theme", "Files", "Advanced"];
public static readonly string[] AdminNavLinks = ["/admin/system", "/admin/system/theme", "/admin/system/files"]; public static readonly string[] AdminNavLinks = ["/admin/system", "/admin/system/theme", "/admin/system/files", "/admin/system/advanced"];
} }