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

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Fetched, non-shipped build inputs: reproduce with scripts/fetch-assets.py.
assets-src/
# Python
__pycache__/
*.pyc
.venv/
# Local scratch
.ui-review/
*.tar.gz

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"

57
AGENTS.md Normal file
View File

@@ -0,0 +1,57 @@
# desfoto.de — Agent Guidelines
## What this repository is
The complete source of **https://desfoto.de**, the photography and video brand of
Denny Schulz (Neumünster). It is a hand-written static site: no framework, no CMS,
no runtime dependencies. `src/` holds Python builders and the editorial content,
`scripts/build-site.py` renders everything into the committed `site/` directory,
and nginx serves `site/` from a container behind the shared VPS Traefik instance.
`dennyschulz.de` is a **separate** site and must never be modified or linked from here.
## Commands
- `python3 scripts/fetch-assets.py` — download pool images, Google Fonts subsets and
licences into `assets-src/` (idempotent; `assets-src/` is not committed).
- `python3 scripts/build-site.py` — rebuild all of `site/`. Prints `build ok` and a
link/alt check; exits non-zero on any broken internal reference.
- `.ocauto/qa` — authoritative deterministic QA: builds twice to prove the output is
reproducible, runs `nginx -t` and `docker compose config`, then
`python3 -m unittest discover -s tests`.
- Local runtime: `docker compose -f compose.yml up -d` → http://127.0.0.1:18430
- UI smoke: `/home/king/bin/oc-ui-smoke http://127.0.0.1:18430/`
- Release: `/home/king/bin/oc-release /home/king/projects/desfoto "<msg>" <files...>`
## Editing rules
- **Content lives in `src/content.py` and `src/pages.py`; never hand-edit `site/`.**
`site/` is generated output that happens to be committed for deployment.
- Never invent facts. Prices, testimonials, client names, awards and biography dates
are intentionally absent; do not add them without verified information. Legal data
in `src/content.py:LEGAL` mirrors the operator's own Impressum (`§ 5 DDG`).
- Keep the design language: paper/ink/rust/teal tokens from `src/theme.py`, rounded
cards, hairline dividers, Fraunces for display and Manrope for text.
- Images come from the operator's own pool through `src/images.json`. Only add an
entry if the operator holds the rights; always give a meaningful German `alt`.
- Two-space indentation, `from __future__ import annotations`, type hints on public
functions, no new third-party Python packages (Pillow is the only dependency).
## Privacy contract
The `/datenschutz/` page makes concrete promises. Any change that would break one of
them is a defect, not a style question:
- no cookies, no `localStorage`, no analytics, no tracking;
- `access_log off` in `nginx.conf` — never enable request logging;
- `error_log /dev/null crit;` in `nginx.conf` — never write an error-log file either;
- fonts, scripts, styles and images are served from this origin only;
- YouTube is embedded exclusively via the click-to-load facade
(`youtube-nocookie.com`) — no iframe before the click, no preconnect;
- the contact form never posts anywhere; it composes a local `mailto:` draft
(the non-JS fallback is a `mailto:` form action, which also stays on the device).
`tests/test_site.py` enforces these promises; extend it when you add a new flow.
## Deployment
`.ocauto/deploy <sha>` ships the committed build to `/home/denny/stacks/desfoto` on
`prod-main`, snapshots the previous release into `/home/denny/stacks/desfoto-releases/`
and removes the obsolete `desfoto.de → dennyschulz.de` labels from `/srv/stack`.
`.ocauto/verify <sha>` proves the live site matches the release. Details, including
rollback, are in `docs/deployment.md`. Traefik is shared infrastructure: route through
labels, never restart or recreate the proxy.

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# desfoto.de
Quellcode und Deployment für **https://desfoto.de** — die Foto- und Videoproduktion
von Denny Schulz aus Neumünster.
Die Seite ist bewusst als statische Website gebaut: kein Framework, kein CMS, keine
Datenbank. `scripts/build-site.py` erzeugt aus `src/` und den heruntergeladenen
Originaldateien in `assets-src/` das vollständige, auslieferbare Verzeichnis `site/`.
## Aufbau
| Pfad | Inhalt |
| --- | --- |
| `src/content.py` | Alle redaktionellen Inhalte, Kontaktdaten und Pflichtangaben |
| `src/pages.py` | Die 13 Seiten und die 404-Seite als HTML-Fabriken |
| `src/layout.py` | Grundgerüst, Navigation, `<head>`, Bild-Helfer |
| `src/theme.py` | Design-Tokens und das komplette Stylesheet |
| `src/images.json` | Zuordnung der Bildmotive aus dem eigenen Bilderpool |
| `scripts/fetch-assets.py` | Lädt Bilderpool, Google-Fonts-Subsets und Lizenzen |
| `scripts/build-site.py` | Erzeugt `site/` inkl. WebP-Varianten, OG-Bildern, Sitemap |
| `tests/test_site.py` | Struktur-, Datenschutz- und Inhaltstests |
| `site/` | Generiertes Ergebnis — wird so ausgeliefert (committet) |
| `assets-src/` | Heruntergeladene Quellen (nicht im Git, per Skript reproduzierbar) |
| `.ocauto/` | QA-, Deploy- und Verify-Hooks |
| `docs/deployment.md` | Produktionsablauf, Rollback, Infrastruktur |
## Lokal bauen und prüfen
```bash
python3 scripts/fetch-assets.py # einmalig, braucht Zugriff auf den Bilderpool
python3 scripts/build-site.py # schreibt site/
.ocauto/qa # vollständige deterministische Prüfung
```
Der lokale Server läuft über Docker Compose:
```bash
docker compose -f compose.yml up -d
open http://127.0.0.1:18430/
```
## Seiten
Startseite · Fotografie · Businessfotografie · Portrait & Model · Familien & Paare ·
Minishootings · Video · Social Media · Projekte · Über · Kontakt · Impressum ·
Datenschutz — plus eine eigene 404-Seite.
## Technische Entscheidungen
- **Schriften selbst gehostet.** Fraunces (Display) und Manrope (Text) werden als
Google-Fonts-`woff2`-Subsets mitgeliefert, dazu die OFL-Lizenzen in `docs/`.
Es wird nichts von `fonts.googleapis.com` geladen.
- **Bilder als WebP in mehreren Breiten.** Erzeugt werden nur Breiten, die kleiner
oder gleich dem Original sind — nichts wird hochskaliert.
- **YouTube erst nach Klick.** Statt eines vorab geladenen iframes gibt es ein
Vorschaubild; das eigentliche Video kommt per Klick über `youtube-nocookie.com`.
- **Kontaktformular ohne Server.** Das Formular setzt aus den Eingaben lokal einen
E-Mail-Entwurf zusammen. Ohne JavaScript greift `mailto:` als Fallback.
Es gibt keinen API-Endpunkt und keine Datenübertragung an den Server.
- **Keine Zugriffsprotokolle.** `nginx.conf` schaltet das Access-Log ab; es gibt
keine Cookies, keine Analyse und keine externen Ressourcen.
## Herkunft der Inhalte
Die Bilder stammen aus dem eigenen Bilderpool des Betreibers und werden als
bereits abgeleitete `web`-Variante geladen. Es werden nur Motive gezeigt, für die
die Rechte vorliegen. Alle gezeigten Personen sind mit der Veröffentlichung
einverstanden.

