144 lines
6.1 KiB
C#
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();
|