Merge branch 'setup'

This commit is contained in:
2026-08-02 02:34:42 +02:00
135 changed files with 5735 additions and 976 deletions
+8 -5
View File
@@ -1,6 +1,5 @@
import Configuration
import Hummingbird
import Logging
/// The entry point of the website executable.
///
@@ -12,12 +11,16 @@ 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` file when one is present, and finally the in-memory defaults (currently just the server name).
/// `.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
@@ -28,9 +31,9 @@ struct App {
]
)
// 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.
// 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
@@ -1,14 +1,17 @@
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 also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
/// 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 MySQL/MariaDB 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.
@@ -16,15 +19,27 @@ import WebsiteLibrary
func application(
reader: ConfigReader
) async -> 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 = Service(
driver: reader.driver,
logger: logger
)
let fluent = persistence()
let fingerprintAssets = FingerprintAssets(logger: logger)
let prepareDB = PrepareDB()
await prepareDB(for: fluent)
@@ -32,8 +47,10 @@ func application(
var app = Application(
router: router(
staticFilesPath: reader.staticFilesPath,
assetVersion: fingerprintAssets(reader.staticFilesPath),
cacheControl: reader.cacheControl,
compressionMinResponseSize: reader.compressionMinResponseSize,
rateLimit: reader.rateLimit,
securityHeaders: reader.securityHeaders,
logLevel: reader.logLevel,
probe: Probe(fluent: fluent)
@@ -46,8 +63,8 @@ func application(
app.addServices(fluent)
// The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB
// backend is left untouched here: a shared database is migrated out of band to avoid multi-instance races.
// The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB 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()
@@ -116,50 +133,67 @@ 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
/// 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.
/// 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.
/// - 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 subscription endpoint.
/// - 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?,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
logLevel: Logger.Level,
probe: Probe
) -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
// 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()
NotFoundMiddleware(
assetVersion: assetVersion
)
FileMiddleware(
staticFilesPath,
cacheControl: cacheControl
)
}
router.addRoutes {
RootController<AppRequestContext>().routes
HealthController<AppRequestContext>(probe: probe).routes
router.addController {
RootController<AppRequestContext>(
assetVersion: assetVersion
)
HealthController<AppRequestContext>(
probe: probe
)
}
return router
@@ -1,5 +1,6 @@
import Configuration
import Hummingbird
import Infrastructure
import Logging
import Persistence
import WebsiteLibrary
@@ -15,9 +16,16 @@ package extension ConfigReader {
/// The `Cache-Control` policy applied to static files, grouped by media type.
///
/// The max-ages are read from the `cache.maxAge.text`, `cache.maxAge.image`, and `cache.maxAge.default` keys. Text files (CSS,
/// JavaScript, plain text) additionally require revalidation once stale; images and everything else are served public with their max-age alone.
/// 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
@@ -32,6 +40,9 @@ package extension ConfigReader {
)
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)]),
@@ -110,6 +121,28 @@ package extension ConfigReader {
)
}
/// 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`
@@ -1,39 +1,6 @@
{
"sourceLanguage" : "en",
"strings" : {
"error.heading" : {
"comment" : "The not-found page's main heading.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Page Not Found"
}
}
}
},
"error.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."
}
}
}
},
"error.title" : {
"comment" : "The not-found page's document title.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Page Not Found"
}
}
}
},
"index.greeting" : {
"comment" : "The landing page's greeting paragraph.",
"localizations" : {
@@ -55,6 +22,39 @@
}
}
}
},
"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"
@@ -1,46 +1,32 @@
import Infrastructure
/// A static file shipped with the website service.
///
/// Each case identifies a file stored under the static files root (the `Resources/Static`
/// directory) and served by Hummingbird's `FileMiddleware` middleware.
enum StaticFile: CaseIterable, Sendable {
/// The `js/app.js` script.
case appJS
/// The `css/error.css` stylesheet for the not-found page.
case errorCSS
/// 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 faviconICO
/// The `icon.png` icon.
case iconPNG
case favicon
/// The `icon.svg` icon.
case iconSVG
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 robotsTXT
case robots
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
case shared
/// The `site.webmanifest` web application manifest.
case siteWebmanifest
/// The `css/style.css` stylesheet.
case styleCSS
}
// MARK: - Enumerations
extension StaticFile {
/// A file extension used by a ``StaticFile``.
enum Extension: String, Sendable {
/// A Cascading Style Sheets file.
case css
/// A JavaScript file.
case js
/// A Portable Network Graphics image.
case png
/// A Windows icon image.
case ico
/// A Scalable Vector Graphics image.
case svg
/// A plain text file.
case txt
/// A web application manifest file.
case webmanifest
}
case site
/// The `sitemap.xml` crawler sitemap.
case sitemap
}
// MARK: - Extensions
@@ -49,88 +35,37 @@ extension StaticFile {
// MARK: Computed
/// The file's content type.
var contentType: String {
switch fileExtension {
case .css: "text/css"
case .js: "text/javascript"
case .png: "image/png"
case .ico: "image/vnd.microsoft.icon"
case .svg: "image/svg+xml"
case .txt: "text/plain"
case .webmanifest: "application/manifest+json"
}
}
/// The file's extension.
var fileExtension: Extension {
/// The file extensions the file is available with.
var fileExtensions: [AssetExtension] {
switch self {
case .errorCSS,
.styleCSS: .css
case .appJS: .js
case .faviconICO: .ico
case .iconPNG: .png
case .iconSVG: .svg
case .robotsTXT: .txt
case .siteWebmanifest: .webmanifest
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 .appJS: "app"
case .errorCSS: "error"
case .faviconICO: "favicon"
case .iconPNG,
.iconSVG: "icon"
case .robotsTXT: "robots"
case .siteWebmanifest: "site"
case .styleCSS: "style"
}
}
/// The path relative to the static files root (e.g. `"css/style.css"`).
///
/// This also matches the URL path the file is served at by `FileMiddleware`.
var relativePath: String {
let file = "\(fileName).\(fileExtension.rawValue)"
return subdirectory
.map { "\($0)/\(file)" } ?? file
}
// MARK: Methods
/// Resolves the file's path against the given base directory.
///
/// - Parameter basePath: the directory the static files are served from.
/// - Returns: the path to the file, relative to the `basePath` path.
func path(
relativeTo basePath: String
) -> String {
guard !basePath.isEmpty else {
return relativePath
}
return "\(basePath)/\(relativePath)"
}
}
// MARK: - Helpers
private extension StaticFile {
// MARK: Computed
/// The sub-directory within the static root that holds the file, if any.
var subdirectory: String? {
switch self {
case .appJS: "js"
case .errorCSS,
.styleCSS: "css"
default: nil
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)"
)
)
}
}
@@ -1,63 +0,0 @@
import Elementary
import Foundation
import Localization
/// The HTML page rendered for a not-found response, with its text localized to a given locale.
struct ErrorPage: HTMLDocument, Sendable {
// MARK: Properties
/// The locale the page content is localized to.
private 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.
/// - Parameter locale: the locale the page content is localized to.
init(
locale: Locale
) {
self.locale = locale
self.localize = .init(bundle: .module)
}
// MARK: Document
/// The page's content: a localized heading and explanatory message.
var body: some HTML {
h1 {
localize("error.heading", locale: locale)
}
p {
localize("error.message", locale: locale)
}
}
/// The metadata and stylesheet link placed in the document head.
var head: some HTML {
meta(.charset(.utf8))
meta(
.name(.viewport),
.content("width=device-width, initial-scale=1")
)
link(
.rel(.stylesheet),
.href("/css/error.css")
)
}
/// The document language, derived from the page's locale and falling back to the default language.
var lang: String {
locale.language.languageCode?.identifier
?? LanguageList(bundle: .module).default
}
/// The localized document title.
var title: String {
localize("error.title", locale: locale)
}
}
@@ -1,14 +1,18 @@
import Elementary
import Foundation
import Infrastructure
import Localization
/// The website's landing page, with its text localized to a given locale.
struct IndexPage: HTMLDocument, Sendable {
struct IndexPage {
// MARK: Properties
/// 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.
private let locale: Locale
let locale: Locale
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
private let localize: Localize
@@ -16,72 +20,40 @@ struct IndexPage: HTMLDocument, Sendable {
// MARK: Initializers
/// Creates a landing page localized to the given locale.
/// - Parameter locale: the locale the page content is localized to.
/// - 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.
init(
locale: Locale
locale: Locale,
assetVersion: String? = nil
) {
self.assetVersion = assetVersion
self.locale = locale
self.localize = .init(bundle: .module)
}
// MARK: Document
}
/// The page's content: a localized greeting followed by the app script.
var body: some HTML {
// MARK: - Page
extension IndexPage: Page {
// MARK: Properties
var content: some HTML {
p {
localize("index.greeting", locale: locale)
}
script(.src("/js/app.js")) {}
}
/// The metadata, stylesheet, icon, and manifest links placed in the document head.
var head: some HTML {
meta(.charset(.utf8))
meta(
.name(.viewport),
.content("width=device-width, initial-scale=1")
)
link(
.rel(.stylesheet),
.href("/css/style.css")
)
link(
.rel(.icon),
.href("/favicon.ico"),
.custom(
name: "sizes",
value: "any"
)
)
link(
.rel(.icon),
.href("/icon.svg"),
.custom(
name: "type",
value: "image/svg+xml"
)
)
link(
.rel("apple-touch-icon"),
.href("/icon.png")
)
link(
.rel("manifest"),
.href("/site.webmanifest")
)
meta(
.name("theme-color"),
.content("#fafafa")
)
var scripts: [any Asset] {
[StaticFile.index, StaticFile.shared]
}
/// The document language, derived from the page's locale and falling back to the default language.
var lang: String {
locale.language.languageCode?.identifier
?? LanguageList(bundle: .module).default
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.index]
}
/// The localized document title.
var title: String {
localize("index.title", locale: locale)
}
@@ -0,0 +1,64 @@
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 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.
init(
locale: Locale,
assetVersion: String? = nil
) {
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)
}
}
@@ -1,72 +0,0 @@
import Elementary
import HTTPTypes
import Hummingbird
import NIOCore
/// A pre-rendered HTTP response for a fully static HTML page.
///
/// The document is rendered to bytes once, at initialization, and every ``response()`` reuses those
/// bytes along with a fixed status and precomputed headers instead of re-rendering. This suits
/// pages whose markup never changes between requests, such as the landing page and the not-found
/// page, avoiding a per-request Elementary render on hot paths.
///
/// ``LocalizedHTMLCollectionResponse`` builds on this type, caching one instance per supported language.
///
/// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the
/// response-compression middleware downstream treats it exactly as it would a freshly rendered page.
struct CachedHTMLResponse: Sendable {
// MARK: Properties
/// The page rendered to bytes once.
private let buffer: ByteBuffer
/// The headers applied to every response, precomputed once.
private let headers: HTTPFields
/// The status applied to every response.
private let status: HTTPResponse.Status
// MARK: Initializers
/// Renders the given document to bytes once.
/// - Parameters:
/// - status: the status applied to every response. Defaults to `.ok`.
/// - additionalHeaders: extra headers merged onto every response, alongside the content type.
/// Used to carry per-language signals such as `Content-Language` and `Vary`.
/// - document: the static HTML document to render and cache.
init(
status: HTTPResponse.Status = .ok,
additionalHeaders: HTTPFields = [:],
document: some HTMLDocument
) {
var headers: HTTPFields = [
.contentType: "text/html; charset=utf-8"
]
for field in additionalHeaders {
headers[field.name] = field.value
}
self.status = status
self.headers = headers
self.buffer = .init(string: document.render())
}
// MARK: Methods
/// Builds a response from the cached, pre-rendered bytes.
///
/// Mirrors the `text/html; charset=utf-8` content type `HTMLResponse` produces, and leaves the
/// `Content-Length` unset so small pages remain eligible for compression.
/// - Returns: the response carrying the cached HTML body.
func response() -> Response {
Response(
status: status,
headers: headers,
body: .init { [buffer] writer in
try await writer.write(buffer)
try await writer.finish(nil)
}
)
}
}
@@ -1,65 +0,0 @@
import Elementary
import Foundation
import HTTPTypes
import Hummingbird
import Localization
/// A per-language collection of pre-rendered HTML responses.
///
/// At initialization it renders the document once for each language the bundle's ``LanguageList``
/// reports and caches the bytes, mirroring ``CachedHTMLResponse``'s render-once model but keyed by
/// language. Each cached response carries a `Content-Language` header and `Vary: Accept-Language`, so
/// shared caches key on the negotiated language instead of serving one language to everyone.
struct LocalizedHTMLCollectionResponse: Sendable {
// MARK: Properties
/// The supported languages and default language, derived from the module's String Catalog.
private let list: LanguageList
/// The pre-rendered responses, keyed by language identifier.
private let responses: [String: CachedHTMLResponse]
// MARK: Initializers
/// Renders the document once per supported language.
/// - Parameters:
/// - status: the status applied to every response. Defaults to `.ok`.
/// - document: builds the document to render for a given locale.
init<Document: HTMLDocument>(
status: HTTPResponse.Status = .ok,
document: (Locale) -> Document
) {
self.list = .init(bundle: .module)
self.responses = list.all
.reduce(into: [:]) { responses, language in
responses[language] = CachedHTMLResponse(
status: status,
additionalHeaders: [
.contentLanguage: language,
.vary: "Accept-Language",
],
document: document(.init(identifier: language))
)
}
}
// MARK: Methods
/// Builds the response for the given language, falling back to the default language.
/// - Parameter language: the negotiated language identifier.
/// - Returns: the cached response for the language, the default language's response when the
/// language is unavailable, or a `500 Internal Server Error` if neither is cached.
func response(
for language: String
) -> Response {
guard
let response = responses[language] ?? responses[list.default]
else {
return .init(status: .internalServerError)
}
return response.response()
}
}
@@ -1,26 +1,12 @@
import Hummingbird
/// A request context that carries the language negotiated for the request.
///
/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language`
/// header and stores it here, so downstream controllers and middleware can serve the matching
/// localization without re-reading the header.
public protocol LocalizedRequestContext: RequestContext {
// MARK: Properties
/// The language identifier negotiated for the request.
var language: String { get set }
}
// MARK: - Context
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.
public struct WebsiteRequestContext: LocalizedRequestContext {
/// 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
@@ -28,6 +14,8 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
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
@@ -38,6 +26,7 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
) {
self.coreContext = .init(source: source)
self.language = .empty
self.remoteAddress = source.channel.remoteAddress
}
}
@@ -1,13 +1,16 @@
import Hummingbird
import NIOCore
import Persistence
import Infrastructure
/// Serves the website's health-check routes.
///
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router (or a sub-group) by the application that composes it:
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
///
/// ```swift
/// router.addRoutes(HealthController<AppRequestContext>(probe: probe).routes)
/// 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
@@ -15,7 +18,7 @@ import Persistence
/// 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>: Sendable {
public struct HealthController<Context: RequestContext> {
// MARK: Properties
@@ -25,21 +28,21 @@ public struct HealthController<Context: RequestContext>: Sendable {
// MARK: Initializers
/// Creates a health controller.
/// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness
/// route is served.
/// - 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: Computed
}
// MARK: - RouterController
extension HealthController: RouterController {
// MARK: Properties
/// The routes served by the controller.
///
/// Serves a `GET` request for the liveness path (`/health`) with a static JSON status payload, and
/// when a `Probe` was supplied a `GET` request for the readiness path (`/health/ready`)
/// that consults the probe.
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
@@ -68,9 +71,8 @@ private extension HealthController {
/// 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
/// 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.
@@ -89,9 +91,8 @@ private extension HealthController {
/// 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.
/// 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.
@@ -1,17 +1,19 @@
import Foundation
import Hummingbird
import Infrastructure
/// Serves the website's root routes.
///
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router
/// (or a sub-group) by the application that composes it:
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
///
/// ```swift
/// router.addRoutes(RootController<AppRequestContext>().routes)
/// 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>: Sendable {
/// - 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
@@ -21,16 +23,26 @@ public struct RootController<Context: LocalizedRequestContext>: Sendable {
// MARK: Initializers
/// Creates a root controller.
public init() {
self.responses = .init { IndexPage(locale: $0) }
/// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
public init(
assetVersion: String? = nil
) {
self.responses = .init(bundle: .module) {
IndexPage(
locale: $0,
assetVersion: assetVersion
)
}
}
// MARK: Computed
}
// MARK: - RouteController
extension RootController: RouterController {
// MARK: Properties
/// The routes served by the controller.
///
/// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage`` in the
/// language negotiated for the request.
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
@@ -52,8 +64,7 @@ private extension RootController {
/// 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.
/// 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.
@@ -63,7 +74,10 @@ private extension RootController {
request: Request,
context: Context
) -> Response {
responses.response(for: context.language)
responses.response(
for: context.language,
request: request
)
}
}
@@ -3,7 +3,9 @@ import Configuration
extension AbsoluteConfigKey {
/// 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 text-based static files.
/// 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)
@@ -50,6 +52,15 @@ extension AbsoluteConfigKey {
/// 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.
@@ -3,7 +3,9 @@ import Configuration
extension ConfigKey {
/// A namespace for the static files cache configuration keys.
public enum Cache {
/// The configuration key for the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text).
/// 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"
@@ -50,6 +52,15 @@ extension ConfigKey {
/// 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.
@@ -1,10 +0,0 @@
import HTTPTypes
extension HTTPField.Name {
/// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`).
static let permissionsPolicy = Self("Permissions-Policy")!
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
static let referrerPolicy = Self("Referrer-Policy")!
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
static let frameOptions = Self("X-Frame-Options")!
}
@@ -1,7 +1,9 @@
extension Int {
/// A namespace for the cache's default configuration values.
public enum Cache {
/// The default max-age, in seconds, applied to text-based static files (1 hour).
/// 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
@@ -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)
}
}
@@ -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,21 @@
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.
/// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
init(
assetVersion: String? = nil
) {
self.init(bundle: .module) {
NotFoundPage(
locale: $0,
assetVersion: assetVersion
)
}
}
}
@@ -1,80 +0,0 @@
import Hummingbird
/// A result builder that collects ``RouteCollection`` values into a stack.
///
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting route
/// collections be listed declaratively rather than added one statement at a time.
@resultBuilder
public enum RouteCollectionBuilder<Context: RequestContext> {
public static func buildExpression(
_ collection: RouteCollection<Context>
) -> [RouteCollection<Context>] {
[collection]
}
public static func buildBlock(
_ collections: [RouteCollection<Context>]...
) -> [RouteCollection<Context>] {
collections.flatMap { $0 }
}
public static func buildOptional(
_ collections: [RouteCollection<Context>]?
) -> [RouteCollection<Context>] {
collections ?? []
}
public static func buildEither(
first collections: [RouteCollection<Context>]
) -> [RouteCollection<Context>] {
collections
}
public static func buildEither(
second collections: [RouteCollection<Context>]
) -> [RouteCollection<Context>] {
collections
}
public static func buildArray(
_ collections: [[RouteCollection<Context>]]
) -> [RouteCollection<Context>] {
collections.flatMap { $0 }
}
}
// MARK: - Helpers
public extension RouterMethods {
// MARK: Methods
/// Adds route collections to the router using the ``RouteCollectionBuilder`` result builder.
///
/// Mirrors `addMiddleware`, letting controllers be listed declaratively:
///
/// ```swift
/// router.addRoutes {
/// RootController<AppRequestContext>().routes
/// HealthController<AppRequestContext>().routes
/// }
/// ```
///
/// Each collection is added at the router's root, exactly as a sequence of
/// `addRoutes(_:)` calls would.
/// - Parameter build: the route-collection stack result builder.
/// - Returns: the router, so calls can be chained.
@discardableResult
func addRoutes(
@RouteCollectionBuilder<Context> _ build: () -> [RouteCollection<Context>]
) -> Self {
for collection in build() {
addRoutes(collection)
}
return self
}
}
@@ -23,27 +23,6 @@ extension String {
/// 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 security headers' default configuration values.
///
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is
/// "sticky" in browsers, so it stays off unless explicitly configured in production.
public enum Security {
/// The default `Content-Security-Policy`.
///
/// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins
/// (`object-src 'none'`), pins the document base URL (`base-uri 'self'`), and forbids framing
/// (`frame-ancestors 'none'`). Both pages link external stylesheets, so no inline-style
/// exception is required.
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
/// The default `X-Content-Type-Options` (disables MIME sniffing).
public static let contentTypeOptions = "nosniff"
/// The default `X-Frame-Options` (forbids framing the page).
public static let frameOptions = "DENY"
/// The default `Referrer-Policy`.
public static let referrerPolicy = "strict-origin-when-cross-origin"
/// The default `Permissions-Policy` (denies access to powerful browser features the site does not use).
public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
}
/// A namespace for the server string constants.
public enum Server {
/// The website server's name.
@@ -1,56 +0,0 @@
import HTTPTypes
import Hummingbird
import Localization
/// Resolves the visitor's preferred language and records it on the request context.
///
/// Placed ahead of the localized responders in the middleware chain, it reads the request's
/// `Accept-Language` header, negotiates the best supported match (falling back to the default
/// language), and stores it on the context's ``LocalizedRequestContext/language``.
///
/// The request is otherwise passed through untouched the URL and routing are not affected so
/// each page is served at its existing path and varies its content by header.
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
/// Negotiates the request's language from its `Accept-Language` header.
private let negotiate: Negotiate
// MARK: Initializers
/// Creates a localization middleware that negotiates against the module's String Catalog languages.
public init() {
self.negotiate = .init(bundle: .module)
}
}
// MARK: - RouterMiddleware
extension LocalizationMiddleware: RouterMiddleware {
// MARK: Functions
/// Negotiates the request's language and records it on the context before passing it down.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var context = context
context.language = negotiate(
acceptLanguage: request.headers[.acceptLanguage]
)
return try await next(request, context)
}
}
@@ -1,67 +0,0 @@
import Hummingbird
/// Serves a custom error page for requests that match neither a route nor a static file.
///
/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error
/// that bubbles up when no file exists for the requested path and responds with the rendered
/// ``ErrorPage`` and a `404 Not Found` status. The page is served in the language stored on the
/// context by ``LocalizationMiddleware``, falling back to the default language.
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
/// The error page, rendered once per supported language and reused for every not-found response.
private let responses: LocalizedHTMLCollectionResponse
// MARK: Initializers
/// Creates a not-found middleware.
public init() {
self.responses = .init(
status: .notFound
) {
ErrorPage(locale: $0)
}
}
}
// MARK: - RouterMiddleware
extension NotFoundMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain, rendering the error page if it results in a not-found
/// response.
///
/// Any error other than `.notFound` is rethrown unchanged.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response, or the rendered ``ErrorPage`` with a `404 Not Found` status.
/// - Throws: any non-not-found error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
do {
return try await next(request, context)
}
catch let error {
guard
let responseError = error as? any HTTPResponseError,
responseError.status == .notFound
else {
throw error
}
return responses.response(
for: context.language
)
}
}
}
@@ -1,146 +0,0 @@
import HTTPTypes
import Hummingbird
/// Stamps a set of security-related HTTP headers onto every response.
///
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever
/// response bubbles back up the rendered landing page, the ``ErrorPage`` produced by
/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` so the browser applies
/// the strict, hardened interpretation of the content instead of its lenient legacy defaults.
///
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for
/// every request, so the per-request cost is a handful of header copies.
public struct SecurityHeadersMiddleware<Context: RequestContext> {
// MARK: Properties
/// The precomputed headers applied to every response.
private let fields: HTTPFields
// MARK: Initializers
/// Creates a security-headers middleware.
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened
/// baseline suitable for a static site, with `Strict-Transport-Security` left off (see
/// ``Configuration``).
public init(
configuration: Configuration = .init()
) {
self.fields = configuration.fields
}
}
// MARK: - RouterMiddleware
extension SecurityHeadersMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and stamps the configured security headers onto the
/// response on the way back up.
///
/// Existing values for the same header names are replaced so downstream middleware cannot leave
/// a weaker policy in place.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response with the security headers applied.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var response = try await next(request, context)
for field in fields {
response.headers[field.name] = field.value
}
return response
}
}
// MARK: - Helpers
private extension SecurityHeadersMiddleware.Configuration {
// MARK: Computed
/// The configuration expressed as the headers to apply, omitting any whose value is `nil`.
var fields: HTTPFields {
var fields = HTTPFields()
fields[.contentSecurityPolicy] = contentSecurityPolicy
fields[.xContentTypeOptions] = contentTypeOptions
fields[.frameOptions] = frameOptions
fields[.referrerPolicy] = referrerPolicy
fields[.permissionsPolicy] = permissionsPolicy
fields[.strictTransportSecurity] = strictTransportSecurity
return fields
}
}
// MARK: - Configuration
extension SecurityHeadersMiddleware {
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
///
/// Each property maps to a single response header. A `nil` value omits that header entirely,
/// which is how `Strict-Transport-Security` stays disabled by default: it is only safe to send
/// over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be
/// switched on (via configuration) only in TLS-terminated production.
public struct Configuration: Sendable {
// MARK: Properties
/// The `Content-Security-Policy` value (controls which sources the browser will load).
public let contentSecurityPolicy: String?
/// The `X-Content-Type-Options` value (disables MIME sniffing when set to `nosniff`).
public let contentTypeOptions: String?
/// The `X-Frame-Options` value (controls whether the page may be framed).
public let frameOptions: String?
/// The `Referrer-Policy` value (controls how much referrer information is shared).
public let referrerPolicy: String?
/// The `Permissions-Policy` value (gates access to powerful browser features).
public let permissionsPolicy: String?
/// The `Strict-Transport-Security` value, or `nil` to omit the header (the default).
public let strictTransportSecurity: String?
// MARK: Initializers
/// Creates a security-headers configuration.
///
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except
/// `strictTransportSecurity`, which defaults to `nil` (omitted). Pass `nil` for any header
/// to drop it from the response.
/// - Parameters:
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
/// - contentTypeOptions: the `X-Content-Type-Options` value.
/// - frameOptions: the `X-Frame-Options` value.
/// - referrerPolicy: the `Referrer-Policy` value.
/// - permissionsPolicy: the `Permissions-Policy` value.
/// - strictTransportSecurity: the `Strict-Transport-Security` value, or `nil` to omit it.
public init(
contentSecurityPolicy: String? = String.Security.contentSecurityPolicy,
contentTypeOptions: String? = String.Security.contentTypeOptions,
frameOptions: String? = String.Security.frameOptions,
referrerPolicy: String? = String.Security.referrerPolicy,
permissionsPolicy: String? = String.Security.permissionsPolicy,
strictTransportSecurity: String? = nil
) {
self.contentSecurityPolicy = contentSecurityPolicy
self.contentTypeOptions = contentTypeOptions
self.frameOptions = frameOptions
self.referrerPolicy = referrerPolicy
self.permissionsPolicy = permissionsPolicy
self.strictTransportSecurity = strictTransportSecurity
}
}
}