feat: launch desfoto.de as a standalone photography site and retire the old redirect
11
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
93
docs/font-fraunces-OFL.txt
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
4
site/.well-known/security.txt
Normal 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
@@ -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 & 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 & Model</a><a href="/familien-und-paare/">Familien & 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 & 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 & Model</a>
|
||||
<a href="/familien-und-paare/">Familien & 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
@@ -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;}
|
||||
BIN
site/assets/fonts/fraunces-latin-ext.woff2
Normal file
BIN
site/assets/fonts/fraunces-latin.woff2
Normal file
BIN
site/assets/fonts/manrope-latin-ext.woff2
Normal file
BIN
site/assets/fonts/manrope-latin.woff2
Normal file
BIN
site/assets/img/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
site/assets/img/business-portrait-dark-1200.webp
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
site/assets/img/business-portrait-dark-1600.webp
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
site/assets/img/business-portrait-dark-480.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/assets/img/business-portrait-dark-768.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
site/assets/img/business-portrait-sit-1200.webp
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
site/assets/img/business-portrait-sit-1600.webp
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
site/assets/img/business-portrait-sit-2000.webp
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
site/assets/img/business-portrait-sit-480.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
site/assets/img/business-portrait-sit-768.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
site/assets/img/business-portrait-stool-1200.webp
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
site/assets/img/business-portrait-stool-1600.webp
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
site/assets/img/business-portrait-stool-480.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
site/assets/img/business-portrait-stool-768.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
site/assets/img/business-portrait-studio-1200.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
site/assets/img/business-portrait-studio-1600.webp
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
site/assets/img/business-portrait-studio-480.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
site/assets/img/business-portrait-studio-768.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
site/assets/img/editorial-back-1200.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
site/assets/img/editorial-back-1600.webp
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
site/assets/img/editorial-back-480.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
site/assets/img/editorial-back-768.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
site/assets/img/editorial-black-1200.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
site/assets/img/editorial-black-1600.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
site/assets/img/editorial-black-480.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
site/assets/img/editorial-black-768.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
site/assets/img/editorial-colour-1200.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
site/assets/img/editorial-colour-1600.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
site/assets/img/editorial-colour-480.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
site/assets/img/editorial-colour-768.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
site/assets/img/editorial-earring-1200.webp
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
site/assets/img/editorial-earring-1600.webp
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
site/assets/img/editorial-earring-480.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
site/assets/img/editorial-earring-768.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
site/assets/img/editorial-pose-1200.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
site/assets/img/editorial-pose-1600.webp
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
site/assets/img/editorial-pose-2000.webp
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
site/assets/img/editorial-pose-480.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
site/assets/img/editorial-pose-768.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
site/assets/img/event-dance-1200.webp
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
site/assets/img/event-dance-1600.webp
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
site/assets/img/event-dance-480.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
site/assets/img/event-dance-768.webp
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
site/assets/img/event-motion-1200.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
site/assets/img/event-motion-1600.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
site/assets/img/event-motion-2000.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
site/assets/img/event-motion-480.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
site/assets/img/event-motion-768.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
site/assets/img/event-speaker-1200.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
site/assets/img/event-speaker-1600.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
site/assets/img/event-speaker-2000.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
site/assets/img/event-speaker-480.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
site/assets/img/event-speaker-768.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
site/assets/img/event-venue-1200.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
site/assets/img/event-venue-1600.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
site/assets/img/event-venue-2000.webp
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
site/assets/img/event-venue-480.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
site/assets/img/event-venue-768.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
site/assets/img/favicon-16.png
Normal file
|
After Width: | Height: | Size: 763 B |
BIN
site/assets/img/favicon-32.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
site/assets/img/gear-cases-1200.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
site/assets/img/gear-cases-1600.webp
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
site/assets/img/gear-cases-2000.webp
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
site/assets/img/gear-cases-480.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
site/assets/img/gear-cases-768.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
site/assets/img/group-feier-1200.webp
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
site/assets/img/group-feier-1600.webp
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
site/assets/img/group-feier-480.webp
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
site/assets/img/group-feier-768.webp
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
site/assets/img/group-outdoor-1200.webp
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
site/assets/img/group-outdoor-1600.webp
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
site/assets/img/group-outdoor-2000.webp
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
site/assets/img/group-outdoor-480.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
site/assets/img/group-outdoor-768.webp
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
site/assets/img/group-posiert-1200.webp
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
site/assets/img/group-posiert-1600.webp
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
site/assets/img/group-posiert-2000.webp
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
site/assets/img/group-posiert-480.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
site/assets/img/group-posiert-768.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |