From 916df7e2f02bc5d183304d7a53857a592fda6338 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 20 Sep 2026 12:45:58 +0200 Subject: [PATCH] Project updates from Template This commit contains the latest updates from the generic Website template, which rework the compression and localization: - Reworked the compression and localization in the Infrastructure package. (3c568e4) - Adopted the reworked compression and localization in the Website service. (08cf3e3) The template commit that only touched the root README (53676ed) was left out, as this project no longer carries that file. Co-Authored-By: Claude Fable 5.1 --- Packages/Infrastructure/Package.swift | 16 ++ Packages/Infrastructure/README.md | 15 +- .../Public/Extensions/Negotiate+Request.swift | 41 +++++ .../Public/Extensions/String+Constants.swift | 8 +- .../Middlewares/CompressionMiddleware.swift | 70 ++++++++ .../Middlewares/LocalizationMiddleware.swift | 79 --------- .../Middlewares/NotFoundMiddleware.swift | 17 +- .../Protocols/LocalizedRequestContext.swift | 14 -- .../Public/Responses/CachedHTMLResponse.swift | 163 ++++++++++++++++-- .../Sources/Public/Types/Analytics.swift | 4 +- .../NegotiateRequestTests.swift} | 21 ++- .../Middlewares/NotFoundMiddlewareTests.swift | 2 - .../Utils/Contexts/StubRequestContext.swift | 8 +- Services/Website/.env.local | 11 +- Services/Website/Package.swift | 12 +- Services/Website/README.md | 31 ++-- .../Sources/App/Extensions/App+Build.swift | 22 +-- .../Contexts/WebsiteRequestContext.swift | 14 +- .../Public/Controllers/RootController.swift | 25 ++- .../LocalizationMiddleware+Defaults.swift | 13 -- Services/Website/Tests/App/AppTests.swift | 46 +++++ .../Controllers/RootControllerTests.swift | 8 - Services/Website/docker-compose.override.yml | 1 + Services/Website/docker-compose.yml | 3 +- 24 files changed, 426 insertions(+), 218 deletions(-) create mode 100644 Packages/Infrastructure/Sources/Public/Extensions/Negotiate+Request.swift create mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/CompressionMiddleware.swift delete mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift delete mode 100644 Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift rename Packages/Infrastructure/Tests/Cases/Public/{Middlewares/LocalizationMiddlewareTests.swift => Extensions/NegotiateRequestTests.swift} (86%) delete mode 100644 Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift diff --git a/Packages/Infrastructure/Package.swift b/Packages/Infrastructure/Package.swift index e399fba..92d74c7 100644 --- a/Packages/Infrastructure/Package.swift +++ b/Packages/Infrastructure/Package.swift @@ -19,6 +19,10 @@ let package = Package( .package( path: "../Localization" ), + .package( + url: "https://github.com/adam-fowler/compress-nio.git", + from: "1.4.2" + ), .package( url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0" @@ -27,12 +31,20 @@ let package = Package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0" ), + .package( + url: "https://github.com/hummingbird-project/hummingbird-compression.git", + from: "2.0.0" + ), ], targets: [ .target( name: "Infrastructure", dependencies: [ .byName(name: "Localization"), + .product( + name: "CompressNIO", + package: "compress-nio" + ), .product( name: "Elementary", package: "elementary" @@ -41,6 +53,10 @@ let package = Package( name: "Hummingbird", package: "hummingbird" ), + .product( + name: "HummingbirdCompression", + package: "hummingbird-compression" + ), ], path: "Sources" ), diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index 9571378..616d5bc 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -1,17 +1,17 @@ # Infrastructure -The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the platform's services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized responses, and page and asset scaffolding. +The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the platform's services build on: declarative routing, hardened HTTP middlewares, pre-rendered and pre-compressed localized responses, and page and asset scaffolding. ## Overview | Role | Types | | --- | --- | | Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | -| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware` (negotiating from a `lang` query parameter, then a leading path segment, then `Accept-Language`), `NotFoundMiddleware` | +| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `CompressionMiddleware`, `RateLimitMiddleware`, `NotFoundMiddleware` | | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | | Link previews | `SocialCard`, its `locale` and the `alternateLocales` of its other language editions, its `Image` and `Style`, and the `Tag` meta tags it derives | | Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, the open `Name` and `Kind` vocabularies, and the site-wide initializer building the `Organization`/`WebSite` pair | | Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to any attribute-bearing HTML or SVG tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets | -| Responses | `CachedHTMLResponse`, and `LocalizedHTMLCollectionResponse` rendering one of them per catalog language | -| Contexts | `LocalizedRequestContext` | +| Responses | `CachedHTMLResponse` (rendered and gzipped once), `LocalizedHTMLCollectionResponse` (one of those per language) | +| Localization | The `Negotiate` extension resolving a request's language from its `lang` override, path prefix, and `Accept-Language` | | Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | ## Design rules @@ -21,6 +21,8 @@ The package holds only what every service can reuse. Anything a service owns is - **Nodes are joined by `@id`, not repetition.** A node another page must point at gets its identifier from a helper rather than a hand-spelled fragment — `organizationID(forSiteURL:)` names the node the site-wide initializer builds, and that initializer's `founder` takes such an identifier back. A service adds the helper for any node it owns, so neither side can drift. - **Services fill the gaps once, via extensions.** A service restores its convenient call sites retroactively — the Website's `*+Defaults` are the pattern. The open schema.org vocabularies work the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, a service adds its own. - **Method structs.** Single-operation types such as `FingerprintAssets` take lifetime-fixed configuration in `init` and per-call inputs in `callAsFunction`. +- **Wrap upstream, do not restate it.** Where a Hummingbird middleware is almost right, the package delegates to it and adds only the missing decision — `CompressionMiddleware` passes everything to `ResponseCompressionMiddleware` except responses that already name an encoding, which it would otherwise compress twice. +- **Ask where the answer is used.** Work every request pays for must be work every request needs. The request's language is negotiated by the responders that read it, not stamped onto a context on the way past. ## Layout Sources are split by visibility, then by kind, one type per file: @@ -29,10 +31,10 @@ Sources/ ├── Public/ public API │ ├── Builders/ RouteCollectionBuilder │ ├── Enumerations/ AssetExtension -│ ├── Extensions/ addController and Analytics.Event tagging, plus the default header names, rate limits, and header values +│ ├── Extensions/ addController, Analytics.Event tagging, and the request-language negotiation, plus the default header names, rate limits, and header values │ ├── Methods/ FingerprintAssets │ ├── Middlewares/ the seven HTTP middlewares -│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController +│ ├── Protocols/ Asset, Page, RouterController │ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse │ └── Types/ Analytics, SocialCard, StructuredData — each nesting its own types in a folder of that name └── Internal/ @@ -50,3 +52,4 @@ Every suite carries a tag for the kind of API it exercises — `.asset`, `.exten ## Requirements - Swift 6.3 toolchain (`swift-tools-version:6.3`). - macOS 15, matching the sibling packages. The services deploy to Linux containers; the packages declare no UI platforms. +- Package dependencies: the local `Localization` package, `elementary`, `hummingbird`, and — for `CompressionMiddleware` and the responses' gzip — `hummingbird-compression` and `compress-nio`. diff --git a/Packages/Infrastructure/Sources/Public/Extensions/Negotiate+Request.swift b/Packages/Infrastructure/Sources/Public/Extensions/Negotiate+Request.swift new file mode 100644 index 0000000..58eb18a --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/Negotiate+Request.swift @@ -0,0 +1,41 @@ +import Foundation +import HTTPTypes +import Hummingbird +import Localization + +public extension Negotiate { + + // MARK: Methods + + /// The language to serve a request in. + /// + /// The `lang` query parameter wins as a deliberate override; failing that, a leading path segment naming a supported language pins it, so an + /// unrouted path under a language's prefix answers in that language. Anything naming no supported language is ignored, leaving the + /// `Accept-Language` header and its fallback to the default. + /// + /// Called where the answer is used rather than stamped onto every request on the way past: page routes that pin their language by URL never ask. + /// - Parameter request: the request to negotiate for. + /// - Returns: the identifier of the supported language to serve. + func callAsFunction( + for request: Request + ) -> String { + let requested = request.uri.queryParameters[.Parameter.language].map(String.init) + ?? request.uri.path.split(separator: "/").first.map(String.init) + + return callAsFunction( + requested: requested, + acceptLanguage: request.headers[.acceptLanguage] + ) + } + +} + +// MARK: - Constants + +private extension Substring { + /// A namespace for the query parameters the negotiation reads. + enum Parameter { + /// The query parameter carrying an explicit language choice; the site's language switcher appends it to the current path. + static let language: Substring = "lang" + } +} diff --git a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift index 2791b1e..adb22ca 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/String+Constants.swift @@ -7,9 +7,11 @@ extension String { /// 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'" + /// base URL (`base-uri 'self'`), holds form submissions to the origin (`form-action 'self'`), and forbids framing + /// (`frame-ancestors 'none'`). No inline-style exception is included, so pages must link external stylesheets. + /// + /// `form-action` is named outright because it inherits from nothing: `default-src` does not cover it, however tight. + public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; form-action '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). diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/CompressionMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/CompressionMiddleware.swift new file mode 100644 index 0000000..38c9d5f --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/CompressionMiddleware.swift @@ -0,0 +1,70 @@ +import HTTPTypes +import Hummingbird +import HummingbirdCompression +import Logging + +/// Compresses the responses that are not already encoded, and passes the ones that are through untouched. +/// +/// Hummingbird's `ResponseCompressionMiddleware` appends to `Content-Encoding` without checking for one, so a pre-compressed page (see +/// ``CachedHTMLResponse``) would go out as `gzip, gzip` — a client decodes once and renders the inner gzip stream. This stands in for that +/// middleware and delegates to it, so the threshold, the negotiation and the compressor stay its behaviour. +/// +/// - Note: `Context` is the request context the middleware is resolved against. +public struct CompressionMiddleware: Sendable { + + // MARK: Properties + + /// The middleware the unencoded responses are handed to. + private let compression: ResponseCompressionMiddleware + + // MARK: Initializers + + /// Creates a compression middleware. + /// - Parameter minimumResponseSizeToCompress: the smallest response body, in bytes, that is compressed at all. + public init( + minimumResponseSizeToCompress: Int + ) { + self.compression = .init( + minimumResponseSizeToCompress: minimumResponseSizeToCompress + ) + } + +} + +// MARK: - RouterMiddleware + +extension CompressionMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Passes the request down the chain and compresses the response on the way back up, unless it already names an encoding. + /// - 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, compressed when it was not already. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + let response = try await next( + request, + context + ) + + guard response.headers[.contentEncoding] == nil else { + return response + } + + // The response is already in hand, so the delegate gets it rather than the chain — `next` runs once. + return try await compression.handle( + request, + context: context + ) { _, _ in + response + } + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift deleted file mode 100644 index f8bb021..0000000 --- a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation -import HTTPTypes -import Hummingbird -import Localization - -/// Resolves the visitor's preferred language and records it on the request context. -/// -/// Placed ahead of the localized responders in the middleware chain, it reads the request's `lang` query parameter, its path, and its -/// `Accept-Language` header, negotiates the best supported match (falling back to the default language), and stores it on the context's -/// ``LocalizedRequestContext/language``. -/// -/// The query parameter is a deliberate override; failing that, a leading path segment naming a supported language pins it, so an unrouted path under -/// a language's prefix — its not-found page — answers in that language. Values naming no supported language are ignored, leaving the header. The -/// request is passed through untouched — the path and routing are unaffected. -/// -/// A site whose page routes pin their language by URL consults this only for responses that belong to no URL — in practice, its not-found page. -public struct LocalizationMiddleware { - - // MARK: Properties - - /// Negotiates the request's language from its `Accept-Language` header. - private let negotiate: Negotiate - - // MARK: Initializers - - /// Creates a localization middleware that negotiates against the 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) - } - -} - -// MARK: - RouterMiddleware - -extension LocalizationMiddleware: RouterMiddleware { - - // MARK: Functions - - /// Negotiates the request's language and records it on the context before passing it down. - /// - Parameters: - /// - request: the incoming request. - /// - context: the context the request is resolved against. - /// - next: the next responder in the middleware chain. - /// - Returns: the downstream response. - /// - Throws: any error thrown downstream. - public func handle( - _ request: Request, - context: Context, - next: (Request, Context) async throws -> Response - ) async throws -> Response { - var context = context - - // The deliberate query override first; failing that, the leading path segment, so a language's whole URL - // prefix — routed or not — answers in its language. - let requested = request.uri.queryParameters[.Parameter.language].map(String.init) - ?? request.uri.path.split(separator: "/").first.map(String.init) - - context.language = negotiate( - requested: requested, - acceptLanguage: request.headers[.acceptLanguage] - ) - - return try await next(request, context) - } - -} - -// MARK: - Constants - -private extension Substring { - /// A namespace for the query parameters the middleware reads. - enum Parameter { - /// The query parameter carrying an explicit language choice; the site's language switcher appends it to the current path. - static let language: Substring = "lang" - } -} diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift index e1ba556..4052d14 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift @@ -1,19 +1,23 @@ import Elementary import Foundation import Hummingbird +import Localization /// 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 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. +/// path and responds with the rendered error page and a `404 Not Found` status. /// -/// Its responses declare `Vary: Accept-Language`: where the page routes pin their language by URL, this is the one responder that negotiates. -/// An unrouted path names no edition, so no canonical URL contradicts the header. -public struct NotFoundMiddleware { +/// It negotiates the language itself, on the way out, so the cost falls on the 404s rather than on every request through the chain — where page +/// routes pin their language by URL, this is the one responder that has to ask. Its responses declare `Vary: Accept-Language` accordingly: an +/// unrouted path names no edition, so no canonical URL contradicts the header. +public struct NotFoundMiddleware { // MARK: Properties + /// Negotiates the language a not-found response is served in. + private let negotiate: Negotiate + /// The error page, rendered once per supported language and reused for every not-found response. private let responses: LocalizedHTMLCollectionResponse @@ -27,6 +31,7 @@ public struct NotFoundMiddleware { bundle: Bundle, document: (Locale) -> Document ) { + self.negotiate = .init(bundle: bundle) self.responses = .init( bundle: bundle, status: .notFound, @@ -69,7 +74,7 @@ extension NotFoundMiddleware: RouterMiddleware { } return responses.response( - for: context.language, + for: negotiate(for: request), request: request ) } diff --git a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift b/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift deleted file mode 100644 index 50dcb35..0000000 --- a/Packages/Infrastructure/Sources/Public/Protocols/LocalizedRequestContext.swift +++ /dev/null @@ -1,14 +0,0 @@ -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/Responses/CachedHTMLResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift index 331defb..88a5ef5 100644 --- a/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift +++ b/Packages/Infrastructure/Sources/Public/Responses/CachedHTMLResponse.swift @@ -1,4 +1,6 @@ +import CompressNIO import Elementary +import Foundation import HTTPTypes import Hummingbird import NIOCore @@ -9,14 +11,14 @@ import NIOCore /// 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. /// +/// The bytes are gzipped once as well, so a page is compressed at startup rather than per request, and served sized rather than chunked. A client that +/// accepts no gzip gets the rendered bytes, leaving ``CompressionMiddleware`` to apply whatever encoding it did negotiate. +/// /// 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. +/// changes the tag, propagating immediately. The tag is weak and spans both encodings, so a revalidation succeeds whichever copy the client holds. /// /// ``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 @@ -27,6 +29,9 @@ public struct CachedHTMLResponse: Sendable { /// The weak entity tag of the rendered bytes, present on successful pages only. private let eTag: String? + /// The page gzipped once, and the headers announcing it, or `nil` when the bytes did not compress. + private let gzip: (buffer: ByteBuffer, headers: HTTPFields)? + /// The headers applied to every response, precomputed once. private let headers: HTTPFields @@ -35,7 +40,7 @@ public struct CachedHTMLResponse: Sendable { // MARK: Initializers - /// Renders the given document to bytes once. + /// Renders the given document to bytes once, and gzips them 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. @@ -74,16 +79,19 @@ public struct CachedHTMLResponse: Sendable { self.eTag = eTag self.headers = headers self.status = status + // A page that will not compress is served as rendered. + self.gzip = Self.gzipped( + buffer, + headers: headers + ) } // 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. + /// A conditional request whose `If-None-Match` names the page's entity tag is answered with a bodyless `304 Not Modified`. Otherwise the + /// page is served: the gzipped copy when the request accepts gzip, and the rendered bytes when it does not. /// - Parameter request: the request the response answers. /// - Returns: the response carrying the cached HTML body, or its `304` revalidation. public func response( @@ -101,10 +109,24 @@ public struct CachedHTMLResponse: Sendable { ) } + guard + let gzip, + Self.acceptsGzip(request) + else { + return Response( + status: status, + headers: headers, + body: .init { [buffer] writer in + try await writer.write(buffer) + try await writer.finish(nil) + } + ) + } + return Response( status: status, - headers: headers, - body: .init { [buffer] writer in + headers: gzip.headers, + body: .init { [buffer = gzip.buffer] writer in try await writer.write(buffer) try await writer.finish(nil) } @@ -112,3 +134,122 @@ public struct CachedHTMLResponse: Sendable { } } + +// MARK: - Helpers + +private extension CachedHTMLResponse { + + // MARK: Methods + + /// Whether the request accepts a gzipped body. + /// + /// Only gzip is precomputed: a request asking for another encoding alone falls through to the rendered bytes for ``CompressionMiddleware`` to + /// encode. A `q=0` is a refusal; the wildcard accepts on the client's behalf. + /// - Parameter request: the incoming request. + /// - Returns: `true` when the gzipped copy may be served. + static func acceptsGzip( + _ request: Request + ) -> Bool { + var wildcard = false + + for value in request.headers[values: .acceptEncoding] { + for entry in value.split(separator: .Separator.comma) { + let parts = entry.split(separator: .Separator.semicolon) + let name = parts.first? + .trimmingCharacters(in: .whitespaces) + .lowercased() + + guard + let name, + name == .gzip || name == .xGzip || name == .wildcard + else { + continue + } + + let isAccepted = quality(of: parts.dropFirst()) > 0 + + if name == .wildcard { + wildcard = isAccepted + } else if isAccepted { + return true + } else { + // An explicit `gzip;q=0` refuses it outright, whatever the wildcard says. + return false + } + } + } + + return wildcard + } + + /// The `q` weight carried by an `Accept-Encoding` entry's parameters; parameters without one are the highest preference. + /// - Parameter parameters: the entry's parameters, the coding name already dropped. + /// - Returns: the entry's weight. + static func quality( + of parameters: some Sequence + ) -> Double { + for parameter in parameters { + let parameter = parameter + .trimmingCharacters(in: .whitespaces) + .lowercased() + + guard parameter.hasPrefix(.qualityPrefix) else { + continue + } + + return Double(parameter.dropFirst(String.qualityPrefix.count)) ?? 0 + } + + return 1 + } + + /// Gzips the rendered bytes and builds the headers announcing them. + /// + /// The copy is complete before the first byte is written, so it carries a `Content-Length` rather than being chunked. Compression that fails or does + /// not pay for itself yields `nil`. + /// - Parameters: + /// - buffer: the rendered bytes. + /// - headers: the headers the response carries before the encoding is announced. + /// - Returns: the compressed bytes and their headers, or `nil` when the page is better served uncompressed. + static func gzipped( + _ buffer: ByteBuffer, + headers: HTTPFields + ) -> (buffer: ByteBuffer, headers: HTTPFields)? { + var source = buffer + + guard + let compressed = try? source.compress(with: .gzip()), + compressed.readableBytes < buffer.readableBytes + else { + return nil + } + + var headers = headers + + headers[.contentEncoding] = .gzip + headers[.contentLength] = String(compressed.readableBytes) + + return (compressed, headers) + } + +} + +// MARK: - Constants + +private extension Character { + enum Separator { + static let comma: Character = "," + static let semicolon: Character = ";" + } +} + +private extension String { + /// The content coding the pages are precompressed with. + static let gzip = "gzip" + /// The prefix of an `Accept-Encoding` entry's weight parameter. + static let qualityPrefix = "q=" + /// The content coding some older clients spell `gzip` as. + static let xGzip = "x-gzip" + /// The `Accept-Encoding` entry accepting any coding on the client's behalf. + static let wildcard = "*" +} diff --git a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift index 2754ffb..e100a3c 100644 --- a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift +++ b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift @@ -35,7 +35,7 @@ public struct Analytics: Sendable { /// - Parameters: /// - scriptURL: the URL the tracker script is loaded from. /// - websiteID: the analytics website identifier the tracker reports as. - /// - domains: the comma-delimited domains the tracker reports from; visits from any other host are ignored. Empty to report from every host. + /// - domains: the comma-delimited domains the tracker reports from; empty to report from every host. /// - excludeHash: whether the tracker drops the URL fragment from reported pageviews; defaults to `true`. /// - doNotTrack: whether the tracker honors the visitor's browser Do Not Track preference; defaults to `true`. /// - performance: whether the tracker collects Core Web Vitals (requires Umami v3.1 or newer); defaults to `true`. @@ -61,6 +61,8 @@ public struct Analytics: Sendable { // MARK: Computed /// The tracker script's attributes: the website id, the reporting domains when set, then each enabled behavior flag; disabled flags are omitted. + /// + /// An empty ``domains`` is omitted rather than rendered: `data-domains=""` is a filter matching no host. public var attributes: [Attribute] { var attributes: [Attribute] = [ .init("data-website-id", value: websiteID) diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Extensions/NegotiateRequestTests.swift similarity index 86% rename from Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift rename to Packages/Infrastructure/Tests/Cases/Public/Extensions/NegotiateRequestTests.swift index bb167a9..7a1466b 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Extensions/NegotiateRequestTests.swift @@ -2,31 +2,30 @@ import Foundation import HTTPTypes import Hummingbird import HummingbirdTesting +import Localization import NIOCore import Testing @testable import Infrastructure @Suite( - "LocalizationMiddleware middleware", - .tags(.middleware) + "Negotiate+Request extension", + .tags(.extension) ) -struct LocalizationMiddlewareTests { +struct NegotiateRequestTests { // MARK: Constants + /// Exercised through a route rather than a hand-built `Request`: the query parameter and path segment it reads are parsed from the URI. private let app: Application = .init(router: { + let negotiate = Negotiate(bundle: .module) let router = Router(context: StubRequestContext.self) - router.addMiddleware { - LocalizationMiddleware(bundle: .module) + router.get("language") { request, _ in + negotiate(for: request) } - - router.get("language") { _, context in - context.language - } - router.get("de/language") { _, context in - context.language + router.get("de/language") { request, _ in + negotiate(for: request) } return router diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift index ea26298..1711e68 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/NotFoundMiddlewareTests.swift @@ -18,7 +18,6 @@ struct NotFoundMiddlewareTests { let router = Router(context: StubRequestContext.self) router.addMiddleware { - LocalizationMiddleware(bundle: .module) NotFoundMiddleware(bundle: .module) { StubPage(locale: $0) } @@ -179,7 +178,6 @@ private extension NotFoundMiddlewareTests { let router = Router(context: StubRequestContext.self) router.addMiddleware { - LocalizationMiddleware(bundle: .module) NotFoundMiddleware(bundle: .module) { StubPage( locale: $0, diff --git a/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift b/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift index aa28122..74db7e3 100644 --- a/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift +++ b/Packages/Infrastructure/Tests/Utils/Contexts/StubRequestContext.swift @@ -1,17 +1,14 @@ import Hummingbird import Infrastructure -/// A ``LocalizedRequestContext`` carrying the core storage and the negotiated language only. -struct StubRequestContext: LocalizedRequestContext { +/// A request context carrying the core storage and nothing else. +struct StubRequestContext: RequestContext { // 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. @@ -20,7 +17,6 @@ struct StubRequestContext: LocalizedRequestContext { source: Source ) { self.coreContext = .init(source: source) - self.language = "" } } diff --git a/Services/Website/.env.local b/Services/Website/.env.local index cff3e97..c4ef37a 100644 --- a/Services/Website/.env.local +++ b/Services/Website/.env.local @@ -55,7 +55,7 @@ ANALYTICS_RECORDER=false # `Content-Security-Policy`. Must allow `String.Analytics.origin` in `script-src` and # `connect-src`, or the tracker is blocked; drop those two once analytics is off. -SECURITY_CONTENT_SECURITY_POLICY=default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none' +SECURITY_CONTENT_SECURITY_POLICY=default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none' # `X-Content-Type-Options`: disables MIME sniffing. SECURITY_CONTENT_TYPE_OPTIONS=nosniff @@ -73,6 +73,15 @@ SECURITY_PERMISSIONS_POLICY=accelerometer=(), camera=(), geolocation=(), gyrosco # remember it stickily once seen, so it stays off in local development. # SECURITY_STRICT_TRANSPORT_SECURITY=max-age=31536000; includeSubDomains +# --- Reverse proxy ----------------------------------------------------------- + +# Both assert a TLS-terminating reverse proxy that sets the forwarded headers, so +# set them together; off for a directly reachable server, where clients can forge +# the headers. The former drives the HTTPS redirect, the latter keys the rate-limit +# buckets by the first `X-Forwarded-For` entry. +HTTPS_TRUST_FORWARDED_PROTO=false +RATE_LIMIT_TRUST_FORWARDED_FOR=false + # --- Persistence ------------------------------------------------------------- # Persistence driver: inMemory (default, no infrastructure) or postgres. diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index f21be13..dee59ec 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -45,8 +45,8 @@ let package = Package( from: "2.25.0" ), .package( - url: "https://github.com/hummingbird-project/hummingbird-compression.git", - from: "2.0.0" + url: "https://github.com/adam-fowler/compress-nio.git", + from: "1.4.2" ), .package( url: "https://github.com/apple/swift-configuration.git", @@ -72,10 +72,6 @@ let package = Package( name: "Hummingbird", package: "hummingbird" ), - .product( - name: "HummingbirdCompression", - package: "hummingbird-compression" - ), ], path: "Sources/App" ), @@ -115,6 +111,10 @@ let package = Package( dependencies: [ .byName(name: "Infrastructure"), .byName(name: "Website"), + .product( + name: "CompressNIO", + package: "compress-nio" + ), .product( name: "HummingbirdTesting", package: "hummingbird" diff --git a/Services/Website/README.md b/Services/Website/README.md index ff10daa..ecb3da1 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -4,14 +4,14 @@ The **CCN** public website service — a [Hummingbird](https://github.com/hummin ## Overview The service: - Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)). -- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers. +- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers. - Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)). - Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable). - Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`. - Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)). - Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file. - Embeds a cookieless [Umami](https://umami.is) tracker on both pages once a deployment configures one; it ships **off**, so an unconfigured copy requests no third-party script (see [Analytics](#analytics)). -- Compresses responses (gzip/deflate) above a configurable size when the client advertises support. +- Serves the pages gzipped once at startup, sized rather than chunked, and compresses everything else (gzip/deflate) above a configurable size when the client advertises support. - Stamps a hardened set of security headers on every response. - Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a PostgreSQL server, selected by a single configuration key. @@ -29,7 +29,7 @@ Two SwiftPM targets: The `Website` executable depends on four local packages, each under `Packages/`: - `Localization` — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`). -- `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 `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable. +- `Infrastructure` — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security, redirect, vary, compression, rate-limit, and not-found middlewares, the `Negotiate(for:)` request-language extension, the `Page` and `Asset` scaffolding, the `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable. - `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. - `Utility` — small shared helpers with no server dependencies, currently the `NormalizeEmail` method. @@ -42,11 +42,10 @@ LogRequestsMiddleware → HTTPSRedirectMiddleware (301 to site.origin when forwarded over plain HTTP) → TrailingSlashRedirectMiddleware (301 to the path without a trailing slash) → VaryMiddleware (marks every response as varying on Accept-Encoding) - → ResponseCompressionMiddleware (gzip/deflate above the size threshold) - → LocalizationMiddleware (negotiates the language: ?lang=, path prefix, then Accept-Language) - → NotFoundMiddleware (renders the localized not-found page on .notFound) - → FileMiddleware (serves Resources/Static) -RootController (GET / → landing page; GET / → its other editions) + → CompressionMiddleware (gzip/deflate above the size threshold; pre-compressed pages pass through) + → NotFoundMiddleware (negotiates the language, renders the not-found page on .notFound) + → FileMiddleware (serves Resources/Static) +RootController (GET / → landing page, language negotiated; GET / → its other editions) HealthController (GET /health → liveness, GET /health/ready → readiness) ``` @@ -89,7 +88,7 @@ The same set belongs in the site-wide structured data: `StructuredData`'s `inLan `sitemap.xml` is the one part that does *not* follow the catalog: it is a static file, so a new language needs its editions added by hand — `/nl`, `/nl/`, one `` each, alongside the default language's. Give each the same spelling the page's own canonical carries (the root is the bare origin, with no trailing slash), or the two disagree about which URL is canonical. -Visitors switch language two ways, both handled by `LocalizationMiddleware` ahead of the routes: a `?lang=` query parameter (what a language switcher links to) and the leading path segment. Either beats `Accept-Language`; a value naming no supported language is ignored. The path segment matters beyond the routed pages — it is what makes an *unrouted* path under a language's prefix answer its not-found page in that language. +Visitors switch language two ways, both read by `Infrastructure`'s `Negotiate(for:)`: a `?lang=` query parameter (what a language switcher links to) and the leading path segment. Either beats `Accept-Language`; a value naming no supported language is ignored. Only the responders that need the answer ask — `RootController`'s bare route and `NotFoundMiddleware` — so no request pays for a negotiation it never reads. The path segment matters beyond the routed pages — it is what makes an *unrouted* path under a language's prefix answer its not-found page in that language. ## Configuration Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**: @@ -128,6 +127,8 @@ The groups are matched in order, so the specific media types (`text/css`, `text/ | --- | --- | --- | --- | | `compression.minimumResponseSize` | `COMPRESSION_MINIMUM_RESPONSE_SIZE` | `1024` | Minimum response body size, in bytes, before compression is applied. | +The pages are gzipped once at startup by `CachedHTMLResponse` and served with a `Content-Length`, so the threshold governs everything else. `CompressionMiddleware` wraps Hummingbird's `ResponseCompressionMiddleware` and leaves a response that already names an encoding alone — the upstream middleware would append a second `gzip`. + ### HTTP server | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | @@ -138,7 +139,7 @@ The groups are matched in order, so the specific media types (`text/css`, `text/ ### HTTPS redirect | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | -| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. | +| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. Pairs with `rateLimit.trustForwardedFor`: both assert a reverse proxy, so set them together, as the Compose files do. | Redirecting collapses the `http://` and `https://` copies of every page onto one address, which is what a search engine consolidates a site's signals against. Three details: @@ -193,11 +194,11 @@ See [Persistence](#persistence-1) below for the workflow. ### Rate limiting | Config key | Environment variable | Default | Description | | --- | --- | --- | --- | -| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window; 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 — when the server is directly reachable, clients can forge it. | +| `rateLimit.limit` | `RATE_LIMIT_LIMIT` | `5` | Requests admitted per client per window; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. | +| `rateLimit.window` | `RATE_LIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. | +| `rateLimit.trustForwardedFor` | `RATE_LIMIT_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 — when the server is directly reachable, clients can forge it. Pairs with `https.trustForwardedProto`; left `false` behind a proxy, every visitor shares the proxy's one bucket. | -> **Configured but unapplied.** The template ships no endpoint worth limiting, so `RateLimitMiddleware` is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one. +> **Configured but unapplied.** The template ships no endpoint worth limiting, so `RateLimitMiddleware` is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one; `docker-compose.yml` already sets `rateLimit.trustForwardedFor` alongside the HTTPS flag, so the buckets are keyed per visitor from the first deploy. ### Analytics The template ships analytics **off**: `analytics.websiteID` is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order: @@ -219,7 +220,7 @@ The tracker's origin is not a configuration key: it is single-sourced in code so ### Security headers | Config key | Environment variable | Default | | --- | --- | --- | -| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` | +| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'` | | `security.contentTypeOptions` | `SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` | | `security.frameOptions` | `SECURITY_FRAME_OPTIONS` | `DENY` | | `security.referrerPolicy` | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index c60adef..2d242f1 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -1,6 +1,5 @@ import Configuration import Hummingbird -import HummingbirdCompression import Localization import Logging import Persistence @@ -140,19 +139,11 @@ 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 -/// HTTPS-redirect middleware that bounces requests forwarded over plain HTTP to the canonical origin, the trailing-slash redirect middleware that -/// collapses each path onto its canonical form, the vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses -/// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's -/// language from its `Accept-Language` header (honouring the `lang` query override and the language a leading path segment names), the -/// not-found middleware that serves the not-found 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 — one per language the String Catalog serves — 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 not-found page, and the served static files. The HTTPS redirect sits directly beneath it, so a redirect carries the security -/// headers but skips the negotiation, compression, and file lookup it would otherwise pay for. The trailing-slash redirect follows it, ahead of the -/// routes and `FileMiddleware` that would otherwise answer both spellings of every path. +/// The chain below reads as its own list; the order is what does not. Security headers sit just inside request logging, so they reach every response a +/// client sees — pages, compressed bodies, the not-found page, the served files. The HTTPS redirect sits directly beneath, keeping those headers while +/// skipping the compression and file lookup it would otherwise pay for. The trailing-slash redirect follows, ahead of the routes and `FileMiddleware` +/// that would otherwise answer both spellings of every path. The language is negotiated by the responders that read it — the landing route and the +/// not-found middleware — rather than on every request past. /// - 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. @@ -196,10 +187,9 @@ private func router( ) TrailingSlashRedirectMiddleware() VaryMiddleware() - ResponseCompressionMiddleware( + CompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) - LocalizationMiddleware() NotFoundMiddleware( assetVersion: assetVersion, analytics: analytics diff --git a/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift index 5c8f037..6c5ac41 100644 --- a/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift +++ b/Services/Website/Sources/Library/Public/Contexts/WebsiteRequestContext.swift @@ -4,16 +4,13 @@ import NIOCore /// The website's request context. /// -/// Extends the core request storage with the negotiated language, defaulting to the default supported language until ``LocalizationMiddleware`` -/// resolves it from the request, and with the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client. -public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressRequestContext { +/// Extends the core request storage with the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client. +public struct WebsiteRequestContext: RemoteAddressRequestContext { // MARK: Properties /// The core request context storage Hummingbird requires. public var coreContext: CoreRequestContextStorage - /// The language identifier negotiated for the request. - public var language: String /// The address of the connected client, captured from the source channel. public let remoteAddress: SocketAddress? @@ -25,14 +22,7 @@ public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressReque source: Source, ) { self.coreContext = .init(source: source) - self.language = .empty self.remoteAddress = source.channel.remoteAddress } } - -// MARK: - Constants - -private extension String { - static let empty = "" -} diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index c6dde33..49bc715 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,6 +1,7 @@ import Foundation import Hummingbird import Infrastructure +import Localization /// Serves the website's root routes. /// @@ -13,10 +14,13 @@ import Infrastructure /// ``` /// /// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. -public struct RootController { +public struct RootController { // MARK: Properties + /// Negotiates the language the bare route answers in. + private let negotiate: Negotiate + /// The landing page, rendered once per supported language and reused for every request. private let responses: LocalizedHTMLCollectionResponse @@ -32,7 +36,13 @@ public struct RootController { siteOrigin: String? = nil, analytics: Analytics? = nil ) { - self.responses = .init(bundle: .module) { + self.negotiate = .init(bundle: .module) + // The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache + // and carry it too. + self.responses = .init( + bundle: .module, + variesOnAcceptLanguage: true + ) { IndexPage( locale: $0, assetVersion: assetVersion, @@ -80,26 +90,27 @@ private extension RootController { /// Handles a request for the landing page. /// - /// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``, falling back to the default language. + /// Renders the ``IndexPage`` in the language negotiated from the request — its `lang` query parameter, then the leading path segment, then + /// `Accept-Language` — falling back to the default language. /// - Parameters: /// - request: the incoming request. /// - context: the context the request is resolved against. - /// - Returns: the cached ``IndexPage`` response for the context's language. + /// - Returns: the cached ``IndexPage`` response for the negotiated language. @Sendable func index( request: Request, context: Context ) -> Response { responses.response( - for: context.language, + for: negotiate(for: request), request: request ) } /// Builds the handler serving the landing page in one fixed language, for the routes carrying the language in their path. /// - /// The path *is* the language choice, so the negotiated context language is ignored: a prefixed URL answers in its language for every - /// visitor and every crawler alike, which is what lets a search engine index it as that edition. + /// The path *is* the language choice, so nothing is negotiated: a prefixed URL answers in its language for every visitor and every crawler + /// alike, which is what lets a search engine index it as that edition. /// - Parameter language: the language the route serves. /// - Returns: the handler answering requests for that edition of the page. func index( diff --git a/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift b/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift deleted file mode 100644 index 009dc7b..0000000 --- a/Services/Website/Sources/Library/Public/Extensions/LocalizationMiddleware+Defaults.swift +++ /dev/null @@ -1,13 +0,0 @@ -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/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 37676d2..61ca5f3 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -1,3 +1,4 @@ +import CompressNIO import Configuration import Foundation import Hummingbird @@ -247,6 +248,51 @@ struct AppTests { } } + /// `CompressionMiddleware` stands in for Hummingbird's `ResponseCompressionMiddleware`, which appends to `Content-Encoding` without + /// checking for one: a pre-compressed page would ship as `gzip, gzip`. Decoding makes it visible — a doubly compressed page decodes once, + /// into bytes that are not HTML. + @Test + func `page to be served pre-compressed, and only once`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/", + method: .get, + headers: [.acceptEncoding: "gzip, deflate, br"] + ) { response in + #expect(response.status == .ok) + #expect(response.headers[values: .contentEncoding] == ["gzip"]) + + var body = response.body + let decoded = try body.decompress(with: .gzip()) + + #expect(String(buffer: decoded).hasPrefix("")) + // Complete before the first byte is written, so it is sized rather than chunked. + #expect(response.headers[.contentLength] == String(response.body.readableBytes)) + #expect(response.body.readableBytes < decoded.readableBytes) + } + } + } + + /// The compressed copy is only handed to a client that asked for it; everyone else gets the rendered bytes. + @Test + func `page to be served uncompressed when gzip is not accepted`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + try await client.execute( + uri: "/", + method: .get, + headers: [.acceptEncoding: "identity"] + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.contentEncoding] == nil) + #expect(String(buffer: response.body).hasPrefix("")) + } + } + } + @Test func `error page to be served when not found`() async throws { try await app( diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift index a07cc2d..c6f3043 100644 --- a/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/RootControllerTests.swift @@ -17,10 +17,6 @@ struct RootControllerTests { private let app: Application = .init(router: { let router = Router(context: WebsiteRequestContext.self) - router.addMiddleware { - LocalizationMiddleware() - } - router.addRoutes(RootController().routes) return router @@ -220,10 +216,6 @@ private extension RootControllerTests { ) -> some ApplicationProtocol { let router = Router(context: WebsiteRequestContext.self) - router.addMiddleware { - LocalizationMiddleware() - } - router.addRoutes(RootController( assetVersion: assetVersion, siteOrigin: siteOrigin, diff --git a/Services/Website/docker-compose.override.yml b/Services/Website/docker-compose.override.yml index 456c3f5..0ba253d 100644 --- a/Services/Website/docker-compose.override.yml +++ b/Services/Website/docker-compose.override.yml @@ -19,6 +19,7 @@ services: environment: LOG_LEVEL: debug HTTPS_TRUST_FORWARDED_PROTO: "false" + RATE_LIMIT_TRUST_FORWARDED_FOR: "false" DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory} DATABASE_HOST: postgres DATABASE_TLS: ${DATABASE_TLS:-off} diff --git a/Services/Website/docker-compose.yml b/Services/Website/docker-compose.yml index 3c5cf21..0aad753 100644 --- a/Services/Website/docker-compose.yml +++ b/Services/Website/docker-compose.yml @@ -21,9 +21,10 @@ services: HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-CCNWebsite} # Falls back to the policy the app ships with; set it in `.env` to allow the analytics origin, # which must match `String.Analytics.origin`. - SECURITY_CONTENT_SECURITY_POLICY: "${SECURITY_CONTENT_SECURITY_POLICY:-default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'}" + SECURITY_CONTENT_SECURITY_POLICY: "${SECURITY_CONTENT_SECURITY_POLICY:-default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'}" SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}" HTTPS_TRUST_FORWARDED_PROTO: "${HTTPS_TRUST_FORWARDED_PROTO:-true}" + RATE_LIMIT_TRUST_FORWARDED_FOR: "${RATE_LIMIT_TRUST_FORWARDED_FOR:-true}" DATABASE_DRIVER: ${DATABASE_DRIVER:-postgres} DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required} DATABASE_PORT: ${DATABASE_PORT:-5432}