38
compose.vps.yml Normal file
View File

@@ -0,0 +1,38 @@
# VPS overlay: attaches the static web service to the shared Traefik network.
# Applied together with compose.yml on the production host:
# docker compose -f compose.yml -f compose.vps.yml up -d
services:
web:
labels:
traefik.enable: 'true'
traefik.docker.network: web
# HTTPS routers -------------------------------------------------------
traefik.http.routers.desfoto.rule: Host(`desfoto.de`)
traefik.http.routers.desfoto.entrypoints: https
traefik.http.routers.desfoto.tls.certresolver: le
traefik.http.routers.desfoto.priority: '200'
traefik.http.routers.desfoto.service: desfoto
traefik.http.routers.desfoto-www.rule: Host(`www.desfoto.de`)
traefik.http.routers.desfoto-www.entrypoints: https
traefik.http.routers.desfoto-www.tls.certresolver: le
traefik.http.routers.desfoto-www.priority: '200'
traefik.http.routers.desfoto-www.middlewares: desfoto-canonical
traefik.http.routers.desfoto-www.service: desfoto
# HTTP is already redirected to HTTPS by the global Traefik entrypoint
# redirection (see /srv/stack/docker-compose.yml); the ACME http-challenge
# is answered by Traefik itself before the redirect.
traefik.http.middlewares.desfoto-canonical.redirectregex.regex: ^https?://(?:www\.)?desfoto\.de/(.*)
traefik.http.middlewares.desfoto-canonical.redirectregex.replacement: https://desfoto.de/$${1}
traefik.http.middlewares.desfoto-canonical.redirectregex.permanent: 'true'
traefik.http.services.desfoto.loadbalancer.server.port: '80'
networks:
- default
- web
networks:
web:
external: true

20
compose.yml Normal file
View File

