Files
ccn/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift
T
javier 65b62681eb 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>
2026-09-04 13:40:35 +00:00

326 lines
13 KiB
Swift

import Configuration
import Hummingbird
import Infrastructure
import Logging
import Persistence
import WebsiteLibrary
package extension ConfigReader {
// MARK: Type aliases
/// The request context type the application serves its routes with; the security headers configuration is generic over it.
typealias AppRequestContext = WebsiteRequestContext
// MARK: Computed
/// The analytics tracker both pages embed, built from the `analytics.*` keys, or `nil` when `analytics.websiteID` resolves empty.
///
/// The identifier is empty by default, so the template serves no tracker at all until a deployment sets `analytics.websiteID` — and
/// clearing it again disables analytics entirely.
///
/// The script URL is not configurable: its origin is single-sourced in `String.Analytics`, so the tracker tag and the
/// `Content-Security-Policy` that must allow it derive from one constant and cannot drift apart. Point that constant at your own
/// instance, and extend `security.contentSecurityPolicy` to allow it, before enabling analytics.
///
/// The `analytics.domains` filter must name the host the pages are served from; it is empty by default, which reports from every host.
/// Set it to a host the deployment does not serve and the tracker silently records nothing.
///
/// Recorder mode is off by default — session recording is the most invasive thing the tracker does, so a deployment opts into it
/// deliberately with the `analytics.recorder` flag. When on, the pages embed the session recorder script alongside the tracker; it loads
/// from the same origin, so the `Content-Security-Policy` needs no extra allowance.
var analytics: Analytics? {
let websiteID = string(
forKey: .Analytics.websiteID,
default: .Analytics.websiteID
)
guard !websiteID.isEmpty else {
return nil
}
return .init(
scriptURL: .Analytics.scriptURL,
websiteID: websiteID,
domains: string(
forKey: .Analytics.domains,
default: .Analytics.domains
),
recorder: bool(
forKey: .Analytics.recorder,
default: false
)
)
}
/// 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, 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,
default: .Cache.maxAgeAsset
)
let maxAgeDefault = int(
forKey: .Cache.maxAgeDefault,
default: .Cache.maxAgeDefault
)
let maxAgeImage = int(
forKey: .Cache.maxAgeImage,
default: .Cache.maxAgeImage
)
let maxAgeText = int(
forKey: .Cache.maxAgeText,
default: .Cache.maxAgeText
)
return .init([
(.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)]),
])
}
/// The minimum response body size, in bytes, before a response is compressed — read from the `compression.minimumResponseSize` key.
var compressionMinResponseSize: Int {
int(
forKey: .Compression.minResponseSize,
default: .Compression.minResponseSize
)
}
/// The persistence backend the service runs against, derived from the `database.*` keys.
///
/// 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 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 {
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
))
)
)
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.
var logLevel: Logger.Level {
string(
forKey: .Log.level,
as: Logger.Level.self,
default: .info
)
}
/// Whether the executable runs in migrate-and-exit mode instead of serving, read from the `database.migrate` flag; off by default.
var migrate: Bool {
bool(
forKey: .Database.migrate,
default: false
)
}
/// 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
/// otherwise.
var rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration {
.init(
limit: int(
forKey: .RateLimit.limit,
default: .RateLimit.limit
),
window: .seconds(int(
forKey: .RateLimit.window,
default: .RateLimit.window
)),
trustForwardedFor: bool(
forKey: .RateLimit.trustForwardedFor,
default: false
)
)
}
/// The security headers middleware configuration, built from the `security.*` keys.
///
/// Every header value has a default except `Strict-Transport-Security`, which is only sent when `security.strictTransportSecurity`
/// is set — the header is a commitment browsers cache, so it must be opted into for deployments actually served over HTTPS.
var securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration {
.init(
contentSecurityPolicy: string(
forKey: .Security.contentSecurityPolicy,
default: .Security.contentSecurityPolicy
),
contentTypeOptions: string(
forKey: .Security.contentTypeOptions,
default: .Security.contentTypeOptions
),
frameOptions: string(
forKey: .Security.frameOptions,
default: .Security.frameOptions
),
referrerPolicy: string(
forKey: .Security.referrerPolicy,
default: .Security.referrerPolicy
),
permissionsPolicy: string(
forKey: .Security.permissionsPolicy,
default: .Security.permissionsPolicy
),
strictTransportSecurity: string(
forKey: .Security.strictTransportSecurity
)
)
}
/// The name the server reports in its `Server` response header, read from the `http.serverName` key.
var serverName: String {
string(
forKey: .HTTP.serverName,
default: .Server.name
)
}
/// 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(
forKey: .Path.staticFiles,
default: .Path.staticResources
)
}
}
// MARK: - Helpers
private extension ConfigReader {
// MARK: Properties
/// 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 {
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)'."
}
}
}