|
|
|
@@ -38,14 +38,16 @@ The persistence backend runs as a `Fluent` service inside the application's Serv
|
|
|
|
|
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
|
|
|
|
|
```
|
|
|
|
|
LogRequestsMiddleware
|
|
|
|
|
→ SecurityHeadersMiddleware (security headers on every response)
|
|
|
|
|
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
|
|
|
|
|
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
|
|
|
|
→ LocalizationMiddleware (negotiates the request's language)
|
|
|
|
|
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
|
|
|
|
|
→ FileMiddleware (serves Resources/Static)
|
|
|
|
|
RootController (GET / → landing page)
|
|
|
|
|
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
|
|
|
|
→ SecurityHeadersMiddleware (security headers on every response)
|
|
|
|
|
→ HTTPSRedirectMiddleware (301 to site.origin when forwarded over plain HTTP)
|
|
|
|
|
→ TrailingSlashRedirectMiddleware (301 to the path without a trailing slash)
|
|
|
|
|
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
|
|
|
|
|
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
|
|
|
|
→ LocalizationMiddleware (negotiates the 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)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
|
|
|
|
@@ -63,7 +65,9 @@ Four of those are page-authored, optional, and **omitted by default** — the re
|
|
|
|
|
|
|
|
|
|
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
|
|
|
|
|
|
|
|
|
|
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas.
|
|
|
|
|
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the `preloadedFonts` links, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas. Those live in `siteMetadata`, which `metadata` returns unchanged — a page that adds head tags of its own composes `siteMetadata` rather than replacing it.
|
|
|
|
|
|
|
|
|
|
`preloadedFonts` is empty until the site ships fonts. Listing one emits `<link rel="preload" as="font" crossorigin>` at `/font/<name>.woff2`, deliberately unversioned: a preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a `unicode-range` no page reaches would add a download that never otherwise happens.
|
|
|
|
|
|
|
|
|
|
## Configuration
|
|
|
|
|
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
|
|
|
|
@@ -88,12 +92,14 @@ 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.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.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for the fingerprinted assets (CSS, JS, MP4, JPEG, WebP) and fonts; also marked `immutable`. The pages reference them through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. Fonts are immutable subset files, preloaded unversioned. |
|
|
|
|
|
| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for the remaining `text/*` assets (e.g. `robots.txt`), which keep unversioned URLs; also marked `must-revalidate`. |
|
|
|
|
|
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). |
|
|
|
|
|
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for the remaining images — the icons (ICO, PNG, SVG), which a browser fetches unversioned whatever the markup says, so they cannot be `immutable`. |
|
|
|
|
|
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else — including `site.webmanifest` (`application/manifest+json`) and `sitemap.xml` (`application/xml`), neither of which is `text/*`. |
|
|
|
|
|
|
|
|
|
|
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`) are resolved before the general `text/*` category.
|
|
|
|
|
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`, `video/mp4`, `image/jpeg`, `image/webp`) resolve before the general `text/*` and `image/*` categories.
|
|
|
|
|
|
|
|
|
|
> **An image the markup references without a `?v=` token must be neither JPEG nor WebP**, or it is served immutable for a year and no deploy can dislodge it.
|
|
|
|
|
|
|
|
|
|
### Response compression
|
|
|
|
|
| Config key | Environment variable | Default | Description |
|
|
|
|
@@ -120,28 +126,17 @@ Redirecting collapses the `http://` and `https://` copies of every page onto one
|
|
|
|
|
|
|
|
|
|
`docker-compose.yml` enables it for production; `docker-compose.override.yml` pins it off for local development.
|
|
|
|
|
|
|
|
|
|
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/mr-rock` and `/mr-rock/` alike, so
|
|
|
|
|
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/about` and `/about/` alike, so
|
|
|
|
|
every `GET`/`HEAD` whose path ends in a slash is answered with a `301` to the form without one (`//` collapses to `/`; `/` is left alone). The
|
|
|
|
|
`Location` is relative, so it keeps the request's own scheme and host. Other methods pass through, since a client may repeat a redirected `POST` as a
|
|
|
|
|
`GET` and drop the body.
|
|
|
|
|
|
|
|
|
|
### HTTPS redirect
|
|
|
|
|
### Site
|
|
|
|
|
| Config key | Environment variable | Default | Description |
|
|
|
|
|
| --- | --- | --- | --- |
|
|
|
|
|
| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. |
|
|
|
|
|
| `site.origin` | `SITE_ORIGIN` | _(empty)_ | The public origin the site is served at (scheme and host, no trailing slash). The HTTPS redirect points at it, and absolute links derive from it. |
|
|
|
|
|
|
|
|
|
|
Redirecting collapses the `http://` and `https://` copies of every page onto one address, which is what a search engine consolidates a site's signals against. Three details:
|
|
|
|
|
|
|
|
|
|
- **`301`, not `302`** — a temporary redirect keeps the HTTP URLs indexed. Browsers cache it for a long time, so settle the target first.
|
|
|
|
|
- **Target built from `site.origin`, not the `Host` header** — a client cannot steer it. An origin that is not itself HTTPS disables the middleware instead of looping.
|
|
|
|
|
- **`/.well-known/` is exempt** — redirecting the ACME challenge path breaks certificate renewal.
|
|
|
|
|
|
|
|
|
|
`docker-compose.yml` enables it for production; `docker-compose.override.yml` pins it off for local development.
|
|
|
|
|
|
|
|
|
|
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/mr-rock` and `/mr-rock/` alike, so
|
|
|
|
|
every `GET`/`HEAD` whose path ends in a slash is answered with a `301` to the form without one (`//` collapses to `/`; `/` is left alone). The
|
|
|
|
|
`Location` is relative, so it keeps the request's own scheme and host. Other methods pass through, since a client may repeat a redirected `POST` as a
|
|
|
|
|
`GET` and drop the body.
|
|
|
|
|
Empty by default, which leaves the [HTTPS redirect](#https-redirect) off: a deployment that trusts the forwarded-protocol header without setting this would otherwise redirect at a host the template guessed, and a `301` is cached for a long time. Bootstrap does **not** fill it in — it rewrites the crawler files only.
|
|
|
|
|
|
|
|
|
|
### Logging
|
|
|
|
|
| Config key | Environment variable | Default | Description |
|
|
|
|
@@ -174,10 +169,12 @@ See [Persistence](#persistence-1) below for the workflow.
|
|
|
|
|
### Rate limiting
|
|
|
|
|
| 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.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window; 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 — when the server is directly reachable, clients can forge it. |
|
|
|
|
|
|
|
|
|
|
> **Configured but unapplied.** The template ships no endpoint worth limiting, so `RateLimitMiddleware` is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one.
|
|
|
|
|
|
|
|
|
|
### Analytics
|
|
|
|
|
The template ships analytics **off**: `analytics.websiteID` is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order:
|
|
|
|
|
|
|
|
|
@@ -316,7 +313,9 @@ docker compose -f docker-compose.yml up -d
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Static assets
|
|
|
|
|
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root. Every one of them is a case of the `StaticFile` enumeration, which is what the pages derive their URLs from.
|
|
|
|
|
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root.
|
|
|
|
|
|
|
|
|
|
Each extension resolves to its own folder (`css/`, `js/`, `img/`, `font/`, `video/`), which a `StaticFile` overrides with `in:` when it needs one of its own — imagery conventionally sits in a folder per page (`img/index`). `img/` ships empty. Every one of them is a constant of the `StaticFile` structure, which is what the pages derive their URLs from.
|
|
|
|
|
|
|
|
|
|
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions — and on a base image pinned by digest, not just by tag — so asset output is reproducible for a given Dockerfile commit:
|
|
|
|
|
- CSS and JS are minified with [esbuild](https://esbuild.github.io) (every file in `css/` and `js/`).
|
|
|
|
@@ -325,7 +324,7 @@ The image build optimizes the files under `Resources/Static` in its `assets` sta
|
|
|
|
|
|
|
|
|
|
The PNG and SVG passes walk the tree (`--recursive`), so images added in a subdirectory are optimized without touching the Dockerfile.
|
|
|
|
|
|
|
|
|
|
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
|
|
|
|
|
Files keep their names and paths, so the URLs derived from the `StaticFile` constants are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
|
|
|
|
|
|
|
|
|
|
Preview the optimized output locally (Docker only, writes to the git-ignored `.build/minified`):
|
|
|
|
|
```sh
|
|
|
|
|