@@ -0,0 +1,20 @@
name: desfoto
services:
web:
image: nginx:1.28-alpine
restart: unless-stopped
volumes:
- ./site:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- 127.0.0.1:18430:80
healthcheck:
test: [CMD, wget, -q, --spider, http://127.0.0.1/]
interval: 15s
timeout: 5s
retries: 5
logging:
driver: json-file
options:
max-size: 5m
max-file: '2'

112
docs/deployment.md Normal file
View File

@@ -0,0 +1,112 @@
# Deployment — desfoto.de
## Zielumgebung
| Baustein | Wert |
| --- | --- |
| Produktionshost | `prod-main` = `opencode-prod@87.106.24.138` (NOPASSWD sudo) |
| Live-Verzeichnis | `/home/denny/stacks/desfoto/` (Owner `denny:denny`) |
| Release-Snapshots | `/home/denny/stacks/desfoto-releases/<stamp>-<sha>/` |
| Compose-Dateien | `compose.yml` + `compose.vps.yml`, Projektname `desfoto` |
| Container | `desfoto-web-1` (`nginx:1.28-alpine`), gebunden an `127.0.0.1:18430` |
| Netzwerk | externes Docker-Netzwerk `web` (Traefik-Docker-Provider) |
| Reverse Proxy | Traefik v3 im Projekt `stack` (`/srv/stack/docker-compose.yml`) |
| Entrypoints | `http` und `https`, ACME-Resolver `le`, HTTP-01-Challenge |
| Zertifikate | `/srv/traefik/acme.json` |
| Domains | `desfoto.de` (kanonisch) und `www.desfoto.de` → 301 auf Apex |
Der Proxy wird **niemals** neu gestartet oder neu erzeugt. Die Anbindung erfolgt
ausschließlich über Labels in `compose.vps.yml`.
## Ablauf einer Veröffentlichung
```bash
/home/king/bin/oc-release /home/king/projects/desfoto \
"feat: ..." <geänderte Dateien...>
```
`oc-release` committet und pusht die genannten Dateien und ruft danach die
getrackten Hooks auf:
1. `.ocauto/deploy <sha>`
- prüft, dass `HEAD` dem Release entspricht und `site/`, `nginx.conf`,
`compose.yml`, `compose.vps.yml` keine uncommitteten Änderungen haben;
- packt diese Pfade in ein Archiv und lädt es nach `prod-main:/tmp`;
- legt auf dem Server zuerst einen Snapshot der aktuell laufenden Version an
(`/home/denny/stacks/desfoto-releases/<stamp>-<sha>/`, zusätzlich als
`previous` verlinkt);
- entpackt die neue Version nach `/home/denny/stacks/desfoto/`, setzt den
Owner auf `denny:denny` und schreibt die Release-Kennung nach `RELEASE`;
- `docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans`
und wartet auf `healthy`;
- entfernt einmalig die veralteten `desfoto.de`-Weiterleitungs-Labels aus
`/srv/stack/docker-compose.yml`: Es werden ausschließlich Traefik-Label-Zeilen
mit `desfoto`-Bezug sowie der zugehörige Kommentar entfernt, das Ergebnis wird
zuerst als Compose-Projekt validiert (`config --quiet`) und danach per atomarem
`mv` an die Stelle der Live-Datei gesetzt. Eine Vorher-Fassung liegt im
Snapshot (`docker-compose.yml.stack-backup`) und daneben als
`docker-compose.yml.bak.<stamp>`. Danach wird ausschließlich der
`landing`-Container mit `--no-deps` neu erzeugt, damit `dennyapp.de` und
`dennyschulz.de` unverändert weiterlaufen. Bleibt eine `desfoto`-Referenz
übrig (z. B. ein eigener Service-Block), wird sie als WARNUNG ausgegeben.
2. `.ocauto/verify <sha>`
- vergleicht den Dateibaum unter `/home/denny/stacks/desfoto/site` mit dem
committeten `site/` (SHA-256 über alle Dateien außer den datierten
`sitemap.xml`/`security.txt`);
- prüft alle 13 Routen, die 404-Seite, `robots.txt`, `sitemap.xml`,
`.well-known/security.txt`, Bilder und Schriften;
- prüft die Weiterleitungen `http → https` und `www → Apex`;
- prüft die Sicherheits-Header, dass kein `Set-Cookie` gesetzt wird und dass
das Zertifikat noch mindestens sieben Tage gültig ist;
- prüft, dass `www.dennyschulz.de` und `dennyapp.de` weiterhin erreichbar sind.
## Routing
`desfoto.de` wird vor dem Aufräumen bereits vom neuen Container bedient, weil die
Routers in `compose.vps.yml` eine explizite Traefik-Priorität `200` tragen und die
alten Weiterleitungsrouters keine Priorität setzen (damit gilt dort die
Regel-Länge). Der Container wird also zuerst gesund geprüft, und erst danach werden
die alten Labels entfernt — der Übergang hat damit kein Fenster ohne Antwort.
`www.desfoto.de` wird über die Middleware `desfoto-canonical` dauerhaft auf
`https://desfoto.de/...` umgeschrieben. Für `http` greift die globale
Entrypoint-Weiterleitung von Traefik auf `https`; die ACME-HTTP-01-Challenge
beantwortet Traefik selbst, bevor diese Weiterleitung greift.
## Rollback
```bash
ROLLBACK_TO=/home/denny/stacks/desfoto-releases/<stamp>-<sha> \
/home/king/projects/desfoto/.ocauto/deploy rollback
```
Der Befehl synchronisiert `site/`, `nginx.conf` und beide Compose-Dateien aus dem
Snapshot zurück nach `/home/denny/stacks/desfoto/` und startet den Stack neu. Die
frühere Version bleibt so lange verfügbar, bis sie bewusst gelöscht wird.
Jeder Release legt den Snapshot an, auch der allererste. Enthält der Snapshot noch
kein `site/` (weil vorher nichts ausgeliefert wurde), entfernt der Rollback den
desfoto-Stack wieder und stellt den Zustand vor der Veröffentlichung her.
Wurde `/srv/stack/docker-compose.yml` verändert, liegt die Vorher-Fassung sowohl im
Snapshot als `docker-compose.yml.stack-backup` als auch daneben unter
`docker-compose.yml.bak.<stamp>`. Beide werden vom Rollback automatisch
zurückgespielt, gefolgt von `docker compose up -d --no-deps landing`; das Backup
`.bak.<stamp>` bleibt zusätzlich für einen manuellen Eingriff liegen.
## Betrieb
```bash
ssh prod-main
sudo docker compose -f /home/denny/stacks/desfoto/compose.yml \
-f /home/denny/stacks/desfoto/compose.vps.yml ps
sudo docker logs --tail 50 desfoto-web-1
sudo docker inspect --format '{{.State.Health.Status}}' desfoto-web-1
```
Access-Logs gibt es bewusst nicht (`access_log off` in `nginx.conf`). Auch Fehler
schreibt nginx nicht weg: `error_log /dev/null crit;` verwirft sie vollständig,
statt sie in eine Datei zu schreiben. Der JSON-Log-Treiber des Containers ist
zusätzlich auf 5 MB × 2 Dateien begrenzt. Das entspricht der Zusage in der
Datenschutzerklärung.

View File

@@ -0,0 +1,93 @@
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

93
docs/font-manrope-OFL.txt Normal file
View File

@@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

86
nginx.conf Normal file
View File

@@ -0,0 +1,86 @@
# desfoto.de - static site behind the existing VPS Traefik (Docker provider).
#
# Truthfulness contract (see /datenschutz/): this server writes no access log,
# sets no cookies, runs no analytics and loads no third-party assets. YouTube is
# only embedded after an explicit click (youtube-nocookie.com).
map $uri $desfoto_cache {
default "public, max-age=0, must-revalidate";
~^/assets/img/ "public, max-age=604800, stale-while-revalidate=86400";
~^/assets/fonts/ "public, max-age=604800, stale-while-revalidate=86400";
~^/assets/site\.(css|js)$ "public, max-age=0, must-revalidate";
~^/(favicon\.svg|site\.webmanifest)$ "public, max-age=86400";
}
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
server_tokens off;
# Redirects stay relative so they keep the https scheme terminated by Traefik.
absolute_redirect off;
# No stored visitor data: the privacy page promises log-free operation.
# Access logging is off and error messages are discarded completely, so this
# server never writes a log file that could contain a visitor IP address.
access_log off;
error_log /dev/null crit;
etag on;
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 512;
gzip_types text/css text/javascript application/javascript application/json application/ld+json application/xml application/manifest+json image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self' mailto:; frame-ancestors 'none'; upgrade-insecure-requests" always;
add_header Cache-Control $desfoto_cache always;
error_page 404 /404.html;
location = /404.html {
internal;
}
location = / {
try_files /index.html =404;
}
# Keep canonical URLs: no /index.html duplicates in the index. The check uses
# $request_uri so the internal redirect from the index module (which still
# carries the original URI) is not caught in a loop.
if ($request_uri ~ ^/(.*/)?index\.html(\?.*)?$) {
return 301 /$1;
}
location = /impressum.html { return 301 /impressum/; }
location = /datenschutz.html { return 301 /datenschutz/; }
location = /kontakt.html { return 301 /kontakt/; }
location ^~ /.well-known/ {
allow all;
autoindex off;
types { }
default_type text/plain;
}
location /assets/ {
try_files $uri =404;
}
location / {
try_files $uri $uri/ =404;
}
location ~ /\.(?!well-known/) {
deny all;
}
}

547
scripts/build-site.py Executable file
View File

