Merge branch 'setup'
@@ -1,10 +1,4 @@
|
||||
# Copy this file to `.env` and adjust values as needed.
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Compose reads `.env` automatically to fill the ${VAR} placeholders in
|
||||
# docker-compose.yml. The Website app ALSO reads a `.env` file at runtime via
|
||||
# swift-configuration (allowMissing: true), so any extra app config keys placed
|
||||
# here are picked up by the running service too.
|
||||
# Local `.env` file used solely for Development purposes.
|
||||
|
||||
# --- Image / deployment -------------------------------------------------------
|
||||
|
||||
@@ -39,7 +33,7 @@ IMAGE_TAG=latest
|
||||
HTTP_SERVER_NAME=SiteWebsite
|
||||
|
||||
# Log verbosity: trace | debug | info | notice | warning | error | critical
|
||||
LOG_LEVEL=info
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# --- Persistence ----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
useCustomWorkingDirectory = "YES"
|
||||
customWorkingDirectory = "/Users/logan/Documents/Development/Platforms/Röck+Cöde/Loud/Services/Website"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
# ================================
|
||||
# Asset image
|
||||
# ================================
|
||||
FROM node:22-alpine AS assets
|
||||
|
||||
ARG ESBUILD_VERSION=0.28.1
|
||||
ARG OXIPNG_VERSION=9.1.5
|
||||
ARG SVGO_VERSION=4.0.2
|
||||
|
||||
# Install the minifiers in their own layer, so they are cached across asset changes.
|
||||
# The oxipng pin is fuzzy (=~) so Alpine package revision bumps (-r0, -r1, ...) do not break the build when the base
|
||||
# image advances.
|
||||
RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \
|
||||
&& npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}"
|
||||
|
||||
# Copy the static files and minify the JS/CSS/SVG sources and losslessly recompress the PNG images in place, keeping
|
||||
# their names so the URL paths derived from the StaticFile enumeration stay unchanged.
|
||||
WORKDIR /static
|
||||
COPY ./Services/Website/Resources/Static .
|
||||
RUN esbuild --minify --allow-overwrite --outdir=css css/*.css \
|
||||
&& esbuild --minify --allow-overwrite --outdir=js js/*.js \
|
||||
&& oxipng --opt max --strip safe *.png \
|
||||
&& svgo --recursive --folder .
|
||||
|
||||
# Export stage: `docker build --target assets-export --output <dir>` writes the minified static files to <dir> for
|
||||
# local inspection.
|
||||
FROM scratch AS assets-export
|
||||
COPY --from=assets /static /
|
||||
|
||||
# ================================
|
||||
# Build image
|
||||
# ================================
|
||||
@@ -14,16 +43,23 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||
WORKDIR /build
|
||||
|
||||
# First just resolve dependencies.
|
||||
# This creates a cached layer that can be reused as long as the manifests do
|
||||
# not change. The Website package depends on the local Localization package via
|
||||
# a relative path, so its manifest must be present for resolution to succeed.
|
||||
# This creates a cached layer that can be reused as long as the manifests do not change. The Website package depends on
|
||||
# the local Localization package via a relative path, so its manifest must be present for resolution to succeed.
|
||||
COPY ./Packages/Localization/Package.swift ./Packages/Localization/
|
||||
COPY ./Packages/Persistence/Package.swift ./Packages/Persistence/
|
||||
COPY ./Packages/Infrastructure/Package.swift ./Packages/Infrastructure/
|
||||
COPY ./Packages/Utility/Package.swift ./Packages/Utility/
|
||||
COPY ./Services/Website/Package.swift ./Services/Website/Package.resolved ./Services/Website/
|
||||
RUN swift package --package-path ./Services/Website resolve
|
||||
|
||||
# Copy entire repo into container
|
||||
COPY . .
|
||||
# Copy only the Swift inputs needed for a release build. Static assets are built in the assets stage and copied into
|
||||
# staging after the binary is produced.
|
||||
COPY ./Packages/Infrastructure/Sources ./Packages/Infrastructure/Sources
|
||||
COPY ./Packages/Localization/Sources ./Packages/Localization/Sources
|
||||
COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources
|
||||
COPY ./Packages/Utility/Sources ./Packages/Utility/Sources
|
||||
COPY ./Services/Website/Sources ./Services/Website/Sources
|
||||
COPY ./Services/Website/Tests ./Services/Website/Tests
|
||||
|
||||
# Build the application, with optimizations, with static linking, and using jemalloc
|
||||
RUN swift build --package-path ./Services/Website -c release \
|
||||
@@ -43,9 +79,12 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
|
||||
# Copy resources bundled by SPM to staging area
|
||||
RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
|
||||
|
||||
# Copy the static files directory (served by FileMiddleware) if it exists
|
||||
# Create the static files directory (served by FileMiddleware) and fill it with the minified copies from the assets stage
|
||||
RUN mkdir -p ./Resources/Static
|
||||
COPY --from=assets /static ./Resources/Static
|
||||
|
||||
# Ensure that by default, neither the directory nor any of its contents are writable.
|
||||
RUN [ -d /build/Services/Website/Resources ] && { mv /build/Services/Website/Resources ./Resources && chmod -R a-w ./Resources; } || true
|
||||
RUN chmod -R a-w ./Resources
|
||||
|
||||
# ================================
|
||||
# Run image
|
||||
@@ -59,6 +98,7 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||
&& apt-get -q install -y \
|
||||
libjemalloc2 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tzdata \
|
||||
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
|
||||
# libcurl4 \
|
||||
|
||||
@@ -105,6 +105,17 @@ db-reset: ## Stop and remove the local database instance and delete its data vol
|
||||
--profile database down mariadb \
|
||||
--volumes
|
||||
|
||||
# --- Assets minification ------------------------------------------------------
|
||||
|
||||
.PHONY: ast-minify
|
||||
ast-minify: ## Preview the minified JS/CSS assets in .build/minified
|
||||
@docker build \
|
||||
--target assets-export \
|
||||
--output .build/minified \
|
||||
--file Dockerfile \
|
||||
../..
|
||||
@echo "Minified assets written to .build/minified"
|
||||
|
||||
# --- Registry deployment ------------------------------------------------------
|
||||
|
||||
.PHONY: img-check
|
||||
|
||||
@@ -20,12 +20,18 @@ let package = Package(
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(
|
||||
path: "../../Packages/Infrastructure"
|
||||
),
|
||||
.package(
|
||||
path: "../../Packages/Localization"
|
||||
),
|
||||
.package(
|
||||
path: "../../Packages/Persistence"
|
||||
),
|
||||
.package(
|
||||
path: "../../Packages/Utility"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/elementary-swift/elementary.git",
|
||||
from: "0.6.0"
|
||||
@@ -55,6 +61,7 @@ let package = Package(
|
||||
.executableTarget(
|
||||
name: "Website",
|
||||
dependencies: [
|
||||
.byName(name: "Localization"),
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "WebsiteLibrary"),
|
||||
.product(
|
||||
@@ -75,8 +82,10 @@ let package = Package(
|
||||
.target(
|
||||
name: "WebsiteLibrary",
|
||||
dependencies: [
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Localization"),
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "Utility"),
|
||||
.product(
|
||||
name: "Configuration",
|
||||
package: "swift-configuration"
|
||||
@@ -104,6 +113,7 @@ let package = Package(
|
||||
.testTarget(
|
||||
name: "WebsiteTests",
|
||||
dependencies: [
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Website"),
|
||||
.product(
|
||||
name: "HummingbirdTesting",
|
||||
@@ -115,6 +125,8 @@ let package = Package(
|
||||
.testTarget(
|
||||
name: "WebsiteLibraryTests",
|
||||
dependencies: [
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "WebsiteLibrary"),
|
||||
.product(
|
||||
name: "Elementary",
|
||||
|
||||
@@ -6,8 +6,8 @@ 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.
|
||||
- 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).
|
||||
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`.
|
||||
- Returns a custom HTML 404 page, localized like the landing page, for any request that matches neither a route nor a static file.
|
||||
- Serves static files (CSS, JS, icons, manifest, `robots.txt`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
|
||||
- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
|
||||
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
|
||||
- Stamps a hardened set of security headers on every response.
|
||||
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key.
|
||||
@@ -22,24 +22,27 @@ Two SwiftPM targets:
|
||||
| Target | Kind | Path | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
|
||||
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, middlewares, pages, cached responses, and configuration helpers. |
|
||||
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, the pages, the `StaticFile` asset catalog, the request context, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
|
||||
|
||||
The `Website` executable depends on two local packages:
|
||||
The `Website` executable depends on four local packages:
|
||||
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
|
||||
- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`.
|
||||
- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
|
||||
- `Utility` (`Packages/Utility`) — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
|
||||
|
||||
The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
|
||||
|
||||
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
|
||||
```
|
||||
LogRequestsMiddleware
|
||||
→ SecurityHeadersMiddleware (security headers on every response)
|
||||
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
||||
→ LocalizationMiddleware (negotiates the request's language)
|
||||
→ NotFoundMiddleware (renders the localized 404 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)
|
||||
→ 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)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -47,8 +50,13 @@ Configuration is read through [swift-configuration](https://github.com/apple/swi
|
||||
the following sources, **highest precedence first**:
|
||||
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
|
||||
2. Process environment variables
|
||||
3. A `.env` file in the working directory (optional)
|
||||
4. Built-in defaults
|
||||
3. A `.env.local` file in the working directory (optional)
|
||||
4. A `.env` file in the working directory (optional)
|
||||
5. Built-in defaults
|
||||
|
||||
The two files play different roles:
|
||||
- **`.env`** (git-ignored) holds your deployment values — it is the file the Makefile and Compose read for the `${VAR}` placeholders, and typically selects the MySQL/MariaDB backend.
|
||||
- **`.env.local`** (tracked) holds the local development values — the in-memory database and `debug` logging, plus the image/deployment placeholders the Makefile falls back to when no `.env` exists. Because it sits *above* `.env`, a direct launch (`swift run` or a debugger) runs against the local values even when `.env` points at a deployment, the same way `docker-compose.override.yml` overrides the base Compose file. Compose itself never reads it, and the production image does not ship it — only the executable, its resources, and the static files are staged into the final stage.
|
||||
|
||||
### Environment variable naming
|
||||
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`,
|
||||
@@ -59,7 +67,8 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
|
||||
### Static file caching
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for text assets (CSS, JS, plain text); also marked `must-revalidate`. |
|
||||
| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS) and fonts; also marked `immutable`. The pages reference CSS/JS through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. |
|
||||
| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for unversioned text assets (e.g. `robots.txt`); also marked `must-revalidate`. |
|
||||
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). |
|
||||
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else (e.g. the web manifest). |
|
||||
|
||||
@@ -100,6 +109,14 @@ See [Persistence](#persistence-1) below for the workflow.
|
||||
| --- | --- | --- | --- |
|
||||
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
|
||||
|
||||
### Rate limiting
|
||||
These keys configure the `RateLimitMiddleware` budget for the upcoming newsletter subscription endpoint. They are read at startup, but the middleware is **not yet attached to any route** — the values have no effect until the subscription endpoint ships.
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
|
||||
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
|
||||
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — otherwise clients can forge it; leave it off when the server is directly reachable. |
|
||||
|
||||
### Security headers
|
||||
| Config key | Environment variable | Default |
|
||||
| --- | --- | --- |
|
||||
@@ -119,6 +136,8 @@ swift run Website # binds to Hummingbird's default 127.0.0.1:8080
|
||||
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
|
||||
```
|
||||
|
||||
A direct run picks up the local development overrides from `.env.local` (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — e.g. `DATABASE_DRIVER=mysql swift run Website` — since process environment variables outrank both files.
|
||||
|
||||
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
|
||||
```sh
|
||||
make pkg-build # swift build
|
||||
@@ -158,13 +177,15 @@ DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
|
||||
### Health checks
|
||||
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` is a readiness check that runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure.
|
||||
|
||||
`docker-compose.yml` configures the `website` container healthcheck against `GET /health`, so Compose reports process liveness without coupling container health to database reachability.
|
||||
|
||||
## Testing
|
||||
```sh
|
||||
make pkg-test
|
||||
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
|
||||
```
|
||||
|
||||
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Website.xctestplan` covers two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
|
||||
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. The `Tests/Website.xctestplan` covers the service's two targets — `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests) — plus the local packages' suites: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`.
|
||||
|
||||
The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database:
|
||||
```sh
|
||||
@@ -192,6 +213,42 @@ docker compose -f docker-compose.yml pull
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Static assets
|
||||
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions so asset output is reproducible for a given Dockerfile commit:
|
||||
- CSS and JS are minified with [esbuild](https://esbuild.github.io).
|
||||
- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng) — the output is pixel-identical, only encoded smaller.
|
||||
- The SVG icon is minified with [svgo](https://github.com/svg/svgo).
|
||||
|
||||
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The sources in the repository stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one, which builds the same Dockerfile — serves the optimized copies.
|
||||
|
||||
The Dockerfile copies only package manifests and Swift source inputs into the release build stage. Static assets are copied from the separate `assets` stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release binary build cache.
|
||||
|
||||
Preview the optimized output locally — requires only Docker and writes to the git-ignored `.build/minified`:
|
||||
```sh
|
||||
make ast-minify
|
||||
```
|
||||
|
||||
### Icons
|
||||
All icons are renditions of the star mark in `icon.svg`, which is the canonical source — there is no external design file to regenerate from.
|
||||
|
||||
| File | Size | Used by | Dark mode |
|
||||
| --- | --- | --- | --- |
|
||||
| `icon.svg` | vector | Tab icon in modern browsers (preferred over the ICO) | Adapts: an embedded `prefers-color-scheme` style flips the accent paths from `#000` to `#f3ecf5`. |
|
||||
| `favicon.ico` | 32×32 | Tab icon in browsers without SVG favicon support (Safari) | Theme-neutral **by design**: it is the orange star *without* the accent paths, so one raster reads on both themes. Keep it accent-free when regenerating. |
|
||||
| `icon-192.png`, `icon-512.png` | 192/512 | `site.webmanifest` install icons and splash screens | None (no platform mechanism); full artwork, light rendering, on a transparent background. |
|
||||
| `apple-touch-icon.png` | 180×180 | iOS home-screen bookmarks | None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black. |
|
||||
|
||||
The landing page pairs the icons with two `theme-color` metas: `#0c0710` (media-qualified for dark, listed first — the first matching entry wins) and `#fafafa` as the fallback.
|
||||
|
||||
The raster icons are committed binaries, regenerated from `icon.svg` on demand via a throwaway container (no local toolchain needed) — e.g. for the 512px rendition:
|
||||
```sh
|
||||
docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
|
||||
apk add --no-cache imagemagick librsvg oxipng &&
|
||||
magick -background none -density 256 /work/icon.svg -depth 8 PNG32:/work/icon-512.png &&
|
||||
oxipng --opt max --strip safe /work/icon-512.png'
|
||||
```
|
||||
(`-density` scales the 192px viewBox: `96 × target ÷ 192`. For `favicon.ico`, rasterize a star-only copy of the SVG at 32px and pack it with `icotool -c --raw`.)
|
||||
|
||||
### Required variables
|
||||
The Makefile and Compose files read these from a `.env` file (or the environment). Provide your own values — do **not** commit secrets.
|
||||
| Variable | Used for |
|
||||
@@ -205,8 +262,8 @@ The Makefile and Compose files read these from a `.env` file (or the environment
|
||||
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
||||
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
|
||||
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
|
||||
| `DATABASE_DRIVER` | `inMemory` (default) or `mysql`. Set to `mysql` in production to use a managed database. |
|
||||
| `DATABASE_DRIVER` | `inMemory` or `mysql`. The production Compose file defaults it to `mysql`; the local override defaults back to the in-memory backend. |
|
||||
| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. |
|
||||
| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `require` in production). |
|
||||
| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `prefer` in production — set `require` when the database enforces TLS, so a stripped connection fails instead of silently downgrading to plaintext). |
|
||||
|
||||
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
|
||||
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 766 B After Width: | Height: | Size: 700 B |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 192 192"><path fill="#e08524" d="M75.3 73.4H18.4l45.3 34.3L48.3 163l46.1-32.3 48.2 34.6-16.9-58.3 44.9-33.6H115l-20.5-55-19.2 55z"/><path d="m96.7 18.8 18.2 8.2 16.5 44.3h-15.1L96.7 18.8zm-47 146 18.7 9.9 42.6-29.9-16.5-11.4-44.8 31.4zm79.1-56.8 17.4 9.4 18.6 60.1-19.7-11.3-16.3-58.2z"/><path d="m173.1 74.3 17.8 9.2-44.7 34-17.4-9.4 44.3-33.8z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 192 192"><style>.accent{fill:#000}@media (prefers-color-scheme:dark){.accent{fill:#f3ecf5}}</style><path fill="#e08524" d="M75.3 73.4H18.4l45.3 34.3L48.3 163l46.1-32.3 48.2 34.6-16.9-58.3 44.9-33.6H115l-20.5-55-19.2 55z"/><path class="accent" d="m96.7 18.8 18.2 8.2 16.5 44.3h-15.1L96.7 18.8zm-47 146 18.7 9.9 42.6-29.9-16.5-11.4-44.8 31.4zm79.1-56.8 17.4 9.4 18.6 60.1-19.7-11.3-16.3-58.2z"/><path class="accent" d="m173.1 74.3 17.8 9.2-44.7 34-17.4-9.4 44.3-33.8z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 549 B |
@@ -3,3 +3,5 @@
|
||||
# Allow crawling of all content
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://loud.amsterdam/sitemap.xml
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
"short_name": "",
|
||||
"name": "",
|
||||
"icons": [{
|
||||
"src": "icon.png",
|
||||
"src": "icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
}, {
|
||||
"src": "icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}],
|
||||
"start_url": "/?utm_source=homescreen",
|
||||
"background_color": "#fafafa",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://loud.amsterdam/</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -1,6 +1,5 @@
|
||||
import Configuration
|
||||
import Hummingbird
|
||||
import Logging
|
||||
|
||||
/// The entry point of the website executable.
|
||||
///
|
||||
@@ -12,12 +11,16 @@ struct App {
|
||||
/// Loads the configuration and runs the mode it selects.
|
||||
///
|
||||
/// The configuration is read from the providers in precedence order: command-line arguments first, then process environment variables, then a
|
||||
/// `.env` file when one is present, and finally the in-memory defaults (currently just the server name).
|
||||
/// `.env.local` file when one is present, then a `.env` file when one is present, and finally the in-memory defaults.
|
||||
static func main() async throws {
|
||||
let reader = try await ConfigReader(
|
||||
providers: [
|
||||
CommandLineArgumentsProvider(),
|
||||
EnvironmentVariablesProvider(),
|
||||
EnvironmentVariablesProvider(
|
||||
environmentFilePath: ".env.local",
|
||||
allowMissing: true
|
||||
),
|
||||
EnvironmentVariablesProvider(
|
||||
environmentFilePath: ".env",
|
||||
allowMissing: true
|
||||
@@ -28,9 +31,9 @@ struct App {
|
||||
]
|
||||
)
|
||||
|
||||
// Migrate-and-exit mode runs the registered migrations against the configured backend and returns,
|
||||
// so a shared database is migrated by a single deliberate invocation (`--database-migrate`) rather
|
||||
// than by every booting instance.
|
||||
// Migrate-and-exit mode runs the registered migrations against the configured backend and returns, so a shared
|
||||
// database is migrated by a single deliberate invocation (`--database-migrate`) rather than by every booting
|
||||
// instance.
|
||||
guard !reader.migrate else {
|
||||
try await migration(
|
||||
reader: reader
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import Configuration
|
||||
import Hummingbird
|
||||
import HummingbirdCompression
|
||||
import Localization
|
||||
import Logging
|
||||
import Persistence
|
||||
import Infrastructure
|
||||
import WebsiteLibrary
|
||||
|
||||
/// Builds the website application.
|
||||
///
|
||||
/// Reads the log level, server name, static files location, minimum response size to compress, and security headers from the configuration, then assembles
|
||||
/// the router, server configuration, and logger. It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
|
||||
/// the router, server configuration, and logger. It warns when the localization catalog cannot be read, since pages would serve raw localization keys.
|
||||
/// It also builds the persistence driver, registers its migrations, and attaches the `Fluent` service so it starts
|
||||
/// and stops alongside the HTTP server; the ephemeral in-memory backend is migrated on startup, while a MySQL/MariaDB backend is migrated out of
|
||||
/// band (so a shared database is never migrated on boot).
|
||||
/// - Parameter reader: the configuration reader the values are read from.
|
||||
@@ -16,15 +19,27 @@ import WebsiteLibrary
|
||||
func application(
|
||||
reader: ConfigReader
|
||||
) async -> some ApplicationProtocol {
|
||||
let languages = LanguageList()
|
||||
let logger = logger(
|
||||
serverName: reader.serverName,
|
||||
logLevel: reader.logLevel
|
||||
)
|
||||
|
||||
// A broken catalog degrades to serving raw localization keys rather than failing, so it is only ever visible to
|
||||
// visitors — surface it here instead.
|
||||
if languages.catalogState != .loaded {
|
||||
let isCatalogMissing = languages.catalogState == .missing
|
||||
|
||||
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
|
||||
}
|
||||
|
||||
let persistence = Service(
|
||||
driver: reader.driver,
|
||||
logger: logger
|
||||
)
|
||||
let fluent = persistence()
|
||||
|
||||
let fingerprintAssets = FingerprintAssets(logger: logger)
|
||||
let prepareDB = PrepareDB()
|
||||
|
||||
await prepareDB(for: fluent)
|
||||
@@ -32,8 +47,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)
|
||||
@@ -46,8 +63,8 @@ func application(
|
||||
|
||||
app.addServices(fluent)
|
||||
|
||||
// The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB
|
||||
// backend is left untouched here: a shared database is migrated out of band to avoid multi-instance races.
|
||||
// The in-memory backend is recreated on every launch, so it is migrated on startup. The MySQL/MariaDB backend is
|
||||
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
|
||||
if case .inMemory = reader.driver {
|
||||
app.beforeServerStarts {
|
||||
try await fluent.migrate()
|
||||
@@ -116,50 +133,67 @@ 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, 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
|
||||
) -> Router<AppRequestContext> {
|
||||
let router = Router(context: AppRequestContext.self)
|
||||
// HEAD siblings are generated for every GET route, so uptime monitors and crawlers probing with HEAD requests get
|
||||
// the page's status and headers instead of a 404.
|
||||
let router = Router(
|
||||
context: AppRequestContext.self,
|
||||
options: .autoGenerateHeadEndpoints
|
||||
)
|
||||
|
||||
router.addMiddleware {
|
||||
LogRequestsMiddleware(logLevel)
|
||||
SecurityHeadersMiddleware(
|
||||
configuration: securityHeaders
|
||||
)
|
||||
VaryMiddleware()
|
||||
ResponseCompressionMiddleware(
|
||||
minimumResponseSizeToCompress: compressionMinResponseSize
|
||||
)
|
||||
LocalizationMiddleware()
|
||||
NotFoundMiddleware()
|
||||
NotFoundMiddleware(
|
||||
assetVersion: assetVersion
|
||||
)
|
||||
FileMiddleware(
|
||||
staticFilesPath,
|
||||
cacheControl: cacheControl
|
||||
)
|
||||
}
|
||||
|
||||
router.addRoutes {
|
||||
RootController<AppRequestContext>().routes
|
||||
HealthController<AppRequestContext>(probe: probe).routes
|
||||
router.addController {
|
||||
RootController<AppRequestContext>(
|
||||
assetVersion: assetVersion
|
||||
)
|
||||
HealthController<AppRequestContext>(
|
||||
probe: probe
|
||||
)
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1,39 +1,6 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"error.heading" : {
|
||||
"comment" : "The not-found page's main heading.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"error.message" : {
|
||||
"comment" : "The not-found page's body text.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sorry, but the page you were trying to view does not exist."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"error.title" : {
|
||||
"comment" : "The not-found page's document title.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"index.greeting" : {
|
||||
"comment" : "The landing page's greeting paragraph.",
|
||||
"localizations" : {
|
||||
@@ -55,6 +22,39 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound.heading" : {
|
||||
"comment" : "The not-found page's main heading.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound.message" : {
|
||||
"comment" : "The not-found page's body text.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sorry, but the page you were trying to view does not exist."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound.title" : {
|
||||
"comment" : "The not-found page's document title.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
||||
@@ -1,46 +1,32 @@
|
||||
import Infrastructure
|
||||
|
||||
/// A static file shipped with the website service.
|
||||
///
|
||||
/// Each case identifies a file stored under the static files root (the `Resources/Static`
|
||||
/// directory) and served by Hummingbird's `FileMiddleware` middleware.
|
||||
enum StaticFile: CaseIterable, Sendable {
|
||||
/// The `js/app.js` script.
|
||||
case appJS
|
||||
/// The `css/error.css` stylesheet for the not-found page.
|
||||
case errorCSS
|
||||
/// Each case identifies a file name stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's
|
||||
/// `FileMiddleware` middleware. A name can be available with more than one extension (see ``fileExtensions``), each resolving to its own file.
|
||||
enum StaticFile: Asset, CaseIterable {
|
||||
/// The `apple-touch-icon.png` icon.
|
||||
case appleTouchIcon
|
||||
/// The `favicon.ico` icon.
|
||||
case faviconICO
|
||||
/// The `icon.png` icon.
|
||||
case iconPNG
|
||||
case favicon
|
||||
/// The `icon.svg` icon.
|
||||
case iconSVG
|
||||
case icon
|
||||
/// The `icon-192.png` icon for the web manifest.
|
||||
case icon192
|
||||
/// The `icon-512.png` icon for the web manifest.
|
||||
case icon512
|
||||
/// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
|
||||
case index
|
||||
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
|
||||
case notFound
|
||||
/// The `robots.txt` crawler directives.
|
||||
case robotsTXT
|
||||
case robots
|
||||
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
|
||||
case shared
|
||||
/// The `site.webmanifest` web application manifest.
|
||||
case siteWebmanifest
|
||||
/// The `css/style.css` stylesheet.
|
||||
case styleCSS
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
extension StaticFile {
|
||||
/// A file extension used by a ``StaticFile``.
|
||||
enum Extension: String, 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 Scalable Vector Graphics image.
|
||||
case svg
|
||||
/// A plain text file.
|
||||
case txt
|
||||
/// A web application manifest file.
|
||||
case webmanifest
|
||||
}
|
||||
case site
|
||||
/// The `sitemap.xml` crawler sitemap.
|
||||
case sitemap
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
@@ -49,88 +35,37 @@ extension StaticFile {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The file's content type.
|
||||
var contentType: String {
|
||||
switch fileExtension {
|
||||
case .css: "text/css"
|
||||
case .js: "text/javascript"
|
||||
case .png: "image/png"
|
||||
case .ico: "image/vnd.microsoft.icon"
|
||||
case .svg: "image/svg+xml"
|
||||
case .txt: "text/plain"
|
||||
case .webmanifest: "application/manifest+json"
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's extension.
|
||||
var fileExtension: Extension {
|
||||
/// The file extensions the file is available with.
|
||||
var fileExtensions: [AssetExtension] {
|
||||
switch self {
|
||||
case .errorCSS,
|
||||
.styleCSS: .css
|
||||
case .appJS: .js
|
||||
case .faviconICO: .ico
|
||||
case .iconPNG: .png
|
||||
case .iconSVG: .svg
|
||||
case .robotsTXT: .txt
|
||||
case .siteWebmanifest: .webmanifest
|
||||
case .appleTouchIcon,
|
||||
.icon192,
|
||||
.icon512: [.png]
|
||||
case .index,
|
||||
.notFound,
|
||||
.shared: [.css, .js]
|
||||
case .favicon: [.ico]
|
||||
case .icon: [.svg]
|
||||
case .robots: [.txt]
|
||||
case .site: [.webmanifest]
|
||||
case .sitemap: [.xml]
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's name, without extension.
|
||||
var fileName: String {
|
||||
switch self {
|
||||
case .appJS: "app"
|
||||
case .errorCSS: "error"
|
||||
case .faviconICO: "favicon"
|
||||
case .iconPNG,
|
||||
.iconSVG: "icon"
|
||||
case .robotsTXT: "robots"
|
||||
case .siteWebmanifest: "site"
|
||||
case .styleCSS: "style"
|
||||
}
|
||||
}
|
||||
|
||||
/// The path relative to the static files root (e.g. `"css/style.css"`).
|
||||
///
|
||||
/// This also matches the URL path the file is served at by `FileMiddleware`.
|
||||
var relativePath: String {
|
||||
let file = "\(fileName).\(fileExtension.rawValue)"
|
||||
|
||||
return subdirectory
|
||||
.map { "\($0)/\(file)" } ?? file
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Resolves the file's path against the given base directory.
|
||||
///
|
||||
/// - Parameter basePath: the directory the static files are served from.
|
||||
/// - Returns: the path to the file, relative to the `basePath` path.
|
||||
func path(
|
||||
relativeTo basePath: String
|
||||
) -> String {
|
||||
guard !basePath.isEmpty else {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
return "\(basePath)/\(relativePath)"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension StaticFile {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The sub-directory within the static root that holds the file, if any.
|
||||
var subdirectory: String? {
|
||||
switch self {
|
||||
case .appJS: "js"
|
||||
case .errorCSS,
|
||||
.styleCSS: "css"
|
||||
default: nil
|
||||
case .appleTouchIcon: "apple-touch-icon"
|
||||
case .favicon: "favicon"
|
||||
case .icon: "icon"
|
||||
case .icon192: "icon-192"
|
||||
case .icon512: "icon-512"
|
||||
case .index: "index"
|
||||
case .notFound: "not-found"
|
||||
case .robots: "robots"
|
||||
case .shared: "shared"
|
||||
case .site: "site"
|
||||
case .sitemap: "sitemap"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
/// The site-wide defaults shared by every page of the website.
|
||||
extension Page {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The document language, derived from the page's locale and falling back to the default language.
|
||||
var lang: String {
|
||||
locale.language.languageCode?.identifier
|
||||
?? LanguageList().default
|
||||
}
|
||||
|
||||
/// The icon, manifest, and theme colour metadata shared by every page of the website.
|
||||
@HTMLBuilder
|
||||
var metadata: some HTML {
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href(StaticFile.favicon.urlPath(
|
||||
for: .ico,
|
||||
version: assetVersion
|
||||
)),
|
||||
.custom(
|
||||
name: "sizes",
|
||||
value: "any"
|
||||
)
|
||||
)
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href(StaticFile.icon.urlPath(
|
||||
for: .svg,
|
||||
version: assetVersion
|
||||
)),
|
||||
.custom(
|
||||
name: "type",
|
||||
value: "image/svg+xml"
|
||||
)
|
||||
)
|
||||
link(
|
||||
.rel("apple-touch-icon"),
|
||||
.href(StaticFile.appleTouchIcon.urlPath(
|
||||
for: .png,
|
||||
version: assetVersion
|
||||
))
|
||||
)
|
||||
link(
|
||||
.rel("manifest"),
|
||||
.href(StaticFile.site.urlPath(
|
||||
for: .webmanifest,
|
||||
version: assetVersion
|
||||
))
|
||||
)
|
||||
meta(
|
||||
.name("theme-color"),
|
||||
.content("#fafafa")
|
||||
)
|
||||
meta(
|
||||
.name("theme-color"),
|
||||
.content("#0c0710"),
|
||||
.custom(
|
||||
name: "media",
|
||||
value: "(prefers-color-scheme: dark)"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Localization
|
||||
|
||||
/// The HTML page rendered for a not-found response, with its text localized to a given locale.
|
||||
struct ErrorPage: HTMLDocument, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The locale the page content is localized to.
|
||||
private let locale: Locale
|
||||
|
||||
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
|
||||
private let localize: Localize
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found page localized to the given locale.
|
||||
/// - Parameter locale: the locale the page content is localized to.
|
||||
init(
|
||||
locale: Locale
|
||||
) {
|
||||
self.locale = locale
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
|
||||
// MARK: Document
|
||||
|
||||
/// The page's content: a localized heading and explanatory message.
|
||||
var body: some HTML {
|
||||
h1 {
|
||||
localize("error.heading", locale: locale)
|
||||
}
|
||||
p {
|
||||
localize("error.message", locale: locale)
|
||||
}
|
||||
}
|
||||
|
||||
/// The metadata and stylesheet link placed in the document head.
|
||||
var head: some HTML {
|
||||
meta(.charset(.utf8))
|
||||
meta(
|
||||
.name(.viewport),
|
||||
.content("width=device-width, initial-scale=1")
|
||||
)
|
||||
link(
|
||||
.rel(.stylesheet),
|
||||
.href("/css/error.css")
|
||||
)
|
||||
}
|
||||
|
||||
/// The document language, derived from the page's locale and falling back to the default language.
|
||||
var lang: String {
|
||||
locale.language.languageCode?.identifier
|
||||
?? LanguageList(bundle: .module).default
|
||||
}
|
||||
|
||||
/// The localized document title.
|
||||
var title: String {
|
||||
localize("error.title", locale: locale)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
/// The website's landing page, with its text localized to a given locale.
|
||||
struct IndexPage: HTMLDocument, Sendable {
|
||||
struct IndexPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
let assetVersion: String?
|
||||
|
||||
/// The locale the page content is localized to.
|
||||
private let locale: Locale
|
||||
let locale: Locale
|
||||
|
||||
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
|
||||
private let localize: Localize
|
||||
@@ -16,72 +20,40 @@ struct IndexPage: HTMLDocument, Sendable {
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a landing page localized to the given locale.
|
||||
/// - Parameter locale: the locale the page content is localized to.
|
||||
/// - Parameters:
|
||||
/// - locale: the locale the page content is localized to.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
init(
|
||||
locale: Locale
|
||||
locale: Locale,
|
||||
assetVersion: String? = nil
|
||||
) {
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = locale
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
|
||||
// MARK: Document
|
||||
}
|
||||
|
||||
/// The page's content: a localized greeting followed by the app script.
|
||||
var body: some HTML {
|
||||
// MARK: - Page
|
||||
|
||||
extension IndexPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
p {
|
||||
localize("index.greeting", locale: locale)
|
||||
}
|
||||
script(.src("/js/app.js")) {}
|
||||
}
|
||||
|
||||
/// The metadata, stylesheet, icon, and manifest links placed in the document head.
|
||||
var head: some HTML {
|
||||
meta(.charset(.utf8))
|
||||
meta(
|
||||
.name(.viewport),
|
||||
.content("width=device-width, initial-scale=1")
|
||||
)
|
||||
link(
|
||||
.rel(.stylesheet),
|
||||
.href("/css/style.css")
|
||||
)
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href("/favicon.ico"),
|
||||
.custom(
|
||||
name: "sizes",
|
||||
value: "any"
|
||||
)
|
||||
)
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href("/icon.svg"),
|
||||
.custom(
|
||||
name: "type",
|
||||
value: "image/svg+xml"
|
||||
)
|
||||
)
|
||||
link(
|
||||
.rel("apple-touch-icon"),
|
||||
.href("/icon.png")
|
||||
)
|
||||
link(
|
||||
.rel("manifest"),
|
||||
.href("/site.webmanifest")
|
||||
)
|
||||
meta(
|
||||
.name("theme-color"),
|
||||
.content("#fafafa")
|
||||
)
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.index, StaticFile.shared]
|
||||
}
|
||||
|
||||
/// The document language, derived from the page's locale and falling back to the default language.
|
||||
var lang: String {
|
||||
locale.language.languageCode?.identifier
|
||||
?? LanguageList(bundle: .module).default
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.index]
|
||||
}
|
||||
|
||||
/// The localized document title.
|
||||
var title: String {
|
||||
localize("index.title", locale: locale)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
/// The HTML page rendered for a not-found response, with its text localized to a given locale.
|
||||
struct NotFoundPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
let assetVersion: String?
|
||||
|
||||
/// The locale the page content is localized to.
|
||||
let locale: Locale
|
||||
|
||||
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
|
||||
private let localize: Localize
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found page localized to the given locale.
|
||||
/// - Parameters:
|
||||
/// - locale: the locale the page content is localized to.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
|
||||
/// default) to leave them unversioned.
|
||||
init(
|
||||
locale: Locale,
|
||||
assetVersion: String? = nil
|
||||
) {
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = locale
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Page
|
||||
|
||||
extension NotFoundPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
h1 {
|
||||
localize("notFound.heading", locale: locale)
|
||||
}
|
||||
p {
|
||||
localize("notFound.message", locale: locale)
|
||||
}
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.notFound, StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.notFound]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
localize("notFound.title", locale: locale)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import Elementary
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import NIOCore
|
||||
|
||||
/// A pre-rendered HTTP response for a fully static HTML page.
|
||||
///
|
||||
/// The document is rendered to bytes once, at initialization, and every ``response()`` reuses those
|
||||
/// bytes — along with a fixed status and precomputed headers — instead of re-rendering. This suits
|
||||
/// pages whose markup never changes between requests, such as the landing page and the not-found
|
||||
/// page, avoiding a per-request Elementary render on hot paths.
|
||||
///
|
||||
/// ``LocalizedHTMLCollectionResponse`` builds on this type, caching one instance per supported language.
|
||||
///
|
||||
/// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the
|
||||
/// response-compression middleware downstream treats it exactly as it would a freshly rendered page.
|
||||
struct CachedHTMLResponse: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The page rendered to bytes once.
|
||||
private let buffer: ByteBuffer
|
||||
/// The headers applied to every response, precomputed once.
|
||||
private let headers: HTTPFields
|
||||
/// The status applied to every response.
|
||||
private let status: HTTPResponse.Status
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Renders the given document to bytes once.
|
||||
/// - Parameters:
|
||||
/// - status: the status applied to every response. Defaults to `.ok`.
|
||||
/// - additionalHeaders: extra headers merged onto every response, alongside the content type.
|
||||
/// Used to carry per-language signals such as `Content-Language` and `Vary`.
|
||||
/// - document: the static HTML document to render and cache.
|
||||
init(
|
||||
status: HTTPResponse.Status = .ok,
|
||||
additionalHeaders: HTTPFields = [:],
|
||||
document: some HTMLDocument
|
||||
) {
|
||||
var headers: HTTPFields = [
|
||||
.contentType: "text/html; charset=utf-8"
|
||||
]
|
||||
|
||||
for field in additionalHeaders {
|
||||
headers[field.name] = field.value
|
||||
}
|
||||
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.buffer = .init(string: document.render())
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds a response from the cached, pre-rendered bytes.
|
||||
///
|
||||
/// Mirrors the `text/html; charset=utf-8` content type `HTMLResponse` produces, and leaves the
|
||||
/// `Content-Length` unset so small pages remain eligible for compression.
|
||||
/// - Returns: the response carrying the cached HTML body.
|
||||
func response() -> Response {
|
||||
Response(
|
||||
status: status,
|
||||
headers: headers,
|
||||
body: .init { [buffer] writer in
|
||||
try await writer.write(buffer)
|
||||
try await writer.finish(nil)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
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.
|
||||
struct LocalizedHTMLCollectionResponse: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The supported languages and default language, derived from the module's String Catalog.
|
||||
private let list: LanguageList
|
||||
|
||||
/// The pre-rendered responses, keyed by language identifier.
|
||||
private let responses: [String: CachedHTMLResponse]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Renders the document once per supported language.
|
||||
/// - Parameters:
|
||||
/// - status: the status applied to every response. Defaults to `.ok`.
|
||||
/// - document: builds the document to render for a given locale.
|
||||
init<Document: HTMLDocument>(
|
||||
status: HTTPResponse.Status = .ok,
|
||||
document: (Locale) -> Document
|
||||
) {
|
||||
self.list = .init(bundle: .module)
|
||||
self.responses = list.all
|
||||
.reduce(into: [:]) { responses, language in
|
||||
responses[language] = CachedHTMLResponse(
|
||||
status: status,
|
||||
additionalHeaders: [
|
||||
.contentLanguage: language,
|
||||
.vary: "Accept-Language",
|
||||
],
|
||||
document: document(.init(identifier: language))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds the response for the given language, falling back to the default language.
|
||||
/// - Parameter language: the negotiated language identifier.
|
||||
/// - Returns: the cached response for the language, the default language's response when the
|
||||
/// language is unavailable, or a `500 Internal Server Error` if neither is cached.
|
||||
func response(
|
||||
for language: String
|
||||
) -> Response {
|
||||
guard
|
||||
let response = responses[language] ?? responses[list.default]
|
||||
else {
|
||||
return .init(status: .internalServerError)
|
||||
}
|
||||
|
||||
return response.response()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +1,12 @@
|
||||
import Hummingbird
|
||||
|
||||
/// A request context that carries the language negotiated for the request.
|
||||
///
|
||||
/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language`
|
||||
/// header and stores it here, so downstream controllers and middleware can serve the matching
|
||||
/// localization without re-reading the header.
|
||||
public protocol LocalizedRequestContext: RequestContext {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The language identifier negotiated for the request.
|
||||
var language: String { get set }
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Context
|
||||
import Infrastructure
|
||||
import NIOCore
|
||||
|
||||
/// The website's request context.
|
||||
///
|
||||
/// Extends the core request storage with the negotiated language, defaulting to the default
|
||||
/// supported language until ``LocalizationMiddleware`` resolves it from the request.
|
||||
public struct WebsiteRequestContext: LocalizedRequestContext {
|
||||
/// Extends the core request storage with the negotiated language, defaulting to the default supported language until ``LocalizationMiddleware``
|
||||
/// resolves it from the request, and with the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client.
|
||||
public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressRequestContext {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
@@ -28,6 +14,8 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
|
||||
public var coreContext: CoreRequestContextStorage
|
||||
/// The language identifier negotiated for the request.
|
||||
public var language: String
|
||||
/// The address of the connected client, captured from the source channel.
|
||||
public let remoteAddress: SocketAddress?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
@@ -38,6 +26,7 @@ public struct WebsiteRequestContext: LocalizedRequestContext {
|
||||
) {
|
||||
self.coreContext = .init(source: source)
|
||||
self.language = .empty
|
||||
self.remoteAddress = source.channel.remoteAddress
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import Hummingbird
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Infrastructure
|
||||
|
||||
/// Serves the website's health-check routes.
|
||||
///
|
||||
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router (or a sub-group) by the application that composes it:
|
||||
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
|
||||
///
|
||||
/// ```swift
|
||||
/// router.addRoutes(HealthController<AppRequestContext>(probe: probe).routes)
|
||||
/// router.addController {
|
||||
/// HealthController<AppRequestContext>(probe: probe)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// It always serves a liveness check at `/health`; when a `Probe` is supplied it also serves a readiness check at `/health/ready` that reports
|
||||
@@ -15,7 +18,7 @@ import Persistence
|
||||
/// readiness failure.
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
|
||||
public struct HealthController<Context: RequestContext>: Sendable {
|
||||
public struct HealthController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
@@ -25,21 +28,21 @@ public struct HealthController<Context: RequestContext>: Sendable {
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a health controller.
|
||||
/// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness
|
||||
/// route is served.
|
||||
/// - Parameter probe: the probe consulted by the readiness route; when `nil`, only the liveness route is served.
|
||||
public init(
|
||||
probe: Probe? = nil
|
||||
) {
|
||||
self.probe = probe
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension HealthController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The routes served by the controller.
|
||||
///
|
||||
/// Serves a `GET` request for the liveness path (`/health`) with a static JSON status payload, and —
|
||||
/// when a `Probe` was supplied — a `GET` request for the readiness path (`/health/ready`)
|
||||
/// that consults the probe.
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
@@ -68,9 +71,8 @@ private extension HealthController {
|
||||
|
||||
/// Handles a request for the liveness check.
|
||||
///
|
||||
/// Returns a constant JSON body built directly per request — the payload is a tiny literal with no
|
||||
/// rendering step, so there is nothing to pre-render or cache. It reports only that the process is up,
|
||||
/// with no dependency check, so an orchestrator restarts the process only when the process itself is
|
||||
/// Returns a constant JSON body built directly per request — the payload is a tiny literal with no rendering step, so there is nothing to pre-render or
|
||||
/// cache. It reports only that the process is up, with no dependency check, so an orchestrator restarts the process only when the process itself is
|
||||
/// unresponsive.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
@@ -89,9 +91,8 @@ private extension HealthController {
|
||||
|
||||
/// Handles a request for the readiness check.
|
||||
///
|
||||
/// Consults the `Probe` supplied at initialization and reports `200 OK` when the service's
|
||||
/// database is reachable, or `503 Service Unavailable` otherwise, so a load balancer withholds
|
||||
/// traffic from an instance that cannot yet serve it without restarting the process.
|
||||
/// Consults the `Probe` supplied at initialization and reports `200 OK` when the service's database is reachable, or `503 Service Unavailable`
|
||||
/// otherwise, so a load balancer withholds traffic from an instance that cannot yet serve it without restarting the process.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
|
||||
/// Serves the website's root routes.
|
||||
///
|
||||
/// The controller exposes its routes as a `RouteCollection` so they can be added to a router
|
||||
/// (or a sub-group) by the application that composes it:
|
||||
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
|
||||
///
|
||||
/// ```swift
|
||||
/// router.addRoutes(RootController<AppRequestContext>().routes)
|
||||
/// router.addController {
|
||||
/// RootController<AppRequestContext>()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the
|
||||
/// context of the router the routes are added to.
|
||||
public struct RootController<Context: LocalizedRequestContext>: Sendable {
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
|
||||
public struct RootController<Context: LocalizedRequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
@@ -21,16 +23,26 @@ public struct RootController<Context: LocalizedRequestContext>: Sendable {
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a root controller.
|
||||
public init() {
|
||||
self.responses = .init { IndexPage(locale: $0) }
|
||||
/// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
public init(
|
||||
assetVersion: String? = nil
|
||||
) {
|
||||
self.responses = .init(bundle: .module) {
|
||||
IndexPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
}
|
||||
|
||||
// MARK: - RouteController
|
||||
|
||||
extension RootController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The routes served by the controller.
|
||||
///
|
||||
/// Serves a `GET` request for the root path (`/`) by rendering the ``IndexPage`` in the
|
||||
/// language negotiated for the request.
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
@@ -52,8 +64,7 @@ private extension RootController {
|
||||
|
||||
/// Handles a request for the landing page.
|
||||
///
|
||||
/// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``,
|
||||
/// falling back to the default language.
|
||||
/// Renders the ``IndexPage`` in the language stored on the context by ``LocalizationMiddleware``, falling back to the default language.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
@@ -63,7 +74,10 @@ private extension RootController {
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
responses.response(for: context.language)
|
||||
responses.response(
|
||||
for: context.language,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import Configuration
|
||||
extension AbsoluteConfigKey {
|
||||
/// A namespace for the static files cache configuration keys, as absolute keys.
|
||||
public enum Cache {
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to text-based static files.
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to fingerprinted assets and fonts.
|
||||
public static let maxAgeAsset: AbsoluteConfigKey = .init(.Cache.maxAgeAsset)
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to unversioned text-based static files.
|
||||
public static let maxAgeText: AbsoluteConfigKey = .init(.Cache.maxAgeText)
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to image static files.
|
||||
public static let maxAgeImage: AbsoluteConfigKey = .init(.Cache.maxAgeImage)
|
||||
@@ -50,6 +52,15 @@ extension AbsoluteConfigKey {
|
||||
/// The absolute configuration key for the minimum log level.
|
||||
public static let level: AbsoluteConfigKey = .init(.Log.level)
|
||||
}
|
||||
/// A namespace for the rate limit configuration keys, as absolute keys.
|
||||
public enum RateLimit {
|
||||
/// The absolute configuration key for the number of requests admitted per client per window.
|
||||
public static let limit: AbsoluteConfigKey = .init(.RateLimit.limit)
|
||||
/// The absolute configuration key for the window length, in seconds.
|
||||
public static let window: AbsoluteConfigKey = .init(.RateLimit.window)
|
||||
/// The absolute configuration key for keying clients by the first `X-Forwarded-For` entry.
|
||||
public static let trustForwardedFor: AbsoluteConfigKey = .init(.RateLimit.trustForwardedFor)
|
||||
}
|
||||
/// A namespace for the path configuration keys, as absolute keys.
|
||||
public enum Path {
|
||||
/// The absolute configuration key for the directory the static files are served from.
|
||||
|
||||
@@ -3,7 +3,9 @@ import Configuration
|
||||
extension ConfigKey {
|
||||
/// A namespace for the static files cache configuration keys.
|
||||
public enum Cache {
|
||||
/// The configuration key for the max-age, in seconds, applied to text-based static files (CSS, JavaScript, plain text).
|
||||
/// The configuration key for the max-age, in seconds, applied to fingerprinted assets (CSS, JavaScript) and fonts.
|
||||
public static let maxAgeAsset: ConfigKey = "cache.maxAge.asset"
|
||||
/// The configuration key for the max-age, in seconds, applied to unversioned text-based static files (e.g. plain text).
|
||||
public static let maxAgeText: ConfigKey = "cache.maxAge.text"
|
||||
/// The configuration key for the max-age, in seconds, applied to image static files (ICO, PNG, SVG).
|
||||
public static let maxAgeImage: ConfigKey = "cache.maxAge.image"
|
||||
@@ -50,6 +52,15 @@ extension ConfigKey {
|
||||
/// The configuration key for the minimum log level.
|
||||
public static let level: ConfigKey = "log.level"
|
||||
}
|
||||
/// A namespace for the rate limit configuration keys.
|
||||
public enum RateLimit {
|
||||
/// The configuration key for the number of requests admitted per client per window.
|
||||
public static let limit: ConfigKey = "rateLimit.limit"
|
||||
/// The configuration key for the window length, in seconds.
|
||||
public static let window: ConfigKey = "rateLimit.window"
|
||||
/// The configuration key for keying clients by the first `X-Forwarded-For` entry (enable only behind a trusted proxy).
|
||||
public static let trustForwardedFor: ConfigKey = "rateLimit.trustForwardedFor"
|
||||
}
|
||||
/// A namespace for the path configuration keys.
|
||||
public enum Path {
|
||||
/// The configuration key for the directory the static files are served from.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import HTTPTypes
|
||||
|
||||
extension HTTPField.Name {
|
||||
/// The `Permissions-Policy` field name (not provided as a standard `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-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let frameOptions = Self("X-Frame-Options")!
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
extension Int {
|
||||
/// A namespace for the cache's default configuration values.
|
||||
public enum Cache {
|
||||
/// The default max-age, in seconds, applied to text-based static files (1 hour).
|
||||
/// The default max-age, in seconds, applied to fingerprinted assets and fonts (1 year).
|
||||
public static let maxAgeAsset = 31_536_000
|
||||
/// The default max-age, in seconds, applied to unversioned text-based static files (1 hour).
|
||||
public static let maxAgeText = 3_600
|
||||
/// The default max-age, in seconds, applied to image static files (1 week).
|
||||
public static let maxAgeImage = 604_800
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
import Localization
|
||||
|
||||
public extension LanguageList {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a language list backed by the module's String Catalog.
|
||||
init() {
|
||||
self.init(bundle: .module)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
|
||||
public extension LocalizationMiddleware {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a localization middleware that negotiates against the module's String Catalog languages.
|
||||
init() {
|
||||
self.init(bundle: .module)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
|
||||
public extension NotFoundMiddleware {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages.
|
||||
/// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
init(
|
||||
assetVersion: String? = nil
|
||||
) {
|
||||
self.init(bundle: .module) {
|
||||
NotFoundPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import Hummingbird
|
||||
|
||||
/// A result builder that collects ``RouteCollection`` values into a stack.
|
||||
///
|
||||
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting route
|
||||
/// collections be listed declaratively rather than added one statement at a time.
|
||||
@resultBuilder
|
||||
public enum RouteCollectionBuilder<Context: RequestContext> {
|
||||
|
||||
public static func buildExpression(
|
||||
_ collection: RouteCollection<Context>
|
||||
) -> [RouteCollection<Context>] {
|
||||
[collection]
|
||||
}
|
||||
|
||||
public static func buildBlock(
|
||||
_ collections: [RouteCollection<Context>]...
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections.flatMap { $0 }
|
||||
}
|
||||
|
||||
public static func buildOptional(
|
||||
_ collections: [RouteCollection<Context>]?
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections ?? []
|
||||
}
|
||||
|
||||
public static func buildEither(
|
||||
first collections: [RouteCollection<Context>]
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections
|
||||
}
|
||||
|
||||
public static func buildEither(
|
||||
second collections: [RouteCollection<Context>]
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections
|
||||
}
|
||||
|
||||
public static func buildArray(
|
||||
_ collections: [[RouteCollection<Context>]]
|
||||
) -> [RouteCollection<Context>] {
|
||||
collections.flatMap { $0 }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
public extension RouterMethods {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Adds route collections to the router using the ``RouteCollectionBuilder`` result builder.
|
||||
///
|
||||
/// Mirrors `addMiddleware`, letting controllers be listed declaratively:
|
||||
///
|
||||
/// ```swift
|
||||
/// router.addRoutes {
|
||||
/// RootController<AppRequestContext>().routes
|
||||
/// HealthController<AppRequestContext>().routes
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Each collection is added at the router's root, exactly as a sequence of
|
||||
/// `addRoutes(_:)` calls would.
|
||||
/// - Parameter build: the route-collection stack result builder.
|
||||
/// - Returns: the router, so calls can be chained.
|
||||
@discardableResult
|
||||
func addRoutes(
|
||||
@RouteCollectionBuilder<Context> _ build: () -> [RouteCollection<Context>]
|
||||
) -> Self {
|
||||
for collection in build() {
|
||||
addRoutes(collection)
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,27 +23,6 @@ extension String {
|
||||
/// The directory, relative to the working directory, that the website's static files are served from.
|
||||
public static let staticResources = "Resources/Static"
|
||||
}
|
||||
/// A namespace for the security headers' default configuration values.
|
||||
///
|
||||
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is
|
||||
/// "sticky" in browsers, so it stays off unless explicitly configured in production.
|
||||
public enum Security {
|
||||
/// The default `Content-Security-Policy`.
|
||||
///
|
||||
/// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins
|
||||
/// (`object-src 'none'`), pins the document base URL (`base-uri 'self'`), and forbids framing
|
||||
/// (`frame-ancestors 'none'`). Both pages link external stylesheets, so no inline-style
|
||||
/// exception is required.
|
||||
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||
/// The default `X-Content-Type-Options` (disables MIME sniffing).
|
||||
public static let contentTypeOptions = "nosniff"
|
||||
/// The default `X-Frame-Options` (forbids framing the page).
|
||||
public static let frameOptions = "DENY"
|
||||
/// The default `Referrer-Policy`.
|
||||
public static let referrerPolicy = "strict-origin-when-cross-origin"
|
||||
/// The default `Permissions-Policy` (denies access to powerful browser features the site does not use).
|
||||
public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||
}
|
||||
/// A namespace for the server string constants.
|
||||
public enum Server {
|
||||
/// The website server's name.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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 module's String Catalog languages.
|
||||
public init() {
|
||||
self.negotiate = .init(bundle: .module)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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
|
||||
/// ``ErrorPage`` 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.
|
||||
public init() {
|
||||
self.responses = .init(
|
||||
status: .notFound
|
||||
) {
|
||||
ErrorPage(locale: $0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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 ``ErrorPage`` 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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 landing page, the ``ErrorPage`` 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.
|
||||
///
|
||||
/// 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 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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Configuration
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Logging
|
||||
import Infrastructure
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@@ -13,11 +13,11 @@ import Testing
|
||||
struct AppTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let textExtensions: [StaticFile.Extension] = [
|
||||
|
||||
// Stylesheets and scripts are referenced through fingerprinted URLs, so they are served immutable.
|
||||
private let immutableExtensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js,
|
||||
.txt
|
||||
.js
|
||||
]
|
||||
|
||||
// Absolute path to the package's "Resources/Static" folder, derived from this
|
||||
@@ -49,6 +49,22 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to answer a head request`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .head
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(response.body.readableBytes == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `health check to be served at the health path`() async throws {
|
||||
try await app(
|
||||
@@ -91,24 +107,103 @@ struct AppTests {
|
||||
func `static files to be served`(
|
||||
staticFile file: StaticFile
|
||||
) async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
for fileExtension in file.fileExtensions {
|
||||
try await client.execute(
|
||||
uri: "/\(file.relativePath(for: fileExtension))",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == fileExtension.contentType)
|
||||
|
||||
let cacheControl = try #require(response.headers[.cacheControl])
|
||||
|
||||
#expect(cacheControl.contains("public") == true)
|
||||
#expect(cacheControl.contains("max-age=") == true)
|
||||
|
||||
if immutableExtensions.contains(fileExtension) {
|
||||
#expect(cacheControl.contains("immutable") == true)
|
||||
} else if fileExtension == .txt {
|
||||
#expect(cacheControl.contains("must-revalidate") == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `versioned asset URL to be served`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/\(file.relativePath)",
|
||||
uri: "/css/shared.css?v=0123456789abcdef",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == file.contentType)
|
||||
|
||||
let cacheControl = try #require(response.headers[.cacheControl])
|
||||
#expect(response.headers[.contentType] == "text/css")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(cacheControl.contains("public") == true)
|
||||
#expect(cacheControl.contains("max-age=") == true)
|
||||
|
||||
if textExtensions.contains(file.fileExtension) {
|
||||
#expect(cacheControl.contains("must-revalidate") == true)
|
||||
}
|
||||
@Test
|
||||
func `landing page to reference fingerprinted assets`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("/css/shared.css?v="))
|
||||
#expect(body.contains("/js/shared.js?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to revalidate with an entity tag`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
let eTag = try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.cacheControl] == "public, no-cache")
|
||||
|
||||
return try #require(response.headers[.eTag])
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.ifNoneMatch: eTag]
|
||||
) { response in
|
||||
#expect(response.status == .notModified)
|
||||
#expect(response.body.readableBytes == 0)
|
||||
#expect(response.headers[.eTag] == eTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `responses to vary on language and encoding`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let vary = try #require(response.headers[.vary])
|
||||
|
||||
#expect(vary.contains("Accept-Language"))
|
||||
#expect(vary.contains("Accept-Encoding"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +257,81 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to reference fingerprinted assets`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("/css/not-found.css?v="))
|
||||
#expect(body.contains("/js/shared.js?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to be served without revalidation headers`() async throws {
|
||||
// A `304 Not Modified` only ever stands in for a success, so the error page must not
|
||||
// invite revalidation with an entity tag or a cache policy.
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.eTag] == nil)
|
||||
#expect(response.headers[.cacheControl] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to vary on language and encoding`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
let vary = try #require(response.headers[.vary])
|
||||
|
||||
#expect(vary.contains("Accept-Language"))
|
||||
#expect(vary.contains("Accept-Encoding"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `landing page to revalidate a conditional head request`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
let eTag = try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
try #require(response.headers[.eTag])
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .head,
|
||||
headers: [.ifNoneMatch: eTag]
|
||||
) { response in
|
||||
#expect(response.status == .notModified)
|
||||
#expect(response.body.readableBytes == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `security headers to be applied to the landing page`() async throws {
|
||||
try await app(
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("StaticFile enumeration")
|
||||
@Suite(
|
||||
"StaticFile enumeration",
|
||||
.tags(.enumeration)
|
||||
)
|
||||
struct StaticFileTests {
|
||||
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
|
||||
typealias File = StaticFile
|
||||
typealias FileExtension = StaticFile.Extension
|
||||
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.contentTypes
|
||||
))
|
||||
func `content type`(
|
||||
for file: File,
|
||||
expects contentType: String
|
||||
) {
|
||||
#expect(file.contentType == contentType)
|
||||
}
|
||||
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileExtensions
|
||||
))
|
||||
func `file extension`(
|
||||
func `file extensions`(
|
||||
for file: File,
|
||||
expects `extension`: FileExtension
|
||||
expects extensions: [AssetExtension]
|
||||
) {
|
||||
#expect(file.fileExtension == `extension`)
|
||||
#expect(file.fileExtensions == extensions)
|
||||
}
|
||||
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileNames
|
||||
@@ -45,93 +36,47 @@ struct StaticFileTests {
|
||||
) {
|
||||
#expect(file.fileName == fileName)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.relativePaths
|
||||
))
|
||||
func `relative path`(
|
||||
for file: File,
|
||||
expects relativePath: String
|
||||
) {
|
||||
#expect(file.relativePath == relativePath)
|
||||
}
|
||||
|
||||
// MARK: Method tests
|
||||
|
||||
@Test(arguments: [
|
||||
"",
|
||||
".",
|
||||
"Resources/Static"
|
||||
])
|
||||
func `path relative to`(
|
||||
_ basePath: String
|
||||
) {
|
||||
for file in File.allCases {
|
||||
let pathRelativeToBasePath = file.path(relativeTo: basePath)
|
||||
|
||||
if basePath.isEmpty {
|
||||
#expect(pathRelativeToBasePath == file.relativePath)
|
||||
} else {
|
||||
#expect(pathRelativeToBasePath == "\(basePath)/\(file.relativePath)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: CaseIterable tests
|
||||
|
||||
|
||||
@Test
|
||||
func `all cases`() {
|
||||
#expect(File.allCases.count == 8)
|
||||
#expect(File.allCases.count == 11)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension StaticFileTests {
|
||||
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let contentTypes: [String] = [
|
||||
"text/javascript",
|
||||
"text/css",
|
||||
"image/vnd.microsoft.icon",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"text/plain",
|
||||
"application/manifest+json",
|
||||
"text/css"
|
||||
]
|
||||
static let fileExtensions: [FileExtension] = [
|
||||
.js,
|
||||
.css,
|
||||
.ico,
|
||||
.png,
|
||||
.svg,
|
||||
.txt,
|
||||
.webmanifest,
|
||||
.css
|
||||
|
||||
static let fileExtensions: [[AssetExtension]] = [
|
||||
[.png],
|
||||
[.ico],
|
||||
[.svg],
|
||||
[.png],
|
||||
[.png],
|
||||
[.css, .js],
|
||||
[.css, .js],
|
||||
[.txt],
|
||||
[.css, .js],
|
||||
[.webmanifest],
|
||||
[.xml]
|
||||
]
|
||||
static let fileNames: [String] = [
|
||||
"app",
|
||||
"error",
|
||||
"apple-touch-icon",
|
||||
"favicon",
|
||||
"icon",
|
||||
"icon",
|
||||
"icon-192",
|
||||
"icon-512",
|
||||
"index",
|
||||
"not-found",
|
||||
"robots",
|
||||
"shared",
|
||||
"site",
|
||||
"style"
|
||||
"sitemap"
|
||||
]
|
||||
static let relativePaths: [String] = [
|
||||
"js/app.js",
|
||||
"css/error.css",
|
||||
"favicon.ico",
|
||||
"icon.png",
|
||||
"icon.svg",
|
||||
"robots.txt",
|
||||
"site.webmanifest",
|
||||
"css/style.css"
|
||||
]
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("IndexPage page")
|
||||
@Suite(
|
||||
"IndexPage page",
|
||||
.tags(.page)
|
||||
)
|
||||
struct IndexPageTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
@@ -17,13 +20,30 @@ struct IndexPageTests {
|
||||
|
||||
#expect(html.contains("<!DOCTYPE html>"))
|
||||
#expect(html.contains(#"lang="en""#))
|
||||
#expect(html.contains("/css/style.css"))
|
||||
#expect(html.contains("/css/shared.css"))
|
||||
#expect(html.contains("/css/index.css"))
|
||||
#expect(html.contains("/favicon.ico"))
|
||||
#expect(html.contains("/icon.svg"))
|
||||
#expect(html.contains("/icon.png"))
|
||||
#expect(html.contains("/apple-touch-icon.png"))
|
||||
#expect(html.contains("/site.webmanifest"))
|
||||
#expect(html.contains(#"media="(prefers-color-scheme: dark)""#))
|
||||
#expect(html.contains("Hello world!"))
|
||||
#expect(html.contains("/js/app.js"))
|
||||
#expect(html.contains("/js/shared.js"))
|
||||
#expect(html.contains("/js/index.js"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders versioned asset URLs when given a version`() {
|
||||
let html = IndexPage(
|
||||
locale: .init(identifier: "en"),
|
||||
assetVersion: "0123456789abcdef"
|
||||
).render()
|
||||
|
||||
#expect(html.contains("/css/shared.css?v=0123456789abcdef"))
|
||||
#expect(html.contains("/css/index.css?v=0123456789abcdef"))
|
||||
#expect(html.contains("/js/shared.js?v=0123456789abcdef"))
|
||||
#expect(html.contains("/js/index.js?v=0123456789abcdef"))
|
||||
#expect(html.contains("/favicon.ico?v=0123456789abcdef"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("ErrorPage page")
|
||||
struct ErrorPageTests {
|
||||
@Suite(
|
||||
"NotFoundPage page",
|
||||
.tags(.page)
|
||||
)
|
||||
struct NotFoundPageTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders its markup`() {
|
||||
let html = ErrorPage(
|
||||
let html = NotFoundPage(
|
||||
locale: .init(identifier: "en")
|
||||
).render()
|
||||
|
||||
@@ -19,7 +22,10 @@ struct ErrorPageTests {
|
||||
#expect(html.contains(#"lang="en""#))
|
||||
#expect(html.contains("Page Not Found"))
|
||||
#expect(html.contains("Sorry, but the page you were trying to view does not exist."))
|
||||
#expect(html.contains("/css/error.css"))
|
||||
#expect(html.contains("/css/shared.css"))
|
||||
#expect(html.contains("/css/not-found.css"))
|
||||
#expect(html.contains("/js/not-found.js"))
|
||||
#expect(html.contains("/js/shared.js"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("HealthController controller")
|
||||
@Suite(
|
||||
"HealthController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct HealthControllerTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Infrastructure
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("RootController controller")
|
||||
@Suite(
|
||||
"RootController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct RootControllerTests {
|
||||
|
||||
// MARK: Constants
|
||||
@@ -42,4 +46,117 @@ struct RootControllerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves the landing page with revalidation headers`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let eTag = try #require(response.headers[.eTag])
|
||||
|
||||
#expect(eTag.hasPrefix(#"W/""#))
|
||||
#expect(response.headers[.cacheControl] == "public, no-cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `revalidates a matching conditional request with a 304`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
let eTag = try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
try #require(response.headers[.eTag])
|
||||
}
|
||||
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.ifNoneMatch: eTag]
|
||||
) { response in
|
||||
#expect(response.status == .notModified)
|
||||
#expect(response.headers[.eTag] == eTag)
|
||||
#expect(response.body.readableBytes == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves the full page to a non-matching conditional request`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.ifNoneMatch: #"W/"0123456789abcdef""#]
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(body.contains("Hello world!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders versioned asset URLs when given a version`() async throws {
|
||||
try await app(
|
||||
assetVersion: "0123456789abcdef"
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains("/css/index.css?v=0123456789abcdef"))
|
||||
#expect(body.contains("/js/index.js?v=0123456789abcdef"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders unversioned asset URLs by default`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains(#"href="/css/index.css""#))
|
||||
#expect(!body.contains("?v="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RootControllerTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose root controller appends the given version token to the landing
|
||||
/// page's asset URLs.
|
||||
/// - Parameter assetVersion: the version token appended to the page's asset URLs.
|
||||
/// - Returns: the configured application.
|
||||
func app(
|
||||
assetVersion: String?
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware()
|
||||
}
|
||||
|
||||
router.addRoutes(RootController<WebsiteRequestContext>(
|
||||
assetVersion: assetVersion
|
||||
).routes)
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("LocalizationMiddleware middleware")
|
||||
struct LocalizationMiddlewareTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let app: Application = .init(router: {
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware()
|
||||
}
|
||||
|
||||
router.get("language") { _, context in
|
||||
context.language
|
||||
}
|
||||
|
||||
return router
|
||||
}())
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `negotiates a supported language from the header`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "en-US,en;q=0.9"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "en")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `falls back to the default without a header`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "en")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("NotFoundMiddleware middleware")
|
||||
struct NotFoundMiddlewareTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let app: Application = .init(router: {
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware()
|
||||
NotFoundMiddleware()
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.get("boom") { _, _ -> String in
|
||||
throw HTTPError(.badRequest)
|
||||
}
|
||||
|
||||
return router
|
||||
}())
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders the error page for an unmatched request`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(response.headers[.contentLanguage] == "en")
|
||||
#expect(response.headers[.vary] == "Accept-Language")
|
||||
#expect(body.contains("Page Not Found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a matched response through untouched`() 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.body == ByteBuffer(string: "Hello!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `rethrows a non-not-found error unchanged`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/boom",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .badRequest)
|
||||
#expect(!body.contains("Page Not Found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("SecurityHeadersMiddleware middleware")
|
||||
struct SecurityHeadersMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `applies the default security headers to a response`() 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[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
#expect(response.headers[.frameOptions] == String.Security.frameOptions)
|
||||
#expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
|
||||
#expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits strict-transport-security by default`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.strictTransportSecurity] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies strict-transport-security when configured`() async throws {
|
||||
let value = "max-age=31536000; includeSubDomains"
|
||||
|
||||
try await app(
|
||||
configuration: .init(strictTransportSecurity: value)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.strictTransportSecurity] == value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies a custom header value`() async throws {
|
||||
let value = "default-src 'none'"
|
||||
|
||||
try await app(
|
||||
configuration: .init(contentSecurityPolicy: value)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.contentSecurityPolicy] == value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits a header whose configured value is nil`() async throws {
|
||||
try await app(
|
||||
configuration: .init(contentTypeOptions: nil)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.xContentTypeOptions] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replaces an existing header value set downstream`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/weak",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SecurityHeadersMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the security-headers middleware ahead of two
|
||||
/// routes: `/hello` returns a plain body, and `/weak` returns a response that already carries a
|
||||
/// deliberately weak `X-Content-Type-Options` value for the middleware to override.
|
||||
func app(
|
||||
configuration: SecurityHeadersMiddleware<BasicRequestContext>.Configuration = .init()
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
SecurityHeadersMiddleware(configuration: configuration)
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.get("weak") { _, _ -> Response in
|
||||
var response = Response(status: .ok)
|
||||
|
||||
response.headers[.xContentTypeOptions] = "weak"
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Testing
|
||||
|
||||
extension Tag {
|
||||
/// Tests exercising a controller of the Website library.
|
||||
@Tag static var controller: Tag
|
||||
/// Tests exercising an enumeration of the Website library.
|
||||
@Tag static var enumeration: Tag
|
||||
/// Tests exercising a page of the Website library.
|
||||
@Tag static var page: Tag
|
||||
}
|
||||
@@ -45,6 +45,20 @@
|
||||
"identifier" : "LocalizationTests",
|
||||
"name" : "LocalizationTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Infrastructure",
|
||||
"identifier" : "InfrastructureTests",
|
||||
"name" : "InfrastructureTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Utility",
|
||||
"identifier" : "UtilityTests",
|
||||
"name" : "UtilityTests"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Local development overrides.
|
||||
# Compose merges this file on top of docker-compose.yml automatically, so a
|
||||
# plain `docker compose up` builds from source instead of pulling a registry image:
|
||||
# Compose merges this file on top of docker-compose.yml automatically, so a plain `docker compose up` builds from
|
||||
# source instead of pulling a registry image:
|
||||
#
|
||||
# docker compose up --build # build locally and run
|
||||
# docker compose up -d # reuse the last local build
|
||||
#
|
||||
# It reuses the `image:` name from the base file, so the local build is tagged
|
||||
# the same way the production image would be.
|
||||
# It reuses the `image:` name from the base file, so the local build is tagged the same way the production image would
|
||||
# be.
|
||||
services:
|
||||
website:
|
||||
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
||||
@@ -20,8 +20,8 @@ services:
|
||||
DATABASE_HOST: ${DATABASE_HOST:-localhost}
|
||||
DATABASE_TLS: ${DATABASE_TLS:-off}
|
||||
|
||||
# Local development database, started only with the `database` profile so a plain
|
||||
# `docker compose up` still runs the in-memory backend:
|
||||
# Local development database, started only with the `database` profile so a plain `docker compose up` still runs the
|
||||
# in-memory backend:
|
||||
#
|
||||
# docker compose --profile database up mariadb
|
||||
mariadb:
|
||||
|
||||
@@ -6,9 +6,8 @@ name: site-platform
|
||||
# docker compose -f docker-compose.yml pull
|
||||
# docker compose -f docker-compose.yml up -d
|
||||
#
|
||||
# The `-f docker-compose.yml` flag is important in production: it skips the
|
||||
# docker-compose.override.yml file, which Compose would otherwise merge in
|
||||
# automatically for local development.
|
||||
# The `-f docker-compose.yml` flag is important in production: it skips the docker-compose.override.yml file, which
|
||||
# Compose would otherwise merge in automatically for local development.
|
||||
services:
|
||||
website:
|
||||
image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
||||
@@ -21,13 +20,18 @@ services:
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-SiteWebsite}
|
||||
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
|
||||
# Persistence: in-memory by default; set DATABASE_DRIVER=mysql to run against a
|
||||
# managed MySQL/MariaDB database. Provide the password via the environment or a
|
||||
# secret — never commit it.
|
||||
# Persistence: in-memory by default; set DATABASE_DRIVER=mysql to run against a managed MySQL/MariaDB database.
|
||||
# Provide the password via the environment or a secret — never commit it.
|
||||
DATABASE_DRIVER: ${DATABASE_DRIVER:-mysql}
|
||||
DATABASE_HOST: ${DATABASE_HOST:-localhost}
|
||||
DATABASE_PORT: ${DATABASE_PORT:-3306}
|
||||
DATABASE_NAME: ${DATABASE_NAME:-site}
|
||||
DATABASE_USERNAME: ${DATABASE_USERNAME:-site}
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-}
|
||||
DATABASE_TLS: ${DATABASE_TLS:-require}
|
||||
DATABASE_TLS: ${DATABASE_TLS:-prefer}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||