Initial commit.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.build
|
||||
.swiftpm
|
||||
.DS_Store
|
||||
.env.local
|
||||
docker-compose.*
|
||||
Makefile
|
||||
README.md
|
||||
@@ -0,0 +1,67 @@
|
||||
# Local `.env` file used solely for Development purposes.
|
||||
|
||||
# --- Image / deployment -------------------------------------------------------
|
||||
|
||||
# Host name of the container running the Website service.
|
||||
HOST_CONTAINER=containers.rock-n-code.com
|
||||
|
||||
# Owner of the container running the Website service.
|
||||
HOST_OWNER=ccn
|
||||
|
||||
# Password for authenticating to the container registry.
|
||||
HOST_PASSWORD=
|
||||
|
||||
# Host port mapped to the container's port 8080.
|
||||
HOST_PORT=8080
|
||||
|
||||
# User name for authenticating to the container registry.
|
||||
HOST_USER=
|
||||
|
||||
# Name of the Docker image to pull/run.
|
||||
IMAGE_NAME=website
|
||||
|
||||
# Platform of the deployment image
|
||||
IMAGE_PLATFORM=linux/amd64
|
||||
|
||||
# Platform of the *local* build only (docker-compose.override.yml)
|
||||
BUILD_PLATFORM=linux/arm64
|
||||
|
||||
# Tag of the image to pull/run.
|
||||
# Use a semver in production; avoid `latest` so rollbacks are unambiguous.
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# --- Application config -------------------------------------------------------
|
||||
|
||||
# Server name reported by the HTTP service.
|
||||
HTTP_SERVER_NAME=CCNWebsite
|
||||
|
||||
# Log verbosity: trace | debug | info | notice | warning | error | critical
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# --- Persistence ----------------------------------------------------------------
|
||||
|
||||
# Persistence driver: inMemory (default, no infrastructure) or postgres.
|
||||
DATABASE_DRIVER=inMemory
|
||||
|
||||
# PostgreSQL connection, used when DATABASE_DRIVER=postgres.
|
||||
DATABASE_HOST=localhost
|
||||
|
||||
# Name of the database to connect to.
|
||||
DATABASE_NAME=ccn
|
||||
|
||||
# Provide the real password via the environment or a secret — never commit it.
|
||||
DATABASE_PASSWORD=ccn
|
||||
|
||||
# Maximum pooled connections per event loop, one loop per core — an 8-core host
|
||||
# can open 8 times this, and every replica that many again.
|
||||
DATABASE_POOL_MAX_PER_EVENT_LOOP=4
|
||||
|
||||
# Port of the database to connect to.
|
||||
DATABASE_PORT=5432
|
||||
|
||||
# TLS posture when connecting: off | prefer | require. Plaintext is the usual
|
||||
# choice over a private container network; use `require` across one you share.
|
||||
DATABASE_TLS=off
|
||||
|
||||
# Username of the database to connect as.
|
||||
DATABASE_USERNAME=ccn
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Website"
|
||||
BuildableName = "Website"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<TestPlans>
|
||||
<TestPlanReference
|
||||
reference = "container:Tests/Website.xctestplan"
|
||||
default = "YES">
|
||||
</TestPlanReference>
|
||||
</TestPlans>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "WebsiteTests"
|
||||
BuildableName = "WebsiteTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "YES"
|
||||
customWorkingDirectory = "/Users/logan/Documents/Development/Web/Röck+Cöde/CCN/Services/Website"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "No">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Website"
|
||||
BuildableName = "Website"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Website"
|
||||
BuildableName = "Website"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,135 @@
|
||||
# ================================
|
||||
# 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 --recursive . \
|
||||
&& 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
|
||||
# ================================
|
||||
FROM swift:6.3-noble AS build
|
||||
|
||||
# Install OS updates
|
||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||
&& apt-get -q update \
|
||||
&& apt-get -q dist-upgrade -y \
|
||||
&& apt-get install -y libjemalloc-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up a build area
|
||||
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.
|
||||
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 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
|
||||
|
||||
# Build the application, with optimizations, with static linking, and using jemalloc.
|
||||
#
|
||||
# SPM validates the path of every target in the root package — including the test targets — even when only the
|
||||
# executable product is built, so those directories have to exist. They are created empty instead of copied: the test
|
||||
# sources are not needed to compile the product, keeping them out means editing a test never invalidates this layer,
|
||||
# and it keeps the local database bind mount under Tests/DB out of the image entirely.
|
||||
RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
|
||||
&& swift build --package-path ./Services/Website -c release \
|
||||
--product "Website" \
|
||||
--static-swift-stdlib \
|
||||
-Xlinker -ljemalloc
|
||||
|
||||
# Switch to the staging area
|
||||
WORKDIR /staging
|
||||
|
||||
# Copy main executable to staging area, without its debug sections
|
||||
RUN cp "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/Website" ./ \
|
||||
&& strip --strip-debug ./Website
|
||||
|
||||
# Copy static swift backtracer binary to staging area
|
||||
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 {} ./ \;
|
||||
|
||||
# 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 chmod -R a-w ./Resources
|
||||
|
||||
# ================================
|
||||
# Run image
|
||||
# ================================
|
||||
FROM ubuntu:noble
|
||||
|
||||
# Make sure all system packages are up to date, and install only essential packages.
|
||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||
&& apt-get -q update \
|
||||
&& apt-get -q dist-upgrade -y \
|
||||
&& apt-get -q install -y \
|
||||
libjemalloc2 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tzdata \
|
||||
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
|
||||
# libcurl4 \
|
||||
# If your app or its dependencies import FoundationXML, also install `libxml2`.
|
||||
# libxml2 \
|
||||
&& rm -r /var/lib/apt/lists/*
|
||||
|
||||
# Create a hummingbird user and group with /app as its home directory
|
||||
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird
|
||||
|
||||
# Switch to the new home directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built executable and any staged resources from builder
|
||||
COPY --from=build --chown=hummingbird:hummingbird /staging /app
|
||||
|
||||
# Provide configuration needed by the built-in crash reporter and some sensible default behaviors.
|
||||
ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static
|
||||
|
||||
# Ensure all further commands run as the hummingbird user
|
||||
USER hummingbird:hummingbird
|
||||
|
||||
# Let Docker bind to port 8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment
|
||||
ENTRYPOINT ["./Website"]
|
||||
CMD ["--http-host", "0.0.0.0", "--http-port", "8080"]
|
||||
@@ -0,0 +1,168 @@
|
||||
# Website service — operational commands
|
||||
# --- Configuration ------------------------------------------------------------
|
||||
|
||||
# Load configuration from .env (the same file Compose reads).
|
||||
ENV_FILE := $(if $(wildcard .env),.env,.env.local)
|
||||
include $(ENV_FILE)
|
||||
export
|
||||
|
||||
# Variables
|
||||
IMAGE_URL := $(HOST_CONTAINER)/$(HOST_OWNER)/$(IMAGE_NAME)
|
||||
|
||||
# Arguments
|
||||
override version ?= $(IMAGE_TAG)
|
||||
|
||||
# Show help if no target is specified.
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# --- Swift package ------------------------------------------------------------
|
||||
|
||||
.PHONY: pkg-build
|
||||
pkg-build: ## Build the Swift package
|
||||
@swift build
|
||||
|
||||
.PHONY: pkg-release
|
||||
pkg-release: ## Release the Swift package
|
||||
@swift build -c release
|
||||
|
||||
.PHONY: pkg-test
|
||||
pkg-test: ## Run the Swift package tests
|
||||
@swift test \
|
||||
--disable-xctest \
|
||||
--enable-code-coverage \
|
||||
--enable-swift-testing \
|
||||
--parallel
|
||||
|
||||
.PHONY: pkg-clean
|
||||
pkg-clean: ## Remove the Swift build artifacts
|
||||
@swift package clean
|
||||
|
||||
.PHONY: pkg-reset
|
||||
pkg-reset: ## Reset the SPM cache and build folder
|
||||
@swift package reset
|
||||
|
||||
.PHONY: pkg-outdated
|
||||
pkg-outdated: ## List the SPM dependencies that can be updated
|
||||
@swift package update --dry-run
|
||||
|
||||
.PHONY: pkg-update
|
||||
pkg-update: ## Update the SPM dependencies
|
||||
@swift package update
|
||||
|
||||
# --- Local development --------------------------------------------------------
|
||||
|
||||
.PHONY: site-run
|
||||
site-run: ## Run the website locally, rebuilding on source changes
|
||||
@hb watch
|
||||
|
||||
.PHONY: site-mount
|
||||
site-mount: ## Mount the website locally
|
||||
@docker compose up \
|
||||
--build \
|
||||
--detach
|
||||
|
||||
.PHONY: site-unmount
|
||||
site-unmount: ## Unmount and remove the local website
|
||||
@docker compose down
|
||||
@$(MAKE) img-remove
|
||||
|
||||
# --- Local database -----------------------------------------------------------
|
||||
|
||||
.PHONY: db-mount
|
||||
db-mount: ## Start the local database instance
|
||||
@docker compose \
|
||||
--profile database up \
|
||||
--detach \
|
||||
--wait postgres
|
||||
|
||||
.PHONY: db-migrate
|
||||
db-migrate: ## Run the migrations against the local database instance
|
||||
@DATABASE_DRIVER=postgres \
|
||||
DATABASE_HOST=127.0.0.1 \
|
||||
DATABASE_TLS=off \
|
||||
swift run \
|
||||
Website \
|
||||
--database-migrate
|
||||
|
||||
.PHONY: db-shell
|
||||
db-shell: ## Open a SQL shell on the local database instance
|
||||
@docker compose \
|
||||
--profile database \
|
||||
exec \
|
||||
--env PGPASSWORD=$(or $(DATABASE_PASSWORD),ccn) \
|
||||
postgres \
|
||||
psql \
|
||||
--username=$(or $(DATABASE_USERNAME),ccn) \
|
||||
--dbname=$(or $(DATABASE_NAME),ccn)
|
||||
|
||||
.PHONY: db-unmount
|
||||
db-unmount: ## Stop and remove the local database instance while keeping its data
|
||||
@docker compose \
|
||||
--profile database down postgres
|
||||
|
||||
.PHONY: db-reset
|
||||
db-reset: ## Stop and remove the local database instance, then delete its data
|
||||
@docker compose \
|
||||
--profile database down postgres \
|
||||
--volumes
|
||||
@if [ -d Tests/DB ]; then \
|
||||
rm -rf Tests/DB; \
|
||||
mkdir -p Tests/DB; \
|
||||
fi
|
||||
|
||||
# --- 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
|
||||
img-check: ## Check the production image builds
|
||||
@docker build \
|
||||
--platform $(IMAGE_PLATFORM) \
|
||||
--file Dockerfile \
|
||||
../..
|
||||
|
||||
.PHONY: img-release
|
||||
img-release: ## Build and push the production image into the container registry
|
||||
@if [ -z "$(version)" ] || [ "$(version)" = "latest" ]; then \
|
||||
echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@docker build \
|
||||
--platform $(IMAGE_PLATFORM) \
|
||||
--tag $(IMAGE_URL):$(version) \
|
||||
--tag $(IMAGE_URL):latest \
|
||||
--file Dockerfile \
|
||||
../..
|
||||
@echo "${HOST_PASSWORD}" \
|
||||
| docker login $(HOST_CONTAINER) \
|
||||
--username $(HOST_USER) \
|
||||
--password-stdin
|
||||
@docker push $(IMAGE_URL):$(version)
|
||||
@docker push $(IMAGE_URL):latest
|
||||
@docker logout $(HOST_CONTAINER)
|
||||
@$(MAKE) img-remove
|
||||
|
||||
.PHONY: img-remove
|
||||
img-remove: # Removes the generated Docker images
|
||||
@images="$$(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep '$(IMAGE_NAME)' | awk '{print $$1}')"; \
|
||||
if [ -n "$$images" ]; then \
|
||||
docker image rm --force $$images; \
|
||||
else \
|
||||
echo "No '$(IMAGE_NAME)' images to remove."; \
|
||||
fi
|
||||
|
||||
# --- Meta ---------------------------------------------------------------------
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show available commands
|
||||
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -0,0 +1,152 @@
|
||||
// swift-tools-version: 6.3
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Website",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
.iOS(.v18),
|
||||
.tvOS(.v18),
|
||||
],
|
||||
products: [
|
||||
.executable(
|
||||
name: "Website",
|
||||
targets: [
|
||||
"Website",
|
||||
"WebsiteLibrary",
|
||||
]
|
||||
)
|
||||
],
|
||||
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"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/hummingbird-community/hummingbird-elementary.git",
|
||||
from: "0.3.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/hummingbird-project/hummingbird.git",
|
||||
from: "2.25.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/hummingbird-project/hummingbird-compression.git",
|
||||
from: "2.0.0"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/apple/swift-configuration.git",
|
||||
from: "1.0.0",
|
||||
traits: [
|
||||
.defaults,
|
||||
"CommandLineArguments",
|
||||
]
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Website",
|
||||
dependencies: [
|
||||
.byName(name: "Localization"),
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "WebsiteLibrary"),
|
||||
.product(
|
||||
name: "Configuration",
|
||||
package: "swift-configuration"
|
||||
),
|
||||
.product(
|
||||
name: "Hummingbird",
|
||||
package: "hummingbird"
|
||||
),
|
||||
.product(
|
||||
name: "HummingbirdCompression",
|
||||
package: "hummingbird-compression"
|
||||
),
|
||||
],
|
||||
path: "Sources/App"
|
||||
),
|
||||
.target(
|
||||
name: "WebsiteLibrary",
|
||||
dependencies: [
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Localization"),
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "Utility"),
|
||||
.product(
|
||||
name: "Configuration",
|
||||
package: "swift-configuration"
|
||||
),
|
||||
.product(
|
||||
name: "Elementary",
|
||||
package: "elementary"
|
||||
),
|
||||
.product(
|
||||
name: "Hummingbird",
|
||||
package: "hummingbird"
|
||||
),
|
||||
.product(
|
||||
name: "HummingbirdElementary",
|
||||
package: "hummingbird-elementary"
|
||||
),
|
||||
],
|
||||
path: "Sources/Library",
|
||||
resources: [
|
||||
// Copied verbatim rather than processed: the String Catalog is read as raw JSON at
|
||||
// runtime so it resolves identically on Darwin and Linux (which cannot compile it).
|
||||
.copy("Catalogs/Localizable.xcstrings")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "WebsiteTests",
|
||||
dependencies: [
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Website"),
|
||||
.product(
|
||||
name: "HummingbirdTesting",
|
||||
package: "hummingbird"
|
||||
),
|
||||
],
|
||||
path: "Tests/App",
|
||||
resources: [
|
||||
// `Static` links to the service's `Resources/Static`, copying it into the test bundle at build
|
||||
// time — the tests must not read the repository tree, which Xcode's test runner is denied.
|
||||
.copy("Static")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "WebsiteLibraryTests",
|
||||
dependencies: [
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "WebsiteLibrary"),
|
||||
.product(
|
||||
name: "Elementary",
|
||||
package: "elementary"
|
||||
),
|
||||
.product(
|
||||
name: "Hummingbird",
|
||||
package: "hummingbird"
|
||||
),
|
||||
.product(
|
||||
name: "HummingbirdTesting",
|
||||
package: "hummingbird"
|
||||
),
|
||||
],
|
||||
path: "Tests/Library"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,349 @@
|
||||
# CCN Website
|
||||
The **CCN** 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.
|
||||
|
||||
## 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.
|
||||
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
|
||||
- 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).
|
||||
- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`.
|
||||
- Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) 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.
|
||||
- Embeds a cookieless [Umami](https://umami.is) tracker on both pages once a deployment configures one; it ships **off**, so an unconfigured copy requests no third-party script (see [Analytics](#analytics)).
|
||||
- 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 PostgreSQL server, selected by a single configuration key.
|
||||
|
||||
## 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).
|
||||
|
||||
## 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, the pages (`IndexPage`, `NotFoundPage`) and their shared `Page` defaults, the `StaticFile` asset catalog, the request context, the String Catalog, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
|
||||
|
||||
The `Website` executable depends on four local packages, each under `Packages/`:
|
||||
- `Localization` — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
|
||||
- `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 `SocialCard`/`StructuredData`/`Analytics` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the `*+Defaults` extensions in `WebsiteLibrary` and the `ConfigReader` properties in the executable.
|
||||
- `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` — 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)
|
||||
→ 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.
|
||||
|
||||
### Page metadata
|
||||
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
|
||||
|
||||
Four of those are page-authored, optional, and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
|
||||
| Property | Renders as | Notes |
|
||||
| --- | --- | --- |
|
||||
| `summary` | `<meta name="description">` | The page's one-line description. |
|
||||
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. |
|
||||
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. |
|
||||
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:profiles:)` builds the site-wide `Organization` + `WebSite` pair. The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
|
||||
|
||||
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.
|
||||
|
||||
## Configuration
|
||||
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
|
||||
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
|
||||
2. Process environment variables
|
||||
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 — including the database password — and is the file the Makefile and Compose read for their `${VAR}` placeholders; it typically selects the PostgreSQL backend. Keep it out of version control and off shared machines: Compose passes its values to the container as environment variables, so anything in it is readable through `docker inspect` and by every process in the container.
|
||||
- **`.env.local`** (tracked) holds the local development overrides: in-memory database, `debug` logging. Sitting *above* `.env`, it keeps a direct launch (`swift run` or a debugger) on the local values even when `.env` points at a deployment. Compose never reads it, and the production image does not ship it.
|
||||
|
||||
The Makefile `include`s `.env` and exports every value, so a target launched through `make` runs with the deployment configuration rather than the `.env.local` one: `make site-run` uses the backend `.env` selects, a bare `swift run Website` the in-memory one. And because a makefile assignment outranks an inherited environment variable, overriding a value for a single invocation takes a command-line variable *after* the target (`make site-mount DATABASE_DRIVER=postgres`) — an environment prefix is silently discarded.
|
||||
|
||||
### 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`.
|
||||
> 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.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 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.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.
|
||||
|
||||
### 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` | `CCNWebsite` | Server name and logger label. |
|
||||
|
||||
### 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 `postgres` (PostgreSQL). |
|
||||
| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
|
||||
| `database.host` | `DATABASE_HOST` | `localhost` | PostgreSQL host. Ignored for `inMemory`. |
|
||||
| `database.port` | `DATABASE_PORT` | `5432` | PostgreSQL port. Ignored for `inMemory`. |
|
||||
| `database.name` | `DATABASE_NAME` | `ccn` | Database name. Ignored for `inMemory`. |
|
||||
| `database.username` | `DATABASE_USERNAME` | `ccn` | 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`. |
|
||||
| `database.pool.timeout` | `DATABASE_POOL_TIMEOUT` | `10` | Seconds a query waits for a pooled connection before failing. Ignored for `inMemory`. |
|
||||
|
||||
> **Connection budget:** the pool holds `database.pool.maxPerEventLoop` connections *per event loop*, and the event loop group runs one loop per core. An 8-core instance can therefore open 32, and each replica that many again — three replicas exhaust PostgreSQL's default `max_connections` of 100. Size this against the server's limit, not against the number alone. On an exhausted pool, a query waits up to `database.pool.timeout` before failing.
|
||||
|
||||
See [Persistence](#persistence-1) below for the workflow.
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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.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. |
|
||||
|
||||
### 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:
|
||||
|
||||
1. Point `String.Analytics.origin` (`Sources/Library/Public/Extensions/String+Constants.swift`) at your own [Umami](https://umami.is) instance. It ships as `https://analytics.example.com`, an [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) reserved domain, so an unconfigured copy can never report to somebody else's server.
|
||||
2. Extend `security.contentSecurityPolicy` to allow that origin in `script-src` and `connect-src` — the default policy is `'self'`-only, so the tracker is blocked until you do.
|
||||
3. Set `ANALYTICS_WEBSITE_ID` on the deployment.
|
||||
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `analytics.websiteID` | `ANALYTICS_WEBSITE_ID` | _(empty — analytics off)_ | The analytics website identifier the tracker on both pages reports as. While it is empty the tracker script is omitted entirely; clearing it again disables analytics on a deployment. |
|
||||
| `analytics.domains` | `ANALYTICS_DOMAINS` | _(empty — every host reports)_ | Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. Left empty, the attribute is omitted and no host is filtered out. |
|
||||
| `analytics.recorder` | `ANALYTICS_RECORDER` | `false` | Whether the pages also embed the session recorder script (`recorder.js`, loaded from the tracker's origin) alongside the tracker. Session recording is the most invasive thing the tracker does, so it is opted into: set it to `true` to enable it on a deployment. |
|
||||
|
||||
The tracker's origin is not a configuration key: it is single-sourced in code so the tracker tag and the `Content-Security-Policy` that must allow it (`security.contentSecurityPolicy` below) cannot drift apart at runtime. The pages emit a `preconnect` hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script.
|
||||
|
||||
> **Set `analytics.domains` to the host the deployment actually serves, or leave it empty.** It is an allowlist: name a host the deployment does not serve (say, pointing a staging box at the production domain) and every visit is dropped silently, with no error. To turn analytics off instead, clear `analytics.websiteID`.
|
||||
|
||||
### 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: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. `docker-compose.yml` enables it for production, where it takes 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
|
||||
```
|
||||
|
||||
A direct run picks up the `.env.local` development overrides (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — `DATABASE_DRIVER=postgres 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
|
||||
make pkg-outdated # list the SPM dependencies that can be updated
|
||||
make pkg-update # update the SPM dependencies
|
||||
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
|
||||
```
|
||||
Unlike a direct `swift run`, these targets inherit the exported `.env` values (see [Configuration](#configuration)), so they run against whichever backend `.env` selects. The one exception is `DATABASE_HOST` under `make site-mount`: the local Compose override pins it to the `postgres` service name, since the `.env` value addresses the database from the host rather than from inside the container. `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.
|
||||
|
||||
### PostgreSQL
|
||||
Set `DATABASE_DRIVER=postgres` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, PostgreSQL is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
|
||||
```sh
|
||||
# Run the registered migrations, then exit.
|
||||
swift run Website --database-migrate
|
||||
# The same, in a container against the managed database.
|
||||
docker compose -f docker-compose.yml run --rm website --database-migrate
|
||||
```
|
||||
|
||||
A local PostgreSQL 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) and initialised once from the `DATABASE_NAME`/`DATABASE_USERNAME`/`DATABASE_PASSWORD` values in `.env`:
|
||||
```sh
|
||||
make db-mount # start PostgreSQL (docker compose --profile database up --wait postgres)
|
||||
make db-migrate # migrate it, from the host (forced to 127.0.0.1 with TLS off)
|
||||
make db-shell # open a SQL shell on it (psql)
|
||||
make db-unmount # stop and remove the container, keeping the data
|
||||
make db-reset # stop and remove the container, and delete the data
|
||||
```
|
||||
Then run the site against it, on the host or in its container:
|
||||
```sh
|
||||
make site-run # hot reload on the host, via localhost:5432
|
||||
make site-mount # containerised, via the Compose service name
|
||||
curl -i localhost:8080/health/ready # 200 once the database is reachable
|
||||
```
|
||||
The containerised run needs no `DATABASE_HOST`: `docker-compose.override.yml` pins it to `postgres`, the Compose service name, which is the only address that resolves from inside the network. The `.env` value is the *host machine's* view (`localhost`) and is left to `make site-run`, which does run on the host. The override also `depends_on` the database, so with the `database` profile enabled the website waits for PostgreSQL to pass its healthcheck; without the profile it still starts alone on the in-memory backend.
|
||||
|
||||
> **Note:** every other `DATABASE_*` override must be passed *after* the target — `DATABASE_TLS=off make site-mount` is silently discarded (see [Configuration](#configuration)), `make site-mount DATABASE_TLS=off` is not.
|
||||
|
||||
> **Note:** `db-reset` deletes `Tests/DB` itself, because Compose's `--volumes` flag cannot clear a bind mount. Use it to start from an empty database — for instance after changing `DATABASE_PASSWORD`, which is only read when the cluster is first initialised.
|
||||
|
||||
### Health checks
|
||||
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` 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. A hanging database is reported as not ready within the probe's 2-second deadline, so the route itself never stalls. `docker-compose.yml` points the `website` container healthcheck at `/health`, keeping container health decoupled from 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. `make pkg-test` runs the service package's own two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
|
||||
|
||||
`Tests/Website.xctestplan` — the plan the `Site.xcodeproj` scheme runs — adds the vendored packages' suites on top of those two: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. From the command line, each of those is run from its own package directory (`swift test` in `Packages/<Name>`).
|
||||
|
||||
The `Persistence` package also has its own suite, run from `Packages/Persistence`. It uses the in-memory backend by default; the PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at a database, so `swift test` stays runnable without one:
|
||||
```sh
|
||||
cd ../../Packages/Persistence && swift test # in-memory only
|
||||
cd ../../Packages/Persistence && POSTGRES_TEST_HOST=127.0.0.1 swift test # against make db-mount
|
||||
```
|
||||
`POSTGRES_TEST_NAME`/`USERNAME`/`PASSWORD` each default to `ccn` (and `POSTGRES_TEST_PORT` is optional), so pass the password explicitly when the local container was initialised with a different `DATABASE_PASSWORD`.
|
||||
|
||||
## Deployment
|
||||
The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`.
|
||||
|
||||
`IMAGE_PLATFORM` is the single source of truth for the deployment architecture: `make img-check` and `make img-release` both build for it, and `docker-compose.yml` runs the pulled image with it. Keep it matched to the deployment host — the three have to agree, or a release builds for one architecture and the production Compose file refuses to run it. Local development builds are separate and follow `BUILD_PLATFORM` (see `docker-compose.override.yml`), since they target your machine rather than the deployment.
|
||||
|
||||
The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under `Tests/DB` out of the build context entirely (`.dockerignore` excludes `**/Tests` for the same reason).
|
||||
|
||||
### Base images
|
||||
All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with
|
||||
|
||||
```sh
|
||||
docker buildx imagetools inspect swift:6.3-noble --format '{{.Manifest.Digest}}'
|
||||
```
|
||||
|
||||
Both build stages are discarded — only `ubuntu:noble` ships — and the build and run stages each `apt-get dist-upgrade`, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself.
|
||||
|
||||
The executable is the `ENTRYPOINT` and its serving flags are the `CMD`:
|
||||
```dockerfile
|
||||
ENTRYPOINT ["./Website"]
|
||||
CMD ["--http-host", "0.0.0.0", "--http-port", "8080"]
|
||||
```
|
||||
The split is what makes the migrate-and-exit invocation below work: `docker compose run --rm website --database-migrate` replaces the `CMD` flags without having to override the entrypoint.
|
||||
|
||||
```sh
|
||||
make img-check # verify it builds for IMAGE_PLATFORM, without tagging or publishing
|
||||
make img-release version=1.2.3 # build, tag, and push a release (an explicit version is required)
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
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/`).
|
||||
- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng), recursively — the output is pixel-identical, only encoded smaller.
|
||||
- SVGs are minified with [svgo](https://github.com/svg/svgo), recursively.
|
||||
|
||||
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.
|
||||
|
||||
Preview the optimized output locally (Docker only, writes to the git-ignored `.build/minified`):
|
||||
```sh
|
||||
make ast-minify
|
||||
```
|
||||
|
||||
#### Crawler files
|
||||
`robots.txt` and `sitemap.xml` need an absolute origin, which the template ships as the placeholder `https://site.example.com` — an [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) reserved domain, so an un-bootstrapped copy can never point a crawler at a real site. The bootstrap script prompts for the canonical site URL and rewrites both files with it; it warns if the placeholder is left in place.
|
||||
|
||||
To change the origin later, edit the `Sitemap:` line in `robots.txt` and the `<loc>` entries in `sitemap.xml`. Add a `<loc>` per public page as the site grows — nothing generates the sitemap at runtime.
|
||||
|
||||
> **Still manual:** `site.webmanifest` ships empty `name` / `short_name` fields; bootstrap does not fill them in.
|
||||
|
||||
### Icons
|
||||
All icons are renditions of the star mark in `icon.svg`, 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 icon and manifest links come from the shared `Page+Defaults` extension, so every page carries them, alongside two `theme-color` metas: `#fafafa` unqualified, then `#0c0710` qualified with `(prefers-color-scheme: dark)`.
|
||||
|
||||
> **Note:** a browser applies the *first* `theme-color` whose media query matches, so the unqualified light value currently wins on both themes. Put the dark, media-qualified meta first in `Page+Defaults` if the dark value should take effect.
|
||||
|
||||
The raster icons are committed binaries, regenerated from `icon.svg` on demand via a throwaway container (no local toolchain needed) — e.g. 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 `.env` (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_TAG` is the fallback for `make img-release`'s `version=` argument. |
|
||||
| `IMAGE_PLATFORM` | Deployment architecture (default `linux/amd64`). Drives `make img-check`, `make img-release`, and the `platform:` the production Compose file runs the pulled image with — keep it matched to the deployment host. |
|
||||
| `BUILD_PLATFORM` | Architecture of the *local* development build only (default `linux/arm64`); set it to `linux/amd64` on an Intel Mac or an amd64 Linux box. Never used by the production Compose file. |
|
||||
| `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 `CCNWebsite`). |
|
||||
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
|
||||
| `DATABASE_DRIVER` | `inMemory` or `postgres`. The production Compose file defaults it to `postgres`; the local override defaults back to the in-memory backend. |
|
||||
| `DATABASE_HOST`, `DATABASE_PASSWORD` | **Mandatory** — the production Compose file carries no default for either, since none can be correct: `localhost` inside the container is the container itself, and a blank password authenticates as nobody. It refuses to start without them rather than booting a website that serves 503s. Both come from `.env`, which is git-ignored — never commit the password. Compose interpolates each file before merging, so both must be set for a local `docker compose up` too, even though the override pins the host. |
|
||||
| `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME` | The rest of the PostgreSQL connection (when `DATABASE_DRIVER=postgres`); these do default (`5432`, `ccn`, `ccn`). |
|
||||
| `DATABASE_POOL_MAX_PER_EVENT_LOOP` | Pooled connections per event loop (default `4`) — see the [connection budget](#persistence) before scaling out. |
|
||||
| `DATABASE_TLS` | TLS posture: `off`, `prefer`, or `require`. The production Compose file defaults it to `require`, which refuses a server offering no TLS; the local override defaults it to `off` for the plaintext development container. `prefer` continues in plaintext when the upgrade is stripped, handing over the password — so it is not a safe production posture. |
|
||||
|
||||
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,43 @@
|
||||
/* Styles for the not-found (404) error page. */
|
||||
|
||||
* {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
color: #888;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: 2em auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #555;
|
||||
font-size: 2em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 auto;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 280px) {
|
||||
body, p {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 0.3em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*! HTML5 Boilerplate v9.0.1 | MIT License | https://html5boilerplate.com/ */
|
||||
|
||||
/* main.css 3.0.0 | MIT License | https://github.com/h5bp/main.css#readme */
|
||||
/*
|
||||
* What follows is the result of much research on cross-browser styling.
|
||||
* Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal,
|
||||
* Kroc Camen, and the H5BP dev community and team.
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Base styles: opinionated defaults
|
||||
========================================================================== */
|
||||
|
||||
html {
|
||||
color: #222;
|
||||
font-size: 1em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove text-shadow in selection highlight:
|
||||
* https://twitter.com/miketaylr/status/12228805301
|
||||
*
|
||||
* Customize the background color to match your design.
|
||||
*/
|
||||
|
||||
::-moz-selection {
|
||||
background: #b3d4fc;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #b3d4fc;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* A better looking default horizontal rule
|
||||
*/
|
||||
|
||||
hr {
|
||||
display: block;
|
||||
height: 1px;
|
||||
border: 0;
|
||||
border-top: 1px solid #ccc;
|
||||
margin: 1em 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove the gap between audio, canvas, iframes,
|
||||
* images, videos and the bottom of their containers:
|
||||
* https://github.com/h5bp/html5-boilerplate/issues/440
|
||||
*/
|
||||
|
||||
audio,
|
||||
canvas,
|
||||
iframe,
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove default fieldset styles.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Allow only vertical resizing of textareas.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Author's custom styles
|
||||
========================================================================== */
|
||||
|
||||
/* ==========================================================================
|
||||
Helper classes
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Hide visually and from screen readers
|
||||
*/
|
||||
|
||||
.hidden,
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Hide only visually, but have it available for screen readers:
|
||||
* https://snook.ca/archives/html_and_css/hiding-content-for-accessibility
|
||||
*
|
||||
* 1. For long content, line feeds are not interpreted as spaces and small width
|
||||
* causes content to wrap 1 word per line:
|
||||
* https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe
|
||||
*/
|
||||
|
||||
.visually-hidden {
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
/* 1 */
|
||||
}
|
||||
|
||||
/*
|
||||
* Extends the .visually-hidden class to allow the element
|
||||
* to be focusable when navigated to via the keyboard:
|
||||
* https://www.drupal.org/node/897638
|
||||
*/
|
||||
|
||||
.visually-hidden.focusable:active,
|
||||
.visually-hidden.focusable:focus {
|
||||
clip: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
position: static;
|
||||
white-space: inherit;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Hide visually and from screen readers, but maintain layout
|
||||
*/
|
||||
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* Clearfix: contain floats
|
||||
*
|
||||
* The use of `table` rather than `block` is only necessary if using
|
||||
* `::before` to contain the top-margins of child elements.
|
||||
*/
|
||||
|
||||
.clearfix::before,
|
||||
.clearfix::after {
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
.clearfix::after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
EXAMPLE Media Queries for Responsive Design.
|
||||
These examples override the primary ('mobile first') styles.
|
||||
Modify as content requires.
|
||||
========================================================================== */
|
||||
|
||||
@media only screen and (min-width: 35em) {
|
||||
/* Style adjustments for viewports that meet the condition */
|
||||
}
|
||||
|
||||
@media print,
|
||||
(min-resolution: 1.25dppx),
|
||||
(min-resolution: 120dpi) {
|
||||
/* Style adjustments for high resolution devices */
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Print styles.
|
||||
Inlined to avoid the additional HTTP request:
|
||||
https://www.phpied.com/delay-loading-your-print-css/
|
||||
========================================================================== */
|
||||
|
||||
@media print {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
/* Black prints faster */
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
a,
|
||||
a:visited {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a[href]::after {
|
||||
content: " (" attr(href) ")";
|
||||
}
|
||||
|
||||
abbr[title]::after {
|
||||
content: " (" attr(title) ")";
|
||||
}
|
||||
|
||||
/*
|
||||
* Don't show links that are fragment identifiers,
|
||||
* or use the `javascript:` pseudo protocol
|
||||
*/
|
||||
a[href^="#"]::after,
|
||||
a[href^="javascript:"]::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
|
||||
pre,
|
||||
blockquote {
|
||||
border: 1px solid #999;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
tr,
|
||||
img {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
p,
|
||||
h2,
|
||||
h3 {
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 700 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1,7 @@
|
||||
# https://www.robotstxt.org/
|
||||
|
||||
# Allow crawling of all content
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://ccn.rock-n-co.de/sitemap.xml
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"short_name": "",
|
||||
"name": "",
|
||||
"icons": [{
|
||||
"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",
|
||||
"theme_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://ccn.rock-n-co.de/</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,52 @@
|
||||
import Configuration
|
||||
import Hummingbird
|
||||
|
||||
/// The entry point of the website executable.
|
||||
///
|
||||
/// Loads the configuration, then either serves the website or — when the `database.migrate` flag is set — runs the registered migrations against the
|
||||
/// configured backend and exits.
|
||||
@main
|
||||
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.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
|
||||
),
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.serverName: .HTTP.serverName
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let app = try await application(
|
||||
reader: reader
|
||||
)
|
||||
|
||||
try await app.runService()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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 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 PostgreSQL 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.
|
||||
/// - Returns: the configured application, ready to run as a service.
|
||||
/// - Throws: an error when the persistence service cannot be built (e.g. its TLS context fails to build).
|
||||
func application(
|
||||
reader: ConfigReader
|
||||
) async throws -> 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 = try Service(
|
||||
driver: reader.driver,
|
||||
logger: logger
|
||||
)
|
||||
let fluent = persistence()
|
||||
|
||||
let fingerprintAssets = FingerprintAssets(logger: logger)
|
||||
let prepareDB = PrepareDB()
|
||||
|
||||
await prepareDB(for: fluent)
|
||||
|
||||
var app = Application(
|
||||
router: router(
|
||||
staticFilesPath: reader.staticFilesPath,
|
||||
assetVersion: fingerprintAssets(reader.staticFilesPath),
|
||||
analytics: reader.analytics,
|
||||
cacheControl: reader.cacheControl,
|
||||
compressionMinResponseSize: reader.compressionMinResponseSize,
|
||||
rateLimit: reader.rateLimit,
|
||||
securityHeaders: reader.securityHeaders,
|
||||
logLevel: reader.logLevel,
|
||||
probe: Probe(fluent: fluent)
|
||||
),
|
||||
configuration: ApplicationConfiguration(
|
||||
reader: reader.scoped(to: "http")
|
||||
),
|
||||
logger: logger
|
||||
)
|
||||
|
||||
app.addServices(fluent)
|
||||
|
||||
// The in-memory backend is recreated on every launch, so it is migrated on startup. The PostgreSQL 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()
|
||||
}
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
/// Runs every registered migration against the configured backend, then exits.
|
||||
///
|
||||
/// This is the out-of-band migration path selected by the `database.migrate` flag: it builds the same driver the service would run against, applies the
|
||||
/// migrations, and shuts the database down — so a shared PostgreSQL database is migrated by a single deliberate invocation rather than by every
|
||||
/// booting instance.
|
||||
/// - Parameter reader: the configuration reader the values are read from.
|
||||
func migration(
|
||||
reader: ConfigReader
|
||||
) async throws {
|
||||
let logger = logger(
|
||||
serverName: reader.serverName,
|
||||
logLevel: reader.logLevel
|
||||
)
|
||||
let service = try Service(
|
||||
driver: reader.driver,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
let fluent = service()
|
||||
let prepareDB = PrepareDB()
|
||||
|
||||
await prepareDB(for: fluent)
|
||||
|
||||
do {
|
||||
try await fluent.migrate()
|
||||
}
|
||||
catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// The request context type the application serves its routes with.
|
||||
private typealias AppRequestContext = WebsiteRequestContext
|
||||
|
||||
/// Builds the application's logger.
|
||||
/// - Parameters:
|
||||
/// - serverName: the label applied to the logger.
|
||||
/// - logLevel: the minimum level the logger emits.
|
||||
/// - Returns: the configured logger.
|
||||
private func logger(
|
||||
serverName: String,
|
||||
logLevel: Logger.Level
|
||||
) -> Logger {
|
||||
var logger = Logger(label: serverName)
|
||||
|
||||
logger.logLevel = logLevel
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
/// Builds the application's router.
|
||||
///
|
||||
/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
|
||||
/// 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.
|
||||
/// - analytics: the analytics tracker both pages embed, or `nil` to omit it.
|
||||
/// - 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 rate-limited routes.
|
||||
/// - 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?,
|
||||
analytics: Analytics?,
|
||||
cacheControl: CacheControl,
|
||||
compressionMinResponseSize: Int,
|
||||
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||
logLevel: Logger.Level,
|
||||
probe: Probe
|
||||
) -> Router<AppRequestContext> {
|
||||
// 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(
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
FileMiddleware(
|
||||
staticFilesPath,
|
||||
cacheControl: cacheControl
|
||||
)
|
||||
}
|
||||
|
||||
router.addController {
|
||||
RootController<AppRequestContext>(
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
HealthController<AppRequestContext>(
|
||||
probe: probe
|
||||
)
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import Configuration
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
import Logging
|
||||
import Persistence
|
||||
import WebsiteLibrary
|
||||
|
||||
package extension ConfigReader {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
/// The request context type the application serves its routes with; the security headers configuration is generic over it.
|
||||
typealias AppRequestContext = WebsiteRequestContext
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The analytics tracker both pages embed, built from the `analytics.*` keys, or `nil` when `analytics.websiteID` resolves empty.
|
||||
///
|
||||
/// The identifier is empty by default, so the template serves no tracker at all until a deployment sets `analytics.websiteID` — and
|
||||
/// clearing it again disables analytics entirely.
|
||||
///
|
||||
/// The script URL is not configurable: its origin is single-sourced in `String.Analytics`, so the tracker tag and the
|
||||
/// `Content-Security-Policy` that must allow it derive from one constant and cannot drift apart. Point that constant at your own
|
||||
/// instance, and extend `security.contentSecurityPolicy` to allow it, before enabling analytics.
|
||||
///
|
||||
/// The `analytics.domains` filter must name the host the pages are served from; it is empty by default, which reports from every host.
|
||||
/// Set it to a host the deployment does not serve and the tracker silently records nothing.
|
||||
///
|
||||
/// Recorder mode is off by default — session recording is the most invasive thing the tracker does, so a deployment opts into it
|
||||
/// deliberately with the `analytics.recorder` flag. When on, the pages embed the session recorder script alongside the tracker; it loads
|
||||
/// from the same origin, so the `Content-Security-Policy` needs no extra allowance.
|
||||
var analytics: Analytics? {
|
||||
let websiteID = string(
|
||||
forKey: .Analytics.websiteID,
|
||||
default: .Analytics.websiteID
|
||||
)
|
||||
|
||||
guard !websiteID.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .init(
|
||||
scriptURL: .Analytics.scriptURL,
|
||||
websiteID: websiteID,
|
||||
domains: string(
|
||||
forKey: .Analytics.domains,
|
||||
default: .Analytics.domains
|
||||
),
|
||||
recorder: bool(
|
||||
forKey: .Analytics.recorder,
|
||||
default: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// The `Cache-Control` policy applied to static files, grouped by media type.
|
||||
///
|
||||
/// 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
|
||||
)
|
||||
let maxAgeImage = int(
|
||||
forKey: .Cache.maxAgeImage,
|
||||
default: .Cache.maxAgeImage
|
||||
)
|
||||
let maxAgeText = int(
|
||||
forKey: .Cache.maxAgeText,
|
||||
default: .Cache.maxAgeText
|
||||
)
|
||||
|
||||
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)]),
|
||||
])
|
||||
}
|
||||
|
||||
/// The minimum response body size, in bytes, before a response is compressed — read from the `compression.minimumResponseSize` key.
|
||||
var compressionMinResponseSize: Int {
|
||||
int(
|
||||
forKey: .Compression.minResponseSize,
|
||||
default: .Compression.minResponseSize
|
||||
)
|
||||
}
|
||||
|
||||
/// The persistence backend the service runs against, derived from the `database.*` keys.
|
||||
///
|
||||
/// When `database.driver` selects PostgreSQL, the connection parameters are assembled from the `database.host`, `database.port`,
|
||||
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`,
|
||||
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any other driver value falls back to the in-memory database.
|
||||
var driver: Driver {
|
||||
switch string(
|
||||
forKey: .Database.driver,
|
||||
default: .Database.driver
|
||||
) {
|
||||
case .Database.driverPostgres:
|
||||
return .postgres(
|
||||
.init(
|
||||
host: string(
|
||||
forKey: .Database.host,
|
||||
default: .Database.host
|
||||
),
|
||||
port: int(
|
||||
forKey: .Database.port,
|
||||
default: .Database.port
|
||||
),
|
||||
name: string(
|
||||
forKey: .Database.name,
|
||||
default: .Database.name
|
||||
),
|
||||
username: string(
|
||||
forKey: .Database.username,
|
||||
default: .Database.username
|
||||
),
|
||||
password: string(
|
||||
forKey: .Database.password,
|
||||
default: ""
|
||||
),
|
||||
tls: tls,
|
||||
maxConnectionsPerEventLoop: int(
|
||||
forKey: .Database.poolMaxPerEventLoop,
|
||||
default: .Database.poolMaxPerEventLoop
|
||||
),
|
||||
poolTimeout: .seconds(int(
|
||||
forKey: .Database.poolTimeout,
|
||||
default: .Database.poolTimeout
|
||||
))
|
||||
)
|
||||
)
|
||||
default:
|
||||
return .inMemory
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimum log level the application emits at, read from the `log.level` key.
|
||||
///
|
||||
/// Falls back to `.info` when the key is unset or its value names no `Logger.Level` case.
|
||||
var logLevel: Logger.Level {
|
||||
string(
|
||||
forKey: .Log.level,
|
||||
as: Logger.Level.self,
|
||||
default: .info
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the executable runs in migrate-and-exit mode instead of serving, read from the `database.migrate` flag; off by default.
|
||||
var migrate: Bool {
|
||||
bool(
|
||||
forKey: .Database.migrate,
|
||||
default: false
|
||||
)
|
||||
}
|
||||
|
||||
/// 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`
|
||||
/// is set — the header is a commitment browsers cache, so it must be opted into for deployments actually served over HTTPS.
|
||||
var securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration {
|
||||
.init(
|
||||
contentSecurityPolicy: string(
|
||||
forKey: .Security.contentSecurityPolicy,
|
||||
default: .Security.contentSecurityPolicy
|
||||
),
|
||||
contentTypeOptions: string(
|
||||
forKey: .Security.contentTypeOptions,
|
||||
default: .Security.contentTypeOptions
|
||||
),
|
||||
frameOptions: string(
|
||||
forKey: .Security.frameOptions,
|
||||
default: .Security.frameOptions
|
||||
),
|
||||
referrerPolicy: string(
|
||||
forKey: .Security.referrerPolicy,
|
||||
default: .Security.referrerPolicy
|
||||
),
|
||||
permissionsPolicy: string(
|
||||
forKey: .Security.permissionsPolicy,
|
||||
default: .Security.permissionsPolicy
|
||||
),
|
||||
strictTransportSecurity: string(
|
||||
forKey: .Security.strictTransportSecurity
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// The name the server reports in its `Server` response header, read from the `http.serverName` key.
|
||||
var serverName: String {
|
||||
string(
|
||||
forKey: .HTTP.serverName,
|
||||
default: .Server.name
|
||||
)
|
||||
}
|
||||
|
||||
/// The directory the static files are served from, read from the `path.staticFiles` key.
|
||||
var staticFilesPath: String {
|
||||
string(
|
||||
forKey: .Path.staticFiles,
|
||||
default: .Path.staticResources
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ConfigReader {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
|
||||
/// other value falls back to `prefer`.
|
||||
var tls: TLS {
|
||||
switch string(
|
||||
forKey: .Database.tls,
|
||||
default: .Database.tls
|
||||
) {
|
||||
case .Database.tlsOff: .off
|
||||
case .Database.tlsRequire: .require
|
||||
default: .prefer
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"index.greeting" : {
|
||||
"comment" : "The landing page's greeting paragraph.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Hello world! This is HTML5 Boilerplate."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"index.title" : {
|
||||
"comment" : "The landing page's document title.",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Index page"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Infrastructure
|
||||
|
||||
/// A static file shipped with the website service.
|
||||
///
|
||||
/// 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 favicon
|
||||
/// The `icon.svg` icon.
|
||||
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 robots
|
||||
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
|
||||
case shared
|
||||
/// The `site.webmanifest` web application manifest.
|
||||
case site
|
||||
/// The `sitemap.xml` crawler sitemap.
|
||||
case sitemap
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension StaticFile {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The file extensions the file is available with.
|
||||
var fileExtensions: [AssetExtension] {
|
||||
switch self {
|
||||
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 .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)"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Localization
|
||||
|
||||
/// The website's landing page, with its text localized to a given locale.
|
||||
struct IndexPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The analytics tracker embedded as a deferred script in the document head, or `nil` to omit it.
|
||||
let analytics: Analytics?
|
||||
|
||||
/// 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 landing 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.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
locale: Locale,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = locale
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension IndexPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
p {
|
||||
localize("index.greeting", locale: locale)
|
||||
}
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.index, StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.index]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
localize("index.title", locale: locale)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 analytics tracker embedded as a deferred script in the document head, or `nil` to omit it.
|
||||
let analytics: Analytics?
|
||||
|
||||
/// 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.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
locale: Locale,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Hummingbird
|
||||
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, and with the connected client's address, so ``RateLimitMiddleware`` can key its budgets per client.
|
||||
public struct WebsiteRequestContext: LocalizedRequestContext, RemoteAddressRequestContext {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The core request context storage Hummingbird requires.
|
||||
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
|
||||
|
||||
/// Creates a request context for the given source.
|
||||
/// - Parameter source: the source the context is initialized from.
|
||||
public init(
|
||||
source: Source,
|
||||
) {
|
||||
self.coreContext = .init(source: source)
|
||||
self.language = .empty
|
||||
self.remoteAddress = source.channel.remoteAddress
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension String {
|
||||
static let empty = ""
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Hummingbird
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Infrastructure
|
||||
|
||||
/// Serves the website's health-check routes.
|
||||
///
|
||||
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
|
||||
///
|
||||
/// ```swift
|
||||
/// 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
|
||||
/// whether the service's database is reachable. The two are kept distinct so an orchestrator can restart on liveness failure but only withhold traffic on
|
||||
/// 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> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The probe consulted for the readiness check, or `nil` when only liveness is served.
|
||||
private let probe: Probe?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a health controller.
|
||||
/// - 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: - RouterController
|
||||
|
||||
extension HealthController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.Health.check,
|
||||
use: check
|
||||
)
|
||||
|
||||
if probe != nil {
|
||||
routes.get(
|
||||
.Health.ready,
|
||||
use: ready
|
||||
)
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HealthController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// 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
|
||||
/// unresponsive.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: a `200 OK` response carrying the static JSON status payload.
|
||||
@Sendable
|
||||
func check(
|
||||
request: Request,
|
||||
context: some RequestContext
|
||||
) -> Response {
|
||||
json(
|
||||
status: .ok,
|
||||
payload: .Payload.live
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: a `200 OK` response when ready, or `503 Service Unavailable` when not.
|
||||
@Sendable
|
||||
func ready(
|
||||
request: Request,
|
||||
context: some RequestContext
|
||||
) async -> Response {
|
||||
guard await probe?() == true else {
|
||||
return json(
|
||||
status: .serviceUnavailable,
|
||||
payload: .Payload.unavailable
|
||||
)
|
||||
}
|
||||
|
||||
return json(
|
||||
status: .ok,
|
||||
payload: .Payload.ready
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a JSON response carrying the given status and payload.
|
||||
/// - Parameters:
|
||||
/// - status: the HTTP status of the response.
|
||||
/// - payload: the JSON body of the response.
|
||||
/// - Returns: the configured JSON response.
|
||||
func json(
|
||||
status: HTTPResponse.Status,
|
||||
payload: String
|
||||
) -> Response {
|
||||
Response(
|
||||
status: status,
|
||||
headers: [.contentType: "application/json"],
|
||||
body: .init(byteBuffer: .init(string: payload))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterPath+Constants
|
||||
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``HealthController`` route paths.
|
||||
enum Health {
|
||||
/// The path of the liveness endpoint.
|
||||
static let check: RouterPath = "/health"
|
||||
/// The path of the readiness endpoint.
|
||||
static let ready: RouterPath = "/health/ready"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
enum Payload {
|
||||
static let live = #"{"status":"ok"}"#
|
||||
static let ready = #"{"status":"ready"}"#
|
||||
static let unavailable = #"{"status":"unavailable"}"#
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
|
||||
/// Serves the website's root routes.
|
||||
///
|
||||
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
|
||||
///
|
||||
/// ```swift
|
||||
/// 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> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The landing page, rendered once per supported language and reused for every request.
|
||||
private let responses: LocalizedHTMLCollectionResponse
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a root controller.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.responses = .init(bundle: .module) {
|
||||
IndexPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouteController
|
||||
|
||||
extension RootController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.Root.index,
|
||||
use: index
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RootController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// 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.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - Returns: the cached ``IndexPage`` response for the context's language.
|
||||
@Sendable
|
||||
func index(
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
responses.response(
|
||||
for: context.language,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``RootController`` route paths.
|
||||
enum Root {
|
||||
/// The path of the landing page.
|
||||
static let index: RouterPath = "/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Configuration
|
||||
|
||||
extension AbsoluteConfigKey {
|
||||
/// A namespace for the analytics configuration keys, as absolute keys.
|
||||
public enum Analytics {
|
||||
/// The absolute configuration key for the analytics website identifier.
|
||||
public static let websiteID: AbsoluteConfigKey = .init(.Analytics.websiteID)
|
||||
/// The absolute configuration key for the comma-delimited domains the tracker reports from.
|
||||
public static let domains: AbsoluteConfigKey = .init(.Analytics.domains)
|
||||
/// The absolute configuration key for recorder mode, loading the session recorder script alongside the tracker.
|
||||
public static let recorder: AbsoluteConfigKey = .init(.Analytics.recorder)
|
||||
}
|
||||
/// 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 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)
|
||||
/// The absolute configuration key for the max-age, in seconds, applied to all other static files.
|
||||
public static let maxAgeDefault: AbsoluteConfigKey = .init(.Cache.maxAgeDefault)
|
||||
}
|
||||
/// A namespace for the response compression configuration keys, as absolute keys.
|
||||
public enum Compression {
|
||||
/// The absolute configuration key for the minimum response body size, in bytes, before compression is applied.
|
||||
public static let minResponseSize: AbsoluteConfigKey = .init(.Compression.minResponseSize)
|
||||
}
|
||||
/// A namespace for the persistence configuration keys, as absolute keys.
|
||||
public enum Database {
|
||||
/// The absolute configuration key selecting migrate-and-exit mode.
|
||||
public static let migrate: AbsoluteConfigKey = .init(.Database.migrate)
|
||||
/// The absolute configuration key for the persistence driver.
|
||||
public static let driver: AbsoluteConfigKey = .init(.Database.driver)
|
||||
/// The absolute configuration key for the PostgreSQL host.
|
||||
public static let host: AbsoluteConfigKey = .init(.Database.host)
|
||||
/// The absolute configuration key for the PostgreSQL port.
|
||||
public static let port: AbsoluteConfigKey = .init(.Database.port)
|
||||
/// The absolute configuration key for the database name.
|
||||
public static let name: AbsoluteConfigKey = .init(.Database.name)
|
||||
/// The absolute configuration key for the database username.
|
||||
public static let username: AbsoluteConfigKey = .init(.Database.username)
|
||||
/// The absolute configuration key for the database password.
|
||||
public static let password: AbsoluteConfigKey = .init(.Database.password)
|
||||
/// The absolute configuration key for the TLS posture used when connecting.
|
||||
public static let tls: AbsoluteConfigKey = .init(.Database.tls)
|
||||
/// The absolute configuration key for the maximum pooled connections per event loop.
|
||||
public static let poolMaxPerEventLoop: AbsoluteConfigKey = .init(.Database.poolMaxPerEventLoop)
|
||||
/// The absolute configuration key for the longest wait, in seconds, for a pooled connection to become available.
|
||||
public static let poolTimeout: AbsoluteConfigKey = .init(.Database.poolTimeout)
|
||||
}
|
||||
/// A namespace for the HTTP server configuration keys, as absolute keys.
|
||||
public enum HTTP {
|
||||
/// The absolute configuration key for the host the server binds to.
|
||||
public static let host: AbsoluteConfigKey = .init(.HTTP.host)
|
||||
/// The absolute configuration key for the port the server listens on.
|
||||
public static let port: AbsoluteConfigKey = .init(.HTTP.port)
|
||||
/// The absolute configuration key for the server's name.
|
||||
public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName)
|
||||
}
|
||||
/// A namespace for the logging configuration keys, as absolute keys.
|
||||
public enum Log {
|
||||
/// 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.
|
||||
public static let staticFiles: AbsoluteConfigKey = .init(.Path.staticFiles)
|
||||
}
|
||||
/// A namespace for the security headers configuration keys, as absolute keys.
|
||||
public enum Security {
|
||||
/// The absolute configuration key for the `Content-Security-Policy` header value.
|
||||
public static let contentSecurityPolicy: AbsoluteConfigKey = .init(.Security.contentSecurityPolicy)
|
||||
/// The absolute configuration key for the `X-Content-Type-Options` header value.
|
||||
public static let contentTypeOptions: AbsoluteConfigKey = .init(.Security.contentTypeOptions)
|
||||
/// The absolute configuration key for the `X-Frame-Options` header value.
|
||||
public static let frameOptions: AbsoluteConfigKey = .init(.Security.frameOptions)
|
||||
/// The absolute configuration key for the `Referrer-Policy` header value.
|
||||
public static let referrerPolicy: AbsoluteConfigKey = .init(.Security.referrerPolicy)
|
||||
/// The absolute configuration key for the `Permissions-Policy` header value.
|
||||
public static let permissionsPolicy: AbsoluteConfigKey = .init(.Security.permissionsPolicy)
|
||||
/// The absolute configuration key for the `Strict-Transport-Security` header value.
|
||||
public static let strictTransportSecurity: AbsoluteConfigKey = .init(.Security.strictTransportSecurity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Configuration
|
||||
|
||||
extension ConfigKey {
|
||||
/// A namespace for the analytics configuration keys.
|
||||
public enum Analytics {
|
||||
/// The configuration key for the analytics website identifier (cleared to disable analytics).
|
||||
public static let websiteID: ConfigKey = "analytics.websiteID"
|
||||
/// The configuration key for the comma-delimited domains the tracker reports from.
|
||||
public static let domains: ConfigKey = "analytics.domains"
|
||||
/// The configuration key for recorder mode, loading the session recorder script alongside the tracker (set to `false` to disable).
|
||||
public static let recorder: ConfigKey = "analytics.recorder"
|
||||
}
|
||||
/// A namespace for the static files cache configuration keys.
|
||||
public enum Cache {
|
||||
/// 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"
|
||||
/// The configuration key for the max-age, in seconds, applied to all other static files (e.g. the web manifest).
|
||||
public static let maxAgeDefault: ConfigKey = "cache.maxAge.default"
|
||||
}
|
||||
/// A namespace for the response compression configuration keys.
|
||||
public enum Compression {
|
||||
/// The configuration key for the minimum response body size, in bytes, before compression is applied.
|
||||
public static let minResponseSize: ConfigKey = "compression.minimumResponseSize"
|
||||
}
|
||||
/// A namespace for the persistence configuration keys.
|
||||
public enum Database {
|
||||
/// The configuration key selecting migrate-and-exit mode (run migrations, then exit) instead of serving.
|
||||
public static let migrate: ConfigKey = "database.migrate"
|
||||
/// The configuration key for the persistence driver (`inMemory` or `postgres`).
|
||||
public static let driver: ConfigKey = "database.driver"
|
||||
/// The configuration key for the PostgreSQL host.
|
||||
public static let host: ConfigKey = "database.host"
|
||||
/// The configuration key for the PostgreSQL port.
|
||||
public static let port: ConfigKey = "database.port"
|
||||
/// The configuration key for the database name.
|
||||
public static let name: ConfigKey = "database.name"
|
||||
/// The configuration key for the database username.
|
||||
public static let username: ConfigKey = "database.username"
|
||||
/// The configuration key for the database password.
|
||||
public static let password: ConfigKey = "database.password"
|
||||
/// The configuration key for the TLS posture used when connecting (`off`, `prefer`, or `require`).
|
||||
public static let tls: ConfigKey = "database.tls"
|
||||
/// The configuration key for the maximum pooled connections per event loop.
|
||||
public static let poolMaxPerEventLoop: ConfigKey = "database.pool.maxPerEventLoop"
|
||||
/// The configuration key for the longest wait, in seconds, for a pooled connection to become available.
|
||||
public static let poolTimeout: ConfigKey = "database.pool.timeout"
|
||||
}
|
||||
/// A namespace for the HTTP server configuration keys.
|
||||
public enum HTTP {
|
||||
/// The configuration key for the host the server binds to.
|
||||
public static let host: ConfigKey = "http.host"
|
||||
/// The configuration key for the port the server listens on.
|
||||
public static let port: ConfigKey = "http.port"
|
||||
/// The configuration key for the server's name.
|
||||
public static let serverName: ConfigKey = "http.serverName"
|
||||
}
|
||||
/// A namespace for the logging configuration keys.
|
||||
public enum Log {
|
||||
/// 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.
|
||||
public static let staticFiles: ConfigKey = "path.staticFiles"
|
||||
}
|
||||
/// A namespace for the security headers configuration keys.
|
||||
public enum Security {
|
||||
/// The configuration key for the `Content-Security-Policy` header value.
|
||||
public static let contentSecurityPolicy: ConfigKey = "security.contentSecurityPolicy"
|
||||
/// The configuration key for the `X-Content-Type-Options` header value.
|
||||
public static let contentTypeOptions: ConfigKey = "security.contentTypeOptions"
|
||||
/// The configuration key for the `X-Frame-Options` header value.
|
||||
public static let frameOptions: ConfigKey = "security.frameOptions"
|
||||
/// The configuration key for the `Referrer-Policy` header value.
|
||||
public static let referrerPolicy: ConfigKey = "security.referrerPolicy"
|
||||
/// The configuration key for the `Permissions-Policy` header value.
|
||||
public static let permissionsPolicy: ConfigKey = "security.permissionsPolicy"
|
||||
/// The configuration key for the `Strict-Transport-Security` header value (omitted when unset).
|
||||
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Configuration
|
||||
|
||||
extension ConfigValue {
|
||||
/// A namespace for the HTTP server's default configuration values.
|
||||
public enum HTTP {
|
||||
/// The default server name.
|
||||
public static let serverName: ConfigValue = .init(stringLiteral: .Server.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
extension Int {
|
||||
/// A namespace for the cache's default configuration values.
|
||||
public enum Cache {
|
||||
/// 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
|
||||
/// The default max-age, in seconds, applied to all other static files (1 day).
|
||||
public static let maxAgeDefault = 86_400
|
||||
}
|
||||
/// A namespace for the response compression's default configuration values.
|
||||
public enum Compression {
|
||||
/// The default minimum response body size, in bytes, before compression is applied (1 KB).
|
||||
public static let minResponseSize = 1_024
|
||||
}
|
||||
/// A namespace for the persistence's default configuration values.
|
||||
public enum Database {
|
||||
/// The default PostgreSQL port.
|
||||
public static let port = 5_432
|
||||
/// The default maximum pooled connections per event loop.
|
||||
public static let poolMaxPerEventLoop = 4
|
||||
/// The default longest wait, in seconds, for a pooled connection to become available (the driver's own default).
|
||||
public static let poolTimeout = 10
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
+13
@@ -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,25 @@
|
||||
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.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the error page embeds, or `nil` (the default) to omit it.
|
||||
init(
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.init(bundle: .module) {
|
||||
NotFoundPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
extension String {
|
||||
/// A namespace for the analytics default configuration values.
|
||||
///
|
||||
/// Analytics ships **off**: ``websiteID`` is empty, so the pages embed no tracker until a deployment sets `analytics.websiteID`. Point
|
||||
/// ``origin`` at your own instance before enabling it — the placeholder is an [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606)
|
||||
/// reserved domain, so an unconfigured copy can never report to someone else's server.
|
||||
public enum Analytics {
|
||||
/// The origin the analytics scripts are loaded from and their beacons are sent to (scheme and host, no trailing slash).
|
||||
///
|
||||
/// Single-sourced here: both ``scriptURL`` and the session recorder script the pages embed in recorder mode derive from this
|
||||
/// constant. It is deliberately not a configuration key — the `Content-Security-Policy` must allow the same origin, and a value that
|
||||
/// can drift at runtime would silently break the tracker it is supposed to permit.
|
||||
public static let origin = "https://analytics.example.com"
|
||||
/// The URL the analytics tracker script is loaded from.
|
||||
public static let scriptURL = "\(origin)/script"
|
||||
/// The default analytics website identifier the tracker reports as: empty, which omits the tracker entirely.
|
||||
public static let websiteID = ""
|
||||
/// The default comma-delimited domains the tracker reports from: empty, which reports from every host.
|
||||
///
|
||||
/// Once set, keep it paired with the host the pages are served at — a deployment that serves from another host without matching
|
||||
/// `analytics.domains` reports from a host it no longer serves, so analytics silently records nothing.
|
||||
public static let domains = ""
|
||||
}
|
||||
/// A namespace for the persistence's default configuration values and recognized tokens.
|
||||
public enum Database {
|
||||
/// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
|
||||
public static let driver = "inMemory"
|
||||
/// The driver token selecting the PostgreSQL backend.
|
||||
public static let driverPostgres = "postgres"
|
||||
/// The default PostgreSQL host.
|
||||
public static let host = "localhost"
|
||||
/// The default database name.
|
||||
public static let name = "ccn"
|
||||
/// The default database username.
|
||||
public static let username = "ccn"
|
||||
/// The default TLS posture token.
|
||||
public static let tls = "prefer"
|
||||
/// The TLS token disabling TLS.
|
||||
public static let tlsOff = "off"
|
||||
/// The TLS token requiring TLS.
|
||||
public static let tlsRequire = "require"
|
||||
}
|
||||
/// A namespace for well-known path string constants.
|
||||
public enum Path {
|
||||
/// 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 server string constants.
|
||||
public enum Server {
|
||||
/// The website server's name.
|
||||
public static let name = "CCNWebsite"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import Configuration
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Infrastructure
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import Website
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("App executable")
|
||||
struct AppTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
// Stylesheets and scripts are referenced through fingerprinted URLs, so they are served immutable.
|
||||
private let immutableExtensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js
|
||||
]
|
||||
|
||||
// Absolute path to the copy of the package's "Resources/Static" folder made into the test bundle
|
||||
// at build time — the repository tree itself is off limits to Xcode's test runner.
|
||||
private let staticFilesPath: String = {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: "Static",
|
||||
withExtension: nil
|
||||
) else {
|
||||
preconditionFailure("The static files are missing from the test bundle.")
|
||||
}
|
||||
|
||||
return url.path
|
||||
}()
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `landing page to be served at root`() 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(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(body.contains("Hello world!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ok"}"#)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `readiness check to be served at the readiness path`() async throws {
|
||||
// Live mode runs the application's service group, so the `Fluent` service starts before the
|
||||
// request and shuts its connection pool down after — the router-only mode never would.
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.live) { client in
|
||||
try await client.execute(
|
||||
uri: "/health/ready",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ready"}"#)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: StaticFile.allCases)
|
||||
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: "/css/shared.css?v=0123456789abcdef",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/css")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `response to be compressed when the client supports it`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
headers: [.acceptEncoding: "gzip"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentEncoding] == "gzip")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `response to not be compressed when the client does not support it`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentEncoding] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `error page to be served when not found`() 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(response.status == .notFound)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(body.contains("Page Not Found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
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)
|
||||
#expect(response.headers[.strictTransportSecurity] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `security headers to be applied to the error page`() 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
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strict-transport-security to be applied when configured`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath,
|
||||
strictTransportSecurity: "max-age=31536000; includeSubDomains"
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.strictTransportSecurity] == "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AppTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func app(
|
||||
staticFilesPath: String,
|
||||
strictTransportSecurity: String? = nil
|
||||
) async throws -> some ApplicationProtocol {
|
||||
try await application(
|
||||
reader: reader(
|
||||
staticFilesPath: staticFilesPath,
|
||||
strictTransportSecurity: strictTransportSecurity
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func reader(
|
||||
staticFilesPath: String,
|
||||
strictTransportSecurity: String? = nil
|
||||
) -> ConfigReader {
|
||||
ConfigReader(providers: [{
|
||||
if let strictTransportSecurity {
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.host: "127.0.0.1",
|
||||
.HTTP.port: "0",
|
||||
.Log.level: "trace",
|
||||
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
||||
.Security.strictTransportSecurity: .init(stringLiteral: strictTransportSecurity)
|
||||
])
|
||||
} else {
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.host: "127.0.0.1",
|
||||
.HTTP.port: "0",
|
||||
.Log.level: "trace",
|
||||
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
||||
])
|
||||
}
|
||||
}()])
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Configuration
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import Website
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite("ConfigReader properties")
|
||||
struct ConfigReaderPropertiesTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `analytics to be omitted by default`() {
|
||||
// The template ships no website identifier, so an unconfigured deployment embeds no tracker at all.
|
||||
#expect(.Analytics.websiteID == "")
|
||||
#expect(reader().analytics == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `analytics to be enabled by setting the website id alone`() throws {
|
||||
let analytics = try #require(reader(values: [
|
||||
.Analytics.websiteID: "0000-website-id"
|
||||
]).analytics)
|
||||
|
||||
#expect(analytics.scriptURL == .Analytics.scriptURL)
|
||||
#expect(analytics.websiteID == "0000-website-id")
|
||||
#expect(analytics.domains.isEmpty)
|
||||
#expect(analytics.excludeHash)
|
||||
#expect(analytics.doNotTrack)
|
||||
#expect(analytics.performance)
|
||||
#expect(!analytics.recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `analytics to switch recorder mode on when configured`() throws {
|
||||
// Session recording is the most invasive thing the tracker does, so it is opted into rather than out of.
|
||||
let analytics = try #require(reader(values: [
|
||||
.Analytics.websiteID: "0000-website-id",
|
||||
.Analytics.recorder: true
|
||||
]).analytics)
|
||||
|
||||
#expect(analytics.recorder)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `analytics to override the website id and domains when configured`() throws {
|
||||
let analytics = try #require(reader(values: [
|
||||
.Analytics.websiteID: "custom-website-id",
|
||||
.Analytics.domains: "staging.example.com"
|
||||
]).analytics)
|
||||
|
||||
#expect(analytics.websiteID == "custom-website-id")
|
||||
#expect(analytics.domains == "staging.example.com")
|
||||
#expect(analytics.scriptURL == .Analytics.scriptURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `analytics to be omitted when the website id is cleared`() {
|
||||
#expect(reader(values: [.Analytics.websiteID: ""]).analytics == nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ConfigReaderPropertiesTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds a configuration reader over the given in-memory values alone.
|
||||
func reader(
|
||||
values: [AbsoluteConfigKey: ConfigValue] = [:]
|
||||
) -> ConfigReader {
|
||||
ConfigReader(providers: [
|
||||
InMemoryProvider(values: values)
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../Resources/Static
|
||||
@@ -0,0 +1,82 @@
|
||||
import Infrastructure
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"StaticFile enumeration",
|
||||
.tags(.enumeration)
|
||||
)
|
||||
struct StaticFileTests {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias File = StaticFile
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileExtensions
|
||||
))
|
||||
func `file extensions`(
|
||||
for file: File,
|
||||
expects extensions: [AssetExtension]
|
||||
) {
|
||||
#expect(file.fileExtensions == extensions)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileNames
|
||||
))
|
||||
func `file name`(
|
||||
for file: File,
|
||||
expects fileName: String
|
||||
) {
|
||||
#expect(file.fileName == fileName)
|
||||
}
|
||||
|
||||
// MARK: CaseIterable tests
|
||||
|
||||
@Test
|
||||
func `all cases`() {
|
||||
#expect(File.allCases.count == 11)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension StaticFileTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let fileExtensions: [[AssetExtension]] = [
|
||||
[.png],
|
||||
[.ico],
|
||||
[.svg],
|
||||
[.png],
|
||||
[.png],
|
||||
[.css, .js],
|
||||
[.css, .js],
|
||||
[.txt],
|
||||
[.css, .js],
|
||||
[.webmanifest],
|
||||
[.xml]
|
||||
]
|
||||
static let fileNames: [String] = [
|
||||
"apple-touch-icon",
|
||||
"favicon",
|
||||
"icon",
|
||||
"icon-192",
|
||||
"icon-512",
|
||||
"index",
|
||||
"not-found",
|
||||
"robots",
|
||||
"shared",
|
||||
"site",
|
||||
"sitemap"
|
||||
]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"IndexPage page",
|
||||
.tags(.page)
|
||||
)
|
||||
struct IndexPageTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders its markup`() {
|
||||
let html = IndexPage(
|
||||
locale: .init(identifier: "en")
|
||||
).render()
|
||||
|
||||
#expect(html.contains("<!DOCTYPE html>"))
|
||||
#expect(html.contains(#"lang="en""#))
|
||||
#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("/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/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"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"NotFoundPage page",
|
||||
.tags(.page)
|
||||
)
|
||||
struct NotFoundPageTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `renders its markup`() {
|
||||
let html = NotFoundPage(
|
||||
locale: .init(identifier: "en")
|
||||
).render()
|
||||
|
||||
#expect(html.contains("<!DOCTYPE html>"))
|
||||
#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/shared.css"))
|
||||
#expect(html.contains("/css/not-found.css"))
|
||||
#expect(html.contains("/js/not-found.js"))
|
||||
#expect(html.contains("/js/shared.js"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"HealthController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct HealthControllerTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `serves the status payload at the health path`() async throws {
|
||||
try await app(
|
||||
probe: nil
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ok"}"#)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves ready at the readiness path when the database is reachable`() async throws {
|
||||
let service = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)
|
||||
let fluent = service()
|
||||
|
||||
do {
|
||||
try await app(
|
||||
probe: Probe(fluent: fluent)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health/ready",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ready"}"#)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves unavailable at the readiness path when the database is unreachable`() async throws {
|
||||
// Port 1 on the loopback interface has nothing listening, so the probe's connection is refused
|
||||
// immediately instead of timing out.
|
||||
let service = try Service(
|
||||
driver: .postgres(
|
||||
.init(
|
||||
host: "127.0.0.1",
|
||||
port: 1,
|
||||
name: "unreachable",
|
||||
username: "nobody",
|
||||
password: "nothing",
|
||||
tls: .off,
|
||||
maxConnectionsPerEventLoop: 1,
|
||||
poolTimeout: .seconds(10)
|
||||
)
|
||||
),
|
||||
logger: Logger(label: "test")
|
||||
)
|
||||
let fluent = service()
|
||||
|
||||
do {
|
||||
try await app(
|
||||
probe: Probe(fluent: fluent)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health/ready",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .serviceUnavailable)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"unavailable"}"#)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `does not serve the readiness path without a probe`() async throws {
|
||||
try await app(probe: nil).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/health/ready",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HealthControllerTests {
|
||||
|
||||
/// Builds a test application serving the ``HealthController`` routes for the given probe.
|
||||
/// - Parameter probe: the probe supplied to the controller, or `nil` for liveness only.
|
||||
/// - Returns: the configured test application.
|
||||
func app(
|
||||
probe: Probe?
|
||||
) -> some ApplicationProtocol {
|
||||
Application(router: {
|
||||
let router = Router()
|
||||
|
||||
router.addRoutes(HealthController<BasicRequestContext>(probe: probe).routes)
|
||||
|
||||
return router
|
||||
}())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Infrastructure
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"RootController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct RootControllerTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let app: Application = .init(router: {
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware()
|
||||
}
|
||||
|
||||
router.addRoutes(RootController<WebsiteRequestContext>().routes)
|
||||
|
||||
return router
|
||||
}())
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `serves the landing page at the root path`() 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(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "text/html; charset=utf-8")
|
||||
#expect(response.headers[.contentLanguage] == "en")
|
||||
#expect(response.headers[.vary] == "Accept-Language")
|
||||
#expect(body.contains("Hello world!"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `embeds no analytics tracker 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("data-website-id"))
|
||||
#expect(!body.contains("analytics"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `embeds the analytics tracker when one is configured`() async throws {
|
||||
try await app(
|
||||
analytics: .init(
|
||||
scriptURL: "https://analytics.example.com/script",
|
||||
websiteID: "0000-website-id",
|
||||
domains: "example.com"
|
||||
)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(body.contains(#"<link rel="preconnect" href="https://analytics.example.com">"#))
|
||||
#expect(body.contains(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com""#))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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 and embeds the given analytics tracker.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs.
|
||||
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||
/// - Returns: the configured application.
|
||||
func app(
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LocalizationMiddleware()
|
||||
}
|
||||
|
||||
router.addRoutes(RootController<WebsiteRequestContext>(
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
).routes)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "F3231E68-357B-4ED7-98E9-F5767AB17024",
|
||||
"name" : "Test Scheme Action",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"performanceAntipatternCheckerEnabled" : true,
|
||||
"targetForVariableExpansion" : {
|
||||
"containerPath" : "container:",
|
||||
"identifier" : "Website",
|
||||
"name" : "Website"
|
||||
},
|
||||
"testInteropMode" : "complete"
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:",
|
||||
"identifier" : "WebsiteTests",
|
||||
"name" : "WebsiteTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:",
|
||||
"identifier" : "WebsiteLibraryTests",
|
||||
"name" : "WebsiteLibraryTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Persistence",
|
||||
"identifier" : "PersistenceTests",
|
||||
"name" : "PersistenceTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Localization",
|
||||
"identifier" : "LocalizationTests",
|
||||
"name" : "LocalizationTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Infrastructure",
|
||||
"identifier" : "InfrastructureTests",
|
||||
"name" : "InfrastructureTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Utility",
|
||||
"identifier" : "UtilityTests",
|
||||
"name" : "UtilityTests"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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:
|
||||
#
|
||||
# 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.
|
||||
services:
|
||||
website:
|
||||
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
||||
# The local build targets the development machine, not the deployment: this cancels the base file's
|
||||
# ${IMAGE_PLATFORM}. Set BUILD_PLATFORM=linux/amd64 in .env when developing on an Intel Mac or an amd64 Linux box.
|
||||
platform: ${BUILD_PLATFORM:-linux/arm64}
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Services/Website/Dockerfile
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_TLS: ${DATABASE_TLS:-off}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
|
||||
# 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 postgres
|
||||
postgres:
|
||||
image: postgres:18
|
||||
container_name: ${HOST_OWNER:-ccn}-db
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- database
|
||||
ports:
|
||||
- "127.0.0.1:${DATABASE_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ${DATABASE_NAME:-ccn}
|
||||
POSTGRES_USER: ${DATABASE_USERNAME:-ccn}
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-ccn}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready --username=$${POSTGRES_USER} --dbname=$${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- ./Tests/DB:/var/lib/postgresql
|
||||
@@ -0,0 +1,38 @@
|
||||
name: ccn-platform
|
||||
|
||||
# Production base configuration.
|
||||
# Deploys a pre-built image pulled from a registry — no build step.
|
||||
#
|
||||
# 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.
|
||||
services:
|
||||
website:
|
||||
image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
||||
platform: ${IMAGE_PLATFORM:-linux/amd64}
|
||||
container_name: ${HOST_OWNER}-${IMAGE_NAME}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-8080}:8080"
|
||||
environment:
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-CCNWebsite}
|
||||
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
|
||||
# Persistence: a managed PostgreSQL database. Provide the password via the environment or a secret — never
|
||||
# commit it.
|
||||
DATABASE_DRIVER: ${DATABASE_DRIVER:-postgres}
|
||||
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
|
||||
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
||||
DATABASE_NAME: ${DATABASE_NAME:-ccn}
|
||||
DATABASE_USERNAME: ${DATABASE_USERNAME:-ccn}
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
|
||||
DATABASE_TLS: ${DATABASE_TLS:-require}
|
||||
DATABASE_POOL_MAX_PER_EVENT_LOOP: ${DATABASE_POOL_MAX_PER_EVENT_LOOP:-4}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
Reference in New Issue
Block a user