Files
Elternbeirat/Elternbeirat.Web/Program.cs
T

144 lines
6.1 KiB
C#

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<PocketBaseClient>(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<PocketBaseHealthCache>();
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<ComponentTypeMetadata>() is not null;
var services = context.RequestServices;
if (!isPage
|| await services.GetRequiredService<PocketBaseHealthCache>().IsHealthyAsync(
services.GetRequiredService<PocketBaseClient>().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<App>();
// 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<Event> 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<Event> 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();