feat: launch desfoto.de as a standalone photography site and retire the old redirect

This commit is contained in:
opencode
2026-09-19 16:02:16 +02:00
commit 160ec017eb
218 changed files with 7518 additions and 0 deletions

189
.ocauto/deploy Executable file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Release deployment for desfoto.de.
#
# .ocauto/deploy <release-sha>
#
# Ships the committed static build to the production host, starts the stack
# behind the existing Traefik instance, and removes the obsolete
# "desfoto.de -> dennyschulz.de" redirect from the shared /srv/stack project.
#
# Rollback: the state before every release is snapshotted to
# /home/denny/stacks/desfoto-releases/<stamp>-<sha>/
# including the shared stack file that the release modified. Restore it with
# ROLLBACK_TO=<dir> .ocauto/deploy rollback
# The snapshot is written even for the very first release, when no site is live
# yet; rolling back to that snapshot then removes the new stack again and puts
# the previous /srv/stack configuration back.
set -euo pipefail
cd "$(dirname "$0")/.."
root="$(pwd)"
release_sha="${1:-}"
remote="${DESFOTO_REMOTE:-prod-main}"
live_dir="/home/denny/stacks/desfoto"
releases_dir="/home/denny/stacks/desfoto-releases"
stack_dir="/srv/stack"
log() { printf '[deploy] %s\n' "$1"; }
die() { printf '[deploy] ERROR: %s\n' "$1" >&2; exit 1; }
if [ "$release_sha" = "rollback" ]; then
target="${ROLLBACK_TO:-}"
[ -n "$target" ] || die "ROLLBACK_TO must name a snapshot directory"
log "rolling back to $target"
# shellcheck disable=SC2029
ssh "$remote" "set -euo pipefail
sudo test -d '$target'
if sudo test -d '$target/site'; then
sudo rsync -a --delete --chown=denny:denny '$target/site/' '$live_dir/site/'
sudo cp '$target/nginx.conf' '$live_dir/nginx.conf'
sudo cp '$target/compose.yml' '$live_dir/compose.yml'
sudo cp '$target/compose.vps.yml' '$live_dir/compose.vps.yml'
cd '$live_dir'
sudo docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans
else
echo '[deploy] initial-state snapshot: removing the desfoto stack again'
if sudo test -d '$live_dir'; then
( cd '$live_dir' && sudo docker compose -f compose.yml -f compose.vps.yml down --remove-orphans )
fi
fi
if sudo test -f '$target/docker-compose.yml.stack-backup'; then
echo '[deploy] restoring the previous shared stack configuration'
sudo cp '$target/docker-compose.yml.stack-backup' '$stack_dir/docker-compose.yml'
cd '$stack_dir'
sudo docker compose up -d --no-deps landing
fi"
log "rollback started"
exit 0
fi
[ -n "$release_sha" ] || die "usage: .ocauto/deploy <release-sha|rollback>"
[ "$(git rev-parse HEAD)" = "$release_sha" ] || die "HEAD does not match $release_sha"
[ -z "$(git status --porcelain -- site nginx.conf compose.yml compose.vps.yml)" ] \
|| die "the deployable files have uncommitted changes"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
log "packing $release_sha"
tar -czf "$tmp/desfoto-$release_sha.tgz" -C "$root" site nginx.conf compose.yml compose.vps.yml
log "uploading"
scp -q "$tmp/desfoto-$release_sha.tgz" "$remote:/tmp/desfoto-$release_sha.tgz"
log "deploying on $remote"
# shellcheck disable=SC2029
ssh "$remote" "REMOTE_SHA='$release_sha' LIVE_DIR='$live_dir' RELEASES_DIR='$releases_dir' STACK_DIR='$stack_dir' bash -s" <<'REMOTE'
set -euo pipefail
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
snapshot="$RELEASES_DIR/$stamp-$REMOTE_SHA"
archive="/tmp/desfoto-$REMOTE_SHA.tgz"
# 1. Snapshot the current state so a rollback stays possible. This also runs for
# the very first release (no site live yet) so ROLLBACK_TO always has a target.
sudo mkdir -p "$snapshot" "$RELEASES_DIR"
if sudo test -d "$LIVE_DIR"; then
echo "[deploy] snapshot -> $snapshot"
sudo cp -a "$LIVE_DIR/." "$snapshot/"
else
echo "[deploy] no previous release; $snapshot records the initial state"
fi
sudo ln -sfn "$snapshot" "$RELEASES_DIR/previous"
# 2. Unpack the new release.
echo "[deploy] unpacking $archive"
sudo mkdir -p "$LIVE_DIR"
sudo rm -rf "$LIVE_DIR/site"
sudo tar -xzf "$archive" -C "$LIVE_DIR"
sudo chown -R denny:denny "$LIVE_DIR"
sudo rm -f "$archive"
printf '%s\n' "$REMOTE_SHA" | sudo tee "$LIVE_DIR/RELEASE" >/dev/null
# 3. Start or update the stack.
cd "$LIVE_DIR"
sudo docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans
# 4. Wait for the container health check.
for _ in $(seq 1 30); do
status="$(sudo docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' desfoto-web-1 2>/dev/null || echo missing)"
[ "$status" = "healthy" ] && break
[ "$status" = "missing" ] && break
sleep 2
done
echo "[deploy] desfoto-web-1: $status"
if [ "$status" != "healthy" ]; then
echo "[deploy] ERROR: container is not healthy" >&2
sudo docker logs --tail 40 desfoto-web-1 >&2 || true
exit 1
fi
# 5. Remove the obsolete desfoto.de redirect from the shared stack project.
# The new stack already owns desfoto.de through a higher Traefik priority,
# this only deletes the now-dead labels so Traefik no longer advertises them.
# Stripped are Traefik label lines that mention desfoto (the old routers
# "desfoto-redirect"/"desfoto-redirect-http", the middleware
# "desfoto-to-website" and any related key) plus the comment that grouped
# them; every other line is copied verbatim. The result is validated as a
# Compose project *before* it replaces the live file, and the replacement
# itself is a single atomic rename.
stack_file="$STACK_DIR/docker-compose.yml"
if sudo grep -q 'desfoto' "$stack_file"; then
echo "[deploy] removing obsolete desfoto redirect labels from $stack_file"
sudo cp "$stack_file" "$STACK_DIR/docker-compose.yml.bak.$stamp"
sudo cp "$stack_file" "$snapshot/docker-compose.yml.stack-backup"
# The temp file lives next to the original (same filesystem, hidden name that
# Compose ignores) so the final mv is atomic.
stack_tmp="$(sudo mktemp "$STACK_DIR/.docker-compose.yml.desfoto.XXXXXX")"
set +e
# Strip only lines that are Traefik labels mentioning desfoto (list or map
# syntax, key or value side) plus the comment that grouped the old redirect.
# The generic "desfoto" substring filter of the previous version could have
# deleted unrelated lines; this one is anchored to label lines.
sudo awk '
/^[[:space:]]*-?[[:space:]]*"?traefik\..*desfoto/ { next }
/# desfoto\.de -> kanonische Fotografen-Website/ { next }
{ print }
' "$stack_file" | sudo tee "$stack_tmp" >/dev/null
strip_status="${PIPESTATUS[0]}"
set -e
if [ "$strip_status" -ne 0 ]; then
sudo rm -f "$stack_tmp"
echo "[deploy] ERROR: cannot read $stack_file (awk exit $strip_status)" >&2
exit 1
fi
sudo chown --reference="$stack_file" "$stack_tmp"
sudo chmod --reference="$stack_file" "$stack_tmp"
# Validate the stripped file as the same Compose project before touching the
# live configuration. The project directory stays $STACK_DIR because the first
# -f file lives there.
validate=(docker compose -f "$stack_tmp")
if sudo test -f "$STACK_DIR/docker-compose.override.yml"; then
validate+=(-f "$STACK_DIR/docker-compose.override.yml")
fi
validate+=(config --quiet)
if ! ( cd "$STACK_DIR" && sudo "${validate[@]}" ); then
sudo rm -f "$stack_tmp"
echo "[deploy] ERROR: stripped stack file is not valid; live file left untouched" >&2
exit 1
fi
sudo mv "$stack_tmp" "$stack_file"
remaining="$(sudo grep -n 'desfoto' "$stack_file" || true)"
if [ -n "$remaining" ]; then
echo "[deploy] WARNING: desfoto references remain in $stack_file:" >&2
printf '%s\n' "$remaining" >&2
fi
( cd "$STACK_DIR" && sudo docker compose up -d --no-deps landing )
echo "[deploy] landing recreated without the desfoto redirect"
else
echo "[deploy] obsolete redirect labels already absent"
fi
# 6. Record the release marker.
echo "[deploy] done: $REMOTE_SHA"
REMOTE
log "release $release_sha deployed"

