Renamed the Web package as Infrastructure (#25)
This PR contains the work done to rename the _Web_ package as _Infrastructure_, to provide a clear naming and purpose to this particular package within the project. To provide further details about the work: * Infrastructure * Asset fingerprinting: an FNV-1a token derived from the static files directory, appended as ?v= to asset URLs so deploys bust caches; pre-rendered pages also revalidate via weak ETags. * New middlewares: fixed-window RateLimitMiddleware (per-client budgets keyed by trusted X-Forwarded-For or remote address) and VaryMiddleware (Accept-Encoding on every response); SecurityHeadersMiddleware now also stamps error responses. * Auto-generated HEAD endpoints, cache max-age configuration, and Docker build/Compose refinements. * Protocols and scaffolding: Asset/AssetExtension, the Page protocol (viewport, stylesheets, scripts, versioned URLs), and LocalizedRequestContext. * Rate limiter's counter store swapped from an actor to a Mutex (no executor hop per request) with amortized batch eviction instead of O(n²) scans under client floods. * FingerprintAssets reports unreadable files to a logger instead of silently producing a token that never busts their cache. Reviewed-on: rock-n-code/loud-amsterdam#25 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
@@ -26,6 +26,8 @@ func application(
|
||||
logger: logger
|
||||
)
|
||||
let fluent = persistence()
|
||||
|
||||
let fingerprintAssets = FingerprintAssets(logger: logger)
|
||||
let prepareDB = PrepareDB()
|
||||
|
||||
await prepareDB(for: fluent)
|
||||
@@ -33,8 +35,10 @@ func application(
|
||||
var app = Application(
|
||||
router: router(
|
||||
staticFilesPath: reader.staticFilesPath,
|
||||
assetVersion: fingerprintAssets(reader.staticFilesPath),
|
||||
cacheControl: reader.cacheControl,
|
||||
compressionMinResponseSize: reader.compressionMinResponseSize,
|
||||
rateLimit: reader.rateLimit,
|
||||
securityHeaders: reader.securityHeaders,
|
||||
logLevel: reader.logLevel,
|
||||
probe: Probe(fluent: fluent)
|
||||
@@ -117,25 +121,31 @@ private func logger(
|
||||
/// Builds the application's router.
|
||||
///
|
||||
/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
|
||||
/// response-compression middleware that compresses responses larger than `minimumResponseSizeToCompress` when the client advertises support,
|
||||
/// the localization middleware that negotiates the request's language from its `Accept-Language` header, the not-found middleware that serves the
|
||||
/// error page, and the static file middleware that serves the contents of `staticFilesPath` (tagging responses with the given `cacheControl`
|
||||
/// directives), then adds the `RootController` routes that render the landing page and the `HealthController` routes that serve the health check.
|
||||
/// vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses
|
||||
/// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's
|
||||
/// language from its `Accept-Language` header, the not-found middleware that serves the error page, and the static file middleware that serves the
|
||||
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then adds the `RootController` routes that
|
||||
/// render the landing page, the `SubscriptionController` routes that register newsletter subscriptions, and the `HealthController` routes
|
||||
/// that serve the health check.
|
||||
///
|
||||
/// The security-headers middleware sits just inside request logging so it covers every response that reaches a client — the landing page, the compressed
|
||||
/// responses, the rendered error page, and the served static files.
|
||||
/// - Parameters:
|
||||
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
|
||||
/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned.
|
||||
/// - cacheControl: the cache-control directives applied to the served static files.
|
||||
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
|
||||
/// - rateLimit: the rate limit applied to the subscription endpoint.
|
||||
/// - securityHeaders: the security headers applied to every response.
|
||||
/// - logLevel: the level the request-logging middleware logs at.
|
||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
||||
/// - Returns: the configured router.
|
||||
private func router(
|
||||
staticFilesPath: String,
|
||||
assetVersion: String?,
|
||||
cacheControl: CacheControl,
|
||||
compressionMinResponseSize: Int,
|
||||
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||
logLevel: Logger.Level,
|
||||
probe: Probe
|
||||
@@ -152,11 +162,14 @@ private func router(
|
||||
SecurityHeadersMiddleware(
|
||||
configuration: securityHeaders
|
||||
)
|
||||
VaryMiddleware()
|
||||
ResponseCompressionMiddleware(
|
||||
minimumResponseSizeToCompress: compressionMinResponseSize
|
||||
)
|
||||
LocalizationMiddleware()
|
||||
NotFoundMiddleware()
|
||||
NotFoundMiddleware(
|
||||
assetVersion: assetVersion
|
||||
)
|
||||
FileMiddleware(
|
||||
staticFilesPath,
|
||||
cacheControl: cacheControl
|
||||
@@ -164,7 +177,9 @@ private func router(
|
||||
}
|
||||
|
||||
router.addController {
|
||||
RootController<AppRequestContext>()
|
||||
RootController<AppRequestContext>(
|
||||
assetVersion: assetVersion
|
||||
)
|
||||
HealthController<AppRequestContext>(
|
||||
probe: probe
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Configuration
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
import Logging
|
||||
import Persistence
|
||||
import WebsiteLibrary
|
||||
@@ -15,9 +16,16 @@ package extension ConfigReader {
|
||||
|
||||
/// The `Cache-Control` policy applied to static files, grouped by media type.
|
||||
///
|
||||
/// The max-ages are read from the `cache.maxAge.text`, `cache.maxAge.image`, and `cache.maxAge.default` keys. Text files (CSS,
|
||||
/// JavaScript, plain text) additionally require revalidation once stale; images and everything else are served public with their max-age alone.
|
||||
/// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and
|
||||
/// `cache.maxAge.default` keys. Stylesheets and scripts are referenced through fingerprinted URLs (see `FingerprintAssets`) and
|
||||
/// fonts are immutable subset files, so all three are served long-lived and `immutable` — a deploy busts them by changing the URL, never by
|
||||
/// revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation once stale; images and
|
||||
/// everything else are served public with their max-age alone. The groups match in order, so the specific types precede the `text` category.
|
||||
var cacheControl: CacheControl {
|
||||
let maxAgeAsset = int(
|
||||
forKey: .Cache.maxAgeAsset,
|
||||
default: .Cache.maxAgeAsset
|
||||
)
|
||||
let maxAgeDefault = int(
|
||||
forKey: .Cache.maxAgeDefault,
|
||||
default: .Cache.maxAgeDefault
|
||||
@@ -32,6 +40,9 @@ package extension ConfigReader {
|
||||
)
|
||||
|
||||
return .init([
|
||||
(.textCss, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.textJavascript, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.font, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
|
||||
(.image, [.public, .maxAge(maxAgeImage)]),
|
||||
(.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
|
||||
@@ -110,6 +121,28 @@ package extension ConfigReader {
|
||||
)
|
||||
}
|
||||
|
||||
/// The rate limit applied to the subscription endpoint, built from the `rateLimit.*` keys.
|
||||
///
|
||||
/// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When
|
||||
/// `rateLimit.trustForwardedFor` is set, clients are keyed by the first `X-Forwarded-For` entry —
|
||||
/// enable it only behind a reverse proxy that sets the header, since clients can forge it otherwise.
|
||||
var rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration {
|
||||
.init(
|
||||
limit: int(
|
||||
forKey: .RateLimit.limit,
|
||||
default: .RateLimit.limit
|
||||
),
|
||||
window: .seconds(int(
|
||||
forKey: .RateLimit.window,
|
||||
default: .RateLimit.window
|
||||
)),
|
||||
trustForwardedFor: bool(
|
||||
forKey: .RateLimit.trustForwardedFor,
|
||||
default: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// The security headers middleware configuration, built from the `security.*` keys.
|
||||
///
|
||||
/// Every header value has a default except `Strict-Transport-Security`, which is only sent when `security.strictTransportSecurity`
|
||||
|
||||
Reference in New Issue
Block a user