Project updates from Template (#1)
This PR contains the latest updates from the generic Website template, which have been added while working on #loud-amsterdam. Reviewed-on: #1 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -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<Context: RequestContext>: 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/"
|
||||
}
|
||||
@@ -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<Context: LocalizedRequestContext> {
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Context: LocalizedRequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -27,6 +30,7 @@ public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||
self.responses = .init(
|
||||
bundle: bundle,
|
||||
status: .notFound,
|
||||
variesOnAcceptLanguage: true,
|
||||
document: document
|
||||
)
|
||||
}
|
||||
|
||||
+114
@@ -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<Context: RequestContext>: 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 = "?"
|
||||
}
|
||||
Reference in New Issue
Block a user