diff --git a/.dockerignore b/.dockerignore index 5b74369..e4ba8fa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,7 +10,7 @@ **/.build **/.swiftpm -# Test sources +# Local database data directory. **/Tests/DB # Xcode project (not used by the Linux build) diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index 36494f9..9571378 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -1,25 +1,26 @@ # 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. +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. ## Overview | Role | Types | | --- | --- | | Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | -| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | +| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware` (negotiating from a `lang` query parameter, then a leading path segment, then `Accept-Language`), `NotFoundMiddleware` | | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | -| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives | -| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies | -| Analytics | `Analytics`, the tracker script `Attribute`s it derives, and the `origin` its preconnect hint targets | -| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` | +| 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` | | Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to | ## 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, the `document:` closure building a page for a locale, and a `Page` conformer's `metadata` plus its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive fully formed and absolute; composing them stays with the page. -- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready representation — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — and `Page` applies it without knowing the vocabulary. Page ``scripts`` and the tracker render as `defer`red head tags, with a `preconnect` hint for the tracker's cross-origin host. -- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `*+Defaults` extensions are the pattern. The open schema.org vocabularies extend the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, and a service adds its own. -- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. +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. What a type needs, it takes as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure that builds a page for a locale, a `Page` conformer's `metadata` and its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive absolute and fully formed — composing them stays with the page. The same holds for request semantics: only the caller knows whether it negotiates the language or pins it by route, so `variesOnAcceptLanguage:` declares whether the responses carry `Vary: Accept-Language`. +- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready form — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — which `Page` applies without knowing the vocabulary. A page's `scripts` and the tracker render as `defer`red head tags, the tracker preceded by a `preconnect` to its cross-origin host. +- **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`. ## Layout Sources are split by visibility, then by kind, one type per file: @@ -28,12 +29,12 @@ Sources/ ├── Public/ public API │ ├── Builders/ RouteCollectionBuilder │ ├── Enumerations/ AssetExtension -│ ├── Extensions/ addController, plus the default header names and values +│ ├── Extensions/ addController and Analytics.Event tagging, plus the default header names, rate limits, and header values │ ├── Methods/ FingerprintAssets -│ ├── Middlewares/ the five HTTP middlewares +│ ├── Middlewares/ the seven HTTP middlewares │ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController │ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse -│ └── Types/ Analytics, SocialCard, StructuredData (the latter two nesting their own types in SocialCard/ and StructuredData/) +│ └── Types/ Analytics, SocialCard, StructuredData — each nesting its own types in a folder of that name └── Internal/ ├── Extensions/ implementation details (the String separators) └── Types/ implementation details (FNV1aHash) @@ -44,8 +45,8 @@ Tests/ ``` ## Testing -Every suite carries a tag for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits. +Every suite carries a tag for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, `.type` — declared in `Tests/Utils/Extensions/Tag+Constants.swift`, so a run can be sliced by kind. A new suite takes the tag matching its subject, or adds one when none fits. ## Requirements - Swift 6.3 toolchain (`swift-tools-version:6.3`). -- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms). +- macOS 15, matching the sibling packages. The services deploy to Linux containers; the packages declare no UI platforms. diff --git a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift index bdcd25e..81b5fb1 100644 --- a/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift +++ b/Packages/Infrastructure/Sources/Public/Enumerations/AssetExtension.swift @@ -1,21 +1,29 @@ /// 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 { +public enum AssetExtension: String, CaseIterable, 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 JPEG image. + case jpg + /// A JavaScript file. + case js + /// An MPEG-4 video. + case mp4 + /// A Portable Network Graphics image. + case png /// A Scalable Vector Graphics image. case svg /// A plain text file. case txt /// A web application manifest file. case webmanifest + /// A WebP image. + case webp + /// A Web Open Font Format 2 font. + case woff2 /// An Extensible Markup Language file. case xml } @@ -30,12 +38,16 @@ public extension AssetExtension { var contentType: String { switch self { case .css: "text/css" - case .js: "text/javascript" - case .png: "image/png" case .ico: "image/vnd.microsoft.icon" + case .jpg: "image/jpeg" + case .js: "text/javascript" + case .mp4: "video/mp4" + case .png: "image/png" case .svg: "image/svg+xml" case .txt: "text/plain" case .webmanifest: "application/manifest+json" + case .webp: "image/webp" + case .woff2: "font/woff2" case .xml: "application/xml" } } @@ -45,6 +57,10 @@ public extension AssetExtension { switch self { case .css: "css" case .js: "js" + case .jpg, + .webp: "img" + case .mp4: "video" + case .woff2: "font" default: nil } } diff --git a/Packages/Infrastructure/Sources/Public/Extensions/AnalyticsEvent+Tagging.swift b/Packages/Infrastructure/Sources/Public/Extensions/AnalyticsEvent+Tagging.swift new file mode 100644 index 0000000..8207a05 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/AnalyticsEvent+Tagging.swift @@ -0,0 +1,24 @@ +import Elementary + +public extension Analytics.Event { + + // MARK: Methods + + /// The event's ``attributes`` as attributes of the tag reporting it. + /// + /// ```swift + /// a(.href(url)) { "Listen" } + /// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging()) + /// ``` + /// + /// - Returns: one HTML attribute per event attribute, applied verbatim. + func tagging() -> [HTMLAttribute] { + attributes.map { + .custom( + name: $0.name, + value: $0.value + ) + } + } + +} diff --git a/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift index 9f89f0a..71b5fc1 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift @@ -5,8 +5,12 @@ public extension 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-Robots-Tag` field name (not provided as a standard `HTTPField.Name`). + static let robotsTag = Self("X-Robots-Tag")! /// 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")! + /// The `X-Forwarded-Proto` field name (not provided as a standard `HTTPField.Name`). + static let xForwardedProto = Self("X-Forwarded-Proto")! } diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift new file mode 100644 index 0000000..3d615f2 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift @@ -0,0 +1,182 @@ +import Foundation +import HTTPTypes +import Hummingbird + +/// Answers a request forwarded over plain HTTP with `301 Moved Permanently` to the same path on the site's HTTPS origin. +/// +/// Behind a TLS-terminating proxy the server only sees plain HTTP, so the visitor's scheme survives only in the `X-Forwarded-Proto` header the proxy +/// sets. Redirecting collapses the `http://` and `https://` copies of every page onto one address — what a search engine consolidates a site's signals +/// against. +/// +/// Three deliberate choices: +/// - **`301`, not `302`.** A temporary redirect tells a crawler the HTTP address is the canonical one, so the HTTP URLs stay indexed. Browsers cache a +/// `301` for a long time, so the target must be settled before enabling this. +/// - **The target comes from ``Configuration/origin``, not the request's `Host` header.** A client cannot steer a configured origin, so it can neither +/// aim the redirect elsewhere nor poison a shared cache with the result. +/// - **`/.well-known/` is exempt.** A certificate authority looks there for an ACME challenge over plain HTTP; redirecting it away breaks renewal, and +/// the breakage surfaces only when the certificate expires. +/// +/// - Note: `Context` is the request context the middleware is resolved against. +public struct HTTPSRedirectMiddleware: Sendable { + + // MARK: Properties + + /// The origin the redirects point at, and whether the forwarded-protocol header is trusted. + private let configuration: Configuration + + /// Whether the middleware redirects at all, resolved once at initialization. + /// + /// An untrusted header disables it, and so does an origin that is not itself HTTPS: redirecting to a plain-HTTP origin would loop. + private let isEnabled: Bool + + // MARK: Initializers + + /// Creates an HTTPS-redirect middleware. + /// - Parameter configuration: the origin the redirects point at, and whether the forwarded-protocol header is trusted. + public init( + configuration: Configuration + ) { + self.configuration = configuration + self.isEnabled = configuration.trustForwardedProto + && configuration.origin.hasPrefix(.httpsPrefix) + } + +} + +// MARK: - RouterMiddleware + +extension HTTPSRedirectMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Redirects a request forwarded over plain HTTP, and passes every other request down the chain. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - next: the next responder in the middleware chain. + /// - Returns: the redirect, or the downstream response. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + guard + isEnabled, + isForwardedOverHTTP(request), + !isWellKnown(request) + else { + return try await next( + request, + context + ) + } + + var response = Response(status: .movedPermanently) + + response.headers[.location] = configuration.origin + target(of: request) + + return response + } + +} + +// MARK: - Helpers + +private extension HTTPSRedirectMiddleware { + + // MARK: Methods + + /// Whether the proxy reported the visitor's request as plain HTTP. + /// + /// Only the leftmost entry is read: a chain of proxies appends to the header, so that entry is the scheme the visitor used. The comparison is + /// case-insensitive, the header carrying a scheme name rather than a fixed-case token. + /// - Parameter request: the incoming request. + /// - Returns: `true` when the header's first entry names plain HTTP. + func isForwardedOverHTTP( + _ request: Request + ) -> Bool { + request.headers[.xForwardedProto]? + .split(separator: ",") + .first? + .trimmingCharacters(in: .whitespaces) + .lowercased() == .httpScheme + } + + /// Whether the request addresses the reserved well-known space, which is left on plain HTTP so an ACME challenge stays reachable. + /// - Parameter request: the incoming request. + /// - Returns: `true` when the path falls inside the well-known space. + func isWellKnown( + _ request: Request + ) -> Bool { + request.uri.path.hasPrefix(.wellKnownPrefix) + } + + /// The path and query the redirect preserves, so a visitor lands on the address they asked for. + /// + /// Rebuilt from the parsed components rather than the raw request target: a target in absolute form would append a second origin to the first. + /// - Parameter request: the incoming request. + /// - Returns: the path, followed by the query when the request carries one. + func target( + of request: Request + ) -> String { + guard + let query = request.uri.query, + !query.isEmpty + else { + return request.uri.path + } + + return request.uri.path + .querySeparator + query + } + +} + +// MARK: - Configuration + +extension HTTPSRedirectMiddleware { + /// The origin an ``HTTPSRedirectMiddleware`` redirects to, and whether it trusts the header telling it to. + public struct Configuration: Sendable { + + // MARK: Properties + + /// The public origin the redirects point at (scheme and host, no trailing slash). + /// + /// An origin that is not itself HTTPS disables the middleware rather than redirecting into a loop. + public let origin: String + + /// Whether the visitor's scheme is read from the `X-Forwarded-Proto` header. + /// + /// This is the middleware's only trigger. Enable it solely behind a reverse proxy that sets the header: on a directly reachable server the header + /// is client-supplied, so it stays off by default. + public let trustForwardedProto: Bool + + // MARK: Initializers + + /// Creates an HTTPS-redirect configuration. + /// - Parameters: + /// - origin: the public origin the redirects point at (scheme and host, no trailing slash). + /// - trustForwardedProto: whether the visitor's scheme is read from the `X-Forwarded-Proto` header. Defaults to `false`. + public init( + origin: String, + trustForwardedProto: Bool = false + ) { + self.origin = origin + self.trustForwardedProto = trustForwardedProto + } + + } +} + +// MARK: - String+Constants + +private extension String { + /// The forwarded-protocol value naming a request made over plain HTTP. + static let httpScheme = "http" + /// The scheme prefix a redirect target must carry for the redirect to terminate. + static let httpsPrefix = "https://" + /// The delimiter placed between a redirect target's path and its query. + static let querySeparator = "?" + /// The reserved path prefix left on plain HTTP, so an ACME challenge stays reachable. + static let wellKnownPrefix = "/.well-known/" +} diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift index 964909c..f8bb021 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/LocalizationMiddleware.swift @@ -5,11 +5,15 @@ import Localization /// Resolves the visitor's preferred language and records it on the request context. /// -/// Placed ahead of the localized responders in the middleware chain, it reads the request's `Accept-Language` header, negotiates the best supported -/// match (falling back to the default language), and stores it on the context's ``LocalizedRequestContext/language``. +/// 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 request is otherwise passed through untouched — the URL and routing are not affected — so each page is served at its existing path and varies its -/// content by header. +/// 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 @@ -49,7 +53,13 @@ extension LocalizationMiddleware: RouterMiddleware { ) 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] ) @@ -57,3 +67,13 @@ extension LocalizationMiddleware: RouterMiddleware { } } + +// 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 b5f51e6..e1ba556 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift @@ -7,6 +7,9 @@ import Hummingbird /// 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. +/// +/// 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 { // MARK: Properties @@ -27,6 +30,7 @@ public struct NotFoundMiddleware { self.responses = .init( bundle: bundle, status: .notFound, + variesOnAcceptLanguage: true, document: document ) } diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/TrailingSlashRedirectMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/TrailingSlashRedirectMiddleware.swift new file mode 100644 index 0000000..19b61e3 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/TrailingSlashRedirectMiddleware.swift @@ -0,0 +1,114 @@ +import HTTPTypes +import Hummingbird + +/// Answers a request whose path carries a trailing slash with `301 Moved Permanently` to the same path without one. +/// +/// The router matches `/page` and `/page/` alike, and `FileMiddleware` serves `/robots.txt/` as readily as `/robots.txt`, so without this +/// every address on the site answers under at least two URLs. A search engine treats those as separate pages competing with each other, and any link +/// earned by one is not credited to the other. Redirecting collapses them onto the form the pages already name as canonical. +/// +/// Two deliberate choices: +/// - **The `Location` is relative.** A relative target resolves against the request's own scheme and host, so the middleware needs no configured +/// origin and cannot be aimed elsewhere by a forged `Host` header. +/// - **Only `GET` and `HEAD` are redirected.** A client answering a `301` to a `POST` may repeat it as a `GET` and drop the body, so a form +/// submission is left to the route that already matches it. +/// +/// - Note: `Context` is the request context the middleware is resolved against. +public struct TrailingSlashRedirectMiddleware: Sendable { + + // MARK: Initializers + + /// Creates a trailing-slash redirect middleware. + public init() {} + +} + +// MARK: - RouterMiddleware + +extension TrailingSlashRedirectMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Redirects a `GET` or `HEAD` whose path carries a trailing slash, and passes every other request down the chain. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - next: the next responder in the middleware chain. + /// - Returns: the redirect, or the downstream response. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + let path = request.uri.path + let canonical = canonicalPath(of: path) + + guard + request.method == .get || request.method == .head, + canonical != path + else { + return try await next( + request, + context + ) + } + + var response = Response(status: .movedPermanently) + + response.headers[.location] = canonical + query(of: request) + + return response + } + +} + +// MARK: - Helpers + +private extension TrailingSlashRedirectMiddleware { + + // MARK: Methods + + /// The path with its trailing slashes removed, which is the form the pages name as their canonical URL. + /// + /// A path of nothing but slashes collapses to the root, so `//` redirects to `/` while `/` itself is left alone. + /// - Parameter path: the requested path. + /// - Returns: the canonical form of the path. + func canonicalPath( + of path: String + ) -> String { + var canonical = path + + while canonical.hasSuffix(.pathSeparator), canonical != .pathSeparator { + canonical.removeLast() + } + + return canonical + } + + /// The query the redirect preserves, so a campaign-tagged link survives the canonicalization. + /// - Parameter request: the incoming request. + /// - Returns: the query prefixed with its delimiter, or an empty string when the request carries none. + func query( + of request: Request + ) -> String { + guard + let query = request.uri.query, + !query.isEmpty + else { + return "" + } + + return .querySeparator + query + } + +} + +// MARK: - String+Constants + +private extension String { + /// The separator a canonical path never ends on, and the root path it collapses to. + static let pathSeparator = "/" + /// The delimiter placed between a redirect target's path and its query. + static let querySeparator = "?" +} diff --git a/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift index b497105..9a635c8 100644 --- a/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift +++ b/Packages/Infrastructure/Sources/Public/Responses/LocalizedHTMLCollectionResponse.swift @@ -7,8 +7,12 @@ import Localization /// A per-language collection of pre-rendered HTML responses. /// /// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring -/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and -/// `Vary: Accept-Language`, so shared caches key on the negotiated language instead of serving one language to everyone. +/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header naming the +/// language it was rendered in. +/// +/// `Vary: Accept-Language` is the caller's to declare, since only the caller knows how it picks the language: a responder that negotiates the +/// header sets it, so shared caches key on the language rather than serving one to everyone; a responder that pins the language by route does +/// not, since the header would announce a negotiation that never happens. public struct LocalizedHTMLCollectionResponse: Sendable { // MARK: Properties @@ -25,21 +29,27 @@ public struct LocalizedHTMLCollectionResponse: Sendable { /// - 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`. + /// - variesOnAcceptLanguage: whether the responses declare `Vary: Accept-Language`. Defaults to `false`; a responder that + /// negotiates the header passes `true`. /// - document: builds the document to render for a given locale. public init( bundle: Bundle, status: HTTPResponse.Status = .ok, + variesOnAcceptLanguage: Bool = false, document: (Locale) -> Document ) { self.list = .init(bundle: bundle) self.responses = list.all .reduce(into: [:]) { responses, language in + var headers: HTTPFields = [.contentLanguage: language] + + if variesOnAcceptLanguage { + headers[.vary] = "Accept-Language" + } + responses[language] = CachedHTMLResponse( status: status, - additionalHeaders: [ - .contentLanguage: language, - .vary: "Accept-Language", - ], + additionalHeaders: headers, document: document(.init( identifier: language )) diff --git a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift index 5a9f321..2754ffb 100644 --- a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift +++ b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift @@ -60,11 +60,10 @@ public struct Analytics: Sendable { // MARK: Computed - /// The tracker script's attributes: the website id and reporting domains, then each enabled behavior flag; disabled flags are omitted. + /// The tracker script's attributes: the website id, the reporting domains when set, then each enabled behavior flag; disabled flags are omitted. public var attributes: [Attribute] { var attributes: [Attribute] = [ - .init("data-website-id", value: websiteID), - .init("data-domains", value: domains) + .init("data-website-id", value: websiteID) ] if !domains.isEmpty { diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift index 0fe4a74..e8a8d3d 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift @@ -7,6 +7,9 @@ public struct SocialCard: Sendable { // MARK: Properties + /// The locales of the card's other language editions, in Open Graph's `language_TERRITORY` form; empty to omit their tags. + public let alternateLocales: [String] + /// The card's share image, or `nil` to omit its tags. public let image: Image? @@ -43,6 +46,7 @@ public struct SocialCard: Sendable { /// - siteName: the name of the site the card belongs to, or `nil` (the default) to omit its tag. /// - locale: the locale of the card's text, ideally in Open Graph's `language_TERRITORY` form (e.g. `en_US`), or `nil` (the default) /// to omit its tag. + /// - alternateLocales: the locales the card's page is also published in, in the same form; empty (the default) to omit their tags. /// - image: the card's share image, or `nil` (the default) to omit its tags. /// - type: the Open Graph type of the object the card describes. Defaults to `website`. /// - style: the layout a Twitter card scraper gives the card. Defaults to ``Style/summaryLargeImage``. @@ -52,10 +56,12 @@ public struct SocialCard: Sendable { url: String? = nil, siteName: String? = nil, locale: String? = nil, + alternateLocales: [String] = [], image: Image? = nil, type: String = "website", style: Style = .summaryLargeImage ) { + self.alternateLocales = alternateLocales self.image = image self.locale = locale self.siteName = siteName @@ -68,8 +74,8 @@ public struct SocialCard: Sendable { // MARK: Computed - /// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, and locale, then the image group, and - /// the Twitter card style last. A tag whose fact the card does not carry is left out. + /// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, locale, and alternate locales, then the + /// image group, and the Twitter card style last. A tag whose fact the card does not carry is left out. public var tags: [Tag] { let tags: [Tag?] = [ Tag(type, name: .type), @@ -78,7 +84,7 @@ public struct SocialCard: Sendable { summary.map { Tag($0, name: .description) }, url.map { Tag($0, name: .url) }, locale.map { Tag($0, name: .locale) }, - ] + (image?.tags ?? []) + [ + ] + alternateLocales.map { Tag($0, name: .localeAlternate) } + (image?.tags ?? []) + [ Tag(style.rawValue, name: .twitter), ] diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift index ec446a5..d286881 100644 --- a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift +++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift @@ -60,6 +60,8 @@ extension SocialCard.Tag { case imageWidth = "og:image:width" /// The `og:locale` tag, carrying the locale of the card's text. case locale = "og:locale" + /// The `og:locale:alternate` tag, carrying the locale of one other language edition of the card's page; repeated once per edition. + case localeAlternate = "og:locale:alternate" /// The `og:site_name` tag, carrying the name of the site the card belongs to. case siteName = "og:site_name" /// The `og:title` tag, carrying the card's title. diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift index 5e5addb..8ffb15b 100644 --- a/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift +++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift @@ -23,6 +23,20 @@ public struct StructuredData: Equatable, Sendable { self.nodes = nodes } + // MARK: Methods + + /// The `@id` of the `Organization` node the site-wide initializer builds. + /// + /// A page asserting a relationship to it — a `Person`'s `worksFor`, say — references this instead of spelling the fragment again, so the two + /// cannot drift. + /// - Parameter url: the absolute URL the site is served at, as passed to that initializer. + /// - Returns: the organization node's `@id`. + public static func organizationID( + forSiteURL url: String + ) -> String { + url + "#organization" + } + // MARK: Computed /// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`. @@ -46,21 +60,49 @@ public extension StructuredData { /// - Parameters: /// - name: the name of the organization and the site. /// - url: the absolute URL the site is served at. + /// - alternateName: the name the organization is also known by, or `nil` (the default) to omit its property. + /// - description: what the organization does, or `nil` (the default) to omit its property. + /// - areaServed: the area the organization serves, or `nil` (the default) to omit its property. + /// - email: the address the organization is written to, or `nil` (the default) to omit its property. /// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property. + /// - inLanguage: the language codes the site is published in; empty (the default) to omit the property. /// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property. + /// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property. init( name: String, url: String, + alternateName: String? = nil, + description: String? = nil, + areaServed: String? = nil, + email: String? = nil, logo: String? = nil, - profiles: [String] = [] + inLanguage: [String] = [], + profiles: [String] = [], + founder: String? = nil ) { - let id = url + "#organization" + let id = Self.organizationID(forSiteURL: url) var organization: [Property] = [ .init(.name, value: .string(name)), .init(.url, value: .string(url)), ] + if let alternateName { + organization.append(.init(.alternateName, value: .string(alternateName))) + } + + if let description { + organization.append(.init(.description, value: .string(description))) + } + + if let areaServed { + organization.append(.init(.areaServed, value: .string(areaServed))) + } + + if let email { + organization.append(.init(.email, value: .string(email))) + } + if let logo { organization.append(.init(.logo, value:.string(logo))) } @@ -72,6 +114,23 @@ public extension StructuredData { )) } + if let founder { + organization.append(.init(.founder, value: .reference(founder))) + } + + var website: [Property] = [ + .init(.name, value: .string(name)), + .init(.url, value: .string(url)), + .init(.publisher, value: .reference(id)), + ] + + if !inLanguage.isEmpty { + website.append(.init( + .inLanguage, + value: .array(inLanguage.map(Value.string)) + )) + } + self.init(nodes: [ .init( type: .organization, @@ -80,11 +139,7 @@ public extension StructuredData { ), .init( type: .website, - properties: [ - .init(.name, value: .string(name)), - .init(.url, value: .string(url)), - .init(.publisher, value: .reference(id)), - ] + properties: website ), ]) } diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift index fb52cb0..6ef5c1d 100644 --- a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift +++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift @@ -69,6 +69,18 @@ extension StructuredData.Property { // MARK: - Constants public extension StructuredData.Property.Name { + /// The name the thing a node describes is also known by. + static let alternateName: Self = "alternateName" + /// The area an organization serves. + static let areaServed: Self = "areaServed" + /// What the thing a node describes is or does. + static let description: Self = "description" + /// The address the thing a node describes is written to. + static let email: Self = "email" + /// The person who founded an organization. + static let founder: Self = "founder" + /// The language a creative work — a site, a page — is published in. + static let inLanguage: Self = "inLanguage" /// The absolute URL of an organization's logo. static let logo: Self = "logo" /// The name of the thing a node describes. diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift index 4ee5324..06b8a31 100644 --- a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift +++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift @@ -1,5 +1,5 @@ extension StructuredData { - /// A value of a ``Property``: a string, a list, a nested node, or a reference to another node. + /// A value of a ``Property``: a string, a number, a list, a nested node, or a reference to another node. /// /// Every string a value renders is escaped as a JSON literal with `<` escaped as well, so a value can never close the `script` /// tag embedding the payload it renders into. @@ -8,6 +8,8 @@ extension StructuredData { case array([Value]) /// A nested node, e.g. the place a schema.org event is located at. case node(Node) + /// A whole number, e.g. a list item's position, rendered unquoted so it reads as a number rather than as text. + case number(Int) /// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object. case reference(String) /// A string value. @@ -28,6 +30,8 @@ extension StructuredData.Value { "[\(values.map(\.fragment).joined(separator: .Separator.comma))]" case .node(let node): node.fragment + case .number(let number): + String(number) case .reference(let id): #"{"@id":\#(Self.literal(id))}"# case .string(let string): diff --git a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift index f1fff74..c0fbbb4 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Enumerations/AssetExtensionTests.swift @@ -32,6 +32,17 @@ struct AssetExtensionTests { #expect(fileExtension.folder == folder) } + // MARK: CaseIterable tests + + @Test + func `covers every case in the parameterised tests`() { + // `zip` stops at the shorter sequence, so a case missing from the arrays below is silently untested rather + // than failing — this is what catches that. + #expect(Self.extensions == AssetExtension.allCases) + #expect(Self.contentTypes.count == AssetExtension.allCases.count) + #expect(Self.folders.count == AssetExtension.allCases.count) + } + } // MARK: - Helpers @@ -42,32 +53,44 @@ private extension AssetExtensionTests { static let extensions: [AssetExtension] = [ .css, - .js, - .png, .ico, + .jpg, + .js, + .mp4, + .png, .svg, .txt, .webmanifest, + .webp, + .woff2, .xml ] static let contentTypes: [String] = [ "text/css", - "text/javascript", - "image/png", "image/vnd.microsoft.icon", + "image/jpeg", + "text/javascript", + "video/mp4", + "image/png", "image/svg+xml", "text/plain", "application/manifest+json", + "image/webp", + "font/woff2", "application/xml" ] static let folders: [String?] = [ "css", + nil, + "img", "js", + "video", nil, nil, nil, nil, - nil, + "img", + "font", nil ] diff --git a/Packages/Infrastructure/Tests/Cases/Public/Extensions/AnalyticsEventTaggingTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Extensions/AnalyticsEventTaggingTests.swift new file mode 100644 index 0000000..3501bb1 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Extensions/AnalyticsEventTaggingTests.swift @@ -0,0 +1,59 @@ +import Elementary +import Testing + +@testable import Infrastructure + +@Suite( + "Analytics.Event+Tagging extension", + .tags(.extension) +) +struct AnalyticsEventTaggingTests { + + // MARK: Methods tests + + @Test + func `applies the event name to the tag`() { + let markup = a(.href("/")) { "Listen" } + .attributes(contentsOf: Analytics.Event(name: "instagram").tagging()) + + #expect(markup.render().contains(#"data-umami-event="instagram""#)) + } + + @Test + func `applies every event attribute to the tag`() { + let event = Analytics.Event( + name: "playlist", + properties: ["set": "avc-xi"] + ) + + let markup = button {} + .attributes(contentsOf: event.tagging()) + let rendered = markup.render() + + #expect(rendered.contains(#"data-umami-event="playlist""#)) + #expect(rendered.contains(#"data-umami-event-set="avc-xi""#)) + } + + @Test + func `returns one attribute per event attribute`() { + let event = Analytics.Event( + name: "playlist", + properties: ["set": "avc-xi"] + ) + + let attributes: [HTMLAttribute] = event.tagging() + + #expect(attributes.count == event.attributes.count) + } + + @Test + func `applies the event name to an SVG tag`() { + // `SVGTag.path` is no `HTMLTrait.Attributes.Global`, so this stops compiling if the method narrows back to HTML tags. + let rendered = SVG.path {} + .attributes(contentsOf: Analytics.Event(name: "logo").tagging()) + .render() + + #expect(rendered.contains(#"data-umami-event="logo""#)) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/HTTPSRedirectMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/HTTPSRedirectMiddlewareTests.swift new file mode 100644 index 0000000..5296a98 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/HTTPSRedirectMiddlewareTests.swift @@ -0,0 +1,179 @@ +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Infrastructure + +@Suite( + "HTTPSRedirectMiddleware middleware", + .tags(.middleware) +) +struct HTTPSRedirectMiddlewareTests { + + // MARK: Functional tests + + @Test + func `redirects a request forwarded over plain http`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.status == .movedPermanently) + #expect(response.headers[.location] == "https://example.com/hello") + } + } + } + + @Test + func `preserves the path and the query of the redirected request`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello?utm_source=test&utm_medium=email", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.headers[.location] == "https://example.com/hello?utm_source=test&utm_medium=email") + } + } + } + + @Test + func `matches the forwarded scheme regardless of its casing`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "HTTP"] + ) { response in + #expect(response.status == .movedPermanently) + } + } + } + + @Test + func `reads the leftmost entry of a proxy chain`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "http, https"] + ) { response in + #expect(response.status == .movedPermanently) + } + } + } + + @Test + func `passes a request forwarded over https through`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "https"] + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.location] == nil) + } + } + } + + @Test + func `passes a request without the forwarded header through`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + } + + @Test + func `passes every request through when the header is not trusted`() async throws { + try await app( + configuration: .init( + origin: "https://example.com", + trustForwardedProto: false + ) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.status == .ok) + } + } + } + + @Test + func `leaves the well-known space on plain http`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/.well-known/acme-challenge/token", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.location] == nil) + } + } + } + + @Test + func `passes every request through when the origin is not itself https`() async throws { + try await app( + configuration: .init( + origin: "http://example.com", + trustForwardedProto: true + ) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.status == .ok) + } + } + } + +} + +// MARK: - Helpers + +private extension HTTPSRedirectMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose router applies the HTTPS-redirect middleware ahead of a `/hello` + /// route returning a plain body and a `/.well-known/acme-challenge/token` route standing in for + /// a certificate authority's challenge file. + func app( + configuration: HTTPSRedirectMiddleware.Configuration = .init( + origin: "https://example.com", + trustForwardedProto: true + ) + ) -> some ApplicationProtocol { + let router = Router() + + router.addMiddleware { + HTTPSRedirectMiddleware(configuration: configuration) + } + + router.get("hello") { _, _ in + "Hello!" + } + + router.get(".well-known/acme-challenge/token") { _, _ in + "token" + } + + return Application(router: router) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift index f246f5c..bb167a9 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/LocalizationMiddlewareTests.swift @@ -25,6 +25,9 @@ struct LocalizationMiddlewareTests { router.get("language") { _, context in context.language } + router.get("de/language") { _, context in + context.language + } return router }()) @@ -69,4 +72,44 @@ struct LocalizationMiddlewareTests { } } + @Test + func `honours a supported lang query parameter over the header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language?lang=de", + method: .get, + headers: [.acceptLanguage: "en"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + + /// A language's whole URL prefix answers in its language, so its not-found page does too. + @Test + func `pins the language a leading path segment names over the header`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/de/language", + method: .get, + headers: [.acceptLanguage: "en"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + + @Test + func `negotiates the header when the lang query parameter is unsupported`() async throws { + try await app.test(.router) { client in + try await client.execute( + uri: "/language?lang=fr", + method: .get, + headers: [.acceptLanguage: "de"] + ) { response in + #expect(String(buffer: response.body) == "de") + } + } + } + } diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/TrailingSlashRedirectMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/TrailingSlashRedirectMiddlewareTests.swift new file mode 100644 index 0000000..7b053bf --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/TrailingSlashRedirectMiddlewareTests.swift @@ -0,0 +1,150 @@ +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Infrastructure + +@Suite( + "TrailingSlashRedirectMiddleware middleware", + .tags(.middleware) +) +struct TrailingSlashRedirectMiddlewareTests { + + // MARK: Functional tests + + @Test + func `redirects a path carrying a trailing slash`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello/", + method: .get + ) { response in + #expect(response.status == .movedPermanently) + #expect(response.headers[.location] == "/hello") + } + } + } + + @Test + func `preserves the query of the redirected request`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello/?utm_source=test&utm_medium=email", + method: .get + ) { response in + #expect(response.headers[.location] == "/hello?utm_source=test&utm_medium=email") + } + } + } + + @Test + func `collapses a path of nothing but slashes onto the root`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "//", + method: .get + ) { response in + #expect(response.status == .movedPermanently) + #expect(response.headers[.location] == "/") + } + } + } + + @Test + func `strips every trailing slash at once`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello///", + method: .get + ) { response in + #expect(response.headers[.location] == "/hello") + } + } + } + + @Test + func `passes the canonical path through`() 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.headers[.location] == nil) + } + } + } + + @Test + func `leaves the root path alone`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.location] == nil) + } + } + } + + @Test + func `redirects a head request as it does a get`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello/", + method: .head + ) { response in + #expect(response.status == .movedPermanently) + #expect(response.headers[.location] == "/hello") + } + } + } + + @Test + func `passes a post through so its body survives`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello/", + method: .post + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.location] == nil) + } + } + } + +} + +// MARK: - Helpers + +private extension TrailingSlashRedirectMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose router applies the trailing-slash middleware ahead of a `/hello` + /// route answering both `GET` and `POST`, and a root route standing in for the landing page. + func app() -> some ApplicationProtocol { + let router = Router() + + router.addMiddleware { + TrailingSlashRedirectMiddleware() + } + + router.get("hello") { _, _ in + "Hello!" + } + + router.post("hello") { _, _ in + "Posted!" + } + + router.get("/") { _, _ in + "Root!" + } + + return Application(router: router) + } + +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift index 6849302..a1c2a1e 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift @@ -18,6 +18,7 @@ struct SocialCardTests { url: "https://site.example/", siteName: "A Site", locale: "en", + alternateLocales: ["nl_NL", "de_DE"], image: .init( url: "https://site.example/img/card.png", width: 2400, @@ -33,6 +34,8 @@ struct SocialCardTests { .init("A summary.", name: .description), .init("https://site.example/", name: .url), .init("en", name: .locale), + .init("nl_NL", name: .localeAlternate), + .init("de_DE", name: .localeAlternate), .init("https://site.example/img/card.png", name: .image), .init("2400", name: .imageWidth), .init("1260", name: .imageHeight), diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift index f0bfcb3..f7eac81 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift @@ -17,6 +17,7 @@ struct StructuredDataTests { name: "A Site", url: "https://site.example/", logo: "https://site.example/logo.png", + inLanguage: ["en", "nl"], profiles: [ "https://social.example/a-site", "https://videos.example/a-site", @@ -26,10 +27,64 @@ struct StructuredDataTests { #expect(data.payload == #"{"@context":"https://schema.org","@graph":["# + #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# + #""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# + - #"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"# + #"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"# ) } + @Test + func `carries the organization's alternate name, description, area, and address in that order`() { + let data = StructuredData( + name: "A Site", + url: "https://site.example/", + alternateName: "The Site", + description: "What the site is.", + areaServed: "A City", + email: "hello@site.example" + ) + + #expect(data.payload.contains( + #""name":"A Site","url":"https://site.example/","alternateName":"The Site","description":"What the site is.","areaServed":"A City","email":"hello@site.example""# + )) + } + + @Test + func `references the organization's founder by identifier`() { + let data = StructuredData( + name: "A Site", + url: "https://site.example/", + profiles: ["https://social.example/a-site"], + founder: "https://site.example/who#person" + ) + + // Last of the organization's properties, after the profiles. + #expect(data.payload.contains( + #""sameAs":["https://social.example/a-site"],"founder":{"@id":"https://site.example/who#person"}}"# + )) + } + + @Test + func `omits the founder when the data carries none`() { + let data = StructuredData( + name: "A Site", + url: "https://site.example/" + ) + + #expect(!data.payload.contains("founder")) + } + + @Test + func `derives the organization's identifier from the site URL`() { + let url = "https://site.example/" + let data = StructuredData( + name: "A Site", + url: url + ) + + // Another page references the organization by this, so it must name the node the initializer actually builds. + #expect(StructuredData.organizationID(forSiteURL: url) == "https://site.example/#organization") + #expect(data.payload.contains(##""@id":"\##(StructuredData.organizationID(forSiteURL: url))""##)) + } + @Test func `omits the properties of the facts minimal data does not carry`() { let data = StructuredData( @@ -120,6 +175,9 @@ struct StructuredDataTests { #expect(StructuredData.Value.array([.string("A"), .string("B")]).fragment == #"["A","B"]"#) #expect(StructuredData.Value.reference("https://site.example/#organization").fragment == #"{"@id":"https://site.example/#organization"}"#) #expect(StructuredData.Value.node(.init(type: "Place", properties: [])).fragment == #"{"@type":"Place"}"#) + // Unquoted, or a consumer reads the position of a list item as text and cannot order by it. + #expect(StructuredData.Value.number(1).fragment == "1") + #expect(StructuredData.Value.number(-3).fragment == "-3") } @Test diff --git a/Packages/Localization/README.md b/Packages/Localization/README.md index 5128cc4..7be3a52 100644 --- a/Packages/Localization/README.md +++ b/Packages/Localization/README.md @@ -1,11 +1,11 @@ # Localization -The server-side localization toolkit the **Loud** services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation. +The server-side localization toolkit the platform's services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation. ## Overview | Role | Types | | --- | --- | | Lookup | `Localize`, a bundle-bound localizer that resolves a catalog key for an explicit locale | -| Negotiation | `Negotiate`, which picks the best supported language from an `Accept-Language` header per RFC 9110 | +| Negotiation | `Negotiate`, which picks the best supported language from an explicit request or an `Accept-Language` header per RFC 9110 | | Languages | `LanguageList`, the supported and default languages a bundle's String Catalog defines | | Diagnostics | `CatalogState`, the outcome of reading the catalog (`loaded`, `missing`, or `undecodable`) | diff --git a/Packages/Localization/Sources/Public/Methods/Negotiate.swift b/Packages/Localization/Sources/Public/Methods/Negotiate.swift index ee41bed..c18694e 100644 --- a/Packages/Localization/Sources/Public/Methods/Negotiate.swift +++ b/Packages/Localization/Sources/Public/Methods/Negotiate.swift @@ -24,18 +24,32 @@ public struct Negotiate: Sendable { // MARK: Methods - /// Picks the best supported language for the given `Accept-Language` header value. + /// Picks the best supported language for the given explicit request and `Accept-Language` header value. /// /// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`. + /// A `requested` language — a deliberate choice, such as a language switcher's query parameter — wins over the header when it matches a + /// supported language, on the same terms a header tag matches; an unsupported value is ignored, so it cannot select a language the catalog + /// does not serve. + /// /// The header is parsed into its language ranges, which are ordered by descending `q` weight as RFC 9110 prescribes: an entry without a weight /// counts as 1, entries weighted 0 are "not acceptable" and dropped, and equal weights keep the header order. Each tag is then matched against the /// supported languages in turn — first by an exact match, then by its primary language subtag, so `de-AT` resolves to a supported `de` — while the /// `*` wildcard accepts the default language. When the header is absent or matches nothing, the default language is returned. - /// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any. + /// - Parameters: + /// - requested: an explicitly requested language identifier, if any; it overrides the header when supported. + /// - acceptLanguage: the raw `Accept-Language` header value, if any. /// - Returns: the identifier of the supported language to serve. public func callAsFunction( + requested: String? = nil, acceptLanguage language: String? ) -> String { + if + let requested, + let match = match(requested, in: list.all) + { + return match + } + guard let language else { return list.default } diff --git a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift index f7cb0bc..6adc16c 100644 --- a/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift +++ b/Packages/Localization/Tests/Cases/Public/Methods/NegotiateTests.swift @@ -127,4 +127,44 @@ struct NegotiateTests { #expect(language == "en") } + @Test + func `honours a supported requested language over the header`() { + let language = negotiate( + requested: "de", + acceptLanguage: "en" + ) + + #expect(language == "de") + } + + @Test + func `matches a regional requested language by its primary subtag`() { + let language = negotiate( + requested: "de-AT", + acceptLanguage: "en" + ) + + #expect(language == "de") + } + + @Test + func `ignores an unsupported requested language`() { + let language = negotiate( + requested: "fr", + acceptLanguage: "de" + ) + + #expect(language == "de") + } + + @Test + func `ignores an empty requested language`() { + let language = negotiate( + requested: "", + acceptLanguage: "de" + ) + + #expect(language == "de") + } + } diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md index c3cb5e8..977375c 100644 --- a/Packages/Persistence/README.md +++ b/Packages/Persistence/README.md @@ -1,5 +1,5 @@ # Persistence -The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe. +The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the platform's services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe. ## Overview | Role | Types | @@ -8,7 +8,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da | Service | `Service`, which builds the `Fluent` service configured for the chosen driver | | Migrations | `PrepareDB`, the single registrar declaring every migration, in order | | Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline | -| Scaffolding (internal) | `ExampleRecord`, `CreateExampleRecord`, and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model | +| Scaffolding | The internal `ExampleRecord` and `CreateExampleRecord`, and the public `Example` snapshot and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model | ## Design rules - **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. The Website service's `ConfigReader+Properties` has the mapping. @@ -39,6 +39,8 @@ Tests/ plaintext-only and silent fake PostgreSQL servers, and the suite Tag constants ``` +> **Exception:** `Example` and `ExampleRepository` are `public` despite sitting under `Internal/`, so they are API a service can call. Move them to `Public/Models` and `Public/Repositories` when the first real domain model replaces them. + ## Testing The suite runs against the in-memory backend by default, so `swift test` needs no database. The PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at one (with optional `POSTGRES_TEST_PORT`, `POSTGRES_TEST_NAME`, `POSTGRES_TEST_USERNAME`, and `POSTGRES_TEST_PASSWORD`); it reverts its migrations afterwards, leaving a shared database as it was found: ```sh diff --git a/Packages/Utility/README.md b/Packages/Utility/README.md index 5f62ff0..9e8b0f5 100644 --- a/Packages/Utility/README.md +++ b/Packages/Utility/README.md @@ -1,5 +1,5 @@ # Utility -The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework. +The general-purpose helpers the platform's services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework. ## Overview | Role | Types | diff --git a/Services/Website/.dockerignore b/Services/Website/.dockerignore index 1f3233b..2a1db0d 100644 --- a/Services/Website/.dockerignore +++ b/Services/Website/.dockerignore @@ -1,7 +1,16 @@ +# Applies only when this directory is itself the build context. The Dockerfile +# builds from the repository root (it copies the sibling Packages/), so today's +# builds read the root .dockerignore instead — keep the two in step. + .build .swiftpm -.DS_Store .env.local docker-compose.* Makefile -README.md \ No newline at end of file +README.md + +# The double star matches at every depth; a bare pattern only covers the context root. +**/.DS_Store + +# Local database data directory (bind-mounted by Compose) +Tests/DB diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index df4967d..c4bea90 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -39,6 +39,10 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ && apt-get install -y libjemalloc-dev \ && rm -rf /var/lib/apt/lists/* +# Pin git to HTTP/1.1 for the dependency clones. GitHub answers the git-upload-pack POST with a 401 when it is sent +# over HTTP/2 by the git 2.43 that Noble ships, which SPM reports as a clone failure asking for a username. +RUN git config --global http.version HTTP/1.1 + # Set up a build area WORKDIR /build diff --git a/Services/Website/README.md b/Services/Website/README.md index ba146e6..ff10daa 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -3,8 +3,8 @@ 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). -- Negotiates each request's language from 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. +- 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. - 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`. @@ -38,14 +38,16 @@ The persistence backend runs as a `Fluent` service inside the application's Serv Requests pass through the middleware chain in this order (outermost first), then reach the routes: ``` LogRequestsMiddleware - → SecurityHeadersMiddleware (security headers on every response) - → VaryMiddleware (marks every response as varying on Accept-Encoding) - → ResponseCompressionMiddleware (gzip/deflate above the size threshold) - → LocalizationMiddleware (negotiates the request's language) - → NotFoundMiddleware (renders the localized not-found page on .notFound) - → FileMiddleware (serves Resources/Static) -RootController (GET / → landing page) -HealthController (GET /health → liveness, GET /health/ready → readiness) + → SecurityHeadersMiddleware (security headers on every response) + → 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) +HealthController (GET /health → liveness, GET /health/ready → readiness) ``` The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free. @@ -53,17 +55,41 @@ The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` ### Page metadata Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags. -Four of those are page-authored, optional, and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values): +Four of those are page-authored and optional. `IndexPage` supplies `canonicalURL`; the other three are **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values): | Property | Renders as | Notes | | --- | --- | --- | | `summary` | `` | The page's one-line description. | -| `canonicalURL` | `` | Absolute URL. | -| `socialCard` | Open Graph + Twitter `` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. | -| `structuredData` | `