65
.ocauto/qa Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Authoritative deterministic QA for the desfoto.de static site.
#
# ./qa -> build the site, prove the build is reproducible, validate the
# nginx configuration, and run the structural test suite.
#
# Exit code 0 means the repository is releasable as-is.
set -euo pipefail
cd "$(dirname "$0")/.."
root="$(pwd)"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
step() { printf '\n== %s\n' "$1"; }
step "build"
python3 scripts/build-site.py
step "build is reproducible"
# sitemap.xml and .well-known/security.txt embed the build date on purpose and
# are therefore excluded from the byte-for-byte comparison.
hash_tree() {
(cd "$root/site" && find . -type f \
! -name sitemap.xml ! -path './.well-known/*' -print0 |
sort -z | xargs -0 sha256sum)
}
hash_tree >"$tmp/before.txt"
python3 scripts/build-site.py >/dev/null
hash_tree >"$tmp/after.txt"
if ! diff -u "$tmp/before.txt" "$tmp/after.txt"; then
echo "FAIL: the build output is not reproducible" >&2
exit 1
fi
echo "rebuild produced byte-identical output for $(wc -l <"$tmp/after.txt") files"
step "nginx configuration"
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
docker run --rm \
-v "$root/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
-v "$root/site:/usr/share/nginx/html:ro" \
nginx:1.28-alpine nginx -t
else
echo "SKIP: no usable docker daemon; nginx -t cannot be verified here" >&2
exit 1
fi
step "compose configuration"
if docker compose version >/dev/null 2>&1; then
DESFOTO_CHECK_ONLY=1 docker compose -f compose.yml -f compose.vps.yml config --quiet
echo "compose.yml + compose.vps.yml are valid"
fi
step "structural tests"
python3 -m unittest discover -s tests -p 'test_*.py' -v
step "shellcheck"
if command -v shellcheck >/dev/null 2>&1; then
mapfile -t scripts < <(git ls-files '*.sh' '.ocauto/*')
if [ "${#scripts[@]}" -gt 0 ]; then
shellcheck "${scripts[@]}"
fi
fi
printf '\nQA PASS\n'

