From d2d83e1105fa6bcb7d51261d9a28d7c300a0c3f1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 8 Sep 2026 11:58:11 -0400 Subject: [PATCH] fix(ui): keep published assets stable across upgrades Signed-off-by: Yordis Prieto --- docs/server-settings.md | 8 +- scripts/publish-tests.sh | 21 ++- .../Components/App.razor | 16 +- .../EventStore.ClusterNode.csproj | 11 +- src/EventStore.ClusterNode/Program.cs | 15 +- .../Transport/Http/UiStaticAssetsTests.cs | 171 ++++++++++++++++++ src/EventStore.Core/ClusterVNodeStartup.cs | 2 +- 7 files changed, 205 insertions(+), 39 deletions(-) create mode 100644 src/EventStore.Core.Tests/Services/Transport/Http/UiStaticAssetsTests.cs diff --git a/docs/server-settings.md b/docs/server-settings.md index 0cf5efb67d..f0c522bdd1 100644 --- a/docs/server-settings.md +++ b/docs/server-settings.md @@ -22,7 +22,7 @@ When TrogonEventStore is installed as a Linux service, the following locations a - **Data:** `/var/lib/eventstore` - **Server logs:** `/var/log/eventstore` - **Test client logs:** `./testclientlog` -- **Web content:** `./ui-assets` then `{Content}/ui-assets` +- **Web content:** `wwwroot/ui/assets` beside the server executable - **Projections:** `./projections` then `{Content}/projections` - **Prelude:** `./Prelude` then `{Content}/Prelude` @@ -32,7 +32,7 @@ When TrogonEventStore is installed as a Linux service, the following locations a - **Data:** `./data` - **Server logs:** `./logs` - **Test client log:** `./testclientlogs` -- **Web content:** `./ui-assets` +- **Web content:** `wwwroot/ui/assets` beside the server executable - **Projections:** `./projections` - **Prelude:** `./Prelude` @@ -44,12 +44,14 @@ When running TrogonEventStore using local binaries, either downloaded or built f - **Data:** `./data` - **Server logs:** `./logs` - **Test client log:** `./testclientlogs` -- **Web content:** `./ui-assets` +- **Web content:** `wwwroot/ui/assets` beside the server executable - **Projections:** `./projections` - **Prelude:** `./Prelude` Depending on the platform and installation type, the location of TrogonEventStore executables, configuration and other necessary files vary. +Deploy the complete published output, including `wwwroot` and the static-asset endpoint manifest, together. UI asset URLs are content-fingerprinted so browsers can safely cache them across upgrades. Replacing asset files independently of the server build is not supported. + ## Database settings ### Database location diff --git a/scripts/publish-tests.sh b/scripts/publish-tests.sh index dc759054d2..7a857f3520 100755 --- a/scripts/publish-tests.sh +++ b/scripts/publish-tests.sh @@ -4,16 +4,29 @@ set -eu source_directory=$1 output_directory=$2 -test_projects=$(mktemp) -trap 'rm -f "$test_projects"' EXIT +staging_directory=$(mktemp -d) +trap 'rm -rf "$staging_directory"' EXIT +test_projects="$staging_directory/test-projects" +node_publish_directory="$staging_directory/node" -find "$source_directory" -maxdepth 1 -type d -name "*.Tests" -print > "$test_projects" +# Test projects do not inherit the web SDK's published static-asset manifest. +dotnet publish \ + --runtime="${RUNTIME}" \ + --no-self-contained \ + --configuration Release \ + --output "$node_publish_directory" \ + "$source_directory/EventStore.ClusterNode" + +find "$source_directory" -maxdepth 2 -type f -name "*.Tests.csproj" -print > "$test_projects" while IFS= read -r test_project; do + test_output_directory="$output_directory/$(basename "$test_project" .csproj)" dotnet publish \ --runtime="${RUNTIME}" \ --no-self-contained \ --configuration Release \ - --output "$output_directory/$(basename "$test_project")" \ + --output "$test_output_directory" \ "$test_project" + cp "$node_publish_directory/EventStore.ClusterNode.staticwebassets.endpoints.json" "$test_output_directory/" + cp -R "$node_publish_directory/wwwroot" "$test_output_directory/" done < "$test_projects" diff --git a/src/EventStore.ClusterNode/Components/App.razor b/src/EventStore.ClusterNode/Components/App.razor index ca46ae7268..2e36c74687 100644 --- a/src/EventStore.ClusterNode/Components/App.razor +++ b/src/EventStore.ClusterNode/Components/App.razor @@ -4,17 +4,17 @@ - - - - + + + + - - - - + + + + diff --git a/src/EventStore.ClusterNode/EventStore.ClusterNode.csproj b/src/EventStore.ClusterNode/EventStore.ClusterNode.csproj index 878485dd50..f0161415bf 100644 --- a/src/EventStore.ClusterNode/EventStore.ClusterNode.csproj +++ b/src/EventStore.ClusterNode/EventStore.ClusterNode.csproj @@ -49,14 +49,7 @@ - - - - - - - - - + + diff --git a/src/EventStore.ClusterNode/Program.cs b/src/EventStore.ClusterNode/Program.cs index 72581cb202..270308e3c5 100644 --- a/src/EventStore.ClusterNode/Program.cs +++ b/src/EventStore.ClusterNode/Program.cs @@ -28,7 +28,6 @@ using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Serilog; @@ -327,18 +326,6 @@ async Task Run(ClusterVNodeHostedService hostedService, ManualResetEventSlim sig var app = builder.Build(); app.UseMiddleware(); - if (adminUiEnabled && Directory.Exists(Locations.UiAssetsDirectory)) - { - app.UseStaticFiles(new StaticFileOptions - { - FileProvider = new PhysicalFileProvider(Locations.UiAssetsDirectory), - RequestPath = "/ui/assets" - }); - } - else if (adminUiEnabled) - { - Log.Warning("UI assets directory {UiAssetsDirectory} is not available.", Locations.UiAssetsDirectory); - } hostedService.Node.Startup.Configure(app); if (oauthEnabled) { @@ -349,7 +336,7 @@ async Task Run(ClusterVNodeHostedService hostedService, ManualResetEventSlim sig { app.MapAdminOperationsEndpoints(); app.MapQueueDashboardEndpoints(); - app.MapStaticAssets(); + app.MapStaticAssets().ShortCircuit(); app.MapRazorComponents(); } diff --git a/src/EventStore.Core.Tests/Services/Transport/Http/UiStaticAssetsTests.cs b/src/EventStore.Core.Tests/Services/Transport/Http/UiStaticAssetsTests.cs new file mode 100644 index 0000000000..51f5042461 --- /dev/null +++ b/src/EventStore.Core.Tests/Services/Transport/Http/UiStaticAssetsTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Threading.Tasks; +using EventStore.ClusterNode.Components; +using EventStore.ClusterNode.Components.Services; +using EventStore.Core.Authentication.PassthroughAuthentication; +using EventStore.Core.Services.Transport.Http; +using EventStore.Core.Services.Transport.Http.Authentication; +using EventStore.Plugins.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace EventStore.Core.Tests.Services.Transport.Http; + +[TestFixture] +public class UiStaticAssetsTests +{ + private WebApplication _app; + private HttpClient _client; + private AssetAuthenticationProvider _authentication; + private AssetSessionAuthenticator _sessions; + + [SetUp] + public async Task SetUp() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ApplicationName = typeof(App).Assembly.GetName().Name, + ContentRootPath = TestContext.Parameters.Get("UiAssetsContentRoot", AppContext.BaseDirectory), + EnvironmentName = "Production" + }); + // Build manifests otherwise enable development-time cache overrides even in Production. + builder.Configuration["ReloadStaticAssetsAtRuntime"] = "false"; + builder.Logging.ClearProviders(); + builder.WebHost.UseTestServer(); + builder.Services.AddRazorComponents(); + builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddSingleton(new SecurityBrowserService(new PassthroughAuthenticationProvider(), false)); + _authentication = new AssetAuthenticationProvider(); + _sessions = new AssetSessionAuthenticator(); + builder.Services.AddSingleton(_authentication); + builder.Services.AddSingleton(_sessions); + builder.Services.AddSingleton>([ + new BasicHttpAuthenticationProvider(_authentication), new AnonymousHttpAuthenticationProvider()]); + builder.Services.AddSingleton(); + _app = builder.Build(); + _app.UseRouting(); + _app.UseMiddleware(); + _app.UseAntiforgery(); + var manifestPath = TestContext.Parameters.Exists("UiAssetsContentRoot") + ? Path.Combine(_app.Environment.ContentRootPath, "EventStore.ClusterNode.staticwebassets.endpoints.json") + : null; + _app.MapStaticAssets(manifestPath).ShortCircuit(); + _app.MapRazorComponents().WithStaticAssets(manifestPath); + await _app.StartAsync(); + _client = _app.GetTestClient(); + } + + [TearDown] + public async Task TearDown() + { + _client?.Dispose(); + if (_app is not null) + await _app.DisposeAsync(); + } + + [TestCase("css/tailwind.generated.css", "text/css")] + [TestCase("js/ui-auth.js", "text/javascript")] + [TestCase("js/admin-operations.js", "text/javascript")] + [TestCase("js/queue-dashboard.js", "text/javascript")] + [TestCase("js/stream-browser.js", "text/javascript")] + [TestCase("favicon.png", "image/png")] + [TestCase("apple-touch-icon.png", "image/png")] + [TestCase("es-tile.png", "image/png")] + [TestCase("fonts/roboto-regular-webfont.woff2", "font/woff2")] + public async Task packaged_assets_have_content_addressed_endpoints(string asset, string contentType) + { + var path = "ui/assets/" + asset; + using var response = await _client.GetAsync("/" + path); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(response.Content.Headers.ContentType?.MediaType, Is.EqualTo(contentType)); + Assert.That(response.Headers.ETag, Is.Not.Null); + var extension = Path.GetExtension(path); + var prefix = path[..^extension.Length] + "."; + var routes = ((IEndpointRouteBuilder)_app).DataSources.SelectMany(source => source.Endpoints) + .OfType().Select(endpoint => endpoint.RoutePattern.RawText); + var fingerprintedPath = routes.Distinct().Single(route => route.StartsWith(prefix, StringComparison.Ordinal) + && route.EndsWith(extension, StringComparison.Ordinal) && route != path); + using var fingerprinted = await _client.GetAsync("/" + fingerprintedPath); + Assert.That(fingerprinted.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(await fingerprinted.Content.ReadAsByteArrayAsync(), Is.EqualTo(await response.Content.ReadAsByteArrayAsync())); + Assert.That(fingerprinted.Headers.CacheControl?.Extensions.Any(extension => extension.Name == "immutable"), Is.True); + using var conditional = new HttpRequestMessage(HttpMethod.Get, "/" + fingerprintedPath); + conditional.Headers.IfNoneMatch.Add(fingerprinted.Headers.ETag); + using var unchanged = await _client.SendAsync(conditional); + Assert.That(unchanged.StatusCode, Is.EqualTo(HttpStatusCode.NotModified)); + } + + [Test] + public async Task sign_in_page_references_fingerprinted_assets() + { + var html = await _client.GetStringAsync("/ui/signin"); + foreach (var asset in new[] { "css/tailwind.generated.css", "js/ui-auth.js", "js/admin-operations.js", + "js/queue-dashboard.js", "js/stream-browser.js", "favicon.png", "apple-touch-icon.png", "es-tile.png" }) + { + var path = "ui/assets/" + asset; + var extension = Path.GetExtension(path); + var prefix = path[..^extension.Length]; + Assert.That(html, Does.Match(System.Text.RegularExpressions.Regex.Escape(prefix) + @"\.[a-z0-9]+" + + System.Text.RegularExpressions.Regex.Escape(extension))); + Assert.That(html, Does.Not.Contain("\"/" + path + "\"").And.Not.Contain("\"" + path + "\"")); + } + } + + [TestCase("Authorization", "Basic YWRtaW46d3Jvbmc=")] + [TestCase("Cookie", "session=invalid")] + public async Task public_assets_do_not_authenticate_credentials_or_sessions(string header, string value) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/ui/assets/js/ui-auth.js"); + request.Headers.Add(header, value); + using var response = await _client.SendAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(_authentication.Checks, Is.Zero); + Assert.That(_sessions.Checks, Is.Zero); + } + + [Test] + public async Task non_asset_ui_requests_still_authenticate() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/ui/signin"); + request.Headers.Add("Authorization", "Basic YWRtaW46d3Jvbmc="); + using var response = await _client.SendAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + Assert.That(_authentication.Checks, Is.EqualTo(1)); + } + + private sealed class AssetAuthenticationProvider() : AuthenticationProviderBase("test") + { + public int Checks; + public override void Authenticate(AuthenticationRequest request) + { + Checks++; + request.Unauthorized(); + } + public override IReadOnlyList GetSupportedAuthenticationSchemes() => ["Basic"]; + } + + private sealed class AssetSessionAuthenticator : IUiSessionAuthenticator + { + public int Checks; + public Task AuthenticateAsync(HttpContext context) + { + Checks++; + return Task.FromResult(null); + } + public Task ValidateRequestAsync(HttpContext context) => Task.FromResult(true); + } +} diff --git a/src/EventStore.Core/ClusterVNodeStartup.cs b/src/EventStore.Core/ClusterVNodeStartup.cs index 269f487407..d3767fe04d 100644 --- a/src/EventStore.Core/ClusterVNodeStartup.cs +++ b/src/EventStore.Core/ClusterVNodeStartup.cs @@ -131,6 +131,7 @@ public void Configure(IApplicationBuilder app) _configureNode(app); app = app + .UseRouting() .UseCors("default") // AuthenticationMiddleware uses _httpAuthenticationProviders and assigns // the resulting ClaimsPrinciple to HttpContext.User @@ -141,7 +142,6 @@ public void Configure(IApplicationBuilder app) // of this yet but plugins may. The registered authentication scheme (es auth) // is driven by the HttpContext.User established above .UseAuthentication() - .UseRouting() .UseMiddleware() .UseAuthorization() .UseAntiforgery();