using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Elternbeirat.Web.Components; using Elternbeirat.Web.Features.Events; using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents(); // "Today" for upcoming events comes from this clock, not DateTime.Today, so the // smoke tests can swap in a fixed date and check the pages before and after an event. builder.Services.AddSingleton(TimeProvider.System); // The typed client reads content from PocketBase over its REST API. The base URL // comes from config: appsettings for local dev, the PocketBase__BaseUrl env var // on the server (see compose.yaml). var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"] ?? throw new InvalidOperationException("PocketBase:BaseUrl is not configured."); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(pocketBaseUrl); // Every page waits for PocketBase. The default wait is 100 seconds, so if // PocketBase is down the page would hang that long. 5 seconds fails fast instead. client.Timeout = TimeSpan.FromSeconds(5); }); // Shared by all requests, so the maintenance gate below asks PocketBase at most once // every few seconds rather than on every page view. builder.Services.AddSingleton(); var app = builder.Build(); // NPM sits in front of the app and handles HTTPS. Without this line the app would // think every request is plain HTTP and would see NPM's IP, not the visitor's. // KnownNetworks/KnownProxies are left empty on purpose: only NPM reaches the app. app.UseForwardedHeaders( new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }); if (app.Environment.IsDevelopment() is false) { app.UseExceptionHandler("/Error", createScopeForErrors: true); // No UseHsts() and no UseHttpsRedirection() here: NPM already handles HTTPS. // Adding them behind NPM would send the browser into a redirect loop. } // Don't add UseStatusCodePagesWithReExecute here. Blazor already shows its own // not-found page. That middleware would catch *every* error code, so when PocketBase // is down (a 503) it would wrongly show "not found" instead of our own message. app.UseAntiforgery(); // Maintenance gate: while PocketBase is down, every page gets the static // maintenance.html with 503 instead of a half-empty page (the menu comes from // PocketBase too). Routing has already picked the endpoint at this point, so the gate // covers exactly the Razor component pages: /health, the calendar feeds, the static // assets (incl. maintenance.html and its font) are not components and answer for // themselves. A new endpoint is thus left alone unless it is a page. The health // result is cached (PocketBaseHealthCache), so the page may come back a few seconds // after PocketBase does. Retry-After tells well-behaved clients and crawlers when to // come back; no-store keeps the 503 page out of any cache. app.Use(async (context, next) => { var isPage = context.GetEndpoint()?.Metadata.GetMetadata() is not null; var services = context.RequestServices; if (!isPage || await services.GetRequiredService().IsHealthyAsync( services.GetRequiredService().IsHealthyAsync, context.RequestAborted)) { await next(context); return; } context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; context.Response.Headers.RetryAfter = "60"; context.Response.Headers.CacheControl = "no-store"; context.Response.ContentType = "text/html; charset=utf-8"; await context.Response.SendFileAsync( app.Environment.WebRootFileProvider.GetFileInfo("maintenance.html"), context.RequestAborted); }); app.MapStaticAssets(); app.MapRazorComponents(); // Calendar feed people can subscribe to. It returns calendar data, not a web page, // so it is a plain endpoint, not a Razor page. If PocketBase is down it returns an // empty calendar rather than an error. app.MapGet("/events.ics", async (PocketBaseClient pocketBase, CancellationToken token) => { IReadOnlyList events; try { events = await pocketBase.GetEventsAsync(token); } catch (PocketBaseUnavailableException) { events = []; } return Results.Text(IcsCalendar.Build(events), "text/calendar; charset=utf-8"); }); // One event as a calendar file ("In Kalender übernehmen" on the events page): the // same calendar as the feed, holding just this event. Unlike the feed it does not // fall back to an empty calendar when PocketBase is down -- a file without the event // the visitor clicked would be a silent failure, so it answers 503. An unknown or // non-public id is a 404. app.MapGet("/events/{id}.ics", async (string id, PocketBaseClient pocketBase, CancellationToken token) => { IReadOnlyList events; try { events = await pocketBase.GetEventsAsync(token); } catch (PocketBaseUnavailableException) { return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } return events.FirstOrDefault(@event => @event.Id == id) is { } match ? Results.Text(IcsCalendar.Build([match]), "text/calendar; charset=utf-8") : Results.NotFound(); }); // Status page for an outside monitor (Uptime Kuma) to check. One check tells all // three states apart: no reply = the app is down, 503 = the app runs but PocketBase // is down, 200 = both are fine. It talks to PocketBase directly, not through the // page-rendering code, so it can still report when that code is the thing failing. app.MapGet("/health", async (PocketBaseClient pocketBase, CancellationToken token) => await pocketBase.IsHealthyAsync(token) ? Results.Text("healthy", "text/plain; charset=utf-8") : Results.Text( "PocketBase unreachable", "text/plain; charset=utf-8", statusCode: StatusCodes.Status503ServiceUnavailable)); app.Run();