Separates website and admin roots, adds /health, hardens the availability gate
Prepares the single-host layout for deployment. The customer's public website moves from wwwroot/ to wwwroot/web/, so a CMS deploy can no longer overwrite content it does not own: with the website in its own directory, the release directory can be swapped without touching it. Each front-end gets its own file provider, and both tolerate a missing directory at startup — a fresh deployment has no website until a separate workspace deploys one, and the CMS must still serve /admin and the API. When the website's index.html is absent, an embedded placeholder is served instead of a 404, which also doubles as proof the CMS itself is running. The placeholder is embedded in the assembly rather than shipped into wwwroot/web/, because that directory is owned and overwritten by the website workspace. Adds GET /health for uptime monitoring. It reports infrastructure liveness only and is deliberately NOT the same thing as /api/v1/Availability/status or /api/v1/System/capabilities: those are CMS domain state that also serve the master/slave protocol. A healthy instance can be switched off by design, and a switched-on instance can be unhealthy, so conflating them would alert on business state and stay silent on real outages. /health is on the availability gate's bypass list for the same reason. Fixes a real defect in the gate's admin bypass. It parsed the bearer token with ReadJwtToken, which reads claims without verifying the signature, so an unauthenticated caller could forge an unsigned token carrying an Owner role claim and bypass the gate that suspends a customer's site. Protected endpoints still rejected them, so nothing leaked — but the gate itself was bypassable. The token is now fully validated against the same parameters as the bearer scheme, resolved from one shared source so the two cannot drift apart. Host wiring for these changes lands with the data-durability commit, since both units touch the same lines of Program.cs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SlpModularCms.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Serves the two independent front-ends this host carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared hosting typically allows only one site or application pool, so this single process
|
||||
/// serves everything:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>/</c> — the customer's public website, from <c>wwwroot/web/</c>. Built and
|
||||
/// deployed separately; it is NOT part of this repository and must survive every CMS deploy.</description></item>
|
||||
/// <item><description><c>/admin</c> — the CMS admin SPA, from <c>wwwroot/admin/</c>, produced by
|
||||
/// <c>dotnet publish</c>.</description></item>
|
||||
/// </list>
|
||||
/// Each mount gets its own file provider so neither can ever serve files belonging to the other,
|
||||
/// and so each can later carry its own headers or caching without disturbing the other.
|
||||
/// </remarks>
|
||||
public static class StaticContentExtensions
|
||||
{
|
||||
/// <summary>Directory under the web root holding the customer's public website.</summary>
|
||||
public const string WebsiteDirectoryName = "web";
|
||||
|
||||
/// <summary>Directory under the web root holding the admin SPA.</summary>
|
||||
public const string AdminDirectoryName = "admin";
|
||||
|
||||
/// <summary>Request path the admin SPA is mounted at.</summary>
|
||||
public const string AdminRequestPath = "/admin";
|
||||
|
||||
private const string PlaceholderResourceName = "SlpModularCms.Api.Extensions.WebsitePlaceholder.html";
|
||||
|
||||
/// <summary>
|
||||
/// Registers both static mounts and the <c>/admin</c> trailing-slash redirect.
|
||||
/// Must be called BEFORE any middleware that needs to observe static responses, because
|
||||
/// static files short-circuit the pipeline.
|
||||
/// </summary>
|
||||
public static WebApplication UseCmsStaticContent(this WebApplication app)
|
||||
{
|
||||
var webRoot = app.Environment.WebRootPath
|
||||
?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||
|
||||
var adminRoot = Path.Combine(webRoot, AdminDirectoryName);
|
||||
var websiteRoot = Path.Combine(webRoot, WebsiteDirectoryName);
|
||||
|
||||
var logger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(typeof(StaticContentExtensions).FullName!);
|
||||
|
||||
WarnIfMissing(logger, adminRoot, "admin SPA");
|
||||
WarnIfMissing(logger, websiteRoot, "public website");
|
||||
|
||||
// A request for exactly "/admin" must become "/admin/", or the SPA — built with
|
||||
// base '/admin/' — resolves its relative asset references one level too high and
|
||||
// fails in a way that looks like a broken deployment.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path.Equals(AdminRequestPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var target = $"{AdminRequestPath}/{context.Request.QueryString}";
|
||||
context.Response.Redirect(target, permanent: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// The admin mount is registered FIRST. Registered the other way around, a request for
|
||||
// /admin/... would be resolved against the website root.
|
||||
RegisterMount(app, adminRoot, AdminRequestPath);
|
||||
RegisterMount(app, websiteRoot, requestPath: string.Empty);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the SPA fallbacks for both mounts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>nonfile</c> constraint on both routes is deliberate: a request for a path that
|
||||
/// looks like a file (has an extension) and does not exist must stay a 404. Serving HTML
|
||||
/// for a missing script would turn a clear "asset is missing" into a confusing parse error.
|
||||
/// </remarks>
|
||||
public static WebApplication MapCmsSpaFallbacks(this WebApplication app)
|
||||
{
|
||||
var webRoot = app.Environment.WebRootPath
|
||||
?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||
|
||||
var adminIndex = Path.Combine(webRoot, AdminDirectoryName, "index.html");
|
||||
var websiteIndex = Path.Combine(webRoot, WebsiteDirectoryName, "index.html");
|
||||
|
||||
app.MapFallback($"{AdminRequestPath}/{{*path:nonfile}}", async context =>
|
||||
{
|
||||
if (File.Exists(adminIndex))
|
||||
{
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(adminIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
});
|
||||
|
||||
app.MapFallback("{*path:nonfile}", async context =>
|
||||
{
|
||||
if (File.Exists(websiteIndex))
|
||||
{
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(websiteIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// No website deployed yet. Serving the placeholder rather than a 404 makes a fresh
|
||||
// installation self-explanatory and doubles as proof the CMS itself is running.
|
||||
await WritePlaceholderAsync(context);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void RegisterMount(WebApplication app, string physicalRoot, string requestPath)
|
||||
{
|
||||
// Tolerating a missing directory is required, not defensive: a fresh deployment has no
|
||||
// wwwroot/web/ until a website workspace deploys into it, and the CMS must still start
|
||||
// and serve /admin and /api/v1.
|
||||
if (!Directory.Exists(physicalRoot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = new PhysicalFileProvider(physicalRoot);
|
||||
|
||||
app.UseDefaultFiles(new DefaultFilesOptions
|
||||
{
|
||||
FileProvider = provider,
|
||||
RequestPath = requestPath
|
||||
});
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = provider,
|
||||
RequestPath = requestPath
|
||||
// Directory browsing is not enabled — a request for a directory resolves to its
|
||||
// default file or falls through to the fallback rules, never to a file listing.
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task WritePlaceholderAsync(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
|
||||
// Embedded in the assembly rather than placed in wwwroot/web/, because that directory is
|
||||
// owned and overwritten by a website workspace: a file there would be deleted by the first
|
||||
// real website deployment, or mistaken for part of the customer's site.
|
||||
await using var stream = typeof(StaticContentExtensions).Assembly
|
||||
.GetManifestResourceStream(PlaceholderResourceName);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
await context.Response.WriteAsync("<!doctype html><title>Nog geen website geplaatst</title>" +
|
||||
"<p>Er staat hier nog geen website. Beheer via <a href=\"/admin/\">/admin/</a>.</p>");
|
||||
return;
|
||||
}
|
||||
|
||||
await stream.CopyToAsync(context.Response.Body);
|
||||
}
|
||||
|
||||
private static void WarnIfMissing(ILogger logger, string path, string description)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal on a fresh installation, but on a running production instance it means the
|
||||
// content has disappeared — worth being visible in Sentry without blocking startup.
|
||||
logger.LogWarning(
|
||||
"Static content directory for the {Description} was not found at {Path}. " +
|
||||
"The application will start, but this path will not serve any files until content is deployed there.",
|
||||
description,
|
||||
path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Nog geen website geplaatst</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
line-height: 1.6;
|
||||
background: #fafafa;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #141414; color: #ededed; }
|
||||
code { background: #262626; }
|
||||
a { color: #ff6b6b; }
|
||||
}
|
||||
main { max-width: 34rem; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
|
||||
p { margin: 0 0 1rem; }
|
||||
code {
|
||||
background: #ececec;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
a { color: #ac0000; }
|
||||
.muted { font-size: 0.9rem; opacity: 0.75; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Er staat hier nog geen website</h1>
|
||||
<p>
|
||||
Het CMS draait, maar er is nog geen publieke website geplaatst. De website
|
||||
wordt apart aangeleverd en hoort in de map <code>wwwroot/web/</code> te staan,
|
||||
met een <code>index.html</code> in de hoofdmap daarvan.
|
||||
</p>
|
||||
<p>
|
||||
Beheerders kunnen inloggen via <a href="/admin/">/admin/</a>.
|
||||
</p>
|
||||
<p class="muted">
|
||||
Deze pagina wordt automatisch vervangen zodra de website is geplaatst.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,6 +17,16 @@
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.16.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Served at '/' when no public website has been deployed into wwwroot/web/ yet.
|
||||
Embedded rather than shipped as a file under wwwroot/web/, because that directory is
|
||||
owned and overwritten by a separate website workspace — a file there would be deleted
|
||||
by the first real website deployment, or mistaken for part of the customer's site.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Extensions\WebsitePlaceholder.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
<ProjectReference Include="..\SlpModularCms.Modules.Availability\SlpModularCms.Modules.Availability.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user