Merged the template branch into main to pick up the 33 upstream changes.

Reconciled the bootstrap-customised files: kept the CCN naming, canonical
origin, database slug and analytics comments, dropped the template-only
Makefile, README.md and Scripts/bootstrap that bootstrap removes, and took
the template's ordering for the security headers in the production compose.
This commit is contained in:
2026-08-30 22:57:45 +02:00
54 changed files with 2273 additions and 309 deletions
@@ -116,6 +116,9 @@ private extension HealthController {
}
/// Builds a JSON response carrying the given status and payload.
///
/// Every response is marked `noindex`: the checks answer `200 OK` to anyone, and `robots.txt` allows the whole site. A `Disallow` rule would
/// stop the crawl but not the indexing, and would publish the paths to everyone reading the file.
/// - Parameters:
/// - status: the HTTP status of the response.
/// - payload: the JSON body of the response.
@@ -126,7 +129,10 @@ private extension HealthController {
) -> Response {
Response(
status: status,
headers: [.contentType: "application/json"],
headers: [
.contentType: "application/json",
.robotsTag: "noindex",
],
body: .init(byteBuffer: .init(string: payload))
)
}
@@ -25,15 +25,18 @@ public struct RootController<Context: LocalizedRequestContext> {
/// Creates a root controller.
/// - Parameters:
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
public init(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil
) {
self.responses = .init(bundle: .module) {
IndexPage(
locale: $0,
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
)
}
@@ -55,6 +58,15 @@ extension RootController: RouterController {
use: index
)
// Every non-default language answers under a prefix of its own, so the `hreflang` alternates the page advertises
// resolve and a crawler can index each edition at a stable URL. A single-language catalog adds none.
for language in Language.all where !language.isDefault {
routes.get(
.init(language.path(IndexPage.path)),
use: index(in: language)
)
}
return routes
}
@@ -84,6 +96,23 @@ private extension RootController {
)
}
/// Builds the handler serving the landing page in one fixed language, for the routes carrying the language in their path.
///
/// The path *is* the language choice, so the negotiated context language is ignored: a prefixed URL answers in its language for every
/// visitor and every crawler alike, which is what lets a search engine index it as that edition.
/// - Parameter language: the language the route serves.
/// - Returns: the handler answering requests for that edition of the page.
func index(
in language: Language
) -> @Sendable (Request, Context) -> Response {
{ request, _ in
responses.response(
for: language.identifier,
request: request
)
}
}
}
// MARK: - Constants
@@ -91,7 +120,7 @@ private extension RootController {
private extension RouterPath {
/// A namespace for the ``RootController`` route paths.
enum Root {
/// The path of the landing page.
static let index: RouterPath = "/"
/// The path of the landing page; the page builds its canonical URL from the same constant.
static let index: RouterPath = .init(IndexPage.path)
}
}
@@ -58,6 +58,11 @@ extension AbsoluteConfigKey {
/// The absolute configuration key for the server's name.
public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName)
}
/// A namespace for the HTTPS redirect configuration keys, as absolute keys.
public enum HTTPS {
/// The absolute configuration key for reading the visitor's original scheme from the `X-Forwarded-Proto` header.
public static let trustForwardedProto: AbsoluteConfigKey = .init(.HTTPS.trustForwardedProto)
}
/// A namespace for the logging configuration keys, as absolute keys.
public enum Log {
/// The absolute configuration key for the minimum log level.
@@ -58,6 +58,12 @@ extension ConfigKey {
/// The configuration key for the server's name.
public static let serverName: ConfigKey = "http.serverName"
}
/// A namespace for the HTTPS redirect configuration keys.
public enum HTTPS {
/// The configuration key for reading the visitor's original scheme from the `X-Forwarded-Proto` header, redirecting the plain-HTTP
/// ones to the site origin (enable only behind a trusted proxy that sets the header).
public static let trustForwardedProto: ConfigKey = "https.trustForwardedProto"
}
/// A namespace for the logging configuration keys.
public enum Log {
/// The configuration key for the minimum log level.
@@ -92,4 +98,9 @@ extension ConfigKey {
/// The configuration key for the `Strict-Transport-Security` header value (omitted when unset).
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
}
/// A namespace for the site configuration keys.
public enum Site {
/// The configuration key for the public origin the site is served at (scheme and host, no trailing slash).
public static let origin: ConfigKey = "site.origin"
}
}
@@ -5,10 +5,10 @@ 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.
/// Creates a not-found middleware that renders the website's not-found 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.
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit the tracker script.
init(
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -24,7 +24,9 @@ extension String {
/// 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"
public static let driver = driverInMemory
/// The driver token selecting the in-memory SQLite backend.
public static let driverInMemory = "inMemory"
/// The driver token selecting the PostgreSQL backend.
public static let driverPostgres = "postgres"
/// The default PostgreSQL host.
@@ -34,9 +36,11 @@ extension String {
/// The default database username.
public static let username = "ccn"
/// The default TLS posture token.
public static let tls = "prefer"
public static let tls = tlsPrefer
/// The TLS token disabling TLS.
public static let tlsOff = "off"
/// The TLS token upgrading to TLS only when the server offers it.
public static let tlsPrefer = "prefer"
/// The TLS token requiring TLS.
public static let tlsRequire = "require"
}
@@ -50,4 +54,13 @@ extension String {
/// The website server's name.
public static let name = "CCNWebsite"
}
/// A namespace for the site string constants.
public enum Site {
/// The default public origin the site is served at (scheme and host, no trailing slash).
///
/// Bootstrap writes the canonical URL it prompts for here, leaving it empty for the placeholder. An empty or non-HTTPS origin disables
/// the HTTPS redirect, which `https.trustForwardedProto` must enable besides a `301` is cached for a long time, so it is never issued
/// at a host nobody named.
public static let origin = ""
}
}