@@ -0,0 +1,547 @@
#!/usr/bin/env python3
"""Build the committed static site in site/ from src/ and assets-src/.
Steps
-----
1. Responsive WebP derivatives for every pool image (no upscaling).
2. Self-hosted font CSS from the downloaded Google Fonts subsets.
3. Brand assets: favicon, touch icon, per-page OpenGraph images.
4. HTML for every route, robots.txt, sitemap.xml, webmanifest, security.txt.
5. A build report with size totals and an internal-link check.
"""
from __future__ import annotations
import datetime as dt
import json
import shutil
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
ASSETS = ROOT / "assets-src"
SITE = ROOT / "site"
IMG_OUT = SITE / "assets" / "img"
FONT_OUT = SITE / "assets" / "fonts"
TTF = ASSETS / "fonts-ttf"
sys.path.insert(0, str(SRC))
import layout # noqa: E402
import pages # noqa: E402
from content import LEGAL, SITE as SITE_META, VIDEO # noqa: E402
from theme import CSS, TOKENS # noqa: E402
WIDTHS = (480, 768, 1200, 1600, 2000)
WEBP_QUALITY = 84
INK = (23, 20, 15)
PAPER = (246, 242, 234)
ACCENT = (192, 67, 28)
# OpenGraph image per route, built from the page's own key visual.
ROUTE_OG = {
"/": ("hero-studio", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."),
"/fotografie/": ("editorial-earring", "og-fotografie.jpg", "Fotografie aus Neumünster"),
"/businessfotografie/": ("business-portrait-studio", "og-businessfotografie.jpg", "Businessfotografie"),
"/portrait-und-model/": ("editorial-colour", "og-portrait.jpg", "Portrait & Model"),
"/familien-und-paare/": ("people-joy", "og-familien.jpg", "Familien & Paare"),
"/minishootings/": ("portrait-natural", "og-minishootings.jpg", "Minishootings"),
"/video/": ("studio-lens", "og-video.jpg", "Videoprojekte"),
"/social-media/": ("event-motion", "og-social-media.jpg", "Social-Media-Inhalte"),
"/projekte/": ("travel-norway-pano", "og-projekte.jpg", "Eigene Projekte"),
"/ueber/": ("business-portrait-dark", "og-ueber.jpg", "Über desfoto"),
"/kontakt/": ("event-venue", "og-kontakt.jpg", "Kontakt"),
"/impressum/": ("gear-cases", "og-impressum.jpg", "Impressum"),
"/datenschutz/": ("event-venue", "og-datenschutz.jpg", "Datenschutz"),
}
def load_manifest() -> list[dict]:
data = json.loads((SRC / "images.json").read_text(encoding="utf-8"))
entries = list(data["images"])
entries.append(
{
"slug": VIDEO["poster"].replace(".jpg", ""),
"raw": VIDEO["poster"],
"alt": f"Vorschaubild zum Musikvideo {VIDEO['title']}",
"focus": "50% 50%",
"local": True,
}
)
return entries
def source_path(entry: dict) -> Path:
webp = ASSETS / "images" / f"{entry['slug']}.webp"
if webp.exists():
return webp
for suffix in (".src", ".jpg", ".jpeg", ".png"):
candidate = ASSETS / "images" / f"{entry['slug']}{suffix}"
if candidate.exists():
return candidate
raise SystemExit(f"missing source asset for {entry['slug']}")
def build_images(entries: list[dict]) -> dict[str, dict]:
if IMG_OUT.exists():
for stale in IMG_OUT.glob("*.webp"):
stale.unlink()
IMG_OUT.mkdir(parents=True, exist_ok=True)
table: dict[str, dict] = {}
total = 0
for entry in entries:
src = Image.open(source_path(entry)).convert("RGB")
src_w, src_h = src.size
widths = sorted({width for width in WIDTHS if width <= src_w} or {min(src_w, WIDTHS[0])})
if src_w < widths[-1]:
widths.append(src_w)
widths = sorted(set(widths))
for width in widths:
height = round(src_h * width / src_w)
out = IMG_OUT / f"{entry['slug']}-{width}.webp"
resized = src if width >= src_w else src.resize((width, height), Image.Resampling.LANCZOS)
resized.save(out, "WebP", quality=WEBP_QUALITY, method=6)
total += out.stat().st_size
table[entry["slug"]] = {
"base": f"/assets/img/{entry['slug']}",
"alt": entry["alt"],
"focus": entry.get("focus", "50% 50%"),
"widths": widths,
"width": widths[-1],
"height": round(src_h * widths[-1] / src_w),
}
print(f" [img] {entry['slug']:<26} {src_w}x{src_h} -> {len(widths)} widths")
print(f" image payload: {total / 1024 / 1024:.1f} MiB across {len(table)} images")
return table
def build_fonts() -> None:
FONT_OUT.mkdir(parents=True, exist_ok=True)
for existing in FONT_OUT.glob("*.woff2"):
existing.unlink()
blocks = []
for family in ("fraunces", "manrope"):
index = json.loads((ASSETS / f"font-index-{family}.json").read_text(encoding="utf-8"))
for item in index:
shutil.copyfile(ASSETS / "fonts" / item["file"], FONT_OUT / item["file"])
for item in index:
weight = "300 700" if family == "fraunces" else "200 800"
blocks.append(
"@font-face{"
f"font-family:'{family.capitalize()}';font-style:normal;font-weight:{weight};"
"font-display:swap;"
f"src:url('/assets/fonts/{item['file']}') format('woff2');"
f"unicode-range:{item['range']};"
"}"
)
print(f" [font] {family}: {len(index)} subsets")
header = (
"/* Self-hosted open licensed fonts (SIL Open Font License 1.1).\n"
" Fraunces + Manrope, sourced from Google Fonts. Licences: docs/font-*-OFL.txt */\n"
)
(SITE / "assets" / "fonts.css").write_text(header + "\n".join(blocks) + "\n", encoding="utf-8")
def build_stylesheet() -> None:
(SITE / "assets" / "site.css").write_text(
f"/* desfoto.de - generated by scripts/build-site.py */\n:root{{{TOKENS}}}{CSS}",
encoding="utf-8",
)
print(" [css] site.css written")
def _font(name: str, size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(TTF / name), size)
def draw_mark(size: int, background: bool = True) -> Image.Image:
"""The desfoto aperture mark, drawn deterministically with PIL."""
scale = 8
s = size * scale
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
if background:
d.rounded_rectangle((0, 0, s - 1, s - 1), radius=int(s * 0.22), fill=INK + (255,))
cx = cy = s / 2
ring_w = max(2, int(s * 0.055))
d.ellipse(
(cx - s * 0.27, cy - s * 0.27, cx + s * 0.27, cy + s * 0.27),
outline=PAPER + (255,),
width=ring_w,
)
d.ellipse(
(cx - s * 0.105, cy - s * 0.105, cx + s * 0.105, cy + s * 0.105),
outline=PAPER + (255,),
width=ring_w,
)
blade_w = max(2, int(s * 0.058))
import math
for angle in (270, 30, 150):
rad = math.radians(angle)
x1 = cx + math.cos(rad) * s * 0.27
y1 = cy + math.sin(rad) * s * 0.27
x2 = cx + math.cos(rad) * s * 0.45
y2 = cy + math.sin(rad) * s * 0.45
d.line((x1, y1, x2, y2), fill=ACCENT + (255,), width=blade_w)
for (px, py) in ((x1, y1), (x2, y2)):
r = blade_w / 2
d.ellipse((px - r, py - r, px + r, py + r), fill=ACCENT + (255,))
return img.resize((size, size), Image.Resampling.LANCZOS)
def build_brand_assets(table: dict[str, dict]) -> None:
(SITE / "favicon.svg").write_text(
"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="desfoto">
<rect width="64" height="64" rx="14" fill="#17140F"/>
<circle cx="32" cy="32" r="17" fill="none" stroke="#F6F2EA" stroke-width="3.4"/>
<circle cx="32" cy="32" r="6.6" fill="none" stroke="#F6F2EA" stroke-width="2.6"/>
<g stroke="#C0431C" stroke-width="3.6" stroke-linecap="round">
<path d="M32 15V6"/><path d="M46.7 40.5 54.4 45"/><path d="M17.3 40.5 9.6 45"/>
</g>
</svg>
""",
encoding="utf-8",
)
draw_mark(180).save(IMG_OUT / "apple-touch-icon.png")
draw_mark(64).save(IMG_OUT / "favicon-32.png")
draw_mark(64).resize((16, 16), Image.Resampling.LANCZOS).save(IMG_OUT / "favicon-16.png")
print(" [brand] favicon.svg, apple-touch-icon.png, favicon-32.png")
og_done = set()
for slug, filename, headline in ROUTE_OG.values():
if filename in og_done:
continue
og_done.add(filename)
build_og(table, slug, filename, headline)
def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) -> None:
meta = table[slug]
source = Image.open(IMG_OUT / f"{slug}-{meta['widths'][-1]}.webp").convert("RGB")
target_w, target_h = 1200, 630
ratio = target_w / target_h
src_ratio = source.width / source.height
if src_ratio > ratio:
new_w = int(source.height * ratio)
left = (source.width - new_w) // 2
source = source.crop((left, 0, left + new_w, source.height))
else:
new_h = int(source.width / ratio)
top = int((source.height - new_h) * 0.35)
source = source.crop((0, top, source.width, top + new_h))
source = source.resize((target_w, target_h), Image.Resampling.LANCZOS)
scrim = Image.new("L", (target_w, target_h), 0)
sd = ImageDraw.Draw(scrim)
for y in range(target_h):
alpha = int(40 + 175 * (y / target_h) ** 1.35)
sd.line((0, y, target_w, y), fill=alpha)
source = Image.composite(Image.new("RGB", (target_w, target_h), (12, 11, 9)), source, scrim)
d = ImageDraw.Draw(source)
d.rectangle((0, 0, target_w, 6), fill=ACCENT)
brand_font = _font("fraunces-600.ttf", 92)
head_font = _font("fraunces-400.ttf", 54)
small_font = _font("manrope-600.ttf", 26)
d.text((72, 74), "desfoto", font=brand_font, fill=PAPER)
d.text((76, 186), "FOTOGRAFIE & VIDEO · NEUMÜNSTER", font=small_font, fill=(230, 161, 132))
lines = wrap_text(headline, head_font, target_w - 150)
y = target_h - 90 - len(lines) * 64
for line in lines:
d.text((76, y), line, font=head_font, fill=PAPER)
y += 64
source.save(IMG_OUT / filename, "JPEG", quality=88, optimize=True, progressive=True)
def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> list[str]:
words = text.split()
lines: list[str] = []
current = ""
for word in words:
candidate = f"{current} {word}".strip()
if font.getlength(candidate) <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines[:3]
def build_html(table: dict[str, dict]) -> list[tuple[str, str]]:
layout.IMAGE_TABLE = table
written: list[tuple[str, str]] = []
for path, rel, factory, _x, _p in pages.ROUTES:
html = factory()
if path in ROUTE_OG:
html = html.replace("og-desfoto.jpg", ROUTE_OG[path][1])
target = SITE / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(html, encoding="utf-8")
written.append((path, rel))
print(f" [html] {rel}")
not_found = pages.not_found().replace("og-desfoto.jpg", "og-desfoto.jpg")
(SITE / "404.html").write_text(not_found, encoding="utf-8")
print(" [html] 404.html")
return written
def build_meta_files(routes: list[tuple[str, str]]) -> None:
today = dt.date.today().isoformat()
urls = []
for path, _rel, _f, _x, priority in pages.ROUTES:
urls.append(
f" <url><loc>{SITE_META['url']}{path}</loc><lastmod>{today}</lastmod>"
f"<changefreq>{'weekly' if priority == '1.0' else 'monthly'}</changefreq>"
f"<priority>{priority}</priority></url>"
)
(SITE / "sitemap.xml").write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(urls)
+ "\n</urlset>\n",
encoding="utf-8",
)
(SITE / "robots.txt").write_text(
"User-agent: *\nAllow: /\n\n"
f"Sitemap: {SITE_META['url']}/sitemap.xml\n",
encoding="utf-8",
)
(SITE / "site.webmanifest").write_text(
json.dumps(
{
"name": "desfoto",
"short_name": "desfoto",
"description": SITE_META["default_description"],
"start_url": "/",
"display": "standalone",
"background_color": "#F6F2EA",
"theme_color": SITE_META["theme_color"],
"icons": [
{"src": "/assets/img/favicon-32.png", "sizes": "32x32", "type": "image/png"},
{"src": "/assets/img/apple-touch-icon.png", "sizes": "180x180", "type": "image/png"},
],
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
wellknown = SITE / ".well-known"
wellknown.mkdir(parents=True, exist_ok=True)
(wellknown / "security.txt").write_text(
"Contact: mailto:info@dennyschulz.de\n"
f"Expires: {(dt.date.today() + dt.timedelta(days=365)).isoformat()}T00:00:00.000Z\n"
"Preferred-Languages: de, en\n"
f"Canonical: {SITE_META['url']}/.well-known/security.txt\n",
encoding="utf-8",
)
print(" [meta] sitemap.xml, robots.txt, site.webmanifest, .well-known/security.txt")
def build_script() -> None:
(SITE / "assets" / "site.js").write_text(
"""/* desfoto.de - progressive enhancement only. */
(function () {
'use strict';
/* Mobile navigation */
var toggle = document.querySelector('[data-nav-toggle]');
var nav = document.getElementById('site-nav');
if (toggle && nav) {
toggle.addEventListener('click', function () {
var open = nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
nav.addEventListener('click', function (event) {
if (event.target.closest('a') && window.innerWidth < 832) {
nav.classList.remove('is-open');
toggle.setAttribute('aria-expanded', 'false');
}
});
}
/* Click-to-load YouTube (youtube-nocookie) */
document.querySelectorAll('[data-video-id]').forEach(function (facade) {
facade.addEventListener('click', function () {
var id = facade.getAttribute('data-video-id');
var iframe = document.createElement('iframe');
iframe.setAttribute('src', 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) +
'?autoplay=1&rel=0&modestbranding=1&playsinline=1');
iframe.setAttribute('title', facade.getAttribute('aria-label') || 'Video');
iframe.setAttribute('loading', 'eager');
iframe.setAttribute('allow', 'accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
iframe.setAttribute('allowfullscreen', '');
iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
var shell = facade.parentElement;
shell.replaceChild(iframe, facade);
iframe.focus();
});
});
/* Contact form -> local mail draft, nothing is transmitted by this page */
var form = document.querySelector('[data-contact-form]');
if (form) {
var status = form.querySelector('[data-contact-status]');
form.addEventListener('submit', function (event) {
event.preventDefault();
var data = new FormData(form);
var name = (data.get('name') || '').toString().trim();
var email = (data.get('email') || '').toString().trim();
var topic = (data.get('topic') || '').toString().trim();
var date = (data.get('date') || '').toString().trim();
var message = (data.get('message') || '').toString().trim();
if (!name || !email || !message) {
if (status) {
status.hidden = false;
status.textContent = 'Bitte Name, E-Mail und Nachricht ausfüllen.';
status.style.color = '#95330F';
}
return;
}
var subject = 'Anfrage über desfoto.de: ' + topic;
var body = 'Name: ' + name + '\\nE-Mail: ' + email + '\\nBereich: ' + topic +
(date ? '\\nWunschtermin: ' + date : '') + '\\n\\n' + message + '\\n';
window.location.href = 'mailto:info@dennyschulz.de?subject=' +
encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
if (status) {
status.hidden = false;
status.textContent = 'Dein Mailprogramm sollte sich mit dem fertigen Entwurf öffnen.';
status.style.color = '';
}
});
}
/* Soft reveal on scroll, disabled for reduced motion */
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var items = document.querySelectorAll('.reveal');
if (reduce || !('IntersectionObserver' in window)) {
items.forEach(function (item) { item.classList.add('is-in'); });
} else {
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-in');
observer.unobserve(entry.target);
}
});
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 });
items.forEach(function (item) { observer.observe(item); });
}
}());
""",
encoding="utf-8",
)
print(" [js] site.js written")
def link_check(routes: list[tuple[str, str]]) -> list[str]:
"""Verify that every internal href/src written into the build resolves."""
import html as html_mod
import re
problems: list[str] = []
built = {f"/{rel}".replace("/index.html", "/") for _p, rel in routes}
built.add("/404.html")
for rel in ["sitemap.xml", "robots.txt", "site.webmanifest", "favicon.svg", ".well-known/security.txt",
"assets/site.css", "assets/site.js", "assets/fonts.css"]:
built.add(f"/{rel}")
for file in SITE.rglob("*"):
if file.is_dir() or file.suffix not in (".html", ".xml", ".json", ".txt", ".webmanifest", ".css", ".js"):
continue
text = file.read_text(encoding="utf-8", errors="replace")
if file.suffix in (".html", ".css"):
for match in re.finditer(r'(?:href|src)="(/[^"#?]*)"', text):
target = html_mod.unescape(match.group(1))
if target in built:
continue
if target.startswith("/assets/img/") or target.startswith("/assets/fonts/"):
if (SITE / target.lstrip("/")).exists():
continue
if target == "/assets/fonts/fonts.css":
continue
problems.append(f"{file.relative_to(SITE)} -> {target}")
if file.suffix == ".html":
for match in re.finditer(r'srcset="([^"]+)"', text):
for candidate in re.findall(r'(/assets/img/[^\s,]+)', match.group(1)):
if not (SITE / candidate.lstrip("/")).exists():
problems.append(f"{file.relative_to(SITE)} -> srcset {candidate}")
for match in re.finditer(r'url\((/(?:assets/fonts/)[^)]+)\)', text):
if not (SITE / match.group(1).lstrip("/")).exists():
problems.append(f"{file.relative_to(SITE)} -> font {match.group(1)}")
for match in re.finditer(r'<loc>([^<]+)</loc>', (SITE / "sitemap.xml").read_text(encoding="utf-8")):
path = match.group(1).replace(SITE_META["url"], "")
if path not in built:
problems.append(f"sitemap.xml -> {path}")
for match in re.finditer(r"url\('(/assets/fonts/[^']+)'\)", (SITE / "assets" / "fonts.css").read_text(encoding="utf-8")):
if not (SITE / match.group(1).lstrip("/")).exists():
problems.append(f"fonts.css -> missing {match.group(1)}")
return problems
def normalize_permissions() -> None:
"""Make the build output readable for the serving nginx worker.
The image and font files are written with the ambient umask, so a build run
with a restrictive umask would leave 0600 files that an unprivileged nginx
worker cannot read (HTTP 403). The build therefore fixes the modes itself:
directories 0755, files 0644.
"""
for path in sorted(SITE.rglob("*"), key=lambda p: len(p.parts), reverse=True):
path.chmod(0o755 if path.is_dir() else 0o644)
SITE.chmod(0o755)
def main() -> int:
print("== images ==")
entries = load_manifest()
table = build_images(entries)
print("== fonts ==")
build_fonts()
build_stylesheet()
print("== brand ==")
build_brand_assets(table)
print("== pages ==")
routes = build_html(table)
build_meta_files(routes)
build_script()
print("== checks ==")
problems = link_check(routes)
html_files = sorted(SITE.rglob("*.html"))
broken_alt = []
import re
for file in html_files:
text = file.read_text(encoding="utf-8")
for tag in re.finditer(r"<img\b[^>]*>", text):
if 'alt="' not in tag.group(0):
broken_alt.append(f"{file.relative_to(SITE)}: img without alt")
if problems:
print(" internal link problems:")
for problem in problems:
print(" -", problem)
if broken_alt:
print(" images without alt:")
for problem in broken_alt:
print(" -", problem)
total = sum(f.stat().st_size for f in SITE.rglob("*") if f.is_file())
normalize_permissions()
print(f" pages: {len(html_files)} | site size: {total / 1024 / 1024:.1f} MiB")
print(f" link problems: {len(problems)} | alt problems: {len(broken_alt)}")
if problems or broken_alt:
return 1
print("build ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())

174
scripts/fetch-assets.py Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Fetch real source assets for desfoto.de.
Sources
-------
* Own image pool (Orgatool volume, served through the photographer's own API).
We pull the pre-derived `web` variant (long edge 2560 px) instead of the
20-60 MB camera originals. Only assets we are allowed to publish as the
rights holder are listed in src/images.json.
* Google Fonts (open licensed, OFL) - downloaded once, then self-hosted.
Licences are written to docs/.
* YouTube poster frame for the own music video "Thjodroerir - Skogamor".
Everything lands in assets-src/ which is *not* shipped. scripts/build-site.py
turns assets-src/ into the committed site/ output.
"""
from __future__ import annotations
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "assets-src"
IMG_OUT = SRC / "images"
FONT_OUT = SRC / "fonts"
TTF_OUT = SRC / "fonts-ttf"
DOCS = ROOT / "docs"
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
# Old Safari advertises truetype-only support, so Google serves real .ttf
# files (the MSIE 6 UA returns EOT, which PIL/FreeType cannot read).
UA_TTF = (
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_8; en-US) "
"AppleWebKit/533.18.1 (KHTML, like Gecko) Version/5.0.2 Safari/533.18.1"
)
# family -> (google css2 query, wanted unicode-range comments)
FAMILIES = {
"fraunces": {
"query": "family=Fraunces:opsz,wght@9..144,300..700",
"keep": ("latin", "latin-ext"),
"license": "https://raw.githubusercontent.com/google/fonts/main/ofl/fraunces/OFL.txt",
},
"manrope": {
"query": "family=Manrope:wght@200..800",
"keep": ("latin", "latin-ext"),
"license": "https://raw.githubusercontent.com/google/fonts/main/ofl/manrope/OFL.txt",
},
}
TTF_QUERIES = (
"family=Fraunces:opsz,wght@9..144,400",
"family=Fraunces:opsz,wght@9..144,600",
"family=Manrope:wght@400",
"family=Manrope:wght@600",
)
VIDEO_ID = "NWWFTf7l8g0"
def fetch(url: str, *, ua: str = UA, timeout: int = 120) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": "*/*"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def try_fetch(url: str, *, ua: str = UA) -> bytes | None:
try:
return fetch(url, ua=ua)
except urllib.error.HTTPError as exc:
if exc.code in (403, 404, 410):
return None
raise
except urllib.error.URLError:
return None
def fetch_images() -> None:
manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8"))
base = manifest["pool_base"].rstrip("/")
ok = skipped = 0
for entry in manifest["images"]:
slug, raw = entry["slug"], entry["raw"]
rel = raw.replace("\\", "/")
target = IMG_OUT / f"{slug}.webp"
source = IMG_OUT / f"{slug}.src"
if target.exists() or source.exists():
skipped += 1
continue
data = try_fetch(f"{base}/_derived/web/{rel}.webp")
if data is None:
print(f" [!] web variant missing, falling back to original: {rel}")
data = fetch(f"{base}/{rel}")
source.write_bytes(data)
else:
target.write_bytes(data)
ok += 1
print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)")
print(f"images: {ok} fetched, {skipped} already present")
poster = IMG_OUT / "video-skogamor-poster.jpg"
if not poster.exists():
data = try_fetch(f"https://i.ytimg.com/vi/{VIDEO_ID}/maxresdefault.jpg")
if data is None:
data = fetch(f"https://i.ytimg.com/vi/{VIDEO_ID}/hqdefault.jpg")
poster.write_bytes(data)
print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)")
def fetch_fonts() -> None:
FONT_OUT.mkdir(parents=True, exist_ok=True)
for family, spec in FAMILIES.items():
css = fetch(f"https://fonts.googleapis.com/css2?{spec['query']}&display=swap").decode()
blocks = re.findall(
r"/\* ([a-z0-9-]+) \*/\s*@font-face \{(.*?)\}", css, flags=re.S
)
index = []
for subset, body in blocks:
if subset not in spec["keep"]:
continue
url = re.search(r"url\((https://[^)]+\.woff2)\)", body)
rng = re.search(r"unicode-range:\s*([^;]+);", body)
if not url:
continue
name = f"{family}-{subset}.woff2"
target = FONT_OUT / name
if not target.exists():
target.write_bytes(fetch(url.group(1)))
index.append({"file": name, "range": rng.group(1).strip() if rng else ""})
print(f" [ok] font {name} ({target.stat().st_size / 1024:.0f} KiB)")
(SRC / f"font-index-{family}.json").write_text(
json.dumps(index, indent=2) + "\n", encoding="utf-8"
)
license_target = DOCS / f"font-{family}-OFL.txt"
if not license_target.exists():
license_target.write_bytes(fetch(spec["license"]))
print(f" [ok] licence {license_target.name}")
# Build-time only TTFs (used to render the OG image and brand PNGs).
TTF_OUT.mkdir(parents=True, exist_ok=True)
for query in TTF_QUERIES:
css = fetch(f"https://fonts.googleapis.com/css2?{query}", ua=UA_TTF).decode()
blocks = re.findall(r"@font-face \{(.*?)\}", css, flags=re.S)
for block in blocks:
url_match = re.search(r"url\((https://[^)]+)\)", block)
if not url_match:
continue
family = (re.search(r"font-family: '([^']+)'", block) or [None, "font"])[1]
weight = (re.search(r"font-weight: (\d+)", block) or [None, "400"])[1]
name = f"{family.lower()}-{weight}.ttf"
target = TTF_OUT / name
if target.exists():
continue
target.write_bytes(fetch(url_match.group(1), ua=UA_TTF))
print(f" [ok] build font {name} ({target.stat().st_size / 1024:.0f} KiB)")
def main() -> int:
for path in (IMG_OUT, FONT_OUT, DOCS):
path.mkdir(parents=True, exist_ok=True)
print("== images ==")
fetch_images()
print("== fonts ==")
fetch_fonts()
print("done")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,4 @@
Contact: mailto:info@dennyschulz.de
Expires: 2027-09-19T00:00:00.000Z
Preferred-Languages: de, en
Canonical: https://desfoto.de/.well-known/security.txt

