Project updates from Template

This commit contains the latest updates from the generic Website template, which rework the compression and localization:

- Reworked the compression and localization in the Infrastructure package. (3c568e4)
- Adopted the reworked compression and localization in the Website service. (08cf3e3)

The template commit that only touched the root README (53676ed) was left out, as this project no longer carries that file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 12:45:58 +02:00
co-authored by Claude Fable 5.1
parent 65b62681eb
commit 916df7e2f0
24 changed files with 426 additions and 218 deletions
+10 -1
View File
@@ -55,7 +55,7 @@ ANALYTICS_RECORDER=false
# `Content-Security-Policy`. Must allow `String.Analytics.origin` in `script-src` and
# `connect-src`, or the tracker is blocked; drop those two once analytics is off.
SECURITY_CONTENT_SECURITY_POLICY=default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
SECURITY_CONTENT_SECURITY_POLICY=default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
# `X-Content-Type-Options`: disables MIME sniffing.
SECURITY_CONTENT_TYPE_OPTIONS=nosniff
@@ -73,6 +73,15 @@ SECURITY_PERMISSIONS_POLICY=accelerometer=(), camera=(), geolocation=(), gyrosco
# remember it stickily once seen, so it stays off in local development.
# SECURITY_STRICT_TRANSPORT_SECURITY=max-age=31536000; includeSubDomains
# --- Reverse proxy -----------------------------------------------------------
# Both assert a TLS-terminating reverse proxy that sets the forwarded headers, so
# set them together; off for a directly reachable server, where clients can forge
# the headers. The former drives the HTTPS redirect, the latter keys the rate-limit
# buckets by the first `X-Forwarded-For` entry.
HTTPS_TRUST_FORWARDED_PROTO=false
RATE_LIMIT_TRUST_FORWARDED_FOR=false
# --- Persistence -------------------------------------------------------------
# Persistence driver: inMemory (default, no infrastructure) or postgres.
+6 -6
View File
@@ -45,8 +45,8 @@ let package = Package(
from: "2.25.0"
),
.package(
url: "https://github.com/hummingbird-project/hummingbird-compression.git",
from: "2.0.0"
url: "https://github.com/adam-fowler/compress-nio.git",
from: "1.4.2"
),
.package(
url: "https://github.com/apple/swift-configuration.git",
@@ -72,10 +72,6 @@ let package = Package(
name: "Hummingbird",
package: "hummingbird"
),
.product(
name: "HummingbirdCompression",
package: "hummingbird-compression"
),
],
path: "Sources/App"
),
@@ -115,6 +111,10 @@ let package = Package(
dependencies: [
.byName(name: "Infrastructure"),
.byName(name: "Website"),
.product(
name: "CompressNIO",
package: "compress-nio"
),
.product(
name: "HummingbirdTesting",
package: "hummingbird"
+16 -15
View File
@@ -4,14 +4,14 @@ The **CCN** public website service — a [Hummingbird](https://github.com/hummin
## Overview
The service:
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)).
- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`.
- Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
- Embeds a cookieless [Umami](https://umami.is) tracker on both pages once a deployment configures one; it ships **off**, so an unconfigured copy requests no third-party script (see [Analytics](#analytics)).
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
- Serves the pages gzipped once at startup, sized rather than chunked, and compresses everything else (gzip/deflate) above a configurable size when the client advertises support.
- Stamps a hardened set of security headers on every response.
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a PostgreSQL server, selected by a single configuration key.
@@ -29,7 +29,7 @@ Two SwiftPM targets:
The `Website` executable depends on four local packages, each under `Packages/`:
- `Localization` — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
- `Infrastructure` — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable.
- `Infrastructure` — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security, redirect, vary, compression, rate-limit, and not-found middlewares, the `Negotiate(for:)` request-language extension, the `Page` and `Asset` scaffolding, the `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable.
- `Persistence` — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
- `Utility` — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
@@ -42,11 +42,10 @@ LogRequestsMiddleware
→ HTTPSRedirectMiddleware (301 to site.origin when forwarded over plain HTTP)
→ TrailingSlashRedirectMiddleware (301 to the path without a trailing slash)
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
ResponseCompressionMiddleware (gzip/deflate above the size threshold)
LocalizationMiddleware (negotiates the language: ?lang=, path prefix, then Accept-Language)
NotFoundMiddleware (renders the localized not-found page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page; GET /<lang> → its other editions)
→ CompressionMiddleware (gzip/deflate above the size threshold; pre-compressed pages pass through)
NotFoundMiddleware (negotiates the language, renders the not-found page on .notFound)
FileMiddleware (serves Resources/Static)
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions)
HealthController (GET /health → liveness, GET /health/ready → readiness)
```
@@ -89,7 +88,7 @@ The same set belongs in the site-wide structured data: `StructuredData`'s `inLan
`sitemap.xml` is the one part that does *not* follow the catalog: it is a static file, so a new language needs its editions added by hand — `/nl`, `/nl/<page>`, one `<loc>` each, alongside the default language's. Give each the same spelling the page's own canonical carries (the root is the bare origin, with no trailing slash), or the two disagree about which URL is canonical.
Visitors switch language two ways, both handled by `LocalizationMiddleware` ahead of the routes: a `?lang=` query parameter (what a language switcher links to) and the leading path segment. Either beats `Accept-Language`; a value naming no supported language is ignored. The path segment matters beyond the routed pages — it is what makes an *unrouted* path under a language's prefix answer its not-found page in that language.
Visitors switch language two ways, both read by `Infrastructure`'s `Negotiate(for:)`: a `?lang=` query parameter (what a language switcher links to) and the leading path segment. Either beats `Accept-Language`; a value naming no supported language is ignored. Only the responders that need the answer ask — `RootController`'s bare route and `NotFoundMiddleware` — so no request pays for a negotiation it never reads. The path segment matters beyond the routed pages — it is what makes an *unrouted* path under a language's prefix answer its not-found page in that language.
## Configuration
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
@@ -128,6 +127,8 @@ The groups are matched in order, so the specific media types (`text/css`, `text/
| --- | --- | --- | --- |
| `compression.minimumResponseSize` | `COMPRESSION_MINIMUM_RESPONSE_SIZE` | `1024` | Minimum response body size, in bytes, before compression is applied. |
The pages are gzipped once at startup by `CachedHTMLResponse` and served with a `Content-Length`, so the threshold governs everything else. `CompressionMiddleware` wraps Hummingbird's `ResponseCompressionMiddleware` and leaves a response that already names an encoding alone — the upstream middleware would append a second `gzip`.
### HTTP server
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
@@ -138,7 +139,7 @@ The groups are matched in order, so the specific media types (`text/css`, `text/
### HTTPS redirect
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. |
| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. Pairs with `rateLimit.trustForwardedFor`: both assert a reverse proxy, so set them together, as the Compose files do. |
Redirecting collapses the `http://` and `https://` copies of every page onto one address, which is what a search engine consolidates a site's signals against. Three details:
@@ -193,11 +194,11 @@ See [Persistence](#persistence-1) below for the workflow.
### Rate limiting
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable **only** behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. |
| `rateLimit.limit` | `RATE_LIMIT_LIMIT` | `5` | Requests admitted per client per window; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
| `rateLimit.window` | `RATE_LIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
| `rateLimit.trustForwardedFor` | `RATE_LIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable **only** behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. Pairs with `https.trustForwardedProto`; left `false` behind a proxy, every visitor shares the proxy's one bucket. |
> **Configured but unapplied.** The template ships no endpoint worth limiting, so `RateLimitMiddleware` is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one.
> **Configured but unapplied.** The template ships no endpoint worth limiting, so `RateLimitMiddleware` is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one; `docker-compose.yml` already sets `rateLimit.trustForwardedFor` alongside the HTTPS flag, so the buckets are keyed per visitor from the first deploy.
### Analytics
The template ships analytics **off**: `analytics.websiteID` is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order:
@@ -219,7 +220,7 @@ The tracker's origin is not a configuration key: it is single-sourced in code so
### Security headers
| Config key | Environment variable | Default |
| --- | --- | --- |
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'` |
| `security.contentTypeOptions` | `SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` |
| `security.frameOptions` | `SECURITY_FRAME_OPTIONS` | `DENY` |
| `security.referrerPolicy` | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` |
@@ -1,6 +1,5 @@
import Configuration
import Hummingbird
import HummingbirdCompression
import Localization
import Logging
import Persistence
@@ -140,19 +139,11 @@ private func logger(
/// Builds the application's router.
///
/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
/// HTTPS-redirect middleware that bounces requests forwarded over plain HTTP to the canonical origin, the trailing-slash redirect middleware that
/// collapses each path onto its canonical form, the vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses
/// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's
/// language from its `Accept-Language` header (honouring the `lang` query override and the language a leading path segment names), the
/// not-found middleware that serves the not-found page, and the static file middleware that serves the
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then adds the `RootController` routes that
/// render the landing page one per language the String Catalog serves and the `HealthController` routes that serve the health check.
///
/// The security-headers middleware sits just inside request logging so it covers every response that reaches a client the landing page, the compressed
/// responses, the rendered not-found page, and the served static files. The HTTPS redirect sits directly beneath it, so a redirect carries the security
/// headers but skips the negotiation, compression, and file lookup it would otherwise pay for. The trailing-slash redirect follows it, ahead of the
/// routes and `FileMiddleware` that would otherwise answer both spellings of every path.
/// The chain below reads as its own list; the order is what does not. Security headers sit just inside request logging, so they reach every response a
/// client sees pages, compressed bodies, the not-found page, the served files. The HTTPS redirect sits directly beneath, keeping those headers while
/// skipping the compression and file lookup it would otherwise pay for. The trailing-slash redirect follows, ahead of the routes and `FileMiddleware`
/// that would otherwise answer both spellings of every path. The language is negotiated by the responders that read it the landing route and the
/// not-found middleware rather than on every request past.
/// - Parameters:
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned.
@@ -196,10 +187,9 @@ private func router(
)
TrailingSlashRedirectMiddleware()
VaryMiddleware()
ResponseCompressionMiddleware(
CompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
)
LocalizationMiddleware()
NotFoundMiddleware(
assetVersion: assetVersion,
analytics: analytics
@@ -4,16 +4,13 @@ import NIOCore
/// The website's request context.
///
/// Extends the core request storage with the negotiated language, defaulting to the default supported language until ``LocalizationMiddleware``
/// resolves it from the request, and with the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client.
public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressRequestContext {
/// Extends the core request storage with the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client.
public struct WebsiteRequestContext: RemoteAddressRequestContext {
// MARK: Properties
/// The core request context storage Hummingbird requires.
public var coreContext: CoreRequestContextStorage
/// The language identifier negotiated for the request.
public var language: String
/// The address of the connected client, captured from the source channel.
public let remoteAddress: SocketAddress?
@@ -25,14 +22,7 @@ public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressReque
source: Source,
) {
self.coreContext = .init(source: source)
self.language = .empty
self.remoteAddress = source.channel.remoteAddress
}
}
// MARK: - Constants
private extension String {
static let empty = ""
}
@@ -1,6 +1,7 @@
import Foundation
import Hummingbird
import Infrastructure
import Localization
/// Serves the website's root routes.
///
@@ -13,10 +14,13 @@ import Infrastructure
/// ```
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct RootController<Context: LocalizedRequestContext> {
public struct RootController<Context: RequestContext> {
// MARK: Properties
/// Negotiates the language the bare route answers in.
private let negotiate: Negotiate
/// The landing page, rendered once per supported language and reused for every request.
private let responses: LocalizedHTMLCollectionResponse
@@ -32,7 +36,13 @@ public struct RootController<Context: LocalizedRequestContext> {
siteOrigin: String? = nil,
analytics: Analytics? = nil
) {
self.responses = .init(bundle: .module) {
self.negotiate = .init(bundle: .module)
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
// and carry it too.
self.responses = .init(
bundle: .module,
variesOnAcceptLanguage: true
) {
IndexPage(
locale: $0,
assetVersion: assetVersion,
@@ -80,26 +90,27 @@ private extension RootController {
/// Handles a request for the landing page.
///
/// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``, falling back to the default language.
/// Renders the ``IndexPage`` in the language negotiated from the request its `lang` query parameter, then the leading path segment, then
/// `Accept-Language` falling back to the default language.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - Returns: the cached ``IndexPage`` response for the context's language.
/// - Returns: the cached ``IndexPage`` response for the negotiated language.
@Sendable
func index(
request: Request,
context: Context
) -> Response {
responses.response(
for: context.language,
for: negotiate(for: request),
request: request
)
}
/// Builds the handler serving the landing page in one fixed language, for the routes carrying the language in their path.
///
/// The path *is* the language choice, so the negotiated context language is ignored: a prefixed URL answers in its language for every
/// visitor and every crawler alike, which is what lets a search engine index it as that edition.
/// The path *is* the language choice, so nothing is negotiated: a prefixed URL answers in its language for every visitor and every crawler
/// alike, which is what lets a search engine index it as that edition.
/// - Parameter language: the language the route serves.
/// - Returns: the handler answering requests for that edition of the page.
func index(
@@ -1,13 +0,0 @@
import Foundation
import Infrastructure
public extension LocalizationMiddleware {
// MARK: Initializers
/// Creates a localization middleware that negotiates against the module's String Catalog languages.
init() {
self.init(bundle: .module)
}
}
+46
View File
@@ -1,3 +1,4 @@
import CompressNIO
import Configuration
import Foundation
import Hummingbird
@@ -247,6 +248,51 @@ struct AppTests {
}
}
/// `CompressionMiddleware` stands in for Hummingbird's `ResponseCompressionMiddleware`, which appends to `Content-Encoding` without
/// checking for one: a pre-compressed page would ship as `gzip, gzip`. Decoding makes it visible a doubly compressed page decodes once,
/// into bytes that are not HTML.
@Test
func `page to be served pre-compressed, and only once`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get,
headers: [.acceptEncoding: "gzip, deflate, br"]
) { response in
#expect(response.status == .ok)
#expect(response.headers[values: .contentEncoding] == ["gzip"])
var body = response.body
let decoded = try body.decompress(with: .gzip())
#expect(String(buffer: decoded).hasPrefix("<!DOCTYPE html>"))
// Complete before the first byte is written, so it is sized rather than chunked.
#expect(response.headers[.contentLength] == String(response.body.readableBytes))
#expect(response.body.readableBytes < decoded.readableBytes)
}
}
}
/// The compressed copy is only handed to a client that asked for it; everyone else gets the rendered bytes.
@Test
func `page to be served uncompressed when gzip is not accepted`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get,
headers: [.acceptEncoding: "identity"]
) { response in
#expect(response.status == .ok)
#expect(response.headers[.contentEncoding] == nil)
#expect(String(buffer: response.body).hasPrefix("<!DOCTYPE html>"))
}
}
}
@Test
func `error page to be served when not found`() async throws {
try await app(
@@ -17,10 +17,6 @@ struct RootControllerTests {
private let app: Application = .init(router: {
let router = Router(context: WebsiteRequestContext.self)
router.addMiddleware {
LocalizationMiddleware()
}
router.addRoutes(RootController<WebsiteRequestContext>().routes)
return router
@@ -220,10 +216,6 @@ private extension RootControllerTests {
) -> some ApplicationProtocol {
let router = Router(context: WebsiteRequestContext.self)
router.addMiddleware {
LocalizationMiddleware()
}
router.addRoutes(RootController<WebsiteRequestContext>(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
@@ -19,6 +19,7 @@ services:
environment:
LOG_LEVEL: debug
HTTPS_TRUST_FORWARDED_PROTO: "false"
RATE_LIMIT_TRUST_FORWARDED_FOR: "false"
DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
DATABASE_HOST: postgres
DATABASE_TLS: ${DATABASE_TLS:-off}
+2 -1
View File
@@ -21,9 +21,10 @@ services:
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-CCNWebsite}
# Falls back to the policy the app ships with; set it in `.env` to allow the analytics origin,
# which must match `String.Analytics.origin`.
SECURITY_CONTENT_SECURITY_POLICY: "${SECURITY_CONTENT_SECURITY_POLICY:-default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'}"
SECURITY_CONTENT_SECURITY_POLICY: "${SECURITY_CONTENT_SECURITY_POLICY:-default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'}"
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
HTTPS_TRUST_FORWARDED_PROTO: "${HTTPS_TRUST_FORWARDED_PROTO:-true}"
RATE_LIMIT_TRUST_FORWARDED_FOR: "${RATE_LIMIT_TRUST_FORWARDED_FOR:-true}"
DATABASE_DRIVER: ${DATABASE_DRIVER:-postgres}
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
DATABASE_PORT: ${DATABASE_PORT:-5432}