Project updates from Template (#1)

This PR contains the latest updates from the generic Website template, which have been added while working on #loud-amsterdam.

Reviewed-on: #1
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-09-04 13:40:35 +00:00
committed by javier
parent 08b4d80064
commit 65b62681eb
60 changed files with 2347 additions and 326 deletions
@@ -16,7 +16,8 @@ import WebsiteLibrary
/// band (so a shared database is never migrated on boot).
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
/// - Throws: an error when the persistence service cannot be built (e.g. its TLS context fails to build).
/// - Throws: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built
/// (e.g. its TLS context fails to build).
func application(
reader: ConfigReader
) async throws -> some ApplicationProtocol {
@@ -34,8 +35,9 @@ func application(
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
}
let driver = try reader.driver
let persistence = try Service(
driver: reader.driver,
driver: driver,
logger: logger
)
let fluent = persistence()
@@ -52,8 +54,12 @@ func application(
analytics: reader.analytics,
cacheControl: reader.cacheControl,
compressionMinResponseSize: reader.compressionMinResponseSize,
httpsRedirect: reader.httpsRedirect,
rateLimit: reader.rateLimit,
securityHeaders: reader.securityHeaders,
// An unset origin leaves the pages without canonical URLs and language alternates, rather than
// building both against an empty host.
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
logLevel: reader.logLevel,
probe: Probe(fluent: fluent)
),
@@ -67,7 +73,7 @@ func application(
// The in-memory backend is recreated on every launch, so it is migrated on startup. The PostgreSQL backend is
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
if case .inMemory = reader.driver {
if case .inMemory = driver {
app.beforeServerStarts {
try await fluent.migrate()
}
@@ -90,7 +96,7 @@ func migration(
logLevel: reader.logLevel
)
let service = try Service(
driver: reader.driver,
driver: try reader.driver,
logger: logger
)
@@ -135,22 +141,28 @@ 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
/// vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses
/// 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, the not-found middleware that serves the error page, and the static file middleware that serves the
/// 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, and the `HealthController` routes that serve the health check.
/// 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 error page, and the served static files.
/// 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.
/// - 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.
/// - analytics: the analytics tracker both pages embed, or `nil` to omit it.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - rateLimit: the rate limit applied to the rate-limited routes.
/// - httpsRedirect: the origin plain-HTTP requests are redirected to, and whether the forwarded-protocol header is trusted.
/// - rateLimit: the rate limit configuration, currently applied to no route.
/// - securityHeaders: the security headers applied to every response.
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
/// - logLevel: the level the request-logging middleware logs at.
/// - probe: the probe consulted by the `HealthController` readiness route.
/// - Returns: the configured router.
@@ -160,8 +172,10 @@ private func router(
analytics: Analytics?,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration,
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
siteOrigin: String?,
logLevel: Logger.Level,
probe: Probe
) -> Router<AppRequestContext> {
@@ -177,6 +191,10 @@ private func router(
SecurityHeadersMiddleware(
configuration: securityHeaders
)
HTTPSRedirectMiddleware(
configuration: httpsRedirect
)
TrailingSlashRedirectMiddleware()
VaryMiddleware()
ResponseCompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
@@ -195,6 +213,7 @@ private func router(
router.addController {
RootController<AppRequestContext>(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
)
HealthController<AppRequestContext>(
@@ -56,10 +56,15 @@ package extension ConfigReader {
/// The `Cache-Control` policy applied to static files, grouped by media type.
///
/// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and
/// `cache.maxAge.default` keys. Stylesheets and scripts are referenced through fingerprinted URLs (see `FingerprintAssets`) and
/// fonts are immutable subset files, so all three are served long-lived and `immutable` a deploy busts them by changing the URL, never by
/// revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation once stale; images and
/// everything else are served public with their max-age alone. The groups match in order, so the specific types precede the `text` category.
/// `cache.maxAge.default` keys. Stylesheets, scripts, videos, JPEGs, and WebPs are referenced through fingerprinted URLs (see
/// `FingerprintAssets`) and fonts are immutable subset files, so all of them are served long-lived and `immutable` a deploy busts them by
/// changing the URL, never by revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation
/// once stale; the remaining images cannot be `immutable` the group covers the icons, and a browser fetches `/favicon.ico` unversioned
/// whatever the markup says and everything else is served public with its max-age alone. The groups match in order, so the specific types
/// precede the `text` and `image` categories.
///
/// - Important: an image the markup references without a `?v=` token must be in neither JPEG nor WebP, or it is served immutable for a year and
/// a deploy cannot dislodge it.
var cacheControl: CacheControl {
let maxAgeAsset = int(
forKey: .Cache.maxAgeAsset,
@@ -82,6 +87,9 @@ package extension ConfigReader {
(.textCss, [.public, .maxAge(maxAgeAsset), .immutable]),
(.textJavascript, [.public, .maxAge(maxAgeAsset), .immutable]),
(.font, [.public, .maxAge(maxAgeAsset), .immutable]),
(.videoMp4, [.public, .maxAge(maxAgeAsset), .immutable]),
(.imageJpeg, [.public, .maxAge(maxAgeAsset), .immutable]),
(.imageWebp, [.public, .maxAge(maxAgeAsset), .immutable]),
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
(.image, [.public, .maxAge(maxAgeImage)]),
(.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
@@ -100,51 +108,70 @@ package extension ConfigReader {
///
/// When `database.driver` selects PostgreSQL, the connection parameters are assembled from the `database.host`, `database.port`,
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`,
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any other driver value falls back to the in-memory database.
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any token but `inMemory` and `postgres` throws a
/// ``ConfigError``: a mistyped driver fails the boot rather than running on the in-memory database and discarding every write on restart.
var driver: Driver {
switch string(
forKey: .Database.driver,
default: .Database.driver
) {
case .Database.driverPostgres:
return .postgres(
.init(
host: string(
forKey: .Database.host,
default: .Database.host
),
port: int(
forKey: .Database.port,
default: .Database.port
),
name: string(
forKey: .Database.name,
default: .Database.name
),
username: string(
forKey: .Database.username,
default: .Database.username
),
password: string(
forKey: .Database.password,
default: ""
),
tls: tls,
maxConnectionsPerEventLoop: int(
forKey: .Database.poolMaxPerEventLoop,
default: .Database.poolMaxPerEventLoop
),
poolTimeout: .seconds(int(
forKey: .Database.poolTimeout,
default: .Database.poolTimeout
))
get throws {
switch string(
forKey: .Database.driver,
default: .Database.driver
) {
case .Database.driverInMemory:
return .inMemory
case .Database.driverPostgres:
return .postgres(
.init(
host: string(
forKey: .Database.host,
default: .Database.host
),
port: int(
forKey: .Database.port,
default: .Database.port
),
name: string(
forKey: .Database.name,
default: .Database.name
),
username: string(
forKey: .Database.username,
default: .Database.username
),
password: string(
forKey: .Database.password,
default: ""
),
tls: try tls,
maxConnectionsPerEventLoop: int(
forKey: .Database.poolMaxPerEventLoop,
default: .Database.poolMaxPerEventLoop
),
poolTimeout: .seconds(int(
forKey: .Database.poolTimeout,
default: .Database.poolTimeout
))
)
)
)
default:
return .inMemory
case let token:
throw ConfigError.unknownDatabaseDriver(token)
}
}
}
/// The HTTPS redirect middleware configuration, built from the `https.trustForwardedProto` key and ``siteOrigin``.
///
/// Off by default, so a deployment without a proxy in front never redirects on a header its clients could have written themselves. The redirects
/// point at ``siteOrigin`` the same value the pages build their canonical URLs from, so the two cannot disagree.
var httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration {
.init(
origin: siteOrigin,
trustForwardedProto: bool(
forKey: .HTTPS.trustForwardedProto,
default: false
)
)
}
/// The minimum log level the application emits at, read from the `log.level` key.
///
/// Falls back to `.info` when the key is unset or its value names no `Logger.Level` case.
@@ -164,7 +191,7 @@ package extension ConfigReader {
)
}
/// The rate limit applied to the subscription endpoint, built from the `rateLimit.*` keys.
/// The rate limit built from the `rateLimit.*` keys; the template applies it to no route yet.
///
/// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When `rateLimit.trustForwardedFor` is set,
/// clients are keyed by the first `X-Forwarded-For` entry enable it only behind a reverse proxy that sets the header, since clients can forge it
@@ -226,6 +253,17 @@ package extension ConfigReader {
)
}
/// The public origin the site is served at (scheme and host, no trailing slash), read from the `site.origin` key.
///
/// The redirects and any absolute links derive from it, so a staging deployment can point it at itself or leave it unset without the
/// production origin leaking into its markup.
var siteOrigin: String {
string(
forKey: .Site.origin,
default: .Site.origin
)
}
/// The directory the static files are served from, read from the `path.staticFiles` key.
var staticFilesPath: String {
string(
@@ -242,16 +280,45 @@ private extension ConfigReader {
// MARK: Properties
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
/// other value falls back to `prefer`.
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key.
///
/// Any token but `off`, `prefer`, and `require` throws a ``ConfigError``: a mistyped posture (`required`, say) fails the boot rather than
/// falling back to `prefer`, which hands the password over in plaintext when the upgrade is stripped.
var tls: TLS {
switch string(
forKey: .Database.tls,
default: .Database.tls
) {
case .Database.tlsOff: .off
case .Database.tlsRequire: .require
default: .prefer
get throws {
switch string(
forKey: .Database.tls,
default: .Database.tls
) {
case .Database.tlsOff: .off
case .Database.tlsPrefer: .prefer
case .Database.tlsRequire: .require
case let token: throw ConfigError.unknownDatabaseTLS(token)
}
}
}
}
// MARK: - ConfigError
/// A configuration value the executable refuses to boot with; ``description`` names the tokens the key accepts.
package enum ConfigError: Error, Equatable, CustomStringConvertible {
/// The `database.driver` key holds an unrecognized token.
case unknownDatabaseDriver(String)
/// The `database.tls` key holds an unrecognized token.
case unknownDatabaseTLS(String)
// MARK: Computed
package var description: String {
switch self {
case .unknownDatabaseDriver(let token):
"Unknown 'database.driver' value '\(token)': use '\(String.Database.driverInMemory)' or '\(String.Database.driverPostgres)'."
case .unknownDatabaseTLS(let token):
"Unknown 'database.tls' value '\(token)': use '\(String.Database.tlsOff)', '\(String.Database.tlsPrefer)', or '\(String.Database.tlsRequire)'."
}
}