124
site/404.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Seite nicht gefunden · desfoto</title>
<meta name="description" content="Die angeforderte Seite existiert nicht. Zurück zur Startseite von desfoto.">
<meta name="author" content="Denny Schulz">
<meta name="robots" content="noindex,follow">
<meta name="theme-color" content="#17140F">
<meta property="og:type" content="website">
<meta property="og:site_name" content="desfoto">
<meta property="og:locale" content="de_DE">
<meta property="og:title" content="Seite nicht gefunden · desfoto">
<meta property="og:description" content="Die angeforderte Seite existiert nicht. Zurück zur Startseite von desfoto.">
<meta property="og:url" content="https://desfoto.de/404.html">
<meta property="og:image" content="https://desfoto.de/assets/img/og-desfoto.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Seite nicht gefunden · desfoto">
<meta name="twitter:description" content="Die angeforderte Seite existiert nicht. Zurück zur Startseite von desfoto.">
<meta name="twitter:image" content="https://desfoto.de/assets/img/og-desfoto.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/assets/img/favicon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="stylesheet" href="/assets/fonts.css">
<link rel="stylesheet" href="/assets/site.css">
</head>
<body>
<a class="skip" href="#main">Zum Inhalt springen</a>
<header class="masthead">
<div class="shell masthead__inner">
<a class="brand" href="/" aria-label="desfoto Startseite">
<svg class="brand__mark" viewBox="0 0 40 40" role="img" aria-label="desfoto Bildmarke">
<circle class="ring" cx="20" cy="20" r="13"/>
<circle class="ring" cx="20" cy="20" r="5.2" stroke-width="2"/>
<path class="blade" d="M20 7 20 1.6"/>
<path class="blade" d="M31.3 26.5 36 29.2"/>
<path class="blade" d="M8.7 26.5 4 29.2"/>
</svg>
<span class="brand__text"><span>desfoto</span><small>Fotografie &amp; Video</small></span>
</a>
<button class="nav__toggle" type="button" aria-expanded="false" aria-controls="site-nav" data-nav-toggle>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg> Menü
</button>
<nav class="nav" id="site-nav" aria-label="Hauptnavigation"><div class="nav__item"><a class="nav__link" href="/fotografie/">Fotografie<svg class="nav__caret" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6l5 5 5-5"/></svg></a><div class="nav__sub"><a href="/businessfotografie/">Businessfotografie</a><a href="/portrait-und-model/">Portrait &amp; Model</a><a href="/familien-und-paare/">Familien &amp; Paare</a><a href="/minishootings/">Minishootings</a></div></div><a class="nav__link" href="/video/">Video</a><a class="nav__link" href="/social-media/">Social Media</a><a class="nav__link" href="/projekte/">Projekte</a><a class="nav__link" href="/ueber/">Über</a><a class="btn btn--solid" href="/kontakt/">Projekt anfragen</a></nav>
</div>
</header>
<main id="main"><section class="section">
<div class="shell center-page">
<div class="center-page__inner">
<span class="kicker">Fehler 404</span>
<h1 class="balance">Diese Seite gibt es nicht.</h1>
<p class="lead">Vielleicht hat sich die Adresse geändert oder es ist ein Tippfehler passiert.
Hier geht es zurück zu den Bildern.</p>
<div class="btn-row">
<a class="btn btn--solid" href="/">Zur Startseite</a>
<a class="btn btn--ghost" href="/projekte/">Projekte ansehen</a>
<a class="btn btn--ghost" href="/kontakt/">Kontakt</a>
</div>
</div>
</div>
</section></main>
<footer class="footer">
<div class="shell">
<div class="footer__grid">
<div class="footer__brand">
<span class="brand">
<svg class="brand__mark" viewBox="0 0 40 40" role="img" aria-label="desfoto Bildmarke">
<circle class="ring" cx="20" cy="20" r="13"/>
<circle class="ring" cx="20" cy="20" r="5.2" stroke-width="2"/>
<path class="blade" d="M20 7 20 1.6"/>
<path class="blade" d="M31.3 26.5 36 29.2"/>
<path class="blade" d="M8.7 26.5 4 29.2"/>
</svg>
<span class="brand__text"><span>desfoto</span><small>Fotografie &amp; Video</small></span>
</span>
<p class="footer__claim">Fotografie und Video aus Neumünster. Ein Angebot von Denny Schulz Fotografie.</p>
</div>
<div>
<h2>Fotografie</h2>
<div class="footer__links">
<a href="/businessfotografie/">Businessfotografie</a>
<a href="/portrait-und-model/">Portrait &amp; Model</a>
<a href="/familien-und-paare/">Familien &amp; Paare</a>
<a href="/minishootings/">Minishootings</a>
</div>
</div>
<div>
<h2>Weitere Leistungen</h2>
<div class="footer__links">
<a href="/video/">Videoprojekte</a>
<a href="/social-media/">Social Media</a>
<a href="/projekte/">Projekte</a>
<a href="/ueber/">Über desfoto</a>
</div>
</div>
<div>
<h2>Kontakt</h2>
<div class="footer__links">
<a href="mailto:info@dennyschulz.de">info@dennyschulz.de</a>
<a href="tel:+491754083133">+49 175 4083133</a>
<a href="/kontakt/">Anfrage stellen</a>
<span class="muted">Neumünster · Schleswig-Holstein</span>
</div>
</div>
</div>
<div class="footer__bottom">
<nav aria-label="Rechtliches">
<a href="/impressum/">Impressum</a>
<a href="/datenschutz/">Datenschutz</a>
<a href="/kontakt/">Kontakt</a>
</nav>
<span>© Denny Schulz · Denny Schulz Fotografie · Alle Rechte an den gezeigten Bildern und Filmen vorbehalten.</span>
</div>
</div>
</footer>
<script src="/assets/site.js" defer></script>
</body>
</html>

6
site/assets/fonts.css Normal file
View File

@@ -0,0 +1,6 @@
/* Self-hosted open licensed fonts (SIL Open Font License 1.1).
Fraunces + Manrope, sourced from Google Fonts. Licences: docs/font-*-OFL.txt */
@font-face{font-family:'Fraunces';font-style:normal;font-weight:300 700;font-display:swap;src:url('/assets/fonts/fraunces-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'Fraunces';font-style:normal;font-weight:300 700;font-display:swap;src:url('/assets/fonts/fraunces-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face{font-family:'Manrope';font-style:normal;font-weight:200 800;font-display:swap;src:url('/assets/fonts/manrope-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'Manrope';font-style:normal;font-weight:200 800;font-display:swap;src:url('/assets/fonts/manrope-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Some files were not shown because too many files have changed in this diff Show More