Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/server-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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`

Expand All @@ -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
Expand Down
21 changes: 17 additions & 4 deletions scripts/publish-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
16 changes: 8 additions & 8 deletions src/EventStore.ClusterNode/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="icon" type="image/png" href="/ui/assets/favicon.png" />
<link rel="apple-touch-icon" href="/ui/assets/apple-touch-icon.png" />
<meta name="msapplication-TileImage" content="/ui/assets/es-tile.png" />
<link rel="stylesheet" href="/ui/assets/css/tailwind.generated.css" />
<link rel="icon" type="image/png" href="@Assets["ui/assets/favicon.png"]" />
<link rel="apple-touch-icon" href="@Assets["ui/assets/apple-touch-icon.png"]" />
<meta name="msapplication-TileImage" content="@Assets["ui/assets/es-tile.png"]" />
<link rel="stylesheet" href="@Assets["ui/assets/css/tailwind.generated.css"]" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="/ui/assets/js/ui-auth.js"></script>
<script src="/ui/assets/js/queue-dashboard.js"></script>
<script src="/ui/assets/js/admin-operations.js"></script>
<script src="/ui/assets/js/stream-browser.js"></script>
<script src="@Assets["ui/assets/js/ui-auth.js"]"></script>
<script src="@Assets["ui/assets/js/queue-dashboard.js"]"></script>
<script src="@Assets["ui/assets/js/admin-operations.js"]"></script>
<script src="@Assets["ui/assets/js/stream-browser.js"]"></script>
</body>
</html>
11 changes: 2 additions & 9 deletions src/EventStore.ClusterNode/EventStore.ClusterNode.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,7 @@
<None Update="package.json" />
<None Update="package-lock.json" />
<None Update="styles\app.css" />
<None Update="ui-assets\apple-touch-icon.png" CopyToOutputDirectory="PreserveNewest" />
<None Update="ui-assets\es-tile.png" CopyToOutputDirectory="PreserveNewest" />
<None Update="ui-assets\favicon.png" CopyToOutputDirectory="PreserveNewest" />
<None Update="ui-assets\fonts\**\*" CopyToOutputDirectory="PreserveNewest" />
<None Update="ui-assets\css\tailwind.generated.css" CopyToOutputDirectory="PreserveNewest" />
<None Update="ui-assets\js\admin-operations.js" CopyToOutputDirectory="PreserveNewest" />
<None Update="ui-assets\js\queue-dashboard.js" CopyToOutputDirectory="PreserveNewest" />
<None Update="ui-assets\js\stream-browser.js" CopyToOutputDirectory="PreserveNewest" />
<None Update="ui-assets\js\ui-auth.js" CopyToOutputDirectory="PreserveNewest" />
<None Remove="ui-assets\**\*" />
<Content Include="ui-assets\**\*" Link="wwwroot\ui\assets\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
15 changes: 1 addition & 14 deletions src/EventStore.ClusterNode/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -327,18 +326,6 @@ async Task Run(ClusterVNodeHostedService hostedService, ManualResetEventSlim sig

var app = builder.Build();
app.UseMiddleware<UiCredentialsMiddleware>();
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)
{
Expand All @@ -349,7 +336,7 @@ async Task Run(ClusterVNodeHostedService hostedService, ManualResetEventSlim sig
{
app.MapAdminOperationsEndpoints();
app.MapQueueDashboardEndpoints();
app.MapStaticAssets();
app.MapStaticAssets().ShortCircuit();
app.MapRazorComponents<App>();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<IAuthenticationProvider>(_authentication);
builder.Services.AddSingleton<IUiSessionAuthenticator>(_sessions);
builder.Services.AddSingleton<IReadOnlyList<IHttpAuthenticationProvider>>([
new BasicHttpAuthenticationProvider(_authentication), new AnonymousHttpAuthenticationProvider()]);
builder.Services.AddSingleton<AuthenticationMiddleware>();
_app = builder.Build();
_app.UseRouting();
_app.UseMiddleware<AuthenticationMiddleware>();
_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<App>().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<RouteEndpoint>().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<string> GetSupportedAuthenticationSchemes() => ["Basic"];
}

private sealed class AssetSessionAuthenticator : IUiSessionAuthenticator
{
public int Checks;
public Task<ClaimsPrincipal> AuthenticateAsync(HttpContext context)
{
Checks++;
return Task.FromResult<ClaimsPrincipal>(null);
}
public Task<bool> ValidateRequestAsync(HttpContext context) => Task.FromResult(true);
}
}
2 changes: 1 addition & 1 deletion src/EventStore.Core/ClusterVNodeStartup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<GrpcStreamLifetimeMiddleware>()
.UseAuthorization()
.UseAntiforgery();
Expand Down
Loading