diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/Sys/AdvancedController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/AdvancedController.cs new file mode 100644 index 00000000..90004044 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Admin/Sys/AdvancedController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Extended.PermFilter; +using Moonlight.ApiServer.Services; + +namespace Moonlight.ApiServer.Http.Controllers.Admin.Sys; + +[Authorize] +[ApiController] +[Route("api/admin/system/advanced")] +public class AdvancedController : Controller +{ + private readonly FrontendService FrontendService; + + public AdvancedController(FrontendService frontendService) + { + FrontendService = frontendService; + } + + [HttpGet("frontend")] + [RequirePermission("admin.system.advanced.frontend")] + public async Task Frontend() + { + var stream = await FrontendService.GenerateZip(); + await Results.File(stream, fileDownloadName: "frontend.zip").ExecuteAsync(HttpContext); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/FrontendController.cs b/Moonlight.ApiServer/Http/Controllers/FrontendController.cs index 58ee891e..75ba881d 100644 --- a/Moonlight.ApiServer/Http/Controllers/FrontendController.cs +++ b/Moonlight.ApiServer/Http/Controllers/FrontendController.cs @@ -11,71 +11,18 @@ namespace Moonlight.ApiServer.Http.Controllers; [ApiController] public class FrontendController : Controller { - private readonly AppConfiguration Configuration; + private readonly FrontendService FrontendService; private readonly PluginService PluginService; - public FrontendController( - AppConfiguration configuration, - PluginService pluginService - ) + public FrontendController(FrontendService frontendService, PluginService pluginService) { - Configuration = configuration; + FrontendService = frontendService; PluginService = pluginService; } [HttpGet("frontend.json")] public async Task 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>(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(); - - styles.AddRange( - PluginService - .LoadedPlugins - .Keys - .SelectMany(x => x.Styles) - ); - - // Add bundle css - styles.Add("css/bundle.min.css"); - - configuration.Styles = styles.ToArray(); - - return configuration; - } + => await FrontendService.GetConfiguration(); [HttpGet("plugins/{assemblyName}")] public async Task GetPluginAssembly(string assemblyName) diff --git a/Moonlight.ApiServer/Services/FrontendService.cs b/Moonlight.ApiServer/Services/FrontendService.cs new file mode 100644 index 00000000..e3a00647 --- /dev/null +++ b/Moonlight.ApiServer/Services/FrontendService.cs @@ -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 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>(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(); + + 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 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(); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Startup.cs b/Moonlight.ApiServer/Startup.cs index 2fd3096e..c9bf05d2 100644 --- a/Moonlight.ApiServer/Startup.cs +++ b/Moonlight.ApiServer/Startup.cs @@ -80,11 +80,13 @@ public class Startup await RegisterAuth(); await HookPluginBuild(); await RegisterPluginAssets(); + await RegisterCors(); await BuildWebApplication(); await PrepareDatabase(); - + + await UseCors(); await UsePluginAssets(); // We need to move the plugin assets to the top to allow plugins to override content await UseBase(); await UseAuth(); @@ -593,4 +595,28 @@ public class Startup } #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 } \ No newline at end of file diff --git a/Moonlight.Client/UI/Views/Admin/Sys/Advanced.razor b/Moonlight.Client/UI/Views/Admin/Sys/Advanced.razor new file mode 100644 index 00000000..3eee8d5e --- /dev/null +++ b/Moonlight.Client/UI/Views/Admin/Sys/Advanced.razor @@ -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 + +
+ +
+ +
+
+
+ Frontend hosting files +
+
+

+ 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 our docs +

+ + Generate frontend.zip +
+
+
+ +@code +{ + private async Task GenerateFrontend(WButton _) + { + var stream = await ApiClient.GetStream("api/admin/system/advanced/frontend"); + + await DownloadService.DownloadStream("frontend.zip", stream); + } +} diff --git a/Moonlight.Client/UiConstants.cs b/Moonlight.Client/UiConstants.cs index 118724cb..4e6a12be 100644 --- a/Moonlight.Client/UiConstants.cs +++ b/Moonlight.Client/UiConstants.cs @@ -2,6 +2,6 @@ namespace Moonlight.Client; public static class UiConstants { - public static readonly string[] AdminNavNames = ["Overview", "Theme", "Files"]; - public static readonly string[] AdminNavLinks = ["/admin/system", "/admin/system/theme", "/admin/system/files"]; + public static readonly string[] AdminNavNames = ["Overview", "Theme", "Files", "Advanced"]; + public static readonly string[] AdminNavLinks = ["/admin/system", "/admin/system/theme", "/admin/system/files", "/admin/system/advanced"]; } \ No newline at end of file