Files
ccn/Services/Website/README.md
T

213 lines
14 KiB
Markdown
Raw Normal View History

# Site Website
The **Site** public website service — a [Hummingbird](https://github.com/hummingbird-project/hummingbird) server that renders a static landing page and serves the site's static assets.
2026-06-28 14:18:04 +00:00
## Overview
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).
2026-06-28 14:18:04 +00:00
- 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.
2026-06-28 14:18:04 +00:00
- 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.
2026-06-28 14:18:04 +00:00
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Docker (optional) for the containerized run/deploy workflow.
- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
2026-06-28 14:18:04 +00:00
## Architecture
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. |
2026-06-28 14:18:04 +00:00
The `Website` executable depends on two local packages:
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `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.
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).
2026-06-28 14:18:04 +00:00
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)
2026-06-28 14:18:04 +00:00
```
## Configuration
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration) from
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
### 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`,
`security.strictTransportSecurity``SECURITY_STRICT_TRANSPORT_SECURITY`, `cache.maxAge.text``CACHE_MAX_AGE_TEXT`.
2026-06-28 14:18:04 +00:00
> To disable a header or override a value, leave the variable **unset** to fall back to the default. A
> variable that is set but **blank** is treated as an explicit empty value, not as "use the default".
### 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.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). |
### Response compression
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `compression.minimumResponseSize` | `COMPRESSION_MINIMUM_RESPONSE_SIZE` | `1024` | Minimum response body size, in bytes, before compression is applied. |
### HTTP server
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `http.host` | `HTTP_HOST` | _none_ | Host the server binds to. Supplied via the `--http-host` CLI flag (the Docker image passes `0.0.0.0`). |
| `http.port` | `HTTP_PORT` | _none_ | Port the server listens on. Supplied via the `--http-port` CLI flag (the Docker image passes `8080`). |
| `http.serverName` | `HTTP_SERVER_NAME` | `SiteWebsite` | Server name and logger label. |
2026-06-28 14:18:04 +00:00
### Logging
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `log.level` | `LOG_LEVEL` | `info` | Minimum log level. |
### Persistence
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `mysql` (MySQL/MariaDB). |
| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
| `database.host` | `DATABASE_HOST` | `localhost` | MySQL/MariaDB host. Ignored for `inMemory`. |
| `database.port` | `DATABASE_PORT` | `3306` | MySQL/MariaDB port. Ignored for `inMemory`. |
| `database.name` | `DATABASE_NAME` | `site` | Database name. Ignored for `inMemory`. |
| `database.username` | `DATABASE_USERNAME` | `site` | Database username. Ignored for `inMemory`. |
| `database.password` | `DATABASE_PASSWORD` | _(empty)_ | Database password. Provide via the environment/a secret — never commit it. |
| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Ignored for `inMemory`. |
| `database.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. |
See [Persistence](#persistence-1) below for the workflow.
2026-06-28 14:18:04 +00:00
### Paths
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
### Security headers
| Config key | Environment variable | Default |
| --- | --- | --- |
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
| `security.contentTypeOptions` | `SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` |
| `security.frameOptions` | `SECURITY_FRAME_OPTIONS` | `DENY` |
| `security.referrerPolicy` | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` |
| `security.permissionsPolicy` | `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` |
| `security.strictTransportSecurity` | `SECURITY_STRICT_TRANSPORT_SECURITY` | _none (omitted)_ |
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: it only takes effect over HTTPS (browsers ignore it on plain HTTP) and is "sticky" in browsers, so it must stay off in local HTTP development. It is enabled for production in `docker-compose.yml`, where it only has an effect once traffic is served over HTTPS behind a TLS-terminating proxy.
## Running locally
Directly with Swift:
```sh
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
```
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
2026-06-28 14:18:04 +00:00
```sh
make pkg-build # swift build
make site-run # run locally with hot reload (hb watch)
make site-mount # docker compose up --build --detach
make site-unmount # docker compose down + remove the local image
2026-06-28 14:18:04 +00:00
```
`make help` lists every available target.
## Persistence
The service persists data through Fluent and selects its backend at runtime with `database.driver`.
### In-memory (default)
With no configuration, the service uses an ephemeral in-memory SQLite database. It is created and **migrated on startup** every launch, so `swift run Website` and `docker compose up` work with no external database — ideal for local development and tests.
### MySQL / MariaDB
Set `DATABASE_DRIVER=mysql` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, a MySQL/MariaDB database is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
```sh
# Run the registered migrations against the configured database, then exit.
swift run Website --database-migrate
# In a container (production), against the managed database:
docker compose -f docker-compose.yml run --rm website --database-migrate
```
A local MariaDB for development lives behind the `database` Compose profile (so a plain `docker compose up` still runs in-memory). Its data directory is bind-mounted to `Tests/DB` (git-ignored), so the database survives `db-unmount` and container restarts:
```sh
make db-mount # start MariaDB (docker compose --profile database up --wait mariadb)
make db-migrate # run migrations against it
make db-shell # open a SQL shell on it
make db-unmount # stop and remove the container (data kept in Tests/DB)
make db-reset # stop and remove the container
DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
```
> **Note:** because the data lives in the bind-mounted `Tests/DB` folder rather than a named volume, `db-reset`'s `--volumes` flag does **not** clear it. To start from an empty database, delete `Tests/DB` by hand.
### 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.
2026-06-28 14:18:04 +00:00
## 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).
2026-06-28 14:18:04 +00:00
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
cd ../../Packages/Persistence && swift test # in-memory only
# With the local MariaDB up (make db-mount):
cd ../../Packages/Persistence && MYSQL_TEST_HOST=127.0.0.1 swift test
```
2026-06-28 14:18:04 +00:00
## Deployment
The production image is built for `linux/amd64` in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080` (`ENTRYPOINT ./Website --http-host 0.0.0.0 --http-port 8080`).
Verify the image builds for its `linux/amd64` target without tagging or publishing:
```sh
make img-check
```
2026-06-28 14:18:04 +00:00
Build, tag, and push a release to the registry (an explicit version is required):
```sh
make img-release version=1.2.3
```
Pull and run the prebuilt image in production — the `-f docker-compose.yml` flag is important, as it skips the local-development override:
```sh
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
```
### 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 |
| --- | --- |
| `HOST_CONTAINER` | Container registry host (e.g. `registry.example.com`). |
| `HOST_OWNER` | Registry namespace / owner. |
| `HOST_USER`, `HOST_PASSWORD` | Registry credentials for `make img-release`. |
| `IMAGE_NAME`, `IMAGE_TAG` | Image name and tag. |
| `IMAGE_PLATFORM` | Build platform (e.g. `linux/amd64`). |
| `HOST_PORT` | Host port mapped to the container's `8080` (default `8080`). |
| `LOG_LEVEL` | Runtime log level (default `info`). |
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
2026-06-28 14:18:04 +00:00
| `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_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). |
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.