From cdded06ba3e7bb2c88dce2a8b2871acb1e6c60f8 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 23 Jul 2026 01:04:37 +0000 Subject: [PATCH] Renamed the Web package as Infrastructure (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the work done to rename the _Web_ package as _Infrastructure_, to provide a clear naming and purpose to this particular package within the project. To provide further details about the work: * Infrastructure * Asset fingerprinting: an FNV-1a token derived from the static files directory, appended as ?v= to asset URLs so deploys bust caches; pre-rendered pages also revalidate via weak ETags. * New middlewares: fixed-window RateLimitMiddleware (per-client budgets keyed by trusted X-Forwarded-For or remote address) and VaryMiddleware (Accept-Encoding on every response); SecurityHeadersMiddleware now also stamps error responses. * Auto-generated HEAD endpoints, cache max-age configuration, and Docker build/Compose refinements. * Protocols and scaffolding: Asset/AssetExtension, the Page protocol (viewport, stylesheets, scripts, versioned URLs), and LocalizedRequestContext. * Rate limiter's counter store swapped from an actor to a Mutex (no executor hop per request) with amortized batch eviction instead of O(n²) scans under client floods. * FingerprintAssets reports unreadable files to a logger instead of silently producing a token that never busts their cache. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/25 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Packages/Infrastructure/Package.swift | 23 +- Packages/Infrastructure/README.md | 34 ++ .../Sources/Internal/Types/FNV1aHash.swift | 47 +++ .../Public/Enumerations/AssetExtension.swift | 53 ++++ .../Extensions/HTTPFieldName+Constants.swift | 4 +- .../Public/Extensions/Int+Constants.swift | 9 + .../Public/Extensions/String+Constants.swift | 23 ++ .../Public/Methods/FingerprintAssets.swift | 85 +++++ .../Middlewares/LocalizationMiddleware.swift | 10 +- .../Middlewares/NotFoundMiddleware.swift | 33 +- .../Middlewares/RateLimitMiddleware.swift | 291 ++++++++++++++++++ .../SecurityHeadersMiddleware.swift | 21 +- .../Public/Middlewares/VaryMiddleware.swift | 71 +++++ .../Sources/Public/Protocols/Asset.swift | 81 +++++ .../Protocols/LocalizedRequestContext.swift | 15 + .../Sources/Public/Protocols/Page.swift | 87 ++++++ .../Public/Responses/CachedHTMLResponse.swift | 114 +++++++ .../LocalizedHTMLCollectionResponse.swift | 31 +- .../Cases/Internal/Types/FNV1aHashTests.swift | 76 +++++ .../Enumerations/AssetExtensionTests.swift | 71 +++++ .../Methods/FingerprintAssetsTests.swift | 171 ++++++++++ .../LocalizationMiddlewareTests.swift | 69 +++++ .../Middlewares/NotFoundMiddlewareTests.swift | 191 ++++++++++++ .../RateLimitMiddlewareTests.swift | 170 ++++++++++ .../SecurityHeadersMiddlewareTests.swift | 42 ++- .../Middlewares/VaryMiddlewareTests.swift | 131 ++++++++ .../Cases/Public/Protocols/AssetTests.swift | 79 +++++ .../Cases/Public/Protocols/PageTests.swift | 62 ++++ .../Tests/Catalogs/Localizable.xcstrings | 23 ++ .../Tests/Utils/Assets/StubAsset.swift | 11 + .../Utils/Contexts/StubRequestContext.swift | 26 ++ .../Utils/Extensions/Tag+Constants.swift | 8 + .../Tests/Utils/Pages/StubPage.swift | 66 ++++ Services/Website/Dockerfile | 2 +- Services/Website/Package.swift | 1 + Services/Website/README.md | 12 +- .../Sources/App/Extensions/App+Build.swift | 27 +- .../Extensions/ConfigReader+Properties.swift | 37 ++- .../Internal/Enumerations/StaticFile.swift | 105 +------ .../Internal/Extensions/Page+Defaults.swift | 70 +++++ .../Library/Internal/Pages/ErrorPage.swift | 21 +- .../Library/Internal/Pages/IndexPage.swift | 21 +- .../Library/Internal/Protocols/Page.swift | 109 ------- .../Responses/CachedHTMLResponse.swift | 72 ----- ...text.swift => WebsiteRequestContext.swift} | 26 +- .../Public/Controllers/RootController.swift | 19 +- .../AbsoluteConfigKey+Constants.swift | 13 +- .../Extensions/ConfigKey+Constants.swift | 13 +- .../Public/Extensions/Int+Constants.swift | 4 +- .../LocalizationMiddleware+Defaults.swift | 13 + .../NotFoundMiddleware+Defaults.swift | 21 ++ .../Public/Extensions/String+Constants.swift | 21 -- Services/Website/Tests/App/AppTests.swift | 179 ++++++++++- .../Enumerations/StaticFileTests.swift | 124 +------- .../Cases/Internal/Pages/IndexPageTests.swift | 14 + .../Controllers/RootControllerTests.swift | 114 +++++++ .../LocalizationMiddlewareTests.swift | 2 + .../Middlewares/NotFoundMiddlewareTests.swift | 95 +++++- 58 files changed, 2844 insertions(+), 519 deletions(-) create mode 100644 Packages/Infrastructure/README.md create mode 100644 Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift create mode 100644 Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Extensions/HTTPFieldName+Constants.swift (72%) create mode 100644 Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift create mode 100644 Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift create mode 100644 Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/LocalizationMiddleware.swift (87%) rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/NotFoundMiddleware.swift (62%) create mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift rename {Services/Website/Sources/Library => Packages/Infrastructure/Sources}/Public/Middlewares/SecurityHeadersMiddleware.swift (88%) create mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/Asset.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift create mode 100644 Packages/Infrastructure/Sources/Public/Protocols/Page.swift create mode 100644 Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift rename {Services/Website/Sources/Library/Internal => Packages/Infrastructure/Sources/Public}/Responses/LocalizedHTMLCollectionResponse.swift (70%) create mode 100644 Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift rename {Services/Website/Tests/Library => Packages/Infrastructure/Tests}/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift (71%) create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift create mode 100644 Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings create mode 100644 Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift create mode 100644 Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift create mode 100644 Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift delete mode 100644 Services/Website/Sources/Library/Internal/Protocols/Page.swift delete mode 100644 Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift rename Services/Website/Sources/Library/Public/Contexts/{LocalizedRequestContext.swift => WebsiteRequestContext.swift} (57%) create mode 100644 Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift create mode 100644 Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift diff --git a/Packages/Infrastructure/Package.swift b/Packages/Infrastructure/Package.swift index 2f9227a..e399fba 100644 --- a/Packages/Infrastructure/Package.swift +++ b/Packages/Infrastructure/Package.swift @@ -16,6 +16,13 @@ let package = Package( ), ], dependencies: [ + .package( + path: "../Localization" + ), + .package( + url: "https://github.com/elementary-swift/elementary.git", + from: "0.6.0" + ), .package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0" @@ -25,6 +32,11 @@ let package = Package( .target( name: "Infrastructure", dependencies: [ + .byName(name: "Localization"), + .product( + name: "Elementary", + package: "elementary" + ), .product( name: "Hummingbird", package: "hummingbird" @@ -36,12 +48,21 @@ let package = Package( name: "InfrastructureTests", dependencies: [ .byName(name: "Infrastructure"), + .product( + name: "Elementary", + package: "elementary" + ), .product( name: "HummingbirdTesting", package: "hummingbird" ), ], - path: "Tests" + path: "Tests", + resources: [ + // Copied verbatim rather than processed: the String Catalog is read as raw JSON at + // runtime so it resolves identically on Darwin and Linux (which cannot compile it). + .copy("Catalogs/Localizable.xcstrings") + ] ), ] ) diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md new file mode 100644 index 0000000..7913a19 --- /dev/null +++ b/Packages/Infrastructure/README.md @@ -0,0 +1,34 @@ +# Infrastructure +The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the **Loud** services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized HTML responses, and the page and asset scaffolding. + +## Overview +The package provides, grouped by role: + +| Role | Types | +| --- | --- | +| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | +| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | +| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | +| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | +| Contexts | `LocalizedRequestContext` | + +## Design rules +The package holds only what every service can reuse; anything a service owns is injected, never referenced: + +- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors. +- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. +- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. + +## Layout +Sources are split by visibility, then by kind, one type per file: + +``` +Sources/Public// public API (Protocols, Middlewares, Responses, …) +Sources/Internal// implementation details (e.g. FNV1aHash) +Tests/Cases/… mirrors the source layout +Tests/Utils/… stubs and test-only extensions +``` + +## Requirements +- Swift 6.3 toolchain (`swift-tools-version:6.3`). +- macOS 15, matching the sibling `Localization` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms). diff --git a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift new file mode 100644 index 0000000..c33baab --- /dev/null +++ b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Hashes bytes with the FNV-1a 64-bit algorithm. +/// +/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it +/// suits values that must agree between instances and survive restarts: the asset version token +/// (``FingerprintAssets``) and the entity tags of the pre-rendered pages (`CachedHTMLResponse`). +/// It is not cryptographic — a collision only risks serving a stale cached asset, not security. +struct FNV1aHash { + + // MARK: Properties + + /// The running hash value. + private var hash: UInt64 + + // MARK: Initializers + + /// Creates a hasher at the FNV-1a offset basis. + init() { + self.hash = 0xcbf2_9ce4_8422_2325 + } + + // MARK: Computed + + /// The hash of everything combined so far, as a fixed-width, 16-character hexadecimal token. + /// + /// Reading it does not consume the running hash: more bytes can be combined afterwards. + var digest: String { + String( + format: "%016llx", + hash + ) + } + + // MARK: Functions + + /// Folds the given bytes into the hash. + /// - Parameter bytes: the bytes to fold in. + mutating func combine( + _ bytes: some Sequence + ) { + for byte in bytes { + hash = (hash ^ UInt64(byte)) &* 0x100_0000_01b3 + } + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift new file mode 100644 index 0000000..8729209 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift @@ -0,0 +1,53 @@ +/// A file extension used by an ``Asset``. +/// +/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its +/// file name when resolving paths. +public enum AssetExtension: 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 + /// An Extensible Markup Language file. + case xml +} + +// MARK: - Extensions + +public extension AssetExtension { + + // MARK: Computed + + /// The file's content type. + var contentType: String { + switch self { + 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" + case .xml: "application/xml" + } + } + + /// The sub-directory within the static root that holds files with this extension, if any. + var subdirectory: String? { + switch self { + case .css: "css" + case .js: "js" + default: nil + } + } + +} diff --git a/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift similarity index 72% rename from Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift rename to Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift index 4ebf3bb..9f89f0a 100644 --- a/Services/Website/Sources/Library/Public/Extensions/HTTPFieldName+Constants.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift @@ -1,10 +1,12 @@ import HTTPTypes -extension HTTPField.Name { +public 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")! + /// The `X-Forwarded-For` field name (not provided as a standard `HTTPField.Name`). + static let xForwardedFor = Self("X-Forwarded-For")! } diff --git a/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift new file mode 100644 index 0000000..b08ed06 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/Int+Constants.swift @@ -0,0 +1,9 @@ +extension Int { + /// A namespace for the rate limit's default configuration values. + public enum RateLimit { + /// The default number of requests admitted per client per window. + public static let limit = 5 + /// The default window length, in seconds (1 minute). + public static let window = 60 + } +} diff --git a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift new file mode 100644 index 0000000..d01536e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift @@ -0,0 +1,23 @@ +extension String { + /// 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'`). No inline-style exception is included, so pages must link + /// external stylesheets. + 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 a static site does not use). + public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" + } +} diff --git a/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift new file mode 100644 index 0000000..3a55d87 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Methods/FingerprintAssets.swift @@ -0,0 +1,85 @@ +import Foundation +import Logging + +/// Derives a version token from the contents of the static files directory. +/// +/// The token folds every file under the directory — its relative path and its bytes, in a stable order — into one FNV-1a digest, so it changes whenever any +/// asset changes and agrees across the instances of a deployment. The pages append it to their asset URLs (`?v=`), which lets the assets be +/// served with a long-lived, immutable cache policy: a deploy that changes an asset changes the URLs pointing at it, so no client ever revalidates or holds a +/// stale copy. +public struct FingerprintAssets: Sendable { + + // MARK: Properties + + /// The logger unreadable files are reported to, or `nil` to skip them silently. + private let logger: Logger? + + // MARK: Initializers + + /// Creates an asset fingerprinting method. + /// - Parameter logger: the logger unreadable files are reported to, or `nil` (the default) to skip them silently. + public init( + logger: Logger? = nil + ) { + self.logger = logger + } + + // MARK: Functions + + /// Fingerprints the static files under the given directory. + /// + /// A file that cannot be read is reported to the ``logger`` and left out of the token, so its + /// later changes would not bust caches — a warning there usually points at a permissions + /// problem in the deployment. + /// - Parameter path: the directory the static files are served from. + /// - Returns: the version token, or `nil` when the directory holds no readable files (asset URLs are then left unversioned). + public func callAsFunction( + _ path: String + ) -> String? { + let manager = FileManager.default + + guard let enumerated = manager.enumerator(atPath: path) else { + return nil + } + + // The path-based enumerator yields paths relative to the directory, so the token depends + // only on the directory's contents — never on where the directory itself lives (the + // URL-based enumerator standardizes symlinked bases, e.g. `/var/…` to `/private/var/…`, + // which would leak the absolute path into the hash). + var files: [String] = [] + + while let relativePath = enumerated.nextObject() as? String { + if enumerated.fileAttributes?[.type] as? FileAttributeType == .typeRegular { + files.append(relativePath) + } + } + + var hash = FNV1aHash() + var hashed = false + + for relativePath in files.sorted() { + guard let contents = manager.contents( + atPath: "\(path)/\(relativePath)" + ) else { + logger?.warning( + "Static file could not be read while fingerprinting; the version token will not reflect it.", + metadata: ["path": "\(relativePath)"] + ) + + continue + } + + hash.combine(Array(relativePath.utf8)) + hash.combine(contents) + + hashed = true + } + + guard hashed else { + return nil + } + + return hash.digest + } + +} diff --git a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift similarity index 87% rename from Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift rename to Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift index db18270..c743aa8 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/LocalizationMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift @@ -1,3 +1,4 @@ +import Foundation import HTTPTypes import Hummingbird import Localization @@ -19,9 +20,12 @@ public struct LocalizationMiddleware { // MARK: Initializers - /// Creates a localization middleware that negotiates against the module's String Catalog languages. - public init() { - self.negotiate = .init(bundle: .module) + /// Creates a localization middleware that negotiates against the given bundle's String Catalog languages. + /// - Parameter bundle: the bundle whose String Catalog names the supported languages. + public init( + bundle: Bundle + ) { + self.negotiate = .init(bundle: bundle) } } diff --git a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift similarity index 62% rename from Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift rename to Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift index 6d053aa..b5f51e6 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/NotFoundMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift @@ -1,11 +1,12 @@ +import Elementary +import Foundation 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. +/// 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 error page 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 { // MARK: Properties @@ -16,12 +17,18 @@ public struct NotFoundMiddleware { // MARK: Initializers /// Creates a not-found middleware. - public init() { + /// - Parameters: + /// - bundle: the bundle whose String Catalog names the languages the page is rendered for. + /// - document: builds the error page to render for a given locale. + public init( + bundle: Bundle, + document: (Locale) -> Document + ) { self.responses = .init( - status: .notFound - ) { - ErrorPage(locale: $0) - } + bundle: bundle, + status: .notFound, + document: document + ) } } @@ -32,15 +39,14 @@ extension NotFoundMiddleware: RouterMiddleware { // MARK: Functions - /// Passes the request down the chain, rendering the error page if it results in a not-found - /// response. + /// 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. + /// - Returns: the downstream response, or the rendered error page with a `404 Not Found` status. /// - Throws: any non-not-found error thrown downstream. public func handle( _ request: Request, @@ -59,7 +65,8 @@ extension NotFoundMiddleware: RouterMiddleware { } return responses.response( - for: context.language + for: context.language, + request: request ) } } diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift new file mode 100644 index 0000000..d1a0f19 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift @@ -0,0 +1,291 @@ +import Foundation +import HTTPTypes +import Hummingbird +import NIOCore +import Synchronization + +/// Rejects a client's requests with `429 Too Many Requests` once they exceed a fixed-window rate limit. +/// +/// Added to the routes that must not be hammered — the subscription endpoint, an unauthenticated database write — it admits up to the configured limit of +/// requests per client per window, and answers the excess with `429 Too Many Requests` and a `Retry-After` header naming the seconds until the +/// window resets. +/// +/// A client is keyed by the first `X-Forwarded-For` entry when the ``Configuration`` trusts it, by the connection's remote address otherwise, and by +/// one shared bucket when neither names the client. The counters live in memory with a bounded capacity, so a flood of distinct clients cannot grow the +/// store without bound — and each instance of a multi-instance deployment enforces its own budget. +public struct RateLimitMiddleware: Sendable { + + // MARK: Properties + + /// The fixed-window request counters, keyed by client. + private let buckets: Buckets + + /// The limits the middleware enforces. + private let configuration: Configuration + + // MARK: Initializers + + /// Creates a rate-limit middleware. + /// - Parameter configuration: the limits the middleware enforces. Defaults to a budget suited to a form endpoint: a handful of requests per + /// client per minute. + public init( + configuration: Configuration = .init() + ) { + self.buckets = .init( + limit: configuration.limit, + window: configuration.window + ) + self.configuration = configuration + } + +} + +// MARK: - RouterMiddleware + +extension RateLimitMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Passes the request down the chain while the client stays within its budget, and answers it with `429 Too Many Requests` and a `Retry-After` + /// header once it does not. + /// - 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 `429` rejection. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + let admission = buckets.admit( + client( + for: request, + context: context + ) + ) + + switch admission { + case .admitted: + return try await next( + request, + context + ) + case .limited(let retryAfter): + var response = Response(status: .tooManyRequests) + + response.headers[.retryAfter] = String(max(1, retryAfter.components.seconds)) + + return response + } + } + +} + +// MARK: - Helpers + +private extension RateLimitMiddleware { + + // MARK: Methods + + /// The key identifying the requesting client: the first `X-Forwarded-For` entry when trusted, the connection's remote address otherwise, and one + /// bucket shared by every unidentifiable client when neither is known. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - Returns: the client key the request is counted under. + func client( + for request: Request, + context: Context + ) -> String { + if + configuration.trustForwardedFor, + let forwarded = request.headers[.xForwardedFor]? + .split(separator: ",") + .first? + .trimmingCharacters(in: .whitespaces), + !forwarded.isEmpty + { + return forwarded + } + + if let address = (context as? any RemoteAddressRequestContext)?.remoteAddress { + return address.ipAddress + ?? address.description + } + + return .unidentified + } + +} + +// MARK: - Buckets + +private extension RateLimitMiddleware { + + /// The outcome of asking the ``Buckets`` store to admit a request. + enum Admission { + /// The request is within the client's budget. + case admitted + /// The client exhausted its budget; the payload is the time until its window resets. + case limited(retryAfter: Duration) + } + + /// The fixed-window request counters, keyed by client. + /// + /// The counters sit behind a mutex rather than an actor: an admission is a handful of dictionary operations, so the lock is held only briefly and the + /// calling task never suspends — requests skip the executor hop an actor would add on every pass through the middleware. + final class Buckets: Sendable { + + // MARK: Properties + + /// The maximum number of clients tracked at once, bounding the store's memory. + private let capacity: Int + + /// The per-client counters: the start of the client's current window and its request count. + private let counters: Mutex<[String: (start: ContinuousClock.Instant, count: Int)]> + + /// The number of requests admitted per client per ``window``. + private let limit: Int + + /// The length of the fixed window the ``limit`` applies to. + private let window: Duration + + // MARK: Initializers + + /// Creates a counter store. + /// - Parameters: + /// - limit: the number of requests admitted per client per window. + /// - window: the length of the fixed window the limit applies to. + /// - capacity: the maximum number of clients tracked at once. + init( + limit: Int, + window: Duration, + capacity: Int = 10_000 + ) { + self.capacity = capacity + self.counters = .init([:]) + self.limit = limit + self.window = window + } + + // MARK: Functions + + /// Counts a request against the client's current window and admits it while the count stays within the limit. + /// - Parameter client: the key the request is counted under. + /// - Returns: the admission outcome. + func admit( + _ client: String + ) -> Admission { + let now = ContinuousClock.now + + return counters.withLock { counters in + if let counter = counters[client], now < counter.start.advanced(by: window) { + guard counter.count < limit else { + return .limited(retryAfter: now.duration(to: counter.start.advanced(by: window))) + } + + counters[client] = (counter.start, counter.count + 1) + + return .admitted + } + + makeRoom( + in: &counters, + at: now + ) + + counters[client] = (now, 1) + + return .admitted + } + } + + // MARK: Methods + + /// Keeps the store within its capacity before a new client is tracked: expired windows are dropped first, and when the store remains full, the + /// oldest live windows are evicted in one batch — a tenth of the capacity — so the sort that finds them runs once per batch of admissions + /// instead of once per request while a flood of distinct clients keeps the store full. + /// - Parameters: + /// - counters: the counters the room is made in. + /// - now: the instant the expiry is evaluated against. + private func makeRoom( + in counters: inout [String: (start: ContinuousClock.Instant, count: Int)], + at now: ContinuousClock.Instant + ) { + guard counters.count >= capacity else { + return + } + + counters = counters.filter { + now < $0.value.start.advanced(by: window) + } + + let headroom = max(1, capacity / 10) + let excess = counters.count - (capacity - headroom) + + guard excess > 0 else { + return + } + + let oldest = counters + .sorted { $0.value.start < $1.value.start } + .prefix(excess) + + for counter in oldest { + counters.removeValue(forKey: counter.key) + } + } + + } + +} + +// MARK: - Configuration + +extension RateLimitMiddleware { + /// The limits a ``RateLimitMiddleware`` enforces. + public struct Configuration: Sendable { + + // MARK: Properties + + /// The number of requests admitted per client per ``window``. + public let limit: Int + + /// Whether a client is keyed by the first `X-Forwarded-For` entry. + /// + /// Enable it only behind a reverse proxy that sets the header — there, the connection's own address would name the proxy for every visitor, + /// sharing one budget across all of them. On a directly reachable server the header is client-supplied, so trusting it lets a client forge fresh keys + /// at will. + public let trustForwardedFor: Bool + + /// The length of the fixed window the ``limit`` applies to. + public let window: Duration + + // MARK: Initializers + + /// Creates a rate-limit configuration. + /// - Parameters: + /// - limit: the number of requests admitted per client per window. + /// - window: the length of the fixed window the limit applies to. + /// - trustForwardedFor: whether a client is keyed by the first `X-Forwarded-For` entry. + public init( + limit: Int = .RateLimit.limit, + window: Duration = .seconds(Int.RateLimit.window), + trustForwardedFor: Bool = false + ) { + self.limit = limit + self.trustForwardedFor = trustForwardedFor + self.window = window + } + + } +} + +// MARK: - String+Constants + +private extension String { + /// The bucket shared by every client the middleware cannot identify. + static let unidentified = "unidentified" +} diff --git a/Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift similarity index 88% rename from Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift rename to Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift index 4bd48e9..ffa531f 100644 --- a/Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift @@ -4,7 +4,7 @@ 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 +/// response bubbles back up — the rendered pages, the error page 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. /// @@ -40,6 +40,9 @@ extension SecurityHeadersMiddleware: RouterMiddleware { /// Passes the request down the chain and stamps the configured security headers onto the /// response on the way back up. /// + /// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the + /// controllers) are converted to their response here rather than left to the router: the router + /// converts them above the middleware chain, where the response would escape these headers. /// Existing values for the same header names are replaced so downstream middleware cannot leave /// a weaker policy in place. /// - Parameters: @@ -47,13 +50,25 @@ extension SecurityHeadersMiddleware: RouterMiddleware { /// - 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. + /// - Throws: any downstream error that does not render as an HTTP response. public func handle( _ request: Request, context: Context, next: (Request, Context) async throws -> Response ) async throws -> Response { - var response = try await next(request, context) + var response: Response + + do { + response = try await next( + request, + context + ) + } catch let error as any HTTPResponseError { + response = try error.response( + from: request, + context: context + ) + } for field in fields { response.headers[field.name] = field.value diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift new file mode 100644 index 0000000..a500a1b --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift @@ -0,0 +1,71 @@ +import Foundation +import HTTPTypes +import Hummingbird + +/// Appends header names to the `Vary` header of every response passing through. +/// +/// Placed just above the response-compression middleware, it marks each response as varying on `Accept-Encoding`: the static files and pre-rendered +/// pages are served with `Cache-Control: public`, so without the signal a shared cache could store a compressed body and hand it to a client that +/// never advertised support for the encoding. +/// +/// Names already present on a response's `Vary` header — such as the `Accept-Language` the localized pages carry — are kept, and duplicates are not +/// added. +public struct VaryMiddleware: Sendable { + + // MARK: Properties + + /// The header names appended to every response's `Vary` header. + private let names: [String] + + // MARK: Initializers + + /// Creates a vary middleware. + /// - Parameter fields: the header names appended to every response's `Vary` header. Defaults to `Accept-Encoding`, the request header + /// the response-compression middleware acts on. + public init( + fields: [HTTPField.Name] = [.acceptEncoding] + ) { + self.names = fields.map(\.rawName) + } + +} + +// MARK: - RouterMiddleware + +extension VaryMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Passes the request down the chain and appends the configured names to the response's `Vary` header on the way back up. + /// - 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 `Vary` names 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 + ) + + var vary = response.headers[.vary]? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } ?? [] + + for name in names where !vary.contains(where: { + $0.caseInsensitiveCompare(name) == .orderedSame + }) { + vary.append(name) + } + + response.headers[.vary] = vary.joined(separator: ", ") + + return response + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift new file mode 100644 index 0000000..6624516 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/Asset.swift @@ -0,0 +1,81 @@ +/// An asset shipped with a website: a file stored under the static files root and served by +/// Hummingbird's `FileMiddleware` middleware. +/// +/// A conforming asset supplies its file name and the extensions it is available with, each +/// resolving to its own file; the protocol derives the paths from them: the file's path within +/// the static files root and the URL path it is served at, optionally versioned to bust caches. +public protocol Asset: Sendable { + + // MARK: Properties + + /// The file extensions the asset is available with. + var fileExtensions: [AssetExtension] { get } + + /// The asset's file name, without extension. + var fileName: String { get } + +} + +// MARK: - Implementations + +public extension Asset { + + // MARK: Methods + + /// Resolves the asset's path against the given base directory. + /// + /// - Parameters: + /// - basePath: the directory the static files are served from. + /// - fileExtension: the extension of the file to resolve. + /// - Returns: the path to the file, relative to the `basePath` path. + func path( + relativeTo basePath: String, + for fileExtension: AssetExtension + ) -> String { + let relativePath = relativePath(for: fileExtension) + + guard !basePath.isEmpty else { + return relativePath + } + + return "\(basePath)/\(relativePath)" + } + + /// Resolves the asset's path relative to the static files root (e.g. `"css/shared.css"`). + /// + /// This also matches the URL path the file is served at by `FileMiddleware`. + /// + /// - Parameter fileExtension: the extension of the file to resolve. + /// - Returns: the path to the file, relative to the static files root. + func relativePath( + for fileExtension: AssetExtension + ) -> String { + let file = "\(fileName).\(fileExtension.rawValue)" + + return fileExtension.subdirectory + .map { "\($0)/\(file)" } ?? file + } + + /// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`). + /// + /// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): + /// `FileMiddleware` ignores the query when resolving the file, while caches key on the full + /// URL, so a deploy that changes the assets busts every cached copy at once. + /// - Parameters: + /// - fileExtension: the extension of the file to resolve. + /// - version: the version token to append, or `nil` to leave the URL unversioned. + /// - Returns: the path to use in `href` and `src` attributes. + func urlPath( + for fileExtension: AssetExtension, + version: String? = nil + ) -> String { + let path = "/\(relativePath(for: fileExtension))" + + guard let version, !version.isEmpty else { + return path + } + + return "\(path)?v=\(version)" + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift new file mode 100644 index 0000000..86fec3e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift @@ -0,0 +1,15 @@ +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 } + +} diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift new file mode 100644 index 0000000..1ab393e --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -0,0 +1,87 @@ +import Elementary +import Foundation + +/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content. +/// +/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the +/// rest of the document around them: the viewport declaration and stylesheet links followed by the metadata in the head, and the content followed by +/// the script tags in the body. +public protocol Page: HTMLDocument, Sendable { + + // MARK: Associated types + + /// The type of the page's markup. + associatedtype Content: HTML + + /// The type of the page's head metadata markup. + associatedtype Metadata: HTML + + // MARK: Properties + + /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. + var assetVersion: String? { get } + + /// The page's markup, rendered before the ``scripts``. + @HTMLBuilder + var content: Content { get } + + /// The locale the page content is localized to. + var locale: Locale { get } + + /// The markup placed in the document head after the ``stylesheets``: icon and manifest + /// links, extra meta tags, and the like. + @HTMLBuilder + var metadata: Metadata { get } + + /// The scripts loaded at the end of the document body, in order. + var scripts: [any Asset] { get } + + /// The stylesheets linked in the document head, in order. + var stylesheets: [any Asset] { get } + +} + +// MARK: - Implementations + +public extension Page { + + // MARK: Computed + + /// The page ``content`` followed by its ``scripts``. + @HTMLBuilder + var body: some HTML { + content + + for file in scripts { + script(.src(file.urlPath( + for: .js, + version: assetVersion + ))) {} + } + } + + /// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head. + /// + /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already + /// emits `` before this markup, and HTML5 allows only one. + @HTMLBuilder + var head: some HTML { + meta( + .name(.viewport), + .content("width=device-width, initial-scale=1") + ) + + metadata + + for file in stylesheets { + link( + .rel(.stylesheet), + .href(file.urlPath( + for: .css, + version: assetVersion + )) + ) + } + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift new file mode 100644 index 0000000..331defb --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift @@ -0,0 +1,114 @@ +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(for:)`` 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. +/// +/// A successful page also revalidates cheaply: its headers carry a weak entity tag derived from the rendered bytes and a `Cache-Control` that asks +/// clients to revalidate (`no-cache`), so a repeat visit costs a `304 Not Modified` instead of a full transfer — and a deploy that changes the page +/// changes the tag, propagating immediately. +/// +/// ``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. +public struct CachedHTMLResponse: Sendable { + + // MARK: Properties + + /// The page rendered to bytes once. + private let buffer: ByteBuffer + + /// The weak entity tag of the rendered bytes, present on successful pages only. + private let eTag: String? + + /// 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. + /// + /// A `200 OK` page gets the revalidation headers (`ETag` and `Cache-Control`); an error page does not, since a `304 Not Modified` only + /// ever stands in for a success. + /// - 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. + public init( + status: HTTPResponse.Status = .ok, + additionalHeaders: HTTPFields = [:], + document: some HTMLDocument + ) { + let buffer = ByteBuffer(string: document.render()) + var headers: HTTPFields = [ + .contentType: "text/html; charset=utf-8" + ] + var eTag: String? + + if status == .ok { + var hash = FNV1aHash() + + hash.combine(buffer.readableBytesView) + + eTag = "W/\"\(hash.digest)\"" + + headers[.eTag] = eTag + headers[.cacheControl] = "public, no-cache" + } + + for field in additionalHeaders { + headers[field.name] = field.value + } + + self.buffer = buffer + self.eTag = eTag + self.headers = headers + self.status = status + } + + // MARK: Methods + + /// Builds a response from the cached, pre-rendered bytes. + /// + /// A conditional request whose `If-None-Match` names the page's entity tag is answered with a + /// bodyless `304 Not Modified`. Otherwise the full page is served, mirroring the + /// `text/html; charset=utf-8` content type `HTMLResponse` produces and leaving the + /// `Content-Length` unset so small pages remain eligible for compression. + /// - Parameter request: the request the response answers. + /// - Returns: the response carrying the cached HTML body, or its `304` revalidation. + public func response( + for request: Request + ) -> Response { + if + let eTag, + request.method == .get || request.method == .head, + let match = request.headers[.ifNoneMatch], + match == "*" || match.contains(eTag) + { + return Response( + status: .notModified, + headers: headers + ) + } + + return Response( + status: status, + headers: headers, + body: .init { [buffer] writer in + try await writer.write(buffer) + try await writer.finish(nil) + } + ) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift similarity index 70% rename from Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift rename to Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift index e6772d1..b8c1161 100644 --- a/Services/Website/Sources/Library/Internal/Responses/LocalizedHTMLCollectionResponse.swift +++ b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift @@ -10,11 +10,11 @@ import Localization /// 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 { +public struct LocalizedHTMLCollectionResponse: Sendable { // MARK: Properties - /// The supported languages and default language, derived from the module's String Catalog. + /// The supported languages and default language, derived from the bundle's String Catalog. private let list: LanguageList /// The pre-rendered responses, keyed by language identifier. @@ -24,13 +24,15 @@ struct LocalizedHTMLCollectionResponse: Sendable { /// Renders the document once per supported language. /// - Parameters: + /// - bundle: the bundle whose String Catalog names the languages the document is rendered for. /// - status: the status applied to every response. Defaults to `.ok`. /// - document: builds the document to render for a given locale. - init( + public init( + bundle: Bundle, status: HTTPResponse.Status = .ok, document: (Locale) -> Document ) { - self.list = .init(bundle: .module) + self.list = .init(bundle: bundle) self.responses = list.all .reduce(into: [:]) { responses, language in responses[language] = CachedHTMLResponse( @@ -39,7 +41,9 @@ struct LocalizedHTMLCollectionResponse: Sendable { .contentLanguage: language, .vary: "Accept-Language", ], - document: document(.init(identifier: language)) + document: document(.init( + identifier: language + )) ) } } @@ -47,19 +51,26 @@ struct LocalizedHTMLCollectionResponse: Sendable { // MARK: Methods /// Builds the response for the given language, falling back to the default language. - /// - Parameter language: the negotiated language identifier. + /// - Parameters: + /// - language: the negotiated language identifier. + /// - request: the request the response answers, consulted for conditional revalidation. /// - 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 + public func response( + for language: String, + request: Request ) -> Response { guard let response = responses[language] ?? responses[list.default] else { - return .init(status: .internalServerError) + return .init( + status: .internalServerError + ) } - return response.response() + return response.response( + for: request + ) } } diff --git a/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift new file mode 100644 index 0000000..e876252 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Internal/Types/FNV1aHashTests.swift @@ -0,0 +1,76 @@ +import Testing + +@testable import Infrastructure + +@Suite("FNV1aHash type") +struct FNV1aHashTests { + + // MARK: Functional tests + + @Test(arguments: [ + ("", "cbf29ce484222325"), + ("a", "af63dc4c8601ec8c"), + ("b", "af63df4c8601f1a5"), + ("foobar", "85944171f73967e8"), + ]) + func `matches the published FNV-1a 64-bit test vectors`( + input: String, + digest: String + ) { + var hash = FNV1aHash() + + hash.combine(input.utf8) + + #expect(hash.digest == digest) + } + + @Test + func `pads the digest to sixteen characters`() { + var hash = FNV1aHash() + + // "aa" hashes to 0x089c4307b54596b7, whose leading zero the digest must keep. + hash.combine("aa".utf8) + + #expect(hash.digest == "089c4307b54596b7") + #expect(hash.digest.count == 16) + } + + @Test + func `hashes incrementally combined bytes as one stream`() { + var combined = FNV1aHash() + var whole = FNV1aHash() + + combined.combine("foo".utf8) + combined.combine("bar".utf8) + whole.combine("foobar".utf8) + + #expect(combined.digest == whole.digest) + } + + @Test + func `distinguishes the order of the combined bytes`() { + var forward = FNV1aHash() + var backward = FNV1aHash() + + forward.combine("ab".utf8) + backward.combine("ba".utf8) + + #expect(forward.digest != backward.digest) + } + + @Test + func `digests without consuming the running hash`() { + var hash = FNV1aHash() + + hash.combine("foo".utf8) + + let first = hash.digest + + #expect(hash.digest == first) + + hash.combine("bar".utf8) + + #expect(hash.digest != first) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift new file mode 100644 index 0000000..98730b5 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift @@ -0,0 +1,71 @@ +import Testing + +@testable import Infrastructure + +@Suite("AssetExtension enumeration") +struct AssetExtensionTests { + + // MARK: Computed tests + + @Test(arguments: zip( + Self.extensions, + Self.contentTypes + )) + func `content type`( + for fileExtension: AssetExtension, + expects contentType: String + ) { + #expect(fileExtension.contentType == contentType) + } + + @Test(arguments: zip( + Self.extensions, + Self.subdirectories + )) + func `subdirectory`( + for fileExtension: AssetExtension, + expects subdirectory: String? + ) { + #expect(fileExtension.subdirectory == subdirectory) + } + +} + +// MARK: - Helpers + +private extension AssetExtensionTests { + + // MARK: Constants + + static let extensions: [AssetExtension] = [ + .css, + .js, + .png, + .ico, + .svg, + .txt, + .webmanifest, + .xml + ] + static let contentTypes: [String] = [ + "text/css", + "text/javascript", + "image/png", + "image/vnd.microsoft.icon", + "image/svg+xml", + "text/plain", + "application/manifest+json", + "application/xml" + ] + static let subdirectories: [String?] = [ + "css", + "js", + nil, + nil, + nil, + nil, + nil, + nil + ] + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift new file mode 100644 index 0000000..5c073e8 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Methods/FingerprintAssetsTests.swift @@ -0,0 +1,171 @@ +import Foundation +import Testing + +@testable import Infrastructure + +@Suite("FingerprintAssets method") +struct FingerprintAssetsTests { + + // MARK: Properties + + private let fingerprint = FingerprintAssets() + + // MARK: Functional tests + + @Test + func `fingerprints the files under a directory`() throws { + let directory = try makeDirectory(files: [ + "css/site.css": "body { margin: 0; }", + "robots.txt": "User-agent: *" + ]) + + defer { + removeDirectory(directory) + } + + let token = try #require(fingerprint(directory.path)) + + #expect(token.count == 16) + #expect(token.allSatisfy { $0.isHexDigit }) + } + + @Test + func `agrees across directories with identical contents`() throws { + let files = [ + "css/site.css": "body { margin: 0; }", + "js/site.js": "console.log(1);" + ] + let first = try makeDirectory(files: files) + let second = try makeDirectory(files: files) + + defer { + removeDirectory(first) + removeDirectory(second) + } + + #expect(fingerprint(first.path) == fingerprint(second.path)) + } + + @Test + func `changes the token when a file's contents change`() throws { + let directory = try makeDirectory(files: [ + "css/site.css": "body { margin: 0; }" + ]) + + defer { + removeDirectory(directory) + } + + let before = fingerprint(directory.path) + + try "body { margin: 1px; }".write( + to: directory.appendingPathComponent("css/site.css"), + atomically: true, + encoding: .utf8 + ) + + #expect(fingerprint(directory.path) != before) + } + + @Test + func `changes the token when a file is renamed`() throws { + let contents = "body { margin: 0; }" + let first = try makeDirectory(files: ["css/site.css": contents]) + let second = try makeDirectory(files: ["css/main.css": contents]) + + defer { + removeDirectory(first) + removeDirectory(second) + } + + #expect(fingerprint(first.path) != fingerprint(second.path)) + } + + @Test + func `changes the token when a file is added`() throws { + let directory = try makeDirectory(files: [ + "css/site.css": "body { margin: 0; }" + ]) + + defer { + removeDirectory(directory) + } + + let before = fingerprint(directory.path) + + try "console.log(1);".write( + to: directory.appendingPathComponent("site.js"), + atomically: true, + encoding: .utf8 + ) + + #expect(fingerprint(directory.path) != before) + } + + @Test + func `returns nil for a directory without files`() throws { + let directory = try makeDirectory(files: [:]) + + defer { + removeDirectory(directory) + } + + #expect(fingerprint(directory.path) == nil) + } + + @Test + func `returns nil for a missing directory`() { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("FingerprintAssetsTests-missing-\(UUID().uuidString)") + + #expect(fingerprint(missing.path) == nil) + } + +} + +// MARK: - Helpers + +private extension FingerprintAssetsTests { + + // MARK: Methods + + /// Creates a unique temporary directory holding the given files, keyed by relative path. + /// - Parameter files: the files to create, keyed by their path relative to the directory. + /// - Returns: the URL of the created directory. + func makeDirectory( + files: [String: String] + ) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FingerprintAssetsTests-\(UUID().uuidString)") + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + + for (relativePath, contents) in files { + let file = directory.appendingPathComponent(relativePath) + + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write( + to: file, + atomically: true, + encoding: .utf8 + ) + } + + return directory + } + + /// Removes a temporary directory created by ``makeDirectory(files:)``. + /// - Parameter directory: the URL of the directory to remove. + func removeDirectory( + _ directory: URL + ) { + try? FileManager.default.removeItem(at: directory) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift new file mode 100644 index 0000000..c18276e --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -0,0 +1,69 @@ +import Foundation +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import Infrastructure + +@Suite("LocalizationMiddleware middleware", .tags(.middleware)) +struct LocalizationMiddlewareTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router(context: StubRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware(bundle: .module) + } + + router.get("language") { _, context in + context.language + } + + return router + }()) + + // MARK: Functional tests + + @Test + func `negotiates a supported language from the header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get, + headers: [.acceptLanguage: "de-DE,de;q=0.9"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + + @Test + func `falls back to the default without a header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get + ) { response in + #expect(String(buffer: response.body) == "en") + } + } + } + + @Test + func `falls back to the default for an unsupported language`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language", + method: .get, + headers: [.acceptLanguage: "fr-FR,fr;q=0.9"] + ) { response in + #expect(String(buffer: response.body) == "en") + } + } + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift new file mode 100644 index 0000000..895d90c --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Hummingbird +import HummingbirdTesting +import NIOCore +import Testing + +@testable import Infrastructure + +@Suite("NotFoundMiddleware middleware", .tags(.middleware)) +struct NotFoundMiddlewareTests { + + // MARK: Constants + + private let app: Application = .init(router: { + let router = Router(context: StubRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware(bundle: .module) + NotFoundMiddleware(bundle: .module) { + StubPage(locale: $0) + } + } + + router.get("hello") { _, _ in + "Hello!" + } + + router.get("boom") { _, _ -> String in + throw HTTPError(.badRequest) + } + + return router + }()) + + // MARK: Functional tests + + @Test + func `renders the error page for an unmatched request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .notFound) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(response.headers[.contentLanguage] == "en") + #expect(response.headers[.vary] == "Accept-Language") + #expect(body.contains("Stub content")) + } + } + } + + @Test + func `renders the error page in the negotiated language`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get, + headers: [.acceptLanguage: "de-DE,de;q=0.9"] + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.contentLanguage] == "de") + } + } + } + + @Test + func `passes a matched response through untouched`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.body == ByteBuffer(string: "Hello!")) + } + } + } + + @Test + func `rethrows a non-not-found error unchanged`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/boom", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .badRequest) + #expect(!body.contains("Stub content")) + } + } + } + + @Test + func `renders versioned asset URLs when given a version`() async throws { + try await app( + assetVersion: "0123456789abcdef" + ).test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains("/css/stub.css?v=0123456789abcdef")) + #expect(body.contains("/js/stub.js?v=0123456789abcdef")) + } + } + } + + @Test + func `renders unversioned asset URLs by default`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains(#"href="/css/stub.css""#)) + #expect(!body.contains("?v=")) + } + } + } + + @Test + func `serves the error page without revalidation headers`() async throws { + // A `304 Not Modified` only ever stands in for a success, so the error page must not + // invite revalidation with an entity tag or a cache policy. + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.eTag] == nil) + #expect(response.headers[.cacheControl] == nil) + } + } + } + + @Test + func `serves the full error page to a conditional request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get, + headers: [.ifNoneMatch: "*"] + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .notFound) + #expect(body.contains("Stub content")) + } + } + } + +} + +// MARK: - Helpers + +private extension NotFoundMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose not-found middleware appends the given version token to the + /// error page's asset URLs. + /// - Parameter assetVersion: the version token appended to the page's asset URLs. + /// - Returns: the configured application. + func app( + assetVersion: String? + ) -> some ApplicationProtocol { + let router = Router(context: StubRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware(bundle: .module) + NotFoundMiddleware(bundle: .module) { + StubPage( + locale: $0, + assetVersion: assetVersion + ) + } + } + + return Application(router: router) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift new file mode 100644 index 0000000..5c6bd32 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift @@ -0,0 +1,170 @@ +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Infrastructure + +@Suite("RateLimitMiddleware middleware", .tags(.middleware)) +struct RateLimitMiddlewareTests { + + // MARK: Functional tests + + @Test + func `admits requests within the limit`() async throws { + try await app( + configuration: .init(limit: 3) + ).test(.router) { client in + for _ in 1 ... 3 { + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + } + } + + @Test + func `rejects a request over the limit with a retry-after header`() async throws { + try await app( + configuration: .init(limit: 2) + ).test(.router) { client in + for _ in 1 ... 2 { + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .tooManyRequests) + + let retryAfter = try #require(response.headers[.retryAfter]) + + #expect(try #require(Int(retryAfter)) >= 1) + } + } + } + + @Test + func `admits requests again once the window resets`() async throws { + try await app( + configuration: .init( + limit: 1, + window: .milliseconds(50) + ) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .tooManyRequests) + } + + try await Task.sleep(for: .milliseconds(100)) + + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + } + + @Test + func `separates clients by their forwarded address when trusted`() async throws { + try await app( + configuration: .init( + limit: 1, + trustForwardedFor: true + ) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.7"] + ) { response in + #expect(response.status == .ok) + } + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.8"] + ) { response in + #expect(response.status == .ok) + } + // The first entry names the client; the appended proxy hop must not change its key. + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.7, 10.0.0.1"] + ) { response in + #expect(response.status == .tooManyRequests) + } + } + } + + @Test + func `ignores the forwarded address when not trusted`() async throws { + try await app( + configuration: .init(limit: 1) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.7"] + ) { response in + #expect(response.status == .ok) + } + // Without trust (and without a connection address in router-only testing), every client + // shares one bucket, so a rotated header must not mint a fresh budget. + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedFor: "203.0.113.8"] + ) { response in + #expect(response.status == .tooManyRequests) + } + } + } + +} + +// MARK: - Helpers + +private extension RateLimitMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose router applies the rate-limit middleware ahead of a single + /// `/hello` route returning a plain body. + func app( + configuration: RateLimitMiddleware.Configuration + ) -> some ApplicationProtocol { + let router = Router() + + router.addMiddleware { + RateLimitMiddleware(configuration: configuration) + } + + router.get("hello") { _, _ in + "Hello!" + } + + return Application(router: router) + } + +} diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift similarity index 71% rename from Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift rename to Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift index e987adc..78db8c0 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift @@ -2,7 +2,7 @@ import Hummingbird import HummingbirdTesting import Testing -@testable import WebsiteLibrary +@testable import Infrastructure @Suite("SecurityHeadersMiddleware middleware", .tags(.middleware)) struct SecurityHeadersMiddlewareTests { @@ -17,11 +17,11 @@ struct SecurityHeadersMiddlewareTests { method: .get ) { response in #expect(response.status == .ok) - #expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy) - #expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions) - #expect(response.headers[.frameOptions] == String.Security.frameOptions) - #expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy) - #expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy) + #expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy) + #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions) + #expect(response.headers[.frameOptions] == .Security.frameOptions) + #expect(response.headers[.referrerPolicy] == .Security.referrerPolicy) + #expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy) } } } @@ -91,7 +91,24 @@ struct SecurityHeadersMiddlewareTests { uri: "/weak", method: .get ) { response in - #expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions) + #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions) + } + } + } + + @Test + func `applies the security headers to an error response`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/throws", + method: .get + ) { response in + #expect(response.status == .badRequest) + #expect(response.headers[.contentSecurityPolicy] == .Security.contentSecurityPolicy) + #expect(response.headers[.xContentTypeOptions] == .Security.contentTypeOptions) + #expect(response.headers[.frameOptions] == .Security.frameOptions) + #expect(response.headers[.referrerPolicy] == .Security.referrerPolicy) + #expect(response.headers[.permissionsPolicy] == .Security.permissionsPolicy) } } } @@ -104,9 +121,10 @@ private extension SecurityHeadersMiddlewareTests { // MARK: Methods - /// Builds an application whose router applies the security-headers middleware ahead of two - /// routes: `/hello` returns a plain body, and `/weak` returns a response that already carries a - /// deliberately weak `X-Content-Type-Options` value for the middleware to override. + /// Builds an application whose router applies the security-headers middleware ahead of three + /// routes: `/hello` returns a plain body, `/weak` returns a response that already carries a + /// deliberately weak `X-Content-Type-Options` value for the middleware to override, and + /// `/throws` fails with an `HTTPError` the way the controllers do on invalid input. func app( configuration: SecurityHeadersMiddleware.Configuration = .init() ) -> some ApplicationProtocol { @@ -128,6 +146,10 @@ private extension SecurityHeadersMiddlewareTests { return response } + router.get("throws") { _, _ -> Response in + throw HTTPError(.badRequest) + } + return Application(router: router) } diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift new file mode 100644 index 0000000..afa7dad --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/VaryMiddlewareTests.swift @@ -0,0 +1,131 @@ +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Infrastructure + +@Suite("VaryMiddleware middleware", .tags(.middleware)) +struct VaryMiddlewareTests { + + // MARK: Functional tests + + @Test + func `adds the default field to a response without a vary header`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/plain", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.vary] == "Accept-Encoding") + } + } + } + + @Test + func `appends to an existing vary header`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/localized", + method: .get + ) { response in + #expect(response.headers[.vary] == "Accept-Language, Accept-Encoding") + } + } + } + + @Test + func `does not duplicate a name already present`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/encoded", + method: .get + ) { response in + #expect(response.headers[.vary] == "Accept-Encoding") + } + } + } + + @Test + func `matches an existing name regardless of its casing`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/lowercased", + method: .get + ) { response in + #expect(response.headers[.vary] == "accept-encoding") + } + } + } + + @Test + func `normalizes the whitespace of an existing list`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/spaced", + method: .get + ) { response in + #expect(response.headers[.vary] == "Accept-Language, User-Agent, Accept-Encoding") + } + } + } + + @Test + func `appends every configured field`() async throws { + try await app( + fields: [.acceptEncoding, .acceptLanguage] + ).test(.router) { client in + try await client.execute( + uri: "/plain", + method: .get + ) { response in + #expect(response.headers[.vary] == "Accept-Encoding, Accept-Language") + } + } + } + +} + +// MARK: - Helpers + +private extension VaryMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose router applies the vary middleware ahead of routes whose + /// responses carry different `Vary` starting points: `/plain` none, `/localized` an + /// `Accept-Language`, `/encoded` an `Accept-Encoding` already, `/lowercased` a lowercase + /// `accept-encoding`, and `/spaced` a list with irregular whitespace. + func app( + fields: [HTTPField.Name] = [.acceptEncoding] + ) -> some ApplicationProtocol { + let router = Router() + + router.addMiddleware { + VaryMiddleware(fields: fields) + } + + router.get("plain") { _, _ in + "Hello!" + } + + for (path, vary) in [ + ("localized", "Accept-Language"), + ("encoded", "Accept-Encoding"), + ("lowercased", "accept-encoding"), + ("spaced", "Accept-Language , User-Agent"), + ] { + router.get(RouterPath(path)) { _, _ -> Response in + var response = Response(status: .ok) + + response.headers[.vary] = vary + + return response + } + } + + return Application(router: router) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift new file mode 100644 index 0000000..ea9c778 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/AssetTests.swift @@ -0,0 +1,79 @@ +import Testing + +@testable import Infrastructure + +@Suite("Asset protocol") +struct AssetTests { + + // MARK: Properties + + private let image = StubAsset( + fileExtensions: [.png], + fileName: "icon" + ) + private let shared = StubAsset( + fileExtensions: [.css, .js], + fileName: "shared" + ) + + // MARK: Method tests + + @Test + func `relative path nests the file inside its extension's sub-directory`() { + #expect(shared.relativePath(for: .css) == "css/shared.css") + #expect(shared.relativePath(for: .js) == "js/shared.js") + } + + @Test + func `relative path keeps the file at the root without a sub-directory`() { + #expect(image.relativePath(for: .png) == "icon.png") + } + + @Test + func `url path prefixes the relative path with a slash`() { + #expect(shared.urlPath(for: .css) == "/css/shared.css") + #expect(image.urlPath(for: .png) == "/icon.png") + } + + @Test + func `url path appends a version token as a query parameter`() { + #expect(shared.urlPath( + for: .css, + version: "0123456789abcdef" + ) == "/css/shared.css?v=0123456789abcdef") + } + + @Test(arguments: [nil, ""] as [String?]) + func `url path without a version`( + version: String? + ) { + #expect(shared.urlPath( + for: .css, + version: version + ) == "/css/shared.css") + } + + @Test(arguments: [ + "", + ".", + "Resources/Static" + ]) + func `path relative to`( + _ basePath: String + ) { + for fileExtension in shared.fileExtensions { + let pathRelativeToBasePath = shared.path( + relativeTo: basePath, + for: fileExtension + ) + let relativePath = shared.relativePath(for: fileExtension) + + if basePath.isEmpty { + #expect(pathRelativeToBasePath == relativePath) + } else { + #expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)") + } + } + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift new file mode 100644 index 0000000..a09a533 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift @@ -0,0 +1,62 @@ +import Elementary +import Foundation +import Testing + +@testable import Infrastructure + +@Suite("Page protocol", .tags(.page)) +struct PageTests { + + // MARK: Functional tests + + @Test + func `assembles the document around the page's parts`() { + let html = StubPage().render() + + #expect(html.contains("Stub Page")) + #expect(html.contains(#"lang="en""#)) + #expect(html.contains(#"name="viewport""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains("Stub content")) + } + + @Test + func `places the metadata between the viewport and the stylesheets`() throws { + let html = StubPage().render() + + let viewport = try #require(html.range(of: #"name="viewport""#)) + let metadata = try #require(html.range(of: #"name="stub""#)) + let stylesheet = try #require(html.range(of: "/css/stub.css")) + + #expect(viewport.lowerBound < metadata.lowerBound) + #expect(metadata.lowerBound < stylesheet.lowerBound) + } + + @Test + func `renders the scripts after the content`() throws { + let html = StubPage().render() + + let content = try #require(html.range(of: "Stub content")) + let script = try #require(html.range(of: "/js/stub.js")) + + #expect(content.lowerBound < script.lowerBound) + } + + @Test + func `appends the version token to the asset URLs`() { + let html = StubPage(assetVersion: "0123456789abcdef").render() + + #expect(html.contains("/css/stub.css?v=0123456789abcdef")) + #expect(html.contains("/js/stub.js?v=0123456789abcdef")) + } + + @Test + func `derives the document language from the locale`() { + let html = StubPage(locale: .init(identifier: "de-DE")).render() + + #expect(html.contains(#"lang="de""#)) + } + +} diff --git a/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings b/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings new file mode 100644 index 0000000..897a3e0 --- /dev/null +++ b/Packages/Infrastructure/Tests/Catalogs/Localizable.xcstrings @@ -0,0 +1,23 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "test.greeting" : { + "comment" : "Fixture string used by the Infrastructure test suite.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hallo" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift b/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift new file mode 100644 index 0000000..c508008 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Assets/StubAsset.swift @@ -0,0 +1,11 @@ +import Infrastructure + +/// An ``Asset`` with a fixed file name and set of extensions. +struct StubAsset: Asset { + + // MARK: Properties + + let fileExtensions: [AssetExtension] + let fileName: String + +} diff --git a/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift b/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift new file mode 100644 index 0000000..aa28122 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift @@ -0,0 +1,26 @@ +import Hummingbird +import Infrastructure + +/// A ``LocalizedRequestContext`` carrying the core storage and the negotiated language only. +struct StubRequestContext: LocalizedRequestContext { + + // MARK: Properties + + /// The core request context storage Hummingbird requires. + var coreContext: CoreRequestContextStorage + + /// The language identifier negotiated for the request. + var language: String + + // MARK: Initializers + + /// Creates a request context for the given source. + /// - Parameter source: the source the context is initialized from. + init( + source: Source + ) { + self.coreContext = .init(source: source) + self.language = "" + } + +} diff --git a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift new file mode 100644 index 0000000..e83de46 --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift @@ -0,0 +1,8 @@ +import Testing + +extension Tag { + /// Tests exercising a middleware of the Infrastructure package. + @Tag static var middleware: Tag + /// Tests exercising the page scaffolding of the Infrastructure package. + @Tag static var page: Tag +} diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift new file mode 100644 index 0000000..eae187d --- /dev/null +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -0,0 +1,66 @@ +import Elementary +import Foundation +import Infrastructure + +/// A ``Page`` with fixed content, metadata, and stub assets. +struct StubPage: Page { + + // 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 + + // MARK: Initializers + + /// Creates a stub page. + /// - Parameters: + /// - locale: the locale the page content is localized to. Defaults to `en`. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the + /// default) to leave them unversioned. + init( + locale: Locale = .init(identifier: "en"), + assetVersion: String? = nil + ) { + self.assetVersion = assetVersion + self.locale = locale + } + + // MARK: Computed + + var content: some HTML { + p { "Stub content" } + } + + var lang: String { + locale.language.languageCode?.identifier ?? "en" + } + + var metadata: some HTML { + meta( + .name("stub"), + .content("marker") + ) + } + + var scripts: [any Asset] { + [StubAsset( + fileExtensions: [.css, .js], + fileName: "stub" + )] + } + + var stylesheets: [any Asset] { + [StubAsset( + fileExtensions: [.css, .js], + fileName: "stub" + )] + } + + var title: String { + "Stub Page" + } + +} diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index f0fef04..2570487 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -55,9 +55,9 @@ RUN swift package --package-path ./Services/Website resolve # Copy only the Swift inputs needed for a release build. Static assets are built # in the assets stage and copied into staging after the binary is produced. +COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Packages/Localization/Sources ./Packages/Localization/Sources COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources -COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources COPY ./Services/Website/Sources ./Services/Website/Sources COPY ./Services/Website/Tests ./Services/Website/Tests diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 72dd107..0163147 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -108,6 +108,7 @@ let package = Package( .testTarget( name: "WebsiteTests", dependencies: [ + .byName(name: "Infrastructure"), .byName(name: "Website"), .product( name: "HummingbirdTesting", diff --git a/Services/Website/README.md b/Services/Website/README.md index 8178dfc..9eef3ae 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -26,7 +26,7 @@ Two SwiftPM targets: The `Website` executable depends on three local packages: - `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). -- `Infrastructure` (`Packages/Infrastructure`) — the `RouterController` protocol the controllers conform to and the `addController` result-builder extension that registers their routes on the router declaratively. +- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`. - `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver. The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination). @@ -65,7 +65,8 @@ A dotted config key maps to an environment variable by upper-casing, splitting c ### Static file caching | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | -| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for text assets (CSS, JS, plain text); also marked `must-revalidate`. | +| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS) and fonts; also marked `immutable`. The pages reference CSS/JS through content-versioned URLs (`?v=`), so a deploy busts them by changing the URL. | +| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for unversioned text assets (e.g. `robots.txt`); also marked `must-revalidate`. | | `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). | | `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else (e.g. the web manifest). | @@ -106,6 +107,13 @@ See [Persistence](#persistence-1) below for the workflow. | --- | --- | --- | --- | | `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. | +### Rate limiting +| Config key | Environment variable | Default | Description | +| --- | --- | --- | --- | +| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. | +| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. | +| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — otherwise clients can forge it; leave it off when the server is directly reachable. | + ### Security headers | Config key | Environment variable | Default | | --- | --- | --- | diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index e7bd7bc..d4f66ed 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -26,6 +26,8 @@ func application( logger: logger ) let fluent = persistence() + + let fingerprintAssets = FingerprintAssets(logger: logger) let prepareDB = PrepareDB() await prepareDB(for: fluent) @@ -33,8 +35,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) @@ -117,25 +121,31 @@ 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, the `SubscriptionController` routes that register newsletter subscriptions, 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.Configuration, securityHeaders: SecurityHeadersMiddleware.Configuration, logLevel: Logger.Level, probe: Probe @@ -152,11 +162,14 @@ private func router( SecurityHeadersMiddleware( configuration: securityHeaders ) + VaryMiddleware() ResponseCompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) LocalizationMiddleware() - NotFoundMiddleware() + NotFoundMiddleware( + assetVersion: assetVersion + ) FileMiddleware( staticFilesPath, cacheControl: cacheControl @@ -164,7 +177,9 @@ private func router( } router.addController { - RootController() + RootController( + assetVersion: assetVersion + ) HealthController( probe: probe ) diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index 2f9fdc2..9acbbe5 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -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.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` diff --git a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift index 69afb22..d953601 100644 --- a/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Enumerations/StaticFile.swift @@ -1,9 +1,11 @@ +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: CaseIterable, Sendable { +enum StaticFile: Asset, CaseIterable { /// The `apple-touch-icon.png` icon. case appleTouchIcon /// The `css/error.css` stylesheet and `js/error.js` script for the not-found page. @@ -28,30 +30,6 @@ enum StaticFile: CaseIterable, Sendable { case sitemap } -// 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 - /// An Extensible Markup Language file. - case xml - } -} - // MARK: - Extensions extension StaticFile { @@ -59,7 +37,7 @@ extension StaticFile { // MARK: Computed /// The file extensions the file is available with. - var fileExtensions: [Extension] { + var fileExtensions: [AssetExtension] { switch self { case .appleTouchIcon, .icon192, @@ -92,79 +70,4 @@ extension StaticFile { } } - // MARK: Methods - - /// Resolves the file's path against the given base directory. - /// - /// - Parameters: - /// - basePath: the directory the static files are served from. - /// - fileExtension: the extension of the file to resolve. - /// - Returns: the path to the file, relative to the `basePath` path. - func path( - relativeTo basePath: String, - for fileExtension: Extension - ) -> String { - let relativePath = relativePath(for: fileExtension) - - guard !basePath.isEmpty else { - return relativePath - } - - return "\(basePath)/\(relativePath)" - } - - /// Resolves the file's path relative to the static files root (e.g. `"css/shared.css"`). - /// - /// This also matches the URL path the file is served at by `FileMiddleware`. - /// - /// - Parameter fileExtension: the extension of the file to resolve. - /// - Returns: the path to the file, relative to the static files root. - func relativePath( - for fileExtension: Extension - ) -> String { - let file = "\(fileName).\(fileExtension.rawValue)" - - return fileExtension.subdirectory - .map { "\($0)/\(file)" } ?? file - } - - /// Resolves the absolute URL path the file is served at (e.g. `"/css/shared.css"`). - /// - /// - Parameter fileExtension: the extension of the file to resolve. - /// - Returns: the path to use in `href` and `src` attributes. - func urlPath( - for fileExtension: Extension - ) -> String { - "/\(relativePath(for: fileExtension))" - } - -} - -extension StaticFile.Extension { - - // MARK: Computed - - /// The file's content type. - var contentType: String { - switch self { - 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" - case .xml: "application/xml" - } - } - - /// The sub-directory within the static root that holds files with this extension, if any. - var subdirectory: String? { - switch self { - case .css: "css" - case .js: "js" - default: nil - } - } - } diff --git a/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift new file mode 100644 index 0000000..71eefba --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/Page+Defaults.swift @@ -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(bundle: .module).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)" + ) + ) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift index 3f332fb..2b19f7b 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ErrorPage.swift @@ -1,5 +1,6 @@ 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. @@ -7,6 +8,9 @@ struct ErrorPage { // 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 @@ -16,10 +20,15 @@ struct ErrorPage { // MARK: Initializers /// Creates a not-found 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) } @@ -40,12 +49,12 @@ extension ErrorPage: Page { } } - var scripts: [StaticFile] { - [.error, .shared] + var scripts: [any Asset] { + [StaticFile.error, StaticFile.shared] } - var stylesheets: [StaticFile] { - [.shared, .error] + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.error] } var title: String { diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index aabef0b..bcb0475 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -1,5 +1,6 @@ import Elementary import Foundation +import Infrastructure import Localization /// The website's landing page, with its text localized to a given locale. @@ -7,6 +8,9 @@ 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. let locale: Locale @@ -16,10 +20,15 @@ struct IndexPage { // 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) } @@ -38,12 +47,12 @@ extension IndexPage: Page { } } - var scripts: [StaticFile] { - [.index, .shared] + var scripts: [any Asset] { + [StaticFile.index, StaticFile.shared] } - var stylesheets: [StaticFile] { - [.shared, .index] + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.index] } var title: String { diff --git a/Services/Website/Sources/Library/Internal/Protocols/Page.swift b/Services/Website/Sources/Library/Internal/Protocols/Page.swift deleted file mode 100644 index e8bc515..0000000 --- a/Services/Website/Sources/Library/Internal/Protocols/Page.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Elementary -import Foundation -import Localization - -/// A page of the website: an HTML document with the shared scaffolding assembled around the page's content. -/// -/// A conforming page supplies its locale, its localized title, the stylesheets and scripts it needs, and its content; the protocol assembles the rest of the -/// document around them: the metadata, stylesheet, icon, and manifest links in the head, the content followed by the script tags in the body, and the -/// document language derived from the locale. -protocol Page: HTMLDocument, Sendable { - - // MARK: Associated types - - /// The type of the page's markup. - associatedtype Content: HTML - - // MARK: Properties - - /// The page's markup, rendered before the ``scripts``. - @HTMLBuilder - var content: Content { get } - - /// The locale the page content is localized to. - var locale: Locale { get } - - /// The scripts loaded at the end of the document body, in order. - var scripts: [StaticFile] { get } - - /// The stylesheets linked in the document head, in order. - var stylesheets: [StaticFile] { get } - -} - -// MARK: - Implementations - -extension Page { - - // MARK: Computed - - /// The page ``content`` followed by its ``scripts``. - @HTMLBuilder - var body: some HTML { - content - for file in scripts { - script(.src(file.urlPath(for: .js))) {} - } - } - - /// The metadata, ``stylesheets``, icon, and manifest links placed in the document head. - /// - /// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already - /// emits `` before this markup, and HTML5 allows only one. - @HTMLBuilder - var head: some HTML { - meta( - .name(.viewport), - .content("width=device-width, initial-scale=1") - ) - for file in stylesheets { - link( - .rel(.stylesheet), - .href(file.urlPath(for: .css)) - ) - } - link( - .rel(.icon), - .href(StaticFile.favicon.urlPath(for: .ico)), - .custom( - name: "sizes", - value: "any" - ) - ) - link( - .rel(.icon), - .href(StaticFile.icon.urlPath(for: .svg)), - .custom( - name: "type", - value: "image/svg+xml" - ) - ) - link( - .rel("apple-touch-icon"), - .href(StaticFile.appleTouchIcon.urlPath(for: .png)) - ) - link( - .rel("manifest"), - .href(StaticFile.site.urlPath(for: .webmanifest)) - ) - meta( - .name("theme-color"), - .content("#fafafa") - ) - meta( - .name("theme-color"), - .content("#0c0710"), - .custom( - name: "media", - value: "(prefers-color-scheme: dark)" - ) - ) - } - - /// 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 - } - -} diff --git a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift b/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift deleted file mode 100644 index f9da28a..0000000 --- a/Services/Website/Sources/Library/Internal/Responses/CachedHTMLResponse.swift +++ /dev/null @@ -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) - } - ) - } - -} diff --git a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift similarity index 57% rename from Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift rename to Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift index 0172f98..36e98d2 100644 --- a/Services/Website/Sources/Library/Public/Contexts/LocalizedRequestContext.swift +++ b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift @@ -1,26 +1,13 @@ 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 { +/// 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 +15,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 +27,7 @@ public struct WebsiteRequestContext: LocalizedRequestContext { ) { self.coreContext = .init(source: source) self.language = .empty + self.remoteAddress = source.channel.remoteAddress } } diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index f4995ed..fef2cb9 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,3 +1,4 @@ +import Foundation import Hummingbird import Infrastructure @@ -24,9 +25,16 @@ public struct RootController { // 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 + ) } } @@ -70,7 +78,10 @@ private extension RootController { request: Request, context: Context ) -> Response { - responses.response(for: context.language) + responses.response( + for: context.language, + request: request + ) } } diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 9e3ac7d..9e4a82e 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -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. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index cef6a42..289641c 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -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. diff --git a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift index 201068a..f1adf3f 100644 --- a/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/Int+Constants.swift @@ -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 diff --git a/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift new file mode 100644 index 0000000..009dc7b --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift @@ -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) + } + +} diff --git a/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift new file mode 100644 index 0000000..545dd1f --- /dev/null +++ b/Services/Website/Sources/Library/Public/Extensions/NotFoundMiddleware+Defaults.swift @@ -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) { + ErrorPage( + locale: $0, + assetVersion: assetVersion + ) + } + } + +} diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index 23fa620..a70db40 100644 --- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -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. diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 23fe521..70fce3c 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -2,6 +2,7 @@ import Configuration import Foundation import Hummingbird import HummingbirdTesting +import Infrastructure import NIOCore import Testing @@ -12,11 +13,11 @@ import Testing struct AppTests { // MARK: Constants - - private let textExtensions: [StaticFile.Extension] = [ + + // Stylesheets and scripts are referenced through fingerprinted URLs, so they are served immutable. + private let immutableExtensions: [AssetExtension] = [ .css, - .js, - .txt + .js ] // Absolute path to the package's "Resources/Static" folder, derived from this @@ -48,6 +49,22 @@ struct AppTests { } } + @Test + func `landing page to answer a head request`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/", + method: .head + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == "text/html; charset=utf-8") + #expect(response.body.readableBytes == 0) + } + } + } + @Test func `health check to be served at the health path`() async throws { try await app( @@ -106,7 +123,9 @@ struct AppTests { #expect(cacheControl.contains("public") == true) #expect(cacheControl.contains("max-age=") == true) - if textExtensions.contains(fileExtension) { + if immutableExtensions.contains(fileExtension) { + #expect(cacheControl.contains("immutable") == true) + } else if fileExtension == .txt { #expect(cacheControl.contains("must-revalidate") == true) } } @@ -114,6 +133,81 @@ struct AppTests { } } + @Test + func `versioned asset URL to be served`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/css/shared.css?v=0123456789abcdef", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentType] == "text/css") + } + } + } + + @Test + func `landing page to reference fingerprinted assets`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains("/css/shared.css?v=")) + #expect(body.contains("/js/shared.js?v=")) + } + } + } + + @Test + func `landing page to revalidate with an entity tag`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + let eTag = try await client.execute( + uri: "/", + method: .get + ) { response in + #expect(response.headers[.cacheControl] == "public, no-cache") + + return try #require(response.headers[.eTag]) + } + + try await client.execute( + uri: "/", + method: .get, + headers: [.ifNoneMatch: eTag] + ) { response in + #expect(response.status == .notModified) + #expect(response.body.readableBytes == 0) + #expect(response.headers[.eTag] == eTag) + } + } + } + + @Test + func `responses to vary on language and encoding`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let vary = try #require(response.headers[.vary]) + + #expect(vary.contains("Accept-Language")) + #expect(vary.contains("Accept-Encoding")) + } + } + } + @Test func `response to be compressed when the client supports it`() async throws { try await app( @@ -163,6 +257,81 @@ struct AppTests { } } + @Test + func `error page to reference fingerprinted assets`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains("/css/error.css?v=")) + #expect(body.contains("/js/shared.js?v=")) + } + } + } + + @Test + func `error page to be served without revalidation headers`() async throws { + // A `304 Not Modified` only ever stands in for a success, so the error page must not + // invite revalidation with an entity tag or a cache policy. + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.eTag] == nil) + #expect(response.headers[.cacheControl] == nil) + } + } + } + + @Test + func `error page to vary on language and encoding`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let vary = try #require(response.headers[.vary]) + + #expect(vary.contains("Accept-Language")) + #expect(vary.contains("Accept-Encoding")) + } + } + } + + @Test + func `landing page to revalidate a conditional head request`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + let eTag = try await client.execute( + uri: "/", + method: .get + ) { response in + try #require(response.headers[.eTag]) + } + + try await client.execute( + uri: "/", + method: .head, + headers: [.ifNoneMatch: eTag] + ) { response in + #expect(response.status == .notModified) + #expect(response.body.readableBytes == 0) + } + } + } + @Test func `security headers to be applied to the landing page`() async throws { try await app( diff --git a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift index 2ca9b20..449238c 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Enumerations/StaticFileTests.swift @@ -1,3 +1,4 @@ +import Infrastructure import Testing @testable import WebsiteLibrary @@ -8,7 +9,6 @@ struct StaticFileTests { // MARK: Type aliases typealias File = StaticFile - typealias FileExtension = StaticFile.Extension // MARK: Computed tests @@ -18,7 +18,7 @@ struct StaticFileTests { )) func `file extensions`( for file: File, - expects extensions: [FileExtension] + expects extensions: [AssetExtension] ) { #expect(file.fileExtensions == extensions) } @@ -34,81 +34,6 @@ struct StaticFileTests { #expect(file.fileName == fileName) } - @Test(arguments: zip( - Self.extensions, - Self.contentTypes - )) - func `content type`( - for fileExtension: FileExtension, - expects contentType: String - ) { - #expect(fileExtension.contentType == contentType) - } - - @Test(arguments: zip( - Self.extensions, - Self.subdirectories - )) - func `subdirectory`( - for fileExtension: FileExtension, - expects subdirectory: String? - ) { - #expect(fileExtension.subdirectory == subdirectory) - } - - // MARK: Method tests - - @Test(arguments: zip( - File.allCases, - Self.relativePaths - )) - func `relative path for`( - for file: File, - expects relativePaths: [String] - ) { - for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { - #expect(file.relativePath(for: fileExtension) == relativePath) - } - } - - @Test(arguments: zip( - File.allCases, - Self.relativePaths - )) - func `url path for`( - for file: File, - expects relativePaths: [String] - ) { - for (fileExtension, relativePath) in zip(file.fileExtensions, relativePaths) { - #expect(file.urlPath(for: fileExtension) == "/\(relativePath)") - } - } - - @Test(arguments: [ - "", - ".", - "Resources/Static" - ]) - func `path relative to`( - _ basePath: String - ) { - for file in File.allCases { - for fileExtension in file.fileExtensions { - let pathRelativeToBasePath = file.path( - relativeTo: basePath, - for: fileExtension - ) - let relativePath = file.relativePath(for: fileExtension) - - if basePath.isEmpty { - #expect(pathRelativeToBasePath == relativePath) - } else { - #expect(pathRelativeToBasePath == "\(basePath)/\(relativePath)") - } - } - } - } - // MARK: CaseIterable tests @Test @@ -124,37 +49,7 @@ private extension StaticFileTests { // MARK: Constants - static let extensions: [FileExtension] = [ - .css, - .js, - .png, - .ico, - .svg, - .txt, - .webmanifest, - .xml - ] - static let contentTypes: [String] = [ - "text/css", - "text/javascript", - "image/png", - "image/vnd.microsoft.icon", - "image/svg+xml", - "text/plain", - "application/manifest+json", - "application/xml" - ] - static let subdirectories: [String?] = [ - "css", - "js", - nil, - nil, - nil, - nil, - nil, - nil - ] - static let fileExtensions: [[FileExtension]] = [ + static let fileExtensions: [[AssetExtension]] = [ [.png], [.css, .js], [.ico], @@ -180,18 +75,5 @@ private extension StaticFileTests { "site", "sitemap" ] - static let relativePaths: [[String]] = [ - ["apple-touch-icon.png"], - ["css/error.css", "js/error.js"], - ["favicon.ico"], - ["icon.svg"], - ["icon-192.png"], - ["icon-512.png"], - ["css/index.css", "js/index.js"], - ["robots.txt"], - ["css/shared.css", "js/shared.js"], - ["site.webmanifest"], - ["sitemap.xml"] - ] } diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift index fc308d2..a98bb33 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/IndexPageTests.swift @@ -29,4 +29,18 @@ struct IndexPageTests { #expect(html.contains("/js/index.js")) } + @Test + func `renders versioned asset URLs when given a version`() { + let html = IndexPage( + locale: .init(identifier: "en"), + assetVersion: "0123456789abcdef" + ).render() + + #expect(html.contains("/css/shared.css?v=0123456789abcdef")) + #expect(html.contains("/css/index.css?v=0123456789abcdef")) + #expect(html.contains("/js/shared.js?v=0123456789abcdef")) + #expect(html.contains("/js/index.js?v=0123456789abcdef")) + #expect(html.contains("/favicon.ico?v=0123456789abcdef")) + } + } diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift index 54c3a4f..b705b7a 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -1,5 +1,6 @@ import Hummingbird import HummingbirdTesting +import Infrastructure import NIOCore import Testing @@ -42,4 +43,117 @@ struct RootControllerTests { } } + @Test + func `serves the landing page with revalidation headers`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let eTag = try #require(response.headers[.eTag]) + + #expect(eTag.hasPrefix(#"W/""#)) + #expect(response.headers[.cacheControl] == "public, no-cache") + } + } + } + + @Test + func `revalidates a matching conditional request with a 304`() async throws { + try await app.test(.router) { client in + let eTag = try await client.execute( + uri: "/", + method: .get + ) { response in + try #require(response.headers[.eTag]) + } + + try await client.execute( + uri: "/", + method: .get, + headers: [.ifNoneMatch: eTag] + ) { response in + #expect(response.status == .notModified) + #expect(response.headers[.eTag] == eTag) + #expect(response.body.readableBytes == 0) + } + } + } + + @Test + func `serves the full page to a non-matching conditional request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get, + headers: [.ifNoneMatch: #"W/"0123456789abcdef""#] + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .ok) + #expect(body.contains("Hello world!")) + } + } + } + + @Test + func `renders versioned asset URLs when given a version`() async throws { + try await app( + assetVersion: "0123456789abcdef" + ).test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains("/css/index.css?v=0123456789abcdef")) + #expect(body.contains("/js/index.js?v=0123456789abcdef")) + } + } + } + + @Test + func `renders unversioned asset URLs by default`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains(#"href="/css/index.css""#)) + #expect(!body.contains("?v=")) + } + } + } + +} + +// MARK: - Helpers + +private extension RootControllerTests { + + // MARK: Methods + + /// Builds an application whose root controller appends the given version token to the landing + /// page's asset URLs. + /// - Parameter assetVersion: the version token appended to the page's asset URLs. + /// - Returns: the configured application. + func app( + assetVersion: String? + ) -> some ApplicationProtocol { + let router = Router(context: WebsiteRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware() + } + + router.addRoutes(RootController( + assetVersion: assetVersion + ).routes) + + return Application(router: router) + } + } diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift index 9b71671..60c8871 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -4,6 +4,8 @@ import HummingbirdTesting import NIOCore import Testing +import Infrastructure + @testable import WebsiteLibrary @Suite("LocalizationMiddleware middleware", .tags(.middleware)) diff --git a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift index 3cffe32..22f1f9b 100644 --- a/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -3,6 +3,8 @@ import HummingbirdTesting import NIOCore import Testing +import Infrastructure + @testable import WebsiteLibrary @Suite("NotFoundMiddleware middleware", .tags(.middleware)) @@ -70,11 +72,102 @@ struct NotFoundMiddlewareTests { method: .get ) { response in let body = String(buffer: response.body) - + #expect(response.status == .badRequest) #expect(!body.contains("Page Not Found")) } } } + @Test + func `renders versioned asset URLs when given a version`() async throws { + try await app( + assetVersion: "0123456789abcdef" + ).test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains("/css/error.css?v=0123456789abcdef")) + #expect(body.contains("/js/shared.js?v=0123456789abcdef")) + } + } + } + + @Test + func `renders unversioned asset URLs by default`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains(#"href="/css/error.css""#)) + #expect(!body.contains("?v=")) + } + } + } + + @Test + func `serves the error page without revalidation headers`() async throws { + // A `304 Not Modified` only ever stands in for a success, so the error page must not + // invite revalidation with an entity tag or a cache policy. + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get + ) { response in + #expect(response.status == .notFound) + #expect(response.headers[.eTag] == nil) + #expect(response.headers[.cacheControl] == nil) + } + } + } + + @Test + func `serves the full error page to a conditional request`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/this-path-does-not-exist", + method: .get, + headers: [.ifNoneMatch: "*"] + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .notFound) + #expect(body.contains("Page Not Found")) + } + } + } + +} + +// MARK: - Helpers + +private extension NotFoundMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose not-found middleware appends the given version token to the + /// error page's asset URLs. + /// - Parameter assetVersion: the version token appended to the page's asset URLs. + /// - Returns: the configured application. + func app( + assetVersion: String? + ) -> some ApplicationProtocol { + let router = Router(context: WebsiteRequestContext.self) + + router.addMiddleware { + LocalizationMiddleware() + NotFoundMiddleware( + assetVersion: assetVersion + ) + } + + return Application(router: router) + } + }