Stabilzie modules

This commit is contained in:
master
2026-02-16 07:32:38 +02:00
parent ab794e167c
commit 45c0f1bb59
45 changed files with 3055 additions and 156 deletions

View File

@@ -20,6 +20,10 @@ public sealed class RouteDispatchMiddleware
"TE", "Trailers", "Transfer-Encoding", "Upgrade"
};
// ReverseProxy paths that are legitimate browser navigation targets (e.g. OIDC flows)
// and must NOT be redirected to the SPA fallback.
private static readonly string[] BrowserProxyPaths = ["/connect", "/.well-known"];
public RouteDispatchMiddleware(
RequestDelegate next,
StellaOpsRouteResolver resolver,
@@ -48,6 +52,22 @@ public sealed class RouteDispatchMiddleware
return;
}
// SPA fallback: when a ReverseProxy route is matched but the request is a
// browser navigation (Accept: text/html, no file extension), serve the SPA
// index.html instead of proxying to the backend. This prevents collisions
// between Angular SPA routes and backend service proxy prefixes.
// Excludes known backend browser-navigation paths (e.g. OIDC /connect).
if (route.Type == StellaOpsRouteType.ReverseProxy && IsBrowserNavigation(context.Request))
{
var spaRoute = _resolver.FindSpaFallbackRoute();
if (spaRoute is not null)
{
_logger.LogDebug("SPA fallback: serving index.html for browser navigation to {Path}", context.Request.Path);
await HandleStaticFiles(context, spaRoute);
return;
}
}
switch (route.Type)
{
case StellaOpsRouteType.StaticFiles:
@@ -221,7 +241,8 @@ public sealed class RouteDispatchMiddleware
{
context.Response.StatusCode = (int)upstreamResponse.StatusCode;
// Copy response headers
// Copy response headers (excluding hop-by-hop and content-length which
// we'll set ourselves after reading the body to ensure accuracy)
foreach (var header in upstreamResponse.Headers)
{
if (!HopByHopHeaders.Contains(header.Key))
@@ -232,12 +253,22 @@ public sealed class RouteDispatchMiddleware
foreach (var header in upstreamResponse.Content.Headers)
{
context.Response.Headers[header.Key] = header.Value.ToArray();
if (!string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
{
context.Response.Headers[header.Key] = header.Value.ToArray();
}
}
// Stream response body
await using var responseStream = await upstreamResponse.Content.ReadAsStreamAsync(context.RequestAborted);
await responseStream.CopyToAsync(context.Response.Body, context.RequestAborted);
// Read the full response body so we can set an accurate Content-Length.
// This is necessary because the upstream may use chunked transfer encoding
// (which we strip as a hop-by-hop header), and without Content-Length or
// Transfer-Encoding the downstream client cannot determine body length.
var body = await upstreamResponse.Content.ReadAsByteArrayAsync(context.RequestAborted);
if (body.Length > 0)
{
context.Response.ContentLength = body.Length;
await context.Response.Body.WriteAsync(body, context.RequestAborted);
}
}
}
@@ -343,4 +374,28 @@ public sealed class RouteDispatchMiddleware
await using var stream = fileInfo.CreateReadStream();
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
/// <summary>
/// Determines if the request is a browser page navigation (as opposed to an XHR/fetch API call).
/// Browser navigations send Accept: text/html and target paths without file extensions.
/// Known backend browser-navigation paths (OIDC endpoints) are excluded.
/// </summary>
private static bool IsBrowserNavigation(HttpRequest request)
{
var path = request.Path.Value ?? string.Empty;
// Paths with file extensions are static asset requests, not SPA navigation
if (System.IO.Path.HasExtension(path))
return false;
// Exclude known backend paths that legitimately receive browser navigations
foreach (var excluded in BrowserProxyPaths)
{
if (path.StartsWith(excluded, StringComparison.OrdinalIgnoreCase))
return false;
}
var accept = request.Headers.Accept.ToString();
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
}
}