Compare commits
139 Commits
553e2f8898
...
worktree-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f144b025b0 | ||
|
|
b38b8c39b8 | ||
|
|
93da5914a8 | ||
|
|
53dba772ae | ||
|
|
e770b81ea5 | ||
|
|
50441f558f | ||
|
|
22589e4729 | ||
|
|
e124c68cf4 | ||
|
|
193a4d6ee6 | ||
|
|
3182da8d92 | ||
|
|
6839cf2a33 | ||
|
|
775b5c062e | ||
|
|
e31dac5c9c | ||
|
|
c2bd1b34f0 | ||
|
|
cfd49ff69e | ||
|
|
1f7b08b74f | ||
|
|
240b373f68 | ||
|
|
09a043431e | ||
|
|
9b21d6aee8 | ||
|
|
e4c8535f42 | ||
|
|
97a2dd8743 | ||
|
|
17d9328c62 | ||
|
|
e10090b9ef | ||
|
|
4f1594390e | ||
|
|
1f4e8a5958 | ||
|
|
d64139d9d1 | ||
|
|
2779502f3b | ||
|
|
9f1e2c9ff5 | ||
|
|
dd99c5dd74 | ||
|
|
b607677f30 | ||
|
|
20fe83d889 | ||
|
|
c7782d554f | ||
|
|
ea65611690 | ||
|
|
17b29edd14 | ||
|
|
3438260090 | ||
|
|
0bd00a3044 | ||
|
|
d301825e50 | ||
|
|
6193e28587 | ||
|
|
bfdf64975c | ||
|
|
ea800e5e2a | ||
|
|
cfff594732 | ||
|
|
0fa330a357 | ||
|
|
a6c85e3658 | ||
|
|
e0aca0f883 | ||
|
|
a77b0c1221 | ||
|
|
393a3c25fd | ||
|
|
8c7a2741b0 | ||
|
|
865c6ed796 | ||
|
|
14542b6e33 | ||
|
|
de7053644b | ||
|
|
f1e0b92f47 | ||
|
|
bead6f1811 | ||
|
|
7769dbc9f4 | ||
|
|
74ca5ee35f | ||
|
|
38973a014e | ||
|
|
fc8b4b164b | ||
|
|
eb63df2000 | ||
|
|
53bd574660 | ||
|
|
581ba01d8d | ||
|
|
9db42d6cc1 | ||
|
|
ab24786d2a | ||
|
|
1aca4c4a41 | ||
|
|
669eaa7c65 | ||
|
|
f15ea031d1 | ||
|
|
25a39fca9c | ||
|
|
e398133907 | ||
|
|
186535f8c9 | ||
|
|
de19d17b00 | ||
|
|
b2e31c3c1b | ||
|
|
9e23620072 | ||
|
|
af42113fca | ||
|
|
c779ec59f9 | ||
|
|
2023ea2931 | ||
|
|
59b18039ed | ||
|
|
96ea7e6815 | ||
|
|
dff81f7bfb | ||
|
|
a9c82ec481 | ||
|
|
97aa372094 | ||
|
|
e61409773e | ||
|
|
7713a03cd5 | ||
|
|
cea94ce260 | ||
|
|
45a992f5a8 | ||
|
|
bd57310bbf | ||
|
|
c2d092f435 | ||
|
|
e19bd60984 | ||
|
|
2aa0ff9e70 | ||
|
|
5dd74df293 | ||
|
|
7712180f3a | ||
|
|
c9a22945c8 | ||
|
|
9d84ebc4fe | ||
|
|
58b9204395 | ||
|
|
0d662f3a5e | ||
|
|
2e864e5b81 | ||
|
|
40d9713b79 | ||
|
|
68d07fe961 | ||
|
|
6145a25fe2 | ||
|
|
c43f45a472 | ||
|
|
134f1e2ae0 | ||
|
|
55ccd5f3c0 | ||
| 3658733003 | |||
| 0bb0a314ad | |||
| b194b565f6 | |||
|
|
6720a5aeb2 | ||
|
|
a7f60ebed8 | ||
|
|
25062be657 | ||
|
|
9662ff5f8c | ||
|
|
f5c7be932b | ||
|
|
dec0001bd1 | ||
|
|
f628ab6435 | ||
|
|
4c5ee96e36 | ||
|
|
53cf1837b2 | ||
|
|
d83ed7254d | ||
|
|
1ae4bfe325 | ||
|
|
c5139851b8 | ||
|
|
f9baf02b86 | ||
|
|
b67bd201b2 | ||
|
|
79735e23e0 | ||
|
|
df37113d38 | ||
|
|
c7d2eeb3f0 | ||
|
|
4e94d85d7e | ||
|
|
dec6b8139b | ||
|
|
7b7d0c92a8 | ||
|
|
448c3cdcdb | ||
|
|
7e52494880 | ||
|
|
1181b97f94 | ||
|
|
458968ded5 | ||
|
|
23515b8542 | ||
|
|
e4ac5f08e7 | ||
|
|
15ef079eff | ||
|
|
56c3e51657 | ||
|
|
2cc8b1174b | ||
|
|
1fc47888d5 | ||
|
|
d435b2b0e4 | ||
|
|
fed427dc4a | ||
|
|
cf78ab2f8e | ||
|
|
c8883d0e40 | ||
|
|
7154092547 | ||
|
|
ada3a3ccaf | ||
|
|
8cf3a2a726 |
@@ -414,7 +414,7 @@ Never Kafka for teams under 10 or <100k events/day. Never gRPC inside a monolith
|
||||
|
||||
| PR contains | Required doc update |
|
||||
|---|---|
|
||||
| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` |
|
||||
| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` — **except** framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing |
|
||||
| New `@ManyToMany` join table or FK | Both DB diagrams |
|
||||
| New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` |
|
||||
| New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` |
|
||||
|
||||
@@ -984,7 +984,7 @@ Mark with `@pytest.mark.asyncio` so pytest runs the coroutine. Without it, the t
|
||||
|
||||
| What changed in code | Doc(s) to update |
|
||||
|---|---|
|
||||
| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) |
|
||||
| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) — **except** framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing |
|
||||
| New `@ManyToMany` join table or FK relationship | Both DB diagrams above |
|
||||
| New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain |
|
||||
| New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain |
|
||||
|
||||
14
.env.example
14
.env.example
@@ -29,16 +29,17 @@ OCR_TRAINING_TOKEN=change-me-in-production
|
||||
# --- Observability ---
|
||||
# Optional stack — start with: docker compose -f docker-compose.observability.yml up -d
|
||||
# Requires the main stack to already be running (docker compose up -d creates archiv-net).
|
||||
# In production the stack is managed from /opt/familienarchiv/ (see docs/DEPLOYMENT.md §4).
|
||||
|
||||
# Ports for host access
|
||||
PORT_GRAFANA=3001
|
||||
PORT_GRAFANA=3003
|
||||
PORT_GLITCHTIP=3002
|
||||
PORT_PROMETHEUS=9090
|
||||
|
||||
# Grafana admin password — change this before exposing Grafana beyond localhost
|
||||
GRAFANA_ADMIN_PASSWORD=changeme
|
||||
|
||||
# GlitchTip domain — production: use https://grafana.raddatz.cloud (must match Caddy vhost)
|
||||
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
|
||||
GLITCHTIP_DOMAIN=http://localhost:3002
|
||||
|
||||
# GlitchTip secret key — Django SECRET_KEY equivalent, used to sign sessions and tokens.
|
||||
@@ -47,6 +48,15 @@ GLITCHTIP_DOMAIN=http://localhost:3002
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(50))"
|
||||
GLITCHTIP_SECRET_KEY=changeme-generate-a-real-secret
|
||||
|
||||
# PostgreSQL hostname for GlitchTip's db-init job and workers.
|
||||
# Override when only the staging stack is running (container name differs from archive-db).
|
||||
# Default (archive-db) is correct for production with the full stack up.
|
||||
POSTGRES_HOST=archive-db
|
||||
|
||||
# $$ escaping note: passwords in /opt/familienarchiv/.env that contain a literal '$' must
|
||||
# use '$$' so Docker Compose does not expand them as variable references.
|
||||
# Example: a password 'p@$$word' should be written as 'p@$$$$word' in the .env file.
|
||||
|
||||
# Error reporting DSNs — leave empty to disable the SDK (safe default).
|
||||
# SENTRY_DSN: backend (Spring Boot) — used by the GlitchTip/Sentry Java SDK
|
||||
SENTRY_DSN=
|
||||
|
||||
@@ -148,7 +148,10 @@ jobs:
|
||||
path: frontend/test-results/screenshots/
|
||||
|
||||
# ─── OCR Service Unit Tests ───────────────────────────────────────────────────
|
||||
# Only spell_check.py, test_confidence.py, test_sender_registry.py — no ML stack required.
|
||||
# Only stdlib/lightweight tests — no ML stack (PyTorch/Surya/Kraken) required.
|
||||
# test_tmpdir.py covers the TMPDIR env var and entrypoint mkdir behaviour (ADR-021).
|
||||
# test_tmpdir_is_inside_persistent_cache_volume is skipped in CI (TMPDIR not
|
||||
# set to /app/cache here); it runs inside the deployed Docker container.
|
||||
ocr-tests:
|
||||
name: OCR Service Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -160,11 +163,11 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install test dependencies
|
||||
run: pip install "pyspellchecker==0.9.0" pytest pytest-asyncio
|
||||
run: pip install "pyspellchecker==0.9.0" "fastapi==0.115.6" pytest pytest-asyncio
|
||||
working-directory: ocr-service
|
||||
|
||||
- name: Run OCR unit tests (no ML stack required)
|
||||
run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py -v
|
||||
run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py test_tmpdir.py -v
|
||||
working-directory: ocr-service
|
||||
|
||||
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
||||
@@ -194,7 +197,7 @@ jobs:
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
chmod +x mvnw
|
||||
./mvnw clean test
|
||||
./mvnw clean verify
|
||||
working-directory: backend
|
||||
|
||||
- name: Upload surefire reports
|
||||
@@ -276,6 +279,27 @@ jobs:
|
||||
echo "$dump" | grep -qE "\['add', 'familienarchiv-auth', 'polling'\]" \
|
||||
|| { echo "FAIL: familienarchiv-auth jail did not resolve to 'polling' backend"; exit 1; }
|
||||
|
||||
# ─── Semgrep Security Scan ───────────────────────────────────────────────────
|
||||
# Catches XXE-unprotected XML parser factories and similar patterns defined in
|
||||
# .semgrep/security.yml. Runs in parallel with backend-unit-tests for fast feedback.
|
||||
# Uses local rules only (no SEMGREP_APP_TOKEN / OIDC — act_runner does not support it).
|
||||
semgrep-scan:
|
||||
name: Semgrep Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Semgrep
|
||||
run: pip install semgrep==1.163.0
|
||||
|
||||
- name: Run security rules
|
||||
run: semgrep --config .semgrep/security.yml --error --metrics=off backend/src/
|
||||
|
||||
# ─── Compose Bucket-Bootstrap Idempotency ─────────────────────────────────────
|
||||
# docker-compose.prod.yml's create-buckets service runs on every
|
||||
# `docker compose up` (one-shot, no restart). Must be idempotent — a
|
||||
@@ -305,6 +329,7 @@ jobs:
|
||||
MAIL_PORT=1025
|
||||
APP_MAIL_FROM=noreply@local
|
||||
IMPORT_HOST_DIR=/tmp/dummy-import
|
||||
COMPOSE_NETWORK_NAME=test-idem-archiv-net
|
||||
EOF
|
||||
|
||||
- name: Bring up minio
|
||||
|
||||
@@ -78,12 +78,6 @@ jobs:
|
||||
APP_MAIL_FROM=noreply@staging.raddatz.cloud
|
||||
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
||||
POSTGRES_USER=archiv
|
||||
PORT_GRAFANA=3003
|
||||
PORT_GLITCHTIP=3002
|
||||
PORT_PROMETHEUS=9090
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
GLITCHTIP_DOMAIN=https://glitchtip.archiv.raddatz.cloud
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
EOF
|
||||
|
||||
@@ -131,12 +125,76 @@ jobs:
|
||||
--profile staging \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Start observability stack
|
||||
- name: Deploy observability configs
|
||||
# Copies the compose file and config tree from the workspace checkout
|
||||
# into /opt/familienarchiv/ — the permanent location that persists
|
||||
# between CI runs. Containers started in the next step bind-mount
|
||||
# from there, so a future workspace wipe cannot corrupt a running
|
||||
# config file.
|
||||
#
|
||||
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
||||
# Gitea is always the single source of truth for secret rotation.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
run: |
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-staging-db-1
|
||||
EOF
|
||||
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||
|
||||
- name: Validate observability compose config
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||
run: |
|
||||
docker compose \
|
||||
-f docker-compose.observability.yml \
|
||||
--env-file .env.staging \
|
||||
up -d --wait
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
|
||||
- name: Start observability stack
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between nightly runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
|
||||
- name: Reload Caddy
|
||||
# Apply any committed Caddyfile changes before smoke-testing the
|
||||
|
||||
@@ -76,12 +76,6 @@ jobs:
|
||||
APP_MAIL_FROM=noreply@raddatz.cloud
|
||||
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
|
||||
POSTGRES_USER=archiv
|
||||
PORT_GRAFANA=3003
|
||||
PORT_GLITCHTIP=3002
|
||||
PORT_PROMETHEUS=9090
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
GLITCHTIP_DOMAIN=https://glitchtip.archiv.raddatz.cloud
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
EOF
|
||||
|
||||
@@ -104,12 +98,74 @@ jobs:
|
||||
--env-file .env.production \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Start observability stack
|
||||
- name: Deploy observability configs
|
||||
# Mirrors the nightly approach: copies obs compose file and config tree
|
||||
# to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016),
|
||||
# then writes obs-secrets.env fresh from Gitea secrets.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
run: |
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-production-db-1
|
||||
EOF
|
||||
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||
|
||||
- name: Validate observability compose config
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
docker compose \
|
||||
-f docker-compose.observability.yml \
|
||||
--env-file .env.production \
|
||||
up -d --wait
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
|
||||
- name: Start observability stack
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
|
||||
- name: Reload Caddy
|
||||
# See nightly.yml — same rationale and mechanism: DooD job containers
|
||||
|
||||
54
.semgrep/security.yml
Normal file
54
.semgrep/security.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
# Semgrep security rules for Familienarchiv backend.
|
||||
# These rules catch the absence of XXE protection on XML parser factories.
|
||||
# CWE-611: Improper Restriction of XML External Entity Reference.
|
||||
# Run: semgrep --config .semgrep/security.yml --error backend/src/
|
||||
|
||||
rules:
|
||||
|
||||
# DocumentBuilderFactory without XXE hardening.
|
||||
# All call sites must call setFeature("…disallow-doctype-decl", true) before use.
|
||||
- id: dbf-xxe-default
|
||||
patterns:
|
||||
- pattern: $X = DocumentBuilderFactory.newInstance();
|
||||
- pattern-not-inside: |
|
||||
...
|
||||
$X.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
...
|
||||
message: >
|
||||
DocumentBuilderFactory without XXE protection (CWE-611).
|
||||
Call XxeSafeXmlParser.hardenedFactory() instead of DocumentBuilderFactory.newInstance().
|
||||
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
|
||||
languages: [java]
|
||||
severity: ERROR
|
||||
|
||||
# SAXParserFactory without XXE hardening.
|
||||
- id: sax-xxe-default
|
||||
patterns:
|
||||
- pattern: $X = SAXParserFactory.newInstance();
|
||||
- pattern-not-inside: |
|
||||
...
|
||||
$X.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
...
|
||||
message: >
|
||||
SAXParserFactory without XXE protection (CWE-611).
|
||||
Set disallow-doctype-decl=true, external-general-entities=false, external-parameter-entities=false,
|
||||
and load-external-dtd=false before use. Follow the pattern in XxeSafeXmlParser.hardenedFactory().
|
||||
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
|
||||
languages: [java]
|
||||
severity: ERROR
|
||||
|
||||
# XMLInputFactory without XXE hardening (StAX parser).
|
||||
- id: stax-xxe-default
|
||||
patterns:
|
||||
- pattern: $X = XMLInputFactory.newInstance();
|
||||
- pattern-not-inside: |
|
||||
...
|
||||
$X.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
|
||||
...
|
||||
message: >
|
||||
XMLInputFactory without XXE protection (CWE-611).
|
||||
Set IS_SUPPORTING_EXTERNAL_ENTITIES=false and SUPPORT_DTD=false before use.
|
||||
Follow the pattern in XxeSafeXmlParser.hardenedFactory().
|
||||
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
|
||||
languages: [java]
|
||||
severity: ERROR
|
||||
32
CLAUDE.md
32
CLAUDE.md
@@ -77,6 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
||||
```
|
||||
backend/src/main/java/org/raddatz/familienarchiv/
|
||||
├── audit/ Audit logging
|
||||
├── auth/ AuthService, AuthSessionController, LoginRequest (Spring Session JDBC)
|
||||
├── config/ Infrastructure config (Minio, Async, Web)
|
||||
├── dashboard/ Dashboard analytics + StatsController/StatsService
|
||||
├── document/ Document domain (entities, controller, service, repository, DTOs)
|
||||
@@ -93,7 +94,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── relationship/ PersonRelationship sub-domain
|
||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ Tag domain
|
||||
└── user/ User domain — AppUser, UserGroup, UserService, auth controllers
|
||||
└── user/ User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
### Layering Rules
|
||||
@@ -274,6 +275,35 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
||||
|
||||
→ See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
|
||||
|
||||
### Observability stack (separate compose file)
|
||||
|
||||
Run via `docker-compose.observability.yml` — requires the main stack to be running first. Full setup procedure: [docs/DEPLOYMENT.md §4](./docs/DEPLOYMENT.md#4-logs--observability).
|
||||
|
||||
| Service | Container | Default Port | Purpose |
|
||||
|---------|-----------|-------------|---------|
|
||||
| Grafana | `obs-grafana` | 3003 | Metrics / logs / traces dashboard |
|
||||
| Prometheus | `obs-prometheus` | 9090 (dev only — `127.0.0.1` bound) | Metrics store |
|
||||
| Loki | `obs-loki` | — (internal) | Log store |
|
||||
| Tempo | `obs-tempo` | — (internal) | Trace store |
|
||||
| GlitchTip | `obs-glitchtip` | 3002 | Error tracking (Sentry-compatible) |
|
||||
|
||||
### Observability env vars
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `PORT_GRAFANA` | Host port for Grafana UI (default: `3003`) |
|
||||
| `PORT_GLITCHTIP` | Host port for GlitchTip UI (default: `3002`) |
|
||||
| `PORT_PROMETHEUS` | Host port for Prometheus UI (default: `9090`) |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` login password — generate with `openssl rand -hex 32` |
|
||||
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` |
|
||||
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (email links, CORS), e.g. `https://glitchtip.example.com` |
|
||||
| `SENTRY_DSN` | GlitchTip/Sentry DSN for the backend (Spring Boot) — leave empty to disable |
|
||||
| `VITE_SENTRY_DSN` | GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite |
|
||||
|
||||
## Observability
|
||||
|
||||
→ See [docs/OBSERVABILITY.md](./docs/OBSERVABILITY.md) — where to look for logs, traces, metrics, and errors.
|
||||
|
||||
## API Testing
|
||||
|
||||
HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension.
|
||||
|
||||
@@ -24,6 +24,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
|
||||
```
|
||||
src/main/java/org/raddatz/familienarchiv/
|
||||
├── audit/ # Audit logging (AuditService, AuditLogQueryService)
|
||||
├── auth/ # AuthService, AuthSessionController, LoginRequest (Spring Session JDBC — ADR-020)
|
||||
├── config/ # Infrastructure config (MinioConfig, AsyncConfig, WebConfig)
|
||||
├── dashboard/ # Dashboard analytics + StatsController/StatsService
|
||||
├── document/ # Document domain — entities, controller, service, repository, DTOs
|
||||
@@ -40,7 +41,7 @@ src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── relationship/ # PersonRelationship sub-domain
|
||||
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ # Tag domain — Tag, TagService, TagController
|
||||
└── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
|
||||
└── user/ # User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
For per-domain ownership and public surface, see each domain's `README.md`.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.0</version>
|
||||
<version>4.0.6</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>org.raddatz</groupId>
|
||||
@@ -29,11 +29,30 @@
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- opentelemetry-spring-boot-starter:2.27.0 was built against opentelemetry-api:1.61.0,
|
||||
but Spring Boot 4.0.0 BOM only manages 1.55.0 (missing GlobalOpenTelemetry.getOrNoop()).
|
||||
Import the core OTel BOM here to override it before the Spring Boot BOM applies. -->
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-bom</artifactId>
|
||||
<version>1.61.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Boot 4.0 splits Micrometer metrics export (incl. Prometheus scrape endpoint) into its own starter -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-micrometer-metrics</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
@@ -50,6 +69,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-session-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
@@ -188,7 +211,7 @@
|
||||
<dependency>
|
||||
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
|
||||
<artifactId>owasp-java-html-sanitizer</artifactId>
|
||||
<version>20240325.1</version>
|
||||
<version>20260101.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- HTML → plain-text extraction for comment previews -->
|
||||
@@ -278,7 +301,7 @@
|
||||
<phase>verify</phase>
|
||||
<goals><goal>report</goal></goals>
|
||||
</execution>
|
||||
<!-- Gate: baseline 89.4% overall / service 90.2% / controller 80.0% -->
|
||||
<!-- Gate: ratchet at 0.77 — actual measured coverage after drift; raise via #496 -->
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<phase>verify</phase>
|
||||
@@ -291,7 +314,7 @@
|
||||
<limit>
|
||||
<counter>BRANCH</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.88</minimum>
|
||||
<minimum>0.77</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
|
||||
@@ -35,7 +35,16 @@ public enum AuditKind {
|
||||
USER_DELETED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
||||
GROUP_MEMBERSHIP_CHANGED;
|
||||
GROUP_MEMBERSHIP_CHANGED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
|
||||
LOGIN_SUCCESS,
|
||||
|
||||
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
|
||||
LOGIN_FAILED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
|
||||
LOGOUT;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthService {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final UserService userService;
|
||||
private final AuditService auditService;
|
||||
|
||||
/**
|
||||
* Validates credentials and returns the authenticated user plus the Spring Security
|
||||
* Authentication object. The caller is responsible for persisting the Authentication
|
||||
* to the session via SecurityContextRepository.
|
||||
*/
|
||||
public LoginResult login(String email, String password, String ip, String ua) {
|
||||
try {
|
||||
Authentication auth = authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(email, password));
|
||||
|
||||
AppUser user = userService.findByEmail(email);
|
||||
auditService.log(AuditKind.LOGIN_SUCCESS, user.getId(), null, Map.of(
|
||||
"userId", user.getId().toString(),
|
||||
"ip", ip,
|
||||
"ua", truncateUa(ua)));
|
||||
return new LoginResult(user, auth);
|
||||
} catch (AuthenticationException ex) {
|
||||
// Audit login failure — intentionally does NOT log the attempted password.
|
||||
// DaoAuthenticationProvider already runs a dummy BCrypt on unknown users to
|
||||
// equalise timing between "user not found" and "wrong password" paths.
|
||||
auditService.log(AuditKind.LOGIN_FAILED, null, null, Map.of(
|
||||
"email", email,
|
||||
"ip", ip,
|
||||
"ua", truncateUa(ua)));
|
||||
throw DomainException.invalidCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
public void logout(String email, String ip, String ua) {
|
||||
AppUser user = userService.findByEmail(email);
|
||||
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||
"userId", user.getId().toString(),
|
||||
"ip", ip,
|
||||
"ua", truncateUa(ua)));
|
||||
}
|
||||
|
||||
private static String truncateUa(String ua) {
|
||||
if (ua == null) return "";
|
||||
return ua.length() > 200 ? ua.substring(0, 200) : ua;
|
||||
}
|
||||
|
||||
public record LoginResult(AppUser user, Authentication authentication) {}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
// @RequirePermission is intentionally absent: login is unauthenticated by design;
|
||||
// logout requires an authenticated session (enforced by SecurityConfig), not a specific permission.
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthSessionController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final SessionAuthenticationStrategy sessionAuthenticationStrategy;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<AppUser> login(
|
||||
@RequestBody LoginRequest request,
|
||||
HttpServletRequest httpRequest,
|
||||
HttpServletResponse httpResponse) {
|
||||
|
||||
String ip = resolveClientIp(httpRequest);
|
||||
String ua = resolveUserAgent(httpRequest);
|
||||
|
||||
AuthService.LoginResult result = authService.login(request.email(), request.password(), ip, ua);
|
||||
|
||||
// Session-fixation defense (CWE-384): rotate the session ID at the authentication
|
||||
// boundary. ChangeSessionIdAuthenticationStrategy invalidates any pre-auth session ID
|
||||
// an attacker may have planted and mints a fresh one before we attach the SecurityContext.
|
||||
httpRequest.getSession(true);
|
||||
sessionAuthenticationStrategy.onAuthentication(result.authentication(), httpRequest, httpResponse);
|
||||
|
||||
// Spring Session JDBC intercepts setAttribute() and persists the record under the
|
||||
// (now rotated) opaque ID; the Set-Cookie: fa_session=<opaque-id> is added automatically.
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(result.authentication());
|
||||
SecurityContextHolder.setContext(context);
|
||||
httpRequest.getSession()
|
||||
.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||
|
||||
return ResponseEntity.ok(result.user());
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(Authentication authentication, HttpServletRequest httpRequest) {
|
||||
String email = authentication.getName();
|
||||
String ip = resolveClientIp(httpRequest);
|
||||
String ua = resolveUserAgent(httpRequest);
|
||||
|
||||
// CWE-613 defense: invalidate the session first — that is the contract the user
|
||||
// is relying on when they click "Log out." Audit is best-effort and must not
|
||||
// bubble up: if the user record was deleted while the session was live, the
|
||||
// audit lookup throws, but the session row in spring_session must still die.
|
||||
HttpSession session = httpRequest.getSession(false);
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
try {
|
||||
authService.logout(email, ip, ua);
|
||||
} catch (Exception ex) {
|
||||
log.warn("Audit logout failed for {}; session was already invalidated", email, ex);
|
||||
}
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the client IP for audit-log purposes.
|
||||
*
|
||||
* <p>Trust model: the leftmost {@code X-Forwarded-For} value is taken at face value.
|
||||
* This is correct <em>only</em> if the ingress (Caddy in production) strips any
|
||||
* client-supplied XFF before forwarding — otherwise an attacker can pin audit-log
|
||||
* IPs to whatever they want. Verify the reverse-proxy config before exposing this
|
||||
* service behind a different ingress.
|
||||
*/
|
||||
private static String resolveClientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
private static String resolveUserAgent(HttpServletRequest request) {
|
||||
String ua = request.getHeader("User-Agent");
|
||||
return ua != null ? ua : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
public record LoginRequest(String email, String password) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.session.web.http.CookieSerializer;
|
||||
import org.springframework.session.web.http.DefaultCookieSerializer;
|
||||
|
||||
@Configuration
|
||||
public class SpringSessionConfig {
|
||||
|
||||
@Bean
|
||||
public CookieSerializer cookieSerializer() {
|
||||
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
|
||||
serializer.setCookieName("fa_session");
|
||||
serializer.setSameSite("Strict");
|
||||
// cookieHttpOnly: true is the DefaultCookieSerializer default
|
||||
// useSecureCookie not set: auto-detects from request.isSecure().
|
||||
// With forward-headers-strategy: native, Caddy's X-Forwarded-Proto: https
|
||||
// causes isSecure() → true in production; direct HTTP in dev/tests → false.
|
||||
return serializer;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ public class DomainException extends RuntimeException {
|
||||
return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message);
|
||||
}
|
||||
|
||||
public static DomainException invalidCredentials() {
|
||||
return new DomainException(ErrorCode.INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED,
|
||||
"Invalid email or password");
|
||||
}
|
||||
|
||||
public static DomainException conflict(ErrorCode code, String message) {
|
||||
return new DomainException(code, HttpStatus.CONFLICT, message);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ public enum ErrorCode {
|
||||
UNAUTHORIZED,
|
||||
/** The authenticated user lacks the required permission. 403 */
|
||||
FORBIDDEN,
|
||||
/** The supplied email/password combination does not match any active account. 401 */
|
||||
INVALID_CREDENTIALS,
|
||||
/** The session has expired or been invalidated. 401 */
|
||||
SESSION_EXPIRED,
|
||||
/** The password-reset token is missing, expired, or already used. 400 */
|
||||
INVALID_RESET_TOKEN,
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
@@ -31,6 +33,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -53,9 +56,27 @@ public class MassImportService {
|
||||
|
||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||
|
||||
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
|
||||
public record SkippedFile(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
|
||||
) {}
|
||||
|
||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
public record ImportStatus(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
|
||||
@JsonIgnore String message,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<SkippedFile> skippedFiles,
|
||||
LocalDateTime startedAt
|
||||
) {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@JsonProperty("skipped")
|
||||
public int skipped() { return skippedFiles.size(); }
|
||||
}
|
||||
|
||||
record ProcessResult(int processed, List<SkippedFile> skippedFiles) {}
|
||||
|
||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||
|
||||
public ImportStatus getStatus() {
|
||||
return currentStatus;
|
||||
@@ -117,22 +138,22 @@ public class MassImportService {
|
||||
if (currentStatus.state() == State.RUNNING) {
|
||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||
}
|
||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
|
||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now());
|
||||
try {
|
||||
File spreadsheet = findSpreadsheetFile();
|
||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
||||
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
|
||||
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
||||
processed, currentStatus.startedAt());
|
||||
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
||||
result.processed(), result.skippedFiles(), currentStatus.startedAt());
|
||||
} catch (NoSpreadsheetException e) {
|
||||
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||
} catch (Exception e) {
|
||||
log.error("Massenimport fehlgeschlagen", e);
|
||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,14 +189,14 @@ public class MassImportService {
|
||||
* Reads an ODS file by parsing its content.xml directly (no extra library needed).
|
||||
* ODS is a ZIP archive; content.xml holds the spreadsheet data as XML.
|
||||
*/
|
||||
private List<List<String>> readOds(File file) throws Exception {
|
||||
List<List<String>> readOds(File file) throws Exception {
|
||||
List<List<String>> result = new ArrayList<>();
|
||||
|
||||
try (ZipFile zip = new ZipFile(file)) {
|
||||
var entry = zip.getEntry("content.xml");
|
||||
if (entry == null) throw new RuntimeException("Ungültige ODS-Datei: content.xml fehlt");
|
||||
|
||||
var factory = DocumentBuilderFactory.newInstance();
|
||||
var factory = XxeSafeXmlParser.hardenedFactory();
|
||||
factory.setNamespaceAware(true);
|
||||
var builder = factory.newDocumentBuilder();
|
||||
var doc = builder.parse(zip.getInputStream(entry));
|
||||
@@ -254,8 +275,10 @@ public class MassImportService {
|
||||
|
||||
// --- Import logic (works on neutral List<String> rows) ---
|
||||
|
||||
private int processRows(List<List<String>> rows) {
|
||||
int count = 0;
|
||||
private ProcessResult processRows(List<List<String>> rows) {
|
||||
int processed = 0;
|
||||
List<SkippedFile> skippedFiles = new ArrayList<>();
|
||||
|
||||
for (int i = 1; i < rows.size(); i++) { // skip header row
|
||||
List<String> cells = rows.get(i);
|
||||
String index = getCell(cells, colIndex);
|
||||
@@ -266,18 +289,51 @@ public class MassImportService {
|
||||
if (fileOnDisk.isEmpty()) {
|
||||
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
||||
}
|
||||
importSingleDocument(cells, fileOnDisk, filename, index);
|
||||
count++;
|
||||
|
||||
if (fileOnDisk.isPresent()) {
|
||||
try {
|
||||
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
||||
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
||||
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
|
||||
continue;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
||||
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
boolean imported = importSingleDocument(cells, fileOnDisk, filename, index);
|
||||
if (imported) {
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
return new ProcessResult(processed, skippedFiles);
|
||||
}
|
||||
|
||||
// package-private: Mockito spy in tests can override to inject IOException
|
||||
InputStream openFileStream(File file) throws IOException {
|
||||
return new FileInputStream(file);
|
||||
}
|
||||
|
||||
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||
try (InputStream is = openFileStream(file)) {
|
||||
byte[] header = is.readNBytes(4);
|
||||
return header.length == 4
|
||||
&& header[0] == 0x25 // %
|
||||
&& header[1] == 0x50 // P
|
||||
&& header[2] == 0x44 // D
|
||||
&& header[3] == 0x46; // F
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||
protected boolean importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
||||
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
String archiveBox = getCell(cells, colBox);
|
||||
@@ -313,7 +369,7 @@ public class MassImportService {
|
||||
status = DocumentStatus.UPLOADED;
|
||||
} catch (Exception e) {
|
||||
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +411,7 @@ public class MassImportService {
|
||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||
}
|
||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
|
||||
class XxeSafeXmlParser {
|
||||
|
||||
private XxeSafeXmlParser() {}
|
||||
|
||||
static DocumentBuilderFactory hardenedFactory() throws ParserConfigurationException {
|
||||
var factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package org.raddatz.familienarchiv.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
|
||||
/**
|
||||
* Promotes the {@code auth_token} cookie to an {@code Authorization} header
|
||||
* so that browser-side requests to {@code /api/*} authenticate the same way
|
||||
* SSR fetches do.
|
||||
*
|
||||
* <p>The SvelteKit login action stores the full HTTP Basic header value
|
||||
* ({@code "Basic <base64>"}) in an HttpOnly cookie. SSR fetches from
|
||||
* {@code hooks.server.ts} read the cookie and pass it explicitly as the
|
||||
* {@code Authorization} header. In the dev environment, Vite's proxy does
|
||||
* the same on every {@code /api/*} request (see {@code vite.config.ts}).
|
||||
* In production, Caddy proxies {@code /api/*} straight to the backend and
|
||||
* does NOT translate the cookie — so client-side {@code fetch} and
|
||||
* {@code EventSource} calls reach the backend without auth, get
|
||||
* {@code 401 WWW-Authenticate: Basic}, and the browser pops a native dialog.
|
||||
*
|
||||
* <p>This filter closes that gap: if a request has an {@code auth_token}
|
||||
* cookie but no explicit {@code Authorization} header, promote the cookie
|
||||
* value (URL-decoded) into the header before Spring Security inspects it.
|
||||
* Explicit {@code Authorization} headers are preserved unchanged.
|
||||
*
|
||||
* <p>See #520. Filter runs at {@code Ordered.HIGHEST_PRECEDENCE} so it
|
||||
* mutates the request before any Spring Security filter sees it.
|
||||
*
|
||||
* <p><b>Scope:</b> only {@code /api/*} requests are touched. The
|
||||
* {@code /actuator/*} block in Caddy plus the open auth/reset paths in
|
||||
* {@link SecurityConfig} must NOT receive a promoted Authorization.
|
||||
*
|
||||
* <p><b>⚠ Log-leakage warning:</b> the wrapped request exposes the
|
||||
* Authorization header via {@code getHeaderNames}/{@code getHeaders}. Any
|
||||
* filter or interceptor that iterates request headers will see the live
|
||||
* Basic credential. Do NOT add a request-header logger downstream of this
|
||||
* filter without explicitly scrubbing the {@code Authorization} field.
|
||||
*/
|
||||
@Component
|
||||
@Order(org.springframework.core.Ordered.HIGHEST_PRECEDENCE)
|
||||
public class AuthTokenCookieFilter extends OncePerRequestFilter {
|
||||
|
||||
static final String COOKIE_NAME = "auth_token";
|
||||
static final String SCOPE_PREFIX = "/api/";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
// Scope: only /api/* needs cookie promotion. /actuator/health (open),
|
||||
// /api/auth/forgot-password (open), /login etc. don't.
|
||||
if (!request.getRequestURI().startsWith(SCOPE_PREFIX)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
// An explicit Authorization header wins — this is the SSR fetch path
|
||||
// (hooks.server.ts builds the header itself).
|
||||
if (request.getHeader(HttpHeaders.AUTHORIZATION) != null) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
for (Cookie c : cookies) {
|
||||
if (COOKIE_NAME.equals(c.getName()) && c.getValue() != null && !c.getValue().isBlank()) {
|
||||
String decoded;
|
||||
try {
|
||||
decoded = URLDecoder.decode(c.getValue(), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
// Malformed percent-encoding — refuse to forward a bogus
|
||||
// Authorization header. Spring Security will treat the
|
||||
// request as unauthenticated.
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
chain.doFilter(new AuthHeaderRequest(request, decoded), response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds (or overrides) the {@code Authorization} header on a wrapped request.
|
||||
* All other headers pass through unchanged.
|
||||
*/
|
||||
static final class AuthHeaderRequest extends HttpServletRequestWrapper {
|
||||
private final String authorization;
|
||||
|
||||
AuthHeaderRequest(HttpServletRequest request, String authorization) {
|
||||
super(request);
|
||||
this.authorization = authorization;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHeader(String name) {
|
||||
if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) {
|
||||
return authorization;
|
||||
}
|
||||
return super.getHeader(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> getHeaders(String name) {
|
||||
if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) {
|
||||
return Collections.enumeration(Collections.singletonList(authorization));
|
||||
}
|
||||
return super.getHeaders(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> getHeaderNames() {
|
||||
Enumeration<String> base = super.getHeaderNames();
|
||||
java.util.Set<String> names = new java.util.LinkedHashSet<>();
|
||||
while (base.hasMoreElements()) names.add(base.nextElement());
|
||||
names.add(HttpHeaders.AUTHORIZATION);
|
||||
return Collections.enumeration(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,22 @@ package org.raddatz.familienarchiv.security;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -34,28 +40,59 @@ public class SecurityConfig {
|
||||
return authProvider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
|
||||
// ChangeSessionIdAuthenticationStrategy rotates the session ID via the Servlet 3.1+
|
||||
// HttpServletRequest.changeSessionId() — preserves attributes, mints a fresh ID.
|
||||
// Used by AuthSessionController.login to defend against session fixation (CWE-384).
|
||||
return new ChangeSessionIdAuthenticationStrategy();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/actuator/**")
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Health and Prometheus are open — Docker health checks and Prometheus scraping need no credentials.
|
||||
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
|
||||
// All other actuator endpoints (metrics, info, env, heapdump…) require authentication.
|
||||
auth.anyRequest().authenticated();
|
||||
})
|
||||
// Explicitly return 401 for any unauthenticated actuator request.
|
||||
// Without this override, Spring Security's DelegatingAuthenticationEntryPoint
|
||||
// would redirect browser-like clients to the form-login page (302 → /login),
|
||||
// making it impossible to distinguish "not authenticated" from "not found" in tests.
|
||||
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
||||
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.csrf(AbstractHttpConfigurer::disable);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF is intentionally disabled. With the cookie-promotion model
|
||||
// (auth_token cookie → Authorization header via AuthTokenCookieFilter,
|
||||
// see #520), every authenticated request to /api/* now carries the
|
||||
// credential automatically once the cookie is set. The CSRF defence
|
||||
// for state-changing endpoints is therefore LOAD-BEARING on:
|
||||
// CSRF is intentionally disabled. The session model relies on:
|
||||
// 1. SameSite=Strict on the fa_session cookie — a cross-site POST from
|
||||
// evil.com cannot include the cookie.
|
||||
// 2. CORS — Spring's default rejects cross-origin requests with credentials
|
||||
// unless explicitly allowed (no allowedOrigins config).
|
||||
//
|
||||
// 1. SameSite=strict on the auth_token cookie (login/+page.server.ts).
|
||||
// A cross-site POST from evil.com cannot include the cookie.
|
||||
// 2. CORS — Spring's default rejects cross-origin requests with
|
||||
// credentials unless explicitly allowed (no allowedOrigins config).
|
||||
//
|
||||
// If either of those is ever weakened (e.g. cookie flipped to
|
||||
// SameSite=lax, CORS allowedOrigins expanded), CSRF protection
|
||||
// MUST be re-enabled here.
|
||||
// If either of those is ever weakened, CSRF protection MUST be re-enabled.
|
||||
// Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524).
|
||||
.csrf(csrf -> csrf.disable())
|
||||
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Health endpoint must be open so CI/Docker health checks work without credentials
|
||||
auth.requestMatchers("/actuator/health").permitAll();
|
||||
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
||||
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
|
||||
// Login is unauthenticated by definition
|
||||
auth.requestMatchers("/api/auth/login").permitAll();
|
||||
// Password reset endpoints are unauthenticated by nature
|
||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||
// Invite-based registration endpoints are public
|
||||
@@ -75,9 +112,10 @@ public class SecurityConfig {
|
||||
// erlaubt pdf im Iframe
|
||||
.headers(headers -> headers
|
||||
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
||||
// Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.formLogin(form -> form.usernameParameter("email"));
|
||||
// Return 401 (not 302 redirect to /login) for unauthenticated API requests.
|
||||
// httpBasic and formLogin are removed — authentication is via Spring Session only.
|
||||
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
||||
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
spring:
|
||||
jpa:
|
||||
show-sql: true
|
||||
# spring.session.cookie.secure is no longer a supported Boot 4.x property.
|
||||
# DefaultCookieSerializer auto-detects Secure from request.isSecure().
|
||||
# Direct HTTP in dev → isSecure()=false → cookie sent without Secure attribute.
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
|
||||
@@ -38,6 +38,13 @@ spring:
|
||||
starttls:
|
||||
enable: true
|
||||
|
||||
session:
|
||||
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
|
||||
jdbc:
|
||||
initialize-schema: never # Flyway owns schema creation (V67)
|
||||
# Cookie name, SameSite, and Secure are configured via SpringSessionConfig#cookieSerializer
|
||||
# (spring.session.cookie.* is not supported in Spring Boot 4.x).
|
||||
|
||||
server:
|
||||
# Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that
|
||||
# request.getScheme(), redirect URLs, and Spring Session "Secure" cookies
|
||||
@@ -49,7 +56,8 @@ management:
|
||||
# Management port is separate from the app port so that:
|
||||
# (a) Caddy never proxies /actuator/* (it only routes :8080 → the app port)
|
||||
# (b) Prometheus scrapes backend:8081 directly inside archiv-net, not via Caddy
|
||||
# (c) Spring Security's session-authenticated filter chain on :8080 never sees actuator requests
|
||||
# Note: in Spring Boot 4.0 the management port shares the security filter chain; /actuator/health
|
||||
# and /actuator/prometheus must be explicitly permitted in SecurityConfig — see SecurityConfig.java.
|
||||
port: 8081
|
||||
endpoints:
|
||||
web:
|
||||
@@ -58,6 +66,16 @@ management:
|
||||
endpoint:
|
||||
prometheus:
|
||||
enabled: true
|
||||
# Spring Boot 4.0: metrics export is disabled by default — explicitly opt in for Prometheus
|
||||
prometheus:
|
||||
metrics:
|
||||
export:
|
||||
enabled: true
|
||||
metrics:
|
||||
tags:
|
||||
# Common tag applied to every metric so Grafana's Spring Boot dashboard can filter by application name.
|
||||
# Override via MANAGEMENT_METRICS_TAGS_APPLICATION env var.
|
||||
application: ${spring.application.name}
|
||||
health:
|
||||
mail:
|
||||
enabled: false
|
||||
@@ -66,13 +84,18 @@ management:
|
||||
probability: 1.0 # 100% in dev; override via MANAGEMENT_TRACING_SAMPLING_PROBABILITY in prod compose
|
||||
|
||||
# OpenTelemetry trace export — failures are non-fatal (app starts cleanly without Tempo running)
|
||||
# The default http://localhost:4317 ensures CI compatibility when no observability stack is present.
|
||||
# Port 4318 = OTLP HTTP (the default transport for Spring Boot's HttpExporter).
|
||||
# Port 4317 is gRPC-only; sending HTTP/1.1 to it produces "Connection reset".
|
||||
otel:
|
||||
service:
|
||||
name: familienarchiv-backend
|
||||
exporter:
|
||||
otlp:
|
||||
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
|
||||
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}
|
||||
logs:
|
||||
exporter: none # Promtail captures Docker logs; disable OTLP log export (Tempo only accepts traces)
|
||||
metrics:
|
||||
exporter: none # Prometheus scrapes /actuator/prometheus; disable OTLP metric export to Tempo
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Re-introduces the Spring Session JDBC tables that were dropped by V2 as unused.
|
||||
-- DDL copied verbatim from Spring Session 3.x schema-postgresql.sql.
|
||||
-- See ADR-020 and issue #523.
|
||||
|
||||
CREATE TABLE spring_session (
|
||||
PRIMARY_ID CHAR(36) NOT NULL,
|
||||
SESSION_ID CHAR(36) NOT NULL,
|
||||
CREATION_TIME BIGINT NOT NULL,
|
||||
LAST_ACCESS_TIME BIGINT NOT NULL,
|
||||
MAX_INACTIVE_INTERVAL INT NOT NULL,
|
||||
EXPIRY_TIME BIGINT NOT NULL,
|
||||
PRINCIPAL_NAME VARCHAR(100),
|
||||
CONSTRAINT spring_session_pk PRIMARY KEY (PRIMARY_ID)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX spring_session_ix1 ON spring_session (SESSION_ID);
|
||||
CREATE INDEX spring_session_ix2 ON spring_session (EXPIRY_TIME);
|
||||
CREATE INDEX spring_session_ix3 ON spring_session (PRINCIPAL_NAME);
|
||||
|
||||
CREATE TABLE spring_session_attributes (
|
||||
SESSION_PRIMARY_ID CHAR(36) NOT NULL,
|
||||
ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
|
||||
ATTRIBUTE_BYTES BYTEA NOT NULL,
|
||||
CONSTRAINT spring_session_attributes_pk PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
|
||||
CONSTRAINT spring_session_attributes_fk FOREIGN KEY (SESSION_PRIMARY_ID)
|
||||
REFERENCES spring_session (PRIMARY_ID) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.raddatz.familienarchiv;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalManagementPort;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class ActuatorPrometheusIT {
|
||||
|
||||
@LocalManagementPort
|
||||
private int managementPort;
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Test
|
||||
void prometheus_endpoint_returns_200_without_credentials() {
|
||||
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||
"http://localhost:" + managementPort + "/actuator/prometheus", String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void prometheus_endpoint_returns_jvm_metrics() {
|
||||
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||
"http://localhost:" + managementPort + "/actuator/prometheus", String.class);
|
||||
|
||||
assertThat(response.getBody()).contains("jvm_memory_used_bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void actuator_metrics_requires_authentication() {
|
||||
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||
"http://localhost:" + managementPort + "/actuator/metrics", String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
private RestTemplate noThrowTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws IOException {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.raddatz.familienarchiv;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalManagementPort;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class ActuatorSecurityTest {
|
||||
|
||||
@LocalManagementPort
|
||||
private int managementPort;
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Test
|
||||
void actuator_health_is_accessible_without_authentication() {
|
||||
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||
"http://localhost:" + managementPort + "/actuator/health", String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void actuator_env_requires_authentication() {
|
||||
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||
"http://localhost:" + managementPort + "/actuator/env", String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
private RestTemplate noThrowTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws IOException {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthServiceTest {
|
||||
|
||||
@Mock AuthenticationManager authenticationManager;
|
||||
@Mock UserService userService;
|
||||
@Mock AuditService auditService;
|
||||
@InjectMocks AuthService authService;
|
||||
|
||||
private static final String IP = "127.0.0.1";
|
||||
private static final String UA = "Mozilla/5.0 (Test)";
|
||||
|
||||
@Test
|
||||
void login_returns_user_on_valid_credentials() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||
|
||||
AuthService.LoginResult result = authService.login("user@test.de", "pass123", IP, UA);
|
||||
|
||||
assertThat(result.user()).isEqualTo(user);
|
||||
assertThat(result.authentication()).isEqualTo(auth);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_fires_LOGIN_SUCCESS_audit_on_valid_credentials() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||
|
||||
authService.login("user@test.de", "pass123", IP, UA);
|
||||
|
||||
verify(auditService).log(
|
||||
eq(AuditKind.LOGIN_SUCCESS),
|
||||
eq(userId),
|
||||
isNull(),
|
||||
argThat(payload -> userId.toString().equals(payload.get("userId").toString())
|
||||
&& IP.equals(payload.get("ip"))
|
||||
&& !payload.containsKey("password"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_throws_INVALID_CREDENTIALS_on_bad_password() {
|
||||
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad"));
|
||||
|
||||
assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.INVALID_CREDENTIALS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_fires_LOGIN_FAILED_audit_on_bad_credentials_without_password_in_payload() {
|
||||
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad"));
|
||||
|
||||
assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
verify(auditService).log(
|
||||
eq(AuditKind.LOGIN_FAILED),
|
||||
isNull(),
|
||||
isNull(),
|
||||
argThat(payload -> "user@test.de".equals(payload.get("email"))
|
||||
&& IP.equals(payload.get("ip"))
|
||||
&& !payload.containsKey("password")
|
||||
&& !payload.containsKey("pwd")
|
||||
&& !payload.containsKey("passwordAttempt"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_treats_unknown_user_identically_to_bad_password() {
|
||||
when(authenticationManager.authenticate(any()))
|
||||
.thenThrow(new BadCredentialsException("unknown user hidden as bad creds"));
|
||||
|
||||
assertThatThrownBy(() -> authService.login("unknown@test.de", "any", IP, UA))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.INVALID_CREDENTIALS));
|
||||
|
||||
verify(auditService).log(eq(AuditKind.LOGIN_FAILED), isNull(), isNull(), anyMap());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_fires_LOGOUT_audit() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||
|
||||
authService.logout("user@test.de", IP, UA);
|
||||
|
||||
verify(auditService).log(
|
||||
eq(AuditKind.LOGOUT),
|
||||
eq(userId),
|
||||
isNull(),
|
||||
argThat(payload -> userId.toString().equals(payload.get("userId").toString())
|
||||
&& IP.equals(payload.get("ip"))
|
||||
&& !payload.containsKey("password"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.auth.AuthService.LoginResult;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(AuthSessionController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class AuthSessionControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean AuthService authService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
@MockitoBean SessionAuthenticationStrategy sessionAuthenticationStrategy;
|
||||
|
||||
// ─── POST /api/auth/login ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void login_returns_200_with_user_on_valid_credentials() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser appUser = AppUser.builder().id(userId).email("user@test.de").build();
|
||||
Authentication auth = mock(Authentication.class);
|
||||
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.email").value("user@test.de"))
|
||||
.andExpect(jsonPath("$.id").value(userId.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_returns_401_with_INVALID_CREDENTIALS_on_bad_credentials() throws Exception {
|
||||
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||
.thenThrow(DomainException.invalidCredentials());
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.INVALID_CREDENTIALS.name()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_is_public_no_session_required() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser appUser = AppUser.builder().id(userId).email("pub@test.de").build();
|
||||
Authentication auth = mock(Authentication.class);
|
||||
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
// No WithMockUser — must be reachable without an active session
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser appUser = AppUser.builder().id(userId).email("fix@test.de").build();
|
||||
Authentication auth = mock(Authentication.class);
|
||||
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Session-fixation defense (CWE-384): the controller must hand the new
|
||||
// Authentication to Spring Security's strategy, which rotates the session ID.
|
||||
verify(sessionAuthenticationStrategy).onAuthentication(eq(auth), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_response_body_does_not_contain_password_field() throws Exception {
|
||||
// Regression guard: AppUser.password is @JsonProperty(WRITE_ONLY). If anyone
|
||||
// ever drops that annotation, this assertion catches the credential leak on
|
||||
// the very next CI run.
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser appUser = AppUser.builder()
|
||||
.id(userId)
|
||||
.email("leak@test.de")
|
||||
.password("$2a$10$shouldnotappearinresponse")
|
||||
.build();
|
||||
Authentication auth = mock(Authentication.class);
|
||||
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||
.thenReturn(new LoginResult(appUser, auth));
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.password").doesNotExist())
|
||||
.andExpect(jsonPath("$.pwd").doesNotExist())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||
org.hamcrest.Matchers.containsString("$2a$10$shouldnotappearinresponse"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_does_not_set_cookie_on_failure() throws Exception {
|
||||
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||
.thenThrow(DomainException.invalidCredentials());
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().doesNotExist("Set-Cookie"));
|
||||
}
|
||||
|
||||
// ─── POST /api/auth/logout ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void logout_returns_204_when_authenticated() throws Exception {
|
||||
doNothing().when(authService).logout(anyString(), anyString(), anyString());
|
||||
|
||||
mockMvc.perform(post("/api/auth/logout")
|
||||
.with(user("user@test.de")))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_returns_401_when_not_authenticated() throws Exception {
|
||||
// No authentication at all — Spring Security must return 401
|
||||
mockMvc.perform(post("/api/auth/logout"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_returns_204_even_when_audit_throws() throws Exception {
|
||||
// CWE-613 defense: the session MUST be invalidated even if the audit lookup
|
||||
// explodes (e.g. user deleted between login and logout). Audit is best-effort.
|
||||
doThrow(new RuntimeException("audit DB down"))
|
||||
.when(authService).logout(anyString(), anyString(), anyString());
|
||||
|
||||
mockMvc.perform(post("/api/auth/logout")
|
||||
.with(user("ghost@test.de")))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.raddatz.familienarchiv.auth;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class AuthSessionIntegrationTest {
|
||||
|
||||
@LocalServerPort int port;
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired AppUserRepository userRepository;
|
||||
@Autowired PasswordEncoder passwordEncoder;
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
private RestTemplate http;
|
||||
private String baseUrl;
|
||||
|
||||
private static final String TEST_EMAIL = "session-it@test.de";
|
||||
private static final String TEST_PASSWORD = "pass4Session!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
http = noThrowRestTemplate();
|
||||
baseUrl = "http://localhost:" + port;
|
||||
// spring_session_attributes cascades on delete — removing the parent row is enough
|
||||
jdbcTemplate.update("DELETE FROM spring_session");
|
||||
jdbcTemplate.update("DELETE FROM app_users WHERE email = ?", TEST_EMAIL);
|
||||
userRepository.save(AppUser.builder()
|
||||
.email(TEST_EMAIL)
|
||||
.password(passwordEncoder.encode(TEST_PASSWORD))
|
||||
.build());
|
||||
}
|
||||
|
||||
// ─── Task 13: full session lifecycle ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void login_sets_opaque_fa_session_cookie() {
|
||||
ResponseEntity<String> response = doLogin();
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
String cookie = extractFaSessionCookie(response);
|
||||
assertThat(cookie).isNotBlank();
|
||||
// Opaque token — must not look like Basic-auth credentials (email:password)
|
||||
assertThat(cookie).doesNotContain(":");
|
||||
}
|
||||
|
||||
@Test
|
||||
void session_cookie_authenticates_subsequent_request() {
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
|
||||
ResponseEntity<String> me = http.exchange(
|
||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
|
||||
ResponseEntity<Void> logout = http.postForEntity(
|
||||
baseUrl + "/api/auth/logout",
|
||||
new HttpEntity<>(cookieHeaders(cookie)), Void.class);
|
||||
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
||||
|
||||
ResponseEntity<String> me = http.exchange(
|
||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// ─── Task 14: idle-timeout ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void session_expired_by_idle_timeout_returns_401() {
|
||||
String cookie = extractFaSessionCookie(doLogin());
|
||||
|
||||
// Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
|
||||
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
|
||||
jdbcTemplate.update(
|
||||
"UPDATE spring_session SET LAST_ACCESS_TIME = ?, EXPIRY_TIME = ?",
|
||||
nineHoursAgoMs, nineHoursAgoMs);
|
||||
|
||||
ResponseEntity<String> me = http.exchange(
|
||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private ResponseEntity<String> doLogin() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
|
||||
return http.postForEntity(baseUrl + "/api/auth/login",
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
}
|
||||
|
||||
private HttpHeaders cookieHeaders(String sessionId) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||
if (setCookieHeader == null) return "";
|
||||
return setCookieHeader.stream()
|
||||
.filter(c -> c.startsWith("fa_session="))
|
||||
.map(c -> c.split(";")[0].substring("fa_session=".length()))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,12 @@ import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.xml.sax.SAXParseException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.OutputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
@@ -32,6 +35,8 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -130,7 +135,7 @@ class MassImportServiceTest {
|
||||
@Test
|
||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
|
||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||
|
||||
assertThatThrownBy(() -> service.runImportAsync())
|
||||
@@ -320,8 +325,8 @@ class MassImportServiceTest {
|
||||
@Test
|
||||
void processRows_returnsZero_whenOnlyHeaderRow() {
|
||||
List<List<String>> rows = List.of(List.of("header", "col1"));
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result).isEqualTo(0);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result.processed()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -330,8 +335,8 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("") // blank index
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result).isEqualTo(0);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
assertThat(result.processed()).isEqualTo(0);
|
||||
verify(documentService, never()).findByOriginalFilename(any());
|
||||
}
|
||||
|
||||
@@ -344,9 +349,9 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("doc001") // no dot → appends ".pdf"
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
|
||||
assertThat(result).isEqualTo(1);
|
||||
assertThat(result.processed()).isEqualTo(1);
|
||||
verify(documentService).findByOriginalFilename("doc001.pdf");
|
||||
}
|
||||
|
||||
@@ -359,9 +364,9 @@ class MassImportServiceTest {
|
||||
List.of("header"),
|
||||
minimalCells("doc002.pdf") // has dot → used as-is
|
||||
);
|
||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||
|
||||
assertThat(result).isEqualTo(1);
|
||||
assertThat(result.processed()).isEqualTo(1);
|
||||
verify(documentService).findByOriginalFilename("doc002.pdf");
|
||||
}
|
||||
|
||||
@@ -520,6 +525,86 @@ class MassImportServiceTest {
|
||||
assertThat(result).isEqualTo("hello");
|
||||
}
|
||||
|
||||
// ─── PDF magic byte validation regression ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
|
||||
setupOneValidOneFakeImport(tempDir);
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
|
||||
setupOneValidOneFakeImport(tempDir);
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
|
||||
setupOneValidOneFakeImport(tempDir);
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::filename)
|
||||
.contains("fake.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
|
||||
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
|
||||
buildMinimalImportXlsx(tempDir, "tiny.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
|
||||
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
|
||||
MassImportService spyService = spy(service);
|
||||
doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));
|
||||
|
||||
spyService.runImportAsync();
|
||||
|
||||
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
|
||||
assertThat(spyService.getStatus().skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::reason)
|
||||
.containsExactly("FILE_READ_ERROR");
|
||||
}
|
||||
|
||||
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||
|
||||
// Security regression — do not remove.
|
||||
@Test
|
||||
void readOds_rejects_xxe_doctype_payload(@TempDir Path tempDir) throws Exception {
|
||||
File malicious = buildXxeOds(tempDir, "file:///etc/hostname");
|
||||
assertThatThrownBy(() -> service.readOds(malicious))
|
||||
.isInstanceOf(SAXParseException.class)
|
||||
.hasMessageContaining("DOCTYPE is disallowed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void readOds_parses_valid_ods_correctly(@TempDir Path tempDir) throws Exception {
|
||||
File valid = buildValidOds(tempDir, "Mustermann");
|
||||
List<List<String>> rows = service.readOds(valid);
|
||||
assertThat(rows).isNotEmpty();
|
||||
assertThat(rows.get(0)).contains("Mustermann");
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -553,4 +638,72 @@ class MassImportServiceTest {
|
||||
"" // 13: transcription
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates a minimal ODS ZIP containing a content.xml with an XXE payload. */
|
||||
private File buildXxeOds(Path dir, String entityTarget) throws Exception {
|
||||
String xml = "<?xml version=\"1.0\"?>"
|
||||
+ "<!DOCTYPE foo [<!ENTITY xxe SYSTEM \"" + entityTarget + "\">]>"
|
||||
+ "<office:document-content"
|
||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||
+ " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
|
||||
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
|
||||
+ "<office:body><office:spreadsheet>"
|
||||
+ "<table:table><table:table-row><table:table-cell>"
|
||||
+ "<text:p>&xxe;</text:p>"
|
||||
+ "</table:table-cell></table:table-row></table:table>"
|
||||
+ "</office:spreadsheet></office:body>"
|
||||
+ "</office:document-content>";
|
||||
return writeOdsZip(dir.resolve("malicious.ods"), xml);
|
||||
}
|
||||
|
||||
/** Creates a minimal valid ODS ZIP containing a content.xml with the given cell value.
|
||||
* cellValue must not contain XML metacharacters ({@code < > &}). */
|
||||
private File buildValidOds(Path dir, String cellValue) throws Exception {
|
||||
String xml = "<?xml version=\"1.0\"?>"
|
||||
+ "<office:document-content"
|
||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||
+ " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
|
||||
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
|
||||
+ "<office:body><office:spreadsheet>"
|
||||
+ "<table:table><table:table-row><table:table-cell>"
|
||||
+ "<text:p>" + cellValue + "</text:p>"
|
||||
+ "</table:table-cell></table:table-row></table:table>"
|
||||
+ "</office:spreadsheet></office:body>"
|
||||
+ "</office:document-content>";
|
||||
return writeOdsZip(dir.resolve("valid.ods"), xml);
|
||||
}
|
||||
|
||||
private File writeOdsZip(Path destination, String contentXml) throws Exception {
|
||||
try (OutputStream fos = Files.newOutputStream(destination);
|
||||
ZipOutputStream zip = new ZipOutputStream(fos)) {
|
||||
zip.putNextEntry(new ZipEntry("content.xml"));
|
||||
zip.write(contentXml.getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
return destination.toFile();
|
||||
}
|
||||
|
||||
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
|
||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
||||
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
||||
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
|
||||
Path xlsx = dir.resolve("import.xlsx");
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
|
||||
sheet.createRow(0).createCell(0).setCellValue("Index");
|
||||
for (int i = 0; i < filenames.length; i++) {
|
||||
sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package org.raddatz.familienarchiv.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* The filter must turn a browser-side {@code Cookie: auth_token=Basic%20<base64>}
|
||||
* into {@code Authorization: Basic <base64>} (URL-decoded) so that Spring's
|
||||
* Basic-auth filter accepts it. Skips when the request already has an explicit
|
||||
* {@code Authorization} header, or when no {@code auth_token} cookie is present.
|
||||
*
|
||||
* <p>See #520.
|
||||
*/
|
||||
class AuthTokenCookieFilterTest {
|
||||
|
||||
private final AuthTokenCookieFilter filter = new AuthTokenCookieFilter();
|
||||
|
||||
@Test
|
||||
void promotes_url_encoded_auth_token_cookie_to_decoded_Authorization_header() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/users/me");
|
||||
req.setCookies(new Cookie("auth_token", "Basic%20YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDpzZWNyZXQ%3D"));
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
ArgumentCaptor<HttpServletRequest> captor = ArgumentCaptor.forClass(HttpServletRequest.class);
|
||||
verify(chain, times(1)).doFilter(captor.capture(), org.mockito.ArgumentMatchers.any(HttpServletResponse.class));
|
||||
|
||||
HttpServletRequest forwarded = captor.getValue();
|
||||
assertThat(forwarded.getHeader("Authorization"))
|
||||
.as("Authorization must be URL-decoded so Spring's Basic parser sees a literal space")
|
||||
.isEqualTo("Basic YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDpzZWNyZXQ=");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preserves_explicit_Authorization_header_and_ignores_cookie() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/users/me");
|
||||
req.addHeader("Authorization", "Basic explicit-header-wins");
|
||||
req.setCookies(new Cookie("auth_token", "Basic%20cookie-would-have-promoted"));
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
// Forwards the original request unchanged — same instance, no wrapping.
|
||||
verify(chain).doFilter(req, res);
|
||||
}
|
||||
|
||||
@Test
|
||||
void passes_through_when_no_cookies_at_all() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/users/me");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
verify(chain).doFilter(req, res);
|
||||
}
|
||||
|
||||
@Test
|
||||
void passes_through_when_auth_token_cookie_is_absent() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/users/me");
|
||||
req.setCookies(new Cookie("some_other_cookie", "value"));
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
verify(chain).doFilter(req, res);
|
||||
}
|
||||
|
||||
@Test
|
||||
void passes_through_when_auth_token_cookie_is_empty() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/users/me");
|
||||
req.setCookies(new Cookie("auth_token", ""));
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
verify(chain).doFilter(req, res);
|
||||
}
|
||||
|
||||
@Test
|
||||
void passes_through_unchanged_when_request_is_outside_api_scope() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
// /actuator/health and similar must NOT receive a promoted Authorization
|
||||
// header — they have their own access rules and should never be authed
|
||||
// via the cookie.
|
||||
req.setRequestURI("/actuator/health");
|
||||
req.setCookies(new Cookie("auth_token", "Basic%20YWR=="));
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
// Forwards the original request unchanged — same instance, no wrapping.
|
||||
verify(chain).doFilter(req, res);
|
||||
}
|
||||
|
||||
@Test
|
||||
void passes_through_unchanged_when_cookie_value_is_malformed_percent_encoding() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRequestURI("/api/users/me");
|
||||
// Lone "%" without two hex digits → URLDecoder throws → filter must
|
||||
// refuse to forward a bogus Authorization header.
|
||||
req.setCookies(new Cookie("auth_token", "Basic%2"));
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
// Forwards the original request unchanged — Spring Security treats it
|
||||
// as unauthenticated rather than crashing on bad input.
|
||||
verify(chain).doFilter(req, res);
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class AdminControllerTest {
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||
when(massImportService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
@@ -60,7 +60,7 @@ class AdminControllerTest {
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||
when(massImportService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
|
||||
@@ -142,10 +142,11 @@ services:
|
||||
container_name: obs-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${PORT_GRAFANA:-3001}:3000"
|
||||
- "127.0.0.1:${PORT_GRAFANA:-3003}:3000"
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
@@ -184,7 +185,7 @@ services:
|
||||
- obs-net
|
||||
|
||||
obs-glitchtip:
|
||||
image: glitchtip/glitchtip:v4
|
||||
image: glitchtip/glitchtip:6.1.6
|
||||
container_name: obs-glitchtip
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -193,7 +194,7 @@ services:
|
||||
obs-glitchtip-db-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@archive-db:5432/glitchtip
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-archive-db}:5432/glitchtip
|
||||
REDIS_URL: redis://obs-redis:6379/0
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||
GLITCHTIP_DOMAIN: ${GLITCHTIP_DOMAIN:-http://localhost:3002}
|
||||
@@ -201,13 +202,19 @@ services:
|
||||
EMAIL_URL: smtp://mailpit:1025
|
||||
GLITCHTIP_MAX_EVENT_LIFE_DAYS: 90
|
||||
ports:
|
||||
- "127.0.0.1:${PORT_GLITCHTIP:-3002}:8080"
|
||||
- "127.0.0.1:${PORT_GLITCHTIP:-3002}:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/8000"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
networks:
|
||||
- archiv-net
|
||||
- obs-net
|
||||
|
||||
obs-glitchtip-worker:
|
||||
image: glitchtip/glitchtip:v4
|
||||
image: glitchtip/glitchtip:6.1.6
|
||||
container_name: obs-glitchtip-worker
|
||||
restart: unless-stopped
|
||||
command: ./bin/run-celery-with-beat.sh
|
||||
@@ -215,7 +222,7 @@ services:
|
||||
obs-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@archive-db:5432/glitchtip
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-archive-db}:5432/glitchtip
|
||||
REDIS_URL: redis://obs-redis:6379/0
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||
networks:
|
||||
@@ -229,10 +236,10 @@ services:
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
command: >
|
||||
sh -c "psql -h archive-db -U ${POSTGRES_USER} -tc
|
||||
sh -c "psql -h ${POSTGRES_HOST:-archive-db} -U ${POSTGRES_USER} -tc
|
||||
\"SELECT 1 FROM pg_database WHERE datname = 'glitchtip'\" |
|
||||
grep -q 1 ||
|
||||
psql -h archive-db -U ${POSTGRES_USER} -c \"CREATE DATABASE glitchtip;\""
|
||||
psql -h ${POSTGRES_HOST:-archive-db} -U ${POSTGRES_USER} -c \"CREATE DATABASE glitchtip;\""
|
||||
networks:
|
||||
- archiv-net
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
networks:
|
||||
archiv-net:
|
||||
driver: bridge
|
||||
name: archiv-net
|
||||
name: ${COMPOSE_NETWORK_NAME:-archiv-net}
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -128,6 +128,23 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# --- OCR: Volume bootstrap ---
|
||||
# Ensures correct ownership and directory structure on ocr-cache / ocr-models
|
||||
# before ocr-service starts. Handles pre-existing volumes (including those
|
||||
# created before the non-root ocr user was introduced in commit 1aca4c4a)
|
||||
# and guarantees /app/cache/.tmp exists for TMPDIR staging. See ADR-021.
|
||||
ocr-volume-init:
|
||||
image: alpine:3.21
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "chown -R 1000:1000 /app/cache /app/models && mkdir -p /app/cache/.tmp && chown 1000:1000 /app/cache/.tmp"
|
||||
volumes:
|
||||
- ocr-models:/app/models
|
||||
- ocr-cache:/app/cache
|
||||
networks: []
|
||||
restart: "no"
|
||||
|
||||
ocr-service:
|
||||
build:
|
||||
context: ./ocr-service
|
||||
@@ -142,8 +159,14 @@ services:
|
||||
memswap_limit: ${OCR_MEM_LIMIT:-12g}
|
||||
volumes:
|
||||
- ocr-models:/app/models
|
||||
- ocr-cache:/root/.cache
|
||||
- ocr-cache:/app/cache # HuggingFace / ketos cache — prevents re-downloads on recreate (HF_HOME)
|
||||
environment:
|
||||
HF_HOME: /app/cache
|
||||
XDG_CACHE_HOME: /app/cache
|
||||
TORCH_HOME: /app/models/torch
|
||||
TMPDIR: /app/cache/.tmp # Stage GB-scale Surya model downloads on SSD, not the 512 MB RAM tmpfs.
|
||||
# /tmp keeps its small DoS cap; training ZIPs still unpack under /tmp
|
||||
# but ZIP Slip protection (_validate_zip_entry) is unchanged. See ADR-021.
|
||||
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
|
||||
TRAINING_TOKEN: ${OCR_TRAINING_TOKEN}
|
||||
OCR_CONFIDENCE_THRESHOLD: "0.3"
|
||||
@@ -161,6 +184,17 @@ services:
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 120s
|
||||
depends_on:
|
||||
ocr-volume-init:
|
||||
condition: service_completed_successfully
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m # training-ZIP unzip + transient PDF buffers only (small, RAM-friendly).
|
||||
# GB-scale model downloads go to TMPDIR=/app/cache/.tmp instead. See ADR-021.
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
backend:
|
||||
image: familienarchiv/backend:${TAG:-nightly}
|
||||
@@ -213,10 +247,15 @@ services:
|
||||
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@raddatz.cloud}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-true}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-true}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
|
||||
OTEL_LOGS_EXPORTER: none
|
||||
OTEL_METRICS_EXPORTER: none
|
||||
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
|
||||
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
|
||||
networks:
|
||||
- archiv-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health | grep -q UP || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
@@ -71,6 +71,23 @@ services:
|
||||
networks:
|
||||
- archiv-net
|
||||
|
||||
# --- OCR: Volume bootstrap ---
|
||||
# Ensures correct ownership and directory structure on ocr_cache / ocr_models
|
||||
# before ocr-service starts. Handles pre-existing volumes (including those
|
||||
# created before the non-root ocr user was introduced in commit 1aca4c4a)
|
||||
# and guarantees /app/cache/.tmp exists for TMPDIR staging. See ADR-021.
|
||||
ocr-volume-init:
|
||||
image: alpine:3.21
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "chown -R 1000:1000 /app/cache /app/models && mkdir -p /app/cache/.tmp && chown 1000:1000 /app/cache/.tmp"
|
||||
volumes:
|
||||
- ocr_models:/app/models
|
||||
- ocr_cache:/app/cache
|
||||
networks: []
|
||||
restart: "no"
|
||||
|
||||
# --- OCR: Python microservice (Surya + Kraken) ---
|
||||
# Single-node only: OCR training reloads the model in-process after each run.
|
||||
# Running multiple replicas would cause training conflicts and model-state divergence.
|
||||
@@ -87,8 +104,14 @@ services:
|
||||
memswap_limit: 12g
|
||||
volumes:
|
||||
- ocr_models:/app/models
|
||||
- ocr_cache:/root/.cache # Hugging Face / ketos model download cache — prevents re-downloads on container recreate
|
||||
- ocr_cache:/app/cache # HuggingFace / ketos cache — prevents re-downloads on recreate (HF_HOME)
|
||||
environment:
|
||||
HF_HOME: /app/cache
|
||||
XDG_CACHE_HOME: /app/cache
|
||||
TORCH_HOME: /app/models/torch
|
||||
TMPDIR: /app/cache/.tmp # Stage GB-scale Surya model downloads on SSD, not the 512 MB RAM tmpfs.
|
||||
# /tmp keeps its small DoS cap; training ZIPs still unpack under /tmp
|
||||
# but ZIP Slip protection (_validate_zip_entry) is unchanged. See ADR-021.
|
||||
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
|
||||
TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||
OCR_CONFIDENCE_THRESHOLD: "0.3"
|
||||
@@ -106,6 +129,17 @@ services:
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 120s
|
||||
depends_on:
|
||||
ocr-volume-init:
|
||||
condition: service_completed_successfully
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m # training-ZIP unzip + transient PDF buffers only (small, RAM-friendly).
|
||||
# GB-scale model downloads go to TMPDIR=/app/cache/.tmp instead. See ADR-021.
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- Backend: Spring Boot ---
|
||||
backend:
|
||||
|
||||
@@ -19,6 +19,7 @@ This doc is the Day-1 checklist and operational reference. It links to the canon
|
||||
5. [Backup + recovery](#5-backup--recovery)
|
||||
6. [Common operational tasks](#6-common-operational-tasks)
|
||||
7. [Known limitations](#7-known-limitations)
|
||||
8. [Upgrade notes](#8-upgrade-notes)
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +44,7 @@ graph TD
|
||||
- SSE notifications transit Caddy (browser → Caddy → backend); the backend is never reachable directly from the public internet. The SvelteKit SSR layer is bypassed for SSE, but Caddy is not.
|
||||
- The Caddyfile responds `404` on `/actuator/*` (defense in depth). Internal monitoring scrapes the backend on the docker network, not through Caddy.
|
||||
- Production and staging cohabit on the same host via docker compose project names: `archiv-production` (ports 8080/3000) and `archiv-staging` (ports 8081/3001).
|
||||
- An optional observability stack (Prometheus, Node Exporter, cAdvisor) runs as a separate compose file: `docker compose -f docker-compose.observability.yml up -d`. It joins `archiv-net` and scrapes the backend's management port (`:8081`). Configuration lives under `infra/observability/`.
|
||||
- An optional observability stack (Prometheus, Node Exporter, cAdvisor, Loki, Tempo, Grafana, GlitchTip) runs as a separate compose file. Configuration lives under `infra/observability/`. In production and CI, the stack is managed from `/opt/familienarchiv/` (CI copies it there on every nightly run) so bind mounts survive workspace wipes — see §4 for the ops procedure.
|
||||
|
||||
### OCR memory requirements
|
||||
|
||||
@@ -107,8 +108,12 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — |
|
||||
| `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — |
|
||||
| `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP gRPC endpoint for distributed traces (Tempo). Set to `http://tempo:4317` via compose. | `http://localhost:4317` | — | — |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP HTTP endpoint for distributed traces (Tempo). Port 4318 = HTTP transport; port 4317 is gRPC-only and causes "Connection reset" with Spring Boot's HttpExporter. | `http://localhost:4318` | — | — |
|
||||
| `OTEL_LOGS_EXPORTER` | Disable OTLP log export — Promtail captures Docker logs via the logging driver; Tempo does not accept logs. | `none` | — | — |
|
||||
| `OTEL_METRICS_EXPORTER` | Disable OTLP metric export — Prometheus scrapes `/actuator/prometheus` via pull model; Tempo does not accept metrics. | `none` | — | — |
|
||||
| `MANAGEMENT_METRICS_TAGS_APPLICATION` | Common tag added to every Micrometer metric. Required by Grafana's Spring Boot Observability dashboard (ID 17175) `label_values(application)` template variable. | `Familienarchiv` | — | — |
|
||||
| `MANAGEMENT_TRACING_SAMPLING_PROBABILITY` | Micrometer tracing sample rate; overridden to `0.0` in test profile. | `0.1` (compose) / `1.0` (dev) | — | — |
|
||||
| `SENTRY_DSN` | GlitchTip / Sentry DSN for backend error reporting. Leave empty to disable the SDK. Set after GlitchTip first-run (§4). | — | — | YES |
|
||||
|
||||
### PostgreSQL container
|
||||
|
||||
@@ -136,17 +141,21 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
|
||||
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
|
||||
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
|
||||
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
|
||||
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
|
||||
|
||||
### Observability stack (`docker-compose.observability.yml`)
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|---|---|---|---|---|
|
||||
| `PORT_PROMETHEUS` | Host port for the Prometheus UI (bound to `127.0.0.1` only) | `9090` | — | — |
|
||||
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3001` | — | — |
|
||||
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — |
|
||||
| `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES |
|
||||
| `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — |
|
||||
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — |
|
||||
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES |
|
||||
| `VITE_SENTRY_DSN` | GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite. Leave empty to disable. Set after GlitchTip first-run (§4). | — | — | YES |
|
||||
|
||||
---
|
||||
|
||||
@@ -193,6 +202,29 @@ curl -fsSL https://tailscale.com/install.sh | sh && tailscale up
|
||||
# files to disk during execution (cleaned up unconditionally on completion).
|
||||
# A multi-tenant runner would need to switch to stdin-piped env files.
|
||||
# (See https://docs.gitea.com/usage/actions/quickstart for the register step.)
|
||||
|
||||
# Runner workspace directory — required for DooD bind-mount resolution (ADR-015).
|
||||
# act_runner stores job workspaces here so that docker compose bind mounts resolve
|
||||
# to real host paths. The path must be identical on the host and inside job containers.
|
||||
mkdir -p /srv/gitea-workspace
|
||||
# Observability config permanent directory — the nightly CI job copies
|
||||
# docker-compose.observability.yml and infra/observability/ here on every run.
|
||||
# The obs stack is always started from this path, not from the workspace.
|
||||
# See ADR-016 for why this directory is used instead of a server-pull approach.
|
||||
mkdir -p /opt/familienarchiv/infra
|
||||
# Both paths must also appear in the runner service volumes in ~/docker/gitea/compose.yaml:
|
||||
# volumes:
|
||||
# - /srv/gitea-workspace:/srv/gitea-workspace
|
||||
# /opt/familienarchiv does NOT need to be in the runner container's volumes — job
|
||||
# containers are spawned by the host daemon directly (DooD), so the host path is
|
||||
# accessible to them as long as runner-config.yaml lists it in valid_volumes + options.
|
||||
# See runner-config.yaml (workdir_parent + valid_volumes + options) and ADR-015/016.
|
||||
|
||||
# ⚠ IMPORTANT: after any change to runner-config.yaml (valid_volumes, options, workdir_parent),
|
||||
# restart the Gitea Act runner for the new config to take effect:
|
||||
# docker restart gitea-runner
|
||||
# Until restarted, job containers are spawned with the old config and any new bind mounts
|
||||
# (e.g. /opt/familienarchiv) will not be available inside job steps.
|
||||
```
|
||||
|
||||
### 3.2 DNS records
|
||||
@@ -226,6 +258,7 @@ git.raddatz.cloud A <server IP>
|
||||
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
|
||||
| `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` |
|
||||
| `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
|
||||
| `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
|
||||
|
||||
### 3.4 First deploy
|
||||
|
||||
@@ -253,6 +286,9 @@ Before the first deploy: rotate `PROD_APP_ADMIN_PASSWORD` to a strong value. Aft
|
||||
|
||||
## 4. Logs + observability
|
||||
|
||||
> **Developer guide (where to look for what, LogQL queries, trace exploration) → [docs/OBSERVABILITY.md](./OBSERVABILITY.md).**
|
||||
> This section covers the ops side: starting the stack, env vars, and CI wiring.
|
||||
|
||||
### First-response commands
|
||||
|
||||
```bash
|
||||
@@ -275,13 +311,70 @@ docker compose logs --tail=200 <service>
|
||||
|
||||
### Observability stack
|
||||
|
||||
An observability stack is available via `docker-compose.observability.yml`. Configuration lives under `infra/observability/`. Start it after the main stack is up (which creates `archiv-net`):
|
||||
An observability stack is available via `docker-compose.observability.yml`. Configuration lives under `infra/observability/`.
|
||||
|
||||
#### Dev — start from the workspace
|
||||
|
||||
```bash
|
||||
docker compose up -d # creates archiv-net
|
||||
docker compose -f docker-compose.observability.yml up -d
|
||||
```
|
||||
|
||||
#### Why the obs stack is managed differently from the main app stack
|
||||
|
||||
The main app stack (`docker-compose.prod.yml`) has no config-file bind mounts — its containers read config from env vars and image defaults. The workspace is wiped after each CI run but that does not affect running containers, because they hold no references to workspace paths.
|
||||
|
||||
The obs stack is different: `prometheus.yml`, `tempo.yml`, Loki config, Grafana provisioning files, and Promtail config are all bind-mounted from the host filesystem into their containers. If those source paths disappear (workspace wipe), the containers can restart fine until a `docker compose up` is run again — at that point Docker tries to re-resolve the bind-mount source and fails because the workspace path no longer exists.
|
||||
|
||||
The fix is to keep the obs compose file and config tree at a **permanent path** that CI copies to on every run but which survives between runs: `/opt/familienarchiv/` (see ADR-016).
|
||||
|
||||
#### Production — managed from `/opt/familienarchiv/`
|
||||
|
||||
Every CI run (nightly + release) copies `docker-compose.observability.yml` and `infra/observability/` to `/opt/familienarchiv/` before starting the stack. Bind mounts then resolve to `/opt/familienarchiv/infra/observability/…` — a stable path that outlasts any workspace wipe.
|
||||
|
||||
**Environment variables** follow the same two-source model as the main stack:
|
||||
|
||||
| Source | What it contains | Managed by |
|
||||
|---|---|---|
|
||||
| `infra/observability/obs.env` | All non-secret config (ports, URLs, hostnames) | Git — reviewed in PRs |
|
||||
| `/opt/familienarchiv/obs-secrets.env` | Passwords and secret keys only | CI — written fresh from Gitea secrets on every deploy |
|
||||
|
||||
Both files are passed explicitly via `--env-file` to the compose command, so there is no implicit auto-read `.env` and no operator-managed file to keep in sync.
|
||||
|
||||
**Non-secret config** (`infra/observability/obs.env`):
|
||||
|
||||
| Key | Value | Notes |
|
||||
|---|---|---|
|
||||
| `PORT_GRAFANA` | `3003` | Avoids collision with staging frontend on port 3001 |
|
||||
| `PORT_GLITCHTIP` | `3002` | |
|
||||
| `PORT_PROMETHEUS` | `9090` | |
|
||||
| `GF_SERVER_ROOT_URL` | `https://grafana.archiv.raddatz.cloud` | Required for alert email links and OAuth redirects |
|
||||
| `GLITCHTIP_DOMAIN` | `https://glitchtip.archiv.raddatz.cloud` | Must match the Caddy vhost |
|
||||
| `POSTGRES_HOST` | `archive-db` | Override if only the staging stack is running |
|
||||
|
||||
**Secret keys** (set in Gitea secrets, injected by CI into `obs-secrets.env`):
|
||||
|
||||
| Gitea secret | Notes |
|
||||
|---|---|
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
|
||||
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
|
||||
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |
|
||||
|
||||
To start or restart the obs stack manually on the server (after CI has run at least once):
|
||||
|
||||
```bash
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
```
|
||||
|
||||
> **Note (manual ops only):** CI clears the destination with `rm -rf` before copying, so deleted files are removed automatically on the next run. If you copy manually with `cp -r` without first removing the directory, stale files from deleted configs will persist until cleaned up:
|
||||
> ```bash
|
||||
> rm /opt/familienarchiv/infra/observability/<path-to-removed-file>
|
||||
> ```
|
||||
|
||||
Current services:
|
||||
|
||||
| Service | Image | Purpose |
|
||||
@@ -290,11 +383,11 @@ Current services:
|
||||
| `obs-node-exporter` | `prom/node-exporter:v1.9.0` | Host-level CPU / memory / disk / network metrics |
|
||||
| `obs-cadvisor` | `gcr.io/cadvisor/cadvisor:v0.52.1` | Per-container resource metrics |
|
||||
| `obs-loki` | `grafana/loki:3.4.2` | Log aggregation — receives log streams from Promtail. Port 3100 is `expose`-only (not host-bound). |
|
||||
| `obs-promtail` | `grafana/promtail:3.4.2` | Log shipping agent — reads all Docker container logs via the Docker socket and forwards them to Loki with `container_name`, `compose_service`, and `compose_project` labels |
|
||||
| `obs-tempo` | `grafana/tempo:2.7.2` | Distributed trace storage — OTLP gRPC receiver on port 4317, OTLP HTTP on port 4318 (both `archiv-net`-internal). Grafana queries traces on port 3200 (`obs-net`-internal). All ports are `expose`-only (not host-bound). |
|
||||
| `obs-grafana` | `grafana/grafana-oss:11.6.1` | Unified observability UI — metrics dashboards, log exploration, trace viewer. Bound to `127.0.0.1:${PORT_GRAFANA:-3001}` on the host. |
|
||||
| `obs-glitchtip` | `glitchtip/glitchtip:v4` | Sentry-compatible error tracker. Receives frontend + backend error events, groups by fingerprint, provides issue UI with stack traces. Bound to `127.0.0.1:${PORT_GLITCHTIP:-3002}`. |
|
||||
| `obs-glitchtip-worker` | `glitchtip/glitchtip:v4` | Celery + beat worker — processes async GlitchTip tasks (event ingestion, notifications, cleanup). |
|
||||
| `obs-promtail` | `grafana/promtail:3.4.2` | Log shipping agent — reads all Docker container logs via the Docker socket and forwards them to Loki with `container_name`, `compose_service`, `compose_project`, and `job` labels. The `job` label is mapped from the Docker Compose service name (`com.docker.compose.service`) so that Grafana Loki dashboard queries (`{job="backend"}`, `{job="frontend"}`) work out of the box and the "App" variable dropdown is populated. |
|
||||
| `obs-tempo` | `grafana/tempo:2.7.2` | Distributed trace storage — OTLP HTTP receiver on port 4318 (`archiv-net`-internal; backend sends traces here). Grafana queries traces on port 3200 (`obs-net`-internal). All ports are `expose`-only (not host-bound). |
|
||||
| `obs-grafana` | `grafana/grafana-oss:11.6.1` | Unified observability UI — metrics dashboards, log exploration, trace viewer. Bound to `127.0.0.1:${PORT_GRAFANA:-3003}` on the host. |
|
||||
| `obs-glitchtip` | `glitchtip/glitchtip:6.1.6` | Sentry-compatible error tracker. Receives frontend + backend error events, groups by fingerprint, provides issue UI with stack traces. Bound to `127.0.0.1:${PORT_GLITCHTIP:-3002}`. |
|
||||
| `obs-glitchtip-worker` | `glitchtip/glitchtip:6.1.6` | Celery + beat worker — processes async GlitchTip tasks (event ingestion, notifications, cleanup). |
|
||||
| `obs-redis` | `redis:7-alpine` | Celery task broker for GlitchTip. Internal to `obs-net`; no host port exposed. |
|
||||
| `obs-glitchtip-db-init` | `postgres:16-alpine` | One-shot init container. Creates the `glitchtip` database on the existing `archive-db` PostgreSQL instance if it does not already exist. Runs at stack startup; exits cleanly once done. |
|
||||
|
||||
@@ -302,7 +395,7 @@ Current services:
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| URL | `http://localhost:3001` (or `http://localhost:$PORT_GRAFANA`) |
|
||||
| URL | `http://localhost:3003` (or `http://localhost:$PORT_GRAFANA`) |
|
||||
| Username | `admin` |
|
||||
| Password | `$GRAFANA_ADMIN_PASSWORD` (default: `changeme` — **change before exposing to a network**) |
|
||||
|
||||
@@ -332,7 +425,7 @@ docker exec obs-loki wget -qO- \
|
||||
|
||||
**Prefer `compose_service` over `container_name` in LogQL queries** — `container_name` differs between dev (`archive-backend`) and prod (`archiv-production-backend-1`), while `compose_service` is stable (`backend`, `db`, `minio`, etc.).
|
||||
|
||||
Prometheus port `9090` and Grafana port `3001` are bound to `127.0.0.1` on the host. No other observability ports are host-bound.
|
||||
Prometheus port `9090` and Grafana port `3003` (default; configurable via `PORT_GRAFANA`) are bound to `127.0.0.1` on the host. No other observability ports are host-bound.
|
||||
|
||||
#### GlitchTip
|
||||
|
||||
@@ -464,3 +557,44 @@ bash scripts/download-kraken-models.sh
|
||||
| **No multi-region** | Single PostgreSQL + MinIO instance; no replication or failover | Deliberate scope decision |
|
||||
| **Max upload size** | 50 MB per file (500 MB per request for multi-file) | Configurable in `application.yaml` (`spring.servlet.multipart`) |
|
||||
| **No automated backup** | Phase 5 of Production v1 milestone is not yet implemented | See §5 above |
|
||||
|
||||
---
|
||||
|
||||
## 8. Upgrade notes
|
||||
|
||||
Version-specific one-time steps that must be run before or after upgrading to a given release. Each subsection is safe to skip on a fresh install.
|
||||
|
||||
### Upgrading to PR #615 — TMPDIR redirect + ocr-volume-init
|
||||
|
||||
`ocr-volume-init` is a new one-shot service in both compose files that runs before `ocr-service` on every `docker compose up`. It:
|
||||
|
||||
1. `chown -R 1000:1000 /app/cache /app/models` — corrects volume ownership so the non-root `ocr` user (uid 1000) can write to volumes that may have been created as root (including volumes from before PR #611).
|
||||
2. `mkdir -p /app/cache/.tmp` — creates the TMPDIR staging directory that Surya uses for GB-scale model downloads. Without this directory, the first model download falls back to the 512 MB `/tmp` tmpfs and fails with ENOSPC. See ADR-021.
|
||||
|
||||
**Verify it succeeded:**
|
||||
```bash
|
||||
docker logs archiv-ocr-volume-init # dev
|
||||
docker logs archiv-production-ocr-volume-init-1 # prod
|
||||
```
|
||||
Expected output: no error lines; exit code 0.
|
||||
|
||||
**Failure mode:** if `chown` is denied (e.g. the volume is mounted read-only), the container exits non-zero and `ocr-service` will not start (`depends_on: condition: service_completed_successfully`). Check `docker logs` for the `chown` error and verify the volume is writable.
|
||||
|
||||
### Upgrading to PR #611 — non-root OCR container
|
||||
|
||||
The OCR cache volume path changed from `/root/.cache` to `/app/cache` (PR #611 — CIS Docker §4.1 hardening). The existing volume was written as root and is inaccessible to the new non-root `ocr` user, causing a `PermissionError` on startup.
|
||||
|
||||
**Before starting the updated container stack**, drop the old root-owned volume. The volume name depends on the compose project name:
|
||||
|
||||
```bash
|
||||
# Dev (docker-compose.yml — project name: familienarchiv)
|
||||
docker volume rm familienarchiv_ocr_cache
|
||||
|
||||
# Production (docker-compose.prod.yml -p archiv-production)
|
||||
docker volume rm archiv-production_ocr-cache
|
||||
|
||||
# Staging (docker-compose.prod.yml -p archiv-staging)
|
||||
docker volume rm archiv-staging_ocr-cache
|
||||
```
|
||||
|
||||
The volume is recreated automatically on `docker compose up`. The OCR service will re-download its model cache on first startup (approximately 1–2 GB, one-time cost).
|
||||
|
||||
180
docs/OBSERVABILITY.md
Normal file
180
docs/OBSERVABILITY.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Observability Guide
|
||||
|
||||
> **Ops reference (starting the stack, env vars, CI wiring) → [DEPLOYMENT.md §4](./DEPLOYMENT.md#4-logs--observability).**
|
||||
> This file is for developers: what signal lives where, how to reach it, and what to look for.
|
||||
|
||||
## Where to look for what
|
||||
|
||||
| I want to… | Go to |
|
||||
|---|---|
|
||||
| See the last N log lines from the backend | `docker compose logs --tail=100 backend` |
|
||||
| Search logs by keyword across time | Grafana → Explore → Loki |
|
||||
| Understand why an HTTP request failed | Grafana → Explore → Loki → filter by `traceId` → follow link to Tempo |
|
||||
| See a full distributed trace (DB queries, HTTP calls) | Grafana → Explore → Tempo → search by service or trace ID |
|
||||
| Check JVM heap / GC / thread count | Grafana → Dashboards → Spring Boot Observability |
|
||||
| Check HTTP error rate or p95 latency | Grafana → Dashboards → Spring Boot Observability |
|
||||
| Check host CPU / memory / disk | Grafana → Dashboards → Node Exporter Full |
|
||||
| See grouped application errors with stack traces | GlitchTip |
|
||||
| Check if the backend is healthy | `curl http://localhost:8081/actuator/health` (on the server) |
|
||||
| Check what Prometheus is scraping | `curl http://localhost:9090/api/v1/targets` (on the server) |
|
||||
|
||||
## Access
|
||||
|
||||
| Tool | External URL | Who it's for |
|
||||
|---|---|---|
|
||||
| Grafana | `https://grafana.archiv.raddatz.cloud` | Logs, metrics, traces — the primary observability UI |
|
||||
| GlitchTip | `https://glitchtip.archiv.raddatz.cloud` | Grouped errors with stack traces and release tracking |
|
||||
|
||||
Loki, Tempo, and Prometheus have no external URL. They are internal services, accessible only through Grafana (or via SSH tunnel — see below).
|
||||
|
||||
## Logs (Loki)
|
||||
|
||||
Logs reach Loki via Promtail, which reads all Docker container logs from the Docker socket and ships them with labels derived from Docker Compose metadata.
|
||||
|
||||
### Labels available in every log line
|
||||
|
||||
| Label | What it contains | Example |
|
||||
|---|---|---|
|
||||
| `job` | Compose service name | `backend`, `frontend`, `db` |
|
||||
| `compose_service` | Same as `job` | `backend` |
|
||||
| `compose_project` | Compose project name | `archiv-staging`, `archiv-production` |
|
||||
| `container_name` | Docker container name | `archiv-staging-backend-1` |
|
||||
| `filename` | Docker log source | `/var/lib/docker/containers/…` |
|
||||
|
||||
**Use `job` in LogQL queries** — it is stable across dev, staging, and production. `container_name` changes between environments.
|
||||
|
||||
### Common LogQL queries in Grafana Explore
|
||||
|
||||
```logql
|
||||
# All backend logs
|
||||
{job="backend"}
|
||||
|
||||
# Backend ERROR and WARN lines only
|
||||
{job="backend"} |= "ERROR" or {job="backend"} |= "WARN"
|
||||
|
||||
# All logs for a specific request (paste a traceId from a log line)
|
||||
{job="backend"} |= "3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||
|
||||
# Log lines containing a specific exception class
|
||||
{job="backend"} |~ "DomainException|NullPointerException"
|
||||
|
||||
# Frontend logs
|
||||
{job="frontend"}
|
||||
|
||||
# Database (slow query log, if enabled)
|
||||
{job="db"}
|
||||
```
|
||||
|
||||
### Log → Trace correlation
|
||||
|
||||
Spring Boot writes the active `traceId` into every log line when a request is being processed:
|
||||
|
||||
```
|
||||
2026-05-16 ... INFO [Familienarchiv,3fa85f64...,1b2c3d4e] o.r.f.document.DocumentService : ...
|
||||
```
|
||||
|
||||
In Grafana Explore → Loki, log lines with a `traceId` field show a **Tempo** link. Clicking it opens the full trace in Explore → Tempo without copying and pasting IDs.
|
||||
|
||||
This linking is configured in the Loki datasource provisioning via the `traceId` derived field regex. No manual setup required.
|
||||
|
||||
## Traces (Tempo)
|
||||
|
||||
The backend sends traces to Tempo via OTLP HTTP (port 4318). Every inbound HTTP request and every JPA query produces a span. Spans are linked into traces by the propagated `traceId` header.
|
||||
|
||||
### Finding a trace in Grafana
|
||||
|
||||
**Option A — from a log line:**
|
||||
1. Grafana → Explore → select *Loki* datasource
|
||||
2. Query `{job="backend"}` and find the failing request
|
||||
3. Click the **Tempo** link in the log line (appears when `traceId` is present)
|
||||
|
||||
**Option B — by service:**
|
||||
1. Grafana → Explore → select *Tempo* datasource
|
||||
2. Query type: **Search**
|
||||
3. Service name: `familienarchiv-backend`
|
||||
4. Filter by HTTP status, duration, or operation name as needed
|
||||
|
||||
**Option C — by trace ID:**
|
||||
1. Grafana → Explore → select *Tempo* datasource
|
||||
2. Query type: **TraceQL** or **Trace ID**
|
||||
3. Paste the trace ID
|
||||
|
||||
### What each span type tells you
|
||||
|
||||
| Root span name pattern | What it covers |
|
||||
|---|---|
|
||||
| `GET /api/documents`, `POST /api/documents` | Full HTTP request lifecycle |
|
||||
| `SELECT archiv.*` | A single JPA/JDBC query inside that request |
|
||||
| `HikariPool.getConnection` | Connection pool wait time |
|
||||
|
||||
A slow `SELECT` span inside an otherwise fast HTTP span pinpoints a missing index. A slow `HikariPool.getConnection` span indicates connection pool exhaustion.
|
||||
|
||||
### Sampling rate
|
||||
|
||||
- **Dev**: 100% of requests are traced (`management.tracing.sampling.probability: 1.0` in `application.yaml`)
|
||||
- **Staging / Production**: 10% (`MANAGEMENT_TRACING_SAMPLING_PROBABILITY=0.1` in `docker-compose.prod.yml`)
|
||||
|
||||
To find a trace for a specific request in staging/production, either increase the sampling rate temporarily or trigger the request multiple times.
|
||||
|
||||
## Metrics (Prometheus → Grafana)
|
||||
|
||||
Prometheus scrapes the backend management endpoint every 15 s:
|
||||
|
||||
```
|
||||
Target: backend:8081/actuator/prometheus
|
||||
Labels: job="spring-boot", application="Familienarchiv"
|
||||
```
|
||||
|
||||
All Spring Boot metrics carry the `application="Familienarchiv"` tag, which is how the Grafana Spring Boot Observability dashboard (ID 17175) filters to this service.
|
||||
|
||||
### Useful Prometheus queries (run on the server or via Grafana Explore → Prometheus)
|
||||
|
||||
```promql
|
||||
# HTTP error rate (5xx) as a fraction of all requests
|
||||
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
|
||||
/ sum(rate(http_server_requests_seconds_count[5m]))
|
||||
|
||||
# p95 response time
|
||||
histogram_quantile(0.95, sum by (le) (
|
||||
rate(http_server_requests_seconds_bucket[5m])
|
||||
))
|
||||
|
||||
# JVM heap used
|
||||
jvm_memory_used_bytes{area="heap", application="Familienarchiv"}
|
||||
|
||||
# Active DB connections
|
||||
hikaricp_connections_active
|
||||
```
|
||||
|
||||
## Errors (GlitchTip)
|
||||
|
||||
GlitchTip receives errors from both the backend (via Sentry Java SDK) and the frontend (via Sentry JavaScript SDK). It groups events by fingerprint, tracks first/last seen times, and links to the release that introduced the error.
|
||||
|
||||
GlitchTip complements Loki: use GlitchTip when you need **grouped, de-duplicated errors with stack traces and release attribution**; use Loki when you need **raw log lines with full context** or want to search across all log levels.
|
||||
|
||||
## Direct API access (debugging only)
|
||||
|
||||
Loki and Tempo bind no host ports. To reach them directly from your laptop, use an SSH tunnel through the server:
|
||||
|
||||
```bash
|
||||
# Loki API on localhost:3100 (then query via curl or logcli)
|
||||
ssh -L 3100:172.20.0.x:3100 root@raddatz.cloud
|
||||
# Replace 172.20.0.x with the obs-loki container IP:
|
||||
# docker inspect obs-loki --format '{{.NetworkSettings.Networks.archiv-obs-net.IPAddress}}'
|
||||
|
||||
# Tempo API on localhost:3200 (then query via curl or tempo-cli)
|
||||
ssh -L 3200:172.20.0.x:3200 root@raddatz.cloud
|
||||
```
|
||||
|
||||
In practice, Grafana Explore covers all common debugging workflows without needing direct API access.
|
||||
|
||||
## Signal summary
|
||||
|
||||
| Signal | Source | Transport | Storage | UI |
|
||||
|---|---|---|---|---|
|
||||
| Application logs | Spring Boot stdout → Docker log driver | Promtail → Loki push API | Loki | Grafana Explore → Loki |
|
||||
| Distributed traces | Spring Boot OTel agent | OTLP HTTP → Tempo:4318 | Tempo | Grafana Explore → Tempo |
|
||||
| JVM + HTTP metrics | Spring Actuator `/actuator/prometheus` | Prometheus pull (15 s) | Prometheus | Grafana dashboards |
|
||||
| Host metrics | node-exporter | Prometheus pull | Prometheus | Grafana → Node Exporter Full |
|
||||
| Container metrics | cAdvisor | Prometheus pull | Prometheus | Grafana (via Prometheus datasource) |
|
||||
| Application errors | Sentry SDK | HTTP POST → GlitchTip ingest | GlitchTip DB | GlitchTip UI |
|
||||
69
docs/adr/015-dood-workspace-bind-mount.md
Normal file
69
docs/adr/015-dood-workspace-bind-mount.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# ADR-015: DooD workspace bind mount for Compose file bind-mount resolution
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The deploy workflows (`.gitea/workflows/nightly.yml`, `release.yml`) run job steps inside Docker containers via Docker-out-of-Docker (DooD): the Gitea runner mounts the host Docker socket, and act_runner spawns sibling containers for each job.
|
||||
|
||||
When a job step calls `docker compose -f docker-compose.observability.yml up`, Docker Compose resolves relative bind-mount sources against `$(pwd)` inside the job container and passes the resulting absolute paths to the **host** daemon. For example, `./infra/observability/prometheus/prometheus.yml` becomes `/some/path/infra/observability/prometheus/prometheus.yml`, and the host daemon tries to bind-mount that path from the **host filesystem**.
|
||||
|
||||
In the default DooD setup (`runner-config.yaml` with only `valid_volumes: ["/var/run/docker.sock"]`), job container workspaces live in the act_runner overlay2 layer. The host has no corresponding directory at the job container's `$(pwd)` path, so the daemon auto-creates an empty directory in its place. The container then fails to start because the mount target was expected to be a file, not a directory:
|
||||
|
||||
```
|
||||
error mounting "…/prometheus/prometheus.yml" to rootfs at "/etc/prometheus/prometheus.yml": not a directory
|
||||
```
|
||||
|
||||
This affected all five config file bind mounts in `docker-compose.observability.yml`.
|
||||
|
||||
## Decision
|
||||
|
||||
Configure act_runner to store job workspaces on a real host path (`/srv/gitea-workspace`) and mount that path into both the runner container and every job container at the **same absolute path**. The identity of the host path and container path is the key constraint: Compose resolves to an absolute path and hands it to the host daemon, which looks for that exact path on the host filesystem.
|
||||
|
||||
**runner-config.yaml changes:**
|
||||
|
||||
```yaml
|
||||
container:
|
||||
workdir_parent: /srv/gitea-workspace
|
||||
valid_volumes:
|
||||
- "/var/run/docker.sock"
|
||||
- "/srv/gitea-workspace"
|
||||
options: "-v /srv/gitea-workspace:/srv/gitea-workspace"
|
||||
```
|
||||
|
||||
**Runner compose.yaml change** (host side — not in this repo):
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
volumes:
|
||||
- /srv/gitea-workspace:/srv/gitea-workspace
|
||||
```
|
||||
|
||||
With this in place, `$(pwd)` inside a job container resolves to `/srv/gitea-workspace/<owner>/<repo>/`, which is a real directory on the host. Compose-managed bind mounts from that directory work without any additional steps.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| **overlay2 `MergedDir` sync via privileged nsenter** (the previous approach, see PR #599 v1) | Required `--privileged --pid=host` (effective root on the host) plus fragile overlay2 driver assumption. Introduced stale-file risk on the host and a second stable path (`/srv/familienarchiv-*/obs-configs`) to maintain separately from the source tree. Replaced by this ADR. |
|
||||
| **Build configs into a dedicated Docker image** (pattern used for MinIO bootstrap, see `infra/minio/Dockerfile`) | Viable for static files that change infrequently. Requires a build step and an image rebuild every time a config changes. Appropriate for bootstrap scripts; too heavy for frequently-tuned observability configs. |
|
||||
| **Add workspace directory to runner-config `valid_volumes` only** (without `workdir_parent`) | `valid_volumes` whitelists paths that workflow steps may reference, but does not change where act_runner stores workspaces. Without `workdir_parent`, the workspace would still be in overlay2 and the bind-mount resolution problem would remain. |
|
||||
| **Map workspace under a different host path than container path** (e.g. host `/srv/workspace`, container `/workspace`) | Compose resolves to the container-internal path (e.g. `/workspace/…`) and passes that to the host daemon. The host daemon interprets the source as a host path. If host `/workspace` does not exist, the daemon creates an empty directory — the original bug. The paths must be identical. |
|
||||
|
||||
## Consequences
|
||||
|
||||
- `/srv/gitea-workspace` must exist on the VPS before the runner starts. The directory was created as part of this change; it is not created automatically.
|
||||
- The runner container's `compose.yaml` (maintained outside this repo at `~/docker/gitea/compose.yaml` on the VPS) must include the `- /srv/gitea-workspace:/srv/gitea-workspace` volume line. This is an out-of-band operational dependency; the prerequisite is documented in `runner-config.yaml`.
|
||||
- `workdir_parent` applies to all jobs on this runner. Any future workflow that calls `docker compose` with relative bind mounts benefits automatically without further configuration.
|
||||
- Job workspaces persist across runs under `/srv/gitea-workspace`. act_runner manages per-run subdirectory cleanup. Orphaned directories from interrupted runs should be cleaned up manually if disk space becomes a concern.
|
||||
- Workflows that previously relied on `OBS_CONFIG_DIR` env var or the `obs-configs` stable path on the host no longer need those. Both were removed in this PR.
|
||||
- This pattern does **not** apply to the `nsenter`-based Caddy reload step (ADR-012), which manages a host systemd service — a different problem class with no bind-mount equivalent.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-011 — single-tenant runner trust model
|
||||
- ADR-012 — nsenter via privileged container for host service management
|
||||
- Issue #598 — original observability stack bind-mount failure
|
||||
- `runner-config.yaml` — `workdir_parent`, `valid_volumes`, `options`
|
||||
57
docs/adr/016-obs-stack-co-location-ci-push.md
Normal file
57
docs/adr/016-obs-stack-co-location-ci-push.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# ADR-016: Observability stack co-location at `/opt/familienarchiv/` with CI-push config sync
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #601 established that the observability stack must survive Gitea CI workspace wipes between nightly runs. When the nightly job completes, act_runner deletes the job workspace. Any Docker container that bind-mounts a config file from a workspace path (`/srv/gitea-workspace/…/infra/observability/prometheus/prometheus.yml`) then references a path that no longer exists on the host. On the next nightly run, Docker Compose either auto-creates an empty directory in its place (causing the container to fail to start because a file mount receives a directory) or finds a stale file from a previous run if the workspace happened to land at the same path.
|
||||
|
||||
ADR-015 solved the workspace bind-mount resolution problem: job workspaces are stored at `/srv/gitea-workspace` so `$(pwd)` inside the job container maps to a real host path. But it did not address persistence: the workspace is still wiped after the job, so bind mounts from workspace-relative paths remain fragile across runs.
|
||||
|
||||
### Decision drivers
|
||||
|
||||
1. Bind-mount sources must point to a host path that persists indefinitely, not to a path that disappears after each CI run.
|
||||
2. Config files must reflect the committed state of the repo after every nightly run (no manual sync steps).
|
||||
3. Secrets must not be written to the workspace or to any path managed by CI; they must survive independently of deployments.
|
||||
4. The solution must not introduce new infrastructure dependencies (no SSH access from CI, no external registry, no additional server-side daemon).
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
**A: Server-pull model** — a systemd timer or cron job on the server does `git pull` from the repo into `/opt/familienarchiv/` and then runs `docker compose up`. Rejected because: (1) requires git credentials on the server and a registered deploy key, (2) adds a second deployment mechanism that diverges from the CI-push model used for the main app stack, (3) timing coupling — the server pull must complete before CI's health checks run, requiring polling or a webhook.
|
||||
|
||||
**B: Separate directory (e.g. `/opt/obs/`)** — keeps obs configs isolated from the app stack. Rejected because: (1) the main app compose files are already in `/opt/familienarchiv/` (managed the same way), and (2) GlitchTip shares the `archive-db` PostgreSQL instance and `archiv-net` Docker network — it is architecturally part of the same deployment unit, not a separate one. Co-location reflects the actual coupling.
|
||||
|
||||
**C: Named Docker configs (Swarm)** — Docker Swarm supports first-class config objects that persist in the cluster. Rejected because the project does not use Swarm and introducing it solely for config persistence is a disproportionate dependency.
|
||||
|
||||
## Decision
|
||||
|
||||
The observability stack is co-located with the main application deployment at `/opt/familienarchiv/`:
|
||||
|
||||
- `docker-compose.observability.yml` → `/opt/familienarchiv/docker-compose.observability.yml`
|
||||
- `infra/observability/` → `/opt/familienarchiv/infra/observability/`
|
||||
|
||||
Both the nightly CI job (`nightly.yml`) and the release job (`release.yml`) copy these files from the workspace checkout to `/opt/familienarchiv/` using `cp -r` on every run (CI-push model). Containers always read config from the permanent location; a workspace wipe has no effect on running containers.
|
||||
|
||||
Environment variables follow a two-source model:
|
||||
|
||||
- `infra/observability/obs.env` (git-tracked, non-secret): all non-sensitive config — host ports, public URLs (`GLITCHTIP_DOMAIN`, `GF_SERVER_ROOT_URL`), and the default `POSTGRES_HOST`. Changes go through PR review. No credentials.
|
||||
- `/opt/familienarchiv/obs-secrets.env` (CI-written, per-deploy): passwords and secret keys only (`GRAFANA_ADMIN_PASSWORD`, `GLITCHTIP_SECRET_KEY`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`), injected fresh from Gitea secrets on every nightly and release deploy. Gitea is the single source of truth for secrets — rotating a secret takes effect on the next deploy without manual server action.
|
||||
|
||||
Both files are passed explicitly via `--env-file` to every obs compose command (config dry-run and `up`). There is no implicit auto-read `.env`. The required key inventory is documented in `docs/DEPLOYMENT.md §4`.
|
||||
|
||||
The CI runner mounts `/opt/familienarchiv` as a bind mount into job containers (see `runner-config.yaml`). This requires a one-time `mkdir -p /opt/familienarchiv/infra` on the server and a runner restart after updating `runner-config.yaml` (see ADR-015 and `docs/DEPLOYMENT.md §3.1`).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Bind-mount sources survive workspace wipes by definition — they are on a persistent host path.
|
||||
- Config is always in sync with the repo after each nightly run.
|
||||
- No new infrastructure dependencies; the CI-push model mirrors how the main app stack is deployed.
|
||||
- Secret rotation requires no manual server action — Gitea secrets are the authoritative store; `obs-secrets.env` is rewritten from scratch on every deploy so a secret change takes effect on the next nightly or release run.
|
||||
|
||||
**Negative:**
|
||||
- `cp -r` does not remove deleted files; a config file removed from the repo persists in `/opt/familienarchiv/infra/observability/` until manually deleted. Acceptable for this project's change frequency. A `rsync -a --delete` would give a clean mirror if this becomes a problem.
|
||||
- Mounting `/opt/familienarchiv/` into CI job containers expands the blast radius of a compromised workflow step — a malicious step could overwrite app compose files and Caddy config. Acceptable because the runner is single-tenant (trusted code only). See `runner-config.yaml` security comment.
|
||||
- Runner must be restarted (`systemctl restart gitea-runner`) after any change to `runner-config.yaml` for the new mount to take effect.
|
||||
48
docs/adr/017-management-port-security.md
Normal file
48
docs/adr/017-management-port-security.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# ADR-017: Spring Boot 4.0 management port shares the main security filter chain
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The Familienarchiv backend runs Spring Boot Actuator on a dedicated management port (8081) so that Caddy never proxies `/actuator/*` requests and Prometheus can reach the scrape endpoint directly inside `archiv-net`.
|
||||
|
||||
In earlier Spring Boot versions (< 4.0), the management server ran in an isolated child application context whose security was governed independently by `ManagementWebSecurityAutoConfiguration`. The main app's `SecurityConfig` filter chain (port 8080) never intercepted requests arriving on port 8081.
|
||||
|
||||
In Spring Boot 4.0 with Jetty, this isolation was removed. The management server now traverses the **same** Spring Security `FilterChainProxy` as the main application. Concretely:
|
||||
|
||||
- Any `SecurityFilterChain` bean in the application context is evaluated for requests arriving on the management port.
|
||||
- There is no longer a separate "management security" child context.
|
||||
|
||||
This was discovered when Prometheus began receiving HTTP 401 responses from `/actuator/prometheus` despite the endpoint being exposed and the `micrometer-registry-prometheus` dependency being present. Prometheus rejected these responses with `received unsupported Content-Type "text/html"` because the main filter chain's form-login `DelegatingAuthenticationEntryPoint` was redirecting unauthenticated requests to `/login` (302 → HTML).
|
||||
|
||||
A secondary issue: Spring Boot 4.0 no longer auto-enables Prometheus metrics export — `management.prometheus.metrics.export.enabled` must be set explicitly, and the Prometheus scrape endpoint requires `spring-boot-starter-micrometer-metrics` (a new starter that was split out in Spring Boot 4.0).
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Dedicated management `SecurityFilterChain`** scoped to `/actuator/**` at `@Order(1)` (highest precedence). This chain:
|
||||
- `permitAll()` for `/actuator/health` and `/actuator/prometheus` — required for Docker health checks and unauthenticated Prometheus scraping.
|
||||
- `authenticated()` for all other actuator endpoints — blocks `/actuator/metrics`, `/actuator/info`, etc. without credentials.
|
||||
- Uses an explicit `401` entry point (not form-login redirect) so that API clients — including Prometheus — receive a machine-readable status code rather than an HTML redirect.
|
||||
- No CSRF, no form login.
|
||||
|
||||
2. **Belt-and-suspenders `permitAll()` in the main `SecurityFilterChain`** for `/actuator/health` and `/actuator/prometheus`, in case a future configuration change causes these paths to escape the management chain's `securityMatcher`.
|
||||
|
||||
3. **Network isolation as the outer defense boundary.** Port 8081 is not published in `docker-compose.yml` and is not routed through Caddy. Only services inside `archiv-net` (primarily Prometheus and the Docker health checker) can reach the management port.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **Exclude `ManagementWebSecurityAutoConfiguration`:** This auto-configuration no longer exists in Spring Boot 4.0. Exclusion is not applicable.
|
||||
- **Keep `SecurityConfig` as the sole filter chain without `@Order(1)` management chain:** The main chain's form-login `DelegatingAuthenticationEntryPoint` redirects browser-like clients to `/login` (302). Prometheus and automated health check clients cannot follow this redirect, so the endpoint would be unreachable without a dedicated chain that returns plain 401 or 200.
|
||||
- **Per-endpoint `@Order(1)` filter chain using `EndpointRequest.toAnyEndpoint()`:** The `spring-boot-security` artifact that provides `EndpointRequest` is not a transitive dependency of `spring-boot-starter-actuator` in Spring Boot 4.0. Using a path-based `securityMatcher("/actuator/**")` achieves the same scoping without an extra dependency.
|
||||
|
||||
## Consequences
|
||||
|
||||
- All actuator endpoints on port 8081 that are not explicitly `permitAll()`-ed require HTTP Basic credentials. Without valid credentials, the response is 401 (not a redirect).
|
||||
- Adding a new actuator endpoint to `management.endpoints.web.exposure.include` implicitly protects it via `anyRequest().authenticated()` in the management chain — no additional `permitAll()` needed unless intentional.
|
||||
- A regression test (`ActuatorPrometheusIT`) verifies:
|
||||
- `/actuator/prometheus` returns 200 without credentials.
|
||||
- `/actuator/metrics` returns 401 without credentials.
|
||||
- Prometheus metric names are present in the response body.
|
||||
- If port 8081 is ever accidentally published in `docker-compose.yml`, actuator endpoints other than health and prometheus are still protected by HTTP Basic. This reduces (but does not eliminate) the risk of inadvertent exposure.
|
||||
86
docs/adr/018-glitchtip-frontend-error-tracking.md
Normal file
86
docs/adr/018-glitchtip-frontend-error-tracking.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# ADR-018: GlitchTip frontend error tracking via @sentry/sveltekit
|
||||
|
||||
**Date:** 2026-05-17
|
||||
**Status:** Accepted
|
||||
**Deciders:** Marcel Raddatz
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Familienarchiv had no client-side error reporting. When a user encountered a crash
|
||||
or unhandled error in the SvelteKit frontend, there was no way for the operator to
|
||||
observe it — errors were invisible until a user manually reported them. A GlitchTip
|
||||
instance (self-hosted, Sentry-compatible) was already running as part of the
|
||||
observability stack (`docker-compose.observability.yml`). The backend already reported
|
||||
server-side errors to it.
|
||||
|
||||
We needed a way to:
|
||||
1. Capture frontend errors automatically and route them to GlitchTip.
|
||||
2. Give users a visible error identifier they can include in a support message.
|
||||
3. Do this without leaking personally identifiable information (PII) from the family
|
||||
archive — documents contain personal histories, names, and relationships.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Use `@sentry/sveltekit` (the official Sentry SDK for SvelteKit) to:
|
||||
|
||||
- Initialise with `sendDefaultPii: false` on both `hooks.server.ts` and `hooks.client.ts`.
|
||||
- Pass a callback to `Sentry.handleErrorWithSentry()` that returns
|
||||
`{ message, errorId }` where `errorId` is `Sentry.lastEventId()` when Sentry
|
||||
captured the event, or a fresh `crypto.randomUUID()` as fallback.
|
||||
- Display the `errorId` on the `+error.svelte` page so users can include it in a
|
||||
report to the operator.
|
||||
|
||||
The SDK is initialised with `enabled: !!import.meta.env.VITE_SENTRY_DSN` so that
|
||||
development and CI builds without a DSN configured do not send any events.
|
||||
|
||||
`VITE_SENTRY_DSN` is a write-only ingest key — it can POST events to GlitchTip but
|
||||
cannot read them. It is safe to include in the client bundle per the Sentry security
|
||||
model; it does not require rotation like a password.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Sentry SaaS** — rejected. The archive contains private family documents and personal
|
||||
history. Sending error events with stack traces to a US-hosted third party is
|
||||
inconsistent with the project's data-minimisation posture. Self-hosted GlitchTip on
|
||||
the same Hetzner VPS keeps all data on infrastructure the operator controls.
|
||||
|
||||
**Custom error logging endpoint** — rejected. The @sentry/sveltekit SDK handles
|
||||
SvelteKit's hook lifecycle, source-map upload, and event grouping automatically.
|
||||
Reimplementing this would cost significant engineering time for no benefit.
|
||||
|
||||
**Log-only (no user-visible errorId)** — rejected. Without a visible error ID, users
|
||||
can only describe what happened in natural language, making it hard to correlate a
|
||||
report with a specific GlitchTip event. The `errorId` closes this gap at negligible UI
|
||||
cost.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Frontend errors are now observable without requiring user reports.
|
||||
- Users can provide an `errorId` that maps directly to a GlitchTip event.
|
||||
- `sendDefaultPii: false` ensures names, IPs, and cookie values are not included in
|
||||
captured events.
|
||||
- `tracesSampleRate: 0.1` limits trace volume to 10% of transactions, keeping
|
||||
GlitchTip load low on the shared VPS.
|
||||
|
||||
**Negative / trade-offs:**
|
||||
- The `@sentry/sveltekit` SDK is now a production dependency. SDK updates must be
|
||||
reviewed for changes to the default PII scrubbing behaviour.
|
||||
- The `handleError` callback in both hooks returns a hardcoded English message
|
||||
(`'An unexpected error occurred'`). This bypasses Paraglide i18n — the error page
|
||||
will always show English text when the hooks are active, regardless of the user's
|
||||
locale. This is acceptable because: (a) the error page is a last-resort fallback
|
||||
not part of normal UX, (b) the `errorId` is the actionable information, not the
|
||||
message text. A future ADR may address this if internationalised error messages
|
||||
become a requirement.
|
||||
- `Sentry.lastEventId()` returns `undefined` when Sentry did not capture the event
|
||||
(e.g. DSN not configured). The `crypto.randomUUID()` fallback guarantees an `errorId`
|
||||
is always present, but that UUID will not appear in GlitchTip.
|
||||
94
docs/adr/019-container-hardening-baseline.md
Normal file
94
docs/adr/019-container-hardening-baseline.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# ADR-019 — Container hardening baseline: non-root user + read-only filesystem
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-17
|
||||
**PR:** #611
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The OCR service ran as `root` inside its container by default. This violated CIS Docker Benchmark §4.1 and CIS §4.6, and meant that any exploit in the OCR pipeline (untrusted PDF content, model deserialization, ZIP handling) could write to or execute anything inside the container without restriction.
|
||||
|
||||
The following risks were present before this baseline:
|
||||
|
||||
- A path-traversal in the ZIP-based training endpoint could overwrite arbitrary paths on the container filesystem (including Python source files and model files).
|
||||
- A compromised dependency running at startup could persist itself to the image layers or model volumes.
|
||||
- Misconfigured model downloads could overwrite `/etc/passwd` or similar via path-traversal — possible because root can write everywhere.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
All containers in this project that have no operational need for elevated privileges **must** apply the following hardening baseline:
|
||||
|
||||
### 1. Non-root user
|
||||
|
||||
Create a dedicated user with a fixed UID and no login shell:
|
||||
|
||||
```dockerfile
|
||||
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1000 <service>
|
||||
```
|
||||
|
||||
Set `HOME` explicitly to a path owned by this user. Do not rely on `~` expansion for any path resolution in application code.
|
||||
|
||||
### 2. Read-only container filesystem
|
||||
|
||||
```yaml
|
||||
read_only: true
|
||||
```
|
||||
|
||||
All paths the application writes to at runtime must be explicitly declared as either a named volume or a `tmpfs` mount. This turns any unexpected write attempt into an immediate, visible `PermissionError` rather than a silent success.
|
||||
|
||||
### 3. Per-path write carve-outs
|
||||
|
||||
Declare only the paths that are actually written at runtime:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- <service>_models:/app/models # persistent model storage
|
||||
- <service>_cache:/app/cache # HuggingFace / ketos download cache
|
||||
tmpfs:
|
||||
- /tmp:size=512m # transient scratch space (ZIP extraction etc.)
|
||||
```
|
||||
|
||||
Do not mount the home directory as a volume unless necessary — use `XDG_CACHE_HOME` and `TORCH_HOME` env vars to redirect library cache writes to the declared writable paths instead.
|
||||
|
||||
### 4. Dropped capabilities and privilege escalation prevention
|
||||
|
||||
```yaml
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
```
|
||||
|
||||
A Python/FastAPI service on port 8000+ requires no Linux capabilities. Dropping all and blocking privilege escalation via setuid prevents any capability regain even if a dependency contains a SUID binary.
|
||||
|
||||
### 5. Startup root canary
|
||||
|
||||
Log a warning during startup if the process is running as root. This catches misconfiguration (e.g., `USER` directive accidentally removed in a future Dockerfile edit) before it becomes a silent vulnerability:
|
||||
|
||||
```python
|
||||
if os.getuid() == 0:
|
||||
logger.warning("Running as root — CIS Docker §4.1 violation")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Any exploit that achieves code execution inside the container is confined: it cannot write outside the declared volumes, cannot acquire new capabilities, and cannot persist to the image filesystem.
|
||||
- `PermissionError` on startup is an explicit, diagnosable failure rather than a silent privilege misuse.
|
||||
- The startup canary catches accidental regressions in the non-root setup.
|
||||
|
||||
**Negative / operational cost:**
|
||||
- Every new feature that writes to a new path (e.g., a new model cache directory, a new scratch path) must add a volume or tmpfs mount. The `read_only: true` flag makes this a hard constraint, not a suggestion.
|
||||
- Library dependencies that write to `HOME` without respecting `XDG_CACHE_HOME` must be identified and redirected explicitly (see `TORCH_HOME`, `XDG_CACHE_HOME`, `HF_HOME` in `docker-compose.yml`).
|
||||
- Existing named volumes written by root (pre-baseline) must be dropped and recreated before upgrading. See [DEPLOYMENT.md §8](../DEPLOYMENT.md#8-upgrade-notes).
|
||||
|
||||
---
|
||||
|
||||
## Applicability
|
||||
|
||||
This baseline applies to the OCR service (PR #611). It should be applied to any new container added to the project unless there is a documented, specific operational reason a capability or writable filesystem is required.
|
||||
94
docs/adr/020-stateful-auth-via-spring-session-jdbc.md
Normal file
94
docs/adr/020-stateful-auth-via-spring-session-jdbc.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# ADR-020 — Stateful Authentication via Spring Session JDBC
|
||||
|
||||
**Date:** 2026-05-17
|
||||
**Status:** Accepted
|
||||
**Issue:** #523
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
PR #521 (closing #520) introduced `AuthTokenCookieFilter` to unblock a production deploy.
|
||||
The filter promotes an `auth_token` cookie — which contains the full HTTP Basic credential
|
||||
(`Basic <base64(email:password)>`) — to an `Authorization` header so browser-direct `/api/*`
|
||||
calls authenticate correctly behind Caddy.
|
||||
|
||||
This model has three concrete problems:
|
||||
|
||||
1. **Cookie = credential.** A stolen `auth_token` cookie leaks the user's password in
|
||||
base64-encoded plaintext. No decode step is needed; the cookie value is directly usable
|
||||
as a credential forever.
|
||||
2. **No server-side revocation.** Logout deletes the local cookie but the credential
|
||||
remains valid until the 24 h `Max-Age` elapses. An attacker who copied the cookie before
|
||||
logout retains access.
|
||||
3. **No audit signal.** There is no server-side record of login or logout events. Observability
|
||||
and compliance tooling cannot reconstruct "who was logged in when".
|
||||
|
||||
Additionally, Nora flagged that `url.protocol === 'https:'` in `login/+page.server.ts` is
|
||||
incorrect behind Caddy: SvelteKit sees `http`, so `Secure=false` was set on the credential
|
||||
cookie in production, transmitting it in cleartext from Caddy to the browser on any network
|
||||
path without TLS.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Replace the `auth_token` / `AuthTokenCookieFilter` model with **Spring Session JDBC**:
|
||||
|
||||
- A `POST /api/auth/login` endpoint in a new `auth` package authenticates with `email +
|
||||
password`, creates a server-side session record in PostgreSQL, and returns the `AppUser`
|
||||
JSON in the response body.
|
||||
- The response sets an **opaque** `fa_session` cookie (`HttpOnly`, `SameSite=Strict`,
|
||||
`Secure` in non-dev profiles, `Max-Age=28800` — 8 h idle timeout) that contains only the
|
||||
session ID, never a credential.
|
||||
- A `POST /api/auth/logout` endpoint invalidates the session record immediately. Subsequent
|
||||
requests carrying the same cookie return 401.
|
||||
- `AuthTokenCookieFilter` is deleted in the same PR. No transitional coexistence period.
|
||||
- Cookie name `fa_session` (not the default `SESSION`) minimises framework fingerprinting.
|
||||
|
||||
Session storage uses the canonical `spring_session` / `spring_session_attributes` tables,
|
||||
re-introduced via `V67__recreate_spring_session_tables.sql` (dropped by V2 when the
|
||||
dependency was previously removed as unused).
|
||||
|
||||
**Idle timeout:** 8 h (`MaxInactiveIntervalInSeconds = 28800`). No 24 h absolute cap is
|
||||
implemented in Phase 1 — the 8 h idle bound contains the risk to one workday. A weekend-long
|
||||
active session is acceptable given the family-archive threat model. The absolute cap and
|
||||
additional revocation paths (password-change, admin force-logout) land in Phase 2 (#524).
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Stay on Basic cookie + add a server-side revocation table
|
||||
|
||||
Keeps the credential-in-cookie problem. Implementing a revocation table would re-invent
|
||||
Spring Session badly — we'd write bespoke session storage that already exists and is
|
||||
well-tested upstream.
|
||||
|
||||
### JWT (stateless)
|
||||
|
||||
Opaque revocation is simpler than JWT revocation (token introspection or short-lived tokens
|
||||
+ refresh). The cluster is single-node; session affinity is not a constraint. Stateless tokens
|
||||
buy complexity without benefit here. JWKS infrastructure and refresh-token rotation are
|
||||
unnecessary for a family archive with < 50 concurrent users.
|
||||
|
||||
### Keep `auth_token` cookie but add `AuthTokenCookieFilter` improvements
|
||||
|
||||
The root problem is that the cookie contains the credential. No amount of filter hardening
|
||||
fixes that. Nora's P1 flag stands until the credential leaves the cookie.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- **One breaking deploy.** All existing sessions (the `auth_token` cookies) become inert
|
||||
on the next request after the deploy. The SvelteKit `handleAuth` hook redirects to
|
||||
`/login?reason=expired`; a banner renders. Users re-login. No data loss.
|
||||
- **~2 KB per active session** in PostgreSQL (`spring_session_attributes` stores the
|
||||
serialised `SecurityContext`). With < 50 family members, this is immaterial.
|
||||
- **Session cleanup task** runs on the default Spring Session JDBC schedule (every 10 min).
|
||||
No custom job needed.
|
||||
- **Caddy / infrastructure unchanged.** `forward-headers-strategy: native` already ensures
|
||||
`Secure` cookies work correctly behind the reverse proxy.
|
||||
- **Dev profile:** `application-dev.yaml` sets `secure: false` on the session cookie so
|
||||
local HTTP dev (port 5173 → 8080) works without TLS.
|
||||
68
docs/adr/021-tmpdir-persistent-volume-staging.md
Normal file
68
docs/adr/021-tmpdir-persistent-volume-staging.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ADR-021 — Route Surya model-download staging to the persistent cache volume via TMPDIR
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-18
|
||||
**Issue:** #614
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
After the container hardening baseline (ADR-019), the OCR service runs with `read_only: true` and a 512 MB `/tmp` tmpfs. The tmpfs was sized for training-ZIP extraction (typically 20–50 images, well under 100 MB).
|
||||
|
||||
Surya's `download_directory()` (surya ≥ 0.6, `surya/common/s3.py`) stages every model file through `tempfile.TemporaryDirectory()` before moving it to the final cache location. `TemporaryDirectory()` honours `$TMPDIR` and falls back to `/tmp`. The `text_recognition` model is 1.34 GB; future Surya models will be in the same range. This blows the 512 MB budget at ~510 MB with `OSError: [Errno 28] No space left on device`.
|
||||
|
||||
The host has 1.8 TB free on the disk that backs `/app/cache`. The failure is a routing problem, not a capacity problem.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Set `TMPDIR=/app/cache/.tmp` in the OCR container so all `tempfile` staging goes to the persistent SSD-backed cache volume.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml / docker-compose.prod.yml — ocr-service.environment
|
||||
TMPDIR: /app/cache/.tmp
|
||||
```
|
||||
|
||||
```dockerfile
|
||||
# ocr-service/Dockerfile — default for bare docker-run usage
|
||||
ENV TMPDIR=/app/cache/.tmp
|
||||
```
|
||||
|
||||
```bash
|
||||
# ocr-service/entrypoint.sh — idempotent directory bootstrap
|
||||
mkdir -p "${TMPDIR:-/tmp}"
|
||||
find "${TMPDIR:-/tmp}" -mindepth 1 -mtime +1 -delete 2>/dev/null || true
|
||||
```
|
||||
|
||||
A one-shot `ocr-volume-init` service in both compose files runs before `ocr-service` to `chown -R 1000:1000` the volumes and `mkdir -p /app/cache/.tmp`. This replaces the manual `docker run --rm alpine chown` step performed on 2026-05-18 and makes fresh-volume correctness a permanent infrastructure-as-code guarantee.
|
||||
|
||||
The `/tmp` tmpfs remains at 512 MB and continues to serve training-ZIP extraction and transient PDF buffers — its original purpose.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Surya model downloads complete: 1.34 GB fits on the SSD, not in 512 MB of RAM.
|
||||
- `shutil.move()` from staging → cache becomes a same-filesystem `rename(2)` — atomic and near-free.
|
||||
- Volume ownership is now automated; no manual `docker run --rm alpine chown` on redeploy.
|
||||
- `/tmp` retains its small 512 MB DoS cap for attacker-influenceable training endpoints (post-auth only, behind `X-Training-Token`).
|
||||
- ZIP Slip protection in `_validate_zip_entry()` is unaffected — it uses `os.path.realpath()` anchored to the extraction directory regardless of where that directory lives.
|
||||
|
||||
**Negative / Trade-offs**
|
||||
|
||||
- If the container is `docker kill`ed mid-download, partial files persist in `/app/cache/.tmp` across container restarts. Mitigated by the `find -mtime +1 -delete` in `entrypoint.sh` — orphans older than one day are removed on startup.
|
||||
- `TMPDIR` pointing inside a volume mount is non-obvious. Any future move of `/app/cache` to a different storage tier must revisit this setting. This ADR is the load-bearing reference.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Approach B — Enlarge `/tmp` to 4 GB**
|
||||
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on CX32 hosts with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
|
||||
|
||||
**Approach C — Both TMPDIR redirect and enlarged /tmp**
|
||||
Belt-and-suspenders: Approach A + 1 GB tmpfs. Discarded in favour of the cleaner Approach A. The defence-in-depth benefit does not outweigh the extra compose churn; the 512 MB cap on `/tmp` is intentional.
|
||||
@@ -8,9 +8,11 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows
|
||||
|
||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||
System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.")
|
||||
|
||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -17,16 +17,16 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.")
|
||||
}
|
||||
|
||||
System_Boundary(observability, "Observability Stack (docker-compose.observability.yml)") {
|
||||
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
|
||||
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend management port 8081 (/actuator/prometheus), node-exporter, and cAdvisor. Retention: 30 days.")
|
||||
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
|
||||
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
|
||||
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
|
||||
Container(promtail, "Promtail", "grafana/promtail:3.4.2", "Ships Docker container logs to Loki via Docker SD.")
|
||||
Container(tempo, "Tempo", "grafana/tempo:2.7.2", "Distributed trace storage. OTLP gRPC receiver on port 4317 (archiv-net). Grafana queries traces on port 3200 (obs-net). All ports internal only.")
|
||||
Container(tempo, "Tempo", "grafana/tempo:2.7.2", "Distributed trace storage. OTLP HTTP receiver on port 4318 (archiv-net). Grafana queries traces on port 3200 (obs-net). All ports internal only.")
|
||||
Container(grafana, "Grafana", "grafana/grafana-oss:11.6.1", "Unified observability UI — dashboards, logs, traces. Datasources (Prometheus, Loki, Tempo) and three dashboards are auto-provisioned.")
|
||||
Container(glitchtip, "GlitchTip", "glitchtip/glitchtip:v4", "Sentry-compatible error tracker — web process. Receives frontend + backend error events, groups by fingerprint, provides issue UI with stack traces.")
|
||||
Container(obs_glitchtip_worker, "GlitchTip Worker", "glitchtip/glitchtip:v4", "Celery + beat worker — async event ingestion, notifications, cleanup.")
|
||||
Container(glitchtip, "GlitchTip", "glitchtip/glitchtip:6.1.6", "Sentry-compatible error tracker — web process. Receives frontend + backend error events, groups by fingerprint, provides issue UI with stack traces.")
|
||||
Container(obs_glitchtip_worker, "GlitchTip Worker", "glitchtip/glitchtip:6.1.6", "Celery + beat worker — async event ingestion, notifications, cleanup.")
|
||||
Container(obs_redis, "Redis", "redis:7-alpine", "Celery task queue for GlitchTip async workers.")
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ Rel(backend, mail, "Sends notification and password-reset emails (optional)", "S
|
||||
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(mc, storage, "Bootstraps bucket + service account on startup", "MinIO Client CLI")
|
||||
Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
|
||||
Rel(backend, tempo, "Sends distributed traces via OTLP", "gRPC / OTLP / port 4317 (archiv-net)")
|
||||
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
|
||||
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
||||
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
||||
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
||||
|
||||
@@ -7,15 +7,24 @@ Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and constructs an Authentication token; delegates credential validation to DaoAuthenticationProvider via BCrypt. Permits password-reset, invite, and register endpoints without authentication.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects. Logs unknown permissions.")
|
||||
Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
|
||||
Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF is disabled pending #524.")
|
||||
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation in #524.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
||||
}
|
||||
|
||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
||||
Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
|
||||
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie")
|
||||
Rel(authCtrl, authSvc, "Validate creds + audit")
|
||||
Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
|
||||
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
|
||||
Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
|
||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
||||
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
||||
Rel(userDetails, db, "Loads user by email", "JDBC")
|
||||
Rel(sessionRepo, db, "spring_session, spring_session_attributes", "JDBC")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
@startuml
|
||||
title Authentication Flow (behind Caddy reverse proxy)
|
||||
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
|
||||
note over Browser, DB
|
||||
Phase 1 of the auth rewrite (ADR-020 / #523).
|
||||
Replaces the Basic-credentials-in-cookie model
|
||||
with an opaque server-side session id (fa_session).
|
||||
end note
|
||||
|
||||
actor User
|
||||
participant Browser
|
||||
participant "Caddy (TLS termination)" as Caddy
|
||||
participant "Frontend (SvelteKit)" as Frontend
|
||||
participant "Backend (Spring Boot)" as Backend
|
||||
participant PostgreSQL as DB
|
||||
participant "spring_session\n(PostgreSQL)" as DB
|
||||
|
||||
== Login ==
|
||||
User -> Browser: Enter email + password
|
||||
Browser -> Caddy: HTTPS POST /login (form action)
|
||||
Browser -> Caddy: HTTPS POST /?/login (form action)
|
||||
note right of Caddy
|
||||
Caddy terminates TLS and forwards
|
||||
to Frontend over HTTP with:
|
||||
@@ -17,33 +23,54 @@ note right of Caddy
|
||||
X-Forwarded-For: <client IP>
|
||||
X-Forwarded-Host: archiv.raddatz.cloud
|
||||
end note
|
||||
Caddy -> Frontend: HTTP POST /login\n+ X-Forwarded-Proto: https
|
||||
Frontend -> Frontend: Base64 encode "email:password"
|
||||
Frontend -> Backend: GET /api/users/me\nAuthorization: Basic <token>\n+ X-Forwarded-Proto: https
|
||||
Caddy -> Frontend: HTTP POST /?/login + X-Forwarded-Proto: https
|
||||
Frontend -> Backend: POST /api/auth/login\n{email, password}\n+ X-Forwarded-Proto: https
|
||||
note right of Backend
|
||||
server.forward-headers-strategy: native
|
||||
Jetty's ForwardedRequestCustomizer
|
||||
reads X-Forwarded-Proto so
|
||||
request.getScheme() returns "https".
|
||||
→ request.getScheme() = "https"
|
||||
→ Secure cookie flag set automatically.
|
||||
end note
|
||||
Backend -> Backend: Spring Security parses Basic Auth
|
||||
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)
|
||||
Backend --> Frontend: 200 OK — UserDTO
|
||||
Frontend -> Caddy: Set-Cookie: auth_token=<base64>\n(httpOnly, **Secure**, SameSite=strict, maxAge=86400)
|
||||
note right of Frontend
|
||||
Secure flag is set because the
|
||||
request scheme observed by the
|
||||
app is https (forwarded by Caddy).
|
||||
end note
|
||||
Caddy -> Browser: HTTPS 200 + Set-Cookie
|
||||
Browser -> Caddy: HTTPS GET / (next request)
|
||||
Caddy -> Frontend: HTTP GET / + X-Forwarded-Proto: https
|
||||
Frontend -> Frontend: hooks.server.ts reads auth_token cookie
|
||||
Frontend -> Backend: GET /api/users/me\nAuthorization: Basic <token>
|
||||
Backend --> Frontend: 200 OK — user in event.locals
|
||||
Frontend --> Caddy: rendered page
|
||||
Caddy --> Browser: HTTPS 200
|
||||
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure
|
||||
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||
|
||||
== Authenticated request ==
|
||||
Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
|
||||
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
|
||||
Frontend -> Frontend: hooks.server.ts reads fa_session
|
||||
Frontend -> Backend: GET /api/users/me\nCookie: fa_session=<opaque>
|
||||
Backend -> DB: SELECT * FROM spring_session\nWHERE SESSION_ID = ?
|
||||
DB --> Backend: row (or null if expired)
|
||||
alt Session valid
|
||||
Backend -> DB: UPDATE spring_session\nSET LAST_ACCESS_TIME = now
|
||||
Backend --> Frontend: 200 OK — AppUser
|
||||
Frontend --> Caddy: rendered page
|
||||
Caddy --> Browser: HTTPS 200
|
||||
else Session expired (idle > 8h) or unknown
|
||||
Backend --> Frontend: 401 Unauthorized
|
||||
Frontend -> Frontend: hooks: delete fa_session cookie
|
||||
Frontend --> Caddy: 302 → /login?reason=expired
|
||||
Caddy --> Browser: HTTPS 302
|
||||
end
|
||||
|
||||
== Logout ==
|
||||
Browser -> Caddy: HTTPS POST /logout
|
||||
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>
|
||||
Frontend -> Backend: POST /api/auth/logout\nCookie: fa_session=<opaque>
|
||||
Backend -> Backend: session.invalidate()\nSecurityContextHolder.clearContext()
|
||||
Backend -> DB: DELETE FROM spring_session\nWHERE SESSION_ID = ?
|
||||
Backend -> Backend: AuditService.log(LOGOUT,\n {userId, ip, ua})
|
||||
Backend --> Frontend: 204 No Content
|
||||
Frontend -> Frontend: cookies.delete('fa_session')
|
||||
Frontend --> Caddy: 303 → /login
|
||||
Caddy --> Browser: HTTPS 303 (cookie cleared)
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -19,6 +19,39 @@ Both containers live in the `gitea_gitea` Docker network on the VPS. The runner
|
||||
|
||||
The `gitea-runner` container mounts the host Docker socket (`/var/run/docker.sock`). When a workflow job runs, act_runner spawns a **sibling container** for each job. That job container also gets the Docker socket mounted (via `valid_volumes` in `runner-config.yaml`), enabling `docker compose` calls in workflow steps.
|
||||
|
||||
### Workspace bind-mount setup (DooD path resolution)
|
||||
|
||||
When a workflow step calls `docker compose up` with relative bind-mount sources (e.g. `./infra/observability/prometheus/prometheus.yml`), Compose resolves them against `$(pwd)` inside the job container and passes the resulting **absolute path** to the host Docker daemon. The host daemon then tries to bind-mount that path from the **host filesystem**.
|
||||
|
||||
In the default DooD setup the job container's workspace lives in the act_runner overlay2 layer — the host has no directory at that path, auto-creates an empty one, and the container fails with:
|
||||
|
||||
```
|
||||
error mounting "…/prometheus/prometheus.yml" to rootfs at "/etc/prometheus/prometheus.yml": not a directory
|
||||
```
|
||||
|
||||
**Solution (ADR-015):** store job workspaces on a real host path and mount it at the **same absolute path** inside the runner and every job container. `runner-config.yaml` configures this via `workdir_parent`, `valid_volumes`, and `options`.
|
||||
|
||||
**One-time host setup** (required on any fresh VPS):
|
||||
|
||||
```bash
|
||||
mkdir -p /srv/gitea-workspace
|
||||
# Then add to the runner service in ~/docker/gitea/compose.yaml:
|
||||
# volumes:
|
||||
# - /srv/gitea-workspace:/srv/gitea-workspace
|
||||
# Restart the runner container for the change to take effect.
|
||||
```
|
||||
|
||||
The path `/srv/gitea-workspace` is the canonical workspace root. It must be identical on the host and inside job containers — if the paths differ, Compose still resolves to the container-internal path, which the host daemon cannot find (the original bug).
|
||||
|
||||
**Disk management:** act_runner cleans per-run subdirectories on completion. Orphaned directories from interrupted runs accumulate under `/srv/gitea-workspace` and should be pruned manually if disk space becomes a concern:
|
||||
|
||||
```bash
|
||||
# List workspace directories older than 7 days
|
||||
find /srv/gitea-workspace -mindepth 3 -maxdepth 3 -type d -mtime +7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Running host-level commands from CI (nsenter pattern)
|
||||
|
||||
Job containers are unprivileged and do not share the host's PID/mount/network namespaces. Commands like `systemctl` that target the host daemon are therefore unavailable by default. When a workflow step needs to manage a host service (e.g. `systemctl reload caddy`), it uses the Docker socket to spin up a **privileged sibling container** in the host PID namespace:
|
||||
@@ -108,6 +141,33 @@ nsenter: failed to execute /bin/systemctl: No such file or directory
|
||||
|
||||
The first error means the Docker socket is not mounted into the job container — check `valid_volumes` in `/root/docker/gitea/runner-config.yaml` on the VPS. The second means the Alpine image is running but cannot enter the host mount namespace; verify `--privileged` and `--pid=host` are both present in the workflow step.
|
||||
|
||||
**Failure mode 4 — workspace bind-mount not configured (observability stack or any compose-with-file-mounts job)**
|
||||
|
||||
Symptom in CI log:
|
||||
```
|
||||
Error response from daemon: error while creating mount source path "…/prometheus/prometheus.yml": mkdir …: not a directory
|
||||
```
|
||||
|
||||
Or the service starts but immediately crashes because a config file was mounted as an empty directory.
|
||||
|
||||
Cause: `/srv/gitea-workspace` does not exist on the host, or the runner container's `compose.yaml` is missing the `- /srv/gitea-workspace:/srv/gitea-workspace` volume line.
|
||||
|
||||
Diagnosis:
|
||||
```bash
|
||||
ssh root@<vps>
|
||||
ls -la /srv/gitea-workspace # must exist and be a directory
|
||||
docker inspect gitea-runner | grep -A5 Mounts # must show /srv/gitea-workspace
|
||||
```
|
||||
|
||||
Recovery:
|
||||
```bash
|
||||
mkdir -p /srv/gitea-workspace
|
||||
# Add volume line to runner compose.yaml, then:
|
||||
docker compose -f ~/docker/gitea/compose.yaml up -d gitea-runner
|
||||
```
|
||||
|
||||
See `docs/DEPLOYMENT.md §3.1` and ADR-015 for the full setup rationale.
|
||||
|
||||
---
|
||||
|
||||
## Gitea vs GitHub Actions Differences
|
||||
|
||||
@@ -12,11 +12,11 @@ The original spec in this doc proposed an overlay pattern (`docker compose -f do
|
||||
|
||||
---
|
||||
|
||||
## Observability stack — not yet deployed
|
||||
## Observability stack
|
||||
|
||||
Prometheus, Loki, Grafana, Alertmanager, Uptime Kuma, GlitchTip and ntfy are **not** part of the production deployment that #497 landed. They are tracked as follow-up issue #498.
|
||||
The observability stack (Prometheus, Loki, Grafana, Tempo, GlitchTip) ships as a separate `docker-compose.observability.yml` alongside the main stack. Configuration lives under `infra/observability/`.
|
||||
|
||||
When that lands the observability containers will join `docker-compose.prod.yml` under a dedicated profile so they can be operated alongside the application stack without affecting the application containers' restart cycle.
|
||||
→ See [docs/DEPLOYMENT.md §4](../DEPLOYMENT.md#4-logs--observability) for the full setup procedure, service URLs, first-run steps, and env var reference.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,14 +38,16 @@ export default defineConfig(
|
||||
'no-undef': 'off',
|
||||
// This rule is designed for Svelte 5's own routing system using resolve().
|
||||
// In SvelteKit, <a href> and goto() from $app/navigation are the correct patterns — resolve() is not needed.
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
// Prevents accidental console.log left in source. console.warn and console.error
|
||||
// are still permitted for intentional server-side logging (e.g. hooks.server.ts).
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
@@ -72,6 +74,13 @@ export default defineConfig(
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
// E2E tests use console.log for diagnostic output — allow it there.
|
||||
files: ['e2e/**'],
|
||||
rules: {
|
||||
'no-console': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.test.ts'],
|
||||
rules: {
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"error_file_too_large": "Die Datei ist zu groß (max. 50 MB).",
|
||||
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
|
||||
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
|
||||
"error_invalid_credentials": "E-Mail-Adresse oder Passwort ist falsch.",
|
||||
"error_session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.",
|
||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
@@ -347,6 +350,9 @@
|
||||
"admin_system_import_status_running": "Import läuft…",
|
||||
"admin_system_import_status_done": "Import abgeschlossen",
|
||||
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
||||
"admin_system_import_skipped_label": "übersprungen",
|
||||
"import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur",
|
||||
"import_reason_file_read_error": "Fehler beim Lesen der Datei",
|
||||
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
||||
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
||||
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
||||
@@ -473,7 +479,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Gesch.",
|
||||
"dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||
"doc_status_placeholder": "Platzhalter",
|
||||
"doc_status_uploaded": "Hochgeladen",
|
||||
@@ -773,19 +779,15 @@
|
||||
"admin_invite_created_title": "Einladung erstellt",
|
||||
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
||||
|
||||
"greeting_morning": "Guten Morgen, {name}.",
|
||||
"greeting_day": "Hallo, {name}.",
|
||||
"greeting_evening": "Guten Abend, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
|
||||
"dashboard_blocks": "{count} Abschnitte",
|
||||
"dashboard_resume_cta": "Weitertranskribieren",
|
||||
"dashboard_resume_other": "oder anderen Brief wählen",
|
||||
"dashboard_empty_title": "Noch kein Dokument begonnen",
|
||||
"dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.",
|
||||
"dashboard_empty_cta": "Zum Archiv",
|
||||
|
||||
"dashboard_mission_caption": "Offene Aufgaben",
|
||||
"queue_segment": "Segmentieren",
|
||||
"queue_segment_blurb": "Seiten aufteilen",
|
||||
@@ -795,7 +797,6 @@
|
||||
"queue_review_blurb": "Texte kontrollieren",
|
||||
"queue_n_open": "{n} offen",
|
||||
"queue_show_all": "Alle anzeigen →",
|
||||
|
||||
"pulse_eyebrow": "Diese Woche",
|
||||
"pulse_headline": "Ihr habt {pages} Seiten bearbeitet.",
|
||||
"pulse_you": "Du selbst hast {pages} davon bearbeitet.",
|
||||
@@ -803,19 +804,15 @@
|
||||
"pulse_transcribed": "Textstellen markiert",
|
||||
"pulse_reviewed": "Textstellen transkribiert",
|
||||
"pulse_uploaded": "Dokumente hochgeladen",
|
||||
|
||||
"feed_caption": "Kommentare & Aktivität",
|
||||
"feed_show_all": "Alle anzeigen",
|
||||
"feed_for_you": "für dich",
|
||||
|
||||
"audit_action_text_saved": "hat Text gespeichert in",
|
||||
"audit_action_file_uploaded": "hat eine Datei hochgeladen:",
|
||||
"audit_action_annotation_created": "hat eine Markierung erstellt in",
|
||||
"audit_action_comment_added": "hat kommentiert:",
|
||||
"audit_action_mention_created": "hat dich erwähnt in",
|
||||
|
||||
"dropzone_release": "Loslassen zum Hochladen",
|
||||
|
||||
"chronik_page_title": "Aktivitäten",
|
||||
"chronik_for_you_caption": "Für dich",
|
||||
"chronik_for_you_count": "{count} neu",
|
||||
@@ -859,9 +856,7 @@
|
||||
"pagination_page_of": "Seite {page} von {total}",
|
||||
"pagination_nav_label": "Seitennavigation",
|
||||
"pagination_page_button": "Seite {page}",
|
||||
|
||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||
|
||||
"transcribe_coach_title": "Erste Transkription?",
|
||||
"transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:",
|
||||
"transcribe_coach_step_1_title": "Rahmen ziehen.",
|
||||
@@ -871,10 +866,8 @@
|
||||
"transcribe_coach_step_3_title": "Speichert automatisch.",
|
||||
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
|
||||
|
||||
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
|
||||
@@ -948,12 +941,9 @@
|
||||
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||
"bulk_edit_topbar_title": "Massenbearbeitung",
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||
|
||||
"nav_stammbaum": "Stammbaum",
|
||||
"nav_geschichten": "Geschichten",
|
||||
|
||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||
|
||||
"geschichten_index_title": "Geschichten",
|
||||
"geschichten_new_button": "Neue Geschichte",
|
||||
"geschichten_filter_all_pill": "Alle",
|
||||
@@ -973,7 +963,6 @@
|
||||
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
||||
"geschichten_card_show_all_for_person": "Alle Geschichten zu {name}",
|
||||
"geschichten_card_show_all": "Alle anzeigen",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
||||
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
||||
"geschichte_editor_status_draft": "ENTWURF",
|
||||
@@ -1000,14 +989,11 @@
|
||||
"geschichte_editor_toolbar_h3": "Unterüberschrift",
|
||||
"geschichte_editor_toolbar_ul": "Aufzählung",
|
||||
"geschichte_editor_toolbar_ol": "Nummerierte Liste",
|
||||
|
||||
"geschichte_delete_confirm_title": "Geschichte löschen?",
|
||||
"geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.",
|
||||
|
||||
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
|
||||
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
|
||||
|
||||
"relation_parent_of": "Elternteil von",
|
||||
"relation_child_of": "Kind von",
|
||||
"relation_spouse_of": "Ehegatte",
|
||||
@@ -1018,7 +1004,6 @@
|
||||
"relation_doctor": "Arzt",
|
||||
"relation_neighbor": "Nachbar",
|
||||
"relation_other": "Sonstige",
|
||||
|
||||
"relation_inferred_parent": "Elternteil",
|
||||
"relation_inferred_child": "Kind",
|
||||
"relation_inferred_spouse": "Ehegatte",
|
||||
@@ -1036,9 +1021,7 @@
|
||||
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
|
||||
"relation_inferred_cousin_1": "Cousin/Cousine",
|
||||
"relation_inferred_distant": "Weitläufige Verwandtschaft",
|
||||
|
||||
"doc_details_field_relationship": "Verwandtschaft",
|
||||
|
||||
"stammbaum_empty_heading": "Noch keine Familienmitglieder",
|
||||
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
|
||||
"stammbaum_empty_link": "→ Zur Personenliste",
|
||||
@@ -1050,7 +1033,6 @@
|
||||
"stammbaum_zoom_in": "Vergrößern",
|
||||
"stammbaum_zoom_out": "Verkleinern",
|
||||
"stammbaum_generations": "Generationen",
|
||||
|
||||
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
|
||||
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
|
||||
@@ -1073,14 +1055,15 @@
|
||||
"relation_form_field_from_year": "Von Jahr",
|
||||
"relation_form_field_to_year": "Bis Jahr",
|
||||
"relation_form_year_placeholder": "z.B. 1920",
|
||||
|
||||
"person_relationships_heading": "Beziehungen",
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||
|
||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||
"timeline_clear_selection": "Auswahl zurücksetzen",
|
||||
"timeline_zoom_reset": "Zurück zur Übersicht",
|
||||
"timeline_bar_aria_singular": "{when}, 1 Dokument",
|
||||
"timeline_bar_aria_plural": "{when}, {count} Dokumente",
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt"
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt",
|
||||
"error_page_id_label": "Fehler-ID",
|
||||
"error_copy_id_label": "ID kopieren",
|
||||
"error_copied": "Kopiert!"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"error_file_too_large": "The file is too large (max. 50 MB).",
|
||||
"error_user_not_found": "User not found.",
|
||||
"error_import_already_running": "An import is already running. Please wait for it to finish.",
|
||||
"error_invalid_credentials": "Email address or password is incorrect.",
|
||||
"error_session_expired": "Your session has expired. Please sign in again.",
|
||||
"error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.",
|
||||
"error_unauthorized": "You are not logged in.",
|
||||
"error_forbidden": "You do not have permission for this action.",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
@@ -347,6 +350,9 @@
|
||||
"admin_system_import_status_running": "Import running…",
|
||||
"admin_system_import_status_done": "Import complete",
|
||||
"admin_system_import_status_done_label": "Documents processed",
|
||||
"admin_system_import_skipped_label": "skipped",
|
||||
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
|
||||
"import_reason_file_read_error": "File read error",
|
||||
"admin_system_import_status_failed": "Import failed",
|
||||
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
||||
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
||||
@@ -473,7 +479,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Stor.",
|
||||
"dashboard_reader_draft_meta": "Draft · last edited {relative}",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_label": "Continue where you left off",
|
||||
"dashboard_resume_fallback": "Unknown document",
|
||||
"doc_status_placeholder": "Placeholder",
|
||||
"doc_status_uploaded": "Uploaded",
|
||||
@@ -773,19 +779,15 @@
|
||||
"admin_invite_created_title": "Invite created",
|
||||
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
||||
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
||||
|
||||
"greeting_morning": "Good morning, {name}.",
|
||||
"greeting_day": "Hello, {name}.",
|
||||
"greeting_evening": "Good evening, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continue where you left off",
|
||||
"dashboard_blocks": "{count} sections",
|
||||
"dashboard_resume_cta": "Continue transcribing",
|
||||
"dashboard_resume_other": "or choose another document",
|
||||
"dashboard_empty_title": "No document started yet",
|
||||
"dashboard_empty_body": "Choose a document from the archive to start transcribing.",
|
||||
"dashboard_empty_cta": "To the archive",
|
||||
|
||||
"dashboard_mission_caption": "Open tasks",
|
||||
"queue_segment": "Segment",
|
||||
"queue_segment_blurb": "Split pages",
|
||||
@@ -795,7 +797,6 @@
|
||||
"queue_review_blurb": "Check texts",
|
||||
"queue_n_open": "{n} open",
|
||||
"queue_show_all": "Show all →",
|
||||
|
||||
"pulse_eyebrow": "This week",
|
||||
"pulse_headline": "You have worked on {pages} pages.",
|
||||
"pulse_you": "You personally worked on {pages} of them.",
|
||||
@@ -803,19 +804,15 @@
|
||||
"pulse_transcribed": "Passages annotated",
|
||||
"pulse_reviewed": "Passages transcribed",
|
||||
"pulse_uploaded": "Documents uploaded",
|
||||
|
||||
"feed_caption": "Comments & activity",
|
||||
"feed_show_all": "Show all",
|
||||
"feed_for_you": "for you",
|
||||
|
||||
"audit_action_text_saved": "saved text in",
|
||||
"audit_action_file_uploaded": "uploaded a file:",
|
||||
"audit_action_annotation_created": "created an annotation in",
|
||||
"audit_action_comment_added": "commented:",
|
||||
"audit_action_mention_created": "mentioned you in",
|
||||
|
||||
"dropzone_release": "Release to upload",
|
||||
|
||||
"chronik_page_title": "Activity",
|
||||
"chronik_for_you_caption": "For you",
|
||||
"chronik_for_you_count": "{count} new",
|
||||
@@ -859,9 +856,7 @@
|
||||
"pagination_page_of": "Page {page} of {total}",
|
||||
"pagination_nav_label": "Pagination",
|
||||
"pagination_page_button": "Page {page}",
|
||||
|
||||
"common_opens_new_tab": "(opens in new tab)",
|
||||
|
||||
"transcribe_coach_title": "First transcription?",
|
||||
"transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:",
|
||||
"transcribe_coach_step_1_title": "Draw a frame.",
|
||||
@@ -871,10 +866,8 @@
|
||||
"transcribe_coach_step_3_title": "Saves automatically.",
|
||||
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
|
||||
|
||||
"transcription_mode_help_label": "Read and edit mode",
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
|
||||
@@ -948,12 +941,9 @@
|
||||
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||
"bulk_edit_topbar_title": "Bulk edit",
|
||||
"bulk_edit_count_pill": "{count} will be edited",
|
||||
|
||||
"nav_stammbaum": "Family tree",
|
||||
"nav_geschichten": "Stories",
|
||||
|
||||
"error_geschichte_not_found": "The story was not found.",
|
||||
|
||||
"geschichten_index_title": "Stories",
|
||||
"geschichten_new_button": "New story",
|
||||
"geschichten_filter_all_pill": "All",
|
||||
@@ -973,7 +963,6 @@
|
||||
"geschichten_card_attach_action": "+ Attach a story",
|
||||
"geschichten_card_show_all_for_person": "All stories about {name}",
|
||||
"geschichten_card_show_all": "Show all",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Story title",
|
||||
"geschichte_editor_body_placeholder": "Write your story here…",
|
||||
"geschichte_editor_status_draft": "DRAFT",
|
||||
@@ -1000,14 +989,11 @@
|
||||
"geschichte_editor_toolbar_h3": "Subheading",
|
||||
"geschichte_editor_toolbar_ul": "Bulleted list",
|
||||
"geschichte_editor_toolbar_ol": "Numbered list",
|
||||
|
||||
"geschichte_delete_confirm_title": "Delete story?",
|
||||
"geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.",
|
||||
|
||||
"error_relationship_not_found": "Relationship not found.",
|
||||
"error_circular_relationship": "This relationship would form a cycle.",
|
||||
"error_duplicate_relationship": "This relationship already exists.",
|
||||
|
||||
"relation_parent_of": "Parent of",
|
||||
"relation_child_of": "Child of",
|
||||
"relation_spouse_of": "Spouse",
|
||||
@@ -1018,7 +1004,6 @@
|
||||
"relation_doctor": "Doctor",
|
||||
"relation_neighbor": "Neighbour",
|
||||
"relation_other": "Other",
|
||||
|
||||
"relation_inferred_parent": "Parent",
|
||||
"relation_inferred_child": "Child",
|
||||
"relation_inferred_spouse": "Spouse",
|
||||
@@ -1036,9 +1021,7 @@
|
||||
"relation_inferred_sibling_inlaw": "Sibling-in-law",
|
||||
"relation_inferred_cousin_1": "Cousin",
|
||||
"relation_inferred_distant": "Distant relative",
|
||||
|
||||
"doc_details_field_relationship": "Relationship",
|
||||
|
||||
"stammbaum_empty_heading": "No family members yet",
|
||||
"stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.",
|
||||
"stammbaum_empty_link": "→ Go to person list",
|
||||
@@ -1050,7 +1033,6 @@
|
||||
"stammbaum_zoom_in": "Zoom in",
|
||||
"stammbaum_zoom_out": "Zoom out",
|
||||
"stammbaum_generations": "Generations",
|
||||
|
||||
"relation_error_duplicate": "This relationship already exists.",
|
||||
"relation_error_circular": "This relationship would form a cycle.",
|
||||
"relation_error_self": "A person cannot be related to themselves.",
|
||||
@@ -1073,14 +1055,15 @@
|
||||
"relation_form_field_from_year": "From year",
|
||||
"relation_form_field_to_year": "To year",
|
||||
"relation_form_year_placeholder": "e.g. 1920",
|
||||
|
||||
"person_relationships_heading": "Relationships",
|
||||
"person_relationships_empty": "No relationships known yet.",
|
||||
|
||||
"timeline_aria_label": "Document density timeline",
|
||||
"timeline_clear_selection": "Clear selection",
|
||||
"timeline_zoom_reset": "Reset zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 document",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documents",
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected"
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected",
|
||||
"error_page_id_label": "Error ID",
|
||||
"error_copy_id_label": "Copy ID",
|
||||
"error_copied": "Copied!"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"error_file_too_large": "El archivo es demasiado grande (máx. 50 MB).",
|
||||
"error_user_not_found": "Usuario no encontrado.",
|
||||
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
|
||||
"error_invalid_credentials": "El correo electrónico o la contraseña son incorrectos.",
|
||||
"error_session_expired": "Su sesión ha expirado. Por favor, inicie sesión de nuevo.",
|
||||
"error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.",
|
||||
"error_unauthorized": "No ha iniciado sesión.",
|
||||
"error_forbidden": "No tiene permiso para realizar esta acción.",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
@@ -347,6 +350,9 @@
|
||||
"admin_system_import_status_running": "Importación en curso…",
|
||||
"admin_system_import_status_done": "Importación completada",
|
||||
"admin_system_import_status_done_label": "Documentos procesados",
|
||||
"admin_system_import_skipped_label": "omitidos",
|
||||
"import_reason_invalid_pdf_signature": "Firma PDF no válida",
|
||||
"import_reason_file_read_error": "Error al leer el archivo",
|
||||
"admin_system_import_status_failed": "Importación fallida",
|
||||
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
||||
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
||||
@@ -473,7 +479,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Hist.",
|
||||
"dashboard_reader_draft_meta": "Borrador · editado hace {relative}",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_label": "Continuar donde lo dejaste",
|
||||
"dashboard_resume_fallback": "Documento desconocido",
|
||||
"doc_status_placeholder": "Marcador",
|
||||
"doc_status_uploaded": "Cargado",
|
||||
@@ -773,19 +779,15 @@
|
||||
"admin_invite_created_title": "Invitación creada",
|
||||
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
||||
|
||||
"greeting_morning": "Buenos días, {name}.",
|
||||
"greeting_day": "Hola, {name}.",
|
||||
"greeting_evening": "Buenas noches, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continuar donde lo dejaste",
|
||||
"dashboard_blocks": "{count} secciones",
|
||||
"dashboard_resume_cta": "Continuar transcripción",
|
||||
"dashboard_resume_other": "o elige otro documento",
|
||||
"dashboard_empty_title": "Aún no has comenzado ningún documento",
|
||||
"dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.",
|
||||
"dashboard_empty_cta": "Al archivo",
|
||||
|
||||
"dashboard_mission_caption": "Tareas pendientes",
|
||||
"queue_segment": "Segmentar",
|
||||
"queue_segment_blurb": "Dividir páginas",
|
||||
@@ -795,7 +797,6 @@
|
||||
"queue_review_blurb": "Controlar textos",
|
||||
"queue_n_open": "{n} pendiente",
|
||||
"queue_show_all": "Ver todo →",
|
||||
|
||||
"pulse_eyebrow": "Esta semana",
|
||||
"pulse_headline": "Habéis trabajado {pages} páginas.",
|
||||
"pulse_you": "Tú mismo has trabajado {pages} de ellas.",
|
||||
@@ -803,19 +804,15 @@
|
||||
"pulse_transcribed": "Fragmentos anotados",
|
||||
"pulse_reviewed": "Fragmentos transcritos",
|
||||
"pulse_uploaded": "Documentos subidos",
|
||||
|
||||
"feed_caption": "Comentarios y actividad",
|
||||
"feed_show_all": "Ver todo",
|
||||
"feed_for_you": "para ti",
|
||||
|
||||
"audit_action_text_saved": "guardó texto en",
|
||||
"audit_action_file_uploaded": "subió un archivo:",
|
||||
"audit_action_annotation_created": "creó una anotación en",
|
||||
"audit_action_comment_added": "comentó:",
|
||||
"audit_action_mention_created": "te mencionó en",
|
||||
|
||||
"dropzone_release": "Suelta para subir",
|
||||
|
||||
"chronik_page_title": "Actividades",
|
||||
"chronik_for_you_caption": "Para ti",
|
||||
"chronik_for_you_count": "{count} nuevas",
|
||||
@@ -859,9 +856,7 @@
|
||||
"pagination_page_of": "Página {page} de {total}",
|
||||
"pagination_nav_label": "Paginación",
|
||||
"pagination_page_button": "Página {page}",
|
||||
|
||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||
|
||||
"transcribe_coach_title": "¿Primera transcripción?",
|
||||
"transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:",
|
||||
"transcribe_coach_step_1_title": "Dibujar un marco.",
|
||||
@@ -871,10 +866,8 @@
|
||||
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
|
||||
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
|
||||
|
||||
"transcription_mode_help_label": "Modo lectura y edición",
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
|
||||
@@ -948,12 +941,9 @@
|
||||
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
||||
"bulk_edit_topbar_title": "Edición masiva",
|
||||
"bulk_edit_count_pill": "Se editarán {count}",
|
||||
|
||||
"nav_stammbaum": "Árbol genealógico",
|
||||
"nav_geschichten": "Historias",
|
||||
|
||||
"error_geschichte_not_found": "No se encontró la historia.",
|
||||
|
||||
"geschichten_index_title": "Historias",
|
||||
"geschichten_new_button": "Nueva historia",
|
||||
"geschichten_filter_all_pill": "Todas",
|
||||
@@ -973,7 +963,6 @@
|
||||
"geschichten_card_attach_action": "+ Adjuntar historia",
|
||||
"geschichten_card_show_all_for_person": "Todas las historias sobre {name}",
|
||||
"geschichten_card_show_all": "Mostrar todas",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Título de la historia",
|
||||
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
||||
"geschichte_editor_status_draft": "BORRADOR",
|
||||
@@ -1000,14 +989,11 @@
|
||||
"geschichte_editor_toolbar_h3": "Subencabezado",
|
||||
"geschichte_editor_toolbar_ul": "Lista con viñetas",
|
||||
"geschichte_editor_toolbar_ol": "Lista numerada",
|
||||
|
||||
"geschichte_delete_confirm_title": "¿Eliminar historia?",
|
||||
"geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.",
|
||||
|
||||
"error_relationship_not_found": "La relación no fue encontrada.",
|
||||
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
||||
"error_duplicate_relationship": "Esta relación ya existe.",
|
||||
|
||||
"relation_parent_of": "Progenitor de",
|
||||
"relation_child_of": "Hijo/a de",
|
||||
"relation_spouse_of": "Cónyuge",
|
||||
@@ -1018,7 +1004,6 @@
|
||||
"relation_doctor": "Médico",
|
||||
"relation_neighbor": "Vecino/a",
|
||||
"relation_other": "Otro",
|
||||
|
||||
"relation_inferred_parent": "Progenitor",
|
||||
"relation_inferred_child": "Hijo/a",
|
||||
"relation_inferred_spouse": "Cónyuge",
|
||||
@@ -1036,9 +1021,7 @@
|
||||
"relation_inferred_sibling_inlaw": "Cuñado/a",
|
||||
"relation_inferred_cousin_1": "Primo/a",
|
||||
"relation_inferred_distant": "Pariente lejano",
|
||||
|
||||
"doc_details_field_relationship": "Parentesco",
|
||||
|
||||
"stammbaum_empty_heading": "Aún no hay miembros de la familia",
|
||||
"stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.",
|
||||
"stammbaum_empty_link": "→ Ir a la lista de personas",
|
||||
@@ -1050,7 +1033,6 @@
|
||||
"stammbaum_zoom_in": "Acercar",
|
||||
"stammbaum_zoom_out": "Alejar",
|
||||
"stammbaum_generations": "Generaciones",
|
||||
|
||||
"relation_error_duplicate": "Esta relación ya existe.",
|
||||
"relation_error_circular": "Esta relación crearía un ciclo.",
|
||||
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
|
||||
@@ -1073,14 +1055,15 @@
|
||||
"relation_form_field_from_year": "Desde año",
|
||||
"relation_form_field_to_year": "Hasta año",
|
||||
"relation_form_year_placeholder": "ej. 1920",
|
||||
|
||||
"person_relationships_heading": "Relaciones",
|
||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||
|
||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||
"timeline_clear_selection": "Borrar selección",
|
||||
"timeline_zoom_reset": "Restablecer zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 documento",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documentos",
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado"
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado",
|
||||
"error_page_id_label": "ID de error",
|
||||
"error_copy_id_label": "Copiar ID",
|
||||
"error_copied": "¡Copiado!"
|
||||
}
|
||||
|
||||
5
frontend/src/app.d.ts
vendored
5
frontend/src/app.d.ts
vendored
@@ -26,6 +26,11 @@ declare global {
|
||||
interface PageData {
|
||||
user?: User; // Available in $page.data.user
|
||||
}
|
||||
|
||||
interface Error {
|
||||
message: string;
|
||||
errorId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
frontend/src/hooks.client.test.ts
Normal file
47
frontend/src/hooks.client.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@sentry/sveltekit', () => ({
|
||||
init: vi.fn(),
|
||||
handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn,
|
||||
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
|
||||
}));
|
||||
|
||||
describe('hooks.client handleError', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns Sentry lastEventId as errorId', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123');
|
||||
|
||||
const { handleError } = await import('./hooks.client');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: {},
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toBe('sentry-event-id-abc123');
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
|
||||
it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue(undefined);
|
||||
|
||||
const { handleError } = await import('./hooks.client');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: {},
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
|
||||
// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip
|
||||
// but cannot read them. Safe to include in the client bundle per Sentry security model.
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.MODE,
|
||||
tracesSampleRate: 1.0,
|
||||
tracesSampleRate: 0.1,
|
||||
sendDefaultPii: false,
|
||||
enabled: !!import.meta.env.VITE_SENTRY_DSN
|
||||
});
|
||||
|
||||
export const handleError = Sentry.handleErrorWithSentry();
|
||||
export const handleError = Sentry.handleErrorWithSentry(() => {
|
||||
const errorId = Sentry.lastEventId() ?? crypto.randomUUID();
|
||||
return { message: 'An unexpected error occurred', errorId };
|
||||
});
|
||||
|
||||
152
frontend/src/hooks.server.test.ts
Normal file
152
frontend/src/hooks.server.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@sentry/sveltekit', () => ({
|
||||
init: vi.fn(),
|
||||
handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn,
|
||||
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
|
||||
}));
|
||||
|
||||
class RedirectMarker {
|
||||
constructor(
|
||||
public status: number,
|
||||
public location: string
|
||||
) {}
|
||||
}
|
||||
|
||||
vi.mock('@sveltejs/kit', () => ({
|
||||
redirect: vi.fn((status: number, location: string) => new RedirectMarker(status, location)),
|
||||
isRedirect: (e: unknown) => e instanceof RedirectMarker
|
||||
}));
|
||||
vi.mock('@sveltejs/kit/hooks', () => ({ sequence: vi.fn((...fns: unknown[]) => fns[0]) }));
|
||||
vi.mock('$lib/paraglide/server', () => ({ paraglideMiddleware: vi.fn() }));
|
||||
vi.mock('$lib/paraglide/runtime', () => ({ cookieName: 'locale', cookieMaxAge: 86400 }));
|
||||
vi.mock('$lib/shared/server/locale', () => ({ detectLocale: vi.fn(() => 'de') }));
|
||||
vi.mock('process', () => ({ env: { API_INTERNAL_URL: 'http://backend:8080' } }));
|
||||
|
||||
const makeEvent = () => ({
|
||||
url: { pathname: '/documents/123' },
|
||||
locals: {}
|
||||
});
|
||||
|
||||
describe('hooks.server handleError', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns Sentry lastEventId as errorId', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123');
|
||||
|
||||
const { handleError } = await import('./hooks.server');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: makeEvent(),
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toBe('sentry-event-id-abc123');
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
|
||||
it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue(undefined);
|
||||
|
||||
const { handleError } = await import('./hooks.server');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: makeEvent(),
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
interface UserGroupEvent {
|
||||
url: URL;
|
||||
cookies: { get: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||
locals: { user?: unknown };
|
||||
request: Request;
|
||||
}
|
||||
|
||||
function makeUserGroupEvent(pathname: string, sessionId?: string): UserGroupEvent {
|
||||
return {
|
||||
url: new URL(`http://localhost${pathname}`),
|
||||
cookies: {
|
||||
get: vi.fn((name: string) => (name === 'fa_session' ? sessionId : undefined)),
|
||||
delete: vi.fn()
|
||||
},
|
||||
locals: {},
|
||||
request: new Request(`http://localhost${pathname}`)
|
||||
};
|
||||
}
|
||||
|
||||
describe('hooks.server userGroup (session lookup + 401 handling)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
it('redirects to /login?reason=expired when backend rejects the session on a non-public path', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }));
|
||||
|
||||
const { handle } = await import('./hooks.server');
|
||||
const event = makeUserGroupEvent('/documents/123', 'stale-session');
|
||||
const resolve = vi.fn();
|
||||
|
||||
await expect((handle as (a: unknown) => unknown)({ event, resolve })).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: '/login?reason=expired'
|
||||
});
|
||||
|
||||
expect(event.cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not redirect when backend 401 fires on a public path (no /login → /login loop)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }));
|
||||
|
||||
const { handle } = await import('./hooks.server');
|
||||
const event = makeUserGroupEvent('/login', 'stale-session');
|
||||
const resolve = vi.fn().mockResolvedValue(new Response());
|
||||
|
||||
await (handle as (a: unknown) => Promise<unknown>)({ event, resolve });
|
||||
|
||||
expect(event.cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
|
||||
expect(resolve).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('passes through when no fa_session cookie is present', async () => {
|
||||
const { handle } = await import('./hooks.server');
|
||||
const event = makeUserGroupEvent('/documents/123', undefined);
|
||||
const resolve = vi.fn().mockResolvedValue(new Response());
|
||||
|
||||
await (handle as (a: unknown) => Promise<unknown>)({ event, resolve });
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(resolve).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('attaches the user to locals when backend returns 200', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'u1', email: 'a@b.de' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const { handle } = await import('./hooks.server');
|
||||
const event = makeUserGroupEvent('/documents/123', 'valid-session');
|
||||
const resolve = vi.fn().mockResolvedValue(new Response());
|
||||
|
||||
await (handle as (a: unknown) => Promise<unknown>)({ event, resolve });
|
||||
|
||||
expect((event.locals as { user: { email: string } }).user.email).toBe('a@b.de');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,18 @@
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
|
||||
import { isRedirect, redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { env } from 'process';
|
||||
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
||||
import { detectLocale } from '$lib/shared/server/locale';
|
||||
|
||||
// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip
|
||||
// but cannot read them. Safe to include in the client bundle per Sentry security model.
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.MODE,
|
||||
tracesSampleRate: 1.0,
|
||||
tracesSampleRate: 0.1,
|
||||
sendDefaultPii: false,
|
||||
enabled: !!import.meta.env.VITE_SENTRY_DSN
|
||||
});
|
||||
|
||||
@@ -55,21 +58,39 @@ const handleParaglide: Handle = ({ event, resolve }) =>
|
||||
});
|
||||
|
||||
const userGroup: Handle = async ({ event, resolve }) => {
|
||||
const auth = event.cookies.get('auth_token');
|
||||
// One-off cleanup of the legacy Basic-credentials cookie from before the Spring Session migration (#523).
|
||||
if (event.cookies.get('auth_token')) {
|
||||
event.cookies.delete('auth_token', { path: '/' });
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
try {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||
headers: { Authorization: auth }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
event.locals.user = user;
|
||||
const sessionId = event.cookies.get('fa_session');
|
||||
if (!sessionId) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||
headers: { Cookie: `fa_session=${sessionId}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
event.locals.user = await response.json();
|
||||
} else if (response.status === 401) {
|
||||
// Backend rejected the session (expired or invalidated). Drop the stale
|
||||
// cookie and surface the reason on the login page. PUBLIC_PATHS check
|
||||
// avoids a redirect loop if the user is already on /login.
|
||||
event.cookies.delete('fa_session', { path: '/' });
|
||||
const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
|
||||
if (!isPublic) {
|
||||
throw redirect(302, '/login?reason=expired');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user in hook:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
// Re-throw SvelteKit redirects (e.g. the /login?reason=expired throw above)
|
||||
// using the official guard rather than duck-typing on the error shape.
|
||||
if (isRedirect(error)) throw error;
|
||||
console.error('Error fetching user in hook:', error);
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
@@ -80,14 +101,11 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
||||
|
||||
if (isApi) {
|
||||
// If the request already carries an explicit Authorization header (e.g. the
|
||||
// login action sends Basic auth), pass it through unchanged.
|
||||
if (request.headers.has('Authorization')) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// Password reset endpoints are public — no auth header needed.
|
||||
// Auth endpoints that establish/check their own credentials manage cookies themselves;
|
||||
// don't double-inject a stale fa_session.
|
||||
const PUBLIC_API_PATHS = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/invite/',
|
||||
@@ -97,24 +115,20 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const token = event.cookies.get('auth_token');
|
||||
|
||||
if (!token) {
|
||||
const sessionId = event.cookies.get('fa_session');
|
||||
if (!sessionId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Clone the request first to preserve the body
|
||||
const clonedRequest = request.clone();
|
||||
|
||||
// Create new request with Authorization header and preserved body
|
||||
const modifiedRequest = new Request(clonedRequest, {
|
||||
// Clone first so the body stream is preserved on the new Request.
|
||||
const cloned = request.clone();
|
||||
const modified = new Request(cloned, {
|
||||
headers: {
|
||||
...Object.fromEntries(clonedRequest.headers),
|
||||
Authorization: token
|
||||
...Object.fromEntries(cloned.headers),
|
||||
Cookie: `fa_session=${sessionId}`
|
||||
}
|
||||
});
|
||||
|
||||
return fetch(modifiedRequest);
|
||||
return fetch(modified);
|
||||
}
|
||||
|
||||
return fetch(request);
|
||||
@@ -122,4 +136,7 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
|
||||
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||
|
||||
export const handleError = Sentry.handleErrorWithSentry();
|
||||
export const handleError = Sentry.handleErrorWithSentry(() => {
|
||||
const errorId = Sentry.lastEventId() ?? crypto.randomUUID();
|
||||
return { message: 'An unexpected error occurred', errorId };
|
||||
});
|
||||
|
||||
38
frontend/src/lib/shared/cookies.spec.ts
Normal file
38
frontend/src/lib/shared/cookies.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractFaSessionId } from './cookies';
|
||||
|
||||
describe('extractFaSessionId', () => {
|
||||
it('extracts the opaque id from a single Set-Cookie header', () => {
|
||||
const headers = ['fa_session=abc123; Path=/; HttpOnly; SameSite=Strict'];
|
||||
expect(extractFaSessionId(headers)).toBe('abc123');
|
||||
});
|
||||
|
||||
it('extracts the value when multiple Set-Cookie headers are present (getSetCookie path)', () => {
|
||||
const headers = [
|
||||
'JSESSIONID=legacy; Path=/',
|
||||
'fa_session=xyz789; Path=/; Max-Age=28800; HttpOnly',
|
||||
'XSRF-TOKEN=ignored; Path=/'
|
||||
];
|
||||
expect(extractFaSessionId(headers)).toBe('xyz789');
|
||||
});
|
||||
|
||||
it('returns null when no header carries fa_session', () => {
|
||||
expect(extractFaSessionId(['Other=foo; Path=/'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty header list', () => {
|
||||
expect(extractFaSessionId([])).toBeNull();
|
||||
});
|
||||
|
||||
it('strips all attributes after the first semicolon', () => {
|
||||
const headers = ['fa_session=opaque-token-with.dots_and-dashes; Path=/; Secure; HttpOnly'];
|
||||
expect(extractFaSessionId(headers)).toBe('opaque-token-with.dots_and-dashes');
|
||||
});
|
||||
|
||||
it('only matches a cookie whose name is exactly fa_session', () => {
|
||||
// A different cookie name that happens to contain "fa_session" as a substring
|
||||
// must not match — anchored to start of header.
|
||||
const headers = ['xfa_session=should-not-match; Path=/'];
|
||||
expect(extractFaSessionId(headers)).toBeNull();
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/shared/cookies.ts
Normal file
20
frontend/src/lib/shared/cookies.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Extracts the fa_session cookie value from a list of Set-Cookie response headers.
|
||||
*
|
||||
* The backend may append attributes like `Path`, `HttpOnly`, `SameSite=Strict`,
|
||||
* `Max-Age`, `Secure`; we only forward the opaque session id — the SvelteKit
|
||||
* cookies API rewrites the attributes itself when re-emitting to the browser.
|
||||
*
|
||||
* Pass the result of `response.headers.getSetCookie()` (modern Node/Undici) or
|
||||
* a single-element array containing `response.headers.get('set-cookie')` for
|
||||
* older runtimes that lack `getSetCookie`.
|
||||
*
|
||||
* Returns `null` if no fa_session cookie is present.
|
||||
*/
|
||||
export function extractFaSessionId(setCookieHeaders: string[]): string | null {
|
||||
for (const header of setCookieHeaders) {
|
||||
const match = header.match(/^fa_session=([^;]+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export type ErrorCode =
|
||||
| 'CIRCULAR_RELATIONSHIP'
|
||||
| 'DUPLICATE_RELATIONSHIP'
|
||||
| 'GESCHICHTE_NOT_FOUND'
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'SESSION_EXPIRED'
|
||||
| 'MISSING_CREDENTIALS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
@@ -154,6 +156,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_duplicate_relationship();
|
||||
case 'GESCHICHTE_NOT_FOUND':
|
||||
return m.error_geschichte_not_found();
|
||||
case 'INVALID_CREDENTIALS':
|
||||
return m.error_invalid_credentials();
|
||||
case 'SESSION_EXPIRED':
|
||||
return m.error_session_expired();
|
||||
case 'MISSING_CREDENTIALS':
|
||||
return m.login_error_missing_credentials();
|
||||
case 'UNAUTHORIZED':
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
function copyId() {
|
||||
const id = page.error?.errorId;
|
||||
if (!id) return;
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(id).then(
|
||||
() => {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
},
|
||||
() => {
|
||||
/* clipboard denied or unavailable — select-all on the <code> element remains */
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.page_title_error()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="px-4 py-12 text-center font-sans">
|
||||
<p class="font-sans text-6xl font-bold text-ink">{page.status}</p>
|
||||
<p class="mt-2 font-sans text-sm text-ink-2">{page.error?.message ?? 'Internal Error'}</p>
|
||||
</div>
|
||||
<main class="px-4 py-12 text-center font-sans">
|
||||
<h1 class="mb-2 font-serif text-2xl font-bold text-ink">{m.page_title_error()}</h1>
|
||||
<p class="mb-8 font-sans text-sm text-ink-2">
|
||||
{page.error?.message ?? m.error_internal_error()}
|
||||
</p>
|
||||
<p class="mb-4 font-mono text-4xl font-bold text-ink">{page.status}</p>
|
||||
|
||||
{#if page.error?.errorId}
|
||||
<div class="mt-6 flex flex-col items-center gap-3">
|
||||
<p class="font-sans text-xs tracking-widest text-ink-2 uppercase">
|
||||
{m.error_page_id_label()}
|
||||
</p>
|
||||
<code
|
||||
class="rounded border border-line bg-surface px-3 py-1 font-mono text-sm text-ink select-all"
|
||||
>
|
||||
{page.error.errorId}
|
||||
</code>
|
||||
<button
|
||||
class="min-h-[44px] min-w-[44px] rounded-sm bg-brand-navy px-5 py-2 font-sans text-sm text-white transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2"
|
||||
onclick={copyId}
|
||||
aria-label={m.error_copy_id_label()}
|
||||
>
|
||||
<span aria-live="polite">{copied ? m.error_copied() : m.error_copy_id_label()}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -15,6 +15,12 @@ const failureMessage = $derived(
|
||||
? m.admin_system_import_failed_no_spreadsheet()
|
||||
: m.admin_system_import_failed_internal()
|
||||
);
|
||||
|
||||
function reasonLabel(code: string): string {
|
||||
if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature();
|
||||
if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error();
|
||||
return code;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -48,6 +54,38 @@ const failureMessage = $derived(
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
||||
</div>
|
||||
{#if importStatus.skipped > 0}
|
||||
<details class="mb-4 rounded-sm border border-warning/40 bg-warning/10 p-4 text-amber-900">
|
||||
<summary class="flex cursor-pointer list-none items-center gap-2">
|
||||
<svg
|
||||
class="details-chevron h-4 w-4 shrink-0 motion-safe:transition-transform"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<div>
|
||||
<span data-testid="skipped-count" class="block text-base font-bold"
|
||||
>{importStatus.skipped}</span
|
||||
>
|
||||
<span class="block font-sans text-xs font-bold tracking-widest uppercase">
|
||||
{m.admin_system_import_skipped_label()}
|
||||
</span>
|
||||
</div>
|
||||
</summary>
|
||||
<ul class="mt-3 space-y-1">
|
||||
{#each importStatus.skippedFiles as skipped (skipped.filename)}
|
||||
<li class="font-mono text-sm text-ink-2">
|
||||
{skipped.filename} — {reasonLabel(skipped.reason)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
<button
|
||||
data-import-trigger
|
||||
onclick={ontrigger}
|
||||
@@ -79,3 +117,9 @@ const failureMessage = $derived(
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
details[open] .details-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,8 @@ const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
|
||||
state: 'IDLE',
|
||||
statusCode: 'IMPORT_IDLE',
|
||||
processed: 0,
|
||||
skipped: 0,
|
||||
skippedFiles: [],
|
||||
startedAt: null,
|
||||
...overrides
|
||||
});
|
||||
@@ -128,4 +130,106 @@ describe('ImportStatusCard', () => {
|
||||
await getByRole('button').click();
|
||||
expect(ontrigger).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows skipped count when DONE and skipped > 0', async () => {
|
||||
const { getByTestId } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'DONE',
|
||||
statusCode: 'IMPORT_DONE',
|
||||
processed: 10,
|
||||
skipped: 3,
|
||||
skippedFiles: [
|
||||
{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||
{ filename: 'other.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||
{ filename: 'tiny.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||
]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByTestId('skipped-count')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('shows skipped filenames in collapsible list when DONE and skipped > 0', async () => {
|
||||
const { getByText } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'DONE',
|
||||
statusCode: 'IMPORT_DONE',
|
||||
processed: 5,
|
||||
skipped: 1,
|
||||
skippedFiles: [{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByText('fake.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skipped section when DONE and skipped is 0', async () => {
|
||||
const { getByTestId } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 5 }),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skipped section when RUNNING even with skipped > 0', async () => {
|
||||
const { getByTestId } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'RUNNING',
|
||||
statusCode: 'IMPORT_RUNNING',
|
||||
processed: 5,
|
||||
skipped: 2,
|
||||
skippedFiles: [
|
||||
{ filename: 'a.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||
{ filename: 'b.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||
]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skipped section when FAILED even with skipped > 0', async () => {
|
||||
const { getByTestId } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'FAILED',
|
||||
statusCode: 'IMPORT_FAILED_INTERNAL',
|
||||
skipped: 1,
|
||||
skippedFiles: [{ filename: 'bad.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows raw reason code for unknown skip reasons', async () => {
|
||||
const { getByText } = render(ImportStatusCard, {
|
||||
props: {
|
||||
importStatus: makeStatus({
|
||||
state: 'DONE',
|
||||
statusCode: 'IMPORT_DONE',
|
||||
processed: 1,
|
||||
skipped: 1,
|
||||
skippedFiles: [{ filename: 'odd.pdf', reason: 'SOME_FUTURE_CODE' }]
|
||||
}),
|
||||
ontrigger: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(getByText('SOME_FUTURE_CODE', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export type SkippedFile = {
|
||||
filename: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ImportStatus = {
|
||||
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||
statusCode: string;
|
||||
processed: number;
|
||||
skipped: number;
|
||||
skippedFiles: SkippedFile[];
|
||||
startedAt: string | null;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,6 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Tags Data', data);
|
||||
|
||||
// 4. Daten zurück an den Browser schicken
|
||||
return json(data);
|
||||
|
||||
@@ -4,7 +4,10 @@ import { page as browserPage } from 'vitest/browser';
|
||||
|
||||
const mockPage = {
|
||||
status: 500,
|
||||
error: { message: 'Internal Error' } as { message: string } | null
|
||||
error: { message: 'Internal Error', errorId: undefined } as {
|
||||
message: string;
|
||||
errorId?: string;
|
||||
} | null
|
||||
};
|
||||
|
||||
vi.mock('$app/state', () => ({
|
||||
@@ -13,6 +16,16 @@ vi.mock('$app/state', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/paraglide/messages.js', () => ({
|
||||
m: {
|
||||
page_title_error: () => 'Es ist etwas schiefgelaufen.',
|
||||
error_internal_error: () => 'Ein unerwarteter Fehler ist aufgetreten.',
|
||||
error_page_id_label: () => 'Fehler-ID',
|
||||
error_copy_id_label: () => 'ID kopieren',
|
||||
error_copied: () => 'Kopiert!'
|
||||
}
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
async function loadComponent() {
|
||||
@@ -20,7 +33,7 @@ async function loadComponent() {
|
||||
}
|
||||
|
||||
describe('+error.svelte', () => {
|
||||
it('renders the page status code prominently', async () => {
|
||||
it('renders the page status code', async () => {
|
||||
mockPage.status = 404;
|
||||
mockPage.error = { message: 'Not Found' };
|
||||
|
||||
@@ -40,13 +53,79 @@ describe('+error.svelte', () => {
|
||||
await expect.element(browserPage.getByText('Database unavailable')).toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to the literal "Internal Error" when page.error is null', async () => {
|
||||
it('falls back to error_internal_error message when page.error is null', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = null;
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await expect.element(browserPage.getByText('Internal Error')).toBeVisible();
|
||||
await expect
|
||||
.element(browserPage.getByText('Ein unerwarteter Fehler ist aufgetreten.'))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('shows errorId when page.error.errorId is set', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' };
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await expect.element(browserPage.getByText('abc-123-def')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows copy button when errorId is present', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' };
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await expect.element(browserPage.getByRole('button', { name: 'ID kopieren' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render errorId section when errorId is absent', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke' };
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await expect.element(browserPage.getByText('Fehler-ID')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Kopiert!" after clicking the copy button', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' };
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await browserPage.getByRole('button', { name: 'ID kopieren' }).click();
|
||||
await expect.element(browserPage.getByText('Kopiert!')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not show "Kopiert!" when clipboard write is rejected', async () => {
|
||||
mockPage.status = 500;
|
||||
mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' };
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
|
||||
const ErrorPage = await loadComponent();
|
||||
render(ErrorPage);
|
||||
|
||||
await browserPage.getByRole('button', { name: 'ID kopieren' }).click();
|
||||
await expect.element(browserPage.getByText('Kopiert!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { extractFaSessionId } from '$lib/shared/cookies';
|
||||
import { getErrorMessage, type ErrorCode } from '$lib/shared/errors';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ url }) => {
|
||||
return { registered: url.searchParams.get('registered') === '1' };
|
||||
return {
|
||||
registered: url.searchParams.get('registered') === '1',
|
||||
reason: url.searchParams.get('reason')
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -17,44 +21,60 @@ export const actions = {
|
||||
return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
|
||||
}
|
||||
|
||||
const credentials = btoa(`${email}:${password}`);
|
||||
const authHeader = `Basic ${credentials}`;
|
||||
|
||||
// Raw fetch is intentional here: we need to pass an explicit Authorization
|
||||
// header built from the form data, not the cookie-based auth used elsewhere.
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
let response: Response;
|
||||
try {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const response = await fetch(`${baseUrl}/api/users/me`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader }
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return fail(401, { error: getErrorMessage('UNAUTHORIZED') });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
|
||||
// The cookie IS the API credential — promoted to `Authorization: Basic …`
|
||||
// on every browser → backend request by AuthTokenCookieFilter on the
|
||||
// Spring side (see #520). It must be Secure on HTTPS or it leaks
|
||||
// a 24h Basic token on plaintext networks. Dev runs over HTTP and
|
||||
// would silently lose the cookie if we hardcoded secure=true.
|
||||
const isHttps = url.protocol === 'https:';
|
||||
cookies.set('auth_token', authHeader, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: isHttps,
|
||||
maxAge: 60 * 60 * 24
|
||||
response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.error('Login request failed', e);
|
||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
let code: ErrorCode = 'INVALID_CREDENTIALS';
|
||||
try {
|
||||
const body = (await response.json()) as { code?: string };
|
||||
if (body?.code) code = body.code as ErrorCode;
|
||||
} catch {
|
||||
// Body not JSON — fall through to INVALID_CREDENTIALS
|
||||
}
|
||||
return fail(401, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
|
||||
// Extract fa_session id from the Set-Cookie header and re-emit to the browser.
|
||||
// Modern Node/Undici exposes getSetCookie(); fall back to a single header for older runtimes.
|
||||
const setCookieHeaders =
|
||||
typeof response.headers.getSetCookie === 'function'
|
||||
? response.headers.getSetCookie()
|
||||
: response.headers.get('set-cookie')
|
||||
? [response.headers.get('set-cookie')!]
|
||||
: [];
|
||||
const sessionId = extractFaSessionId(setCookieHeaders);
|
||||
if (!sessionId) {
|
||||
console.error('Backend returned 200 OK on login but no fa_session cookie');
|
||||
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
|
||||
}
|
||||
|
||||
const isHttps = url.protocol === 'https:';
|
||||
cookies.set('fa_session', sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: isHttps,
|
||||
maxAge: 60 * 60 * 8 // 8h — must match backend spring.session.timeout
|
||||
});
|
||||
|
||||
// Best-effort cleanup of the legacy Basic-auth cookie from older deployments.
|
||||
cookies.delete('auth_token', { path: '/' });
|
||||
|
||||
return redirect(303, '/');
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
@@ -5,7 +5,10 @@ import AuthHeader from '../AuthHeader.svelte';
|
||||
let {
|
||||
data,
|
||||
form
|
||||
}: { data: { registered: boolean }; form?: { error?: string; success?: boolean } } = $props();
|
||||
}: {
|
||||
data: { registered: boolean; reason?: string | null };
|
||||
form?: { error?: string; success?: boolean };
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -38,6 +41,31 @@ let {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.reason === 'expired'}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mb-5 flex items-start gap-3 rounded-sm border border-amber-200 bg-amber-50 px-4 py-3 font-sans"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-warning"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 6a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 6Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-warning">{m.error_session_expired()}</p>
|
||||
<p class="mt-1 text-sm text-warning">{m.error_session_expired_explainer()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||
{m.login_heading()}
|
||||
</h1>
|
||||
@@ -49,11 +77,13 @@ let {
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.login_label_email()}</label
|
||||
>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
@@ -81,7 +111,7 @@ let {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
class="mt-2 min-h-[44px] w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{m.login_btn_submit()}
|
||||
</button>
|
||||
|
||||
117
frontend/src/routes/login/page.server.test.ts
Normal file
117
frontend/src/routes/login/page.server.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { API_INTERNAL_URL: 'http://backend:8080' }
|
||||
}));
|
||||
|
||||
import { actions, load } from './+page.server';
|
||||
|
||||
type ActionsRecord = Record<string, (e: never) => unknown>;
|
||||
|
||||
function makeRequest(form: Record<string, string>): Request {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(form)) fd.set(k, v);
|
||||
return new Request('http://localhost/login?/login', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
function makeCookies() {
|
||||
return {
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
get: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
function loadEvent(search: string) {
|
||||
return {
|
||||
url: new URL(`http://localhost/login${search}`),
|
||||
request: new Request('http://localhost/login', { method: 'GET' }),
|
||||
route: { id: '/login' }
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe('login load', () => {
|
||||
it('exposes registered=true when ?registered=1 is present', async () => {
|
||||
const result = await load(loadEvent('?registered=1'));
|
||||
expect(result).toEqual({ registered: true, reason: null });
|
||||
});
|
||||
|
||||
it('exposes reason=expired when ?reason=expired is present', async () => {
|
||||
const result = await load(loadEvent('?reason=expired'));
|
||||
expect(result).toEqual({ registered: false, reason: 'expired' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('login action', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('returns 400 when email is missing', async () => {
|
||||
const result = await (actions as ActionsRecord).login({
|
||||
request: makeRequest({ password: 'pw' }),
|
||||
cookies: makeCookies(),
|
||||
fetch: vi.fn(),
|
||||
url: new URL('http://localhost/login')
|
||||
} as never);
|
||||
expect((result as { status: number }).status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 401 with INVALID_CREDENTIALS when the backend rejects credentials', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 'INVALID_CREDENTIALS' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await (actions as ActionsRecord).login({
|
||||
request: makeRequest({ email: 'a@b.de', password: 'wrong' }),
|
||||
cookies: makeCookies(),
|
||||
fetch: mockFetch,
|
||||
url: new URL('http://localhost/login')
|
||||
} as never);
|
||||
|
||||
expect((result as { status: number }).status).toBe(401);
|
||||
});
|
||||
|
||||
it('re-emits fa_session and deletes legacy auth_token on success', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'Set-Cookie': 'fa_session=opaque-id; Path=/; HttpOnly; SameSite=Strict' }
|
||||
})
|
||||
);
|
||||
const cookies = makeCookies();
|
||||
|
||||
// redirect() throws a Redirect instance — assert via rejects.
|
||||
const redirected = (actions as ActionsRecord).login({
|
||||
request: makeRequest({ email: 'a@b.de', password: 'pw' }),
|
||||
cookies,
|
||||
fetch: mockFetch,
|
||||
url: new URL('http://localhost/login')
|
||||
} as never);
|
||||
|
||||
await expect(redirected).rejects.toMatchObject({ status: 303, location: '/' });
|
||||
|
||||
expect(cookies.set).toHaveBeenCalledWith(
|
||||
'fa_session',
|
||||
'opaque-id',
|
||||
expect.objectContaining({ httpOnly: true, sameSite: 'strict', maxAge: 60 * 60 * 8 })
|
||||
);
|
||||
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
|
||||
});
|
||||
|
||||
it('returns 500 when backend response omits fa_session cookie', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const cookies = makeCookies();
|
||||
|
||||
const result = await (actions as ActionsRecord).login({
|
||||
request: makeRequest({ email: 'a@b.de', password: 'pw' }),
|
||||
cookies,
|
||||
fetch: mockFetch,
|
||||
url: new URL('http://localhost/login')
|
||||
} as never);
|
||||
|
||||
expect((result as { status: number }).status).toBe(500);
|
||||
expect(cookies.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,30 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ cookies }) => {
|
||||
// Das Auth-Cookie löschen
|
||||
default: async ({ cookies, fetch }) => {
|
||||
const sessionId = cookies.get('fa_session');
|
||||
|
||||
// Best-effort backend logout: invalidates the server-side session row
|
||||
// and writes the LOGOUT audit entry. The client cookie is deleted
|
||||
// unconditionally below so a network failure here still logs the user out.
|
||||
if (sessionId) {
|
||||
try {
|
||||
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
await fetch(`${baseUrl}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: `fa_session=${sessionId}` }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Backend logout failed; clearing client cookie anyway', e);
|
||||
}
|
||||
}
|
||||
|
||||
cookies.delete('fa_session', { path: '/' });
|
||||
// Also drop the legacy Basic-auth cookie in case a stale one lingers from before the migration.
|
||||
cookies.delete('auth_token', { path: '/' });
|
||||
|
||||
// Zur Login-Seite werfen
|
||||
throw redirect(302, '/login');
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
63
frontend/src/routes/logout/page.server.test.ts
Normal file
63
frontend/src/routes/logout/page.server.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { API_INTERNAL_URL: 'http://backend:8080' }
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
type ActionsRecord = Record<string, (e: never) => unknown>;
|
||||
|
||||
function makeCookies(sessionId?: string) {
|
||||
return {
|
||||
get: vi.fn((name: string) => (name === 'fa_session' ? sessionId : undefined)),
|
||||
set: vi.fn(),
|
||||
delete: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
describe('logout action', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('calls backend /api/auth/logout with the session cookie and redirects to /login', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
const cookies = makeCookies('opaque-id');
|
||||
|
||||
await expect(
|
||||
(actions as ActionsRecord).default({ cookies, fetch: mockFetch } as never)
|
||||
).rejects.toMatchObject({ status: 303, location: '/login' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://backend:8080/api/auth/logout',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { Cookie: 'fa_session=opaque-id' }
|
||||
})
|
||||
);
|
||||
expect(cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
|
||||
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
|
||||
});
|
||||
|
||||
it('clears cookies even when the backend logout call fails', async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'));
|
||||
const cookies = makeCookies('opaque-id');
|
||||
|
||||
await expect(
|
||||
(actions as ActionsRecord).default({ cookies, fetch: mockFetch } as never)
|
||||
).rejects.toMatchObject({ status: 303, location: '/login' });
|
||||
|
||||
expect(cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
|
||||
});
|
||||
|
||||
it('skips the backend call when no session cookie is present', async () => {
|
||||
const mockFetch = vi.fn();
|
||||
const cookies = makeCookies(undefined);
|
||||
|
||||
await expect(
|
||||
(actions as ActionsRecord).default({ cookies, fetch: mockFetch } as never)
|
||||
).rejects.toMatchObject({ status: 303, location: '/login' });
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
|
||||
});
|
||||
});
|
||||
@@ -17,19 +17,9 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_PROXY_TARGET || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
// Inject Authorization header from the auth_token cookie so that
|
||||
// browser-side fetch('/api/...') calls work the same as SSR fetches
|
||||
// (which go through handleFetch in hooks.server.ts).
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq, req) => {
|
||||
const cookies = req.headers.cookie ?? '';
|
||||
const match = cookies.match(/auth_token=([^;]+)/);
|
||||
if (match) {
|
||||
proxyReq.setHeader('Authorization', decodeURIComponent(match[1]));
|
||||
}
|
||||
});
|
||||
}
|
||||
changeOrigin: true
|
||||
// The browser forwards the fa_session cookie to the backend automatically;
|
||||
// no header injection needed (ADR-020).
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -71,7 +61,8 @@ export default defineConfig({
|
||||
'src/lib/shared/utils/**',
|
||||
'src/lib/shared/server/**',
|
||||
'src/lib/shared/discussion/**',
|
||||
'src/lib/document/**'
|
||||
'src/lib/document/**',
|
||||
'src/hooks.server.ts'
|
||||
],
|
||||
exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**'],
|
||||
thresholds: {
|
||||
|
||||
@@ -95,6 +95,6 @@ grafana.archiv.raddatz.cloud {
|
||||
}
|
||||
|
||||
glitchtip.archiv.raddatz.cloud {
|
||||
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
import security_headers
|
||||
reverse_proxy 127.0.0.1:3002
|
||||
}
|
||||
|
||||
24
infra/observability/obs.env
Normal file
24
infra/observability/obs.env
Normal file
@@ -0,0 +1,24 @@
|
||||
# Non-secret observability stack configuration — tracked in git.
|
||||
# Secret values (passwords, keys) are injected by CI from Gitea secrets
|
||||
# into /opt/familienarchiv/obs-secrets.env at deploy time.
|
||||
#
|
||||
# For local dev the main .env file supplies these values instead;
|
||||
# this file is only used in the CI/production path.
|
||||
|
||||
# Host ports (all bound to 127.0.0.1 — Caddy is the external entry point)
|
||||
PORT_GRAFANA=3003
|
||||
PORT_GLITCHTIP=3002
|
||||
PORT_PROMETHEUS=9090
|
||||
|
||||
# Public URLs — used for internal redirects, alert email links, OAuth callbacks
|
||||
GF_SERVER_ROOT_URL=https://grafana.archiv.raddatz.cloud
|
||||
GLITCHTIP_DOMAIN=https://glitchtip.archiv.raddatz.cloud
|
||||
|
||||
POSTGRES_USER=archiv
|
||||
|
||||
# PostgreSQL hostname for GlitchTip db-init and workers.
|
||||
# The actual value depends on the Compose project name — it is not a fixed string.
|
||||
# CI sets POSTGRES_HOST in obs-secrets.env per environment:
|
||||
# staging: archiv-staging-db-1 (project archiv-staging + service db)
|
||||
# production: archiv-production-db-1 (project archiv-production + service db)
|
||||
# For local dev, set POSTGRES_HOST in your .env file (defaults to archive-db there).
|
||||
@@ -15,8 +15,6 @@ scrape_configs:
|
||||
metrics_path: /actuator/prometheus
|
||||
static_configs:
|
||||
# Uses the Docker service name (not container_name) for reliable DNS resolution.
|
||||
# Target will show as DOWN until backend instrumentation issue adds
|
||||
# micrometer-registry-prometheus and exposes the endpoint — this is expected.
|
||||
- targets: ['backend:8081']
|
||||
|
||||
- job_name: ocr-service
|
||||
|
||||
@@ -28,3 +28,5 @@ scrape_configs:
|
||||
target_label: 'compose_project'
|
||||
- source_labels: ['__meta_docker_container_log_stream']
|
||||
target_label: 'logstream'
|
||||
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
|
||||
target_label: 'job'
|
||||
|
||||
@@ -36,9 +36,6 @@ metrics_generator:
|
||||
source: tempo
|
||||
storage:
|
||||
path: /var/tempo/generator/wal
|
||||
processors:
|
||||
- service-graphs
|
||||
- span-metrics
|
||||
|
||||
# Tempo HTTP API (port 3200) is unauthenticated. Access is controlled entirely
|
||||
# by network isolation: only Grafana (on obs-net) should reach this port.
|
||||
|
||||
@@ -5,3 +5,5 @@
|
||||
**LLM reminder:** the OCR service is a **single-node container** — training reloads the model in-process, so multiple replicas cause model-state divergence (see ADR-001). All job tracking and business logic stay in Spring Boot; the Python service is stateless OCR only.
|
||||
|
||||
**LLM reminder:** `ALLOWED_PDF_HOSTS` must never be set to `*` — that opens SSRF. The default (`minio,localhost,127.0.0.1`) is correct for dev.
|
||||
|
||||
**LLM reminder:** `TMPDIR` points to `/app/cache/.tmp` (persistent SSD volume). Never redirect it back to `/tmp` or any RAM-backed path — `/tmp` is 512 MB and cannot stage GB-scale Surya model downloads (causes ENOSPC). The `ocr-volume-init` container creates the directory on fresh volumes; `entrypoint.sh` ensures it exists as a fallback. See ADR-021.
|
||||
|
||||
@@ -23,8 +23,19 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1000 ocr \
|
||||
&& mkdir -p /home/ocr /app/models /app/cache \
|
||||
&& chown -R ocr:ocr /app /home/ocr
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENV HOME=/home/ocr
|
||||
ENV HF_HOME=/app/cache
|
||||
ENV XDG_CACHE_HOME=/app/cache
|
||||
ENV TORCH_HOME=/app/models/torch
|
||||
ENV TMPDIR=/app/cache/.tmp
|
||||
|
||||
USER ocr
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -32,6 +32,10 @@ Python FastAPI microservice that performs the actual handwritten text recognitio
|
||||
| `ALLOWED_PDF_HOSTS` | `minio,localhost,127.0.0.1` | YES | — | SSRF protection — comma-separated allowed PDF source hosts. Never set to `*`. |
|
||||
| `KRAKEN_MODEL_PATH` | `/app/models/` | — | — | Directory where Kraken HTR models are stored (populated by `download-kraken-models.sh`) |
|
||||
| `BLLA_MODEL_PATH` | `/app/models/blla.mlmodel` | — | — | Kraken baseline layout analysis model. Auto-downloaded via `ensure_blla_model.py` on startup if missing. |
|
||||
| `HF_HOME` | `/app/cache` | — | — | HuggingFace model cache root. Keeps model downloads on the persistent cache volume. |
|
||||
| `XDG_CACHE_HOME` | `/app/cache` | — | — | XDG cache root (used by some Surya components alongside `HF_HOME`). |
|
||||
| `TORCH_HOME` | `/app/models/torch` | — | — | PyTorch model cache. Kept on the persistent models volume. |
|
||||
| `TMPDIR` | `/app/cache/.tmp` | — | — | Download-staging directory for GB-scale Surya model files. Must point to a disk-backed path, not the 512 MB `/tmp` tmpfs — see ADR-021. |
|
||||
|
||||
## Key files
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ log = logging.getLogger(__name__)
|
||||
BLLA_MODEL_PATH = os.environ.get("BLLA_MODEL_PATH", "/app/models/blla.mlmodel")
|
||||
# DOI for "General segmentation model for print and handwriting" — ketos 7 compatible.
|
||||
BLLA_MODEL_DOI = "10.5281/zenodo.14602569"
|
||||
HTRMOPO_DIR = os.path.expanduser("~/.local/share/htrmopo")
|
||||
HTRMOPO_DIR = os.environ.get("HTRMOPO_DIR") or "/app/models/.htrmopo"
|
||||
|
||||
|
||||
def _model_is_loadable(path: str) -> bool:
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure TMPDIR exists on the persistent cache volume (created by the volume-init
|
||||
# container, but guaranteed here for fresh volumes and bare docker-run usage).
|
||||
# Remove stale partial downloads left by a previous docker-kill.
|
||||
mkdir -p "${TMPDIR:-/tmp}"
|
||||
find "${TMPDIR:-/tmp}" -mindepth 1 -mtime +1 -delete 2>/dev/null || true
|
||||
|
||||
# Validate the blla segmentation base model and download it if missing or
|
||||
# incompatible. ketos 7 dropped support for legacy PyTorch ZIP archives —
|
||||
# this ensures the volume always holds a loadable CoreML protobuf model.
|
||||
|
||||
@@ -27,6 +27,7 @@ from engines import kraken as kraken_engine
|
||||
from engines import surya as surya_engine
|
||||
from models import OcrBlock, OcrRequest
|
||||
from preprocessing import preprocess_page
|
||||
from utils import _validate_zip_entry
|
||||
|
||||
TRAINING_TOKEN = os.environ.get("TRAINING_TOKEN", "")
|
||||
KRAKEN_MODEL_PATH = os.environ.get("KRAKEN_MODEL_PATH", "/app/models/german_kurrent.mlmodel")
|
||||
@@ -56,6 +57,8 @@ async def lifespan(app: FastAPI):
|
||||
"""Load lightweight models at startup. Surya loads lazily on first request."""
|
||||
global _models_ready
|
||||
|
||||
if os.getuid() == 0:
|
||||
logger.warning("Running as root — CIS Docker §4.1 violation")
|
||||
logger.info("Loading Kraken model at startup (Surya loads lazily on first OCR request)...")
|
||||
kraken_engine.load_models()
|
||||
load_spell_checker()
|
||||
@@ -289,14 +292,6 @@ def _check_training_token(x_training_token: str | None) -> None:
|
||||
raise HTTPException(status_code=403, detail="Invalid or missing X-Training-Token")
|
||||
|
||||
|
||||
def _validate_zip_entry(name: str, extract_dir: str) -> None:
|
||||
"""Reject ZIP Slip attacks: path traversal and absolute paths."""
|
||||
if os.path.isabs(name) or name.startswith(".."):
|
||||
raise HTTPException(status_code=400, detail=f"Unsafe ZIP entry: {name}")
|
||||
resolved = os.path.realpath(os.path.join(extract_dir, name))
|
||||
if not resolved.startswith(os.path.realpath(extract_dir)):
|
||||
raise HTTPException(status_code=400, detail=f"ZIP Slip detected: {name}")
|
||||
|
||||
|
||||
def _rotate_backups(model_path: str, keep: int = 3) -> None:
|
||||
"""Keep only the last `keep` timestamped backups of the model."""
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
"""Unit tests for ensure_blla_model.main()."""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import ensure_blla_model
|
||||
|
||||
|
||||
# ─── HTRMOPO_DIR env var resolution ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_htrmopo_dir_reads_from_env_var():
|
||||
"""HTRMOPO_DIR uses the HTRMOPO_DIR env var when set, not ~ expansion."""
|
||||
with patch.dict(os.environ, {"HTRMOPO_DIR": "/custom/htrmopo"}):
|
||||
importlib.reload(ensure_blla_model)
|
||||
result = ensure_blla_model.HTRMOPO_DIR
|
||||
importlib.reload(ensure_blla_model)
|
||||
assert result == "/custom/htrmopo"
|
||||
|
||||
|
||||
def test_htrmopo_dir_default_is_fixed_path():
|
||||
"""Default HTRMOPO_DIR is a fixed path not derived from ~ (no-create-home safe)."""
|
||||
clean_env = {k: v for k, v in os.environ.items() if k != "HTRMOPO_DIR"}
|
||||
with patch.dict(os.environ, clean_env, clear=True):
|
||||
importlib.reload(ensure_blla_model)
|
||||
result = ensure_blla_model.HTRMOPO_DIR
|
||||
importlib.reload(ensure_blla_model)
|
||||
assert result == "/app/models/.htrmopo"
|
||||
|
||||
|
||||
def test_htrmopo_dir_falls_back_to_default_when_set_to_empty_string():
|
||||
"""HTRMOPO_DIR='' must not produce an empty path — get() returns '' for blank env vars."""
|
||||
with patch.dict(os.environ, {"HTRMOPO_DIR": ""}):
|
||||
importlib.reload(ensure_blla_model)
|
||||
result = ensure_blla_model.HTRMOPO_DIR
|
||||
importlib.reload(ensure_blla_model)
|
||||
assert result != ""
|
||||
|
||||
|
||||
# ─── Model already loadable ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
36
ocr-service/test_main.py
Normal file
36
ocr-service/test_main.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Tests for main.py — startup behavior."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from main import app
|
||||
|
||||
|
||||
# ─── Root canary ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_logs_warning_when_running_as_root(caplog):
|
||||
"""Lifespan emits a WARNING when the process uid is 0 (running as root)."""
|
||||
with patch("main.os.getuid", return_value=0), \
|
||||
patch("main.kraken_engine.load_models"), \
|
||||
patch("main.load_spell_checker"), \
|
||||
caplog.at_level(logging.WARNING, logger="main"):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test"):
|
||||
pass
|
||||
assert "Running as root" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_does_not_warn_when_running_as_non_root(caplog):
|
||||
"""Lifespan does not emit a root warning when running as a non-root user."""
|
||||
with patch("main.os.getuid", return_value=1000), \
|
||||
patch("main.kraken_engine.load_models"), \
|
||||
patch("main.load_spell_checker"), \
|
||||
caplog.at_level(logging.WARNING, logger="main"):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test"):
|
||||
pass
|
||||
assert "Running as root" not in caplog.text
|
||||
151
ocr-service/test_tmpdir.py
Normal file
151
ocr-service/test_tmpdir.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tests for TMPDIR configuration and entrypoint mkdir behavior — ADR-021."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi import HTTPException
|
||||
from utils import _validate_zip_entry
|
||||
|
||||
_ENTRYPOINT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "entrypoint.sh")
|
||||
|
||||
|
||||
def _run_entrypoint(tmpdir, tmp_path):
|
||||
"""Run entrypoint.sh with TMPDIR set to tmpdir; python3/uvicorn are stubbed out."""
|
||||
stub_bin = tmp_path / "stub_bin"
|
||||
stub_bin.mkdir(exist_ok=True)
|
||||
for name in ("python3", "uvicorn"):
|
||||
stub = stub_bin / name
|
||||
stub.write_text("#!/bin/sh\nexit 0\n")
|
||||
stub.chmod(0o755)
|
||||
env = {
|
||||
**os.environ,
|
||||
"TMPDIR": str(tmpdir),
|
||||
"PATH": f"{stub_bin}:{os.environ.get('PATH', '/usr/bin:/bin')}",
|
||||
}
|
||||
return subprocess.run(["bash", _ENTRYPOINT], env=env, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_tempfile_uses_tmpdir_when_set(monkeypatch, tmp_path):
|
||||
"""Python honours the TMPDIR env var when creating temporary directories.
|
||||
|
||||
Documents the mechanism that routes Surya model staging to the persistent
|
||||
cache volume instead of the 512 MB RAM tmpfs. See ADR-021.
|
||||
"""
|
||||
custom_tmp = tmp_path / "model_staging"
|
||||
custom_tmp.mkdir()
|
||||
monkeypatch.setenv("TMPDIR", str(custom_tmp))
|
||||
monkeypatch.setattr(tempfile, "tempdir", None)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
assert td.startswith(str(custom_tmp))
|
||||
|
||||
|
||||
def test_entrypoint_creates_tmpdir(tmp_path):
|
||||
"""entrypoint.sh creates the TMPDIR directory when it does not exist.
|
||||
|
||||
On a fresh ocr_cache volume, /app/cache/.tmp is absent. The entrypoint
|
||||
must create it before uvicorn starts so the first Surya model download
|
||||
does not exhaust the 512 MB /tmp tmpfs (ENOSPC). See ADR-021.
|
||||
"""
|
||||
custom_tmp = tmp_path / "model-staging"
|
||||
assert not custom_tmp.exists(), "pre-condition: directory must not exist yet"
|
||||
|
||||
stub_bin = tmp_path / "stub_bin"
|
||||
stub_bin.mkdir()
|
||||
for name in ("python3", "uvicorn"):
|
||||
stub = stub_bin / name
|
||||
stub.write_text("#!/bin/sh\nexit 0\n")
|
||||
stub.chmod(0o755)
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"TMPDIR": str(custom_tmp),
|
||||
"PATH": f"{stub_bin}:{os.environ.get('PATH', '/usr/bin:/bin')}",
|
||||
}
|
||||
result = subprocess.run(
|
||||
["bash", _ENTRYPOINT],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"entrypoint.sh exited {result.returncode}\n"
|
||||
f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
assert custom_tmp.exists(), (
|
||||
f"entrypoint.sh did not create TMPDIR={custom_tmp}\n"
|
||||
f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("TMPDIR", "").startswith("/app/cache"),
|
||||
reason="TMPDIR contract only enforced inside the OCR Docker container",
|
||||
)
|
||||
def test_tmpdir_is_inside_persistent_cache_volume():
|
||||
"""TMPDIR must point to the persistent cache volume, not a RAM tmpfs.
|
||||
|
||||
Catches accidental reversion to /tmp or any tmpfs-backed path.
|
||||
Runs only inside the OCR Docker container where TMPDIR=/app/cache/.tmp.
|
||||
To run manually: docker exec archiv-ocr python -m pytest test_tmpdir.py::test_tmpdir_is_inside_persistent_cache_volume -v
|
||||
See ADR-021.
|
||||
"""
|
||||
tmpdir = os.environ["TMPDIR"]
|
||||
assert tmpdir.startswith("/app/cache"), (
|
||||
f"TMPDIR={tmpdir!r} must be under /app/cache to route model downloads "
|
||||
"to the SSD-backed cache volume — see ADR-021"
|
||||
)
|
||||
|
||||
|
||||
def test_entrypoint_removes_day_old_orphans(tmp_path):
|
||||
"""entrypoint.sh deletes partial downloads older than 1 day from TMPDIR.
|
||||
|
||||
Simulates a file left behind by a docker-kill mid-download: backdate its
|
||||
mtime by 2 days using os.utime(), run the entrypoint, assert it is gone.
|
||||
See ADR-021.
|
||||
"""
|
||||
staging = tmp_path / "staging"
|
||||
staging.mkdir()
|
||||
stale_file = staging / "model.safetensors.partial"
|
||||
stale_file.write_bytes(b"partial download")
|
||||
two_days_ago = time.time() - 2 * 24 * 3600
|
||||
os.utime(stale_file, (two_days_ago, two_days_ago))
|
||||
|
||||
result = _run_entrypoint(staging, tmp_path)
|
||||
assert result.returncode == 0, f"entrypoint.sh exited {result.returncode}\nstderr: {result.stderr}"
|
||||
assert not stale_file.exists(), "day-old orphan should have been deleted by entrypoint.sh"
|
||||
|
||||
|
||||
def test_entrypoint_preserves_fresh_files(tmp_path):
|
||||
"""entrypoint.sh does not delete files newer than 1 day from TMPDIR.
|
||||
|
||||
An in-progress download whose mtime is recent must survive the orphan
|
||||
cleanup so a concurrent or just-started model fetch is not interrupted.
|
||||
See ADR-021.
|
||||
"""
|
||||
staging = tmp_path / "staging"
|
||||
staging.mkdir()
|
||||
fresh_file = staging / "model.safetensors.part"
|
||||
fresh_file.write_bytes(b"in progress")
|
||||
# mtime is now — no os.utime() call needed
|
||||
|
||||
result = _run_entrypoint(staging, tmp_path)
|
||||
assert result.returncode == 0, f"entrypoint.sh exited {result.returncode}\nstderr: {result.stderr}"
|
||||
assert fresh_file.exists(), "recent file should not have been deleted by entrypoint.sh"
|
||||
|
||||
|
||||
def test_zipslip_still_anchors_under_custom_tmpdir(tmp_path):
|
||||
"""_validate_zip_entry rejects path-traversal when extract_dir is under a custom TMPDIR.
|
||||
|
||||
When TMPDIR=/app/cache/.tmp, extraction dirs live under that path.
|
||||
Verifies os.path.realpath() still anchors correctly against the non-default base.
|
||||
"""
|
||||
extract_dir = tmp_path / "model-staging" / "tmpXXX"
|
||||
extract_dir.mkdir(parents=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_zip_entry("../evil.py", str(extract_dir))
|
||||
assert exc_info.value.status_code == 400
|
||||
14
ocr-service/utils.py
Normal file
14
ocr-service/utils.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Utility functions shared across the OCR service with no ML-stack imports."""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _validate_zip_entry(name: str, extract_dir: str) -> None:
|
||||
"""Reject ZIP Slip attacks: path traversal and absolute paths."""
|
||||
if os.path.isabs(name) or name.startswith(".."):
|
||||
raise HTTPException(status_code=400, detail=f"Unsafe ZIP entry: {name}")
|
||||
resolved = os.path.realpath(os.path.join(extract_dir, name))
|
||||
if not resolved.startswith(os.path.realpath(extract_dir)):
|
||||
raise HTTPException(status_code=400, detail=f"ZIP Slip detected: {name}")
|
||||
@@ -1,16 +1,26 @@
|
||||
# runner-config.yaml — only the relevant section
|
||||
container:
|
||||
# passed as DOCKER_HOST inside the job container
|
||||
# join the same network Gitea is on, so job containers can resolve 'gitea'
|
||||
# for actions/checkout and other internal API calls.
|
||||
network: gitea_gitea
|
||||
# passed as DOCKER_HOST inside the job container; act_runner auto-mounts
|
||||
# this socket path into the job, so no explicit -v option is needed.
|
||||
docker_host: "unix:///var/run/docker.sock"
|
||||
# whitelists the socket path so workflows can mount it
|
||||
# Job workspaces are stored here and mounted at the same absolute path
|
||||
# inside job containers. Identical host <-> container path is the requirement:
|
||||
# Compose resolves relative bind mounts to $(pwd) inside the job container
|
||||
# and passes that absolute path to the host daemon, which must find the file
|
||||
# at that exact host path. Prerequisite: /srv/gitea-workspace exists on the
|
||||
# host and is bind-mounted in the runner container (see compose.yaml).
|
||||
workdir_parent: /srv/gitea-workspace
|
||||
# whitelists volumes that workflow steps may bind-mount
|
||||
valid_volumes:
|
||||
- "/var/run/docker.sock"
|
||||
# appended to `docker run` when the runner spawns a job container
|
||||
# SECURITY: Mounting the Docker socket grants job containers root-equivalent
|
||||
# access to the host Docker daemon. Acceptable here because only trusted code
|
||||
# from this private repo runs on this runner. Do NOT use on a runner that
|
||||
# accepts untrusted PRs from external contributors.
|
||||
options: "-v /var/run/docker.sock:/var/run/docker.sock"
|
||||
# keep network mode default (bridge) — Testcontainers handles its own networking
|
||||
- "/srv/gitea-workspace"
|
||||
- "/opt/familienarchiv"
|
||||
# mount the workspace and the permanent obs/config directory into job containers.
|
||||
# /opt/familienarchiv is the stable path CI copies configs to (ADR-016); it must
|
||||
# be mounted here so deploy steps can write through to the host filesystem.
|
||||
options: "-v /srv/gitea-workspace:/srv/gitea-workspace -v /opt/familienarchiv:/opt/familienarchiv"
|
||||
# keep behavior default — Testcontainers handles its own networking
|
||||
force_pull: false
|
||||
|
||||
|
||||
Reference in New Issue
Block a user