Files
ccn/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift
T

257 lines
9.8 KiB
Swift
Raw Normal View History

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 the landing page embeds, built from the `analytics.*` keys, or `nil` when `analytics.websiteID` resolves
/// empty — a deployment disables analytics entirely by clearing the identifier.
///
/// 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.
///
/// The `analytics.domains` filter must name the host the pages are served from — i.e. the host of ``siteOrigin``. The two keys are
/// independent, so a deployment that overrides `site.origin` without matching `analytics.domains` reports from a host it no longer
/// serves and records nothing; change them together.
///
/// Recorder mode is on by default — the pages embed the session recorder script alongside the tracker — and the `analytics.recorder`
/// flag turns it off for a deployment. The recorder loads from the same origin as the tracker, 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: true
)
)
}
/// 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.
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]),
(.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 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 applied to the subscription endpoint, built from the `rateLimit.*` keys.
///
/// `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 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
}
}
}