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 other driver value falls back to the in-memory database. 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 )) ) ) default: return .inMemory } } /// 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.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.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.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: `off` and `require` map to their postures, and any /// other value falls back to `prefer`. var tls: TLS { switch string( forKey: .Database.tls, default: .Database.tls ) { case .Database.tlsOff: .off case .Database.tlsRequire: .require default: .prefer } } }