Implemented frontend hosting file generation helper
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<FrontendConfiguration> 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;
|
||||
}
|
||||
=> await FrontendService.GetConfiguration();
|
||||
|
||||
[HttpGet("plugins/{assemblyName}")]
|
||||
public async Task GetPluginAssembly(string assemblyName)
|
||||
|
||||
195
Moonlight.ApiServer/Services/FrontendService.cs
Normal file
195
Moonlight.ApiServer/Services/FrontendService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
41
Moonlight.Client/UI/Views/Admin/Sys/Advanced.razor
Normal file
41
Moonlight.Client/UI/Views/Admin/Sys/Advanced.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
Reference in New Issue
Block a user