diff --git a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs index f415b336..bed1ac56 100644 --- a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs @@ -80,6 +80,29 @@ internal static void UseDashboardWithEndpoints(this IA // Validate and normalize base path var basePath = NormalizeBasePath(config.BasePath); + // Extract inline preload script from embedded index.html at startup. + // Serving it as an external file allows CSP script-src 'self' without 'unsafe-inline'. + string preloadScript = null; + string htmlTemplate = null; + var indexFile = embeddedFileProvider.GetFileInfo("index.html"); + if (indexFile.Exists) + { + using var stream = indexFile.CreateReadStream(); + using var reader = new StreamReader(stream); + var rawHtml = reader.ReadToEnd(); + + var scriptMatch = Regex.Match(rawHtml, @""); + if (scriptMatch.Success) + { + preloadScript = scriptMatch.Groups[1].Value; + htmlTemplate = rawHtml.Remove(scriptMatch.Index, scriptMatch.Length); + } + else + { + htmlTemplate = rawHtml; + } + } + // Map a branch for the basePath to properly isolate dashboard app.Map(basePath, dashboardApp => { @@ -94,7 +117,7 @@ internal static void UseDashboardWithEndpoints(this IA OnPrepareResponse = ctx => { // Cache static assets for 1 hour - if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css") || + if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css") || ctx.File.Name.EndsWith(".ico") || ctx.File.Name.EndsWith(".png")) { ctx.Context.Response.Headers.CacheControl = "public,max-age=3600"; @@ -102,6 +125,32 @@ internal static void UseDashboardWithEndpoints(this IA } }); + // Serve dashboard config and preload scripts as external files (before auth). + // This eliminates inline scripts so the dashboard works with CSP script-src 'self'. + dashboardApp.Use(async (context, next) => + { + var path = context.Request.Path.Value; + + if (string.Equals(path, "/__tickerq-config.js", StringComparison.OrdinalIgnoreCase)) + { + var configJs = GenerateConfigJs(context, basePath, config); + context.Response.ContentType = "application/javascript; charset=utf-8"; + context.Response.Headers.CacheControl = "no-cache"; + await context.Response.WriteAsync(configJs); + return; + } + + if (string.Equals(path, "/__tickerq-preload.js", StringComparison.OrdinalIgnoreCase) && preloadScript != null) + { + context.Response.ContentType = "application/javascript; charset=utf-8"; + context.Response.Headers.CacheControl = "public,max-age=3600"; + await context.Response.WriteAsync(preloadScript); + return; + } + + await next(); + }); + // Set up routing and CORS dashboardApp.UseRouting(); dashboardApp.UseCors("TickerQ_Dashboard_CORS"); @@ -129,22 +178,13 @@ internal static void UseDashboardWithEndpoints(this IA { await next(); - if (context.Response.StatusCode == 404) + if (context.Response.StatusCode == 404 && htmlTemplate != null) { - var file = embeddedFileProvider.GetFileInfo("index.html"); - if (file.Exists) - { - await using var stream = file.CreateReadStream(); - using var reader = new StreamReader(stream); - var htmlContent = await reader.ReadToEndAsync(); + var htmlContent = InjectExternalScripts(htmlTemplate, context, basePath); - // Inject the base tag and other replacements into the HTML - htmlContent = ReplaceBasePath(htmlContent, context, basePath, config); - - context.Response.ContentType = "text/html"; - context.Response.StatusCode = 200; - await context.Response.WriteAsync(htmlContent); - } + context.Response.ContentType = "text/html"; + context.Response.StatusCode = 200; + await context.Response.WriteAsync(htmlContent); } }); }); @@ -161,20 +201,18 @@ private static string NormalizeBasePath(string basePath) return basePath.TrimEnd('/'); } - private static string ReplaceBasePath(string htmlContent, HttpContext httpContext, string basePath, DashboardOptionsBuilder config) + /// + /// Generates the runtime config JavaScript served as an external file. + /// Sets window.TickerQConfig and window.__dynamic_base__ for Vite dynamic base. + /// + private static string GenerateConfigJs(HttpContext httpContext, string basePath, DashboardOptionsBuilder config) { - if (string.IsNullOrEmpty(htmlContent)) - return htmlContent ?? string.Empty; - - // Compute the frontend base path as PathBase + backend basePath. - // This ensures correct URLs when the host app uses UsePathBase. var pathBase = httpContext.Request.PathBase.HasValue ? httpContext.Request.PathBase.Value : string.Empty; var frontendBasePath = CombinePathBase(pathBase, basePath); - // Build the config object var envConfig = new FrontendConfigResponse { BasePath = frontendBasePath, @@ -187,7 +225,6 @@ private static string ReplaceBasePath(string htmlContent, HttpContext httpContex } }; - // Serialize without over-escaping, but make sure it won't break var frontendJsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -196,41 +233,37 @@ private static string ReplaceBasePath(string htmlContent, HttpContext httpContex }; var json = JsonSerializer.Serialize(envConfig, frontendJsonOptions); - json = SanitizeForInlineScript(json); + return $"(function(){{try{{window.TickerQConfig={json};window.__dynamic_base__=window.TickerQConfig.basePath;}}catch(e){{console.error('TickerQ config failed:',e);}}}})();"; + } + + /// + /// Injects base tag and external script references into the HTML template. + /// Config must load before preload since the preload script uses window.__dynamic_base__. + /// + private static string InjectExternalScripts(string htmlTemplate, HttpContext httpContext, string basePath) + { + if (string.IsNullOrEmpty(htmlTemplate)) + return htmlTemplate ?? string.Empty; - // Add base tag for proper asset loading - var baseTag = $@""; + var pathBase = httpContext.Request.PathBase.HasValue + ? httpContext.Request.PathBase.Value + : string.Empty; - // Inline bootstrap: set TickerQConfig and derive __dynamic_base__ (vite-plugin-dynamic-base) - var script = $@""; + var injection = $@"" + + @"" + + @""; - var fullInjection = baseTag + script; - // Prefer inject immediately after opening - var headOpen = Regex.Match(htmlContent, "(?is)]*>"); + var headOpen = Regex.Match(htmlTemplate, "(?is)]*>"); if (headOpen.Success) - { - return htmlContent.Insert(headOpen.Index + headOpen.Length, fullInjection); - } + return htmlTemplate.Insert(headOpen.Index + headOpen.Length, injection); - // Fallback: just before - var closeIdx = htmlContent.IndexOf("", StringComparison.OrdinalIgnoreCase); + var closeIdx = htmlTemplate.IndexOf("", StringComparison.OrdinalIgnoreCase); if (closeIdx >= 0) - { - return htmlContent.Insert(closeIdx, fullInjection); - } + return htmlTemplate.Insert(closeIdx, injection); - // Last resort: prepend (ensures script runs early) - return fullInjection + htmlContent; + return injection + htmlTemplate; } private static string CombinePathBase(string pathBase, string basePath) @@ -260,10 +293,5 @@ private static string CombinePathBase(string pathBase, string basePath) return pathBase + basePath; } - /// - /// Prevents </script> in JSON strings from prematurely closing the inline script. - /// - private static string SanitizeForInlineScript(string json) - => json.Replace("