137
.ocauto/verify Executable file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Post-release verification for desfoto.de.
#
# .ocauto/verify <release-sha>
#
# Proves that (a) the live desfoto.de serves the released build and assets,
# (b) the security posture matches the promises on /datenschutz/, and
# (c) the neighbouring sites (dennyschulz.de, dennyapp.de) are untouched.
set -euo pipefail
cd "$(dirname "$0")/.."
root="$(pwd)"
release_sha="${1:-unknown}"
remote="${DESFOTO_REMOTE:-prod-main}"
base="https://desfoto.de"
failures=0
pass() { printf ' ok %s\n' "$1"; }
fail() { printf ' FAIL %s\n' "$1" >&2; failures=$((failures + 1)); }
check_status() {
local url="$1" want="$2" got
got="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$url" || echo 000)"
if [ "$got" = "$want" ]; then pass "$url -> $got"; else fail "$url -> $got (want $want)"; fi
}
check_contains() {
local url="$1" needle="$2" body
body="$(curl -sS --max-time 30 "$url" || true)"
if printf '%s' "$body" | grep -qF -- "$needle"; then
pass "$url contains '$needle'"
else
fail "$url does not contain '$needle'"
fi
}
check_header() {
local url="$1" header="$2" headers
headers="$(curl -sSI --max-time 30 "$url" || true)"
if printf '%s' "$headers" | grep -qi "^$header:"; then
pass "$url sends $header"
else
fail "$url is missing the $header header"
fi
}
printf '\n== release identity\n'
if [ "$release_sha" != "unknown" ] && git -C "$root" rev-parse --verify --quiet "$release_sha" >/dev/null; then
local_hash="$(cd "$root/site" && find . -type f ! -name sitemap.xml ! -path './.well-known/*' -print0 |
sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1)"
remote_hash="$(ssh "$remote" "sudo sh -c 'cd /home/denny/stacks/desfoto/site && find . -type f ! -name sitemap.xml ! -path ./.well-known/\* -print0 | sort -z | xargs -0 sha256sum | sha256sum'" | cut -d' ' -f1)"
if [ "$local_hash" = "$remote_hash" ]; then
pass "deployed site tree matches the release ($local_hash)"
else
fail "deployed site tree differs from the release ($local_hash != $remote_hash)"
fi
remote_release="$(ssh "$remote" 'sudo cat /home/denny/stacks/desfoto/RELEASE' | tr -d '\r\n')"
if [ "$remote_release" = "$release_sha" ]; then
pass "release marker is $release_sha"
else
fail "release marker is '$remote_release' (want $release_sha)"
fi
fi
printf '\n== routes\n'
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familien-und-paare/ \
/minishootings/ /video/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/; do
check_status "$base$route" 200
done
printf '\n== canonical host and redirects\n'
www_status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 https://www.desfoto.de/ || echo 000)"
www_target="$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 https://www.desfoto.de/ || true)"
if [ "$www_status" = "301" ] && [ "$www_target" = "https://desfoto.de/" ]; then
pass "www.desfoto.de -> https://desfoto.de/ (301)"
else
fail "www.desfoto.de -> $www_status $www_target"
fi
http_target="$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 http://desfoto.de/ || true)"
case "$http_target" in
https://desfoto.de/*) pass "http://desfoto.de/ -> $http_target" ;;
*) fail "http://desfoto.de/ redirects to '$http_target'" ;;
esac
printf '\n== content\n'
check_contains "$base/" "Bilder und Filme, die nicht beliebig aussehen"
check_contains "$base/impressum/" "§ 5 DDG"
check_contains "$base/impressum/" "DE462149560"
check_contains "$base/datenschutz/" "keine Zugriffsprotokolle"
check_contains "$base/datenschutz/" "youtube-nocookie.com"
check_contains "$base/video/" "NWWFTf7l8g0"
check_contains "$base/sitemap.xml" "<loc>https://desfoto.de/video/</loc>"
printf '\n== error handling and metadata\n'
check_status "$base/diese-seite-gibt-es-nicht/" 404
check_contains "$base/diese-seite-gibt-es-nicht/" "Diese Seite gibt es nicht"
check_status "$base/.well-known/security.txt" 200
check_status "$base/robots.txt" 200
printf '\n== assets\n'
check_status "$base/assets/img/hero-studio-1200.webp" 200
check_status "$base/assets/img/favicon-32.png" 200
check_status "$base/assets/site.css" 200
check_status "$base/assets/fonts/fraunces-latin.woff2" 200
check_status "$base/assets/img/og-desfoto.jpg" 200
printf '\n== privacy promises and headers\n'
check_header "$base/" "Strict-Transport-Security"
check_header "$base/" "Content-Security-Policy"
check_header "$base/" "X-Content-Type-Options"
check_header "$base/" "Referrer-Policy"
if curl -sSI --max-time 30 "$base/" | grep -qi '^set-cookie:'; then
fail "a cookie is set on the homepage"
else
pass "no Set-Cookie on the homepage"
fi
printf '\n== neighbouring sites untouched\n'
check_status "https://www.dennyschulz.de/" 200
check_contains "https://www.dennyschulz.de/impressum" "Denny Schulz"
check_status "https://dennyapp.de/" 200
printf '\n== TLS\n'
if command -v openssl >/dev/null 2>&1; then
if echo | openssl s_client -servername desfoto.de -connect desfoto.de:443 2>/dev/null |
openssl x509 -noout -checkend 604800 -subject 2>/dev/null; then
pass "certificate for desfoto.de is valid for at least 7 more days"
else
fail "certificate for desfoto.de is missing or expires within 7 days"
fi
fi
if [ "$failures" -ne 0 ]; then
printf '\nVERIFY FAIL (%d problem(s)) release=%s\n' "$failures" "$release_sha" >&2
exit 1
fi
printf '\nVERIFY PASS release=%s\n' "$release_sha"