Initial commit.

This commit is contained in:
2026-08-19 23:19:08 +02:00
commit 22e737d9c2
153 changed files with 11680 additions and 0 deletions
@@ -0,0 +1,59 @@
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 `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.
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
// 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
context.language = negotiate(
acceptLanguage: request.headers[.acceptLanguage]
)
return try await next(request, context)
}
}
@@ -0,0 +1,74 @@
import Elementary
import Foundation
import Hummingbird
/// Serves a custom error page for requests that match neither a route nor a static file.
///
/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested
/// path and responds with the rendered error page and a `404 Not Found` status. The page is served in the language stored on the context by
/// ``LocalizationMiddleware``, falling back to the default language.
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
/// The error page, rendered once per supported language and reused for every not-found response.
private let responses: LocalizedHTMLCollectionResponse
// MARK: Initializers
/// Creates a not-found middleware.
/// - Parameters:
/// - bundle: the bundle whose String Catalog names the languages the page is rendered for.
/// - document: builds the error page to render for a given locale.
public init<Document: HTMLDocument>(
bundle: Bundle,
document: (Locale) -> Document
) {
self.responses = .init(
bundle: bundle,
status: .notFound,
document: document
)
}
}
// MARK: - RouterMiddleware
extension NotFoundMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain, rendering the error page if it results in a not-found response.
///
/// Any error other than `.notFound` is rethrown unchanged.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response, or the rendered error page with a `404 Not Found` status.
/// - Throws: any non-not-found error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
do {
return try await next(request, context)
}
catch let error {
guard
let responseError = error as? any HTTPResponseError,
responseError.status == .notFound
else {
throw error
}
return responses.response(
for: context.language,
request: request
)
}
}
}
@@ -0,0 +1,291 @@
import Foundation
import HTTPTypes
import Hummingbird
import NIOCore
import Synchronization
/// Rejects a client's requests with `429 Too Many Requests` once they exceed a fixed-window rate limit.
///
/// Added to the routes that must not be hammered the subscription endpoint, an unauthenticated database write it admits up to the configured limit of
/// requests per client per window, and answers the excess with `429 Too Many Requests` and a `Retry-After` header naming the seconds until the
/// window resets.
///
/// A client is keyed by the first `X-Forwarded-For` entry when the ``Configuration`` trusts it, by the connection's remote address otherwise, and by
/// one shared bucket when neither names the client. The counters live in memory with a bounded capacity, so a flood of distinct clients cannot grow the
/// store without bound and each instance of a multi-instance deployment enforces its own budget.
public struct RateLimitMiddleware<Context: RequestContext>: Sendable {
// MARK: Properties
/// The fixed-window request counters, keyed by client.
private let buckets: Buckets
/// The limits the middleware enforces.
private let configuration: Configuration
// MARK: Initializers
/// Creates a rate-limit middleware.
/// - Parameter configuration: the limits the middleware enforces. Defaults to a budget suited to a form endpoint: a handful of requests per
/// client per minute.
public init(
configuration: Configuration = .init()
) {
self.buckets = .init(
limit: configuration.limit,
window: configuration.window
)
self.configuration = configuration
}
}
// MARK: - RouterMiddleware
extension RateLimitMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain while the client stays within its budget, and answers it with `429 Too Many Requests` and a `Retry-After`
/// header once it does not.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response, or the `429` rejection.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
let admission = buckets.admit(
client(
for: request,
context: context
)
)
switch admission {
case .admitted:
return try await next(
request,
context
)
case .limited(let retryAfter):
var response = Response(status: .tooManyRequests)
response.headers[.retryAfter] = String(max(1, retryAfter.components.seconds))
return response
}
}
}
// MARK: - Helpers
private extension RateLimitMiddleware {
// MARK: Methods
/// The key identifying the requesting client: the first `X-Forwarded-For` entry when trusted, the connection's remote address otherwise, and one
/// bucket shared by every unidentifiable client when neither is known.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - Returns: the client key the request is counted under.
func client(
for request: Request,
context: Context
) -> String {
if
configuration.trustForwardedFor,
let forwarded = request.headers[.xForwardedFor]?
.split(separator: ",")
.first?
.trimmingCharacters(in: .whitespaces),
!forwarded.isEmpty
{
return forwarded
}
if let address = (context as? any RemoteAddressRequestContext)?.remoteAddress {
return address.ipAddress
?? address.description
}
return .unidentified
}
}
// MARK: - Buckets
private extension RateLimitMiddleware {
/// The outcome of asking the ``Buckets`` store to admit a request.
enum Admission {
/// The request is within the client's budget.
case admitted
/// The client exhausted its budget; the payload is the time until its window resets.
case limited(retryAfter: Duration)
}
/// The fixed-window request counters, keyed by client.
///
/// The counters sit behind a mutex rather than an actor: an admission is a handful of dictionary operations, so the lock is held only briefly and the
/// calling task never suspends requests skip the executor hop an actor would add on every pass through the middleware.
final class Buckets: Sendable {
// MARK: Properties
/// The maximum number of clients tracked at once, bounding the store's memory.
private let capacity: Int
/// The per-client counters: the start of the client's current window and its request count.
private let counters: Mutex<[String: (start: ContinuousClock.Instant, count: Int)]>
/// The number of requests admitted per client per ``window``.
private let limit: Int
/// The length of the fixed window the ``limit`` applies to.
private let window: Duration
// MARK: Initializers
/// Creates a counter store.
/// - Parameters:
/// - limit: the number of requests admitted per client per window.
/// - window: the length of the fixed window the limit applies to.
/// - capacity: the maximum number of clients tracked at once.
init(
limit: Int,
window: Duration,
capacity: Int = 10_000
) {
self.capacity = capacity
self.counters = .init([:])
self.limit = limit
self.window = window
}
// MARK: Functions
/// Counts a request against the client's current window and admits it while the count stays within the limit.
/// - Parameter client: the key the request is counted under.
/// - Returns: the admission outcome.
func admit(
_ client: String
) -> Admission {
let now = ContinuousClock.now
return counters.withLock { counters in
if let counter = counters[client], now < counter.start.advanced(by: window) {
guard counter.count < limit else {
return .limited(retryAfter: now.duration(to: counter.start.advanced(by: window)))
}
counters[client] = (counter.start, counter.count + 1)
return .admitted
}
makeRoom(
in: &counters,
at: now
)
counters[client] = (now, 1)
return .admitted
}
}
// MARK: Methods
/// Keeps the store within its capacity before a new client is tracked: expired windows are dropped first, and when the store remains full, the
/// oldest live windows are evicted in one batch a tenth of the capacity so the sort that finds them runs once per batch of admissions
/// instead of once per request while a flood of distinct clients keeps the store full.
/// - Parameters:
/// - counters: the counters the room is made in.
/// - now: the instant the expiry is evaluated against.
private func makeRoom(
in counters: inout [String: (start: ContinuousClock.Instant, count: Int)],
at now: ContinuousClock.Instant
) {
guard counters.count >= capacity else {
return
}
counters = counters.filter {
now < $0.value.start.advanced(by: window)
}
let headroom = max(1, capacity / 10)
let excess = counters.count - (capacity - headroom)
guard excess > 0 else {
return
}
let oldest = counters
.sorted { $0.value.start < $1.value.start }
.prefix(excess)
for counter in oldest {
counters.removeValue(forKey: counter.key)
}
}
}
}
// MARK: - Configuration
extension RateLimitMiddleware {
/// The limits a ``RateLimitMiddleware`` enforces.
public struct Configuration: Sendable {
// MARK: Properties
/// The number of requests admitted per client per ``window``.
public let limit: Int
/// Whether a client is keyed by the first `X-Forwarded-For` entry.
///
/// Enable it only behind a reverse proxy that sets the header there, the connection's own address would name the proxy for every visitor,
/// sharing one budget across all of them. On a directly reachable server the header is client-supplied, so trusting it lets a client forge fresh keys
/// at will.
public let trustForwardedFor: Bool
/// The length of the fixed window the ``limit`` applies to.
public let window: Duration
// MARK: Initializers
/// Creates a rate-limit configuration.
/// - Parameters:
/// - limit: the number of requests admitted per client per window.
/// - window: the length of the fixed window the limit applies to.
/// - trustForwardedFor: whether a client is keyed by the first `X-Forwarded-For` entry.
public init(
limit: Int = .RateLimit.limit,
window: Duration = .seconds(Int.RateLimit.window),
trustForwardedFor: Bool = false
) {
self.limit = limit
self.trustForwardedFor = trustForwardedFor
self.window = window
}
}
}
// MARK: - String+Constants
private extension String {
/// The bucket shared by every client the middleware cannot identify.
static let unidentified = "unidentified"
}
@@ -0,0 +1,154 @@
import HTTPTypes
import Hummingbird
/// Stamps a set of security-related HTTP headers onto every response.
///
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever response bubbles back up the rendered pages, the
/// error page produced by ``NotFoundMiddleware``, and every static file served by `FileMiddleware` so the browser applies the strict, hardened
/// interpretation of the content instead of its lenient legacy defaults.
///
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for every request, so the per-request cost is a handful of
/// header copies.
public struct SecurityHeadersMiddleware<Context: RequestContext> {
// MARK: Properties
/// The precomputed headers applied to every response.
private let fields: HTTPFields
// MARK: Initializers
/// Creates a security-headers middleware.
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened baseline suitable for a static site, with
/// `Strict-Transport-Security` left off (see ``Configuration``).
public init(
configuration: Configuration = .init()
) {
self.fields = configuration.fields
}
}
// MARK: - RouterMiddleware
extension SecurityHeadersMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and stamps the configured security headers onto the response on the way back up.
///
/// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the controllers) are converted to their response
/// here rather than left to the router: the router converts them above the middleware chain, where the response would escape these headers.
/// Existing values for the same header names are replaced so downstream middleware cannot leave a weaker policy in place.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response with the security headers applied.
/// - Throws: any downstream error that does not render as an HTTP response.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var response: Response
do {
response = try await next(
request,
context
)
} catch let error as any HTTPResponseError {
response = try error.response(
from: request,
context: context
)
}
for field in fields {
response.headers[field.name] = field.value
}
return response
}
}
// MARK: - Helpers
private extension SecurityHeadersMiddleware.Configuration {
// MARK: Computed
/// The configuration expressed as the headers to apply, omitting any whose value is `nil`.
var fields: HTTPFields {
var fields = HTTPFields()
fields[.contentSecurityPolicy] = contentSecurityPolicy
fields[.xContentTypeOptions] = contentTypeOptions
fields[.frameOptions] = frameOptions
fields[.referrerPolicy] = referrerPolicy
fields[.permissionsPolicy] = permissionsPolicy
fields[.strictTransportSecurity] = strictTransportSecurity
return fields
}
}
// MARK: - Configuration
extension SecurityHeadersMiddleware {
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
///
/// Each property maps to a single response header. A `nil` value omits that header entirely, which is how `Strict-Transport-Security` stays
/// disabled by default: it is only safe to send over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be switched on
/// (via configuration) only in TLS-terminated production.
public struct Configuration: Sendable {
// MARK: Properties
/// The `Content-Security-Policy` value (controls which sources the browser will load).
public let contentSecurityPolicy: String?
/// The `X-Content-Type-Options` value (disables MIME sniffing when set to `nosniff`).
public let contentTypeOptions: String?
/// The `X-Frame-Options` value (controls whether the page may be framed).
public let frameOptions: String?
/// The `Referrer-Policy` value (controls how much referrer information is shared).
public let referrerPolicy: String?
/// The `Permissions-Policy` value (gates access to powerful browser features).
public let permissionsPolicy: String?
/// The `Strict-Transport-Security` value, or `nil` to omit the header (the default).
public let strictTransportSecurity: String?
// MARK: Initializers
/// Creates a security-headers configuration.
///
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except `strictTransportSecurity`, which defaults
/// to `nil` (omitted). Pass `nil` for any header to drop it from the response.
/// - Parameters:
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
/// - contentTypeOptions: the `X-Content-Type-Options` value.
/// - frameOptions: the `X-Frame-Options` value.
/// - referrerPolicy: the `Referrer-Policy` value.
/// - permissionsPolicy: the `Permissions-Policy` value.
/// - strictTransportSecurity: the `Strict-Transport-Security` value, or `nil` to omit it.
public init(
contentSecurityPolicy: String? = String.Security.contentSecurityPolicy,
contentTypeOptions: String? = String.Security.contentTypeOptions,
frameOptions: String? = String.Security.frameOptions,
referrerPolicy: String? = String.Security.referrerPolicy,
permissionsPolicy: String? = String.Security.permissionsPolicy,
strictTransportSecurity: String? = nil
) {
self.contentSecurityPolicy = contentSecurityPolicy
self.contentTypeOptions = contentTypeOptions
self.frameOptions = frameOptions
self.referrerPolicy = referrerPolicy
self.permissionsPolicy = permissionsPolicy
self.strictTransportSecurity = strictTransportSecurity
}
}
}
@@ -0,0 +1,71 @@
import Foundation
import HTTPTypes
import Hummingbird
/// Appends header names to the `Vary` header of every response passing through.
///
/// Placed just above the response-compression middleware, it marks each response as varying on `Accept-Encoding`: the static files and pre-rendered
/// pages are served with `Cache-Control: public`, so without the signal a shared cache could store a compressed body and hand it to a client that
/// never advertised support for the encoding.
///
/// Names already present on a response's `Vary` header such as the `Accept-Language` the localized pages carry are kept, and duplicates are not
/// added.
public struct VaryMiddleware<Context: RequestContext>: Sendable {
// MARK: Properties
/// The header names appended to every response's `Vary` header.
private let names: [String]
// MARK: Initializers
/// Creates a vary middleware.
/// - Parameter fields: the header names appended to every response's `Vary` header. Defaults to `Accept-Encoding`, the request header
/// the response-compression middleware acts on.
public init(
fields: [HTTPField.Name] = [.acceptEncoding]
) {
self.names = fields.map(\.rawName)
}
}
// MARK: - RouterMiddleware
extension VaryMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and appends the configured names to the response's `Vary` header on the way back up.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response with the `Vary` names applied.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var response = try await next(
request,
context
)
var vary = response.headers[.vary]?
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) } ?? []
for name in names where !vary.contains(where: {
$0.caseInsensitiveCompare(name) == .orderedSame
}) {
vary.append(name)
}
response.headers[.vary] = vary.joined(separator: ", ")
return response
}
}