Initial commit.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import Configuration
|
||||
import Hummingbird
|
||||
|
||||
/// The entry point of the website executable.
|
||||
///
|
||||
/// Loads the configuration, then either serves the website or — when the `database.migrate` flag is set — runs the registered migrations against the
|
||||
/// configured backend and exits.
|
||||
@main
|
||||
struct App {
|
||||
|
||||
/// Loads the configuration and runs the mode it selects.
|
||||
///
|
||||
/// The configuration is read from the providers in precedence order: command-line arguments first, then process environment variables, then a
|
||||
/// `.env.local` file when one is present, then a `.env` file when one is present, and finally the in-memory defaults.
|
||||
static func main() async throws {
|
||||
let reader = try await ConfigReader(
|
||||
providers: [
|
||||
CommandLineArgumentsProvider(),
|
||||
EnvironmentVariablesProvider(),
|
||||
EnvironmentVariablesProvider(
|
||||
environmentFilePath: ".env.local",
|
||||
allowMissing: true
|
||||
),
|
||||
EnvironmentVariablesProvider(
|
||||
environmentFilePath: ".env",
|
||||
allowMissing: true
|
||||
),
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.serverName: .HTTP.serverName
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
// Migrate-and-exit mode runs the registered migrations against the configured backend and returns, so a shared
|
||||
// database is migrated by a single deliberate invocation (`--database-migrate`) rather than by every booting
|
||||
// instance.
|
||||
guard !reader.migrate else {
|
||||
try await migration(
|
||||
reader: reader
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let app = try await application(
|
||||
reader: reader
|
||||
)
|
||||
|
||||
try await app.runService()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import Configuration
|
||||
import Hummingbird
|
||||
import HummingbirdCompression
|
||||
import Localization
|
||||
import Logging
|
||||
import Persistence
|
||||
import Infrastructure
|
||||
import WebsiteLibrary
|
||||
|
||||
/// Builds the website application.
|
||||
///
|
||||
/// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles
|
||||
/// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys.
|
||||
/// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
|
||||
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a PostgreSQL backend is migrated out of
|
||||
/// 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).
|
||||
func application(
|
||||
reader: ConfigReader
|
||||
) async throws -> some ApplicationProtocol {
|
||||
let languages = LanguageList()
|
||||
let logger = logger(
|
||||
serverName: reader.serverName,
|
||||
logLevel: reader.logLevel
|
||||
)
|
||||
|
||||
// A broken catalog degrades to serving raw localization keys rather than failing, so it is only ever visible to
|
||||
// visitors — surface it here instead.
|
||||
if languages.catalogState != .loaded {
|
||||
let isCatalogMissing = languages.catalogState == .missing
|
||||
|
||||
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
|
||||
}
|
||||
|
||||
let persistence = try Service(
|
||||
driver: reader.driver,
|
||||
logger: logger
|
||||
)
|
||||
let fluent = persistence()
|
||||
|
||||
let fingerprintAssets = FingerprintAssets(logger: logger)
|
||||
let prepareDB = PrepareDB()
|
||||
|
||||
await prepareDB(for: fluent)
|
||||
|
||||
var app = Application(
|
||||
router: router(
|
||||
staticFilesPath: reader.staticFilesPath,
|
||||
assetVersion: fingerprintAssets(reader.staticFilesPath),
|
||||
analytics: reader.analytics,
|
||||
cacheControl: reader.cacheControl,
|
||||
compressionMinResponseSize: reader.compressionMinResponseSize,
|
||||
rateLimit: reader.rateLimit,
|
||||
securityHeaders: reader.securityHeaders,
|
||||
logLevel: reader.logLevel,
|
||||
probe: Probe(fluent: fluent)
|
||||
),
|
||||
configuration: ApplicationConfiguration(
|
||||
reader: reader.scoped(to: "http")
|
||||
),
|
||||
logger: logger
|
||||
)
|
||||
|
||||
app.addServices(fluent)
|
||||
|
||||
// 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 {
|
||||
app.beforeServerStarts {
|
||||
try await fluent.migrate()
|
||||
}
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
/// Runs every registered migration against the configured backend, then exits.
|
||||
///
|
||||
/// This is the out-of-band migration path selected by the `database.migrate` flag: it builds the same driver the service would run against, applies the
|
||||
/// migrations, and shuts the database down — so a shared PostgreSQL database is migrated by a single deliberate invocation rather than by every
|
||||
/// booting instance.
|
||||
/// - Parameter reader: the configuration reader the values are read from.
|
||||
func migration(
|
||||
reader: ConfigReader
|
||||
) async throws {
|
||||
let logger = logger(
|
||||
serverName: reader.serverName,
|
||||
logLevel: reader.logLevel
|
||||
)
|
||||
let service = try Service(
|
||||
driver: reader.driver,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
let fluent = service()
|
||||
let prepareDB = PrepareDB()
|
||||
|
||||
await prepareDB(for: fluent)
|
||||
|
||||
do {
|
||||
try await fluent.migrate()
|
||||
}
|
||||
catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// The request context type the application serves its routes with.
|
||||
private typealias AppRequestContext = WebsiteRequestContext
|
||||
|
||||
/// Builds the application's logger.
|
||||
/// - Parameters:
|
||||
/// - serverName: the label applied to the logger.
|
||||
/// - logLevel: the minimum level the logger emits.
|
||||
/// - Returns: the configured logger.
|
||||
private func logger(
|
||||
serverName: String,
|
||||
logLevel: Logger.Level
|
||||
) -> Logger {
|
||||
var logger = Logger(label: serverName)
|
||||
|
||||
logger.logLevel = logLevel
|
||||
|
||||
return 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
|
||||
/// 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
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
/// - 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.
|
||||
/// - securityHeaders: the security headers applied to every response.
|
||||
/// - logLevel: the level the request-logging middleware logs at.
|
||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
||||
/// - Returns: the configured router.
|
||||
private func router(
|
||||
staticFilesPath: String,
|
||||
assetVersion: String?,
|
||||
analytics: Analytics?,
|
||||
cacheControl: CacheControl,
|
||||
compressionMinResponseSize: Int,
|
||||
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||
logLevel: Logger.Level,
|
||||
probe: Probe
|
||||
) -> Router<AppRequestContext> {
|
||||
// HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing with HEAD requests get
|
||||
// the page's status and headers instead of a 404.
|
||||
let router = Router(
|
||||
context: AppRequestContext.self,
|
||||
options: .autoGenerateHeadEndpoints
|
||||
)
|
||||
|
||||
router.addMiddleware {
|
||||
LogRequestsMiddleware(logLevel)
|
||||
SecurityHeadersMiddleware(
|
||||
configuration: securityHeaders
|
||||
)
|
||||
VaryMiddleware()
|
||||
ResponseCompressionMiddleware(
|
||||
minimumResponseSizeToCompress: compressionMinResponseSize
|
||||
)
|
||||
LocalizationMiddleware()
|
||||
NotFoundMiddleware(
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
FileMiddleware(
|
||||
staticFilesPath,
|
||||
cacheControl: cacheControl
|
||||
)
|
||||
}
|
||||
|
||||
router.addController {
|
||||
RootController<AppRequestContext>(
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
HealthController<AppRequestContext>(
|
||||
probe: probe
|
||||
)
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"index.greeting" : {
|
||||
"comment" : "The landing page's greeting paragraph.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Hello world! This is HTML5 Boilerplate."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"index.title" : {
|
||||
"comment" : "The landing page's document title.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Index page"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound.heading" : {
|
||||
"comment" : "The not-found page's main heading.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound.message" : {
|
||||
"comment" : "The not-found page's body text.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sorry, but the page you were trying to view does not exist."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound.title" : {
|
||||
"comment" : "The not-found page's document title.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Infrastructure
|
||||
|
||||
/// A static file shipped with the website service.
|
||||
///
|
||||
/// Each case identifies a file name stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's
|
||||
/// `FileMiddleware` middleware. A name can be available with more than one extension (see ``fileExtensions``), each resolving to its own file.
|
||||
enum StaticFile: Asset, CaseIterable {
|
||||
/// The `apple-touch-icon.png` icon.
|
||||
case appleTouchIcon
|
||||
/// The `favicon.ico` icon.
|
||||
case favicon
|
||||
/// The `icon.svg` icon.
|
||||
case icon
|
||||
/// The `icon-192.png` icon for the web manifest.
|
||||
case icon192
|
||||
/// The `icon-512.png` icon for the web manifest.
|
||||
case icon512
|
||||
/// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
|
||||
case index
|
||||
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
|
||||
case notFound
|
||||
/// The `robots.txt` crawler directives.
|
||||
case robots
|
||||
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
|
||||
case shared
|
||||
/// The `site.webmanifest` web application manifest.
|
||||
case site
|
||||
/// The `sitemap.xml` crawler sitemap.
|
||||
case sitemap
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension StaticFile {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The file extensions the file is available with.
|
||||
var fileExtensions: [AssetExtension] {
|
||||
switch self {
|
||||
case .appleTouchIcon,
|
||||
.icon192,
|
||||
.icon512: [.png]
|
||||
case .index,
|
||||
.notFound,
|
||||
.shared: [.css, .js]
|
||||
case .favicon: [.ico]
|
||||
case .icon: [.svg]
|
||||
case .robots: [.txt]
|
||||
case .site: [.webmanifest]
|
||||
case .sitemap: [.xml]
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's name, without extension.
|
||||
var fileName: String {
|
||||
switch self {
|
||||
case .appleTouchIcon: "apple-touch-icon"
|
||||
case .favicon: "favicon"
|
||||
case .icon: "icon"
|
||||
case .icon192: "icon-192"
|
||||
case .icon512: "icon-512"
|
||||
case .index: "index"
|
||||
case .notFound: "not-found"
|
||||
case .robots: "robots"
|
||||
case .shared: "shared"
|
||||
case .site: "site"
|
||||
case .sitemap: "sitemap"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
/// The site-wide defaults shared by every page of the website.
|
||||
extension Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The document language, derived from the page's locale and falling back to the default language.
|
||||
var lang: String {
|
||||
locale.language.languageCode?.identifier
|
||||
?? LanguageList().default
|
||||
}
|
||||
|
||||
/// The icon, manifest, and theme colour metadata shared by every page of the website.
|
||||
@HTMLBuilder
|
||||
var metadata: some HTML {
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href(StaticFile.favicon.urlPath(
|
||||
for: .ico,
|
||||
version: assetVersion
|
||||
)),
|
||||
.custom(
|
||||
name: "sizes",
|
||||
value: "any"
|
||||
)
|
||||
)
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href(StaticFile.icon.urlPath(
|
||||
for: .svg,
|
||||
version: assetVersion
|
||||
)),
|
||||
.custom(
|
||||
name: "type",
|
||||
value: "image/svg+xml"
|
||||
)
|
||||
)
|
||||
link(
|
||||
.rel("apple-touch-icon"),
|
||||
.href(StaticFile.appleTouchIcon.urlPath(
|
||||
for: .png,
|
||||
version: assetVersion
|
||||
))
|
||||
)
|
||||
link(
|
||||
.rel("manifest"),
|
||||
.href(StaticFile.site.urlPath(
|
||||
for: .webmanifest,
|
||||
version: assetVersion
|
||||
))
|
||||
)
|
||||
meta(
|
||||
.name("theme-color"),
|
||||
.content("#fafafa")
|
||||
)
|
||||
meta(
|
||||
.name("theme-color"),
|
||||
.content("#0c0710"),
|
||||
.custom(
|
||||
name: "media",
|
||||
value: "(prefers-color-scheme: dark)"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
/// The website's landing page, with its text localized to a given locale.
|
||||
struct IndexPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The analytics tracker embedded as a deferred script in the document head, or `nil` to omit it.
|
||||
let analytics: Analytics?
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
let assetVersion: String?
|
||||
|
||||
/// The locale the page content is localized to.
|
||||
let locale: Locale
|
||||
|
||||
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
|
||||
private let localize: Localize
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a landing page localized to the given locale.
|
||||
/// - Parameters:
|
||||
/// - locale: the locale the page content is localized to.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
locale: Locale,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = locale
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension IndexPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
p {
|
||||
localize("index.greeting", locale: locale)
|
||||
}
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.index, StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.index]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
localize("index.title", locale: locale)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
/// The HTML page rendered for a not-found response, with its text localized to a given locale.
|
||||
struct NotFoundPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The analytics tracker embedded as a deferred script in the document head, or `nil` to omit it.
|
||||
let analytics: Analytics?
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
let assetVersion: String?
|
||||
|
||||
/// The locale the page content is localized to.
|
||||
let locale: Locale
|
||||
|
||||
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
|
||||
private let localize: Localize
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found page localized to the given locale.
|
||||
/// - Parameters:
|
||||
/// - locale: the locale the page content is localized to.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
|
||||
/// default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
locale: Locale,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = locale
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Page
|
||||
|
||||
extension NotFoundPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
h1 {
|
||||
localize("notFound.heading", locale: locale)
|
||||
}
|
||||
p {
|
||||
localize("notFound.message", locale: locale)
|
||||
}
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.notFound, StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.notFound]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
localize("notFound.title", locale: locale)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
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 {
|
||||
|
||||
// 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?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a request context for the given source.
|
||||
/// - Parameter source: the source the context is initialized from.
|
||||
public init(
|
||||
source: Source,
|
||||
) {
|
||||
self.coreContext = .init(source: source)
|
||||
self.language = .empty
|
||||
self.remoteAddress = source.channel.remoteAddress
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension String {
|
||||
static let empty = ""
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Hummingbird
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Infrastructure
|
||||
|
||||
/// Serves the website's health-check routes.
|
||||
///
|
||||
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
|
||||
///
|
||||
/// ```swift
|
||||
/// router.addController {
|
||||
/// HealthController<AppRequestContext>(probe: probe)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports
|
||||
/// whether the service's database is reachable. The two are kept distinct so an orchestrator can restart on liveness failure but only withhold traffic on
|
||||
/// readiness failure.
|
||||
///
|
||||
/// - 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 HealthController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The probe consulted for the readiness check, or `nil` when only liveness is served.
|
||||
private let probe: Probe?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a health controller.
|
||||
/// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness route is served.
|
||||
public init(
|
||||
probe: Probe? = nil
|
||||
) {
|
||||
self.probe = probe
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension HealthController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.Health.check,
|
||||
use: check
|
||||
)
|
||||
|
||||
if probe != nil {
|
||||
routes.get(
|
||||
.Health.ready,
|
||||
use: ready
|
||||
)
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HealthController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles a request for the liveness check.
|
||||
///
|
||||
/// Returns a constant JSON body built directly per request — the payload is a tiny literal with no rendering step, so there is nothing to pre-render or
|
||||
/// cache. It reports only that the process is up, with no dependency check, so an orchestrator restarts the process only when the process itself is
|
||||
/// unresponsive.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: a `200 OK` response carrying the static JSON status payload.
|
||||
@Sendable
|
||||
func check(
|
||||
request: Request,
|
||||
context: some RequestContext
|
||||
) -> Response {
|
||||
json(
|
||||
status: .ok,
|
||||
payload: .Payload.live
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles a request for the readiness check.
|
||||
///
|
||||
/// Consults the `Probe` supplied at initialization and reports `200 OK` when the service's database is reachable, or `503 Service Unavailable`
|
||||
/// otherwise, so a load balancer withholds traffic from an instance that cannot yet serve it without restarting the process.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: a `200 OK` response when ready, or `503 Service Unavailable` when not.
|
||||
@Sendable
|
||||
func ready(
|
||||
request: Request,
|
||||
context: some RequestContext
|
||||
) async -> Response {
|
||||
guard await probe?() == true else {
|
||||
return json(
|
||||
status: .serviceUnavailable,
|
||||
payload: .Payload.unavailable
|
||||
)
|
||||
}
|
||||
|
||||
return json(
|
||||
status: .ok,
|
||||
payload: .Payload.ready
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a JSON response carrying the given status and payload.
|
||||
/// - Parameters:
|
||||
/// - status: the HTTP status of the response.
|
||||
/// - payload: the JSON body of the response.
|
||||
/// - Returns: the configured JSON response.
|
||||
func json(
|
||||
status: HTTPResponse.Status,
|
||||
payload: String
|
||||
) -> Response {
|
||||
Response(
|
||||
status: status,
|
||||
headers: [.contentType: "application/json"],
|
||||
body: .init(byteBuffer: .init(string: payload))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterPath+Constants
|
||||
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``HealthController`` route paths.
|
||||
enum Health {
|
||||
/// The path of the liveness endpoint.
|
||||
static let check: RouterPath = "/health"
|
||||
/// The path of the readiness endpoint.
|
||||
static let ready: RouterPath = "/health/ready"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
enum Payload {
|
||||
static let live = #"{"status":"ok"}"#
|
||||
static let ready = #"{"status":"ready"}"#
|
||||
static let unavailable = #"{"status":"unavailable"}"#
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
|
||||
/// Serves the website's root routes.
|
||||
///
|
||||
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
|
||||
///
|
||||
/// ```swift
|
||||
/// router.addController {
|
||||
/// RootController<AppRequestContext>()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - 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> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The landing page, rendered once per supported language and reused for every request.
|
||||
private let responses: LocalizedHTMLCollectionResponse
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a root controller.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.responses = .init(bundle: .module) {
|
||||
IndexPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouteController
|
||||
|
||||
extension RootController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.Root.index,
|
||||
use: index
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RootController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// 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.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: the cached ``IndexPage`` response for the context's language.
|
||||
@Sendable
|
||||
func index(
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
responses.response(
|
||||
for: context.language,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``RootController`` route paths.
|
||||
enum Root {
|
||||
/// The path of the landing page.
|
||||
static let index: RouterPath = "/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Configuration
|
||||
|
||||
extension AbsoluteConfigKey {
|
||||
/// A namespace for the analytics configuration keys, as absolute keys.
|
||||
public enum Analytics {
|
||||
/// The absolute configuration key for the analytics website identifier.
|
||||
public static let websiteID: AbsoluteConfigKey = .init(.Analytics.websiteID)
|
||||
/// The absolute configuration key for the comma-delimited domains the tracker reports from.
|
||||
public static let domains: AbsoluteConfigKey = .init(.Analytics.domains)
|
||||
/// The absolute configuration key for recorder mode, loading the session recorder script alongside the tracker.
|
||||
public static let recorder: AbsoluteConfigKey = .init(.Analytics.recorder)
|
||||
}
|
||||
/// A namespace for the static files cache configuration keys, as absolute keys.
|
||||
public enum Cache {
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to fingerprinted assets and fonts.
|
||||
public static let maxAgeAsset: AbsoluteConfigKey = .init(.Cache.maxAgeAsset)
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to unversioned text-based static files.
|
||||
public static let maxAgeText: AbsoluteConfigKey = .init(.Cache.maxAgeText)
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to image static files.
|
||||
public static let maxAgeImage: AbsoluteConfigKey = .init(.Cache.maxAgeImage)
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to all other static files.
|
||||
public static let maxAgeDefault: AbsoluteConfigKey = .init(.Cache.maxAgeDefault)
|
||||
}
|
||||
/// A namespace for the response compression configuration keys, as absolute keys.
|
||||
public enum Compression {
|
||||
/// The absolute configuration key for the minimum response body size, in bytes, before compression is applied.
|
||||
public static let minResponseSize: AbsoluteConfigKey = .init(.Compression.minResponseSize)
|
||||
}
|
||||
/// A namespace for the persistence configuration keys, as absolute keys.
|
||||
public enum Database {
|
||||
/// The absolute configuration key selecting migrate-and-exit mode.
|
||||
public static let migrate: AbsoluteConfigKey = .init(.Database.migrate)
|
||||
/// The absolute configuration key for the persistence driver.
|
||||
public static let driver: AbsoluteConfigKey = .init(.Database.driver)
|
||||
/// The absolute configuration key for the PostgreSQL host.
|
||||
public static let host: AbsoluteConfigKey = .init(.Database.host)
|
||||
/// The absolute configuration key for the PostgreSQL port.
|
||||
public static let port: AbsoluteConfigKey = .init(.Database.port)
|
||||
/// The absolute configuration key for the database name.
|
||||
public static let name: AbsoluteConfigKey = .init(.Database.name)
|
||||
/// The absolute configuration key for the database username.
|
||||
public static let username: AbsoluteConfigKey = .init(.Database.username)
|
||||
/// The absolute configuration key for the database password.
|
||||
public static let password: AbsoluteConfigKey = .init(.Database.password)
|
||||
/// The absolute configuration key for the TLS posture used when connecting.
|
||||
public static let tls: AbsoluteConfigKey = .init(.Database.tls)
|
||||
/// The absolute configuration key for the maximum pooled connections per event loop.
|
||||
public static let poolMaxPerEventLoop: AbsoluteConfigKey = .init(.Database.poolMaxPerEventLoop)
|
||||
/// The absolute configuration key for the longest wait, in seconds, for a pooled connection to become available.
|
||||
public static let poolTimeout: AbsoluteConfigKey = .init(.Database.poolTimeout)
|
||||
}
|
||||
/// A namespace for the HTTP server configuration keys, as absolute keys.
|
||||
public enum HTTP {
|
||||
/// The absolute configuration key for the host the server binds to.
|
||||
public static let host: AbsoluteConfigKey = .init(.HTTP.host)
|
||||
/// The absolute configuration key for the port the server listens on.
|
||||
public static let port: AbsoluteConfigKey = .init(.HTTP.port)
|
||||
/// The absolute configuration key for the server's name.
|
||||
public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName)
|
||||
}
|
||||
/// A namespace for the logging configuration keys, as absolute keys.
|
||||
public enum Log {
|
||||
/// The absolute configuration key for the minimum log level.
|
||||
public static let level: AbsoluteConfigKey = .init(.Log.level)
|
||||
}
|
||||
/// A namespace for the rate limit configuration keys, as absolute keys.
|
||||
public enum RateLimit {
|
||||
/// The absolute configuration key for the number of requests admitted per client per window.
|
||||
public static let limit: AbsoluteConfigKey = .init(.RateLimit.limit)
|
||||
/// The absolute configuration key for the window length, in seconds.
|
||||
public static let window: AbsoluteConfigKey = .init(.RateLimit.window)
|
||||
/// The absolute configuration key for keying clients by the first `X-Forwarded-For` entry.
|
||||
public static let trustForwardedFor: AbsoluteConfigKey = .init(.RateLimit.trustForwardedFor)
|
||||
}
|
||||
/// A namespace for the path configuration keys, as absolute keys.
|
||||
public enum Path {
|
||||
/// The absolute configuration key for the directory the static files are served from.
|
||||
public static let staticFiles: AbsoluteConfigKey = .init(.Path.staticFiles)
|
||||
}
|
||||
/// A namespace for the security headers configuration keys, as absolute keys.
|
||||
public enum Security {
|
||||
/// The absolute configuration key for the `Content-Security-Policy` header value.
|
||||
public static let contentSecurityPolicy: AbsoluteConfigKey = .init(.Security.contentSecurityPolicy)
|
||||
/// The absolute configuration key for the `X-Content-Type-Options` header value.
|
||||
public static let contentTypeOptions: AbsoluteConfigKey = .init(.Security.contentTypeOptions)
|
||||
/// The absolute configuration key for the `X-Frame-Options` header value.
|
||||
public static let frameOptions: AbsoluteConfigKey = .init(.Security.frameOptions)
|
||||
/// The absolute configuration key for the `Referrer-Policy` header value.
|
||||
public static let referrerPolicy: AbsoluteConfigKey = .init(.Security.referrerPolicy)
|
||||
/// The absolute configuration key for the `Permissions-Policy` header value.
|
||||
public static let permissionsPolicy: AbsoluteConfigKey = .init(.Security.permissionsPolicy)
|
||||
/// The absolute configuration key for the `Strict-Transport-Security` header value.
|
||||
public static let strictTransportSecurity: AbsoluteConfigKey = .init(.Security.strictTransportSecurity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Configuration
|
||||
|
||||
extension ConfigKey {
|
||||
/// A namespace for the analytics configuration keys.
|
||||
public enum Analytics {
|
||||
/// The configuration key for the analytics website identifier (cleared to disable analytics).
|
||||
public static let websiteID: ConfigKey = "analytics.websiteID"
|
||||
/// The configuration key for the comma-delimited domains the tracker reports from.
|
||||
public static let domains: ConfigKey = "analytics.domains"
|
||||
/// The configuration key for recorder mode, loading the session recorder script alongside the tracker (set to `false` to disable).
|
||||
public static let recorder: ConfigKey = "analytics.recorder"
|
||||
}
|
||||
/// A namespace for the static files cache configuration keys.
|
||||
public enum Cache {
|
||||
/// The configuration key for the max-age, in seconds, applied to fingerprinted assets (CSS, JavaScript) and fonts.
|
||||
public static let maxAgeAsset: ConfigKey = "cache.maxAge.asset"
|
||||
/// The configuration key for the max-age, in seconds, applied to unversioned text-based static files (e.g. plain text).
|
||||
public static let maxAgeText: ConfigKey = "cache.maxAge.text"
|
||||
/// The configuration key for the max-age, in seconds, applied to image static files (ICO, PNG, SVG).
|
||||
public static let maxAgeImage: ConfigKey = "cache.maxAge.image"
|
||||
/// The configuration key for the max-age, in seconds, applied to all other static files (e.g. the web manifest).
|
||||
public static let maxAgeDefault: ConfigKey = "cache.maxAge.default"
|
||||
}
|
||||
/// A namespace for the response compression configuration keys.
|
||||
public enum Compression {
|
||||
/// The configuration key for the minimum response body size, in bytes, before compression is applied.
|
||||
public static let minResponseSize: ConfigKey = "compression.minimumResponseSize"
|
||||
}
|
||||
/// A namespace for the persistence configuration keys.
|
||||
public enum Database {
|
||||
/// The configuration key selecting migrate-and-exit mode (run migrations, then exit) instead of serving.
|
||||
public static let migrate: ConfigKey = "database.migrate"
|
||||
/// The configuration key for the persistence driver (`inMemory` or `postgres`).
|
||||
public static let driver: ConfigKey = "database.driver"
|
||||
/// The configuration key for the PostgreSQL host.
|
||||
public static let host: ConfigKey = "database.host"
|
||||
/// The configuration key for the PostgreSQL port.
|
||||
public static let port: ConfigKey = "database.port"
|
||||
/// The configuration key for the database name.
|
||||
public static let name: ConfigKey = "database.name"
|
||||
/// The configuration key for the database username.
|
||||
public static let username: ConfigKey = "database.username"
|
||||
/// The configuration key for the database password.
|
||||
public static let password: ConfigKey = "database.password"
|
||||
/// The configuration key for the TLS posture used when connecting (`off`, `prefer`, or `require`).
|
||||
public static let tls: ConfigKey = "database.tls"
|
||||
/// The configuration key for the maximum pooled connections per event loop.
|
||||
public static let poolMaxPerEventLoop: ConfigKey = "database.pool.maxPerEventLoop"
|
||||
/// The configuration key for the longest wait, in seconds, for a pooled connection to become available.
|
||||
public static let poolTimeout: ConfigKey = "database.pool.timeout"
|
||||
}
|
||||
/// A namespace for the HTTP server configuration keys.
|
||||
public enum HTTP {
|
||||
/// The configuration key for the host the server binds to.
|
||||
public static let host: ConfigKey = "http.host"
|
||||
/// The configuration key for the port the server listens on.
|
||||
public static let port: ConfigKey = "http.port"
|
||||
/// The configuration key for the server's name.
|
||||
public static let serverName: ConfigKey = "http.serverName"
|
||||
}
|
||||
/// A namespace for the logging configuration keys.
|
||||
public enum Log {
|
||||
/// The configuration key for the minimum log level.
|
||||
public static let level: ConfigKey = "log.level"
|
||||
}
|
||||
/// A namespace for the rate limit configuration keys.
|
||||
public enum RateLimit {
|
||||
/// The configuration key for the number of requests admitted per client per window.
|
||||
public static let limit: ConfigKey = "rateLimit.limit"
|
||||
/// The configuration key for the window length, in seconds.
|
||||
public static let window: ConfigKey = "rateLimit.window"
|
||||
/// The configuration key for keying clients by the first `X-Forwarded-For` entry (enable only behind a trusted proxy).
|
||||
public static let trustForwardedFor: ConfigKey = "rateLimit.trustForwardedFor"
|
||||
}
|
||||
/// A namespace for the path configuration keys.
|
||||
public enum Path {
|
||||
/// The configuration key for the directory the static files are served from.
|
||||
public static let staticFiles: ConfigKey = "path.staticFiles"
|
||||
}
|
||||
/// A namespace for the security headers configuration keys.
|
||||
public enum Security {
|
||||
/// The configuration key for the `Content-Security-Policy` header value.
|
||||
public static let contentSecurityPolicy: ConfigKey = "security.contentSecurityPolicy"
|
||||
/// The configuration key for the `X-Content-Type-Options` header value.
|
||||
public static let contentTypeOptions: ConfigKey = "security.contentTypeOptions"
|
||||
/// The configuration key for the `X-Frame-Options` header value.
|
||||
public static let frameOptions: ConfigKey = "security.frameOptions"
|
||||
/// The configuration key for the `Referrer-Policy` header value.
|
||||
public static let referrerPolicy: ConfigKey = "security.referrerPolicy"
|
||||
/// The configuration key for the `Permissions-Policy` header value.
|
||||
public static let permissionsPolicy: ConfigKey = "security.permissionsPolicy"
|
||||
/// The configuration key for the `Strict-Transport-Security` header value (omitted when unset).
|
||||
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Configuration
|
||||
|
||||
extension ConfigValue {
|
||||
/// A namespace for the HTTP server's default configuration values.
|
||||
public enum HTTP {
|
||||
/// The default server name.
|
||||
public static let serverName: ConfigValue = .init(stringLiteral: .Server.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
extension Int {
|
||||
/// A namespace for the cache's default configuration values.
|
||||
public enum Cache {
|
||||
/// The default max-age, in seconds, applied to fingerprinted assets and fonts (1 year).
|
||||
public static let maxAgeAsset = 31_536_000
|
||||
/// The default max-age, in seconds, applied to unversioned text-based static files (1 hour).
|
||||
public static let maxAgeText = 3_600
|
||||
/// The default max-age, in seconds, applied to image static files (1 week).
|
||||
public static let maxAgeImage = 604_800
|
||||
/// The default max-age, in seconds, applied to all other static files (1 day).
|
||||
public static let maxAgeDefault = 86_400
|
||||
}
|
||||
/// A namespace for the response compression's default configuration values.
|
||||
public enum Compression {
|
||||
/// The default minimum response body size, in bytes, before compression is applied (1 KB).
|
||||
public static let minResponseSize = 1_024
|
||||
}
|
||||
/// A namespace for the persistence's default configuration values.
|
||||
public enum Database {
|
||||
/// The default PostgreSQL port.
|
||||
public static let port = 5_432
|
||||
/// The default maximum pooled connections per event loop.
|
||||
public static let poolMaxPerEventLoop = 4
|
||||
/// The default longest wait, in seconds, for a pooled connection to become available (the driver's own default).
|
||||
public static let poolTimeout = 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
import Localization
|
||||
|
||||
public extension LanguageList {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a language list backed by the module's String Catalog.
|
||||
init() {
|
||||
self.init(bundle: .module)
|
||||
}
|
||||
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
|
||||
public extension NotFoundMiddleware {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the error page embeds, or `nil` (the default) to omit it.
|
||||
init(
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.init(bundle: .module) {
|
||||
NotFoundPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
extension String {
|
||||
/// A namespace for the analytics default configuration values.
|
||||
///
|
||||
/// Analytics ships **off**: ``websiteID`` is empty, so the pages embed no tracker until a deployment sets `analytics.websiteID`. Point
|
||||
/// ``origin`` at your own instance before enabling it — the placeholder is an [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606)
|
||||
/// reserved domain, so an unconfigured copy can never report to someone else's server.
|
||||
public enum Analytics {
|
||||
/// The origin the analytics scripts are loaded from and their beacons are sent to (scheme and host, no trailing slash).
|
||||
///
|
||||
/// Single-sourced here: both ``scriptURL`` and the session recorder script the pages embed in recorder mode derive from this
|
||||
/// constant. It is deliberately not a configuration key — the `Content-Security-Policy` must allow the same origin, and a value that
|
||||
/// can drift at runtime would silently break the tracker it is supposed to permit.
|
||||
public static let origin = "https://analytics.example.com"
|
||||
/// The URL the analytics tracker script is loaded from.
|
||||
public static let scriptURL = "\(origin)/script"
|
||||
/// The default analytics website identifier the tracker reports as: empty, which omits the tracker entirely.
|
||||
public static let websiteID = ""
|
||||
/// The default comma-delimited domains the tracker reports from: empty, which reports from every host.
|
||||
///
|
||||
/// Once set, keep it paired with the host the pages are served at — a deployment that serves from another host without matching
|
||||
/// `analytics.domains` reports from a host it no longer serves, so analytics silently records nothing.
|
||||
public static let domains = ""
|
||||
}
|
||||
/// A namespace for the persistence's default configuration values and recognized tokens.
|
||||
public enum Database {
|
||||
/// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
|
||||
public static let driver = "inMemory"
|
||||
/// The driver token selecting the PostgreSQL backend.
|
||||
public static let driverPostgres = "postgres"
|
||||
/// The default PostgreSQL host.
|
||||
public static let host = "localhost"
|
||||
/// The default database name.
|
||||
public static let name = "ccn"
|
||||
/// The default database username.
|
||||
public static let username = "ccn"
|
||||
/// The default TLS posture token.
|
||||
public static let tls = "prefer"
|
||||
/// The TLS token disabling TLS.
|
||||
public static let tlsOff = "off"
|
||||
/// The TLS token requiring TLS.
|
||||
public static let tlsRequire = "require"
|
||||
}
|
||||
/// A namespace for well-known path string constants.
|
||||
public enum Path {
|
||||
/// The directory, relative to the working directory, that the website's static files are served from.
|
||||
public static let staticResources = "Resources/Static"
|
||||
}
|
||||
/// A namespace for the server string constants.
|
||||
public enum Server {
|
||||
/// The website server's name.
|
||||
public static let name = "CCNWebsite"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user