#!/usr/bin/env bash # Post-release verification for desfoto.de. # # .ocauto/verify # # 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}" # DESFOTO_BASE lets the hook be dry-run against a local container # (DESFOTO_BASE=http://127.0.0.1:18430 .ocauto/verify unknown); the # domain-specific sections (deployed tree, canonical host, neighbours, TLS) # only run against the real production origin. base="${DESFOTO_BASE:-https://desfoto.de}" live=0 if [ "$base" = "https://desfoto.de" ]; then live=1; fi 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 } check_redirect() { local url="$1" want="$2" got target got="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$url" || echo 000)" target="$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 "$url" || true)" if [ "$got" = "301" ] && [ "$target" = "$want" ]; then pass "$url -> 301 $want" else fail "$url -> $got ${target:-} (want 301 $want)" fi } check_not_contains() { local url="$1" needle="$2" body body="$(curl -sS --max-time 30 "$url" || true)" if printf '%s' "$body" | grep -qF -- "$needle"; then fail "$url still contains '$needle'" else pass "$url no longer contains '$needle'" fi } printf '\n== release identity\n' if [ "$live" = 1 ] && [ "$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/ /familie/ \ /minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/; do check_status "$base$route" 200 done # The retired photo URL must keep working: it is the umbrella page now. check_redirect "$base/shootings" "$base/fotografie/" check_redirect "$base/shootings/" "$base/fotografie/" check_redirect "$base/shootings.html" "$base/fotografie/" check_redirect "$base/fotografie.html" "$base/fotografie/" # Familien & Paare was renamed: the remaining family work stays with desfoto. check_redirect "$base/familien-und-paare" "$base/familie/" check_redirect "$base/familien-und-paare/" "$base/familie/" check_redirect "$base/familien-und-paare.html" "$base/familie/" printf '\n== canonical host and redirects\n' if [ "$live" = 1 ]; then 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 fi printf '\n== content\n' check_contains "$base/" 'href="/fotografie/"' check_contains "$base/" 'href="/musik-und-buehne/"' check_contains "$base/" 'href="/video/"' check_contains "$base/" "Mobiles Studio" check_contains "$base/" "Studio Neumünster" check_contains "$base/" "desfoto ist ein Angebot von Denny Schulz, Neumünster." check_not_contains "$base/" 'href="/shootings' check_not_contains "$base/" "Paarshooting" check_contains "$base/fotografie/" "Autarke Blitzanlage" check_contains "$base/fotografie/" "Minishootings" check_contains "$base/familie/" "Familienshooting" check_contains "$base/musik-und-buehne/" "Thjódrörir" check_contains "$base/musik-und-buehne/" "Sagenbringer" 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/" "https://www.dennyschulz.de/" check_contains "$base/" "https://dennyapp.de/" check_contains "$base/sitemap.xml" "https://desfoto.de/fotografie/" check_not_contains "$base/sitemap.xml" "https://desfoto.de/shootings/" 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/logo-160.png" 200 check_status "$base/assets/img/favicon-32.png" 200 check_status "$base/assets/img/og-fotografie.jpg" 200 check_status "$base/assets/site.css" 200 check_status "$base/assets/fonts/fraunces-latin.woff2" 200 # The portfolio expansion ships the operator's own public work... check_status "$base/assets/img/music-dreadlocks-1200.webp" 200 check_status "$base/assets/img/business-hotel-window-1200.webp" 200 check_status "$base/assets/img/free-portrait-light-1200.webp" 200 check_status "$base/assets/img/editorial-leap-1200.webp" 200 # ...and the wedding pictures showing the guests stay removed. check_status "$base/assets/img/group-feier-1200.webp" 404 check_status "$base/assets/img/group-outdoor-1200.webp" 404 check_status "$base/assets/img/group-posiert-1200.webp" 404 # Couple imagery and the studio shot with the old business card ship nowhere. check_status "$base/assets/img/couple-park-1200.webp" 404 check_status "$base/assets/img/portrait-couple-1200.webp" 404 check_status "$base/assets/img/studio-setup-1200.webp" 404 # Brautpaar-Bilder sind komplett aus dem Bestand: Fotografie- und Eventbilder # mit Paaren gehören zu dennyschulz.de, nicht zu desfoto. check_status "$base/assets/img/portrait-natural-1200.webp" 404 check_status "$base/assets/img/event-motion-1200.webp" 404 check_status "$base/assets/img/event-dance-1200.webp" 404 check_contains "$base/minishootings/" "free-modern-portrait-1200.webp" check_contains "$base/social-media/" "event-speaker-1200.webp" check_contains "$base/projekte/" "music-dreadlocks-1200.webp" check_contains "$base/familie/" "free-portrait-light-1200.webp" 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' if [ "$live" = 1 ]; then check_status "https://www.dennyschulz.de/" 200 check_contains "https://www.dennyschulz.de/impressum" "Denny Schulz" check_status "https://dennyapp.de/" 200 fi printf '\n== TLS\n' if [ "$live" = 1 ] && 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"