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]
|
[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)
|
||||||
|
|||||||
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 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
|
||||||
}
|
}
|
||||||
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 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"];
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user