Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"PreToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "Bash|Grep",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "/Users/stwaidele/.local/bin/graphify hook-guard search"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Read|Glob",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "/Users/stwaidele/.local/bin/graphify hook-guard read"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -68,7 +68,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Pull and restart the Test stack
|
- name: Pull and restart the Test stack
|
||||||
run: |
|
run: |
|
||||||
ssh deploy@$DEPLOY_HOST 'cd /srv/DOCKER/dorfteich-test \
|
ssh deploy@$DEPLOY_HOST 'cd /home/DOCKER/dorfteich-test \
|
||||||
&& docker compose pull --quiet && docker compose up -d --remove-orphans \
|
&& docker compose pull --quiet && docker compose up -d --remove-orphans \
|
||||||
&& docker compose ps'
|
&& docker compose ps'
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@ -138,6 +138,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Pull and restart the Int stack
|
- name: Pull and restart the Int stack
|
||||||
run: |
|
run: |
|
||||||
ssh deploy@$DEPLOY_HOST 'cd /srv/DOCKER/dorfteich-int \
|
ssh deploy@$DEPLOY_HOST 'cd /home/DOCKER/dorfteich-int \
|
||||||
&& docker compose pull --quiet && docker compose up -d --remove-orphans \
|
&& docker compose pull --quiet && docker compose up -d --remove-orphans \
|
||||||
&& docker compose ps'
|
&& docker compose ps'
|
||||||
|
|||||||
@ -38,112 +38,18 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Fails if a real .env (anything but .env.example) is ever tracked, or
|
|
||||||
# if a tracked file matches an obvious secret pattern (issue #198).
|
|
||||||
# .env.example is the authoritative reference; real values never enter
|
|
||||||
# the repository (docs/self-hosting/README.md).
|
|
||||||
- name: No tracked .env files or secret material
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
bad_env=$(git ls-files | grep -E '(^|/)\.env(\.[^/]*)?$' | grep -v '\.env\.example$' || true)
|
|
||||||
if [ -n "$bad_env" ]; then
|
|
||||||
echo "tracked .env file(s) — only .env.example may be tracked:"
|
|
||||||
echo "$bad_env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
secrets=$(git grep -nIE -e '-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20}|xox[baprs]-[0-9A-Za-z-]{10}' -- . || true)
|
|
||||||
if [ -n "$secrets" ]; then
|
|
||||||
echo "tracked file matches a secret pattern:"
|
|
||||||
echo "$secrets"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# One authoritative Node version (issue #236): `.node-version` is the
|
|
||||||
# pin; every Dockerfile image tag and the engines floor must match it
|
|
||||||
# exactly, and workflows select Node only through node-version-file.
|
|
||||||
# Raising Node = update .node-version, every `FROM node:` tag and the
|
|
||||||
# engines floor in ONE commit (procedure: docs/architecture/operations.md
|
|
||||||
# §Update strategy). The bracketed grep pattern keeps this step from
|
|
||||||
# matching its own source (same trick as the secret fence above).
|
|
||||||
- name: Node version pin is consistent
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
ver="$(cat .node-version)"
|
|
||||||
echo "pinned Node version: $ver"
|
|
||||||
bad=0
|
|
||||||
for f in apps/*/Dockerfile; do
|
|
||||||
if grep '^FROM node:' "$f" | grep -v "node:${ver}-alpine"; then
|
|
||||||
echo "$f pins a different Node image than node:${ver}-alpine"
|
|
||||||
bad=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if grep -rn "node-version[:] " .gitea/workflows; then
|
|
||||||
echo "workflows must use node-version-file, not a literal version"
|
|
||||||
bad=1
|
|
||||||
fi
|
|
||||||
if grep -rnE 'node:[0-9][^ ]*-alpine' .gitea/workflows | grep -v "node:${ver}-alpine"; then
|
|
||||||
echo "a workflow references a different node image than node:${ver}-alpine"
|
|
||||||
bad=1
|
|
||||||
fi
|
|
||||||
if ! grep -q "\"node\": \">=${ver}\"" package.json; then
|
|
||||||
echo "package.json engines floor does not match ${ver}"
|
|
||||||
bad=1
|
|
||||||
fi
|
|
||||||
exit "$bad"
|
|
||||||
|
|
||||||
# Third-party deploy images are pinned by digest (issue #203): every
|
|
||||||
# image in the deploy compose that is not one of our own
|
|
||||||
# (${IMAGE_PREFIX}…) must carry @sha256 — the tag stays for
|
|
||||||
# readability, the digest decides what runs. Update procedure:
|
|
||||||
# deploy/stages.md §Third-party image digests. compose.dev.yml is a
|
|
||||||
# local convenience, deliberately not held to this.
|
|
||||||
- name: Third-party compose images are digest-pinned
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
bad=$(grep -hE '^ *image: ' deploy/compose/docker-compose.yml | grep -v 'IMAGE_PREFIX' | grep -v '@sha256:' || true)
|
|
||||||
if [ -n "$bad" ]; then
|
|
||||||
echo "third-party image reference(s) without a digest:"
|
|
||||||
echo "$bad"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A fresh named volume inherits the ownership of the image directory it
|
|
||||||
# is mounted over. Every /data/… path the api image defaults to must
|
|
||||||
# therefore be pre-created AND chowned to `node`, or the non-root user
|
|
||||||
# cannot write to it — found on a real deploy in #303, where the env
|
|
||||||
# entry was added but the mkdir/chown line was not.
|
|
||||||
- name: api image pre-creates its data directories node-owned
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
dirs=$(grep -oE '[A-Z_]+_DIR=/data/[a-z]+' apps/api/Dockerfile | cut -d= -f2 | sort -u)
|
|
||||||
bad=0
|
|
||||||
for d in $dirs; do
|
|
||||||
grep -q "mkdir -p .*$d" apps/api/Dockerfile || {
|
|
||||||
echo "$d is not pre-created in apps/api/Dockerfile"; bad=1; }
|
|
||||||
grep -q "chown -R node:node .*$d" apps/api/Dockerfile || {
|
|
||||||
echo "$d is not chowned to node in apps/api/Dockerfile"; bad=1; }
|
|
||||||
done
|
|
||||||
exit "$bad"
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
- name: Set up pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# License allowlist gate (issue #202): fails when any dependency's
|
|
||||||
# license falls outside the documented policy in
|
|
||||||
# scripts/check-licenses.mjs (which is also where the reasoning and
|
|
||||||
# per-package exceptions live).
|
|
||||||
- name: License allowlist
|
|
||||||
run: pnpm licenses list --json | node scripts/check-licenses.mjs
|
|
||||||
|
|
||||||
# Build first: package type checks resolve @dorfteich/shared through
|
# Build first: package type checks resolve @dorfteich/shared through
|
||||||
# its built dist, and i18n:check imports the built helpers.
|
# its built dist, and i18n:check imports the built helpers.
|
||||||
- name: Build all packages
|
- name: Build all packages
|
||||||
@ -193,7 +99,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@ -210,16 +116,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Start api, collab, and static web server
|
- name: Start api, collab, and static web server
|
||||||
run: |
|
run: |
|
||||||
# VS_NFD_MODE=marked: the marking pack and the a11y admin scan
|
(cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &)
|
||||||
# cover the marked state (issue #244); mode off is covered by
|
|
||||||
# local full runs and the marking pack's off-assertions there.
|
|
||||||
(cd apps/api && PORT=3001 VS_NFD_MODE=marked node dist/main.js > /tmp/api.log 2>&1 &)
|
|
||||||
# Second api on the SAME database with VS_NFD_MODE=hidden: the
|
|
||||||
# marking pack's hidden half runs against it via its own static
|
|
||||||
# server (issue #245); the mode is env-only, so sharing the db is
|
|
||||||
# exactly the deploy semantics.
|
|
||||||
(cd apps/api && PORT=3006 VS_NFD_MODE=hidden MIGRATE_ON_START=false node dist/main.js > /tmp/api-hidden.log 2>&1 &)
|
|
||||||
(PORT=5176 API_TARGET=http://127.0.0.1:3006 node scripts/e2e-static-server.mjs > /tmp/web-hidden.log 2>&1 &)
|
|
||||||
(cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &)
|
(cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &)
|
||||||
(PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &)
|
(PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &)
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
@ -345,16 +242,6 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before admin-settings pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
- name: Run admin-settings pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before admin-quotas pack
|
- name: Reset login rate limit before admin-quotas pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
@ -375,18 +262,6 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before invitations pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
# Invitations (issue #332) need the mail catcher like the auth pack:
|
|
||||||
# the invite link and the follow-up verification both travel by mail.
|
|
||||||
- name: Run invitations pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before permission-matrix pack
|
- name: Reset login rate limit before permission-matrix pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
@ -491,76 +366,6 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/backlinks.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before page-tree pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
- name: Run page-tree pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/page-tree.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before graph pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
- name: Run graph pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/graph.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before favorites pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
- name: Run favorites pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/favorites.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before settings-nav pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
- name: Run settings-nav pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before tasks pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
- name: Run tasks pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/tasks.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before create-missing-page pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
- name: Run create-missing-page pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/create-missing-page.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before vault-import pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
- name: Run vault-import pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/import-vault.spec.ts
|
|
||||||
|
|
||||||
- name: Reset login rate limit before search pack
|
- name: Reset login rate limit before search pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
@ -646,57 +451,6 @@ jobs:
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
# Six logins per run since #180 doubled the scans (3 contexts × light/
|
|
||||||
# dark, limit is 10/min) → reset first (see note above).
|
|
||||||
- name: Reset login rate limit before a11y pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
# WCAG-A/AA-Regressionsschutz (issue #171): axe-Scan der Kernscreens,
|
|
||||||
# seit #180 in beiden Farbschemata.
|
|
||||||
- name: Run a11y pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
|
|
||||||
|
|
||||||
# Das a11y-Pack kostet seit #301 einen Login mehr (der Reflow-Zaun);
|
|
||||||
# damit reicht das Budget nicht mehr bis in die VS-NfD-Packs → hier
|
|
||||||
# zusätzlich zurücksetzen (siehe Hinweis oben).
|
|
||||||
- name: Reset login rate limit before the VS-NfD packs
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
# VS-NfD-Markierungen im Modus `marked` (issue #244).
|
|
||||||
- name: Run VS-NfD marking pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
|
|
||||||
|
|
||||||
# Ausblendung + Policy-Hinweis im Modus `hidden` (issue #245).
|
|
||||||
- name: Run VS-NfD hidden pack
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
curl -sf http://localhost:3006/api/v1/readyz >/dev/null && break
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
E2E_BASE_URL=http://localhost:5176 E2E_VS_NFD_MODE=hidden \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts
|
|
||||||
|
|
||||||
# The marking pack's extra login on top of the six a11y logins pushes
|
|
||||||
# the theme pack over the 10/min login limit — reset again (#244).
|
|
||||||
- name: Reset login rate limit before theme pack
|
|
||||||
run: |
|
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
|
||||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
|
||||||
|
|
||||||
# Hell/Dunkel/System-Umschalter (issue #180).
|
|
||||||
- name: Run theme pack
|
|
||||||
run: |
|
|
||||||
E2E_BASE_URL=http://localhost:5173 \
|
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/theme.spec.ts
|
|
||||||
|
|
||||||
- name: Run setup wizard pack
|
- name: Run setup wizard pack
|
||||||
run: |
|
run: |
|
||||||
E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \
|
E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \
|
||||||
@ -730,7 +484,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@ -754,16 +508,13 @@ jobs:
|
|||||||
# image has no iproute2). Sharing the netns means no published ports.
|
# image has no iproute2). Sharing the netns means no published ports.
|
||||||
- name: Start pinned pandoc + Gotenberg sidecars
|
- name: Start pinned pandoc + Gotenberg sidecars
|
||||||
run: |
|
run: |
|
||||||
# Sidecar names carry THIS job container's id: parallel runs on the
|
# Clear any leftovers from an earlier interrupted run so the named
|
||||||
# shared host must not collide on a fixed name (a fixed-name rm -f
|
# containers never collide, and nothing leaks on the shared host.
|
||||||
# here even killed a sibling run's live sidecars — run 547).
|
docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
|
||||||
JOB_ID=$(cat /etc/hostname)
|
JOB_ID=$(cat /etc/hostname)
|
||||||
echo "PANDOC_NAME=fidelity-pandoc-${JOB_ID}" >> "$GITHUB_ENV"
|
docker run -d --name fidelity-pandoc \
|
||||||
echo "GOTENBERG_NAME=fidelity-gotenberg-${JOB_ID}" >> "$GITHUB_ENV"
|
|
||||||
docker rm -f "fidelity-pandoc-${JOB_ID}" "fidelity-gotenberg-${JOB_ID}" 2>/dev/null || true
|
|
||||||
docker run -d --name "fidelity-pandoc-${JOB_ID}" \
|
|
||||||
--network "container:${JOB_ID}" pandoc/core:3.6 server
|
--network "container:${JOB_ID}" pandoc/core:3.6 server
|
||||||
docker run -d --name "fidelity-gotenberg-${JOB_ID}" \
|
docker run -d --name fidelity-gotenberg \
|
||||||
--network "container:${JOB_ID}" gotenberg/gotenberg:8
|
--network "container:${JOB_ID}" gotenberg/gotenberg:8
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
curl -sf http://localhost:3030/version >/dev/null && break
|
curl -sf http://localhost:3030/version >/dev/null && break
|
||||||
@ -787,15 +538,15 @@ jobs:
|
|||||||
- name: Dump sidecar logs on failure
|
- name: Dump sidecar logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
echo '--- pandoc ---'; docker logs "$PANDOC_NAME" 2>&1 | tail -30 || true
|
echo '--- pandoc ---'; docker logs fidelity-pandoc 2>&1 | tail -30 || true
|
||||||
echo '--- gotenberg ---'; docker logs "$GOTENBERG_NAME" 2>&1 | tail -30 || true
|
echo '--- gotenberg ---'; docker logs fidelity-gotenberg 2>&1 | tail -30 || true
|
||||||
|
|
||||||
# Always tear the sidecars down — they run on the shared runner host, so a
|
# Always tear the sidecars down — they run on the shared runner host, so a
|
||||||
# leaked (especially Chromium-backed Gotenberg) container would waste its
|
# leaked (especially Chromium-backed Gotenberg) container would waste its
|
||||||
# memory until the next run.
|
# memory until the next run and break re-runs on the container name.
|
||||||
- name: Stop sidecars
|
- name: Stop sidecars
|
||||||
if: always()
|
if: always()
|
||||||
run: docker rm -f "$PANDOC_NAME" "$GOTENBERG_NAME" 2>/dev/null || true
|
run: docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
|
||||||
|
|
||||||
images:
|
images:
|
||||||
name: Build container images
|
name: Build container images
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
# Monthly restore drill (ADR 0015, issue #87): restores the latest backup
|
# Monthly restore drill (ADR 0015, issue #87): restores the latest backup
|
||||||
# set of the drilled stage into a scratch environment on the runner's Docker
|
# set of the drilled stage into a scratch environment on the runner's Docker
|
||||||
# daemon (the stage host), verifies it, and logs the outcome as a comment on
|
# daemon (the stage host), verifies it, and logs the outcome as a comment on
|
||||||
# the pinned "Restore drills" issue. Since go-live (2026-07-12, #89) the
|
# the pinned "Restore drills" issue. Pre-go-live the drilled stage is Test;
|
||||||
# drilled stage is Prod; the runner on ONE holds the dorfteich-prod_backups
|
# switch DRILL_SOURCE_VOLUME to the Prod backups volume at go-live (#89).
|
||||||
# volume. TAG stays `test` on purpose: it only selects the drill-harness
|
|
||||||
# images (backup + api) that perform and verify the restore, and a forward
|
|
||||||
# schema restores a Prod set fine — it is not a claim about the Prod release.
|
|
||||||
|
|
||||||
name: Restore drill
|
name: Restore drill
|
||||||
|
|
||||||
@ -21,7 +18,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_BASE: gitea.101010.cloud/stwaidele/dorfteich
|
IMAGE_BASE: gitea.101010.cloud/stwaidele/dorfteich
|
||||||
DRILL_SOURCE_VOLUME: dorfteich-prod_backups
|
DRILL_SOURCE_VOLUME: dorfteich-test_backups
|
||||||
DRILL_LOG_ISSUE: '98'
|
DRILL_LOG_ISSUE: '98'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@ -55,7 +52,7 @@ jobs:
|
|||||||
} > comment.md
|
} > comment.md
|
||||||
# JSON-encode via a node container — the runner image guarantees
|
# JSON-encode via a node container — the runner image guarantees
|
||||||
# only git/curl/docker, not python or node.
|
# only git/curl/docker, not python or node.
|
||||||
docker run --rm -i node:22.15.1-alpine node -e \
|
docker run --rm -i node:22.15-alpine node -e \
|
||||||
'const fs=require("fs");process.stdout.write(JSON.stringify({body:fs.readFileSync(0,"utf8")}))' \
|
'const fs=require("fs");process.stdout.write(JSON.stringify({body:fs.readFileSync(0,"utf8")}))' \
|
||||||
< comment.md > comment.json
|
< comment.md > comment.json
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
|
|||||||
@ -38,13 +38,13 @@ jobs:
|
|||||||
- name: Set up SSH
|
- name: Set up SSH
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY_PROD }}" > ~/.ssh/id_ed25519
|
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY_TEST }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
printf '%s\n' "${{ secrets.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts
|
printf '%s\n' "${{ secrets.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Pin the version and restart the Prod stack
|
- name: Pin the version and restart the Prod stack
|
||||||
run: |
|
run: |
|
||||||
ssh deploy@$DEPLOY_HOST "cd /srv/DOCKER/dorfteich-prod \
|
ssh deploy@$DEPLOY_HOST "cd /home/DOCKER/dorfteich-prod \
|
||||||
&& sed -i 's/^TAG=.*/TAG=$VERSION/' .env \
|
&& sed -i 's/^TAG=.*/TAG=$VERSION/' .env \
|
||||||
&& docker compose pull --quiet && docker compose up -d --remove-orphans \
|
&& docker compose pull --quiet && docker compose up -d --remove-orphans \
|
||||||
&& docker compose ps"
|
&& docker compose ps"
|
||||||
|
|||||||
@ -38,62 +38,6 @@ jobs:
|
|||||||
docker push $IMAGE_BASE-$app:$TAG
|
docker push $IMAGE_BASE-$app:$TAG
|
||||||
done
|
done
|
||||||
|
|
||||||
# Supply-chain artefacts (issue #202): one CycloneDX SBOM per release
|
|
||||||
# image, one for the pnpm workspace, plus the full license report —
|
|
||||||
# attached as build artefacts of this run BEFORE the release is
|
|
||||||
# published, so a red gate stops the release. Mechanics dictated by
|
|
||||||
# the runner (the job talks to the HOST daemon, so bind mounts of
|
|
||||||
# workspace paths resolve on the host and go nowhere): files travel
|
|
||||||
# into the pinned syft container via `docker cp` (an API stream), and
|
|
||||||
# images via `docker save` to a tar copied the same way — syft cannot
|
|
||||||
# read a tar from stdin (not seekable).
|
|
||||||
- name: Generate SBOMs
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TAG=${GITHUB_REF_NAME}
|
|
||||||
SYFT=anchore/syft:v1.33.0
|
|
||||||
mkdir -p supply-chain sbom-src
|
|
||||||
cp pnpm-lock.yaml package.json sbom-src/
|
|
||||||
c=$(docker create $SYFT scan dir:/src --source-name dorfteich-workspace --source-version "$TAG" -o cyclonedx-json=/out.json)
|
|
||||||
docker cp sbom-src "$c:/src"
|
|
||||||
docker start -a "$c"
|
|
||||||
docker cp "$c:/out.json" supply-chain/sbom-workspace-$TAG.cdx.json
|
|
||||||
docker rm "$c" > /dev/null
|
|
||||||
for app in web api collab backup; do
|
|
||||||
docker save $IMAGE_BASE-$app:$TAG -o image.tar
|
|
||||||
c=$(docker create $SYFT scan docker-archive:/image.tar --source-name dorfteich-$app --source-version "$TAG" -o cyclonedx-json=/out.json)
|
|
||||||
docker cp image.tar "$c:/image.tar"
|
|
||||||
docker start -a "$c"
|
|
||||||
docker cp "$c:/out.json" supply-chain/sbom-image-$app-$TAG.cdx.json
|
|
||||||
docker rm "$c" > /dev/null
|
|
||||||
rm image.tar
|
|
||||||
done
|
|
||||||
ls -l supply-chain/
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .node-version
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: License report and allowlist gate
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
pnpm licenses list --json > supply-chain/licenses-${GITHUB_REF_NAME}.json
|
|
||||||
node scripts/check-licenses.mjs < supply-chain/licenses-${GITHUB_REF_NAME}.json
|
|
||||||
|
|
||||||
- name: Attach supply-chain artefacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: supply-chain-${{ github.ref_name }}
|
|
||||||
path: supply-chain/
|
|
||||||
|
|
||||||
- name: Generate release notes and publish the release
|
- name: Generate release notes and publish the release
|
||||||
run: |
|
run: |
|
||||||
TAG=${GITHUB_REF_NAME}
|
TAG=${GITHUB_REF_NAME}
|
||||||
@ -110,7 +54,7 @@ jobs:
|
|||||||
echo '_No database migrations in this release._'
|
echo '_No database migrations in this release._'
|
||||||
fi
|
fi
|
||||||
} > notes.md
|
} > notes.md
|
||||||
TAG=$TAG docker run --rm -i -e TAG node:22.15.1-alpine node -e \
|
TAG=$TAG docker run --rm -i -e TAG node:22.15-alpine node -e \
|
||||||
'const fs=require("fs");const body=fs.readFileSync(0,"utf8");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:process.env.TAG,body}))' \
|
'const fs=require("fs");const body=fs.readFileSync(0,"utf8");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:process.env.TAG,body}))' \
|
||||||
< notes.md > release.json
|
< notes.md > release.json
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
@ -119,25 +63,3 @@ jobs:
|
|||||||
--data @release.json \
|
--data @release.json \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \
|
||||||
> /dev/null && echo "release $TAG published"
|
> /dev/null && echo "release $TAG published"
|
||||||
|
|
||||||
# Operations QA (issue #90): the pre-approval gate. A human pushes the
|
|
||||||
# prod-vX.Y.Z tag only after BOTH jobs of this release run are green.
|
|
||||||
ops-qa:
|
|
||||||
name: Release-candidate operations QA
|
|
||||||
needs: build-release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Log in to the Gitea registry
|
|
||||||
run: printf '%s' "${{ secrets.REGISTRY_TOKEN }}" | tr -d '[:space:]' | docker login gitea.101010.cloud -u fable-5 --password-stdin
|
|
||||||
|
|
||||||
- name: Run update simulation, degraded readiness, and backup roundtrip
|
|
||||||
run: |
|
|
||||||
PREV=$(git tag --list 'v*.*.*' --sort=-v:refname | grep -vx "$GITHUB_REF_NAME" | head -n1 || true)
|
|
||||||
NEXT_TAG=$GITHUB_REF_NAME PREV_TAG=$PREV IMAGE_BASE=$IMAGE_BASE \
|
|
||||||
sh deploy/release-qa.sh
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -14,6 +14,3 @@ apps/api/data/
|
|||||||
# Font catalog WOFF2 + generated stylesheet — fetched at build time
|
# Font catalog WOFF2 + generated stylesheet — fetched at build time
|
||||||
# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed.
|
# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed.
|
||||||
apps/web/public/fonts/
|
apps/web/public/fonts/
|
||||||
|
|
||||||
# graphify knowledge graph (generated)
|
|
||||||
graphify-out/
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
22.15.1
|
|
||||||
@ -14,9 +14,3 @@ fixtures/import/*.src.html
|
|||||||
# Export fidelity corpus (issue #69): the round-trip snapshots must stay exactly
|
# Export fidelity corpus (issue #69): the round-trip snapshots must stay exactly
|
||||||
# as pandoc reads the exported document back — Prettier would break them.
|
# as pandoc reads the exported document back — Prettier would break them.
|
||||||
fixtures/export/*.expected.md
|
fixtures/export/*.expected.md
|
||||||
packages/plugins/*/vendor/
|
|
||||||
# Agent/tool config generated by Claude Code + `graphify claude install`
|
|
||||||
# (regenerated on demand, not hand-formatted source) — keep out of Prettier so
|
|
||||||
# a re-install never breaks the lint gate.
|
|
||||||
CLAUDE.md
|
|
||||||
.claude/
|
|
||||||
|
|||||||
29
CLAUDE.md
29
CLAUDE.md
@ -1,29 +0,0 @@
|
|||||||
## Barrierefreiheit (verbindlich, ADR 0017)
|
|
||||||
|
|
||||||
Jede UI-Änderung wird von Anfang an barrierefrei entwickelt (WCAG 2.1
|
|
||||||
AA) — nicht nachträglich. Kurzfassung; Details und Begründung in
|
|
||||||
`docs/architecture/adr/0017-accessibility-by-default.md`:
|
|
||||||
|
|
||||||
- **Bausteine wiederverwenden:** `IconButton` (erzwungener Name),
|
|
||||||
`Field` (Label + Fehler-Verdrahtung), `useModalFocus` für Dialoge
|
|
||||||
(Trap/Initialfokus/Rückgabe + `aria-labelledby`). Keine Parallelbauten.
|
|
||||||
- **Tastatur zuerst:** alles erreichbar/bedienbar, Fokus sichtbar,
|
|
||||||
Escape schließt, kein Fokusverlust; Einzeltasten-Shortcuts respektieren
|
|
||||||
`lib/single-key-shortcuts.ts`.
|
|
||||||
- **Name/Rolle/Wert:** korrekte Rollen, lokalisierte (de+en) Labels,
|
|
||||||
Zustände via aria-*; dekorative Icons `aria-hidden`.
|
|
||||||
- **BEIDE Renderpfade:** Editor-/NodeView-Pfad UND docToHtml/Server-Pfad
|
|
||||||
gleichwertig behandeln (Cache rollt lazy aus).
|
|
||||||
- **Kontrast/Farbe:** Tokens nutzen (Text ≥ 4,5:1, UI ≥ 3:1;
|
|
||||||
`--color-border-input` für Feldränder; `--color-favorite` nur Icons);
|
|
||||||
Farbe nie als einziges Merkmal.
|
|
||||||
- **Reflow:** kein seitenweites Horizontal-Scrollen bei 320 px
|
|
||||||
(`min-width: 0` an Flex-/Grid-Kindern nicht vergessen).
|
|
||||||
- **Bewegung/Zeit:** `prefers-reduced-motion` respektieren, keine zu
|
|
||||||
kurzen Auto-Dismiss-Zeiten.
|
|
||||||
- **CI-Pack pflegen:** neue Kern-Screens in `apps/web/e2e/a11y.spec.ts`
|
|
||||||
aufnehmen; die Allowlist bleibt leer bzw. nur mit Begründung.
|
|
||||||
- Neue aria-Labels können bestehende `getByLabel`-e2e-Locator mehrdeutig
|
|
||||||
machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
|
|
||||||
|
|
||||||
Verstöße gelten in Review und Abnahme als Funktionsfehler.
|
|
||||||
22
README.md
22
README.md
@ -32,23 +32,10 @@ offline support.
|
|||||||
first-run setup wizard yields a working instance. Start here:
|
first-run setup wizard yields a working instance. Start here:
|
||||||
[`docs/self-hosting/README.md`](docs/self-hosting/README.md).
|
[`docs/self-hosting/README.md`](docs/self-hosting/README.md).
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- **What is Dorfteich?** — [`docs/features.md`](docs/features.md)
|
|
||||||
- **Manuals** (user / pond admin / site admin / API / MCP) —
|
|
||||||
[`docs/manual/`](docs/manual/README.md), auf Deutsch:
|
|
||||||
[`docs/de/`](docs/de/manual/README.md)
|
|
||||||
- **Extending it** (plugins, core) —
|
|
||||||
[`docs/developer/extending.md`](docs/developer/extending.md)
|
|
||||||
- **Running it** — [`docs/self-hosting/`](docs/self-hosting/README.md)
|
|
||||||
- **How it works inside** — [`docs/architecture/`](docs/architecture/README.md)
|
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `docs/manual/` | User-facing manuals: user, pond-admin, site-admin, API, and MCP guides (start at [`docs/manual/README.md`](docs/manual/README.md)) |
|
|
||||||
| `docs/developer/` | Extending Dorfteich: plugin development and core contributions |
|
|
||||||
| `docs/architecture/` | Architecture documentation: ADRs, data model, permission model, collaboration and plugin concepts, deployment and operations |
|
| `docs/architecture/` | Architecture documentation: ADRs, data model, permission model, collaboration and plugin concepts, deployment and operations |
|
||||||
| `docs/self-hosting/` | Install, update, backup, and troubleshooting guide for running your own instance |
|
| `docs/self-hosting/` | Install, update, backup, and troubleshooting guide for running your own instance |
|
||||||
| `apps/` | Application packages (web frontend, API server, collaboration server) — created as implementation proceeds |
|
| `apps/` | Application packages (web frontend, API server, collaboration server) — created as implementation proceeds |
|
||||||
@ -73,10 +60,9 @@ imported as `@dorfteich/shared` — never copy code between apps.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Feature-complete for a 1.0: collaboration, permissions, import/export,
|
The project is in the architecture and backlog phase. Implementation stories
|
||||||
plugins, public REST API + MCP, backups with off-host copies and in-app
|
are tracked as issues in this repository. Start reading at
|
||||||
restore — all shipped and release-gated. Work is tracked as issues in
|
[`docs/architecture/README.md`](docs/architecture/README.md).
|
||||||
this repository.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Build context is the repository root (workspace build):
|
# Build context is the repository root (workspace build):
|
||||||
# docker build -f apps/api/Dockerfile .
|
# docker build -f apps/api/Dockerfile .
|
||||||
|
|
||||||
FROM node:22.15.1-alpine AS build
|
FROM node:22.15-alpine AS build
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
RUN npm install -g pnpm@11
|
RUN npm install -g pnpm@11
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||||
@ -21,27 +21,25 @@ RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \
|
|||||||
# needed for migrate-on-start) at /out.
|
# needed for migrate-on-start) at /out.
|
||||||
&& pnpm --filter @dorfteich/api deploy --prod --legacy /out \
|
&& pnpm --filter @dorfteich/api deploy --prod --legacy /out \
|
||||||
&& cp -r apps/api/dist /out/dist \
|
&& cp -r apps/api/dist /out/dist \
|
||||||
&& cp -r apps/api/assets /out/assets \
|
|
||||||
&& cp -r /repo/fonts /out/fonts
|
&& cp -r /repo/fonts /out/fonts
|
||||||
|
|
||||||
FROM node:22.15.1-alpine
|
FROM node:22.15-alpine
|
||||||
ARG APP_VERSION=0.0.0-dev
|
ARG APP_VERSION=0.0.0-dev
|
||||||
# Default the data dirs to the writable, node-owned locations created below, so
|
# Default the data dirs to the writable, node-owned locations created below, so
|
||||||
# the image works out of the box even where compose does not set them; compose
|
# the image works out of the box even where compose does not set them; compose
|
||||||
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
|
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
|
||||||
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
|
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build --chown=node:node /out /app
|
COPY --from=build --chown=node:node /out /app
|
||||||
# Generate the Prisma client for this image's platform.
|
# Generate the Prisma client for this image's platform.
|
||||||
RUN node node_modules/prisma/build/index.js generate
|
RUN node node_modules/prisma/build/index.js generate
|
||||||
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
|
# A fresh named volume mounted at /data/uploads or /data/plugins is created
|
||||||
# or /data/branding is created
|
|
||||||
# root-owned; pre-creating them here (Docker copies an image directory's
|
# root-owned; pre-creating them here (Docker copies an image directory's
|
||||||
# ownership into a new volume on first mount) lets the non-root `node` user
|
# ownership into a new volume on first mount) lets the non-root `node` user
|
||||||
# write to them. /data/backups is mounted read-only here, but pre-creating it
|
# write to them. /data/backups is mounted read-only here, but pre-creating it
|
||||||
# node-owned keeps the shared `backups` volume writable for the backup
|
# node-owned keeps the shared `backups` volume writable for the backup
|
||||||
# sidecar even when the api container is the one that initializes it.
|
# sidecar even when the api container is the one that initializes it.
|
||||||
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups
|
RUN mkdir -p /data/uploads /data/plugins /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/secrets /data/backups
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
# Runtime assets
|
|
||||||
|
|
||||||
## `reference-vs-nfd.docx` / `reference-vs-nfd.odt` (issue #209, ADR 0022)
|
|
||||||
|
|
||||||
Pandoc reference documents for the DOCX/ODT export of a **classified**
|
|
||||||
page: their page setup defines a header and footer carrying the VS-NfD
|
|
||||||
marking, which pandoc copies into its output — so the marking repeats on
|
|
||||||
every page in Word and LibreOffice and is not deletable body text.
|
|
||||||
Unclassified exports pass no reference document and are unchanged.
|
|
||||||
|
|
||||||
These are **derived binaries — never edit them by hand.** Source of truth
|
|
||||||
is `../scripts/gen-classified-reference-docs.mjs`: it takes the default
|
|
||||||
reference documents of the pinned sidecar (`pandoc/core:3.6`, the exact
|
|
||||||
image the stages run) and injects the header/footer, with the wording from
|
|
||||||
`classificationMarking()` in `@dorfteich/shared` (single source, ADR
|
|
||||||
0022). Regenerate — after a pandoc pin bump, a wording change, or a layout
|
|
||||||
tweak in the script — with Docker running:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm --filter @dorfteich/shared build # the script imports the wording
|
|
||||||
node apps/api/scripts/gen-classified-reference-docs.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit script and binaries together. The fidelity suite
|
|
||||||
(`export.fidelity.test.ts`) asserts against the real pinned pandoc that a
|
|
||||||
marked export carries the header/footer parts and an unmarked one does
|
|
||||||
not.
|
|
||||||
|
|
||||||
### Per-page verification in the office suites
|
|
||||||
|
|
||||||
After regenerating, confirm the marking repeats on **every** page of a
|
|
||||||
multi-page export (not just structurally in the XML):
|
|
||||||
|
|
||||||
1. Produce a marked multi-page export (any classified page with a few
|
|
||||||
screens of text, exported to `.docx` and `.odt`).
|
|
||||||
2. **LibreOffice** (scriptable):
|
|
||||||
`soffice --headless --convert-to pdf <file>` and check every PDF page
|
|
||||||
shows the marking twice (header + footer) — e.g. with `pypdf`.
|
|
||||||
3. **Word**: open the `.docx`, check header and footer on every page
|
|
||||||
(print preview). Word's AppleScript/sandbox makes this hard to script —
|
|
||||||
this step is a quick manual look.
|
|
||||||
|
|
||||||
Last verified 2026-07-31 (pandoc 3.6 output): LibreOffice 25.8, both
|
|
||||||
formats, 5/5 pages with 2 markings each. Word: manual check pending —
|
|
||||||
sample files in the workspace under `doku/209-marked-sample.docx/.odt`.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 683 B |
Binary file not shown.
Binary file not shown.
@ -18,7 +18,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dorfteich/plugin-sdk": "workspace:*",
|
"@dorfteich/plugin-sdk": "workspace:*",
|
||||||
"@dorfteich/shared": "workspace:*",
|
"@dorfteich/shared": "workspace:*",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
@ -30,7 +29,6 @@
|
|||||||
"fflate": "^0.8.3",
|
"fflate": "^0.8.3",
|
||||||
"fractional-indexing": "^4.0.0",
|
"fractional-indexing": "^4.0.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
"jose": "^6.2.4",
|
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"nestjs-pino": "^4.3.0",
|
"nestjs-pino": "^4.3.0",
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "ApiTokenScope" AS ENUM ('READ', 'WRITE');
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "api_tokens" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"token_hash" TEXT NOT NULL,
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"scope" "ApiTokenScope" NOT NULL,
|
|
||||||
"pond_ids" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
|
||||||
"expires_at" TIMESTAMP(3),
|
|
||||||
"revoked_at" TIMESTAMP(3),
|
|
||||||
"last_used_at" TIMESTAMP(3),
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "pages" ADD COLUMN "parent_id" TEXT;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "pages_parent_id_idx" ON "pages"("parent_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "pages" ADD CONSTRAINT "pages_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "conversion_jobs" ADD COLUMN "options" JSONB;
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
-- Personal page favorites (issue #132).
|
|
||||||
|
|
||||||
CREATE TABLE "page_favorites" (
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"page_id" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "page_favorites_pkey" PRIMARY KEY ("user_id", "page_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "page_favorites_page_id_idx" ON "page_favorites"("page_id");
|
|
||||||
|
|
||||||
ALTER TABLE "page_favorites" ADD CONSTRAINT "page_favorites_user_id_fkey"
|
|
||||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "page_favorites" ADD CONSTRAINT "page_favorites_page_id_fkey"
|
|
||||||
FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "pages_pond_id_created_at_idx" ON "pages"("pond_id", "created_at");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "pages_pond_id_updated_at_idx" ON "pages"("pond_id", "updated_at");
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "feed_tokens" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"token_hash" TEXT NOT NULL,
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"last_used_at" TIMESTAMP(3),
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "feed_tokens_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "feed_tokens_token_hash_key" ON "feed_tokens"("token_hash");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "feed_tokens_user_id_idx" ON "feed_tokens"("user_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "feed_tokens" ADD CONSTRAINT "feed_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "page_mentions" (
|
|
||||||
"page_id" TEXT NOT NULL,
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "page_mentions_pkey" PRIMARY KEY ("page_id","user_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "page_mentions_user_id_idx" ON "page_mentions"("user_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
-- Issue #194: attachments have exactly one deletion semantics (hard delete
|
|
||||||
-- by sweep, purge, or manual removal) — the never-written soft-delete
|
|
||||||
-- marker goes away.
|
|
||||||
ALTER TABLE "attachments" DROP COLUMN "deleted_at";
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
-- Issue #195, one-off backfill: the full-text index must hold no trashed
|
|
||||||
-- content. Clears the search vector of every page that is trashed itself
|
|
||||||
-- or lives in a trashed pond; the application keeps this invariant from
|
|
||||||
-- now on (trash hooks + reindex paths).
|
|
||||||
UPDATE page_content_cache c
|
|
||||||
SET search_vector = NULL
|
|
||||||
FROM pages p
|
|
||||||
LEFT JOIN ponds po ON po.id = p.pond_id
|
|
||||||
WHERE p.id = c.page_id
|
|
||||||
AND (p.deleted_at IS NOT NULL OR po.deleted_at IS NOT NULL);
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
-- #233: conversion job payloads become prunable. The raw input/result bytes
|
|
||||||
-- are transient; a daily job nulls them once a finished job passes
|
|
||||||
-- `conversion.payloadRetentionDays` (default 30). The row survives for
|
|
||||||
-- status/audit purposes.
|
|
||||||
ALTER TABLE "conversion_jobs" ALTER COLUMN "input" DROP NOT NULL;
|
|
||||||
|
|
||||||
-- Backfill: clear the payloads of jobs that already finished longer ago than
|
|
||||||
-- the default period. Recently finished jobs keep their bytes so a pending
|
|
||||||
-- download still works; the scheduled job picks them up when they age out.
|
|
||||||
-- PENDING/RUNNING rows are untouched (the worker's stale-lock recovery may
|
|
||||||
-- still re-run them).
|
|
||||||
UPDATE "conversion_jobs"
|
|
||||||
SET "input" = NULL, "result" = NULL, "result_mime_type" = NULL
|
|
||||||
WHERE "status" IN ('SUCCEEDED', 'FAILED')
|
|
||||||
AND "updated_at" < now() - interval '30 days'
|
|
||||||
AND ("input" IS NOT NULL OR "result" IS NOT NULL);
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- #199: integrity hash for uploaded files. New uploads store the SHA-256 of
|
|
||||||
-- their bytes at write time; existing rows are hashed by the nightly
|
|
||||||
-- backfill (part of the orphan-file-sweep job), which reads the uploads
|
|
||||||
-- volume — something this SQL migration cannot do.
|
|
||||||
ALTER TABLE "attachments" ADD COLUMN "sha256" TEXT;
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
-- #204 (ADR 0022): classification becomes first-class page metadata. The
|
|
||||||
-- column is a marking, not a protection mechanism — permissions are
|
|
||||||
-- untouched. NOT NULL with a default backfills every existing page to
|
|
||||||
-- UNCLASSIFIED in the same statement.
|
|
||||||
CREATE TYPE "PageClassification" AS ENUM ('UNCLASSIFIED', 'VS_NFD');
|
|
||||||
|
|
||||||
ALTER TABLE "pages"
|
|
||||||
ADD COLUMN "classification" "PageClassification" NOT NULL DEFAULT 'UNCLASSIFIED';
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
-- #222 (ADR 0023): read-access trail for classified pages. Its own table —
|
|
||||||
-- volume, purpose and legal basis differ from audit_log. No foreign keys:
|
|
||||||
-- evidence must survive page purges and hard user deletions unchanged.
|
|
||||||
-- Partitioning and retention follow in #224.
|
|
||||||
CREATE TABLE "read_events" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"actor_id" TEXT,
|
|
||||||
"session_key" TEXT NOT NULL,
|
|
||||||
"page_id" TEXT,
|
|
||||||
"pond_id" TEXT NOT NULL,
|
|
||||||
"channel" TEXT NOT NULL,
|
|
||||||
"classification" TEXT NOT NULL,
|
|
||||||
"details" JSONB,
|
|
||||||
|
|
||||||
CONSTRAINT "read_events_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "read_events_page_id_occurred_at_idx" ON "read_events"("page_id", "occurred_at");
|
|
||||||
|
|
||||||
CREATE INDEX "read_events_actor_id_occurred_at_idx" ON "read_events"("actor_id", "occurred_at");
|
|
||||||
|
|
||||||
CREATE INDEX "read_events_occurred_at_idx" ON "read_events"("occurred_at");
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
-- #223 (ADR 0023): dedup window for the read trail. Aligned buckets
|
|
||||||
-- (floor(epoch / window)) with a unique (dedup_key, window_bucket) pair make
|
|
||||||
-- concurrent duplicates collapse race-free at insert time.
|
|
||||||
ALTER TABLE "read_events"
|
|
||||||
ADD COLUMN "dedup_key" TEXT,
|
|
||||||
ADD COLUMN "window_bucket" BIGINT,
|
|
||||||
ADD COLUMN "window_seconds" INTEGER;
|
|
||||||
|
|
||||||
-- Backfill rows written between the #222 and #223 deploys under the default
|
|
||||||
-- 5-minute window, then apply the window's own semantics retroactively:
|
|
||||||
-- within one (key, bucket) pair only the FIRST event is the evidence row —
|
|
||||||
-- exactly what the window would have recorded had it existed.
|
|
||||||
UPDATE "read_events"
|
|
||||||
SET "dedup_key" = "session_key" || ':' || COALESCE("page_id", '-') || ':' || "channel",
|
|
||||||
"window_bucket" = FLOOR(EXTRACT(EPOCH FROM "occurred_at") / 300)::BIGINT,
|
|
||||||
"window_seconds" = 300
|
|
||||||
WHERE "dedup_key" IS NULL;
|
|
||||||
|
|
||||||
DELETE FROM "read_events" keep
|
|
||||||
USING "read_events" first
|
|
||||||
WHERE keep."dedup_key" = first."dedup_key"
|
|
||||||
AND keep."window_bucket" = first."window_bucket"
|
|
||||||
AND (first."occurred_at" < keep."occurred_at"
|
|
||||||
OR (first."occurred_at" = keep."occurred_at" AND first."id" < keep."id"));
|
|
||||||
|
|
||||||
ALTER TABLE "read_events"
|
|
||||||
ALTER COLUMN "dedup_key" SET NOT NULL,
|
|
||||||
ALTER COLUMN "window_bucket" SET NOT NULL,
|
|
||||||
ALTER COLUMN "window_seconds" SET NOT NULL;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "read_events_dedup_key_window_bucket_key"
|
|
||||||
ON "read_events"("dedup_key", "window_bucket");
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
-- #224 (ADR 0023): convert read_events to monthly RANGE partitions on
|
|
||||||
-- occurred_at. Volume grows unbounded with use; retention then DROPs whole
|
|
||||||
-- expired partitions instead of scanning deletes. The primary key gains the
|
|
||||||
-- partition column (PostgreSQL requirement); the dedup unique pair
|
|
||||||
-- (dedup_key, window_bucket) moves to PER-PARTITION unique indexes — a
|
|
||||||
-- partitioned parent cannot carry it without the partition key. A bucket
|
|
||||||
-- spanning a month boundary can therefore record one duplicate; documented
|
|
||||||
-- in ADR 0023, over-recording is acceptable, gaps are not.
|
|
||||||
--
|
|
||||||
-- A DEFAULT partition catches rows outside every maintained range, so a
|
|
||||||
-- lagging maintenance job can never make classified reads fail (the trail's
|
|
||||||
-- hard-failure semantics would otherwise turn an ops miss into an outage).
|
|
||||||
|
|
||||||
ALTER TABLE "read_events" RENAME TO "read_events_old";
|
|
||||||
ALTER INDEX "read_events_pkey" RENAME TO "read_events_old_pkey";
|
|
||||||
ALTER INDEX "read_events_dedup_key_window_bucket_key" RENAME TO "read_events_old_dedup_key";
|
|
||||||
ALTER INDEX "read_events_page_id_occurred_at_idx" RENAME TO "read_events_old_page_idx";
|
|
||||||
ALTER INDEX "read_events_actor_id_occurred_at_idx" RENAME TO "read_events_old_actor_idx";
|
|
||||||
ALTER INDEX "read_events_occurred_at_idx" RENAME TO "read_events_old_at_idx";
|
|
||||||
|
|
||||||
CREATE TABLE "read_events" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"actor_id" TEXT,
|
|
||||||
"session_key" TEXT NOT NULL,
|
|
||||||
"page_id" TEXT,
|
|
||||||
"pond_id" TEXT NOT NULL,
|
|
||||||
"channel" TEXT NOT NULL,
|
|
||||||
"classification" TEXT NOT NULL,
|
|
||||||
"details" JSONB,
|
|
||||||
"dedup_key" TEXT NOT NULL,
|
|
||||||
"window_bucket" BIGINT NOT NULL,
|
|
||||||
"window_seconds" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "read_events_pkey" PRIMARY KEY ("id", "occurred_at")
|
|
||||||
) PARTITION BY RANGE ("occurred_at");
|
|
||||||
|
|
||||||
-- Non-unique parent indexes propagate to every partition automatically.
|
|
||||||
CREATE INDEX "read_events_page_id_occurred_at_idx" ON "read_events"("page_id", "occurred_at");
|
|
||||||
CREATE INDEX "read_events_actor_id_occurred_at_idx" ON "read_events"("actor_id", "occurred_at");
|
|
||||||
CREATE INDEX "read_events_occurred_at_idx" ON "read_events"("occurred_at");
|
|
||||||
|
|
||||||
-- The safety-net partition, plus the current and the next month — the daily
|
|
||||||
-- maintenance job (read-trail-maintenance) keeps creating months ahead and
|
|
||||||
-- adds the same per-partition dedup index to each new one.
|
|
||||||
CREATE TABLE "read_events_default" PARTITION OF "read_events" DEFAULT;
|
|
||||||
CREATE UNIQUE INDEX "read_events_default_dedup_key"
|
|
||||||
ON "read_events_default"("dedup_key", "window_bucket");
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
m DATE;
|
|
||||||
part TEXT;
|
|
||||||
BEGIN
|
|
||||||
FOR i IN 0..1 LOOP
|
|
||||||
m := date_trunc('month', now())::date + (i || ' month')::interval;
|
|
||||||
part := 'read_events_y' || to_char(m, 'YYYY') || 'm' || to_char(m, 'MM');
|
|
||||||
EXECUTE format(
|
|
||||||
'CREATE TABLE %I PARTITION OF "read_events" FOR VALUES FROM (%L) TO (%L)',
|
|
||||||
part, m, m + interval '1 month');
|
|
||||||
EXECUTE format(
|
|
||||||
'CREATE UNIQUE INDEX %I ON %I ("dedup_key", "window_bucket")',
|
|
||||||
part || '_dedup_key', part);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
INSERT INTO "read_events"
|
|
||||||
("id", "occurred_at", "actor_id", "session_key", "page_id", "pond_id",
|
|
||||||
"channel", "classification", "details", "dedup_key", "window_bucket",
|
|
||||||
"window_seconds")
|
|
||||||
SELECT "id", "occurred_at", "actor_id", "session_key", "page_id", "pond_id",
|
|
||||||
"channel", "classification", "details", "dedup_key", "window_bucket",
|
|
||||||
"window_seconds"
|
|
||||||
FROM "read_events_old";
|
|
||||||
|
|
||||||
DROP TABLE "read_events_old";
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
-- #217 (ADR 0021): IdP claim mapping. Grants gain an origin so mapped rows
|
|
||||||
-- are distinguishable from manual ones (the mapping only ever touches its
|
|
||||||
-- own); the site-admin flag gains a "managed" marker so only a
|
|
||||||
-- mapping-granted flag can be mapping-revoked.
|
|
||||||
ALTER TABLE "role_grants"
|
|
||||||
ADD COLUMN "origin" TEXT NOT NULL DEFAULT 'manual';
|
|
||||||
|
|
||||||
ALTER TABLE "users"
|
|
||||||
ADD COLUMN "is_site_admin_managed" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
-- #232: SHA-256 of the installed bundle ZIP, observed at install time.
|
|
||||||
-- NULL for plugins installed before this migration — the admin UI says so
|
|
||||||
-- and a reinstall records it.
|
|
||||||
ALTER TABLE "plugins" ADD COLUMN "bundle_hash" TEXT;
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
-- #303: operator-uploaded font families (ADR 0016 §#303).
|
|
||||||
-- The bytes live on disk under CUSTOM_FONTS_DIR; these rows record only what
|
|
||||||
-- the upload form stated, because the api never parses the font file.
|
|
||||||
|
|
||||||
CREATE TABLE "custom_fonts" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"family" TEXT NOT NULL,
|
|
||||||
"slug" TEXT NOT NULL,
|
|
||||||
"category" TEXT NOT NULL,
|
|
||||||
"licence" TEXT NOT NULL,
|
|
||||||
"licence_url" TEXT,
|
|
||||||
"uploaded_by" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "custom_fonts_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Both unique: `family` keeps `fonts.<slot>.family` in pond settings
|
|
||||||
-- unambiguous, `slug` owns a directory under CUSTOM_FONTS_DIR.
|
|
||||||
CREATE UNIQUE INDEX "custom_fonts_family_key" ON "custom_fonts"("family");
|
|
||||||
CREATE UNIQUE INDEX "custom_fonts_slug_key" ON "custom_fonts"("slug");
|
|
||||||
|
|
||||||
ALTER TABLE "custom_fonts" ADD CONSTRAINT "custom_fonts_uploaded_by_fkey"
|
|
||||||
FOREIGN KEY ("uploaded_by") REFERENCES "users"("id")
|
|
||||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
CREATE TABLE "custom_font_weights" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"font_id" TEXT NOT NULL,
|
|
||||||
"weight" INTEGER NOT NULL,
|
|
||||||
"has_woff" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"byte_size" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "custom_font_weights_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "custom_font_weights_font_id_weight_key"
|
|
||||||
ON "custom_font_weights"("font_id", "weight");
|
|
||||||
|
|
||||||
-- Deleting a family takes its weights with it; the files on disk are removed
|
|
||||||
-- by the service in the same operation.
|
|
||||||
ALTER TABLE "custom_font_weights" ADD CONSTRAINT "custom_font_weights_font_id_fkey"
|
|
||||||
FOREIGN KEY ("font_id") REFERENCES "custom_fonts"("id")
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
-- Peer invitations (issue #332): a user invites an e-mail address; the token
|
|
||||||
-- allows exactly one registration even while registration is closed.
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "invitations" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"inviter_id" TEXT NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"token_hash" TEXT NOT NULL,
|
|
||||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
"revoked_at" TIMESTAMP(3),
|
|
||||||
"accepted_at" TIMESTAMP(3),
|
|
||||||
"accepted_user_id" TEXT,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "invitations_token_hash_key" ON "invitations"("token_hash");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "invitations_inviter_id_idx" ON "invitations"("inviter_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@ -37,11 +37,6 @@ model User {
|
|||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
locale String @default("en")
|
locale String @default("en")
|
||||||
isSiteAdmin Boolean @default(false) @map("is_site_admin")
|
isSiteAdmin Boolean @default(false) @map("is_site_admin")
|
||||||
/// True when the flag was last SET by the IdP claim mapping (issue #217):
|
|
||||||
/// only then may the mapping revoke it again on a later login. A manual
|
|
||||||
/// admin toggle clears the marker, so hand-granted admins are never
|
|
||||||
/// demoted by a missing claim.
|
|
||||||
isSiteAdminManaged Boolean @default(false) @map("is_site_admin_managed")
|
|
||||||
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
|
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
|
||||||
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
|
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
|
||||||
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
|
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
|
||||||
@ -55,9 +50,6 @@ model User {
|
|||||||
identities UserIdentity[]
|
identities UserIdentity[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
authTokens AuthToken[]
|
authTokens AuthToken[]
|
||||||
apiTokens ApiToken[]
|
|
||||||
feedTokens FeedToken[]
|
|
||||||
mentionRows PageMention[]
|
|
||||||
ponds Pond[]
|
ponds Pond[]
|
||||||
pages Page[]
|
pages Page[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
@ -66,36 +58,10 @@ model User {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
watches Watch[]
|
watches Watch[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
favorites PageFavorite[]
|
|
||||||
customFonts CustomFont[]
|
|
||||||
invitations Invitation[] @relation("InvitationsSent")
|
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Peer invitations (issue #332): a user invites an e-mail address; the
|
|
||||||
/// token allows exactly one registration even while registration is
|
|
||||||
/// closed. Only the SHA-256 hash of the token is stored (auth-tokens
|
|
||||||
/// pattern); revoked/accepted rows are kept so the settings UI can show
|
|
||||||
/// history. "Open" (pending, unexpired) rows count against the per-user
|
|
||||||
/// quota `invitations.maxOpenPerUser`.
|
|
||||||
model Invitation {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
inviterId String @map("inviter_id")
|
|
||||||
email String
|
|
||||||
tokenHash String @unique @map("token_hash")
|
|
||||||
expiresAt DateTime @map("expires_at")
|
|
||||||
revokedAt DateTime? @map("revoked_at")
|
|
||||||
acceptedAt DateTime? @map("accepted_at")
|
|
||||||
acceptedUserId String? @map("accepted_user_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([inviterId])
|
|
||||||
@@map("invitations")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persistent audit trail (issue #86, security.md §Logging): auth events and
|
/// Persistent audit trail (issue #86, security.md §Logging): auth events and
|
||||||
/// admin actions — grants, member roles, plugin installs, quota and settings
|
/// admin actions — grants, member roles, plugin installs, quota and settings
|
||||||
/// changes, setup steps, manual job triggers. Written by AuditService, which
|
/// changes, setup steps, manual job triggers. Written by AuditService, which
|
||||||
@ -121,52 +87,6 @@ model AuditEntry {
|
|||||||
@@map("audit_log")
|
@@map("audit_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-access trail for classified pages (issue #222, ADR 0023): one row per
|
|
||||||
/// read of a `VS_NFD` page, per channel. Separate from `audit_log` because
|
|
||||||
/// volume, purpose and legal basis all differ. Deliberately WITHOUT foreign
|
|
||||||
/// keys: evidence must survive a page purge and a hard user deletion — the
|
|
||||||
/// ids stay as recorded (pseudonymous uuids), history is never rewritten.
|
|
||||||
///
|
|
||||||
/// In migrated databases the table is RANGE-partitioned by `occurred_at`
|
|
||||||
/// (monthly, issue #224) — hence the composite id. The dedup unique pair
|
|
||||||
/// lives per partition there (a partitioned parent cannot carry it without
|
|
||||||
/// the partition key); `db push` test databases get it on the plain table.
|
|
||||||
model ReadEvent {
|
|
||||||
id String @default(uuid())
|
|
||||||
occurredAt DateTime @default(now()) @map("occurred_at")
|
|
||||||
/// Null = anonymous reader (public grant); `sessionKey` still names the
|
|
||||||
/// browsing session, so the anonymous marker is explicit, not an accident.
|
|
||||||
actorId String? @map("actor_id")
|
|
||||||
/// `session:<id>` for cookie sessions, `token:<id>` for PATs, `job:<id>`
|
|
||||||
/// for background builds (account data export), `anon` for anonymous
|
|
||||||
/// visitors — the dedup-window key basis (#223).
|
|
||||||
sessionKey String @map("session_key")
|
|
||||||
pageId String? @map("page_id")
|
|
||||||
pondId String @map("pond_id")
|
|
||||||
/// Which read surface fired: `page_view` | `no_js_shell` | `public_api` |
|
|
||||||
/// `attachment` | `export` | `collab_join` (READ_CHANNELS union in code).
|
|
||||||
channel String
|
|
||||||
/// Classification at read time — a later reclassification must not
|
|
||||||
/// rewrite history (ADR 0023).
|
|
||||||
classification String
|
|
||||||
details Json?
|
|
||||||
/// Dedup window (issue #223): `<sessionKey>:<pageId|->:<channel>` plus the
|
|
||||||
/// aligned bucket `floor(epoch / windowSeconds)`. The unique pair makes
|
|
||||||
/// concurrent duplicate reads collapse race-free (insert or P2002-skip).
|
|
||||||
dedupKey String @map("dedup_key")
|
|
||||||
windowBucket BigInt @map("window_bucket")
|
|
||||||
/// Window length the event was recorded under — the row itself states it
|
|
||||||
/// represents up to this many seconds, so the evidence is not overread.
|
|
||||||
windowSeconds Int @map("window_seconds")
|
|
||||||
|
|
||||||
@@id([id, occurredAt])
|
|
||||||
@@unique([dedupKey, windowBucket])
|
|
||||||
@@index([pageId, occurredAt])
|
|
||||||
@@index([actorId, occurredAt])
|
|
||||||
@@index([occurredAt])
|
|
||||||
@@map("read_events")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Threaded page comments (issue #91, data-model.md §Comments). Threads are
|
/// Threaded page comments (issue #91, data-model.md §Comments). Threads are
|
||||||
/// one level deep: roots carry the optional document anchor and the resolve
|
/// one level deep: roots carry the optional document anchor and the resolve
|
||||||
/// state, replies reference the root via `parentId`. Purging a page cascades
|
/// state, replies reference the root via `parentId`. Purging a page cascades
|
||||||
@ -315,10 +235,6 @@ model RoleGrant {
|
|||||||
scopeType GrantScopeType @map("scope_type")
|
scopeType GrantScopeType @map("scope_type")
|
||||||
scopeId String? @map("scope_id")
|
scopeId String? @map("scope_id")
|
||||||
effect GrantEffect
|
effect GrantEffect
|
||||||
/// `manual` (admin-created) or `idp` (written by the claim mapping,
|
|
||||||
/// issue #217). The mapping only ever creates and revokes ITS OWN rows —
|
|
||||||
/// manual grants are never touched, which is the documented precedence.
|
|
||||||
origin String @default("manual")
|
|
||||||
createdBy String @map("created_by")
|
createdBy String @map("created_by")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@ -334,31 +250,13 @@ model RoleGrant {
|
|||||||
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
|
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
|
||||||
/// on every save (issue #23). `sortKey` uses fractional indexing so pages
|
/// on every save (issue #23). `sortKey` uses fractional indexing so pages
|
||||||
/// can be reordered without rewriting siblings (sidebar reorder is #26).
|
/// can be reordered without rewriting siblings (sidebar reorder is #26).
|
||||||
/// `parentId` nests pages into a tree (issue #106), mirroring the label
|
|
||||||
/// hierarchy (max 6 levels, enforced in the service; cycles rejected at write
|
|
||||||
/// time). Purely organizational: slugs stay flat and pond-unique, so moving a
|
|
||||||
/// page never changes its URL or breaks wikilinks. Trashed pages keep their
|
|
||||||
/// `parentId` (restore re-attaches to the nearest live ancestor, issue #107);
|
|
||||||
/// `SetNull` is only the FK backstop — purge promotes children explicitly.
|
|
||||||
/// VS-NfD marking level of a page (ADR 0022). Deliberately an enum on Page,
|
|
||||||
/// not a label: instance-wide meaning, not user-deletable in routine content
|
|
||||||
/// work, inherits down the tree (#205), reaches every output channel
|
|
||||||
/// (#206–#212). It is a MARKING, not a protection mechanism — separation of
|
|
||||||
/// levels happens outside the application (one instance per level).
|
|
||||||
enum PageClassification {
|
|
||||||
UNCLASSIFIED
|
|
||||||
VS_NFD
|
|
||||||
}
|
|
||||||
|
|
||||||
model Page {
|
model Page {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
pondId String @map("pond_id")
|
pondId String @map("pond_id")
|
||||||
parentId String? @map("parent_id")
|
|
||||||
title String
|
title String
|
||||||
slug String
|
slug String
|
||||||
ydocState Bytes @map("ydoc_state")
|
ydocState Bytes @map("ydoc_state")
|
||||||
sortKey String @map("sort_key")
|
sortKey String @map("sort_key")
|
||||||
classification PageClassification @default(UNCLASSIFIED)
|
|
||||||
createdBy String @map("created_by")
|
createdBy String @map("created_by")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@ -366,29 +264,20 @@ model Page {
|
|||||||
deletedBy String? @map("deleted_by")
|
deletedBy String? @map("deleted_by")
|
||||||
|
|
||||||
pond Pond @relation(fields: [pondId], references: [id])
|
pond Pond @relation(fields: [pondId], references: [id])
|
||||||
parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
|
|
||||||
children Page[] @relation("PageHierarchy")
|
|
||||||
creator User @relation(fields: [createdBy], references: [id])
|
creator User @relation(fields: [createdBy], references: [id])
|
||||||
updates PageUpdate[]
|
updates PageUpdate[]
|
||||||
contentCache PageContentCache?
|
contentCache PageContentCache?
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
versions PageVersion[]
|
versions PageVersion[]
|
||||||
pendingContributors PagePendingContributor[]
|
pendingContributors PagePendingContributor[]
|
||||||
mentionRows PageMention[]
|
|
||||||
labels PageLabel[]
|
labels PageLabel[]
|
||||||
outgoingLinks PageLink[] @relation("outgoingLinks")
|
outgoingLinks PageLink[] @relation("outgoingLinks")
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
incomingLinks PageLink[] @relation("incomingLinks")
|
incomingLinks PageLink[] @relation("incomingLinks")
|
||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
favorites PageFavorite[]
|
|
||||||
|
|
||||||
@@unique([pondId, slug])
|
@@unique([pondId, slug])
|
||||||
@@index([pondId])
|
@@index([pondId])
|
||||||
@@index([parentId])
|
|
||||||
// Time-filtered listings (issue #148): "pages of this pond created/updated
|
|
||||||
// since X" hit these instead of scanning the pond.
|
|
||||||
@@index([pondId, createdAt])
|
|
||||||
@@index([pondId, updatedAt])
|
|
||||||
@@map("pages")
|
@@map("pages")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -442,21 +331,6 @@ model PageVersion {
|
|||||||
/// Collab flushes the current session's contributors here (deduplicated by the
|
/// Collab flushes the current session's contributors here (deduplicated by the
|
||||||
/// composite key); version creation on either side reads and clears it in the
|
/// composite key); version creation on either side reads and clears it in the
|
||||||
/// same transaction as writing the snapshot. Cascades on page purge (ADR 0013).
|
/// same transaction as writing the snapshot. Cascades on page purge (ADR 0013).
|
||||||
/// Derived mention index (issue #151): one row per user currently
|
|
||||||
/// mentioned in the page's document. Rewritten on every collab persist;
|
|
||||||
/// the diff against the previous rows drives the `mentioned` notifications.
|
|
||||||
model PageMention {
|
|
||||||
pageId String @map("page_id")
|
|
||||||
userId String @map("user_id")
|
|
||||||
|
|
||||||
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([pageId, userId])
|
|
||||||
@@index([userId])
|
|
||||||
@@map("page_mentions")
|
|
||||||
}
|
|
||||||
|
|
||||||
model PagePendingContributor {
|
model PagePendingContributor {
|
||||||
pageId String @map("page_id")
|
pageId String @map("page_id")
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
@ -565,22 +439,6 @@ model PageLabel {
|
|||||||
@@map("page_labels")
|
@@map("page_labels")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Personal page favorites (issue #132) — per user, deliberately NOT
|
|
||||||
/// pond-wide (planning pivot documented on the issue). Trashed pages keep
|
|
||||||
/// their rows, so a restore keeps the star; a purge cascades them away.
|
|
||||||
model PageFavorite {
|
|
||||||
userId String @map("user_id")
|
|
||||||
pageId String @map("page_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([userId, pageId])
|
|
||||||
@@index([pageId])
|
|
||||||
@@map("page_favorites")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum QuotaSubjectType {
|
enum QuotaSubjectType {
|
||||||
USER
|
USER
|
||||||
POND
|
POND
|
||||||
@ -620,18 +478,15 @@ model PondUsage {
|
|||||||
/// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the
|
/// `<uploadsDir>/<pondId>/<id>` (FileStorageService); this row carries the
|
||||||
/// metadata needed to serve and account for it. `pageId` starts unset —
|
/// metadata needed to serve and account for it. `pageId` starts unset —
|
||||||
/// images are uploaded before the page referencing them is known
|
/// images are uploaded before the page referencing them is known
|
||||||
/// (paste-then-insert, issue #28) — and is claimed on every collab persist
|
/// (paste-then-insert, issue #28) — and is set on every page state save to
|
||||||
/// by whichever page's document embeds the file (issue #31), or at upload
|
/// whichever page's document currently embeds the file (issue #31,
|
||||||
/// for the page attachments panel (#61); the trash-purge job uses that
|
/// `PagesService.saveState`); the trash-purge job uses that link to delete
|
||||||
/// link to delete a purged page's files. A row whose `pageId` is STILL
|
/// a purged page's files. Not touched when an image is later removed from
|
||||||
/// null after a grace period was claimed by nothing and is reclaimed by
|
/// its page's content — an orphan-file sweep to reclaim those is a
|
||||||
/// the nightly orphan-file sweep (issue #194, OrphanSweepService).
|
/// separate future maintenance job (operations.md), not this one.
|
||||||
/// Claimed files are deliberately NOT auto-reclaimed when the content
|
/// `deletedAt` stays unused for now — purge hard-deletes attachments
|
||||||
/// stops referencing them: the page attachments panel lists them as
|
/// directly rather than soft-deleting them first — reserved for that same
|
||||||
/// user-managed objects (insert is optional there), so "not embedded" is
|
/// future orphan-sweep job.
|
||||||
/// not "unused" — the pond file manager is the human cleanup path.
|
|
||||||
/// Deletion is hard everywhere (sweep, purge, manual) — there is no
|
|
||||||
/// soft-delete state on attachments (issue #194 removed `deletedAt`).
|
|
||||||
model Attachment {
|
model Attachment {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
pondId String @map("pond_id")
|
pondId String @map("pond_id")
|
||||||
@ -641,12 +496,8 @@ model Attachment {
|
|||||||
sizeBytes Int @map("size_bytes")
|
sizeBytes Int @map("size_bytes")
|
||||||
storagePath String @map("storage_path")
|
storagePath String @map("storage_path")
|
||||||
uploadedBy String @map("uploaded_by")
|
uploadedBy String @map("uploaded_by")
|
||||||
/// SHA-256 (hex) of the stored bytes (issue #199), computed from the
|
|
||||||
/// in-memory upload buffer as it is written — never by re-reading disk.
|
|
||||||
/// Downloads verify against it and fail closed on mismatch. Null only
|
|
||||||
/// for rows that predate #199 until the nightly backfill hashes them.
|
|
||||||
sha256 String?
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
pond Pond @relation(fields: [pondId], references: [id])
|
pond Pond @relation(fields: [pondId], references: [id])
|
||||||
page Page? @relation(fields: [pageId], references: [id])
|
page Page? @relation(fields: [pageId], references: [id])
|
||||||
@ -714,53 +565,6 @@ model AuthToken {
|
|||||||
@@map("auth_tokens")
|
@@map("auth_tokens")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ApiTokenScope {
|
|
||||||
READ
|
|
||||||
WRITE
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Personal access tokens for the public API (issue #104). Only the SHA-256
|
|
||||||
/// hash of the secret is stored (auth-tokens pattern); a token acts AS its
|
|
||||||
/// user — the whole permission model applies — narrowed by `scope` and the
|
|
||||||
/// optional pond restriction. Revoking keeps the row so the settings UI can
|
|
||||||
/// show history; validation skips revoked/expired rows.
|
|
||||||
/// Read-only feed authentication (issue #149): a `dt_feed_…` secret carried as
|
|
||||||
/// a query parameter in Atom feed URLs, so feed readers can subscribe to
|
|
||||||
/// non-public ponds/pages. Deliberately much narrower than an ApiToken —
|
|
||||||
/// it can only ever authenticate the two feed endpoints, never the API.
|
|
||||||
model FeedToken {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
tokenHash String @unique @map("token_hash")
|
|
||||||
userId String @map("user_id")
|
|
||||||
name String
|
|
||||||
lastUsedAt DateTime? @map("last_used_at")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@map("feed_tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
model ApiToken {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
tokenHash String @unique @map("token_hash")
|
|
||||||
userId String @map("user_id")
|
|
||||||
name String
|
|
||||||
scope ApiTokenScope
|
|
||||||
/// Empty = every pond the user may access; else only these pond ids.
|
|
||||||
pondIds String[] @default([]) @map("pond_ids")
|
|
||||||
expiresAt DateTime? @map("expires_at")
|
|
||||||
revokedAt DateTime? @map("revoked_at")
|
|
||||||
lastUsedAt DateTime? @map("last_used_at")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@map("api_tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixed-window rate-limit counters (ADR 0002: no Redis). `key` encodes
|
/// Fixed-window rate-limit counters (ADR 0002: no Redis). `key` encodes
|
||||||
/// scope and subject, e.g. "login:ip:203.0.113.7".
|
/// scope and subject, e.g. "login:ip:203.0.113.7".
|
||||||
model RateLimit {
|
model RateLimit {
|
||||||
@ -838,11 +642,9 @@ enum ConversionJobStatus {
|
|||||||
/// enqueued PENDING, a worker claims it (`FOR UPDATE SKIP LOCKED`, `lockedAt`
|
/// enqueued PENDING, a worker claims it (`FOR UPDATE SKIP LOCKED`, `lockedAt`
|
||||||
/// recovers a crashed run), calls the pandoc sidecar with a timeout, and
|
/// recovers a crashed run), calls the pandoc sidecar with a timeout, and
|
||||||
/// stores the output bytes or an `errorCode`. `input`/`result` are the raw
|
/// stores the output bytes or an `errorCode`. `input`/`result` are the raw
|
||||||
/// document bytes — kept small by the request size limit and transient, not
|
/// document bytes — kept small by the request size limit and pruned by a
|
||||||
/// the durable copy an Attachment is: the daily `conversion-payload-prune`
|
/// later maintenance job (they are transient, not the durable copy an
|
||||||
/// job (#233) nulls both once a finished job passes
|
/// Attachment is). The polling endpoint `GET /jobs/:id` is owner-scoped.
|
||||||
/// `conversion.payloadRetentionDays`; the row survives for status/audit.
|
|
||||||
/// The polling endpoint `GET /jobs/:id` is owner-scoped.
|
|
||||||
model ConversionJob {
|
model ConversionJob {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
ownerId String @map("owner_id")
|
ownerId String @map("owner_id")
|
||||||
@ -852,9 +654,7 @@ model ConversionJob {
|
|||||||
sourceFormat String @map("source_format")
|
sourceFormat String @map("source_format")
|
||||||
targetFormat String @map("target_format")
|
targetFormat String @map("target_format")
|
||||||
standalone Boolean @default(true)
|
standalone Boolean @default(true)
|
||||||
/// Null once the retention job (#233) pruned a finished job's payload —
|
input Bytes
|
||||||
/// never while the job is PENDING/RUNNING (incl. stale-lock recovery).
|
|
||||||
input Bytes?
|
|
||||||
status ConversionJobStatus @default(PENDING)
|
status ConversionJobStatus @default(PENDING)
|
||||||
attempts Int @default(0)
|
attempts Int @default(0)
|
||||||
result Bytes?
|
result Bytes?
|
||||||
@ -863,12 +663,8 @@ model ConversionJob {
|
|||||||
lockedAt DateTime? @map("locked_at")
|
lockedAt DateTime? @map("locked_at")
|
||||||
/// For a data-export job (#68): when its stored result stops being
|
/// For a data-export job (#68): when its stored result stops being
|
||||||
/// downloadable and is purged (GDPR data minimization). Null for every
|
/// downloadable and is purged (GDPR data minimization). Null for every
|
||||||
/// other job kind, whose payload the general retention (#233) prunes.
|
/// other job kind, whose result never expires.
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
/// Kind-specific job options (issue #117): a vault import carries
|
|
||||||
/// `{parentPageId, labelIds, frontmatterMode}`; a PDF/DOCX/ODT export of a
|
|
||||||
/// classified page carries `{marking}` (issues #208/#209). Null otherwise.
|
|
||||||
options Json?
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@ -910,8 +706,6 @@ model Plugin {
|
|||||||
mode PluginInstanceMode @default(DISABLED)
|
mode PluginInstanceMode @default(DISABLED)
|
||||||
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
|
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
|
||||||
manifest Json
|
manifest Json
|
||||||
/// SHA-256 (hex) of the installed bundle ZIP (#232); null = pre-#232 install.
|
|
||||||
bundleHash String? @map("bundle_hash")
|
|
||||||
installedAt DateTime @default(now()) @map("installed_at")
|
installedAt DateTime @default(now()) @map("installed_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
/// Set when uninstalled; active queries filter `removedAt: null`.
|
/// Set when uninstalled; active queries filter `removedAt: null`.
|
||||||
@ -938,48 +732,3 @@ model PondPlugin {
|
|||||||
@@id([pondId, pluginId])
|
@@id([pondId, pluginId])
|
||||||
@@map("pond_plugins")
|
@@map("pond_plugins")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An operator-uploaded font family (issue #303, ADR 0016 §#303). The bytes
|
|
||||||
/// live on disk under CUSTOM_FONTS_DIR — this row only records what the
|
|
||||||
/// upload form stated, because the api never parses the font file itself.
|
|
||||||
/// Additive to the compile-time catalog: a family whose name or slug
|
|
||||||
/// collides with a catalog entry is rejected, so `fonts.<slot>.family` in a
|
|
||||||
/// pond's settings stays unambiguous.
|
|
||||||
model CustomFont {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
/// CSS `font-family` name, as typed by the uploader.
|
|
||||||
family String @unique
|
|
||||||
/// URL/file-safe form; names the directory under CUSTOM_FONTS_DIR.
|
|
||||||
slug String @unique
|
|
||||||
/// Drives the system fallback stack, like FontCatalogEntry.category.
|
|
||||||
category String
|
|
||||||
/// Free-text licence label, e.g. "Commercial — Foundry XY". Required so
|
|
||||||
/// an attribution obligation can be met on the font catalogue page.
|
|
||||||
licence String
|
|
||||||
licenceUrl String? @map("licence_url")
|
|
||||||
uploadedBy String @map("uploaded_by")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
uploader User @relation(fields: [uploadedBy], references: [id])
|
|
||||||
weights CustomFontWeight[]
|
|
||||||
|
|
||||||
@@map("custom_fonts")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One weight of a custom family. Style is always `normal`: the PDF
|
|
||||||
/// `@font-face` builder emits only that, and browsers synthesise oblique —
|
|
||||||
/// italic uploads are a follow-up, not a silent half-feature.
|
|
||||||
model CustomFontWeight {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
fontId String @map("font_id")
|
|
||||||
weight Int
|
|
||||||
/// Whether a legacy WOFF was supplied next to the required WOFF2.
|
|
||||||
hasWoff Boolean @default(false) @map("has_woff")
|
|
||||||
byteSize Int @map("byte_size")
|
|
||||||
|
|
||||||
font CustomFont @relation(fields: [fontId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([fontId, weight])
|
|
||||||
@@map("custom_font_weights")
|
|
||||||
}
|
|
||||||
|
|||||||
@ -311,36 +311,6 @@ async function seedContentFixtures(ownerId: string): Promise<void> {
|
|||||||
deriveContentOf(everyElementDoc),
|
deriveContentOf(everyElementDoc),
|
||||||
);
|
);
|
||||||
|
|
||||||
// "Classified Note" (issue #206, ADR 0022): a VS-NfD-marked page so e2e
|
|
||||||
// (a11y pack) can assert the marking banner in both themes. Kept simple —
|
|
||||||
// the marking, not the content, is what the fixture exists for.
|
|
||||||
const classifiedDoc = editorSchema.node('doc', null, [
|
|
||||||
editorSchema.node('heading', { level: 1 }, [editorSchema.text('Classified Note')]),
|
|
||||||
editorSchema.node('paragraph', null, [
|
|
||||||
editorSchema.text('This fixture page carries the VS-NfD marking.'),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
const classifiedYdoc = new Y.Doc();
|
|
||||||
prosemirrorJSONToYXmlFragment(
|
|
||||||
editorSchema,
|
|
||||||
classifiedDoc.toJSON(),
|
|
||||||
classifiedYdoc.getXmlFragment('default'),
|
|
||||||
);
|
|
||||||
const classifiedState = new Uint8Array(Y.encodeStateAsUpdate(classifiedYdoc));
|
|
||||||
classifiedYdoc.destroy();
|
|
||||||
const classifiedPageId = await upsertFixturePage(
|
|
||||||
pond.id,
|
|
||||||
'classified-note',
|
|
||||||
'Classified Note',
|
|
||||||
ownerId,
|
|
||||||
classifiedState,
|
|
||||||
deriveContentOf(classifiedDoc),
|
|
||||||
);
|
|
||||||
await prisma.page.update({
|
|
||||||
where: { id: classifiedPageId },
|
|
||||||
data: { classification: 'VS_NFD' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// "Fixture Image": one real, servable uploaded image (the Markdown
|
// "Fixture Image": one real, servable uploaded image (the Markdown
|
||||||
// fixture above only carries a placeholder fileId for round-trip
|
// fixture above only carries a placeholder fileId for round-trip
|
||||||
// testing — this is the one that actually resolves via /media/:fileId).
|
// testing — this is the one that actually resolves via /media/:fileId).
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regenerate the classified reference documents (issue #209, ADR 0022):
|
|
||||||
* `apps/api/assets/reference-vs-nfd.docx` / `.odt`.
|
|
||||||
*
|
|
||||||
* The DOCX/ODT export of a classified page passes these to pandoc via
|
|
||||||
* `--reference-doc`; pandoc copies the reference's page setup — including
|
|
||||||
* headers and footers — into its output, which is how the VS-NfD marking
|
|
||||||
* repeats on every page in Word and LibreOffice without being deletable
|
|
||||||
* body text.
|
|
||||||
*
|
|
||||||
* The binaries are DERIVED files: base = the default reference documents of
|
|
||||||
* the PINNED pandoc (`pandoc/core:3.6`, the exact sidecar the stages run),
|
|
||||||
* plus a header and footer carrying the marking. Never edit the binaries by
|
|
||||||
* hand — edit this script and re-run it (Docker required):
|
|
||||||
*
|
|
||||||
* node apps/api/scripts/gen-classified-reference-docs.mjs
|
|
||||||
*
|
|
||||||
* The marking wording comes from @dorfteich/shared (single source, ADR
|
|
||||||
* 0022); the shared package must be built (`pnpm --filter @dorfteich/shared
|
|
||||||
* build`).
|
|
||||||
*/
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import { classificationMarking } from '@dorfteich/shared';
|
|
||||||
import { strToU8, strFromU8, unzipSync, zipSync } from 'fflate';
|
|
||||||
|
|
||||||
const PANDOC_IMAGE = 'pandoc/core:3.6';
|
|
||||||
const MARKING = classificationMarking('vs_nfd');
|
|
||||||
const outDir = join(dirname(fileURLToPath(import.meta.url)), '../assets');
|
|
||||||
|
|
||||||
function defaultReference(name) {
|
|
||||||
return execFileSync('docker', ['run', '--rm', PANDOC_IMAGE, '--print-default-data-file', name], {
|
|
||||||
maxBuffer: 64 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeXml(value) {
|
|
||||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DOCX: add word/header1.xml + word/footer1.xml, register them in the
|
|
||||||
* content types and document relationships, and reference them from the
|
|
||||||
* document's sectPr — Word repeats them on every page. */
|
|
||||||
function patchDocx(bytes) {
|
|
||||||
const zip = unzipSync(new Uint8Array(bytes));
|
|
||||||
const marking = escapeXml(MARKING);
|
|
||||||
|
|
||||||
const partXml = (root) =>
|
|
||||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
|
||||||
`<w:${root} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
|
||||||
`<w:p><w:pPr><w:jc w:val="center"/></w:pPr>` +
|
|
||||||
`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${marking}</w:t></w:r>` +
|
|
||||||
`</w:p></w:${root}>`;
|
|
||||||
zip['word/header1.xml'] = strToU8(partXml('hdr'));
|
|
||||||
zip['word/footer1.xml'] = strToU8(partXml('ftr'));
|
|
||||||
|
|
||||||
const types = strFromU8(zip['[Content_Types].xml']);
|
|
||||||
zip['[Content_Types].xml'] = strToU8(
|
|
||||||
types.replace(
|
|
||||||
'</Types>',
|
|
||||||
'<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml" />' +
|
|
||||||
'<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" />' +
|
|
||||||
'</Types>',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const rels = strFromU8(zip['word/_rels/document.xml.rels']);
|
|
||||||
zip['word/_rels/document.xml.rels'] = strToU8(
|
|
||||||
rels.replace(
|
|
||||||
'</Relationships>',
|
|
||||||
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Id="rIdVsNfdHeader" Target="header1.xml" />' +
|
|
||||||
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Id="rIdVsNfdFooter" Target="footer1.xml" />' +
|
|
||||||
'</Relationships>',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const doc = strFromU8(zip['word/document.xml']);
|
|
||||||
if (!doc.includes('<w:sectPr>')) throw new Error('reference.docx has no sectPr');
|
|
||||||
zip['word/document.xml'] = strToU8(
|
|
||||||
doc.replace(
|
|
||||||
'<w:sectPr>',
|
|
||||||
'<w:sectPr>' +
|
|
||||||
'<w:headerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:type="default" r:id="rIdVsNfdHeader" />' +
|
|
||||||
'<w:footerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:type="default" r:id="rIdVsNfdFooter" />',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return zipSync(zip);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ODT: give the Standard master page a header with the marking and put the
|
|
||||||
* marking next to the existing page number in its footer — LibreOffice
|
|
||||||
* repeats master-page headers/footers on every page. */
|
|
||||||
function patchOdt(bytes) {
|
|
||||||
const zip = unzipSync(new Uint8Array(bytes));
|
|
||||||
const marking = escapeXml(MARKING);
|
|
||||||
const styles = strFromU8(zip['styles.xml']);
|
|
||||||
if (!styles.includes('<style:footer>')) throw new Error('reference.odt has no footer');
|
|
||||||
const patched = styles
|
|
||||||
.replace(
|
|
||||||
'<style:footer>',
|
|
||||||
`<style:header><text:p text:style-name="MP1">${marking}</text:p></style:header><style:footer>`,
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
'<style:footer>\n <text:p text:style-name="MP1">',
|
|
||||||
`<style:footer>\n <text:p text:style-name="MP1">${marking} · `,
|
|
||||||
);
|
|
||||||
zip['styles.xml'] = strToU8(patched);
|
|
||||||
return zipSync(zip);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
writeFileSync(join(outDir, 'reference-vs-nfd.docx'), patchDocx(defaultReference('reference.docx')));
|
|
||||||
writeFileSync(join(outDir, 'reference-vs-nfd.odt'), patchOdt(defaultReference('reference.odt')));
|
|
||||||
console.log(`generated reference-vs-nfd.docx/.odt in ${outDir} (marking: ${MARKING})`);
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Generates the shipped default favicons (issue #306):
|
|
||||||
* `apps/api/assets/default-favicon-32.png` and `-180.png`.
|
|
||||||
*
|
|
||||||
* The api serves these whenever an operator has not uploaded one, so an
|
|
||||||
* instance always has a tab icon — the `<link rel="icon">` in index.html is
|
|
||||||
* static and its resource must never 404.
|
|
||||||
*
|
|
||||||
* Drawn here rather than pulled in as a binary: the whole toolchain must
|
|
||||||
* survive the `--network none` offline build (96-offline-build-protokoll.md),
|
|
||||||
* and adding an image library for one 32×32 icon would be the tail wagging
|
|
||||||
* the dog. Node's own zlib is enough to write a PNG.
|
|
||||||
*
|
|
||||||
* Motif: a pond seen from above — the accent-green disc with two ripples.
|
|
||||||
*
|
|
||||||
* Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit
|
|
||||||
* script and binaries together.
|
|
||||||
*/
|
|
||||||
import { deflateSync } from 'node:zlib';
|
|
||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
/** Brand green — the same value as index.html's light `theme-color`. */
|
|
||||||
const GREEN = [0x2f, 0x6f, 0x4f];
|
|
||||||
const LIGHT = [0xe8, 0xf2, 0xec];
|
|
||||||
|
|
||||||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
||||||
let c = n;
|
|
||||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
return c >>> 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
function crc32(buf) {
|
|
||||||
let c = 0xffffffff;
|
|
||||||
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
||||||
return (c ^ 0xffffffff) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunk(type, data) {
|
|
||||||
const length = Buffer.alloc(4);
|
|
||||||
length.writeUInt32BE(data.length);
|
|
||||||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
||||||
const crc = Buffer.alloc(4);
|
|
||||||
crc.writeUInt32BE(crc32(body));
|
|
||||||
return Buffer.concat([length, body, crc]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal RGBA PNG writer — no filtering, one IDAT. */
|
|
||||||
function encodePng(size, rgba) {
|
|
||||||
const ihdr = Buffer.alloc(13);
|
|
||||||
ihdr.writeUInt32BE(size, 0);
|
|
||||||
ihdr.writeUInt32BE(size, 4);
|
|
||||||
ihdr[8] = 8; // bit depth
|
|
||||||
ihdr[9] = 6; // colour type RGBA
|
|
||||||
const raw = Buffer.alloc(size * (size * 4 + 1));
|
|
||||||
for (let y = 0; y < size; y += 1) {
|
|
||||||
raw[y * (size * 4 + 1)] = 0; // filter: none
|
|
||||||
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
|
||||||
}
|
|
||||||
return Buffer.concat([
|
|
||||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
||||||
chunk('IHDR', ihdr),
|
|
||||||
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
|
||||||
chunk('IEND', Buffer.alloc(0)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Colour at one point of the unit square, in continuous coordinates — the
|
|
||||||
* caller supersamples it, which is where the anti-aliasing comes from.
|
|
||||||
*/
|
|
||||||
function sample(x, y) {
|
|
||||||
const dx = x - 0.5;
|
|
||||||
const dy = y - 0.5;
|
|
||||||
const r = Math.hypot(dx, dy);
|
|
||||||
if (r > 0.48) return null; // outside the disc: transparent
|
|
||||||
// Two ripples spreading from a point struck slightly above centre — rings
|
|
||||||
// rather than a bullseye, which is why the centre stays green and the
|
|
||||||
// spacing widens outward the way real ripples do.
|
|
||||||
const rr = Math.hypot(dx, dy + 0.06);
|
|
||||||
const onRing = (radius, width) => Math.abs(rr - radius) < width;
|
|
||||||
if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT;
|
|
||||||
return GREEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(size) {
|
|
||||||
const SS = 4; // supersampling factor
|
|
||||||
const out = Buffer.alloc(size * size * 4);
|
|
||||||
for (let y = 0; y < size; y += 1) {
|
|
||||||
for (let x = 0; x < size; x += 1) {
|
|
||||||
let r = 0;
|
|
||||||
let g = 0;
|
|
||||||
let b = 0;
|
|
||||||
let a = 0;
|
|
||||||
for (let sy = 0; sy < SS; sy += 1) {
|
|
||||||
for (let sx = 0; sx < SS; sx += 1) {
|
|
||||||
const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size);
|
|
||||||
if (c) {
|
|
||||||
r += c[0];
|
|
||||||
g += c[1];
|
|
||||||
b += c[2];
|
|
||||||
a += 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const n = SS * SS;
|
|
||||||
const covered = a / 255;
|
|
||||||
const i = (y * size + x) * 4;
|
|
||||||
// Premultiplied average of the covered samples only, so the edge fades
|
|
||||||
// in alpha rather than towards black.
|
|
||||||
out[i] = covered ? Math.round(r / covered) : 0;
|
|
||||||
out[i + 1] = covered ? Math.round(g / covered) : 0;
|
|
||||||
out[i + 2] = covered ? Math.round(b / covered) : 0;
|
|
||||||
out[i + 3] = Math.round(a / n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets');
|
|
||||||
for (const size of [32, 180]) {
|
|
||||||
const file = join(assets, `default-favicon-${size}.png`);
|
|
||||||
writeFileSync(file, encodePng(size, render(size)));
|
|
||||||
console.log(`wrote ${file}`);
|
|
||||||
}
|
|
||||||
@ -11,17 +11,9 @@ import {
|
|||||||
} from '../settings/instance-settings.service';
|
} from '../settings/instance-settings.service';
|
||||||
import { SiteAdminGuard } from './site-admin.guard';
|
import { SiteAdminGuard } from './site-admin.guard';
|
||||||
|
|
||||||
// Lifecycle markers and file-backed metadata, not configuration: never
|
// Lifecycle markers, not configuration: never editable through this
|
||||||
// editable through this endpoint. The setup lock must be irreversible
|
// endpoint (the setup lock must be irreversible, issue #80).
|
||||||
// (issue #80), and the branding entries only describe bytes on disk
|
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
|
||||||
// (issue #306) — writing one by hand would claim an asset that is not
|
|
||||||
// there. Both have their own write paths.
|
|
||||||
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set([
|
|
||||||
'setup.completedAt',
|
|
||||||
'instance.logo',
|
|
||||||
'instance.logoDark',
|
|
||||||
'instance.favicon',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Partial update: any subset of the known settings, each validated by
|
// Partial update: any subset of the known settings, each validated by
|
||||||
// its own schema inside the service (double validation is fine — this
|
// its own schema inside the service (double validation is fine — this
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BackupModule } from '../backup/backup.module';
|
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
|
||||||
import { QuotasModule } from '../quotas/quotas.module';
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
import { AdminSettingsController } from './admin.controller';
|
import { AdminSettingsController } from './admin.controller';
|
||||||
import { BackupAdminController } from './backup-admin.controller';
|
|
||||||
import { BackupAdminService } from './backup-admin.service';
|
|
||||||
import { PseudonymizationService } from './pseudonymization.service';
|
import { PseudonymizationService } from './pseudonymization.service';
|
||||||
import { QuotaAdminController } from './quota-admin.controller';
|
import { QuotaAdminController } from './quota-admin.controller';
|
||||||
import { SystemAdminController } from './system-admin.controller';
|
import { SystemAdminController } from './system-admin.controller';
|
||||||
@ -20,28 +15,13 @@ import { UserAdminController } from './user-admin.controller';
|
|||||||
import { UserAdminService } from './user-admin.service';
|
import { UserAdminService } from './user-admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule],
|
||||||
QuotasModule,
|
|
||||||
UsersModule,
|
|
||||||
AuthModule,
|
|
||||||
SchedulerModule,
|
|
||||||
BackupModule,
|
|
||||||
SearchModule,
|
|
||||||
PondsModule,
|
|
||||||
],
|
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminSettingsController,
|
AdminSettingsController,
|
||||||
BackupAdminController,
|
|
||||||
QuotaAdminController,
|
QuotaAdminController,
|
||||||
SystemAdminController,
|
SystemAdminController,
|
||||||
UserAdminController,
|
UserAdminController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [QuotaAdminService, SystemAdminService, UserAdminService, PseudonymizationService],
|
||||||
BackupAdminService,
|
|
||||||
QuotaAdminService,
|
|
||||||
SystemAdminService,
|
|
||||||
UserAdminService,
|
|
||||||
PseudonymizationService,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Post, Put, Req, UseGuards } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
backupConnectionTestInputSchema,
|
|
||||||
backupRestoreInputSchema,
|
|
||||||
backupSettingsInputSchema,
|
|
||||||
type BackupConnectionTestInput,
|
|
||||||
type BackupConnectionTestResult,
|
|
||||||
type BackupRestoreInput,
|
|
||||||
type BackupSetsView,
|
|
||||||
type BackupSettingsInput,
|
|
||||||
type BackupSettingsView,
|
|
||||||
} from '@dorfteich/shared';
|
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
|
||||||
import { SiteAdminGuard } from './site-admin.guard';
|
|
||||||
import { BackupAdminService } from './backup-admin.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Site-Admin backup management (issue #103): Nextcloud target settings with
|
|
||||||
* a live connection test, the manual backup trigger, and the in-app restore
|
|
||||||
* (both answer 202 — the sidecar executes, progress arrives through the
|
|
||||||
* status files surfaced on GET /admin/system/backup).
|
|
||||||
*/
|
|
||||||
@Controller('admin/system/backup')
|
|
||||||
@UseGuards(SiteAdminGuard)
|
|
||||||
export class BackupAdminController {
|
|
||||||
constructor(private readonly backup: BackupAdminService) {}
|
|
||||||
|
|
||||||
@Get('settings')
|
|
||||||
settings(): Promise<BackupSettingsView> {
|
|
||||||
return this.backup.settingsView();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put('settings')
|
|
||||||
saveSettings(
|
|
||||||
@Body(new ZodValidationPipe(backupSettingsInputSchema)) input: BackupSettingsInput,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<BackupSettingsView> {
|
|
||||||
return this.backup.saveSettings(input, request.user!);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('nextcloud/test')
|
|
||||||
@HttpCode(200)
|
|
||||||
testConnection(
|
|
||||||
@Body(new ZodValidationPipe(backupConnectionTestInputSchema))
|
|
||||||
input: BackupConnectionTestInput,
|
|
||||||
): Promise<BackupConnectionTestResult> {
|
|
||||||
return this.backup.testConnection(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('sets')
|
|
||||||
sets(): Promise<BackupSetsView> {
|
|
||||||
return this.backup.sets();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('run')
|
|
||||||
@HttpCode(202)
|
|
||||||
async run(@Req() request: AuthedRequest): Promise<{ requested: true }> {
|
|
||||||
await this.backup.requestRun(request.user!);
|
|
||||||
return { requested: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('restore')
|
|
||||||
@HttpCode(202)
|
|
||||||
async restore(
|
|
||||||
@Body(new ZodValidationPipe(backupRestoreInputSchema)) input: BackupRestoreInput,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<{ requested: true }> {
|
|
||||||
await this.backup.requestRestore(input, request.user!);
|
|
||||||
return { requested: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,408 +0,0 @@
|
|||||||
import { createServer, type Server } from 'node:http';
|
|
||||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { setTimeout as sleep } from 'node:timers/promises';
|
|
||||||
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
BACKUP_COMMAND_CHANNEL,
|
|
||||||
RESTORE_STATUS_FILE,
|
|
||||||
archiveFileName,
|
|
||||||
dumpFileName,
|
|
||||||
type BackupCommand,
|
|
||||||
type BackupSetsView,
|
|
||||||
type BackupSettingsView,
|
|
||||||
type RestoreStatus,
|
|
||||||
type SystemBackupView,
|
|
||||||
} from '@dorfteich/shared';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { Client } from 'pg';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fake Nextcloud endpoint covering the WebDAV subset the admin endpoints
|
|
||||||
* use (PROPFIND/MKCOL) plus a remote bundle listing — the connection test
|
|
||||||
* and the restore picker run against real HTTP.
|
|
||||||
*/
|
|
||||||
function createDavServer(): {
|
|
||||||
server: Server;
|
|
||||||
start(): Promise<string>;
|
|
||||||
stop(): Promise<void>;
|
|
||||||
setAuthOk(ok: boolean): void;
|
|
||||||
} {
|
|
||||||
let authOk = true;
|
|
||||||
const files = ['dorfteich-backup-20260710-030000.tar.gz'];
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
if (!authOk) {
|
|
||||||
res.statusCode = 401;
|
|
||||||
return res.end();
|
|
||||||
}
|
|
||||||
if (req.method === 'PROPFIND') {
|
|
||||||
const depth = req.headers.depth;
|
|
||||||
res.statusCode = 207;
|
|
||||||
res.setHeader('Content-Type', 'application/xml');
|
|
||||||
const children =
|
|
||||||
depth === '1'
|
|
||||||
? files
|
|
||||||
.map(
|
|
||||||
(name) => `<d:response><d:href>${req.url}/${name}</d:href><d:propstat><d:prop>
|
|
||||||
<d:getcontentlength>2048</d:getcontentlength><d:resourcetype/>
|
|
||||||
</d:prop></d:propstat></d:response>`,
|
|
||||||
)
|
|
||||||
.join('')
|
|
||||||
: '';
|
|
||||||
return res.end(
|
|
||||||
`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response><d:href>${req.url}/</d:href><d:propstat><d:prop>
|
|
||||||
<d:resourcetype><d:collection/></d:resourcetype>
|
|
||||||
</d:prop></d:propstat></d:response>${children}</d:multistatus>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (req.method === 'MKCOL') {
|
|
||||||
res.statusCode = 201;
|
|
||||||
return res.end();
|
|
||||||
}
|
|
||||||
res.statusCode = 405;
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
server,
|
|
||||||
setAuthOk: (ok) => {
|
|
||||||
authOk = ok;
|
|
||||||
},
|
|
||||||
start: () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
server.listen(0, '127.0.0.1', () => {
|
|
||||||
resolve(`http://127.0.0.1:${(server.address() as { port: number }).port}`);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
stop: () => new Promise((resolve) => server.close(() => resolve())),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup administration end to end (issue #103): settings roundtrip with
|
|
||||||
* the app password landing in the secret store (never the database), the
|
|
||||||
* live connection test, the restore picker's set listing, command NOTIFYs
|
|
||||||
* for run/restore, the public restore-status endpoint, and the maintenance
|
|
||||||
* gate's 503 semantics including staleness.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('backup admin (e2e, issue #103)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let backupsDir: string;
|
|
||||||
let secretsFile: string;
|
|
||||||
let davUrl: string;
|
|
||||||
const dav = createDavServer();
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const password = 'backupadmin ist vorsichtig 1';
|
|
||||||
const ids: Record<string, string> = {};
|
|
||||||
const cookies: Record<string, string> = {};
|
|
||||||
const baseSecretsFile = process.env.SECRETS_FILE;
|
|
||||||
|
|
||||||
const commands: BackupCommand[] = [];
|
|
||||||
let listenClient: Client;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
async function makeUser(handle: string, siteAdmin: boolean): Promise<void> {
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const username = `bak-${handle}-${suffix}`;
|
|
||||||
const user = await users.createUser({
|
|
||||||
username,
|
|
||||||
email: `${username}@example.org`,
|
|
||||||
displayName: `Bak ${handle}`,
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await users.markEmailVerified(user.id);
|
|
||||||
if (siteAdmin)
|
|
||||||
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
|
|
||||||
ids[handle] = user.id;
|
|
||||||
cookies[handle] = sessionCookieOf(
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: username, password })
|
|
||||||
.expect(200),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function settingsInput(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
localRetentionDays: 14,
|
|
||||||
remoteRetentionDays: 60,
|
|
||||||
nextcloud: {
|
|
||||||
enabled: true,
|
|
||||||
baseUrl: davUrl,
|
|
||||||
username: 'clouduser',
|
|
||||||
folder: `dorfteich-e2e-${suffix}`,
|
|
||||||
uploadSchedule: 'weekly',
|
|
||||||
password: 'app-password-123',
|
|
||||||
...((overrides.nextcloud as object) ?? {}),
|
|
||||||
},
|
|
||||||
...Object.fromEntries(Object.entries(overrides).filter(([k]) => k !== 'nextcloud')),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
backupsDir = mkdtempSync(join(tmpdir(), 'dorfteich-backup-admin-'));
|
|
||||||
secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-backup-secrets-')), 'secrets.env');
|
|
||||||
process.env.BACKUPS_DIR = backupsDir;
|
|
||||||
process.env.SECRETS_FILE = secretsFile;
|
|
||||||
// The in-test WebDAV server must be allowlisted (issue #192) — the
|
|
||||||
// policy paths themselves are covered by backup-allowlist*.e2e.db.test.ts.
|
|
||||||
process.env.BACKUP_ALLOWED_TARGETS = '127.0.0.1';
|
|
||||||
davUrl = await dav.start();
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
// Clean leftovers of earlier runs — the settings keys are singletons.
|
|
||||||
await prisma.instanceSetting.deleteMany({ where: { key: { startsWith: 'backup.' } } });
|
|
||||||
app = await createTestApp();
|
|
||||||
await makeUser('admin', true);
|
|
||||||
await makeUser('user', false);
|
|
||||||
|
|
||||||
listenClient = new Client({ connectionString: process.env.TEST_DATABASE_URL });
|
|
||||||
await listenClient.connect();
|
|
||||||
listenClient.on('notification', (message) => {
|
|
||||||
if (message.channel === BACKUP_COMMAND_CHANNEL && message.payload) {
|
|
||||||
commands.push(JSON.parse(message.payload) as BackupCommand);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await listenClient.query(`LISTEN ${BACKUP_COMMAND_CHANNEL}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.BACKUPS_DIR;
|
|
||||||
if (baseSecretsFile === undefined) delete process.env.SECRETS_FILE;
|
|
||||||
else process.env.SECRETS_FILE = baseSecretsFile;
|
|
||||||
await dav.stop();
|
|
||||||
await listenClient.end().catch(() => undefined);
|
|
||||||
const all = Object.values(ids);
|
|
||||||
await prisma.instanceSetting.deleteMany({ where: { key: { startsWith: 'backup.' } } });
|
|
||||||
await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } });
|
|
||||||
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
|
||||||
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects non-admins on every backup admin route', async () => {
|
|
||||||
for (const [method, path] of [
|
|
||||||
['get', '/api/v1/admin/system/backup/settings'],
|
|
||||||
['put', '/api/v1/admin/system/backup/settings'],
|
|
||||||
['post', '/api/v1/admin/system/backup/nextcloud/test'],
|
|
||||||
['get', '/api/v1/admin/system/backup/sets'],
|
|
||||||
['post', '/api/v1/admin/system/backup/run'],
|
|
||||||
['post', '/api/v1/admin/system/backup/restore'],
|
|
||||||
] as const) {
|
|
||||||
await api()[method](path).set('Cookie', cookies.user!).expect(403);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tests the connection against a live WebDAV endpoint', async () => {
|
|
||||||
const ok = await api()
|
|
||||||
.post('/api/v1/admin/system/backup/nextcloud/test')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send({
|
|
||||||
baseUrl: davUrl,
|
|
||||||
username: 'clouduser',
|
|
||||||
folder: 'dorfteich-e2e',
|
|
||||||
password: 'app-password-123',
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
expect(ok.body).toEqual({ ok: true });
|
|
||||||
|
|
||||||
dav.setAuthOk(false);
|
|
||||||
const bad = await api()
|
|
||||||
.post('/api/v1/admin/system/backup/nextcloud/test')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send({
|
|
||||||
baseUrl: davUrl,
|
|
||||||
username: 'clouduser',
|
|
||||||
folder: 'dorfteich-e2e',
|
|
||||||
password: 'wrong',
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
expect(bad.body.ok).toBe(false);
|
|
||||||
expect(String(bad.body.error)).toContain('authentication failed');
|
|
||||||
dav.setAuthOk(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to save an enabled target that fails the connection test', async () => {
|
|
||||||
dav.setAuthOk(false);
|
|
||||||
const res = await api()
|
|
||||||
.put('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send(settingsInput())
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('backup_connection_failed');
|
|
||||||
dav.setAuthOk(true);
|
|
||||||
// Nothing was persisted.
|
|
||||||
const view = await api()
|
|
||||||
.get('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.expect(200);
|
|
||||||
expect((view.body as BackupSettingsView).nextcloud.enabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('saves settings, keeping the app password out of the database', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.put('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send(settingsInput())
|
|
||||||
.expect(200);
|
|
||||||
const view = res.body as BackupSettingsView;
|
|
||||||
expect(view).toMatchObject({
|
|
||||||
localRetentionDays: 14,
|
|
||||||
remoteRetentionDays: 60,
|
|
||||||
nextcloud: {
|
|
||||||
enabled: true,
|
|
||||||
baseUrl: davUrl,
|
|
||||||
username: 'clouduser',
|
|
||||||
uploadSchedule: 'weekly',
|
|
||||||
passwordSet: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Secret store holds the password; instance_settings never does.
|
|
||||||
expect(readFileSync(secretsFile, 'utf8')).toMatch(
|
|
||||||
/BACKUP_NEXTCLOUD_PASSWORD="?app-password-123"?/,
|
|
||||||
);
|
|
||||||
const rows = await prisma.instanceSetting.findMany({
|
|
||||||
where: { key: { startsWith: 'backup.' } },
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(rows.map((row) => row.value))).not.toContain('app-password-123');
|
|
||||||
|
|
||||||
// Re-saving without a password keeps the stored one (write-only field).
|
|
||||||
await api()
|
|
||||||
.put('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send(settingsInput({ nextcloud: { password: undefined } }))
|
|
||||||
.expect(200);
|
|
||||||
expect(readFileSync(secretsFile, 'utf8')).toMatch(
|
|
||||||
/BACKUP_NEXTCLOUD_PASSWORD="?app-password-123"?/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists local and remote restore sets, and reflects the target on the card', async () => {
|
|
||||||
writeFileSync(join(backupsDir, dumpFileName('20260712-030000')), 'dump');
|
|
||||||
writeFileSync(join(backupsDir, archiveFileName('20260712-030000')), 'archive');
|
|
||||||
// An incomplete set never shows up as restorable.
|
|
||||||
writeFileSync(join(backupsDir, dumpFileName('20260712-040000')), 'dump-only');
|
|
||||||
|
|
||||||
const res = await api()
|
|
||||||
.get('/api/v1/admin/system/backup/sets')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.expect(200);
|
|
||||||
const sets = res.body as BackupSetsView;
|
|
||||||
expect(sets.remoteConfigured).toBe(true);
|
|
||||||
expect(sets.local.map((s) => s.backupId)).toEqual(['20260712-030000']);
|
|
||||||
expect(sets.remote.map((s) => s.backupId)).toEqual(['20260710-030000']);
|
|
||||||
expect(sets.remote[0]!.sizeBytes).toBe(2048);
|
|
||||||
|
|
||||||
const card = await api()
|
|
||||||
.get('/api/v1/admin/system/backup')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.expect(200);
|
|
||||||
expect((card.body as SystemBackupView).remoteConfigured).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends run and restore commands over NOTIFY, audit-logged', async () => {
|
|
||||||
commands.length = 0;
|
|
||||||
await api().post('/api/v1/admin/system/backup/run').set('Cookie', cookies.admin!).expect(202);
|
|
||||||
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/system/backup/restore')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send({ source: 'local', backupId: '20260712-030000', confirm: '20260712-030000' })
|
|
||||||
.expect(202);
|
|
||||||
|
|
||||||
await sleep(300);
|
|
||||||
expect(commands).toEqual([
|
|
||||||
{ kind: 'run', requestedBy: `bak-admin-${suffix}` },
|
|
||||||
{
|
|
||||||
kind: 'restore',
|
|
||||||
source: 'local',
|
|
||||||
backupId: '20260712-030000',
|
|
||||||
requestedBy: `bak-admin-${suffix}`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const audit = await prisma.auditEntry.findMany({
|
|
||||||
where: { actorId: ids.admin!, action: { startsWith: 'backup.' } },
|
|
||||||
});
|
|
||||||
const actions = audit.map((entry) => entry.action);
|
|
||||||
expect(actions).toContain('backup.run_triggered');
|
|
||||||
expect(actions).toContain('backup.restore_requested');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('guards the restore trigger: confirm mismatch, unknown sets, bad sources', async () => {
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/system/backup/restore')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send({ source: 'local', backupId: '20260712-030000', confirm: 'nope' })
|
|
||||||
.expect(400);
|
|
||||||
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/system/backup/restore')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send({ source: 'local', backupId: '20260712-040000', confirm: '20260712-040000' })
|
|
||||||
.expect(404);
|
|
||||||
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/system/backup/restore')
|
|
||||||
.set('Cookie', cookies.admin!)
|
|
||||||
.send({ source: 'remote', backupId: '20260712-050000', confirm: '20260712-050000' })
|
|
||||||
.expect(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serves the public restore status and gates the api during a restore', async () => {
|
|
||||||
// No restore yet → idle, and the api serves normally.
|
|
||||||
const idle = await api().get('/api/v1/backup/restore-status').expect(200);
|
|
||||||
expect(idle.body).toEqual({ state: 'idle' });
|
|
||||||
|
|
||||||
const running: RestoreStatus = {
|
|
||||||
schemaVersion: 1,
|
|
||||||
state: 'running',
|
|
||||||
backupId: '20260712-030000',
|
|
||||||
source: 'local',
|
|
||||||
requestedBy: 'admin',
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
finishedAt: null,
|
|
||||||
};
|
|
||||||
writeFileSync(join(backupsDir, RESTORE_STATUS_FILE), JSON.stringify(running));
|
|
||||||
await sleep(1600); // maintenance state cache TTL
|
|
||||||
|
|
||||||
// Anonymous status endpoint keeps answering; everything else 503s.
|
|
||||||
const status = await api().get('/api/v1/backup/restore-status').expect(200);
|
|
||||||
expect((status.body as RestoreStatus).state).toBe('running');
|
|
||||||
const gated = await api().get('/api/v1/ponds').set('Cookie', cookies.admin!).expect(503);
|
|
||||||
expect(gated.body.code).toBe('maintenance_mode');
|
|
||||||
await api().get('/api/v1/healthz').expect(200);
|
|
||||||
|
|
||||||
// A crashed restore (stale running state) must not brick the instance.
|
|
||||||
writeFileSync(
|
|
||||||
join(backupsDir, RESTORE_STATUS_FILE),
|
|
||||||
JSON.stringify({ ...running, startedAt: new Date(Date.now() - 31 * 60_000).toISOString() }),
|
|
||||||
);
|
|
||||||
await sleep(1600);
|
|
||||||
await api().get('/api/v1/ponds').set('Cookie', cookies.admin!).expect(200);
|
|
||||||
|
|
||||||
// A finished restore leaves the gate open and reports its result.
|
|
||||||
writeFileSync(
|
|
||||||
join(backupsDir, RESTORE_STATUS_FILE),
|
|
||||||
JSON.stringify({ ...running, state: 'succeeded', finishedAt: new Date().toISOString() }),
|
|
||||||
);
|
|
||||||
await sleep(1600);
|
|
||||||
const done = await api().get('/api/v1/backup/restore-status').expect(200);
|
|
||||||
expect((done.body as RestoreStatus).state).toBe('succeeded');
|
|
||||||
await api().get('/api/v1/ponds').set('Cookie', cookies.admin!).expect(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
import { readdirSync } from 'node:fs';
|
|
||||||
import { statSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
BACKUP_COMMAND_CHANNEL,
|
|
||||||
archiveFileName,
|
|
||||||
backupIdTime,
|
|
||||||
dumpFileName,
|
|
||||||
listSets,
|
|
||||||
remoteBundleId,
|
|
||||||
type BackupCommand,
|
|
||||||
type BackupConnectionTestInput,
|
|
||||||
type BackupConnectionTestResult,
|
|
||||||
type BackupRestoreInput,
|
|
||||||
type BackupSetView,
|
|
||||||
type BackupSetsView,
|
|
||||||
type BackupSettingsInput,
|
|
||||||
type BackupSettingsView,
|
|
||||||
} from '@dorfteich/shared';
|
|
||||||
import { webdavList } from '@dorfteich/shared/webdav';
|
|
||||||
import { User } from '@prisma/client';
|
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
|
||||||
import { BackupTargetService } from '../backup/backup-target.service';
|
|
||||||
import { MaintenanceStateService } from '../backup/maintenance-state.service';
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Site-Admin backup management (issue #103): the Nextcloud target
|
|
||||||
* configuration, manual "back up now", the restore picker (local sets from
|
|
||||||
* the read-only backups mount, remote sets via WebDAV), and the restore
|
|
||||||
* trigger. The sidecar does the actual work — commands travel over the
|
|
||||||
* {@link BACKUP_COMMAND_CHANNEL} NOTIFY bus, progress comes back through
|
|
||||||
* `status.json`/`restore-status.json`.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class BackupAdminService {
|
|
||||||
constructor(
|
|
||||||
private readonly target: BackupTargetService,
|
|
||||||
private readonly maintenance: MaintenanceStateService,
|
|
||||||
private readonly settings: InstanceSettingsService,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
private readonly config: AppConfig,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
settingsView(): Promise<BackupSettingsView> {
|
|
||||||
return this.target.settingsView();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists the backup settings. When the Nextcloud side is enabled, the
|
|
||||||
* connection is live-tested first (with the new password if provided,
|
|
||||||
* else the stored one) — like the setup wizard's SMTP step, nothing is
|
|
||||||
* saved on failure.
|
|
||||||
*/
|
|
||||||
async saveSettings(input: BackupSettingsInput, actor: User): Promise<BackupSettingsView> {
|
|
||||||
if (input.nextcloud.enabled) {
|
|
||||||
this.assertTargetAllowed(input.nextcloud.baseUrl);
|
|
||||||
const test = await this.target.testConnection({
|
|
||||||
baseUrl: input.nextcloud.baseUrl,
|
|
||||||
username: input.nextcloud.username,
|
|
||||||
folder: input.nextcloud.folder,
|
|
||||||
password: input.nextcloud.password,
|
|
||||||
});
|
|
||||||
if (!test.ok) {
|
|
||||||
throw new BadRequestException({
|
|
||||||
code: 'backup_connection_failed',
|
|
||||||
details: { nextcloud: [test.error ?? 'connection failed'] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.target.storePassword(input.nextcloud.password ?? '');
|
|
||||||
await this.settings.set('backup.localRetentionDays', input.localRetentionDays, actor.id);
|
|
||||||
await this.settings.set('backup.remoteRetentionDays', input.remoteRetentionDays, actor.id);
|
|
||||||
await this.settings.set('backup.nextcloud.enabled', input.nextcloud.enabled, actor.id);
|
|
||||||
await this.settings.set('backup.nextcloud.baseUrl', input.nextcloud.baseUrl, actor.id);
|
|
||||||
await this.settings.set('backup.nextcloud.username', input.nextcloud.username, actor.id);
|
|
||||||
await this.settings.set('backup.nextcloud.folder', input.nextcloud.folder, actor.id);
|
|
||||||
await this.settings.set(
|
|
||||||
'backup.nextcloud.uploadSchedule',
|
|
||||||
input.nextcloud.uploadSchedule,
|
|
||||||
actor.id,
|
|
||||||
);
|
|
||||||
// settings.set audits each key; one summary entry names the intent.
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'backup.settings_changed',
|
|
||||||
actorId: actor.id,
|
|
||||||
details: { nextcloudEnabled: input.nextcloud.enabled },
|
|
||||||
});
|
|
||||||
return this.settingsView();
|
|
||||||
}
|
|
||||||
|
|
||||||
testConnection(input: BackupConnectionTestInput): Promise<BackupConnectionTestResult> {
|
|
||||||
// Policy first (issue #192): the "test connection" button must not be
|
|
||||||
// usable as an egress probe towards non-allowlisted hosts.
|
|
||||||
this.assertTargetAllowed(input.baseUrl);
|
|
||||||
return this.target.testConnection(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deploy-level target policy (issue #192, ADR 0026): an empty
|
|
||||||
* `BACKUP_ALLOWED_TARGETS` disables remote targets outright; a host
|
|
||||||
* outside the list is rejected with an admin-visible error.
|
|
||||||
*/
|
|
||||||
private assertTargetAllowed(baseUrl: string): void {
|
|
||||||
if (!this.target.remoteAllowed()) {
|
|
||||||
throw new BadRequestException({ code: 'backup_remote_disabled_by_policy' });
|
|
||||||
}
|
|
||||||
if (!this.target.targetAllowed(baseUrl)) {
|
|
||||||
throw new BadRequestException({
|
|
||||||
code: 'backup_target_not_allowed',
|
|
||||||
details: { nextcloud: [`host is not in BACKUP_ALLOWED_TARGETS`] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Both restore sources for the picker: newest first. */
|
|
||||||
async sets(): Promise<BackupSetsView> {
|
|
||||||
const local = this.localSets();
|
|
||||||
const target = await this.target.resolveTarget();
|
|
||||||
if (!target) return { local, remoteConfigured: false, remote: [] };
|
|
||||||
|
|
||||||
const listed = await webdavList(target);
|
|
||||||
if (!listed.ok) {
|
|
||||||
return { local, remoteConfigured: true, remote: [], remoteError: listed.error };
|
|
||||||
}
|
|
||||||
const remote = listed.value
|
|
||||||
.filter((entry) => !entry.isCollection)
|
|
||||||
.map((entry) => ({ id: remoteBundleId(entry.name), sizeBytes: entry.sizeBytes }))
|
|
||||||
.filter((entry): entry is { id: string; sizeBytes: number | null } => entry.id !== null)
|
|
||||||
.map((entry) => this.setView(entry.id, entry.sizeBytes))
|
|
||||||
.sort((a, b) => b.backupId.localeCompare(a.backupId));
|
|
||||||
return { local, remoteConfigured: true, remote };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Back up now": dump + upload, executed by the sidecar (202-style). */
|
|
||||||
async requestRun(actor: User): Promise<void> {
|
|
||||||
await this.notify({ kind: 'run', requestedBy: actor.username });
|
|
||||||
await this.audit.record({ action: 'backup.run_triggered', actorId: actor.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requests an in-app restore. The type-to-confirm value must repeat the
|
|
||||||
* backup id — the UI enforces it too, this is the server-side backstop
|
|
||||||
* for the most destructive action the instance has.
|
|
||||||
*/
|
|
||||||
async requestRestore(input: BackupRestoreInput, actor: User): Promise<void> {
|
|
||||||
if (input.confirm !== input.backupId) {
|
|
||||||
throw new BadRequestException({ code: 'backup_restore_confirm_mismatch' });
|
|
||||||
}
|
|
||||||
if (this.maintenance.current()?.state === 'running' && this.maintenance.isActive()) {
|
|
||||||
throw new ConflictException({ code: 'backup_restore_running' });
|
|
||||||
}
|
|
||||||
if (input.source === 'remote') {
|
|
||||||
const target = await this.target.resolveTarget();
|
|
||||||
if (!target) throw new BadRequestException({ code: 'backup_remote_not_configured' });
|
|
||||||
const listed = await webdavList(target);
|
|
||||||
const exists =
|
|
||||||
listed.ok && listed.value.some((entry) => remoteBundleId(entry.name) === input.backupId);
|
|
||||||
if (!exists) throw new NotFoundException({ code: 'backup_set_not_found' });
|
|
||||||
} else {
|
|
||||||
const complete = this.localSets().some((set) => set.backupId === input.backupId);
|
|
||||||
if (!complete) throw new NotFoundException({ code: 'backup_set_not_found' });
|
|
||||||
}
|
|
||||||
await this.notify({
|
|
||||||
kind: 'restore',
|
|
||||||
source: input.source,
|
|
||||||
backupId: input.backupId,
|
|
||||||
requestedBy: actor.username,
|
|
||||||
});
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'backup.restore_requested',
|
|
||||||
actorId: actor.id,
|
|
||||||
targetType: 'backup',
|
|
||||||
targetId: input.backupId,
|
|
||||||
details: { source: input.source },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async notify(command: BackupCommand): Promise<void> {
|
|
||||||
await this.prisma
|
|
||||||
.$executeRaw`SELECT pg_notify(${BACKUP_COMMAND_CHANNEL}, ${JSON.stringify(command)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Complete sets on the read-only backups mount, newest first. */
|
|
||||||
private localSets(): BackupSetView[] {
|
|
||||||
let names: string[];
|
|
||||||
try {
|
|
||||||
names = readdirSync(this.config.env.BACKUPS_DIR);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return listSets(names)
|
|
||||||
.filter((set) => set.complete)
|
|
||||||
.map((set) => {
|
|
||||||
let sizeBytes: number | null = 0;
|
|
||||||
for (const file of [dumpFileName(set.id), archiveFileName(set.id)]) {
|
|
||||||
try {
|
|
||||||
sizeBytes = (sizeBytes ?? 0) + statSync(join(this.config.env.BACKUPS_DIR, file)).size;
|
|
||||||
} catch {
|
|
||||||
sizeBytes = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.setView(set.id, sizeBytes);
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.backupId.localeCompare(a.backupId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private setView(backupId: string, sizeBytes: number | null): BackupSetView {
|
|
||||||
return {
|
|
||||||
backupId,
|
|
||||||
startedAt: backupIdTime(backupId)?.toISOString() ?? new Date(0).toISOString(),
|
|
||||||
sizeBytes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup target allowlist (issue #192, ADR 0026), empty-list half: with
|
|
||||||
* `BACKUP_ALLOWED_TARGETS` unset (the default) every remote target is
|
|
||||||
* unavailable by policy — the view says so, and enabling one is rejected
|
|
||||||
* before any connection attempt. Lives in its own file because the env is
|
|
||||||
* read once at app boot.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('backup targets disabled by empty allowlist (e2e, issue #192)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const password = 'backup allowlist pass 2';
|
|
||||||
let adminId: string;
|
|
||||||
let adminCookie: string;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
delete process.env.BACKUP_ALLOWED_TARGETS;
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
app = await createTestApp();
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const username = `bae-admin-${suffix}`;
|
|
||||||
const admin = await users.createUser({
|
|
||||||
username,
|
|
||||||
email: `${username}@example.org`,
|
|
||||||
displayName: 'Backup Admin Empty',
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
adminId = admin.id;
|
|
||||||
await users.markEmailVerified(adminId);
|
|
||||||
await prisma.user.update({ where: { id: adminId }, data: { isSiteAdmin: true } });
|
|
||||||
adminCookie = sessionCookieOf(
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: username, password })
|
|
||||||
.expect(200),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.auditEntry.deleteMany({ where: { actorId: adminId } });
|
|
||||||
await prisma.session.deleteMany({ where: { userId: adminId } });
|
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId: adminId } });
|
|
||||||
await prisma.user.deleteMany({ where: { id: adminId } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports remote targets as unavailable by policy', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.get('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.expect(200);
|
|
||||||
expect(res.body.remoteTargets).toEqual({ allowed: false, allowlist: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects enabling any remote destination', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.put('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.send({
|
|
||||||
localRetentionDays: null,
|
|
||||||
remoteRetentionDays: 30,
|
|
||||||
nextcloud: {
|
|
||||||
enabled: true,
|
|
||||||
baseUrl: 'https://cloud.example.org/dav',
|
|
||||||
username: 'backupuser',
|
|
||||||
folder: 'dorfteich-backups',
|
|
||||||
uploadSchedule: 'daily',
|
|
||||||
password: 'app-pass',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('backup_remote_disabled_by_policy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects the connection test outright', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.post('/api/v1/admin/system/backup/nextcloud/test')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.send({
|
|
||||||
baseUrl: 'https://cloud.example.org/dav',
|
|
||||||
username: 'backupuser',
|
|
||||||
folder: 'dorfteich-backups',
|
|
||||||
password: 'app-pass',
|
|
||||||
})
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('backup_remote_disabled_by_policy');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
// Deploy-level env — must be set BEFORE the app (AppConfig) boots.
|
|
||||||
process.env.BACKUP_ALLOWED_TARGETS = 'cloud.example.org';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup target allowlist (issue #192, ADR 0026), populated-list half:
|
|
||||||
* hosts outside `BACKUP_ALLOWED_TARGETS` are rejected admin-visibly, hosts
|
|
||||||
* inside pass the policy. The empty-list half lives in its own file
|
|
||||||
* (`backup-allowlist-empty.e2e.db.test.ts`) because the env is read once
|
|
||||||
* at app boot.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('backup target allowlist (e2e, issue #192)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const password = 'backup allowlist pass 1';
|
|
||||||
let adminId: string;
|
|
||||||
let adminCookie: string;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
const settingsInput = (baseUrl: string, enabled = true) => ({
|
|
||||||
localRetentionDays: null,
|
|
||||||
remoteRetentionDays: 30,
|
|
||||||
nextcloud: {
|
|
||||||
enabled,
|
|
||||||
baseUrl,
|
|
||||||
username: 'backupuser',
|
|
||||||
folder: 'dorfteich-backups',
|
|
||||||
uploadSchedule: 'daily',
|
|
||||||
password: 'app-pass',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
app = await createTestApp();
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const username = `bal-admin-${suffix}`;
|
|
||||||
const admin = await users.createUser({
|
|
||||||
username,
|
|
||||||
email: `${username}@example.org`,
|
|
||||||
displayName: 'Backup Admin',
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
adminId = admin.id;
|
|
||||||
await users.markEmailVerified(adminId);
|
|
||||||
await prisma.user.update({ where: { id: adminId }, data: { isSiteAdmin: true } });
|
|
||||||
adminCookie = sessionCookieOf(
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: username, password })
|
|
||||||
.expect(200),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.instanceSetting.deleteMany({ where: { key: { startsWith: 'backup.' } } });
|
|
||||||
await prisma.auditEntry.deleteMany({ where: { actorId: adminId } });
|
|
||||||
await prisma.session.deleteMany({ where: { userId: adminId } });
|
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId: adminId } });
|
|
||||||
await prisma.user.deleteMany({ where: { id: adminId } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes the policy in the settings view', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.get('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.expect(200);
|
|
||||||
expect(res.body.remoteTargets).toEqual({
|
|
||||||
allowed: true,
|
|
||||||
allowlist: ['cloud.example.org'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects enabling a destination outside the allowlist', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.put('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.send(settingsInput('https://evil.example.net/dav'))
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('backup_target_not_allowed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects the connection test towards a non-allowlisted host', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.post('/api/v1/admin/system/backup/nextcloud/test')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.send({
|
|
||||||
baseUrl: 'https://evil.example.net/dav',
|
|
||||||
username: 'backupuser',
|
|
||||||
folder: 'dorfteich-backups',
|
|
||||||
password: 'app-pass',
|
|
||||||
})
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('backup_target_not_allowed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets an allowlisted destination through the policy', async () => {
|
|
||||||
// The host passes the policy; what fails afterwards is the live
|
|
||||||
// connection test against the (unreachable) example host — proving the
|
|
||||||
// rejection above was the policy, not the connectivity.
|
|
||||||
const res = await api()
|
|
||||||
.put('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.send(settingsInput('https://cloud.example.org/dav'))
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('backup_connection_failed');
|
|
||||||
|
|
||||||
// Saving the same destination disabled skips the connection test and
|
|
||||||
// persists — an existing in-allowlist configuration stays untouched.
|
|
||||||
await api()
|
|
||||||
.put('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.send(settingsInput('https://cloud.example.org/dav', false))
|
|
||||||
.expect(200);
|
|
||||||
const view = await api()
|
|
||||||
.get('/api/v1/admin/system/backup/settings')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.expect(200);
|
|
||||||
expect(view.body.nextcloud.baseUrl).toBe('https://cloud.example.org/dav');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -4,7 +4,6 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SearchProvider } from '../search/search.provider';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GDPR account deletion (issue #59, security.md §Privacy). Rather than
|
* GDPR account deletion (issue #59, security.md §Privacy). Rather than
|
||||||
@ -18,7 +17,6 @@ export class PseudonymizationService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly search: SearchProvider,
|
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(PseudonymizationService.name);
|
this.logger.setContext(PseudonymizationService.name);
|
||||||
@ -47,14 +45,6 @@ export class PseudonymizationService {
|
|||||||
data: { deletedAt: new Date(), deletedBy: userId },
|
data: { deletedAt: new Date(), deletedBy: userId },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Trash path includes leaving the search index (issue #195).
|
|
||||||
const personalPonds = await this.prisma.pond.findMany({
|
|
||||||
where: { ownerId: userId, type: 'PERSONAL' },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
for (const pond of personalPonds) {
|
|
||||||
await this.search.removePond(pond.id);
|
|
||||||
}
|
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
action: 'user.pseudonymized',
|
action: 'user.pseudonymized',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
|
|||||||
@ -1,21 +1,16 @@
|
|||||||
import { Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
auditListQuerySchema,
|
auditListQuerySchema,
|
||||||
readEventListQuerySchema,
|
|
||||||
type AuditListQuery,
|
type AuditListQuery,
|
||||||
type AuditListView,
|
type AuditListView,
|
||||||
type JobTriggerResult,
|
type JobTriggerResult,
|
||||||
type ReadEventListQuery,
|
|
||||||
type ReadEventListView,
|
|
||||||
type StorageOverviewView,
|
type StorageOverviewView,
|
||||||
type SystemBackupView,
|
type SystemBackupView,
|
||||||
type SystemJobView,
|
type SystemJobView,
|
||||||
type VsNfdProfileView,
|
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { VsNfdProfileService } from '../settings/vs-nfd-profile.service';
|
|
||||||
import { SiteAdminGuard } from './site-admin.guard';
|
import { SiteAdminGuard } from './site-admin.guard';
|
||||||
import { SystemAdminService } from './system-admin.service';
|
import { SystemAdminService } from './system-admin.service';
|
||||||
|
|
||||||
@ -23,17 +18,7 @@ import { SystemAdminService } from './system-admin.service';
|
|||||||
@Controller('admin/system')
|
@Controller('admin/system')
|
||||||
@UseGuards(SiteAdminGuard)
|
@UseGuards(SiteAdminGuard)
|
||||||
export class SystemAdminController {
|
export class SystemAdminController {
|
||||||
constructor(
|
constructor(private readonly system: SystemAdminService) {}
|
||||||
private readonly system: SystemAdminService,
|
|
||||||
private readonly vsNfdProfile: VsNfdProfileService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Active VS-NfD mode + catalog verdict for the running configuration
|
|
||||||
* (issue #243, ADR 0027). Exposure only — the treatments are #244–#246. */
|
|
||||||
@Get('vs-nfd-profile')
|
|
||||||
vsNfd(): Promise<VsNfdProfileView> {
|
|
||||||
return this.vsNfdProfile.evaluate();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('jobs')
|
@Get('jobs')
|
||||||
async jobs(): Promise<SystemJobView[]> {
|
async jobs(): Promise<SystemJobView[]> {
|
||||||
@ -49,7 +34,7 @@ export class SystemAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('backup')
|
@Get('backup')
|
||||||
backup(): Promise<SystemBackupView> {
|
backup(): SystemBackupView {
|
||||||
return this.system.backup();
|
return this.system.backup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,15 +45,6 @@ export class SystemAdminController {
|
|||||||
return this.system.auditLog(query);
|
return this.system.auditLog(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read-access trail queries (issue #224): "who read page X", "what did
|
|
||||||
* user Y read" — Site-Admin only, like the audit viewer above. */
|
|
||||||
@Get('read-events')
|
|
||||||
async readEvents(
|
|
||||||
@Query(new ZodValidationPipe(readEventListQuerySchema)) query: ReadEventListQuery,
|
|
||||||
): Promise<ReadEventListView> {
|
|
||||||
return this.system.readEvents(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('storage')
|
@Get('storage')
|
||||||
async storage(): Promise<StorageOverviewView> {
|
async storage(): Promise<StorageOverviewView> {
|
||||||
return this.system.storage();
|
return this.system.storage();
|
||||||
|
|||||||
@ -7,21 +7,16 @@ import {
|
|||||||
AUDIT_PAGE_SIZE,
|
AUDIT_PAGE_SIZE,
|
||||||
BACKUP_FRESH_MAX_AGE_HOURS,
|
BACKUP_FRESH_MAX_AGE_HOURS,
|
||||||
BACKUP_STATUS_FILE,
|
BACKUP_STATUS_FILE,
|
||||||
READ_EVENT_PAGE_SIZE,
|
|
||||||
type AuditListQuery,
|
type AuditListQuery,
|
||||||
type AuditListView,
|
type AuditListView,
|
||||||
type BackupStatus,
|
type BackupStatus,
|
||||||
type JobTriggerResult,
|
type JobTriggerResult,
|
||||||
type ReadEventListQuery,
|
|
||||||
type ReadEventListView,
|
|
||||||
type StorageOverviewView,
|
type StorageOverviewView,
|
||||||
type SystemBackupView,
|
type SystemBackupView,
|
||||||
type SystemJobView,
|
type SystemJobView,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { BackupTargetService } from '../backup/backup-target.service';
|
|
||||||
import { MaintenanceStateService } from '../backup/maintenance-state.service';
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SchedulerService } from '../scheduler/scheduler.service';
|
import { SchedulerService } from '../scheduler/scheduler.service';
|
||||||
@ -40,8 +35,6 @@ export class SystemAdminService {
|
|||||||
private readonly scheduler: SchedulerService,
|
private readonly scheduler: SchedulerService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly backupTarget: BackupTargetService,
|
|
||||||
private readonly maintenance: MaintenanceStateService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -96,9 +89,8 @@ export class SystemAdminService {
|
|||||||
return { outcome, job };
|
return { outcome, job };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The backup card mirrors status.json including the freshness verdict,
|
/** The backup card mirrors status.json including the freshness verdict. */
|
||||||
* plus the off-host target state and restore progress (issue #103). */
|
backup(): SystemBackupView {
|
||||||
async backup(): Promise<SystemBackupView> {
|
|
||||||
const path = join(this.config.env.BACKUPS_DIR, BACKUP_STATUS_FILE);
|
const path = join(this.config.env.BACKUPS_DIR, BACKUP_STATUS_FILE);
|
||||||
let status: BackupStatus | null = null;
|
let status: BackupStatus | null = null;
|
||||||
if (existsSync(path)) {
|
if (existsSync(path)) {
|
||||||
@ -117,8 +109,6 @@ export class SystemAdminService {
|
|||||||
fresh: Number.isFinite(ageHours) && ageHours <= BACKUP_FRESH_MAX_AGE_HOURS,
|
fresh: Number.isFinite(ageHours) && ageHours <= BACKUP_FRESH_MAX_AGE_HOURS,
|
||||||
status,
|
status,
|
||||||
maxAgeHours: BACKUP_FRESH_MAX_AGE_HOURS,
|
maxAgeHours: BACKUP_FRESH_MAX_AGE_HOURS,
|
||||||
remoteConfigured: (await this.backupTarget.resolveTarget()) !== null,
|
|
||||||
restore: this.maintenance.current(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,66 +153,6 @@ export class SystemAdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The Site-Admin query path over the read-access trail (issue #224,
|
|
||||||
* ADR 0023) — evidence nobody can read is not evidence. Answers "who read
|
|
||||||
* page X" and "what did user Y read" within a period. API-only by design
|
|
||||||
* (no panel yet): the trail is an examiner's tool, not a daily screen —
|
|
||||||
* documented in data-model.md §read_events.
|
|
||||||
*/
|
|
||||||
async readEvents(query: ReadEventListQuery): Promise<ReadEventListView> {
|
|
||||||
const where: Prisma.ReadEventWhereInput = {};
|
|
||||||
if (query.pageId) where.pageId = query.pageId;
|
|
||||||
if (query.actor) {
|
|
||||||
const actor = await this.prisma.user.findUnique({ where: { username: query.actor } });
|
|
||||||
// An unknown username matches nothing rather than everything.
|
|
||||||
where.actorId = actor?.id ?? '00000000-0000-0000-0000-000000000000';
|
|
||||||
}
|
|
||||||
if (query.channel) where.channel = query.channel;
|
|
||||||
if (query.from || query.to) {
|
|
||||||
where.occurredAt = {
|
|
||||||
...(query.from ? { gte: query.from } : {}),
|
|
||||||
...(query.to ? { lte: query.to } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = await this.prisma.readEvent.count({ where });
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / READ_EVENT_PAGE_SIZE));
|
|
||||||
const page = Math.min(query.page, pageCount);
|
|
||||||
const events = await this.prisma.readEvent.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { occurredAt: 'desc' },
|
|
||||||
skip: (page - 1) * READ_EVENT_PAGE_SIZE,
|
|
||||||
take: READ_EVENT_PAGE_SIZE,
|
|
||||||
});
|
|
||||||
// No FK on actor_id (evidence outlives accounts) — resolve what still
|
|
||||||
// exists in one query, show the bare id otherwise.
|
|
||||||
const actorIds = [...new Set(events.map((e) => e.actorId).filter((id): id is string => !!id))];
|
|
||||||
const actors = actorIds.length
|
|
||||||
? await this.prisma.user.findMany({
|
|
||||||
where: { id: { in: actorIds } },
|
|
||||||
select: { id: true, username: true, displayName: true },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const actorById = new Map(actors.map((a) => [a.id, a]));
|
|
||||||
return {
|
|
||||||
entries: events.map((event) => ({
|
|
||||||
id: event.id,
|
|
||||||
occurredAt: event.occurredAt.toISOString(),
|
|
||||||
actor: event.actorId ? (actorById.get(event.actorId) ?? null) : null,
|
|
||||||
pageId: event.pageId,
|
|
||||||
pondId: event.pondId,
|
|
||||||
channel: event.channel,
|
|
||||||
classification: event.classification,
|
|
||||||
windowSeconds: event.windowSeconds,
|
|
||||||
details: (event.details as Record<string, unknown> | null) ?? null,
|
|
||||||
})),
|
|
||||||
page,
|
|
||||||
pageCount,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async storage(): Promise<StorageOverviewView> {
|
async storage(): Promise<StorageOverviewView> {
|
||||||
const usages = await this.prisma.pondUsage.findMany({
|
const usages = await this.prisma.pondUsage.findMany({
|
||||||
where: { pond: { deletedAt: null } },
|
where: { pond: { deletedAt: null } },
|
||||||
|
|||||||
@ -12,11 +12,9 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
AdminCreateUserInput,
|
|
||||||
AdminUserListQuery,
|
AdminUserListQuery,
|
||||||
AdminUserListView,
|
AdminUserListView,
|
||||||
AdminUserView,
|
AdminUserView,
|
||||||
adminCreateUserSchema,
|
|
||||||
adminUserListQuerySchema,
|
adminUserListQuerySchema,
|
||||||
setSiteAdminSchema,
|
setSiteAdminSchema,
|
||||||
setUserDisabledSchema,
|
setUserDisabledSchema,
|
||||||
@ -33,14 +31,6 @@ import { UserAdminService } from './user-admin.service';
|
|||||||
export class UserAdminController {
|
export class UserAdminController {
|
||||||
constructor(private readonly users: UserAdminService) {}
|
constructor(private readonly users: UserAdminService) {}
|
||||||
|
|
||||||
@Post()
|
|
||||||
async create(
|
|
||||||
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<AdminUserView> {
|
|
||||||
return this.users.createUser(request.user!, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,71 +62,13 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const all = Object.values(ids);
|
const all = Object.values(ids);
|
||||||
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
||||||
await deletePondsWhere(prisma, { ownerId: { in: all } });
|
await prisma.pond.deleteMany({ where: { ownerId: { in: all } } });
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
|
|
||||||
const username = `ua-created-${suffix}`;
|
|
||||||
const res = await api()
|
|
||||||
.post('/api/v1/admin/users')
|
|
||||||
.set('Cookie', cookies.admin1!)
|
|
||||||
.send({
|
|
||||||
username,
|
|
||||||
email: `${username}@example.org`,
|
|
||||||
displayName: 'UA Created',
|
|
||||||
password,
|
|
||||||
locale: 'de',
|
|
||||||
})
|
|
||||||
.expect(201);
|
|
||||||
const created = res.body as { id: string; status: string };
|
|
||||||
ids.created = created.id;
|
|
||||||
// No verification hop: the admin vouched for the address.
|
|
||||||
expect(created.status).toBe('ACTIVE');
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: username, password })
|
|
||||||
.expect(200);
|
|
||||||
// The personal pond exists exactly like after self-registration.
|
|
||||||
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects duplicate usernames with a field-level conflict', async () => {
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/users')
|
|
||||||
.set('Cookie', cookies.admin1!)
|
|
||||||
.send({
|
|
||||||
username: `ua-created-${suffix}`,
|
|
||||||
email: `ua-created-other-${suffix}@example.org`,
|
|
||||||
displayName: 'UA Dup',
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
})
|
|
||||||
.expect(409)
|
|
||||||
.expect((r) =>
|
|
||||||
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
|
|
||||||
'validation.taken',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses creation for non-admins', async () => {
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/users')
|
|
||||||
.set('Cookie', cookies.bob!)
|
|
||||||
.send({
|
|
||||||
username: `ua-sneak-${suffix}`,
|
|
||||||
email: `ua-sneak-${suffix}@example.org`,
|
|
||||||
displayName: 'UA Sneak',
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
})
|
|
||||||
.expect(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists and searches users (Site-Admin only)', async () => {
|
it('lists and searches users (Site-Admin only)', async () => {
|
||||||
const res = await api()
|
const res = await api()
|
||||||
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
AdminCreateUserInput,
|
|
||||||
AdminUserListQuery,
|
AdminUserListQuery,
|
||||||
AdminUserListView,
|
AdminUserListView,
|
||||||
AdminUserStatus,
|
AdminUserStatus,
|
||||||
@ -11,9 +10,7 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
import { PseudonymizationService } from './pseudonymization.service';
|
import { PseudonymizationService } from './pseudonymization.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,33 +27,12 @@ export class UserAdminService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly pseudonymizer: PseudonymizationService,
|
private readonly pseudonymizer: PseudonymizationService,
|
||||||
private readonly auth: AuthService,
|
private readonly auth: AuthService,
|
||||||
private readonly users: UsersService,
|
|
||||||
private readonly ponds: PondsService,
|
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(UserAdminService.name);
|
this.logger.setContext(UserAdminService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an account on behalf of a user (issue #331). The e-mail is
|
|
||||||
* marked verified immediately — the admin vouches for the address — and
|
|
||||||
* the personal pond is provisioned exactly like the verify-email path
|
|
||||||
* does, so the account is indistinguishable from a self-registered one.
|
|
||||||
*/
|
|
||||||
async createUser(actor: User, input: AdminCreateUserInput): Promise<AdminUserView> {
|
|
||||||
const user = await this.users.createUser(input);
|
|
||||||
const verified = await this.users.markEmailVerified(user.id);
|
|
||||||
await this.ponds.ensurePersonalPond(verified);
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'user.created_by_admin',
|
|
||||||
actorId: actor.id,
|
|
||||||
targetType: 'user',
|
|
||||||
targetId: user.id,
|
|
||||||
});
|
|
||||||
return this.viewOf(verified, await this.pondCountOf(user.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
|
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
|
||||||
const q = query.q?.trim();
|
const q = query.q?.trim();
|
||||||
const where: Prisma.UserWhereInput = q
|
const where: Prisma.UserWhereInput = q
|
||||||
@ -139,9 +115,7 @@ export class UserAdminService {
|
|||||||
if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin();
|
if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin();
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
// A manual toggle takes ownership of the flag: the IdP mapping
|
data: { isSiteAdmin: value },
|
||||||
// (#217) may only revoke what it itself set.
|
|
||||||
data: { isSiteAdmin: value, isSiteAdminManaged: false },
|
|
||||||
});
|
});
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
action: 'user.site_admin_set',
|
action: 'user.site_admin_set',
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_FILTER } from '@nestjs/core';
|
import { APP_FILTER } from '@nestjs/core';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BackupModule } from './backup/backup.module';
|
|
||||||
import { BrandingModule } from './branding/branding.module';
|
|
||||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||||
import { maskTokenParam } from './common/mask-token-param';
|
|
||||||
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
|
|
||||||
import { CommentsModule } from './comments/comments.module';
|
import { CommentsModule } from './comments/comments.module';
|
||||||
import { CompactionModule } from './compaction/compaction.module';
|
import { CompactionModule } from './compaction/compaction.module';
|
||||||
import { AppConfig } from './config/app-config.service';
|
import { AppConfig } from './config/app-config.service';
|
||||||
@ -17,24 +13,19 @@ import { ConfigModule } from './config/config.module';
|
|||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { GrantsModule } from './grants/grants.module';
|
import { GrantsModule } from './grants/grants.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { HomeModule } from './home/home.module';
|
|
||||||
import { FontsModule } from './fonts/fonts.module';
|
|
||||||
import { ImportExportModule } from './import-export/import-export.module';
|
import { ImportExportModule } from './import-export/import-export.module';
|
||||||
import { LabelsModule } from './labels/labels.module';
|
import { LabelsModule } from './labels/labels.module';
|
||||||
import { LegalModule } from './legal/legal.module';
|
import { LegalModule } from './legal/legal.module';
|
||||||
import { LinksModule } from './links/links.module';
|
import { LinksModule } from './links/links.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
import { McpModule } from './mcp/mcp.module';
|
|
||||||
import { MembersModule } from './members/members.module';
|
import { MembersModule } from './members/members.module';
|
||||||
import { PagesModule } from './pages/pages.module';
|
import { PagesModule } from './pages/pages.module';
|
||||||
import { PermissionsModule } from './permissions/permissions.module';
|
import { PermissionsModule } from './permissions/permissions.module';
|
||||||
import { PluginsModule } from './plugins/plugins.module';
|
import { PluginsModule } from './plugins/plugins.module';
|
||||||
import { PondsModule } from './ponds/ponds.module';
|
import { PondsModule } from './ponds/ponds.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { PublicApiModule } from './public-api/public-api.module';
|
|
||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
||||||
import { ReadTrailModule } from './read-trail/read-trail.module';
|
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
import { SetupModule } from './setup/setup.module';
|
import { SetupModule } from './setup/setup.module';
|
||||||
@ -42,7 +33,6 @@ import { TrashModule } from './trash/trash.module';
|
|||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { WatchesModule } from './watches/watches.module';
|
import { WatchesModule } from './watches/watches.module';
|
||||||
import { FavoritesModule } from './favorites/favorites.module';
|
|
||||||
import { VersionsModule } from './versions/versions.module';
|
import { VersionsModule } from './versions/versions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -50,16 +40,11 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
ConfigModule,
|
ConfigModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
ReadTrailModule,
|
|
||||||
RateLimitModule,
|
RateLimitModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
// Before SetupModule and AuthModule: global guards run in registration
|
// Before AuthModule: global guards run in registration order, and the
|
||||||
// order, and the maintenance gate (in-app restore, issue #103) must
|
// setup gate must win over AuthGuard's 401 while setup is pending.
|
||||||
// answer before anything touches the mid-restore database.
|
|
||||||
BackupModule,
|
|
||||||
// Before AuthModule: the setup gate must win over AuthGuard's 401 while
|
|
||||||
// setup is pending.
|
|
||||||
SetupModule,
|
SetupModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
@ -67,7 +52,6 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
PagesModule,
|
PagesModule,
|
||||||
CommentsModule,
|
CommentsModule,
|
||||||
WatchesModule,
|
WatchesModule,
|
||||||
FavoritesModule,
|
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
TrashModule,
|
TrashModule,
|
||||||
@ -75,16 +59,11 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
VersionsModule,
|
VersionsModule,
|
||||||
LabelsModule,
|
LabelsModule,
|
||||||
LegalModule,
|
LegalModule,
|
||||||
HomeModule,
|
|
||||||
LinksModule,
|
LinksModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
GrantsModule,
|
GrantsModule,
|
||||||
MembersModule,
|
MembersModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
PublicApiModule,
|
|
||||||
McpModule,
|
|
||||||
BrandingModule,
|
|
||||||
FontsModule,
|
|
||||||
ImportExportModule,
|
ImportExportModule,
|
||||||
PluginsModule,
|
PluginsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@ -99,11 +78,6 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
autoLogging: config.env.NODE_ENV !== 'test',
|
autoLogging: config.env.NODE_ENV !== 'test',
|
||||||
// Request bodies are never logged (operations.md logging rules).
|
// Request bodies are never logged (operations.md logging rules).
|
||||||
redact: { paths: ['req.headers.authorization', 'req.headers.cookie'], remove: true },
|
redact: { paths: ['req.headers.authorization', 'req.headers.cookie'], remove: true },
|
||||||
// Feed tokens travel as `?token=` (issue #191) — mask them so the
|
|
||||||
// request log never stores the credential.
|
|
||||||
serializers: {
|
|
||||||
req: (req: { url?: string }) => ({ ...req, url: maskTokenParam(req.url) }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@ -111,10 +85,4 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
],
|
],
|
||||||
providers: [{ provide: APP_FILTER, useClass: ApiExceptionFilter }],
|
providers: [{ provide: APP_FILTER, useClass: ApiExceptionFilter }],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule {}
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
|
||||||
// Module-level (not main.ts) so createTestApp boots the identical
|
|
||||||
// security-header/CORS middleware — see security-headers.middleware.ts.
|
|
||||||
consumer.apply(SecurityHeadersMiddleware).forRoutes('{*path}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* The audit event catalogue (issue #201): every action id the trail may
|
|
||||||
* carry, with the severity the stdout line is stamped with. This const is
|
|
||||||
* the CODE half of the published catalogue in
|
|
||||||
* `docs/architecture/audit-events.md` — `audit-catalogue.test.ts` fails
|
|
||||||
* whenever the two drift, so an id cannot be added, renamed, or removed
|
|
||||||
* without its documentation moving in the same commit.
|
|
||||||
*
|
|
||||||
* Compatibility promise (the reason this exists): ids are never repurposed.
|
|
||||||
* New events may be added (minor catalogue version); an id that stops being
|
|
||||||
* emitted is retired in the catalogue document, its meaning frozen forever —
|
|
||||||
* so an operator's SIEM rules survive our releases.
|
|
||||||
*/
|
|
||||||
export const AUDIT_EVENTS = {
|
|
||||||
'api.token_created': { severity: 'info' },
|
|
||||||
'api.token_revoked': { severity: 'info' },
|
|
||||||
'api.write': { severity: 'info' },
|
|
||||||
'audit.pruned': { severity: 'info' },
|
|
||||||
'auth.email_verified': { severity: 'info' },
|
|
||||||
'auth.identity_linked': { severity: 'notice' },
|
|
||||||
'auth.login_failed': { severity: 'warning' },
|
|
||||||
'auth.login_succeeded': { severity: 'info' },
|
|
||||||
'auth.password_reset': { severity: 'notice' },
|
|
||||||
'auth.proxy_rejected': { severity: 'warning' },
|
|
||||||
'auth.signup': { severity: 'info' },
|
|
||||||
'backup.restore_requested': { severity: 'warning' },
|
|
||||||
'backup.run_triggered': { severity: 'info' },
|
|
||||||
'backup.settings_changed': { severity: 'notice' },
|
|
||||||
'file.integrity_failed': { severity: 'critical' },
|
|
||||||
'grant.created': { severity: 'notice' },
|
|
||||||
'grant.deleted': { severity: 'notice' },
|
|
||||||
'invitation.accepted': { severity: 'notice' },
|
|
||||||
'invitation.created': { severity: 'info' },
|
|
||||||
'invitation.revoked': { severity: 'info' },
|
|
||||||
'job.triggered': { severity: 'info' },
|
|
||||||
'member.added': { severity: 'notice' },
|
|
||||||
'member.removed': { severity: 'notice' },
|
|
||||||
'member.role_changed': { severity: 'notice' },
|
|
||||||
'page.classification_lowered': { severity: 'warning' },
|
|
||||||
'page.classification_raised': { severity: 'notice' },
|
|
||||||
'plugin.installed': { severity: 'notice' },
|
|
||||||
'plugin.rejected': { severity: 'warning' },
|
|
||||||
'plugin.mode_set': { severity: 'notice' },
|
|
||||||
'plugin.pond_toggled': { severity: 'info' },
|
|
||||||
'plugin.uninstalled': { severity: 'notice' },
|
|
||||||
'pond.archived': { severity: 'notice' },
|
|
||||||
'pond.purged': { severity: 'notice' },
|
|
||||||
'quota.override_cleared': { severity: 'notice' },
|
|
||||||
'quota.override_set': { severity: 'notice' },
|
|
||||||
'read_trail.pruned': { severity: 'info' },
|
|
||||||
'settings.changed': { severity: 'notice' },
|
|
||||||
'branding.changed': { severity: 'notice' },
|
|
||||||
'font.uploaded': { severity: 'notice' },
|
|
||||||
'font.deleted': { severity: 'notice' },
|
|
||||||
'setup.admin_created': { severity: 'notice' },
|
|
||||||
'setup.completed': { severity: 'info' },
|
|
||||||
'setup.preseeded': { severity: 'info' },
|
|
||||||
'setup.smtp_stored': { severity: 'info' },
|
|
||||||
'user.created_by_admin': { severity: 'notice' },
|
|
||||||
'user.deleted': { severity: 'notice' },
|
|
||||||
'user.disabled_set': { severity: 'notice' },
|
|
||||||
'user.pseudonymized': { severity: 'notice' },
|
|
||||||
'user.site_admin_set': { severity: 'notice' },
|
|
||||||
'user.verification_resent': { severity: 'info' },
|
|
||||||
} as const satisfies Record<string, { severity: AuditSeverity }>;
|
|
||||||
|
|
||||||
/** Severity vocabulary of the catalogue — syslog-inspired, four levels are
|
|
||||||
* enough for rule routing (critical pages someone, warning feeds detection,
|
|
||||||
* notice is configuration drift, info is lifecycle noise). */
|
|
||||||
export type AuditSeverity = 'info' | 'notice' | 'warning' | 'critical';
|
|
||||||
|
|
||||||
/** A catalogued action id — the ONLY thing {@link AuditService.record}
|
|
||||||
* accepts, so an uncatalogued event cannot be emitted (compile-time), and
|
|
||||||
* the doc fence keeps the catalogue document in step (test-time). */
|
|
||||||
export type AuditAction = keyof typeof AUDIT_EVENTS;
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { AUDIT_EVENTS } from './audit-actions';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fence that keeps the published audit catalogue and the code together
|
|
||||||
* (issue #201): every id in `AUDIT_EVENTS` must appear as an event row in
|
|
||||||
* `docs/architecture/audit-events.md` with the same severity, and the
|
|
||||||
* document may not describe ids the code does not know. Emission of an
|
|
||||||
* uncatalogued id is already a TYPE error (AuditAction union) — this test
|
|
||||||
* covers the half the compiler cannot see: the document.
|
|
||||||
*/
|
|
||||||
// __dirname, not import.meta: the api package compiles CJS (tsconfig has no
|
|
||||||
// nodenext module), and vitest resolves both — the compiler only the former.
|
|
||||||
const doc = readFileSync(join(__dirname, '../../../../docs/architecture/audit-events.md'), 'utf8');
|
|
||||||
|
|
||||||
/** Event rows are `| \`ns.event\` | trigger | severity | …` — the dot in the
|
|
||||||
* id keeps field-set rows (`msg`, `severity`, …) out of the match. The
|
|
||||||
* namespace may carry an underscore since `read_trail.*` (issue #224). */
|
|
||||||
function documentedEvents(): Map<string, string> {
|
|
||||||
const events = new Map<string, string>();
|
|
||||||
for (const line of doc.split('\n')) {
|
|
||||||
const id = /^\| `([a-z_]+\.[a-z_]+)` +\|/.exec(line)?.[1];
|
|
||||||
if (!id) continue;
|
|
||||||
const cells = line.split('|').map((cell) => cell.trim());
|
|
||||||
// cells[0] is the empty string before the leading pipe.
|
|
||||||
events.set(id, cells[3] ?? '');
|
|
||||||
}
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('audit catalogue fence (issue #201)', () => {
|
|
||||||
it('documents exactly the ids the code can emit', () => {
|
|
||||||
const documented = documentedEvents();
|
|
||||||
const inCode = Object.keys(AUDIT_EVENTS).sort();
|
|
||||||
expect([...documented.keys()].sort()).toEqual(inCode);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('documents each id with the severity the code stamps', () => {
|
|
||||||
const documented = documentedEvents();
|
|
||||||
for (const [action, { severity }] of Object.entries(AUDIT_EVENTS)) {
|
|
||||||
expect(`${action}: ${documented.get(action)}`).toBe(`${action}: ${severity}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { AuditRetentionService } from './audit-retention.service';
|
|
||||||
|
|
||||||
const DAY = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audit-trail retention (issue #196): entries past `audit.retentionDays`
|
|
||||||
* are pruned, newer ones stay, and the pruning itself lands in the trail
|
|
||||||
* (`audit.pruned` with count and cutoff) so the gap is explainable.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('audit retention (e2e, issue #196)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const marker = `retention-${suffix}`;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
// A short period so ages are unambiguous; written straight to the row
|
|
||||||
// BEFORE the app boots (the settings cache is in-process and fills on
|
|
||||||
// first read). The key is cleaned afterAll.
|
|
||||||
await prisma.instanceSetting.upsert({
|
|
||||||
where: { key: 'audit.retentionDays' },
|
|
||||||
create: { key: 'audit.retentionDays', value: 30 },
|
|
||||||
update: { value: 30 },
|
|
||||||
});
|
|
||||||
app = await createTestApp();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.instanceSetting.deleteMany({ where: { key: 'audit.retentionDays' } });
|
|
||||||
await prisma.auditEntry.deleteMany({
|
|
||||||
where: { OR: [{ targetId: { contains: suffix } }, { action: 'audit.pruned' }] },
|
|
||||||
});
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prunes entries past the period, keeps newer ones, and records the pruning', async () => {
|
|
||||||
await prisma.auditEntry.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
action: 'test.old',
|
|
||||||
targetType: 'test',
|
|
||||||
targetId: marker,
|
|
||||||
at: new Date(Date.now() - 40 * DAY),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'test.older',
|
|
||||||
targetType: 'test',
|
|
||||||
targetId: marker,
|
|
||||||
at: new Date(Date.now() - 400 * DAY),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'test.fresh',
|
|
||||||
targetType: 'test',
|
|
||||||
targetId: marker,
|
|
||||||
at: new Date(Date.now() - 5 * DAY),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const pruned = await app.get(AuditRetentionService).pruneExpired();
|
|
||||||
expect(pruned).toBeGreaterThanOrEqual(2);
|
|
||||||
|
|
||||||
const remaining = await prisma.auditEntry.findMany({ where: { targetId: marker } });
|
|
||||||
expect(remaining.map((entry) => entry.action)).toEqual(['test.fresh']);
|
|
||||||
|
|
||||||
// The gap is explainable: the pruning run is itself on the trail.
|
|
||||||
const prunedEvent = await prisma.auditEntry.findFirst({
|
|
||||||
where: { action: 'audit.pruned' },
|
|
||||||
orderBy: { at: 'desc' },
|
|
||||||
});
|
|
||||||
expect(prunedEvent).not.toBeNull();
|
|
||||||
expect(prunedEvent!.details).toMatchObject({ retentionDays: 30 });
|
|
||||||
expect((prunedEvent!.details as { count: number }).count).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is a no-op when nothing is due', async () => {
|
|
||||||
const pruned = await app.get(AuditRetentionService).pruneExpired();
|
|
||||||
expect(pruned).toBe(0);
|
|
||||||
// The fresh marker entry from the first test is untouched.
|
|
||||||
expect(await prisma.auditEntry.count({ where: { targetId: marker } })).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
|
||||||
|
|
||||||
import { ClockService } from '../common/clock.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
||||||
|
|
||||||
import { AuditService } from './audit.service';
|
|
||||||
|
|
||||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audit-trail retention (issue #196): the daily job deletes `audit_log`
|
|
||||||
* entries older than the configurable `audit.retentionDays` (default one
|
|
||||||
* year) and records the deletion itself (`audit.pruned` with count and
|
|
||||||
* cutoff) so a gap in the trail is always explainable. Separate from
|
|
||||||
* {@link AuditService} because the settings service audits its own writes
|
|
||||||
* — folding retention into AuditService would close a constructor cycle.
|
|
||||||
* The read-access trail (#224) is deliberately not covered here.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class AuditRetentionService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly settings: InstanceSettingsService,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
private readonly clock: ClockService,
|
|
||||||
private readonly logger: PinoLogger,
|
|
||||||
) {
|
|
||||||
this.logger.setContext(AuditRetentionService.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async pruneExpired(): Promise<number> {
|
|
||||||
const retentionDays = await this.settings.get('audit.retentionDays');
|
|
||||||
const cutoff = new Date(this.clock.now().getTime() - retentionDays * MS_PER_DAY);
|
|
||||||
const { count } = await this.prisma.auditEntry.deleteMany({
|
|
||||||
where: { at: { lt: cutoff } },
|
|
||||||
});
|
|
||||||
if (count > 0) {
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'audit.pruned',
|
|
||||||
details: { count, cutoff: cutoff.toISOString(), retentionDays },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +1,7 @@
|
|||||||
import { Global, Module, OnModuleInit } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CommonModule } from '../common/common.module';
|
|
||||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
|
||||||
import { SchedulerService } from '../scheduler/scheduler.service';
|
|
||||||
import { SettingsModule } from '../settings/settings.module';
|
|
||||||
|
|
||||||
import { AuditRetentionService } from './audit-retention.service';
|
|
||||||
import { AuditService } from './audit.service';
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
/** Daily, per operations.md's maintenance-jobs table (issue #196). */
|
|
||||||
const AUDIT_RETENTION_CADENCE_SECONDS = 24 * 60 * 60;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global because the audit trail cuts across nearly every feature module
|
* Global because the audit trail cuts across nearly every feature module
|
||||||
* (auth, grants, members, admin, plugins, setup) — like PrismaModule, one
|
* (auth, grants, members, admin, plugins, setup) — like PrismaModule, one
|
||||||
@ -18,23 +9,7 @@ const AUDIT_RETENTION_CADENCE_SECONDS = 24 * 60 * 60;
|
|||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CommonModule, SchedulerModule, SettingsModule],
|
providers: [AuditService],
|
||||||
providers: [AuditService, AuditRetentionService],
|
|
||||||
exports: [AuditService],
|
exports: [AuditService],
|
||||||
})
|
})
|
||||||
export class AuditModule implements OnModuleInit {
|
export class AuditModule {}
|
||||||
constructor(
|
|
||||||
private readonly scheduler: SchedulerService,
|
|
||||||
private readonly retention: AuditRetentionService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
onModuleInit(): void {
|
|
||||||
this.scheduler.register({
|
|
||||||
name: 'audit-retention',
|
|
||||||
cadenceSeconds: AUDIT_RETENTION_CADENCE_SECONDS,
|
|
||||||
run: async () => {
|
|
||||||
await this.retention.pruneExpired();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,13 +4,9 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
import { AUDIT_EVENTS, AuditAction } from './audit-actions';
|
|
||||||
|
|
||||||
export interface AuditEvent {
|
export interface AuditEvent {
|
||||||
/** Stable dot-namespaced id from the catalogue (issue #201,
|
/** Stable dot-namespaced id, e.g. `grant.created` — the UI translates it. */
|
||||||
* docs/architecture/audit-events.md) — the UI translates it, SIEM rules
|
action: string;
|
||||||
* key on it. The union makes an uncatalogued emission a type error. */
|
|
||||||
action: AuditAction;
|
|
||||||
/** The acting user; null/undefined for anonymous events. */
|
/** The acting user; null/undefined for anonymous events. */
|
||||||
actorId?: string | null;
|
actorId?: string | null;
|
||||||
targetType?: string;
|
targetType?: string;
|
||||||
@ -41,15 +37,7 @@ export class AuditService {
|
|||||||
async record(event: AuditEvent): Promise<void> {
|
async record(event: AuditEvent): Promise<void> {
|
||||||
const { action, actorId, targetType, targetId, details } = event;
|
const { action, actorId, targetType, targetId, details } = event;
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
// `severity` is the catalogue's routing hint for SIEM rules (#201) —
|
{ actor: actorId ?? null, targetType, targetId, ...details },
|
||||||
// pino's own `level` stays 30/info so log transport is unaffected.
|
|
||||||
{
|
|
||||||
severity: AUDIT_EVENTS[action].severity,
|
|
||||||
actor: actorId ?? null,
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
...details,
|
|
||||||
},
|
|
||||||
`audit: ${action}`,
|
`audit: ${action}`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
AuthMethodsView,
|
|
||||||
CurrentUser as CurrentUserShape,
|
CurrentUser as CurrentUserShape,
|
||||||
LoginInput,
|
LoginInput,
|
||||||
SignupInput,
|
SignupInput,
|
||||||
@ -21,15 +20,13 @@ import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|||||||
import { SetupExempt } from '../setup/setup.guard';
|
import { SetupExempt } from '../setup/setup.guard';
|
||||||
import {
|
import {
|
||||||
AuthedRequest,
|
AuthedRequest,
|
||||||
LocalCredentialFlow,
|
|
||||||
Public,
|
Public,
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
toCurrentUser,
|
toCurrentUser,
|
||||||
} from './auth.guard';
|
} from './auth.guard';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { OidcService } from './oidc.service';
|
import { SessionsService } from './sessions.service';
|
||||||
import { SessionsService, sessionAbsoluteMs } from './sessions.service';
|
|
||||||
|
|
||||||
@AuthenticatedOnly() // routes reachable without a session opt out via @Public
|
@AuthenticatedOnly() // routes reachable without a session opt out via @Public
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ -39,7 +36,6 @@ export class AuthController {
|
|||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly oidc: OidcService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Public: the SPA hides the signup route while registration is closed. */
|
/** Public: the SPA hides the signup route while registration is closed. */
|
||||||
@ -49,21 +45,8 @@ export class AuthController {
|
|||||||
return { mode: await this.settings.get('auth.registrationMode') };
|
return { mode: await this.settings.get('auth.registrationMode') };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public: what the login screen offers (issue #214) — the local form
|
|
||||||
* and/or the deploy-configured OIDC provider. */
|
|
||||||
@SetupExempt()
|
|
||||||
@Public()
|
|
||||||
@Get('methods')
|
|
||||||
methods(): AuthMethodsView {
|
|
||||||
return {
|
|
||||||
local: this.config.env.AUTH_LOCAL_ENABLED,
|
|
||||||
oidc: this.oidc.enabled ? { label: this.oidc.providerLabel } : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('signup')
|
@Post('signup')
|
||||||
@LocalCredentialFlow()
|
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
@RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 })
|
@RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 })
|
||||||
async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> {
|
async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> {
|
||||||
@ -72,7 +55,6 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('verify-email')
|
@Post('verify-email')
|
||||||
@LocalCredentialFlow()
|
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 })
|
@RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 })
|
||||||
async verifyEmail(
|
async verifyEmail(
|
||||||
@ -83,7 +65,6 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('resend-verification')
|
@Post('resend-verification')
|
||||||
@LocalCredentialFlow()
|
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 })
|
@RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 })
|
||||||
async resendVerification(
|
async resendVerification(
|
||||||
@ -97,7 +78,6 @@ export class AuthController {
|
|||||||
@SetupExempt()
|
@SetupExempt()
|
||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@LocalCredentialFlow()
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })
|
@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })
|
||||||
async login(
|
async login(
|
||||||
@ -110,12 +90,7 @@ export class AuthController {
|
|||||||
input.password,
|
input.password,
|
||||||
request.headers['user-agent'],
|
request.headers['user-agent'],
|
||||||
);
|
);
|
||||||
setSessionCookie(
|
setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production');
|
||||||
response,
|
|
||||||
sessionToken,
|
|
||||||
this.config.env.NODE_ENV === 'production',
|
|
||||||
sessionAbsoluteMs(this.config.env),
|
|
||||||
);
|
|
||||||
return toCurrentUser(user);
|
return toCurrentUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +116,6 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('forgot-password')
|
@Post('forgot-password')
|
||||||
@LocalCredentialFlow()
|
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 })
|
@RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 })
|
||||||
async forgotPassword(
|
async forgotPassword(
|
||||||
@ -152,7 +126,6 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('reset-password')
|
@Post('reset-password')
|
||||||
@LocalCredentialFlow()
|
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
|
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
|
||||||
async resetPassword(
|
async resetPassword(
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import request from 'supertest';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
|
||||||
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@ -46,7 +46,7 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Verified users own a personal pond (#21) — remove it before them.
|
// Verified users own a personal pond (#21) — remove it before them.
|
||||||
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
|
||||||
SetMetadata,
|
SetMetadata,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
createParamDecorator,
|
createParamDecorator,
|
||||||
@ -14,7 +13,6 @@ import type { User } from '@prisma/client';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { ProxyIdentityService } from './proxy-identity.service';
|
|
||||||
import { SessionsService } from './sessions.service';
|
import { SessionsService } from './sessions.service';
|
||||||
|
|
||||||
export const SESSION_COOKIE = 'dt_session';
|
export const SESSION_COOKIE = 'dt_session';
|
||||||
@ -23,18 +21,6 @@ const IS_PUBLIC_KEY = 'isPublic';
|
|||||||
/** Marks a route as reachable without a session (login, signup, healthz…). */
|
/** Marks a route as reachable without a session (login, signup, healthz…). */
|
||||||
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
|
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
||||||
export const LOCAL_CREDENTIAL_KEY = 'isLocalCredentialFlow';
|
|
||||||
/**
|
|
||||||
* Marks a route as part of the LOCAL credential machinery (issue #216,
|
|
||||||
* ADR 0021): password login, signup, e-mail verification, password
|
|
||||||
* forgot/reset/change. With `AUTH_LOCAL_ENABLED=false` every marked route
|
|
||||||
* answers 404 (existence hidden, the switch precedent) — and the
|
|
||||||
* enumeration fence in `local-auth-switch.e2e.db.test.ts` fails when an
|
|
||||||
* auth route is neither marked nor on its reviewed allowlist, so a new
|
|
||||||
* credential flow cannot ship unswitched by accident.
|
|
||||||
*/
|
|
||||||
export const LocalCredentialFlow = (): MethodDecorator => SetMetadata(LOCAL_CREDENTIAL_KEY, true);
|
|
||||||
|
|
||||||
export interface AuthedRequest extends Request {
|
export interface AuthedRequest extends Request {
|
||||||
user?: User;
|
user?: User;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@ -63,23 +49,13 @@ export function toCurrentUser(user: User): CurrentUserShape {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Session cookie contract shared by login and the setup wizard (issue #80). */
|
||||||
* Session cookie contract shared by login and the setup wizard (issue #80).
|
export function setSessionCookie(response: Response, token: string, production: boolean): void {
|
||||||
* `maxAgeMs` follows the configured absolute session bound (#190) — the
|
|
||||||
* server-side idle/absolute checks are authoritative, the cookie merely
|
|
||||||
* stops outliving them.
|
|
||||||
*/
|
|
||||||
export function setSessionCookie(
|
|
||||||
response: Response,
|
|
||||||
token: string,
|
|
||||||
production: boolean,
|
|
||||||
maxAgeMs: number,
|
|
||||||
): void {
|
|
||||||
response.cookie(SESSION_COOKIE, token, {
|
response.cookie(SESSION_COOKIE, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: production,
|
secure: production,
|
||||||
maxAge: maxAgeMs,
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -98,38 +74,19 @@ export class AuthGuard implements CanActivate {
|
|||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly proxyIdentity: ProxyIdentityService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<AuthedRequest>();
|
const request = context.switchToHttp().getRequest<AuthedRequest>();
|
||||||
|
|
||||||
// The hard local-auth switch (issue #216): marked credential routes
|
|
||||||
// disappear entirely — before any session or CSRF logic runs.
|
|
||||||
if (!this.config.env.AUTH_LOCAL_ENABLED) {
|
|
||||||
const isLocalFlow = this.reflector.getAllAndOverride<boolean>(LOCAL_CREDENTIAL_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
if (isLocalFlow) throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawToken = (request.cookies as Record<string, string> | undefined)?.[SESSION_COOKIE];
|
const rawToken = (request.cookies as Record<string, string> | undefined)?.[SESSION_COOKIE];
|
||||||
|
|
||||||
if (rawToken && MUTATING_METHODS.has(request.method)) {
|
if (rawToken && MUTATING_METHODS.has(request.method)) {
|
||||||
this.assertSameOrigin(request);
|
this.assertSameOrigin(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trusted-proxy identity first (issue #215): when the perimeter
|
|
||||||
// authenticates, its header IS the identity for this request — a
|
|
||||||
// session cookie riding along never escalates beyond it, and an
|
|
||||||
// untrusted peer carrying the header is rejected inside resolve().
|
|
||||||
const proxyUser = await this.proxyIdentity.resolve(request);
|
|
||||||
if (proxyUser) {
|
|
||||||
request.user = proxyUser;
|
|
||||||
} else if (rawToken) {
|
|
||||||
// Attach the user whenever the cookie is valid — public routes may
|
// Attach the user whenever the cookie is valid — public routes may
|
||||||
// still want to know who is asking.
|
// still want to know who is asking.
|
||||||
|
if (rawToken) {
|
||||||
const validated = await this.sessions.validate(rawToken);
|
const validated = await this.sessions.validate(rawToken);
|
||||||
if (validated) {
|
if (validated) {
|
||||||
request.user = validated.user;
|
request.user = validated.user;
|
||||||
@ -148,27 +105,13 @@ export class AuthGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fail closed (#189): a cookie-carrying mutation must prove its origin —
|
|
||||||
* browsers always send `Origin` on cross- and same-origin mutations, so a
|
|
||||||
* missing header means "not a browser page of ours" and is rejected like a
|
|
||||||
* mismatch. Non-browser clients (curl, scripts) either send a matching
|
|
||||||
* `Origin` explicitly or authenticate with a PAT/bearer token and no
|
|
||||||
* cookie, which never reaches this check — the exception for them is
|
|
||||||
* structural (bound to the cookie), never a header loophole.
|
|
||||||
*/
|
|
||||||
private assertSameOrigin(request: Request): void {
|
private assertSameOrigin(request: Request): void {
|
||||||
const origin = request.headers.origin ?? request.headers.referer;
|
const origin = request.headers.origin ?? request.headers.referer;
|
||||||
if (!origin) throw new ForbiddenException({ code: 'csrf_origin_mismatch' });
|
// Non-browser clients (curl, supertest) send neither header; SameSite
|
||||||
|
// cookies already stop cross-site browser requests without Origin.
|
||||||
|
if (!origin) return;
|
||||||
const expected = new URL(this.config.env.APP_BASE_URL).origin;
|
const expected = new URL(this.config.env.APP_BASE_URL).origin;
|
||||||
let actual: string;
|
if (new URL(origin).origin !== expected) {
|
||||||
try {
|
|
||||||
actual = new URL(origin).origin;
|
|
||||||
} catch {
|
|
||||||
// An unparsable Origin/Referer is a broken or hostile client, not ours.
|
|
||||||
throw new ForbiddenException({ code: 'csrf_origin_mismatch' });
|
|
||||||
}
|
|
||||||
if (actual !== expected) {
|
|
||||||
throw new ForbiddenException({ code: 'csrf_origin_mismatch' });
|
throw new ForbiddenException({ code: 'csrf_origin_mismatch' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
import { GrantsModule } from '../grants/grants.module';
|
|
||||||
import { InvitationsModule } from '../invitations/invitations.module';
|
|
||||||
|
|
||||||
import { MailModule } from '../mail/mail.module';
|
import { MailModule } from '../mail/mail.module';
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
@ -12,42 +8,18 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthGuard } from './auth.guard';
|
import { AuthGuard } from './auth.guard';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthTokensService } from './auth-tokens.service';
|
import { AuthTokensService } from './auth-tokens.service';
|
||||||
import { ClaimMappingService } from './claim-mapping.service';
|
|
||||||
import { OidcController } from './oidc.controller';
|
|
||||||
import { OidcService } from './oidc.service';
|
|
||||||
import { ProxyIdentityService } from './proxy-identity.service';
|
|
||||||
import { SessionsModule } from './sessions.module';
|
import { SessionsModule } from './sessions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
|
imports: [UsersModule, MailModule, SessionsModule, PondsModule],
|
||||||
controllers: [AuthController, OidcController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
AuthTokensService,
|
AuthTokensService,
|
||||||
ClaimMappingService,
|
|
||||||
OidcService,
|
|
||||||
ProxyIdentityService,
|
|
||||||
// Global default-protected: every route needs a session unless it
|
// Global default-protected: every route needs a session unless it
|
||||||
// opts out with @Public().
|
// opts out with @Public().
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
],
|
],
|
||||||
exports: [AuthTokensService, AuthService, OidcService],
|
exports: [AuthTokensService, AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule implements OnModuleInit {
|
export class AuthModule {}
|
||||||
constructor(
|
|
||||||
private readonly config: AppConfig,
|
|
||||||
private readonly oidc: OidcService,
|
|
||||||
private readonly proxyIdentity: ProxyIdentityService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
onModuleInit(): void {
|
|
||||||
// #216: local auth off without ANY external path means nobody can ever
|
|
||||||
// sign in — loudly stated at boot, because the operator will otherwise
|
|
||||||
// discover it at the login screen.
|
|
||||||
if (!this.config.env.AUTH_LOCAL_ENABLED && !this.oidc.enabled && !this.proxyIdentity.enabled) {
|
|
||||||
new Logger(AuthModule.name).warn(
|
|
||||||
'AUTH_LOCAL_ENABLED=false with neither OIDC nor proxy authentication configured — no sign-in path exists',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { User } from '@prisma/client';
|
|||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { InvitationsService } from '../invitations/invitations.service';
|
|
||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
@ -34,7 +33,6 @@ export class AuthService {
|
|||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
private readonly mail: MailService,
|
private readonly mail: MailService,
|
||||||
private readonly ponds: PondsService,
|
private readonly ponds: PondsService,
|
||||||
private readonly invitations: InvitationsService,
|
|
||||||
private readonly rateLimits: RateLimitService,
|
private readonly rateLimits: RateLimitService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
@ -45,37 +43,10 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async signup(input: SignupInput): Promise<void> {
|
async signup(input: SignupInput): Promise<void> {
|
||||||
// An invitation token (issue #332) lets exactly one signup through a
|
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
|
||||||
// closed registration. Claimed atomically BEFORE the account exists;
|
|
||||||
// rolled back if the signup fails (duplicate username), so the invitee
|
|
||||||
// can retry with the same link.
|
|
||||||
const invitation = input.invitationToken
|
|
||||||
? await this.invitations.redeem(input.invitationToken)
|
|
||||||
: null;
|
|
||||||
if (input.invitationToken && !invitation) {
|
|
||||||
throw new BadRequestException({ code: 'token_invalid' });
|
|
||||||
}
|
|
||||||
if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') {
|
|
||||||
throw new ForbiddenException({ code: 'registration_closed' });
|
throw new ForbiddenException({ code: 'registration_closed' });
|
||||||
}
|
}
|
||||||
let user: User;
|
const user = await this.users.createUser(input);
|
||||||
try {
|
|
||||||
user = await this.users.createUser(input);
|
|
||||||
} catch (error) {
|
|
||||||
if (invitation) await this.invitations.unredeem(invitation.id);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (invitation) {
|
|
||||||
await this.invitations.markAccepted(invitation.id, user.id);
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'invitation.accepted',
|
|
||||||
actorId: user.id,
|
|
||||||
targetType: 'invitation',
|
|
||||||
targetId: invitation.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The invite link proves nothing about the mailbox (it can be
|
|
||||||
// forwarded), so the usual verification mail still applies.
|
|
||||||
await this.sendVerificationMail(user);
|
await this.sendVerificationMail(user);
|
||||||
await this.audit.record({ action: 'auth.signup', actorId: user.id });
|
await this.audit.record({ action: 'auth.signup', actorId: user.id });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,272 +0,0 @@
|
|||||||
import { createServer, type Server } from 'node:http';
|
|
||||||
import type { AddressInfo } from 'node:net';
|
|
||||||
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from 'jose';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
|
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IdP claim mapping (issue #217, ADR 0021): declarative `idpMapping.rules`
|
|
||||||
* turn ID-token claims into pond roles and the site-admin flag on every
|
|
||||||
* OIDC login — through the same grant-service path as manual grants (the
|
|
||||||
* collab revocation notify is asserted), with removal on the next login,
|
|
||||||
* "manual wins" precedence, and audited changes.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('idp claim mapping (e2e, issue #217)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let idp: Server;
|
|
||||||
let issuer: string;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
|
|
||||||
let signingKey: CryptoKey;
|
|
||||||
let publicJwk: Record<string, unknown>;
|
|
||||||
let nextClaims: (nonce: string) => JWTPayload;
|
|
||||||
let currentNonce = '';
|
|
||||||
|
|
||||||
let adminId: string;
|
|
||||||
let pondId: string;
|
|
||||||
const pondSlug = `mapped-${suffix}`;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
async function loginViaIdp(): Promise<string> {
|
|
||||||
const begin = await api().get('/api/v1/auth/oidc/login').expect(302);
|
|
||||||
const url = new URL(begin.headers.location!);
|
|
||||||
currentNonce = url.searchParams.get('nonce')!;
|
|
||||||
const stateCookie = (begin.headers['set-cookie'] as unknown as string[])
|
|
||||||
.find((c) => c.startsWith('dt_oidc='))!
|
|
||||||
.split(';')[0]!;
|
|
||||||
const res = await api()
|
|
||||||
.get(
|
|
||||||
`/api/v1/auth/oidc/callback?code=fake&state=${encodeURIComponent(
|
|
||||||
url.searchParams.get('state')!,
|
|
||||||
)}`,
|
|
||||||
)
|
|
||||||
.set('Cookie', stateCookie)
|
|
||||||
.expect(302);
|
|
||||||
expect(res.headers.location!).toMatch(/\/$/);
|
|
||||||
return sessionCookieOf(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
function subjectClaims(groups: string[]): (nonce: string) => JWTPayload {
|
|
||||||
return (nonce) => ({
|
|
||||||
iss: issuer,
|
|
||||||
aud: 'dorfteich-map',
|
|
||||||
sub: `mapped-${suffix}`,
|
|
||||||
nonce,
|
|
||||||
email: `mapped-${suffix}@idp.example`,
|
|
||||||
email_verified: true,
|
|
||||||
preferred_username: `mapped-${suffix}`,
|
|
||||||
groups,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
let signingPublic: CryptoKey;
|
|
||||||
({ privateKey: signingKey, publicKey: signingPublic } = await generateKeyPair('RS256', {
|
|
||||||
extractable: true,
|
|
||||||
}));
|
|
||||||
publicJwk = { ...(await exportJWK(signingPublic)), kid: 'map-key', alg: 'RS256' };
|
|
||||||
|
|
||||||
idp = createServer((req, res) => {
|
|
||||||
void (async () => {
|
|
||||||
res.setHeader('content-type', 'application/json');
|
|
||||||
if (req.url === '/.well-known/openid-configuration') {
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
issuer,
|
|
||||||
authorization_endpoint: `${issuer}/authorize`,
|
|
||||||
token_endpoint: `${issuer}/token`,
|
|
||||||
jwks_uri: `${issuer}/jwks`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (req.url === '/jwks') {
|
|
||||||
res.end(JSON.stringify({ keys: [publicJwk] }));
|
|
||||||
} else if (req.url === '/token') {
|
|
||||||
req.resume();
|
|
||||||
req.on('end', () => {
|
|
||||||
void (async () => {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const idToken = await new SignJWT({ ...nextClaims(currentNonce) })
|
|
||||||
.setProtectedHeader({ alg: 'RS256', kid: 'map-key' })
|
|
||||||
.setIssuedAt(now)
|
|
||||||
.setExpirationTime(now + 300)
|
|
||||||
.sign(signingKey);
|
|
||||||
res.end(JSON.stringify({ id_token: idToken }));
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => idp.listen(0, '127.0.0.1', resolve));
|
|
||||||
issuer = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`;
|
|
||||||
|
|
||||||
process.env.OIDC_ISSUER = issuer;
|
|
||||||
process.env.OIDC_CLIENT_ID = 'dorfteich-map';
|
|
||||||
app = await createTestApp();
|
|
||||||
|
|
||||||
// A pond to map into, owned by an admin user (created via the service,
|
|
||||||
// grants via prisma BEFORE the first permission query — test-db rule).
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const admin = await users.createUser({
|
|
||||||
username: `map-admin-${suffix}`,
|
|
||||||
email: `map-admin-${suffix}@example.test`,
|
|
||||||
displayName: 'Map Admin',
|
|
||||||
password: 'mapping admin 123',
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await users.markEmailVerified(admin.id);
|
|
||||||
adminId = admin.id;
|
|
||||||
const pond = await prisma.pond.create({
|
|
||||||
data: { slug: pondSlug, name: 'Mapped Pond', type: 'SHARED', ownerId: adminId },
|
|
||||||
});
|
|
||||||
pondId = pond.id;
|
|
||||||
await prisma.roleGrant.create({
|
|
||||||
data: {
|
|
||||||
pondId,
|
|
||||||
subjectType: 'USER',
|
|
||||||
subjectId: adminId,
|
|
||||||
role: 'POND_ADMIN',
|
|
||||||
scopeType: 'POND',
|
|
||||||
scopeId: null,
|
|
||||||
effect: 'ALLOW',
|
|
||||||
createdBy: adminId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.get(InstanceSettingsService).set(
|
|
||||||
'idpMapping.rules',
|
|
||||||
[
|
|
||||||
{ claim: 'groups', value: 'wiki-editors', role: 'editor', pondSlug },
|
|
||||||
{ claim: 'groups', value: 'wiki-admins', role: 'site_admin' },
|
|
||||||
],
|
|
||||||
adminId,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.OIDC_ISSUER;
|
|
||||||
delete process.env.OIDC_CLIENT_ID;
|
|
||||||
await new Promise<void>((resolve) => idp.close(() => resolve()));
|
|
||||||
await prisma.instanceSetting.deleteMany({ where: { key: 'idpMapping.rules' } });
|
|
||||||
await prisma.userIdentity.deleteMany({ where: { provider: `oidc:${issuer}` } });
|
|
||||||
await prisma.roleGrant.deleteMany({ where: { pondId } });
|
|
||||||
await prisma.page.deleteMany({
|
|
||||||
where: { pond: { owner: { username: { contains: suffix } } } },
|
|
||||||
});
|
|
||||||
await prisma.roleGrant.deleteMany({
|
|
||||||
where: { pond: { owner: { username: { contains: suffix } } } },
|
|
||||||
});
|
|
||||||
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('grants the mapped pond role on login and access actually works', async () => {
|
|
||||||
nextClaims = subjectClaims(['wiki-editors']);
|
|
||||||
const session = await loginViaIdp();
|
|
||||||
|
|
||||||
const grant = await prisma.roleGrant.findFirst({
|
|
||||||
where: { pondId, subjectType: 'USER', origin: 'idp' },
|
|
||||||
});
|
|
||||||
expect(grant).toMatchObject({ role: 'EDITOR', effect: 'ALLOW' });
|
|
||||||
|
|
||||||
// The permission model actually honours it (no raw-row bypass).
|
|
||||||
const pages = await api()
|
|
||||||
.get(`/api/v1/ponds/${pondId}/pages`)
|
|
||||||
.set('Cookie', session)
|
|
||||||
.expect(200);
|
|
||||||
expect(Array.isArray(pages.body)).toBe(true);
|
|
||||||
|
|
||||||
const audit = await prisma.auditEntry.findFirst({
|
|
||||||
where: { action: 'grant.created', targetId: pondId },
|
|
||||||
orderBy: { at: 'desc' },
|
|
||||||
});
|
|
||||||
expect(audit?.details).toMatchObject({ origin: 'idp_mapping' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('revokes the mapped grant on the next login without the claim — via the revocation path', async () => {
|
|
||||||
const notifier = app.get(PondAccessNotifier);
|
|
||||||
const notifySpy = vi.spyOn(notifier, 'notifyAccessChanged');
|
|
||||||
nextClaims = subjectClaims([]);
|
|
||||||
const session = await loginViaIdp();
|
|
||||||
try {
|
|
||||||
expect(await prisma.roleGrant.findFirst({ where: { pondId, origin: 'idp' } })).toBeNull();
|
|
||||||
// The removal travelled through the grant service: the collab
|
|
||||||
// revocation notify fired for this pond (the pg_notify access
|
|
||||||
// listener terminates live sessions — that path's own tests cover
|
|
||||||
// the socket close).
|
|
||||||
expect(notifySpy.mock.calls.some(([id]) => id === pondId)).toBe(true);
|
|
||||||
// …and the pond is out of reach again (404: existence hidden).
|
|
||||||
await api().get(`/api/v1/ponds/${pondId}/pages`).set('Cookie', session).expect(404);
|
|
||||||
} finally {
|
|
||||||
notifySpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never touches a manual grant, and re-creating over one is skipped (manual wins)', async () => {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: `mapped-${suffix}@idp.example` },
|
|
||||||
});
|
|
||||||
// A manual reader grant made by the pond admin.
|
|
||||||
await prisma.roleGrant.create({
|
|
||||||
data: {
|
|
||||||
pondId,
|
|
||||||
subjectType: 'USER',
|
|
||||||
subjectId: user!.id,
|
|
||||||
role: 'READER',
|
|
||||||
scopeType: 'POND',
|
|
||||||
scopeId: null,
|
|
||||||
effect: 'ALLOW',
|
|
||||||
createdBy: adminId,
|
|
||||||
origin: 'manual',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Login without any mapped claim: the manual grant survives.
|
|
||||||
nextClaims = subjectClaims([]);
|
|
||||||
await loginViaIdp();
|
|
||||||
const manual = await prisma.roleGrant.findFirst({
|
|
||||||
where: { pondId, subjectId: user!.id, origin: 'manual' },
|
|
||||||
});
|
|
||||||
expect(manual).not.toBeNull();
|
|
||||||
expect(manual!.role).toBe('READER');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps and revokes the site-admin flag — but never demotes a hand-promoted admin', async () => {
|
|
||||||
nextClaims = subjectClaims(['wiki-admins']);
|
|
||||||
await loginViaIdp();
|
|
||||||
let user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } });
|
|
||||||
expect(user).toMatchObject({ isSiteAdmin: true, isSiteAdminManaged: true });
|
|
||||||
|
|
||||||
nextClaims = subjectClaims([]);
|
|
||||||
await loginViaIdp();
|
|
||||||
user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } });
|
|
||||||
expect(user).toMatchObject({ isSiteAdmin: false, isSiteAdminManaged: false });
|
|
||||||
|
|
||||||
// Hand-promoted (managed=false): a claimless login must not demote.
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: user!.id },
|
|
||||||
data: { isSiteAdmin: true, isSiteAdminManaged: false },
|
|
||||||
});
|
|
||||||
nextClaims = subjectClaims([]);
|
|
||||||
await loginViaIdp();
|
|
||||||
user = await prisma.user.findUnique({ where: { email: `mapped-${suffix}@idp.example` } });
|
|
||||||
expect(user!.isSiteAdmin).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { User } from '@prisma/client';
|
|
||||||
import type { JWTPayload } from 'jose';
|
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
|
||||||
import { GrantsService } from '../grants/grants.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IdP claim mapping (issue #217, ADR 0021): on every OIDC login the
|
|
||||||
* declarative rules in `idpMapping.rules` are evaluated against the ID
|
|
||||||
* token's claims and reconciled against the user's MAPPING-OWNED state:
|
|
||||||
*
|
|
||||||
* - Pond grants are created and revoked through {@link GrantsService} —
|
|
||||||
* the same path as manual grants, so the permission cache is
|
|
||||||
* invalidated and live collab sessions are revalidated
|
|
||||||
* (`notifyAccessChanged` → the collab access listener) exactly as on a
|
|
||||||
* manual change. No raw row writes.
|
|
||||||
* - The mapping only ever touches rows with `origin = 'idp'` and only
|
|
||||||
* demotes a site admin whose flag it itself set
|
|
||||||
* (`isSiteAdminManaged`) — **manual wins**: hand-made grants and
|
|
||||||
* hand-promoted admins are never revoked by a missing claim.
|
|
||||||
* - Every change is audited (grant.created/grant.deleted with
|
|
||||||
* `origin: idp_mapping`; user.site_admin_set with the same marker).
|
|
||||||
*
|
|
||||||
* Reconciliation happens at login because that is when fresh claims
|
|
||||||
* exist; between logins the leaver case is the IdP's (disable there =
|
|
||||||
* no new login) plus the operator's account-disable flag.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ClaimMappingService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly grants: GrantsService,
|
|
||||||
private readonly settings: InstanceSettingsService,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
private readonly logger: PinoLogger,
|
|
||||||
) {
|
|
||||||
this.logger.setContext(ClaimMappingService.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async apply(user: User, payload: JWTPayload): Promise<void> {
|
|
||||||
const rules = await this.settings.get('idpMapping.rules');
|
|
||||||
if (rules.length === 0) return;
|
|
||||||
|
|
||||||
const matched = rules.filter((rule) => claimMatches(payload[rule.claim], rule.value));
|
|
||||||
|
|
||||||
await this.reconcileSiteAdmin(
|
|
||||||
user,
|
|
||||||
matched.some((rule) => rule.role === 'site_admin'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Desired pond grants, resolved slug → id (unknown slugs are a
|
|
||||||
// configuration error: logged, never fatal for the login).
|
|
||||||
const desired = new Map<string, 'pond_admin' | 'editor' | 'reader'>();
|
|
||||||
for (const rule of matched) {
|
|
||||||
if (rule.role === 'site_admin') continue;
|
|
||||||
const pond = await this.prisma.pond.findFirst({
|
|
||||||
where: { slug: rule.pondSlug!, deletedAt: null },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!pond) {
|
|
||||||
this.logger.warn({ pondSlug: rule.pondSlug }, 'idp mapping: unknown pond slug');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Multiple rules for one pond: the strongest role wins.
|
|
||||||
const current = desired.get(pond.id);
|
|
||||||
if (!current || rank(rule.role) > rank(current)) desired.set(pond.id, rule.role);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await this.prisma.roleGrant.findMany({
|
|
||||||
where: { subjectType: 'USER', subjectId: user.id, origin: 'idp' },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const grant of existing) {
|
|
||||||
const wanted = desired.get(grant.pondId);
|
|
||||||
if (wanted && toDbRole(wanted) === grant.role) {
|
|
||||||
desired.delete(grant.pondId); // already in place
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.grants.deleteGrant(user, grant.pondId, grant.id, { origin: 'idp' });
|
|
||||||
} catch (error) {
|
|
||||||
// E.g. the last-Pond-Admin protection: the grant stays, the login
|
|
||||||
// proceeds — an operator decision is needed, not a lockout.
|
|
||||||
this.logger.warn(
|
|
||||||
{ grantId: grant.id, pondId: grant.pondId, err: error },
|
|
||||||
'idp mapping: grant revocation refused',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [pondId, role] of desired) {
|
|
||||||
try {
|
|
||||||
await this.grants.createGrant(
|
|
||||||
user,
|
|
||||||
pondId,
|
|
||||||
{
|
|
||||||
subjectType: 'user',
|
|
||||||
subjectId: user.id,
|
|
||||||
role,
|
|
||||||
scopeType: 'pond',
|
|
||||||
scopeId: null,
|
|
||||||
effect: 'allow',
|
|
||||||
},
|
|
||||||
{ origin: 'idp' },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// A colliding MANUAL grant (grant_exists) is fine — manual wins,
|
|
||||||
// the mapping never replaces it with an owned copy.
|
|
||||||
this.logger.warn({ pondId, role, err: error }, 'idp mapping: grant creation skipped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async reconcileSiteAdmin(user: User, shouldBeAdmin: boolean): Promise<void> {
|
|
||||||
if (shouldBeAdmin && !user.isSiteAdmin) {
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: { isSiteAdmin: true, isSiteAdminManaged: true },
|
|
||||||
});
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'user.site_admin_set',
|
|
||||||
actorId: user.id,
|
|
||||||
targetType: 'user',
|
|
||||||
targetId: user.id,
|
|
||||||
details: { isSiteAdmin: true, origin: 'idp_mapping' },
|
|
||||||
});
|
|
||||||
} else if (!shouldBeAdmin && user.isSiteAdmin && user.isSiteAdminManaged) {
|
|
||||||
// Only the mapping's own promotion is revocable by a missing claim.
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: { isSiteAdmin: false, isSiteAdminManaged: false },
|
|
||||||
});
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'user.site_admin_set',
|
|
||||||
actorId: user.id,
|
|
||||||
targetType: 'user',
|
|
||||||
targetId: user.id,
|
|
||||||
details: { isSiteAdmin: false, origin: 'idp_mapping' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A claim matches when it equals the value or, as an array, contains it. */
|
|
||||||
function claimMatches(claim: unknown, value: string): boolean {
|
|
||||||
if (Array.isArray(claim)) return claim.some((entry) => String(entry) === value);
|
|
||||||
if (claim === undefined || claim === null) return false;
|
|
||||||
return String(claim) === value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rank(role: 'pond_admin' | 'editor' | 'reader'): number {
|
|
||||||
return role === 'pond_admin' ? 3 : role === 'editor' ? 2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDbRole(role: 'pond_admin' | 'editor' | 'reader'): 'POND_ADMIN' | 'EDITOR' | 'READER' {
|
|
||||||
return role === 'pond_admin' ? 'POND_ADMIN' : role === 'editor' ? 'EDITOR' : 'READER';
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
||||||
import { SUPPRESS_ORIGIN_HEADER, createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSRF origin check, fail closed (issue #189): a cookie-carrying mutation
|
|
||||||
* without `Origin` and `Referer` is rejected exactly like a mismatch, and
|
|
||||||
* the exception for non-browser clients is structural — PAT/bearer requests
|
|
||||||
* carry no cookie and never reach the check. Cookie-authenticated requests
|
|
||||||
* never benefit from any header-based bypass.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('csrf origin check (e2e, issue #189)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const password = 'csrf fail closed pass 1';
|
|
||||||
const ids: Record<string, string> = {};
|
|
||||||
const cookies: Record<string, string> = {};
|
|
||||||
let pondId: string;
|
|
||||||
let pondSlug: string;
|
|
||||||
let patToken: string;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
async function makeUser(handle: string): Promise<void> {
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const username = `csrf-${handle}-${suffix}`;
|
|
||||||
const user = await users.createUser({
|
|
||||||
username,
|
|
||||||
email: `${username}@example.org`,
|
|
||||||
displayName: `Csrf ${handle}`,
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await users.markEmailVerified(user.id);
|
|
||||||
ids[handle] = user.id;
|
|
||||||
cookies[handle] = sessionCookieOf(
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: username, password })
|
|
||||||
.expect(200),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
app = await createTestApp();
|
|
||||||
for (const handle of ['owner', 'siteadmin']) {
|
|
||||||
await makeUser(handle);
|
|
||||||
}
|
|
||||||
await prisma.user.update({ where: { id: ids.siteadmin! }, data: { isSiteAdmin: true } });
|
|
||||||
// Per-user quota override, never the instance default (shared database).
|
|
||||||
await api()
|
|
||||||
.put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`)
|
|
||||||
.set('Cookie', cookies.siteadmin!)
|
|
||||||
.send({ value: 5 })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
// A pond opted into the public API, and a write-scope PAT for it.
|
|
||||||
const pond = await api()
|
|
||||||
.post('/api/v1/ponds')
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.send({ name: `CSRF Pond ${suffix}` })
|
|
||||||
.expect(201);
|
|
||||||
pondId = pond.body.id;
|
|
||||||
pondSlug = pond.body.slug;
|
|
||||||
await app.get(InstanceSettingsService).set('api.enabled', true, ids.siteadmin!);
|
|
||||||
await api()
|
|
||||||
.patch(`/api/v1/ponds/${pondId}`)
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.send({ apiEnabled: true })
|
|
||||||
.expect(200);
|
|
||||||
const pat = await api()
|
|
||||||
.post('/api/v1/users/me/api-tokens')
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.send({ name: 'csrf-write', scope: 'write' })
|
|
||||||
.expect(201);
|
|
||||||
patToken = pat.body.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
const all = Object.values(ids);
|
|
||||||
await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } });
|
|
||||||
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } });
|
|
||||||
await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } });
|
|
||||||
await prisma.apiToken.deleteMany({ where: { userId: { in: all } } });
|
|
||||||
const ponds = await prisma.pond.findMany({
|
|
||||||
where: { ownerId: { in: all } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
const pondIds = ponds.map((p) => p.id);
|
|
||||||
await prisma.pageVersion.deleteMany({ where: { page: { pondId: { in: pondIds } } } });
|
|
||||||
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
|
|
||||||
await prisma.roleGrant.deleteMany({ where: { pondId: { in: pondIds } } });
|
|
||||||
await prisma.pondUsage.deleteMany({ where: { pondId: { in: pondIds } } });
|
|
||||||
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
|
|
||||||
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a cookie mutation that sends neither Origin nor Referer', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.patch(`/api/v1/ponds/${pondId}`)
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.set(SUPPRESS_ORIGIN_HEADER, '1')
|
|
||||||
.send({ name: `CSRF Pond ${suffix}` })
|
|
||||||
.expect(403);
|
|
||||||
expect(res.body.code).toBe('csrf_origin_mismatch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a cookie mutation from a mismatching origin (kept behaviour)', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.patch(`/api/v1/ponds/${pondId}`)
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.set('Origin', 'https://evil.example')
|
|
||||||
.send({ name: `CSRF Pond ${suffix}` })
|
|
||||||
.expect(403);
|
|
||||||
expect(res.body.code).toBe('csrf_origin_mismatch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a cookie mutation with an unparsable Origin instead of erroring', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.patch(`/api/v1/ponds/${pondId}`)
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.set('Origin', 'not a url')
|
|
||||||
.send({ name: `CSRF Pond ${suffix}` })
|
|
||||||
.expect(403);
|
|
||||||
expect(res.body.code).toBe('csrf_origin_mismatch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a cookie mutation from the matching origin', async () => {
|
|
||||||
await api()
|
|
||||||
.patch(`/api/v1/ponds/${pondId}`)
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.send({ name: `CSRF Pond ${suffix}` })
|
|
||||||
.expect(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves cookie reads untouched — the check binds to mutations', async () => {
|
|
||||||
await api()
|
|
||||||
.get('/api/v1/auth/me')
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.set(SUPPRESS_ORIGIN_HEADER, '1')
|
|
||||||
.expect(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a PAT mutation through without either header — no cookie, no check', async () => {
|
|
||||||
await api()
|
|
||||||
.post(`/api/public/v1/ponds/${pondSlug}/pages`)
|
|
||||||
.set('Authorization', `Bearer ${patToken}`)
|
|
||||||
.set(SUPPRESS_ORIGIN_HEADER, '1')
|
|
||||||
.send({ title: `CSRF PAT page ${suffix}` })
|
|
||||||
.expect(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces the check when a request carries both cookie and bearer token', async () => {
|
|
||||||
// Cookie-authenticated requests never benefit from the bearer exception.
|
|
||||||
const res = await api()
|
|
||||||
.patch(`/api/v1/ponds/${pondId}`)
|
|
||||||
.set('Cookie', cookies.owner!)
|
|
||||||
.set('Authorization', `Bearer ${patToken}`)
|
|
||||||
.set(SUPPRESS_ORIGIN_HEADER, '1')
|
|
||||||
.send({ name: `CSRF Pond ${suffix}` })
|
|
||||||
.expect(403);
|
|
||||||
expect(res.body.code).toBe('csrf_origin_mismatch');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PATH_METADATA } from '@nestjs/common/constants';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
import { LOCAL_CREDENTIAL_KEY } from './auth.guard';
|
|
||||||
import { AuthController } from './auth.controller';
|
|
||||||
import { OidcController } from './oidc.controller';
|
|
||||||
import { SessionsService } from './sessions.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The hard local-auth switch (issue #216, ADR 0021): AUTH_LOCAL_ENABLED=false
|
|
||||||
* closes EVERY local credential flow with 404 — enumerated, not assumed —
|
|
||||||
* while sessions themselves, logout, and token issuance for
|
|
||||||
* externally-authenticated users keep working (the stated decision: PATs
|
|
||||||
* and feed tokens authorize API access under their own switches, they are
|
|
||||||
* not interactive sign-in). A fence asserts every auth route is either
|
|
||||||
* marked as a local flow or on the reviewed allowlist.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('local-auth switch (e2e, issue #216)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
|
|
||||||
/** Every local credential surface — the enumeration the issue demands. */
|
|
||||||
const LOCAL_ROUTES: { method: 'post'; path: string; body: Record<string, unknown> }[] = [
|
|
||||||
{ method: 'post', path: '/api/v1/auth/login', body: { usernameOrEmail: 'x', password: 'y' } },
|
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/api/v1/auth/signup',
|
|
||||||
body: {
|
|
||||||
username: `switch-${suffix}`,
|
|
||||||
email: `switch-${suffix}@example.test`,
|
|
||||||
displayName: 'x',
|
|
||||||
password: 'ein langes passwort 123',
|
|
||||||
locale: 'en',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ method: 'post', path: '/api/v1/auth/verify-email', body: { token: 'x' } },
|
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/api/v1/auth/resend-verification',
|
|
||||||
body: { email: 'x@example.test' },
|
|
||||||
},
|
|
||||||
{ method: 'post', path: '/api/v1/auth/forgot-password', body: { email: 'x@example.test' } },
|
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/api/v1/auth/reset-password',
|
|
||||||
body: { token: 'x', password: 'ein langes passwort 123' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/api/v1/users/me/change-password',
|
|
||||||
body: { currentPassword: 'x', newPassword: 'ein langes passwort 123' },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
process.env.AUTH_LOCAL_ENABLED = 'false';
|
|
||||||
app = await createTestApp();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.AUTH_LOCAL_ENABLED;
|
|
||||||
await prisma.apiToken.deleteMany({ where: { user: { username: { contains: suffix } } } });
|
|
||||||
await prisma.feedToken.deleteMany({ where: { user: { username: { contains: suffix } } } });
|
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('answers 404 on every enumerated local credential route', async () => {
|
|
||||||
for (const route of LOCAL_ROUTES) {
|
|
||||||
const res = await api()[route.method](route.path).send(route.body);
|
|
||||||
expect(`${route.path}: ${res.status}`).toBe(`${route.path}: 404`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports local:false so the login screen hides the form', async () => {
|
|
||||||
const res = await api().get('/api/v1/auth/methods').expect(200);
|
|
||||||
expect(res.body.local).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps sessions, logout, and PAT/feed-token issuance working for externally-authenticated users', async () => {
|
|
||||||
// An externally-authenticated user is simulated by creating the session
|
|
||||||
// through the session service — exactly what the OIDC/proxy paths do.
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const user = await users.createUser({
|
|
||||||
username: `ext-${suffix}`,
|
|
||||||
email: `ext-${suffix}@example.test`,
|
|
||||||
displayName: 'External',
|
|
||||||
password: 'nie benutzt weil lokal aus',
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await users.markEmailVerified(user.id);
|
|
||||||
const token = await app.get(SessionsService).create(user.id, undefined);
|
|
||||||
const cookie = `dt_session=${token}`;
|
|
||||||
|
|
||||||
const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200);
|
|
||||||
expect(me.body.id).toBe(user.id);
|
|
||||||
|
|
||||||
// Stated decision (#216): token issuance is API authorization, not
|
|
||||||
// interactive sign-in — it stays available under its own switches.
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/users/me/api-tokens')
|
|
||||||
.set('Cookie', cookie)
|
|
||||||
.send({ name: `switch-${suffix}`, scope: 'read' })
|
|
||||||
.expect(201);
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/users/me/feed-tokens')
|
|
||||||
.set('Cookie', cookie)
|
|
||||||
.send({ name: `switch-${suffix}` })
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
await api().post('/api/v1/auth/logout').set('Cookie', cookie).expect(204);
|
|
||||||
await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fence: every auth route is either a marked local flow or on the reviewed allowlist', () => {
|
|
||||||
// Routes that must stay reachable with local auth off — reviewed here.
|
|
||||||
const allowlist = new Set([
|
|
||||||
'registration', // signup-mode discovery; harmless metadata
|
|
||||||
'methods', // the login screen's discovery endpoint
|
|
||||||
'logout', // ending a session is not a credential flow
|
|
||||||
'me', // session introspection
|
|
||||||
'login', // OidcController: IdP redirect
|
|
||||||
'link', // OidcController: explicit identity linking
|
|
||||||
'callback', // OidcController: IdP return leg
|
|
||||||
]);
|
|
||||||
for (const controller of [AuthController, OidcController]) {
|
|
||||||
for (const name of Object.getOwnPropertyNames(controller.prototype)) {
|
|
||||||
if (name === 'constructor') continue;
|
|
||||||
const handler = controller.prototype[name as keyof typeof controller.prototype] as (
|
|
||||||
...args: unknown[]
|
|
||||||
) => unknown;
|
|
||||||
const path = Reflect.getMetadata(PATH_METADATA, handler) as string | undefined;
|
|
||||||
if (path === undefined) continue; // not a route
|
|
||||||
const marked = Reflect.getMetadata(LOCAL_CREDENTIAL_KEY, handler) === true;
|
|
||||||
expect(
|
|
||||||
marked || allowlist.has(path),
|
|
||||||
`${controller.name}.${name} (path "${path}") is neither @LocalCredentialFlow nor allowlisted`,
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
|
|
||||||
import type { Response } from 'express';
|
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
|
||||||
import { RateLimit } from '../rate-limit/rate-limit.guard';
|
|
||||||
|
|
||||||
import { AuthedRequest, Public, setSessionCookie } from './auth.guard';
|
|
||||||
import { OidcService } from './oidc.service';
|
|
||||||
import { sessionAbsoluteMs } from './sessions.service';
|
|
||||||
|
|
||||||
/** Carries state+nonce+PKCE verifier across the IdP round-trip — signed
|
|
||||||
* (purpose-derived key), HttpOnly, Lax so the top-level callback
|
|
||||||
* navigation still sends it, and 10 minutes short-lived. */
|
|
||||||
const OIDC_STATE_COOKIE = 'dt_oidc';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OIDC endpoints (issue #214, ADR 0021). Browser-navigation shaped: `login`
|
|
||||||
* and `link` answer 302 to the IdP, the callback lands back here and
|
|
||||||
* redirects into the SPA — errors become `/login?error=<code>` so the SPA
|
|
||||||
* can translate them.
|
|
||||||
*/
|
|
||||||
@AuthenticatedOnly()
|
|
||||||
@Controller('auth/oidc')
|
|
||||||
export class OidcController {
|
|
||||||
constructor(
|
|
||||||
private readonly oidc: OidcService,
|
|
||||||
private readonly config: AppConfig,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private stateCookie(response: Response, value: string): void {
|
|
||||||
response.cookie(OIDC_STATE_COOKIE, value, {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: this.config.env.NODE_ENV === 'production',
|
|
||||||
maxAge: 10 * 60 * 1000,
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Get('login')
|
|
||||||
@RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 })
|
|
||||||
async login(@Res() response: Response): Promise<void> {
|
|
||||||
this.oidc.assertEnabled();
|
|
||||||
const { url, stateToken } = await this.oidc.beginLogin();
|
|
||||||
this.stateCookie(response, stateToken);
|
|
||||||
response.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The deliberate account-linking flow (ADR 0021 §2): only a logged-in
|
|
||||||
* user attaches an IdP identity to their own account. */
|
|
||||||
@Get('link')
|
|
||||||
@RateLimit({ scope: 'oidc-login', limit: 30, windowSeconds: 60 })
|
|
||||||
async link(@Req() request: AuthedRequest, @Res() response: Response): Promise<void> {
|
|
||||||
this.oidc.assertEnabled();
|
|
||||||
const { url, stateToken } = await this.oidc.beginLogin(request.user!.id);
|
|
||||||
this.stateCookie(response, stateToken);
|
|
||||||
response.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Get('callback')
|
|
||||||
@RateLimit({ scope: 'oidc-callback', limit: 30, windowSeconds: 60 })
|
|
||||||
async callback(
|
|
||||||
@Query('code') code: string | undefined,
|
|
||||||
@Query('state') state: string | undefined,
|
|
||||||
@Query('error') idpError: string | undefined,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
@Res() response: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
this.oidc.assertEnabled();
|
|
||||||
const base = this.config.env.APP_BASE_URL;
|
|
||||||
response.clearCookie(OIDC_STATE_COOKIE, { path: '/' });
|
|
||||||
const stateToken = (request.cookies as Record<string, string> | undefined)?.[OIDC_STATE_COOKIE];
|
|
||||||
if (idpError || !code || !state || !stateToken) {
|
|
||||||
response.redirect(`${base}/login?error=oidc_cancelled`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await this.oidc.completeLogin(
|
|
||||||
code,
|
|
||||||
state,
|
|
||||||
stateToken,
|
|
||||||
request.headers['user-agent'],
|
|
||||||
);
|
|
||||||
if (result.linked) {
|
|
||||||
response.redirect(`${base}/settings?oidc=linked`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSessionCookie(
|
|
||||||
response,
|
|
||||||
result.sessionToken!,
|
|
||||||
this.config.env.NODE_ENV === 'production',
|
|
||||||
sessionAbsoluteMs(this.config.env),
|
|
||||||
);
|
|
||||||
response.redirect(`${base}/`);
|
|
||||||
} catch (error) {
|
|
||||||
const code_ =
|
|
||||||
typeof (error as { response?: { code?: string } })?.response?.code === 'string'
|
|
||||||
? (error as { response: { code: string } }).response.code
|
|
||||||
: 'oidc_failed';
|
|
||||||
response.redirect(`${base}/login?error=${encodeURIComponent(code_)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,351 +0,0 @@
|
|||||||
import { createServer, type Server } from 'node:http';
|
|
||||||
import type { AddressInfo } from 'node:net';
|
|
||||||
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from 'jose';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OIDC Authorization Code + PKCE against a local fake IdP (issue #214,
|
|
||||||
* ADR 0021): discovery, JWKS-validated ID tokens, state/nonce binding, PKCE
|
|
||||||
* verifier at the token endpoint, JIT account creation, the documented
|
|
||||||
* refusal to link silently by e-mail, and the explicit link flow. The fake
|
|
||||||
* IdP is protocol-shaped exactly like Keycloak's endpoints — the Keycloak
|
|
||||||
* verification itself is a manual procedure (security.md §External
|
|
||||||
* authentication).
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('oidc login (e2e, issue #214)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let idp: Server;
|
|
||||||
let issuer: string;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
|
|
||||||
let signingKey: CryptoKey;
|
|
||||||
let publicJwk: Record<string, unknown>;
|
|
||||||
let wrongKey: CryptoKey;
|
|
||||||
/** What the fake token endpoint returns next (set per test). */
|
|
||||||
let nextIdToken: (() => Promise<string>) | null = null;
|
|
||||||
/** The last body the token endpoint received (PKCE assertions). */
|
|
||||||
let lastTokenRequest: URLSearchParams | null = null;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
async function mintIdToken(
|
|
||||||
claims: JWTPayload,
|
|
||||||
options: { key?: CryptoKey; expired?: boolean } = {},
|
|
||||||
): Promise<string> {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
return new SignJWT({ ...claims })
|
|
||||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
|
||||||
.setIssuedAt(options.expired ? now - 7200 : now)
|
|
||||||
.setExpirationTime(options.expired ? now - 3600 : now + 300)
|
|
||||||
.sign(options.key ?? signingKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs /auth/oidc/login and returns the pieces the callback needs. */
|
|
||||||
async function beginLogin(cookie?: string) {
|
|
||||||
const req = api().get('/api/v1/auth/oidc/login');
|
|
||||||
const res = await (cookie ? req.set('Cookie', cookie) : req).expect(302);
|
|
||||||
const url = new URL(res.headers.location!);
|
|
||||||
const stateCookie = (res.headers['set-cookie'] as unknown as string[])
|
|
||||||
.find((c) => c.startsWith('dt_oidc='))!
|
|
||||||
.split(';')[0]!;
|
|
||||||
return {
|
|
||||||
state: url.searchParams.get('state')!,
|
|
||||||
nonce: url.searchParams.get('nonce')!,
|
|
||||||
challenge: url.searchParams.get('code_challenge')!,
|
|
||||||
stateCookie,
|
|
||||||
authorizeUrl: url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callback(state: string, stateCookie: string) {
|
|
||||||
return api()
|
|
||||||
.get(`/api/v1/auth/oidc/callback?code=fake-code&state=${encodeURIComponent(state)}`)
|
|
||||||
.set('Cookie', stateCookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
function redirectTarget(res: request.Response): string {
|
|
||||||
return res.headers.location!;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
let signingPublic: CryptoKey;
|
|
||||||
({ privateKey: signingKey, publicKey: signingPublic } = await generateKeyPair('RS256', {
|
|
||||||
extractable: true,
|
|
||||||
}));
|
|
||||||
({ privateKey: wrongKey } = await generateKeyPair('RS256', { extractable: true }));
|
|
||||||
publicJwk = { ...(await exportJWK(signingPublic)), kid: 'test-key', alg: 'RS256' };
|
|
||||||
|
|
||||||
idp = createServer((req, res) => {
|
|
||||||
void (async () => {
|
|
||||||
if (req.url === '/.well-known/openid-configuration') {
|
|
||||||
res.setHeader('content-type', 'application/json');
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
issuer,
|
|
||||||
authorization_endpoint: `${issuer}/authorize`,
|
|
||||||
token_endpoint: `${issuer}/token`,
|
|
||||||
jwks_uri: `${issuer}/jwks`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (req.url === '/jwks') {
|
|
||||||
res.setHeader('content-type', 'application/json');
|
|
||||||
res.end(JSON.stringify({ keys: [publicJwk] }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (req.url === '/token') {
|
|
||||||
let body = '';
|
|
||||||
req.on('data', (chunk) => (body += chunk));
|
|
||||||
req.on('end', () => {
|
|
||||||
void (async () => {
|
|
||||||
lastTokenRequest = new URLSearchParams(body);
|
|
||||||
res.setHeader('content-type', 'application/json');
|
|
||||||
if (!nextIdToken) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.end(JSON.stringify({ error: 'invalid_grant' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.end(JSON.stringify({ id_token: await nextIdToken(), token_type: 'Bearer' }));
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end();
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => idp.listen(0, '127.0.0.1', resolve));
|
|
||||||
issuer = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`;
|
|
||||||
|
|
||||||
process.env.OIDC_ISSUER = issuer;
|
|
||||||
process.env.OIDC_CLIENT_ID = 'dorfteich-test';
|
|
||||||
process.env.OIDC_PROVIDER_LABEL = 'Fake IdP';
|
|
||||||
app = await createTestApp();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.OIDC_ISSUER;
|
|
||||||
delete process.env.OIDC_CLIENT_ID;
|
|
||||||
delete process.env.OIDC_PROVIDER_LABEL;
|
|
||||||
await new Promise<void>((resolve) => idp.close(() => resolve()));
|
|
||||||
await prisma.userIdentity.deleteMany({ where: { provider: `oidc:${issuer}` } });
|
|
||||||
await prisma.page.deleteMany({
|
|
||||||
where: { pond: { owner: { email: { contains: `${suffix}@idp.example` } } } },
|
|
||||||
});
|
|
||||||
await prisma.roleGrant.deleteMany({
|
|
||||||
where: { pond: { owner: { email: { contains: `${suffix}@idp.example` } } } },
|
|
||||||
});
|
|
||||||
await prisma.pond.deleteMany({
|
|
||||||
where: { owner: { email: { contains: `${suffix}@idp.example` } } },
|
|
||||||
});
|
|
||||||
await prisma.user.deleteMany({ where: { email: { contains: `${suffix}@idp.example` } } });
|
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: `local-${suffix}` } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('advertises the provider on /auth/methods', async () => {
|
|
||||||
const res = await api().get('/api/v1/auth/methods').expect(200);
|
|
||||||
expect(res.body).toEqual({ local: true, oidc: { label: 'Fake IdP' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs in end to end: PKCE at the token endpoint, JIT user, identity, personal pond, session', async () => {
|
|
||||||
const { state, nonce, challenge, stateCookie, authorizeUrl } = await beginLogin();
|
|
||||||
expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256');
|
|
||||||
expect(authorizeUrl.searchParams.get('client_id')).toBe('dorfteich-test');
|
|
||||||
|
|
||||||
nextIdToken = () =>
|
|
||||||
mintIdToken({
|
|
||||||
iss: issuer,
|
|
||||||
aud: 'dorfteich-test',
|
|
||||||
sub: `subject-${suffix}`,
|
|
||||||
nonce,
|
|
||||||
email: `nadia-${suffix}@idp.example`,
|
|
||||||
email_verified: true,
|
|
||||||
preferred_username: `nadia-${suffix}`,
|
|
||||||
name: 'Nadia IdP',
|
|
||||||
});
|
|
||||||
const res = await callback(state, stateCookie);
|
|
||||||
expect(res.status).toBe(302);
|
|
||||||
expect(redirectTarget(res)).toMatch(/\/$/);
|
|
||||||
const session = sessionCookieOf(res);
|
|
||||||
expect(session).toContain('dt_session=');
|
|
||||||
|
|
||||||
// PKCE: the verifier travelled to the token endpoint and matches the
|
|
||||||
// challenge from the authorize redirect.
|
|
||||||
expect(lastTokenRequest?.get('grant_type')).toBe('authorization_code');
|
|
||||||
const verifier = lastTokenRequest?.get('code_verifier');
|
|
||||||
expect(verifier).toBeTruthy();
|
|
||||||
const { createHash } = await import('node:crypto');
|
|
||||||
expect(createHash('sha256').update(verifier!).digest('base64url')).toBe(challenge);
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: `nadia-${suffix}@idp.example` },
|
|
||||||
});
|
|
||||||
expect(user).toMatchObject({ status: 'ACTIVE', displayName: 'Nadia IdP' });
|
|
||||||
const identity = await prisma.userIdentity.findUnique({
|
|
||||||
where: {
|
|
||||||
provider_subject: { provider: `oidc:${issuer}`, subject: `subject-${suffix}` },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(identity?.userId).toBe(user!.id);
|
|
||||||
const personal = await prisma.pond.findFirst({
|
|
||||||
where: { ownerId: user!.id, type: 'PERSONAL' },
|
|
||||||
});
|
|
||||||
expect(personal).not.toBeNull();
|
|
||||||
|
|
||||||
const me = await api().get('/api/v1/auth/me').set('Cookie', session).expect(200);
|
|
||||||
expect(me.body.email).toBe(`nadia-${suffix}@idp.example`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reuses the existing account on the next login of the same subject', async () => {
|
|
||||||
const before = await prisma.user.count({ where: { email: { contains: `${suffix}@idp` } } });
|
|
||||||
const { state, nonce, stateCookie } = await beginLogin();
|
|
||||||
nextIdToken = () =>
|
|
||||||
mintIdToken({
|
|
||||||
iss: issuer,
|
|
||||||
aud: 'dorfteich-test',
|
|
||||||
sub: `subject-${suffix}`,
|
|
||||||
nonce,
|
|
||||||
email: `nadia-${suffix}@idp.example`,
|
|
||||||
email_verified: true,
|
|
||||||
});
|
|
||||||
const res = await callback(state, stateCookie);
|
|
||||||
expect(res.status).toBe(302);
|
|
||||||
expect(redirectTarget(res)).toMatch(/\/$/);
|
|
||||||
const after = await prisma.user.count({ where: { email: { contains: `${suffix}@idp` } } });
|
|
||||||
expect(after).toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a wrong state, a foreign nonce, a bad signature, wrong issuer/audience and an expired token', async () => {
|
|
||||||
// Wrong state: cookie from one round, state from nowhere.
|
|
||||||
const first = await beginLogin();
|
|
||||||
const bad = await callback('not-the-state', first.stateCookie);
|
|
||||||
expect(redirectTarget(bad)).toContain('error=oidc_state_invalid');
|
|
||||||
|
|
||||||
const cases: {
|
|
||||||
claims: (nonce: string) => JWTPayload;
|
|
||||||
options?: { key?: CryptoKey; expired?: boolean };
|
|
||||||
}[] = [
|
|
||||||
// Foreign nonce.
|
|
||||||
{ claims: () => baseClaims('other-nonce') },
|
|
||||||
// Signature from the wrong key.
|
|
||||||
{ claims: (n) => baseClaims(n), options: { key: wrongKey } },
|
|
||||||
// Wrong issuer.
|
|
||||||
{ claims: (n) => ({ ...baseClaims(n), iss: 'https://evil.example' }) },
|
|
||||||
// Wrong audience.
|
|
||||||
{ claims: (n) => ({ ...baseClaims(n), aud: 'someone-else' }) },
|
|
||||||
// Expired.
|
|
||||||
{ claims: (n) => baseClaims(n), options: { expired: true } },
|
|
||||||
];
|
|
||||||
function baseClaims(nonce: string): JWTPayload {
|
|
||||||
return {
|
|
||||||
iss: issuer,
|
|
||||||
aud: 'dorfteich-test',
|
|
||||||
sub: `reject-${suffix}`,
|
|
||||||
nonce,
|
|
||||||
email: `reject-${suffix}@idp.example`,
|
|
||||||
email_verified: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
for (const testCase of cases) {
|
|
||||||
const { state, nonce, stateCookie } = await beginLogin();
|
|
||||||
nextIdToken = () => mintIdToken(testCase.claims(nonce), testCase.options);
|
|
||||||
const res = await callback(state, stateCookie);
|
|
||||||
expect(redirectTarget(res)).toContain('error=oidc_token_invalid');
|
|
||||||
}
|
|
||||||
// None of the rejected attempts created anything.
|
|
||||||
expect(
|
|
||||||
await prisma.user.findUnique({ where: { email: `reject-${suffix}@idp.example` } }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to adopt an existing local account by e-mail — and links it via the explicit flow', async () => {
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const password = 'lokales konto 123';
|
|
||||||
const local = await users.createUser({
|
|
||||||
username: `local-${suffix}`,
|
|
||||||
email: `local-${suffix}@idp.example`,
|
|
||||||
displayName: 'Local User',
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await users.markEmailVerified(local.id);
|
|
||||||
|
|
||||||
// Silent adoption refused (ADR 0021 §2 — account-takeover path).
|
|
||||||
const attempt = await beginLogin();
|
|
||||||
nextIdToken = () =>
|
|
||||||
mintIdToken({
|
|
||||||
iss: issuer,
|
|
||||||
aud: 'dorfteich-test',
|
|
||||||
sub: `local-subject-${suffix}`,
|
|
||||||
nonce: attempt.nonce,
|
|
||||||
email: `local-${suffix}@idp.example`,
|
|
||||||
email_verified: true,
|
|
||||||
});
|
|
||||||
const refused = await callback(attempt.state, attempt.stateCookie);
|
|
||||||
expect(redirectTarget(refused)).toContain('error=oidc_link_required');
|
|
||||||
|
|
||||||
// The explicit link flow, from a logged-in session.
|
|
||||||
const login = await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: `local-${suffix}`, password })
|
|
||||||
.expect(200);
|
|
||||||
const sessionCookie = sessionCookieOf(login);
|
|
||||||
const linkRes = await api()
|
|
||||||
.get('/api/v1/auth/oidc/link')
|
|
||||||
.set('Cookie', sessionCookie)
|
|
||||||
.expect(302);
|
|
||||||
const linkUrl = new URL(linkRes.headers.location!);
|
|
||||||
const linkState = linkUrl.searchParams.get('state')!;
|
|
||||||
const linkNonce = linkUrl.searchParams.get('nonce')!;
|
|
||||||
const linkCookie = (linkRes.headers['set-cookie'] as unknown as string[])
|
|
||||||
.find((c) => c.startsWith('dt_oidc='))!
|
|
||||||
.split(';')[0]!;
|
|
||||||
nextIdToken = () =>
|
|
||||||
mintIdToken({
|
|
||||||
iss: issuer,
|
|
||||||
aud: 'dorfteich-test',
|
|
||||||
sub: `local-subject-${suffix}`,
|
|
||||||
nonce: linkNonce,
|
|
||||||
email: `local-${suffix}@idp.example`,
|
|
||||||
email_verified: true,
|
|
||||||
});
|
|
||||||
const linked = await callback(linkState, linkCookie);
|
|
||||||
expect(redirectTarget(linked)).toContain('oidc=linked');
|
|
||||||
const identity = await prisma.userIdentity.findUnique({
|
|
||||||
where: {
|
|
||||||
provider_subject: { provider: `oidc:${issuer}`, subject: `local-subject-${suffix}` },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(identity?.userId).toBe(local.id);
|
|
||||||
|
|
||||||
// From now on the IdP login lands in the linked account.
|
|
||||||
const again = await beginLogin();
|
|
||||||
nextIdToken = () =>
|
|
||||||
mintIdToken({
|
|
||||||
iss: issuer,
|
|
||||||
aud: 'dorfteich-test',
|
|
||||||
sub: `local-subject-${suffix}`,
|
|
||||||
nonce: again.nonce,
|
|
||||||
email: `local-${suffix}@idp.example`,
|
|
||||||
email_verified: true,
|
|
||||||
});
|
|
||||||
const res = await callback(again.state, again.stateCookie);
|
|
||||||
const session = sessionCookieOf(res);
|
|
||||||
const me = await api().get('/api/v1/auth/me').set('Cookie', session).expect(200);
|
|
||||||
expect(me.body.id).toBe(local.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,359 +0,0 @@
|
|||||||
import { createHash, randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { slugify } from '@dorfteich/shared';
|
|
||||||
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
|
||||||
import { User } from '@prisma/client';
|
|
||||||
import { SignJWT, createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
import { ClaimMappingService } from './claim-mapping.service';
|
|
||||||
import { SessionsService } from './sessions.service';
|
|
||||||
|
|
||||||
/** The state cookie's signed payload lives this long — ample for one
|
|
||||||
* round-trip to the IdP's login form. */
|
|
||||||
const STATE_TTL_SECONDS = 10 * 60;
|
|
||||||
|
|
||||||
/** Explicit asymmetric allowlist for ID-token signatures (no HS*, no
|
|
||||||
* `none`): Keycloak's default RS256 plus the common EC profile. */
|
|
||||||
const ID_TOKEN_ALGORITHMS = ['RS256', 'ES256'];
|
|
||||||
|
|
||||||
/** What we mint into the signed, HttpOnly state cookie before redirecting
|
|
||||||
* to the IdP: CSRF binding (`state`), replay binding (`nonce`), the PKCE
|
|
||||||
* verifier, and — for the deliberate account-linking flow — the session
|
|
||||||
* user the new identity must attach to. */
|
|
||||||
interface OidcStateClaims extends JWTPayload {
|
|
||||||
state: string;
|
|
||||||
nonce: string;
|
|
||||||
codeVerifier: string;
|
|
||||||
linkUserId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DiscoveryDocument {
|
|
||||||
issuer: string;
|
|
||||||
authorization_endpoint: string;
|
|
||||||
token_endpoint: string;
|
|
||||||
jwks_uri: string;
|
|
||||||
end_session_endpoint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OIDC Authorization Code with PKCE (issue #214, ADR 0021). Deliberately
|
|
||||||
* built on `jose` (the vetted library from #188) plus `fetch` — no new
|
|
||||||
* dependency enters the supply chain for a security base function.
|
|
||||||
* Discovery-based: nothing here is Keycloak-specific; Keycloak is the
|
|
||||||
* reference IdP the flow is verified against (procedure in
|
|
||||||
* `docs/architecture/security.md` §External authentication).
|
|
||||||
*
|
|
||||||
* Identity linking follows ADR 0021 §2: `provider = "oidc:<issuer>"`,
|
|
||||||
* `subject` from the token. An existing local account is NEVER linked
|
|
||||||
* silently by e-mail — that would be an account-takeover path. Instead the
|
|
||||||
* login is refused with `oidc_link_required`, and the user (logged in
|
|
||||||
* locally) links explicitly via `GET /auth/oidc/link`.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class OidcService {
|
|
||||||
private discoveryCache: DiscoveryDocument | null = null;
|
|
||||||
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly users: UsersService,
|
|
||||||
private readonly sessions: SessionsService,
|
|
||||||
private readonly ponds: PondsService,
|
|
||||||
private readonly claimMapping: ClaimMappingService,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
private readonly config: AppConfig,
|
|
||||||
private readonly logger: PinoLogger,
|
|
||||||
) {
|
|
||||||
this.logger.setContext(OidcService.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OIDC is a deploy-level decision (ADR 0021): enabled iff issuer and
|
|
||||||
* client id are configured. */
|
|
||||||
get enabled(): boolean {
|
|
||||||
return Boolean(this.config.env.OIDC_ISSUER && this.config.env.OIDC_CLIENT_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
get providerLabel(): string {
|
|
||||||
return this.config.env.OIDC_PROVIDER_LABEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get issuer(): string {
|
|
||||||
return this.config.env.OIDC_ISSUER!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get clientId(): string {
|
|
||||||
return this.config.env.OIDC_CLIENT_ID!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get redirectUri(): string {
|
|
||||||
return `${this.config.env.APP_BASE_URL}/api/v1/auth/oidc/callback`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The identity provider key: one issuer, one provider namespace. */
|
|
||||||
private get provider(): string {
|
|
||||||
return `oidc:${this.issuer}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEnabled(): void {
|
|
||||||
// 404, not 403: consistent with the instance switches (`api.enabled`
|
|
||||||
// et al.) — an unconfigured surface hides its existence.
|
|
||||||
if (!this.enabled) throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async discover(): Promise<DiscoveryDocument> {
|
|
||||||
if (this.discoveryCache) return this.discoveryCache;
|
|
||||||
const url = `${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
||||||
const response = await fetch(url).catch(() => null);
|
|
||||||
if (!response?.ok) {
|
|
||||||
throw new ServiceUnavailableException({ code: 'oidc_discovery_failed' });
|
|
||||||
}
|
|
||||||
const doc = (await response.json()) as DiscoveryDocument;
|
|
||||||
if (doc.issuer !== this.issuer) {
|
|
||||||
// RFC 8414 §3.3: the advertised issuer must match the configured one.
|
|
||||||
throw new ServiceUnavailableException({ code: 'oidc_discovery_failed' });
|
|
||||||
}
|
|
||||||
this.discoveryCache = doc;
|
|
||||||
this.jwks = createRemoteJWKSet(new URL(doc.jwks_uri));
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds the IdP redirect plus the signed state-cookie value. */
|
|
||||||
async beginLogin(linkUserId?: string): Promise<{ url: string; stateToken: string }> {
|
|
||||||
const doc = await this.discover();
|
|
||||||
const state = randomBytes(24).toString('base64url');
|
|
||||||
const nonce = randomBytes(24).toString('base64url');
|
|
||||||
const codeVerifier = randomBytes(48).toString('base64url');
|
|
||||||
const challenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
|
||||||
|
|
||||||
const url = new URL(doc.authorization_endpoint);
|
|
||||||
url.searchParams.set('response_type', 'code');
|
|
||||||
url.searchParams.set('client_id', this.clientId);
|
|
||||||
url.searchParams.set('redirect_uri', this.redirectUri);
|
|
||||||
url.searchParams.set('scope', this.config.env.OIDC_SCOPES);
|
|
||||||
url.searchParams.set('state', state);
|
|
||||||
url.searchParams.set('nonce', nonce);
|
|
||||||
url.searchParams.set('code_challenge', challenge);
|
|
||||||
url.searchParams.set('code_challenge_method', 'S256');
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const claims: OidcStateClaims = { state, nonce, codeVerifier };
|
|
||||||
if (linkUserId) claims.linkUserId = linkUserId;
|
|
||||||
const stateToken = await new SignJWT({ ...claims })
|
|
||||||
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
||||||
.setIssuedAt(now)
|
|
||||||
.setExpirationTime(now + STATE_TTL_SECONDS)
|
|
||||||
.sign(deriveTokenKey(this.config.env.COLLAB_TOKEN_SECRET, 'oidc-state'));
|
|
||||||
|
|
||||||
return { url: url.toString(), stateToken };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async verifyStateToken(stateToken: string): Promise<OidcStateClaims> {
|
|
||||||
try {
|
|
||||||
const { payload } = await jwtVerify(
|
|
||||||
stateToken,
|
|
||||||
deriveTokenKey(this.config.env.COLLAB_TOKEN_SECRET, 'oidc-state'),
|
|
||||||
{ algorithms: ['HS256'] },
|
|
||||||
);
|
|
||||||
if (typeof payload.state !== 'string' || typeof payload.nonce !== 'string') throw new Error();
|
|
||||||
if (typeof payload.codeVerifier !== 'string') throw new Error();
|
|
||||||
return payload as OidcStateClaims;
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException({ code: 'oidc_state_invalid' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The callback half: state check, code exchange, ID-token validation
|
|
||||||
* (signature via JWKS, issuer, audience, expiry — and the nonce binding),
|
|
||||||
* then identity resolution. Returns the session token to set plus where
|
|
||||||
* the SPA should land.
|
|
||||||
*/
|
|
||||||
async completeLogin(
|
|
||||||
code: string,
|
|
||||||
state: string,
|
|
||||||
stateToken: string,
|
|
||||||
userAgent: string | undefined,
|
|
||||||
): Promise<{ sessionToken: string | null; linked: boolean }> {
|
|
||||||
const doc = await this.discover();
|
|
||||||
const stored = await this.verifyStateToken(stateToken);
|
|
||||||
if (state !== stored.state) {
|
|
||||||
throw new BadRequestException({ code: 'oidc_state_invalid' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
redirect_uri: this.redirectUri,
|
|
||||||
client_id: this.clientId,
|
|
||||||
code_verifier: stored.codeVerifier,
|
|
||||||
});
|
|
||||||
// Confidential client: secret via client_secret_post (Keycloak default
|
|
||||||
// accepts it); a public client authenticates with PKCE alone.
|
|
||||||
if (this.config.env.OIDC_CLIENT_SECRET) {
|
|
||||||
body.set('client_secret', this.config.env.OIDC_CLIENT_SECRET);
|
|
||||||
}
|
|
||||||
const tokenResponse = await fetch(doc.token_endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body,
|
|
||||||
}).catch(() => null);
|
|
||||||
if (!tokenResponse?.ok) {
|
|
||||||
this.logger.warn({ status: tokenResponse?.status }, 'oidc: code exchange failed');
|
|
||||||
throw new BadRequestException({ code: 'oidc_exchange_failed' });
|
|
||||||
}
|
|
||||||
const tokens = (await tokenResponse.json()) as { id_token?: string };
|
|
||||||
if (!tokens.id_token) throw new BadRequestException({ code: 'oidc_exchange_failed' });
|
|
||||||
|
|
||||||
let payload: JWTPayload;
|
|
||||||
try {
|
|
||||||
({ payload } = await jwtVerify(tokens.id_token, this.jwks!, {
|
|
||||||
issuer: this.issuer,
|
|
||||||
audience: this.clientId,
|
|
||||||
algorithms: ID_TOKEN_ALGORITHMS,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, 'oidc: id token rejected');
|
|
||||||
throw new BadRequestException({ code: 'oidc_token_invalid' });
|
|
||||||
}
|
|
||||||
if (typeof payload.nonce !== 'string' || payload.nonce !== stored.nonce) {
|
|
||||||
throw new BadRequestException({ code: 'oidc_token_invalid' });
|
|
||||||
}
|
|
||||||
if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
|
|
||||||
throw new BadRequestException({ code: 'oidc_token_invalid' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stored.linkUserId) {
|
|
||||||
await this.linkIdentity(stored.linkUserId, payload.sub);
|
|
||||||
return { sessionToken: null, linked: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.resolveUser(payload);
|
|
||||||
if (user.status === 'DISABLED') {
|
|
||||||
throw new BadRequestException({ code: 'account_disabled' });
|
|
||||||
}
|
|
||||||
// Claim mapping (issue #217): reconcile mapped grants and the managed
|
|
||||||
// site-admin flag against this login's fresh claims — before the
|
|
||||||
// session exists, so the first request already sees the new state.
|
|
||||||
await this.claimMapping.apply(user, payload);
|
|
||||||
const sessionToken = await this.sessions.create(user.id, userAgent);
|
|
||||||
await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'auth.login_succeeded',
|
|
||||||
actorId: user.id,
|
|
||||||
details: { provider: this.provider },
|
|
||||||
});
|
|
||||||
return { sessionToken, linked: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The deliberate linking rule (ADR 0021 §2): only an authenticated user
|
|
||||||
* links an IdP identity to their own account — never automatic by mail. */
|
|
||||||
private async linkIdentity(userId: string, subject: string): Promise<void> {
|
|
||||||
const existing = await this.prisma.userIdentity.findUnique({
|
|
||||||
where: { provider_subject: { provider: this.provider, subject } },
|
|
||||||
});
|
|
||||||
if (existing && existing.userId !== userId) {
|
|
||||||
throw new ConflictException({ code: 'oidc_identity_taken' });
|
|
||||||
}
|
|
||||||
if (!existing) {
|
|
||||||
await this.prisma.userIdentity.create({
|
|
||||||
data: { userId, provider: this.provider, subject },
|
|
||||||
});
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'auth.identity_linked',
|
|
||||||
actorId: userId,
|
|
||||||
details: { provider: this.provider },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveUser(payload: JWTPayload): Promise<User> {
|
|
||||||
const identity = await this.prisma.userIdentity.findUnique({
|
|
||||||
where: { provider_subject: { provider: this.provider, subject: payload.sub! } },
|
|
||||||
});
|
|
||||||
if (identity) {
|
|
||||||
const user = await this.users.findById(identity.userId);
|
|
||||||
if (!user) throw new BadRequestException({ code: 'oidc_token_invalid' });
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First login of this subject: just-in-time creation. The IdP owns the
|
|
||||||
// account lifecycle (ADR 0021), so the account arrives ACTIVE and
|
|
||||||
// mail-verified — provided the IdP says the address is verified.
|
|
||||||
const email = typeof payload.email === 'string' ? payload.email.toLowerCase() : null;
|
|
||||||
if (!email) throw new BadRequestException({ code: 'oidc_email_missing' });
|
|
||||||
if (payload.email_verified === false) {
|
|
||||||
throw new BadRequestException({ code: 'oidc_email_unverified' });
|
|
||||||
}
|
|
||||||
const clash = await this.users.findByEmail(email);
|
|
||||||
if (clash) {
|
|
||||||
// The documented refusal: the local owner of this address must link
|
|
||||||
// explicitly (GET /auth/oidc/link) — silent adoption would be an
|
|
||||||
// account-takeover path (ADR 0021 §2).
|
|
||||||
throw new ConflictException({ code: 'oidc_link_required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferred =
|
|
||||||
typeof payload.preferred_username === 'string' && payload.preferred_username
|
|
||||||
? payload.preferred_username
|
|
||||||
: email.split('@')[0]!;
|
|
||||||
const displayName =
|
|
||||||
typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : preferred;
|
|
||||||
const username = await this.uniqueUsername(slugify(preferred) || 'user');
|
|
||||||
|
|
||||||
const user = await this.prisma.$transaction(async (tx) => {
|
|
||||||
const created = await tx.user.create({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
displayName,
|
|
||||||
locale: 'en',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
emailVerifiedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.userIdentity.create({
|
|
||||||
data: { userId: created.id, provider: this.provider, subject: payload.sub! },
|
|
||||||
});
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
// Same invariant as e-mail verification: every active account owns a
|
|
||||||
// personal pond (idempotent).
|
|
||||||
await this.ponds.ensurePersonalPond(user);
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'auth.signup',
|
|
||||||
actorId: user.id,
|
|
||||||
details: { provider: this.provider },
|
|
||||||
});
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async uniqueUsername(base: string): Promise<string> {
|
|
||||||
const taken = new Set(
|
|
||||||
(
|
|
||||||
await this.prisma.user.findMany({
|
|
||||||
where: { OR: [{ username: base }, { username: { startsWith: `${base}-` } }] },
|
|
||||||
select: { username: true },
|
|
||||||
})
|
|
||||||
).map((row) => row.username),
|
|
||||||
);
|
|
||||||
if (!taken.has(base)) return base;
|
|
||||||
for (let n = 2; ; n += 1) {
|
|
||||||
const candidate = `${base}-${n}`;
|
|
||||||
if (!taken.has(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
const HEADER = 'x-auth-user';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trusted reverse-proxy authentication (issue #215, ADR 0021): off by
|
|
||||||
* default (header fully ignored), identity only from a trusted TCP peer, a
|
|
||||||
* spoofing peer rejected AND audited, no privilege escalation past a
|
|
||||||
* riding-along session cookie, and the mTLS variant mapping a forwarded
|
|
||||||
* certificate DN attribute.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('trusted-proxy identity (e2e, issue #215)', () => {
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const password = 'proxy identitaet 123';
|
|
||||||
|
|
||||||
const PROXY_ENV = ['AUTH_PROXY_HEADER', 'AUTH_PROXY_TRUSTED_PEERS', 'AUTH_PROXY_MODE'] as const;
|
|
||||||
|
|
||||||
async function bootApp(env: Partial<Record<(typeof PROXY_ENV)[number], string>>) {
|
|
||||||
for (const key of PROXY_ENV) delete process.env[key];
|
|
||||||
Object.assign(process.env, env);
|
|
||||||
return createTestApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeUser(app: INestApplication, handle: string) {
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const user = await users.createUser({
|
|
||||||
username: `${handle}-${suffix}`,
|
|
||||||
email: `${handle}-${suffix}@example.test`,
|
|
||||||
displayName: handle,
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await users.markEmailVerified(user.id);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
for (const key of PROXY_ENV) delete process.env[key];
|
|
||||||
await prisma.auditEntry.deleteMany({ where: { action: 'auth.proxy_rejected' } });
|
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores the header entirely while the feature is off', async () => {
|
|
||||||
const app = await bootApp({});
|
|
||||||
try {
|
|
||||||
await makeUser(app, 'off');
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.get('/api/v1/auth/me')
|
|
||||||
.set(HEADER, `off-${suffix}`)
|
|
||||||
.expect(401);
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticates a trusted peer, maps by username, and never escalates past a session cookie', async () => {
|
|
||||||
const app = await bootApp({
|
|
||||||
AUTH_PROXY_HEADER: HEADER,
|
|
||||||
AUTH_PROXY_TRUSTED_PEERS: '127.0.0.1',
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const alice = await makeUser(app, 'alice');
|
|
||||||
const bob = await makeUser(app, 'bob');
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
const me = await api().get('/api/v1/auth/me').set(HEADER, alice.username).expect(200);
|
|
||||||
expect(me.body.id).toBe(alice.id);
|
|
||||||
|
|
||||||
// Unknown identity: authenticated by nobody.
|
|
||||||
await api().get('/api/v1/auth/me').set(HEADER, `ghost-${suffix}`).expect(401);
|
|
||||||
|
|
||||||
// A session cookie riding along never escalates beyond the header
|
|
||||||
// identity: bob's cookie plus alice's header acts as alice.
|
|
||||||
const login = await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: bob.username, password })
|
|
||||||
.expect(200);
|
|
||||||
const both = await api()
|
|
||||||
.get('/api/v1/auth/me')
|
|
||||||
.set('Cookie', sessionCookieOf(login))
|
|
||||||
.set(HEADER, alice.username)
|
|
||||||
.expect(200);
|
|
||||||
expect(both.body.id).toBe(alice.id);
|
|
||||||
// Without the header the same cookie still works normally.
|
|
||||||
const cookieOnly = await api()
|
|
||||||
.get('/api/v1/auth/me')
|
|
||||||
.set('Cookie', sessionCookieOf(login))
|
|
||||||
.expect(200);
|
|
||||||
expect(cookieOnly.body.id).toBe(bob.id);
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects and audits the header from an untrusted peer — even with a valid session', async () => {
|
|
||||||
const app = await bootApp({
|
|
||||||
AUTH_PROXY_HEADER: HEADER,
|
|
||||||
AUTH_PROXY_TRUSTED_PEERS: '203.0.113.9',
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const carol = await makeUser(app, 'carol');
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
await api().get('/api/v1/auth/me').set(HEADER, carol.username).expect(403);
|
|
||||||
const audit = await prisma.auditEntry.findFirst({
|
|
||||||
where: { action: 'auth.proxy_rejected' },
|
|
||||||
orderBy: { at: 'desc' },
|
|
||||||
});
|
|
||||||
expect(audit?.details).toMatchObject({ header: HEADER });
|
|
||||||
|
|
||||||
const login = await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: carol.username, password })
|
|
||||||
.expect(200);
|
|
||||||
// The spoofed header poisons the request even alongside a valid
|
|
||||||
// cookie — rejecting is safer than guessing which identity wins.
|
|
||||||
await api()
|
|
||||||
.get('/api/v1/auth/me')
|
|
||||||
.set('Cookie', sessionCookieOf(login))
|
|
||||||
.set(HEADER, carol.username)
|
|
||||||
.expect(403);
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps the configured DN attribute in mtls-dn mode', async () => {
|
|
||||||
const app = await bootApp({
|
|
||||||
AUTH_PROXY_HEADER: HEADER,
|
|
||||||
AUTH_PROXY_TRUSTED_PEERS: '127.0.0.1',
|
|
||||||
AUTH_PROXY_MODE: 'mtls-dn',
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const dana = await makeUser(app, 'dana');
|
|
||||||
const me = await request(app.getHttpServer())
|
|
||||||
.get('/api/v1/auth/me')
|
|
||||||
.set(HEADER, `CN=${dana.username},OU=unit,O=example`)
|
|
||||||
.expect(200);
|
|
||||||
expect(me.body.id).toBe(dana.id);
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { User } from '@prisma/client';
|
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
import type { AuthedRequest } from './auth.guard';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trusted reverse-proxy authentication (issue #215, ADR 0021): the
|
|
||||||
* perimeter (proxy or mTLS terminator) authenticates and forwards the
|
|
||||||
* identity in a configured header; the application trusts that header ONLY
|
|
||||||
* when the request's TCP peer is on the configured allowlist.
|
|
||||||
*
|
|
||||||
* The trust boundary, stated plainly (security.md §External
|
|
||||||
* authentication): everything upstream of the configured peers is the
|
|
||||||
* operator's responsibility; the application's contribution is that the
|
|
||||||
* header is worthless from anywhere else — a header from an untrusted peer
|
|
||||||
* rejects the request outright and lands in the audit trail
|
|
||||||
* (`auth.proxy_rejected`), because someone is attempting a spoof.
|
|
||||||
*
|
|
||||||
* Deliberately NO just-in-time creation here: the header carries no
|
|
||||||
* verified e-mail, so accounts must already exist (the IdP/OIDC path or an
|
|
||||||
* admin creates them) and are mapped by username or e-mail — explicit
|
|
||||||
* configuration, never guessed.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ProxyIdentityService {
|
|
||||||
constructor(
|
|
||||||
private readonly users: UsersService,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
private readonly config: AppConfig,
|
|
||||||
private readonly logger: PinoLogger,
|
|
||||||
) {
|
|
||||||
this.logger.setContext(ProxyIdentityService.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enabled only with BOTH the header name and a non-empty allowlist. */
|
|
||||||
get enabled(): boolean {
|
|
||||||
return Boolean(
|
|
||||||
this.config.env.AUTH_PROXY_HEADER && this.config.env.AUTH_PROXY_TRUSTED_PEERS.length > 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the request's proxy identity, or null when the feature is off
|
|
||||||
* or the header is absent. Throws 403 (audited) for an untrusted peer
|
|
||||||
* carrying the header, 401 for an unknown identity.
|
|
||||||
*/
|
|
||||||
async resolve(request: AuthedRequest): Promise<User | null> {
|
|
||||||
if (!this.enabled) return null;
|
|
||||||
const headerName = this.config.env.AUTH_PROXY_HEADER!.toLowerCase();
|
|
||||||
const raw = request.headers[headerName];
|
|
||||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
||||||
if (!value) return null;
|
|
||||||
|
|
||||||
const peer = normalizePeer(request.socket.remoteAddress ?? '');
|
|
||||||
const trusted = this.config.env.AUTH_PROXY_TRUSTED_PEERS.map(normalizePeer);
|
|
||||||
if (!trusted.includes(peer)) {
|
|
||||||
// A spoof attempt, not a misconfiguration: reject and evidence it.
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'auth.proxy_rejected',
|
|
||||||
details: { peer, header: headerName },
|
|
||||||
});
|
|
||||||
throw new ForbiddenException({ code: 'proxy_peer_untrusted' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const identity = this.extractIdentity(value);
|
|
||||||
if (!identity) throw new UnauthorizedException({ code: 'proxy_identity_unknown' });
|
|
||||||
const user =
|
|
||||||
this.config.env.AUTH_PROXY_MAP === 'email'
|
|
||||||
? await this.users.findByEmail(identity)
|
|
||||||
: await this.users.findByUsernameOrEmail(identity);
|
|
||||||
if (!user || user.status !== 'ACTIVE') {
|
|
||||||
throw new UnauthorizedException({ code: 'proxy_identity_unknown' });
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `plain`: the value is the identity. `mtls-dn`: the value is a client
|
|
||||||
* certificate subject DN as forwarded by the TLS terminator; the identity
|
|
||||||
* is the configured attribute (default CN). */
|
|
||||||
private extractIdentity(value: string): string | null {
|
|
||||||
if (this.config.env.AUTH_PROXY_MODE === 'plain') return value.trim() || null;
|
|
||||||
const attribute = this.config.env.AUTH_PROXY_DN_ATTRIBUTE.toLowerCase();
|
|
||||||
for (const part of value.split(/[,/]/)) {
|
|
||||||
const [key, ...rest] = part.split('=');
|
|
||||||
if (key?.trim().toLowerCase() === attribute) {
|
|
||||||
const extracted = rest.join('=').trim();
|
|
||||||
return extracted || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `::ffff:127.0.0.1` and `127.0.0.1` are the same peer. */
|
|
||||||
function normalizePeer(address: string): string {
|
|
||||||
return address.replace(/^::ffff:/i, '').trim();
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
import { afterAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
import { SessionsService, hashSessionToken } from './sessions.service';
|
|
||||||
|
|
||||||
const HOUR = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/** A config stub with just the session bounds (absolute 2 h, idle 1 h). */
|
|
||||||
function configWith(absoluteHours: number, idleHours: number): AppConfig {
|
|
||||||
return {
|
|
||||||
env: { SESSION_ABSOLUTE_HOURS: absoluteHours, SESSION_IDLE_HOURS: idleHours },
|
|
||||||
} as AppConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.skipIf(!hasTestDb)('SessionsService bounds (database, issue #190)', () => {
|
|
||||||
const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!;
|
|
||||||
const sessions = hasTestDb ? new SessionsService(prisma, configWith(2, 1)) : null!;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
let userId: string;
|
|
||||||
|
|
||||||
async function makeUser(): Promise<string> {
|
|
||||||
if (userId) return userId;
|
|
||||||
const users = new UsersService(prisma);
|
|
||||||
const user = await users.createUser({
|
|
||||||
username: `sess-${suffix}`,
|
|
||||||
email: `sess-${suffix}@example.org`,
|
|
||||||
displayName: 'Sess Test',
|
|
||||||
password: 'session bounds pass 1',
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
userId = user.id;
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates a session and rewrites its timestamps to simulate age. */
|
|
||||||
async function sessionAgedTo(expiresInMs: number, lastSeenAgoMs: number): Promise<string> {
|
|
||||||
const raw = await sessions.create(await makeUser(), 'test-agent');
|
|
||||||
await prisma.session.update({
|
|
||||||
where: { id: hashSessionToken(raw) },
|
|
||||||
data: {
|
|
||||||
expiresAt: new Date(Date.now() + expiresInMs),
|
|
||||||
lastSeenAt: new Date(Date.now() - lastSeenAgoMs),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (!hasTestDb) return;
|
|
||||||
await prisma.session.deleteMany({ where: { userId } });
|
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId } });
|
|
||||||
await prisma.user.deleteMany({ where: { id: userId } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets the absolute bound at creation', async () => {
|
|
||||||
const raw = await sessions.create(await makeUser(), 'test-agent');
|
|
||||||
const row = await prisma.session.findUniqueOrThrow({ where: { id: hashSessionToken(raw) } });
|
|
||||||
const msLeft = row.expiresAt.getTime() - Date.now();
|
|
||||||
expect(msLeft).toBeGreaterThan(1.9 * HOUR);
|
|
||||||
expect(msLeft).toBeLessThanOrEqual(2 * HOUR);
|
|
||||||
await sessions.destroyByRawToken(raw);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a session past its absolute bound and removes the row', async () => {
|
|
||||||
const raw = await sessionAgedTo(-1000, 0);
|
|
||||||
expect(await sessions.validate(raw)).toBeNull();
|
|
||||||
expect(await prisma.session.findUnique({ where: { id: hashSessionToken(raw) } })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a session idle past the idle bound even before its absolute bound', async () => {
|
|
||||||
const raw = await sessionAgedTo(HOUR, 1.5 * HOUR);
|
|
||||||
expect(await sessions.validate(raw)).toBeNull();
|
|
||||||
expect(await prisma.session.findUnique({ where: { id: hashSessionToken(raw) } })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renews the idle bound on active use but never extends the absolute bound', async () => {
|
|
||||||
const raw = await sessionAgedTo(HOUR, 0.5 * HOUR);
|
|
||||||
const before = await prisma.session.findUniqueOrThrow({
|
|
||||||
where: { id: hashSessionToken(raw) },
|
|
||||||
});
|
|
||||||
expect(await sessions.validate(raw)).not.toBeNull();
|
|
||||||
const after = await prisma.session.findUniqueOrThrow({ where: { id: hashSessionToken(raw) } });
|
|
||||||
expect(after.lastSeenAt.getTime()).toBeGreaterThan(before.lastSeenAt.getTime());
|
|
||||||
expect(after.expiresAt.getTime()).toBe(before.expiresAt.getTime());
|
|
||||||
await sessions.destroyByRawToken(raw);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides idle-expired sessions from the session list', async () => {
|
|
||||||
const live = await sessionAgedTo(HOUR, 0);
|
|
||||||
const idle = await sessionAgedTo(HOUR, 1.5 * HOUR);
|
|
||||||
const listed = await sessions.listForUser(userId);
|
|
||||||
const ids = listed.map((s) => s.id);
|
|
||||||
expect(ids).toContain(hashSessionToken(live));
|
|
||||||
expect(ids).not.toContain(hashSessionToken(idle));
|
|
||||||
await sessions.destroyByRawToken(live);
|
|
||||||
await sessions.destroyByRawToken(idle);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -3,52 +3,24 @@ import { createHash, randomBytes } from 'node:crypto';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Session, User } from '@prisma/client';
|
import { Session, User } from '@prisma/client';
|
||||||
|
|
||||||
import type { ApiEnv } from '@dorfteich/shared';
|
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // sliding 30 days
|
||||||
|
const REFRESH_AT_MOST_EVERY_MS = 60 * 60 * 1000; // avoid write storms
|
||||||
|
|
||||||
export interface ValidatedSession {
|
export interface ValidatedSession {
|
||||||
session: Session;
|
session: Session;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The absolute session bound — also the cookie `maxAge` (auth.guard.ts). */
|
|
||||||
export function sessionAbsoluteMs(env: ApiEnv): number {
|
|
||||||
return env.SESSION_ABSOLUTE_HOURS * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opaque server-side sessions (ADR 0007). The cookie value is 32 random
|
* Opaque server-side sessions (ADR 0007). The cookie value is 32 random
|
||||||
* bytes; the database stores only its SHA-256 hash as the row id, so a
|
* bytes; the database stores only its SHA-256 hash as the row id, so a
|
||||||
* database leak cannot be replayed as cookies.
|
* database leak cannot be replayed as cookies.
|
||||||
*
|
|
||||||
* Two configurable bounds (issue #190): `expiresAt` is the ABSOLUTE limit,
|
|
||||||
* set once at creation and never extended; the IDLE limit is enforced
|
|
||||||
* server-side against `lastSeenAt`, which active use renews. The old
|
|
||||||
* sliding 30-day expiry is gone — activity keeps a session alive only up
|
|
||||||
* to the absolute bound.
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionsService {
|
export class SessionsService {
|
||||||
constructor(
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly config: AppConfig,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private absoluteMs(): number {
|
|
||||||
return sessionAbsoluteMs(this.config.env);
|
|
||||||
}
|
|
||||||
|
|
||||||
private idleMs(): number {
|
|
||||||
// An idle bound above the absolute one would never fire anyway.
|
|
||||||
return Math.min(this.config.env.SESSION_IDLE_HOURS * 60 * 60 * 1000, this.absoluteMs());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `lastSeenAt` write throttle: fine-grained enough for the idle bound. */
|
|
||||||
private refreshAtMostEveryMs(): number {
|
|
||||||
return Math.min(60 * 60 * 1000, Math.floor(this.idleMs() / 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(userId: string, userAgent: string | undefined): Promise<string> {
|
async create(userId: string, userAgent: string | undefined): Promise<string> {
|
||||||
const raw = randomBytes(32).toString('base64url');
|
const raw = randomBytes(32).toString('base64url');
|
||||||
@ -56,7 +28,7 @@ export class SessionsService {
|
|||||||
data: {
|
data: {
|
||||||
id: hashSessionToken(raw),
|
id: hashSessionToken(raw),
|
||||||
userId,
|
userId,
|
||||||
expiresAt: new Date(Date.now() + this.absoluteMs()),
|
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
|
||||||
userAgent: summarizeUserAgent(userAgent),
|
userAgent: summarizeUserAgent(userAgent),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -68,32 +40,20 @@ export class SessionsService {
|
|||||||
where: { id: hashSessionToken(raw) },
|
where: { id: hashSessionToken(raw) },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
});
|
});
|
||||||
if (!session) return null;
|
if (!session || session.expiresAt <= new Date()) return null;
|
||||||
const now = Date.now();
|
|
||||||
const idleExpired = now - session.lastSeenAt.getTime() >= this.idleMs();
|
|
||||||
if (session.expiresAt.getTime() <= now || idleExpired) {
|
|
||||||
// Expired either way — remove the row so the session list stays truthful.
|
|
||||||
await this.prisma.session.deleteMany({ where: { id: session.id } });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (session.user.status === 'DISABLED') return null;
|
if (session.user.status === 'DISABLED') return null;
|
||||||
|
|
||||||
// Renew the idle bound (throttled against write storms); the absolute
|
// Sliding expiration, refreshed at most once per hour.
|
||||||
// `expiresAt` is deliberately never touched.
|
if (Date.now() - session.lastSeenAt.getTime() > REFRESH_AT_MOST_EVERY_MS) {
|
||||||
if (now - session.lastSeenAt.getTime() > this.refreshAtMostEveryMs()) {
|
|
||||||
await this.prisma.session.update({
|
await this.prisma.session.update({
|
||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
data: { lastSeenAt: new Date(now) },
|
data: { lastSeenAt: new Date(), expiresAt: new Date(Date.now() + SESSION_TTL_MS) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { user, ...bare } = session;
|
const { user, ...bare } = session;
|
||||||
return { session: bare as Session, user };
|
return { session: bare as Session, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
private idleCutoff(now: number): Date {
|
|
||||||
return new Date(now - this.idleMs());
|
|
||||||
}
|
|
||||||
|
|
||||||
async destroyByRawToken(raw: string): Promise<void> {
|
async destroyByRawToken(raw: string): Promise<void> {
|
||||||
await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } });
|
await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } });
|
||||||
}
|
}
|
||||||
@ -113,13 +73,8 @@ export class SessionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listForUser(userId: string): Promise<Session[]> {
|
listForUser(userId: string): Promise<Session[]> {
|
||||||
// Both bounds, so an idle-expired session never shows as active.
|
|
||||||
return this.prisma.session.findMany({
|
return this.prisma.session.findMany({
|
||||||
where: {
|
where: { userId, expiresAt: { gt: new Date() } },
|
||||||
userId,
|
|
||||||
expiresAt: { gt: new Date() },
|
|
||||||
lastSeenAt: { gt: this.idleCutoff(Date.now()) },
|
|
||||||
},
|
|
||||||
orderBy: { lastSeenAt: 'desc' },
|
orderBy: { lastSeenAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
import type { RestoreStatusResponse } from '@dorfteich/shared';
|
|
||||||
|
|
||||||
import { Public } from '../auth/auth.guard';
|
|
||||||
import { SetupExempt } from '../setup/setup.guard';
|
|
||||||
import { MaintenanceExempt } from './maintenance.guard';
|
|
||||||
import { MaintenanceStateService } from './maintenance-state.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The status page behind maintenance mode (issue #103): while an in-app
|
|
||||||
* restore runs, this is the one application endpoint that keeps answering —
|
|
||||||
* the SPA's maintenance screen polls it to show progress and to know when
|
|
||||||
* to reload. Public: everyone hitting the instance mid-restore deserves the
|
|
||||||
* honest answer, and the status carries nothing sensitive.
|
|
||||||
*/
|
|
||||||
@Controller('backup')
|
|
||||||
export class BackupStatusController {
|
|
||||||
constructor(private readonly state: MaintenanceStateService) {}
|
|
||||||
|
|
||||||
@Get('restore-status')
|
|
||||||
@Public()
|
|
||||||
@SetupExempt()
|
|
||||||
@MaintenanceExempt()
|
|
||||||
restoreStatus(): RestoreStatusResponse {
|
|
||||||
return this.state.current() ?? { state: 'idle' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
isBackupTargetAllowed,
|
|
||||||
type BackupConnectionTestResult,
|
|
||||||
type BackupSettingsView,
|
|
||||||
} from '@dorfteich/shared';
|
|
||||||
import { webdavCheck, type WebDavTarget } from '@dorfteich/shared/webdav';
|
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
import { SecretStoreService } from '../config/secret-store.service';
|
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The api's view of the Nextcloud backup target (issue #103): instance
|
|
||||||
* settings hold everything non-secret, the app password lives in the
|
|
||||||
* wizard-written secret store (security.md §Secrets) under the key the
|
|
||||||
* backup sidecar reads. This service resolves, tests, and persists the
|
|
||||||
* combination — it never returns the password.
|
|
||||||
*/
|
|
||||||
export const NEXTCLOUD_PASSWORD_SECRET_KEY = 'BACKUP_NEXTCLOUD_PASSWORD';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BackupTargetService {
|
|
||||||
constructor(
|
|
||||||
private readonly settings: InstanceSettingsService,
|
|
||||||
private readonly secretStore: SecretStoreService,
|
|
||||||
private readonly config: AppConfig,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Deploy-level allowlist (issue #192): empty = remote targets disabled. */
|
|
||||||
allowlist(): string[] {
|
|
||||||
return this.config.env.BACKUP_ALLOWED_TARGETS;
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteAllowed(): boolean {
|
|
||||||
return this.allowlist().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
targetAllowed(target: string): boolean {
|
|
||||||
return isBackupTargetAllowed(this.allowlist(), target);
|
|
||||||
}
|
|
||||||
|
|
||||||
async settingsView(): Promise<BackupSettingsView> {
|
|
||||||
return {
|
|
||||||
localRetentionDays: await this.settings.get('backup.localRetentionDays'),
|
|
||||||
remoteRetentionDays: await this.settings.get('backup.remoteRetentionDays'),
|
|
||||||
remoteTargets: { allowed: this.remoteAllowed(), allowlist: this.allowlist() },
|
|
||||||
nextcloud: {
|
|
||||||
enabled: await this.settings.get('backup.nextcloud.enabled'),
|
|
||||||
baseUrl: await this.settings.get('backup.nextcloud.baseUrl'),
|
|
||||||
username: await this.settings.get('backup.nextcloud.username'),
|
|
||||||
folder: await this.settings.get('backup.nextcloud.folder'),
|
|
||||||
uploadSchedule: await this.settings.get('backup.nextcloud.uploadSchedule'),
|
|
||||||
passwordSet: Boolean(this.storedPassword()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The effective WebDAV target, or null when disabled or not fully
|
|
||||||
* configured — the exact resolution the sidecar applies on its side.
|
|
||||||
*/
|
|
||||||
async resolveTarget(): Promise<WebDavTarget | null> {
|
|
||||||
const view = await this.settingsView();
|
|
||||||
const password = this.storedPassword();
|
|
||||||
const { enabled, baseUrl, username, folder } = view.nextcloud;
|
|
||||||
if (!enabled || !baseUrl || !username || !password) return null;
|
|
||||||
// Policy backstop (issue #192): a configured target outside the deploy
|
|
||||||
// allowlist behaves like no target at all.
|
|
||||||
if (!this.targetAllowed(baseUrl)) return null;
|
|
||||||
return { baseUrl, username, password, folder };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Live connection test (credentials + folder, creating missing folder
|
|
||||||
* segments) for the admin "test connection" button. An empty password
|
|
||||||
* falls back to the stored one, so a saved configuration can be re-tested
|
|
||||||
* without re-entering the secret.
|
|
||||||
*/
|
|
||||||
async testConnection(candidate: {
|
|
||||||
baseUrl: string;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
password?: string;
|
|
||||||
}): Promise<BackupConnectionTestResult> {
|
|
||||||
const password = candidate.password || this.storedPassword();
|
|
||||||
if (!password) return { ok: false, error: 'no app password provided or stored' };
|
|
||||||
const result = await webdavCheck({
|
|
||||||
baseUrl: candidate.baseUrl,
|
|
||||||
username: candidate.username,
|
|
||||||
folder: candidate.folder,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stores a new app password; empty input keeps the current one. */
|
|
||||||
async storePassword(password: string): Promise<void> {
|
|
||||||
if (!password) return;
|
|
||||||
await this.secretStore.set({ [NEXTCLOUD_PASSWORD_SECRET_KEY]: password });
|
|
||||||
}
|
|
||||||
|
|
||||||
storedPassword(): string {
|
|
||||||
return this.secretStore.read()[NEXTCLOUD_PASSWORD_SECRET_KEY] ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
|
||||||
|
|
||||||
import { SettingsModule } from '../settings/settings.module';
|
|
||||||
import { BackupStatusController } from './backup-status.controller';
|
|
||||||
import { BackupTargetService } from './backup-target.service';
|
|
||||||
import { MaintenanceGuard } from './maintenance.guard';
|
|
||||||
import { MaintenanceStateService } from './maintenance-state.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup/restore integration of the api (issue #103): the maintenance gate
|
|
||||||
* around in-app restores plus the Nextcloud target resolution. Imported in
|
|
||||||
* AppModule BEFORE SetupModule on purpose — global guards run in
|
|
||||||
* registration order, and mid-restore nothing (not even the setup gate's
|
|
||||||
* database read) should touch the database.
|
|
||||||
*/
|
|
||||||
@Module({
|
|
||||||
imports: [SettingsModule],
|
|
||||||
controllers: [BackupStatusController],
|
|
||||||
providers: [
|
|
||||||
BackupTargetService,
|
|
||||||
MaintenanceStateService,
|
|
||||||
{ provide: APP_GUARD, useClass: MaintenanceGuard },
|
|
||||||
],
|
|
||||||
exports: [BackupTargetService, MaintenanceStateService],
|
|
||||||
})
|
|
||||||
export class BackupModule {}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
RESTORE_STALE_MAX_AGE_MINUTES,
|
|
||||||
RESTORE_STATUS_FILE,
|
|
||||||
type RestoreStatus,
|
|
||||||
} from '@dorfteich/shared';
|
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 1500;
|
|
||||||
const WATCH_INTERVAL_MS = 2000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors the sidecar's `restore-status.json` (issue #103): while a restore
|
|
||||||
* is `running` the maintenance guard answers 503, and once it flips to
|
|
||||||
* `succeeded` this service restarts the api — the restored database
|
|
||||||
* invalidates every in-process cache (settings, permissions, setup state),
|
|
||||||
* and a fresh boot also runs `migrate deploy` for sets from older versions.
|
|
||||||
* Docker's `unless-stopped` policy brings the container back up.
|
|
||||||
*
|
|
||||||
* A `running` state older than {@link RESTORE_STALE_MAX_AGE_MINUTES} counts
|
|
||||||
* as crashed (the sidecar died before writing a final state), so the
|
|
||||||
* instance never stays bricked behind the gate.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class MaintenanceStateService implements OnModuleInit, OnModuleDestroy {
|
|
||||||
private cached: { status: RestoreStatus | null; readAt: number } | null = null;
|
|
||||||
private watcher: NodeJS.Timeout | null = null;
|
|
||||||
private sawRunning = false;
|
|
||||||
private restartScheduled = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly config: AppConfig,
|
|
||||||
private readonly logger: PinoLogger,
|
|
||||||
) {
|
|
||||||
this.logger.setContext(MaintenanceStateService.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
onModuleInit(): void {
|
|
||||||
if (this.config.env.NODE_ENV === 'test') return;
|
|
||||||
// Poll independently of traffic: the restart must also happen when no
|
|
||||||
// request arrives while the instance sits in maintenance.
|
|
||||||
this.watcher = setInterval(() => this.observe(), WATCH_INTERVAL_MS);
|
|
||||||
this.watcher.unref?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
onModuleDestroy(): void {
|
|
||||||
if (this.watcher) clearInterval(this.watcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The current restore status (short cache — the guard reads per request). */
|
|
||||||
current(): RestoreStatus | null {
|
|
||||||
const now = Date.now();
|
|
||||||
if (this.cached && now - this.cached.readAt < CACHE_TTL_MS) return this.cached.status;
|
|
||||||
const status = this.read();
|
|
||||||
this.cached = { status, readAt: now };
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the instance is in maintenance (a fresh, running restore). */
|
|
||||||
isActive(): boolean {
|
|
||||||
const status = this.current();
|
|
||||||
if (!status || status.state !== 'running') return false;
|
|
||||||
const ageMinutes = (Date.now() - new Date(status.startedAt).getTime()) / 60_000;
|
|
||||||
if (!Number.isFinite(ageMinutes) || ageMinutes > RESTORE_STALE_MAX_AGE_MINUTES) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private observe(): void {
|
|
||||||
this.cached = null;
|
|
||||||
if (this.isActive()) {
|
|
||||||
if (!this.sawRunning) {
|
|
||||||
this.sawRunning = true;
|
|
||||||
this.logger.warn({}, 'restore running — maintenance mode active');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.sawRunning) return;
|
|
||||||
const status = this.current();
|
|
||||||
this.sawRunning = false;
|
|
||||||
if (status?.state === 'succeeded' && !this.restartScheduled) {
|
|
||||||
this.restartScheduled = true;
|
|
||||||
this.logger.warn(
|
|
||||||
{ backupId: status.backupId },
|
|
||||||
'restore succeeded — restarting the api for a clean boot on the restored database',
|
|
||||||
);
|
|
||||||
if (this.config.env.NODE_ENV === 'production') {
|
|
||||||
// A short delay lets the log line flush; docker restarts the container.
|
|
||||||
setTimeout(() => process.exit(0), 1000).unref?.();
|
|
||||||
} else {
|
|
||||||
this.logger.warn({}, 'non-production api: restart the api process manually now');
|
|
||||||
}
|
|
||||||
} else if (status?.state === 'failed') {
|
|
||||||
this.logger.error(
|
|
||||||
{ backupId: status.backupId, error: status.error },
|
|
||||||
'restore failed — instance left maintenance mode without restoring',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private read(): RestoreStatus | null {
|
|
||||||
const path = join(this.config.env.BACKUPS_DIR, RESTORE_STATUS_FILE);
|
|
||||||
if (!existsSync(path)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(path, 'utf8')) as RestoreStatus;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
Injectable,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
SetMetadata,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
|
|
||||||
import { MaintenanceStateService } from './maintenance-state.service';
|
|
||||||
|
|
||||||
const MAINTENANCE_EXEMPT_KEY = 'maintenanceExempt';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks routes that stay reachable while an in-app restore runs: the health
|
|
||||||
* probes (monitors must keep seeing the instance) and the restore status
|
|
||||||
* endpoint the maintenance screen polls.
|
|
||||||
*/
|
|
||||||
export const MaintenanceExempt = (): MethodDecorator & ClassDecorator =>
|
|
||||||
SetMetadata(MAINTENANCE_EXEMPT_KEY, true);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global first-line guard (registered before SetupModule and AuthModule via
|
|
||||||
* module order): while the backup sidecar restores the database, every
|
|
||||||
* non-exempt route answers 503 `maintenance_mode` (issue #103) — nothing
|
|
||||||
* may read or write mid-restore state.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class MaintenanceGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly state: MaintenanceStateService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const exempt = this.reflector.getAllAndOverride<boolean>(MAINTENANCE_EXEMPT_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
if (exempt) return true;
|
|
||||||
if (this.state.isActive()) {
|
|
||||||
throw new ServiceUnavailableException({ code: 'maintenance_mode' });
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filesystem binding for branding assets (issue #306; pond overrides #307).
|
|
||||||
*
|
|
||||||
* One flat directory of PNGs named by a caller-supplied key
|
|
||||||
* (`instance-logo-light`, later `pond-<id>-favicon-32`). Flat because there
|
|
||||||
* are a handful of files per instance and the backup archives the directory
|
|
||||||
* as a whole — a tree would buy nothing and cost a traversal question.
|
|
||||||
*
|
|
||||||
* The key is constrained here rather than trusted from the route: it is the
|
|
||||||
* only thing between a request parameter and a path.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class BrandingStorageService {
|
|
||||||
constructor(private readonly config: AppConfig) {}
|
|
||||||
|
|
||||||
/** Lowercase, digits and dashes only — no dot, so no `..`, and no slash,
|
|
||||||
* so the file cannot leave the directory whatever a caller sends. */
|
|
||||||
private pathFor(key: string): string {
|
|
||||||
if (!/^[a-z0-9-]{1,120}$/.test(key)) throw new Error(`invalid branding key: ${key}`);
|
|
||||||
return join(this.config.env.BRANDING_DIR, `${key}.png`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(key: string, bytes: Buffer): Promise<void> {
|
|
||||||
await mkdir(this.config.env.BRANDING_DIR, { recursive: true });
|
|
||||||
await writeFile(this.pathFor(key), bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bytes, or null when the file is absent — a missing asset is a normal
|
|
||||||
* state here (nothing uploaded, or metadata and disk drifted after a
|
|
||||||
* partial restore), and every caller has a fallback. */
|
|
||||||
async read(key: string): Promise<Buffer | null> {
|
|
||||||
try {
|
|
||||||
return await readFile(this.pathFor(key));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Idempotent: removing what is not there is success. */
|
|
||||||
async remove(key: string): Promise<void> {
|
|
||||||
await rm(this.pathFor(key), { force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
UploadedFiles,
|
|
||||||
UseGuards,
|
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
|
||||||
import {
|
|
||||||
BrandingView,
|
|
||||||
FAVICON_SIZES,
|
|
||||||
FaviconSize,
|
|
||||||
LOGO_VARIANTS,
|
|
||||||
LogoVariant,
|
|
||||||
MAX_BRANDING_BYTES,
|
|
||||||
PondBranding,
|
|
||||||
} from '@dorfteich/shared';
|
|
||||||
import type { Response } from 'express';
|
|
||||||
|
|
||||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
|
||||||
import { AuthedRequest, Public } from '../auth/auth.guard';
|
|
||||||
import { RequiresPondRole } from '../permissions/permission.decorators';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { BrandingService } from './branding.service';
|
|
||||||
|
|
||||||
function parseVariant(value: unknown): LogoVariant {
|
|
||||||
if (!LOGO_VARIANTS.includes(value as LogoVariant)) {
|
|
||||||
throw new BadRequestException({ code: 'bad_request' });
|
|
||||||
}
|
|
||||||
return value as LogoVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public branding surface (issue #306).
|
|
||||||
*
|
|
||||||
* Unauthenticated by design and worth stating plainly in the admin UI: the
|
|
||||||
* login screen carries the branding and the browser fetches the favicon before
|
|
||||||
* anyone signs in, so an operator's logo IS visible to anonymous visitors.
|
|
||||||
*/
|
|
||||||
@Controller('branding')
|
|
||||||
export class BrandingController {
|
|
||||||
constructor(private readonly branding: BrandingService) {}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Get()
|
|
||||||
view(): Promise<BrandingView> {
|
|
||||||
return this.branding.view();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Get('logo')
|
|
||||||
async logo(
|
|
||||||
@Query('variant') variant: string | undefined,
|
|
||||||
@Query('pond') pondId: string | undefined,
|
|
||||||
@Res() res: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const wanted = parseVariant(variant ?? 'light');
|
|
||||||
// A pond scope serves the pond's own bytes and nothing else: the caller
|
|
||||||
// already resolved WHICH level applies (`resolveBranding`), so silently
|
|
||||||
// falling back here would mix variants across levels — exactly what #307
|
|
||||||
// forbids.
|
|
||||||
const bytes = pondId
|
|
||||||
? await this.branding.pondLogoBytes(pondId, wanted)
|
|
||||||
: await this.branding.logoBytes(wanted);
|
|
||||||
// No shipped default: without a logo the app renders the instance NAME as
|
|
||||||
// text, so an empty answer here is the honest one.
|
|
||||||
if (!bytes) {
|
|
||||||
res.status(404).json({ code: 'not_found', message: 'no logo' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.setHeader('Content-Type', 'image/png');
|
|
||||||
// The caller puts the content hash in the query string, so a given URL
|
|
||||||
// never changes what it points at.
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
||||||
res.send(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Get('favicon')
|
|
||||||
async favicon(
|
|
||||||
@Query('size') size: string | undefined,
|
|
||||||
@Query('pond') pondId: string | undefined,
|
|
||||||
@Res() res: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const wanted = Number(size ?? 32);
|
|
||||||
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
|
|
||||||
throw new BadRequestException({ code: 'bad_request' });
|
|
||||||
}
|
|
||||||
const pondBytes = pondId
|
|
||||||
? await this.branding.pondFaviconBytes(pondId, wanted as FaviconSize)
|
|
||||||
: null;
|
|
||||||
const { bytes, uploaded } = pondBytes
|
|
||||||
? { bytes: pondBytes, uploaded: true }
|
|
||||||
: await this.branding.faviconBytes(wanted as FaviconSize);
|
|
||||||
res.setHeader('Content-Type', 'image/png');
|
|
||||||
// The `<link rel="icon">` href is a constant in index.html, so this URL
|
|
||||||
// cannot carry a hash — revalidation is the only way a replaced favicon
|
|
||||||
// ever reaches a browser that already has one.
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
res.setHeader('ETag', `"${uploaded ? 'custom' : 'default'}-${bytes.length}"`);
|
|
||||||
res.send(bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Site-Admin management of the instance branding (issue #306). */
|
|
||||||
@Controller('admin/branding')
|
|
||||||
@UseGuards(SiteAdminGuard)
|
|
||||||
export class BrandingAdminController {
|
|
||||||
constructor(private readonly branding: BrandingService) {}
|
|
||||||
|
|
||||||
@Post('logo')
|
|
||||||
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
|
||||||
async setLogo(
|
|
||||||
@Query('variant') variant: string | undefined,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
|
||||||
): Promise<BrandingView> {
|
|
||||||
const file = files?.find((entry) => entry.fieldname === 'file');
|
|
||||||
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
|
|
||||||
return this.branding.setLogo(request.user!, parseVariant(variant ?? 'light'), file.buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('logo')
|
|
||||||
clearLogo(
|
|
||||||
@Query('variant') variant: string | undefined,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<BrandingView> {
|
|
||||||
return this.branding.clearLogo(request.user!, parseVariant(variant ?? 'light'));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('favicon')
|
|
||||||
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
|
||||||
async setFavicon(
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
|
||||||
): Promise<BrandingView> {
|
|
||||||
// Field names are the pixel sizes the browser rendered: `png-32`, `png-180`.
|
|
||||||
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
|
|
||||||
const collected = {} as Record<FaviconSize, Buffer>;
|
|
||||||
for (const size of FAVICON_SIZES) {
|
|
||||||
const bytes = byField.get(`png-${size}`);
|
|
||||||
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
|
|
||||||
collected[size] = bytes;
|
|
||||||
}
|
|
||||||
return this.branding.setFavicon(request.user!, collected);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('favicon')
|
|
||||||
clearFavicon(@Req() request: AuthedRequest): Promise<BrandingView> {
|
|
||||||
return this.branding.clearFavicon(request.user!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pond-level branding (issue #307). The uploader here is an ordinary Pond
|
|
||||||
* Admin rather than the operator, so the security rules of #306 are not
|
|
||||||
* relaxed by a single line: SVG refused, magic bytes checked server-side,
|
|
||||||
* size caps enforced, content type pinned on serving, no image parsing.
|
|
||||||
*
|
|
||||||
* 404/403 policy: a user who cannot see the pond gets 404 from the pond-role
|
|
||||||
* guard, one who can see but not administer it gets 403.
|
|
||||||
*/
|
|
||||||
@Controller('ponds/:pondId/branding')
|
|
||||||
export class PondBrandingController {
|
|
||||||
constructor(
|
|
||||||
private readonly branding: BrandingService,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** The pond row the quota is charged to. */
|
|
||||||
private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> {
|
|
||||||
const pond = await this.prisma.pond.findUnique({
|
|
||||||
where: { id: pondId },
|
|
||||||
select: { id: true, ownerId: true },
|
|
||||||
});
|
|
||||||
if (!pond) throw new NotFoundException();
|
|
||||||
return pond;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@RequiresPondRole('reader', { idParam: 'pondId' })
|
|
||||||
view(@Param('pondId') pondId: string): Promise<PondBranding> {
|
|
||||||
return this.branding.pondBranding(pondId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('logo')
|
|
||||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
|
||||||
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
|
||||||
async setLogo(
|
|
||||||
@Param('pondId') pondId: string,
|
|
||||||
@Query('variant') variant: string | undefined,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
const file = files?.find((entry) => entry.fieldname === 'file');
|
|
||||||
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
|
|
||||||
return this.branding.setPondLogo(
|
|
||||||
request.user!,
|
|
||||||
await this.pondOf(pondId),
|
|
||||||
parseVariant(variant ?? 'light'),
|
|
||||||
file.buffer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('logo')
|
|
||||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
|
||||||
async clearLogo(
|
|
||||||
@Param('pondId') pondId: string,
|
|
||||||
@Query('variant') variant: string | undefined,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
return this.branding.clearPondLogo(
|
|
||||||
request.user!,
|
|
||||||
await this.pondOf(pondId),
|
|
||||||
parseVariant(variant ?? 'light'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('favicon')
|
|
||||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
|
||||||
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
|
||||||
async setFavicon(
|
|
||||||
@Param('pondId') pondId: string,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
|
|
||||||
const collected = {} as Record<FaviconSize, Buffer>;
|
|
||||||
for (const size of FAVICON_SIZES) {
|
|
||||||
const bytes = byField.get(`png-${size}`);
|
|
||||||
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
|
|
||||||
collected[size] = bytes;
|
|
||||||
}
|
|
||||||
return this.branding.setPondFavicon(request.user!, await this.pondOf(pondId), collected);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('favicon')
|
|
||||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
|
||||||
async clearFavicon(
|
|
||||||
@Param('pondId') pondId: string,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,250 +0,0 @@
|
|||||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A real PNG of `size`×`size`, built the same way the shipped default is —
|
|
||||||
* the api reads the IHDR, so the header has to be genuine.
|
|
||||||
*/
|
|
||||||
async function png(size: number): Promise<Buffer> {
|
|
||||||
const { deflateSync } = await import('node:zlib');
|
|
||||||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
||||||
let c = n;
|
|
||||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
return c >>> 0;
|
|
||||||
});
|
|
||||||
const crc32 = (buf: Buffer): number => {
|
|
||||||
let c = 0xffffffff;
|
|
||||||
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
|
||||||
return (c ^ 0xffffffff) >>> 0;
|
|
||||||
};
|
|
||||||
const chunk = (type: string, data: Buffer): Buffer => {
|
|
||||||
const length = Buffer.alloc(4);
|
|
||||||
length.writeUInt32BE(data.length);
|
|
||||||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
||||||
const crc = Buffer.alloc(4);
|
|
||||||
crc.writeUInt32BE(crc32(body));
|
|
||||||
return Buffer.concat([length, body, crc]);
|
|
||||||
};
|
|
||||||
const ihdr = Buffer.alloc(13);
|
|
||||||
ihdr.writeUInt32BE(size, 0);
|
|
||||||
ihdr.writeUInt32BE(size, 4);
|
|
||||||
ihdr[8] = 8;
|
|
||||||
ihdr[9] = 6;
|
|
||||||
const raw = Buffer.alloc(size * (size * 4 + 1));
|
|
||||||
return Buffer.concat([
|
|
||||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
||||||
chunk('IHDR', ihdr),
|
|
||||||
chunk('IDAT', deflateSync(raw)),
|
|
||||||
chunk('IEND', Buffer.alloc(0)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.skipIf(!hasTestDb)('instance branding (e2e, issue #306)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let brandingDir: string;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const password = 'markenzeichen mit teich 1';
|
|
||||||
const admin = { username: `ba-${suffix}` };
|
|
||||||
const plain = { username: `bp-${suffix}` };
|
|
||||||
let adminCookie: string;
|
|
||||||
let plainCookie: string;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
// A real directory: the point is that bytes land somewhere and come back.
|
|
||||||
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-branding-'));
|
|
||||||
process.env.BRANDING_DIR = brandingDir;
|
|
||||||
app = await createTestApp();
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
|
|
||||||
const adminUser = await users.createUser({
|
|
||||||
username: admin.username,
|
|
||||||
email: `${admin.username}@example.org`,
|
|
||||||
displayName: `Branding Admin ${suffix}`,
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await users.markEmailVerified(adminUser.id);
|
|
||||||
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
|
||||||
|
|
||||||
const plainUser = await users.createUser({
|
|
||||||
username: plain.username,
|
|
||||||
email: `${plain.username}@example.org`,
|
|
||||||
displayName: `Branding Plain ${suffix}`,
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await users.markEmailVerified(plainUser.id);
|
|
||||||
|
|
||||||
const login = async (username: string): Promise<string> =>
|
|
||||||
sessionCookieOf(
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: username, password })
|
|
||||||
.expect(200),
|
|
||||||
);
|
|
||||||
adminCookie = await login(admin.username);
|
|
||||||
plainCookie = await login(plain.username);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.instanceSetting.deleteMany({
|
|
||||||
where: { key: { in: ['instance.logo', 'instance.logoDark', 'instance.favicon'] } },
|
|
||||||
});
|
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
await rm(brandingDir, { recursive: true, force: true });
|
|
||||||
delete process.env.BRANDING_DIR;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serves the shipped default favicon before anything is uploaded', async () => {
|
|
||||||
// The `<link rel="icon">` in index.html is a constant — this route must
|
|
||||||
// never 404, or the browser keeps its generic icon for good.
|
|
||||||
const res = await api().get('/api/v1/branding/favicon').expect(200);
|
|
||||||
expect(res.headers['content-type']).toContain('image/png');
|
|
||||||
expect(res.body.subarray(0, 8).toString('latin1')).toContain('PNG');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores a logo, reports it, and serves the bytes without a session', async () => {
|
|
||||||
const bytes = await png(64);
|
|
||||||
const view = await api()
|
|
||||||
.post('/api/v1/admin/branding/logo?variant=light')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.attach('file', bytes, 'logo.png')
|
|
||||||
.expect(201);
|
|
||||||
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
|
|
||||||
expect(view.body.logoDark).toBeNull();
|
|
||||||
|
|
||||||
// On disk, under the key the pond override (#307) will extend.
|
|
||||||
const onDisk = await readFile(join(brandingDir, 'instance-logo-light.png'));
|
|
||||||
expect(onDisk.length).toBe(bytes.length);
|
|
||||||
|
|
||||||
// Anonymous: the login screen carries the branding.
|
|
||||||
const served = await api().get('/api/v1/branding/logo?variant=light').expect(200);
|
|
||||||
expect(served.headers['content-type']).toContain('image/png');
|
|
||||||
const anon = await api().get('/api/v1/branding').expect(200);
|
|
||||||
expect(anon.body.logo.hash).toBe(view.body.logo.hash);
|
|
||||||
expect(anon.body.instanceName).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('answers 404 for a logo variant that was never uploaded', async () => {
|
|
||||||
// No shipped default for the logo: without one the app renders the
|
|
||||||
// instance NAME, so an empty answer is the honest one.
|
|
||||||
await api().get('/api/v1/branding/logo?variant=dark').expect(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an SVG with its own message, not a generic one', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.post('/api/v1/admin/branding/logo?variant=light')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.attach('file', Buffer.from('<?xml version="1.0"?><svg xmlns="..."><script/></svg>'), 'x.png')
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('branding_svg_rejected');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects bytes that are not a PNG at all', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.post('/api/v1/admin/branding/logo?variant=light')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.attach('file', Buffer.from('GIF89a and then some'), 'x.png')
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('branding_not_a_png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a logo larger than the maximum edge', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.post('/api/v1/admin/branding/logo?variant=light')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.attach('file', await png(600), 'x.png')
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('branding_image_too_large');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('takes both favicon sizes together and serves each back', async () => {
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/branding/favicon')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.attach('png-32', await png(32), 'f32.png')
|
|
||||||
.attach('png-180', await png(180), 'f180.png')
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
for (const size of [32, 180]) {
|
|
||||||
const res = await api().get(`/api/v1/branding/favicon?size=${size}`).expect(200);
|
|
||||||
expect(res.body.length).toBe((await png(size)).length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a favicon whose bytes do not match the size they claim', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.post('/api/v1/admin/branding/favicon')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.attach('png-32', await png(64), 'f32.png')
|
|
||||||
.attach('png-180', await png(180), 'f180.png')
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('branding_favicon_not_square');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears an asset and falls back again', async () => {
|
|
||||||
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', adminCookie).expect(200);
|
|
||||||
const view = await api().get('/api/v1/branding').expect(200);
|
|
||||||
expect(view.body.favicon).toBeNull();
|
|
||||||
// Back to the shipped default rather than a 404.
|
|
||||||
await api().get('/api/v1/branding/favicon').expect(200);
|
|
||||||
|
|
||||||
await api()
|
|
||||||
.delete('/api/v1/admin/branding/logo?variant=light')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.expect(200);
|
|
||||||
await api().get('/api/v1/branding/logo?variant=light').expect(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps management away from a non-admin, but not reading', async () => {
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/branding/logo?variant=light')
|
|
||||||
.set('Cookie', plainCookie)
|
|
||||||
.attach('file', await png(32), 'x.png')
|
|
||||||
.expect(403);
|
|
||||||
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', plainCookie).expect(403);
|
|
||||||
await api().get('/api/v1/branding').set('Cookie', plainCookie).expect(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('audits every branding change with scope, asset and direction', async () => {
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/admin/branding/logo?variant=dark')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.attach('file', await png(48), 'logo.png')
|
|
||||||
.expect(201);
|
|
||||||
const entry = await prisma.auditEntry.findFirst({
|
|
||||||
where: { action: 'branding.changed', targetId: 'instance.logoDark' },
|
|
||||||
orderBy: { at: 'desc' },
|
|
||||||
});
|
|
||||||
expect(entry).not.toBeNull();
|
|
||||||
expect(entry!.details).toMatchObject({ scope: 'instance', asset: 'logoDark', change: 'set' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to write branding metadata through the settings endpoint', async () => {
|
|
||||||
// The metadata describes bytes on disk; hand-writing it would claim an
|
|
||||||
// asset that is not there, so the settings PATCH does not accept it.
|
|
||||||
const res = await api()
|
|
||||||
.patch('/api/v1/admin/settings')
|
|
||||||
.set('Cookie', adminCookie)
|
|
||||||
.send({ 'instance.logo': { hash: 'deadbeefdeadbeef', width: 10, height: 10 } })
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('bad_request');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { PermissionsModule } from '../permissions/permissions.module';
|
|
||||||
import { QuotasModule } from '../quotas/quotas.module';
|
|
||||||
|
|
||||||
import {
|
|
||||||
BrandingAdminController,
|
|
||||||
BrandingController,
|
|
||||||
PondBrandingController,
|
|
||||||
} from './branding.controller';
|
|
||||||
import { BrandingStorageService } from './branding-storage.service';
|
|
||||||
import { BrandingService } from './branding.service';
|
|
||||||
|
|
||||||
/** Instance branding — logo and favicon (issue #306). Exports the services so
|
|
||||||
* the pond-level override (#307) can build on the same storage and the same
|
|
||||||
* resolution path instead of a parallel one. */
|
|
||||||
@Module({
|
|
||||||
imports: [PermissionsModule, QuotasModule],
|
|
||||||
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
|
|
||||||
providers: [BrandingService, BrandingStorageService],
|
|
||||||
exports: [BrandingService, BrandingStorageService],
|
|
||||||
})
|
|
||||||
export class BrandingModule {}
|
|
||||||
@ -1,380 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
BrandingAsset,
|
|
||||||
BrandingView,
|
|
||||||
FAVICON_SIZES,
|
|
||||||
FaviconSize,
|
|
||||||
LOGO_VARIANTS,
|
|
||||||
LogoVariant,
|
|
||||||
PondBranding,
|
|
||||||
pondSettingsSchema,
|
|
||||||
MAX_BRANDING_BYTES,
|
|
||||||
MAX_LOGO_EDGE,
|
|
||||||
hasPngMagic,
|
|
||||||
looksLikeSvg,
|
|
||||||
pngDimensions,
|
|
||||||
} from '@dorfteich/shared';
|
|
||||||
import { User } from '@prisma/client';
|
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { QuotaService } from '../quotas/quota.service';
|
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
||||||
import { BrandingStorageService } from './branding-storage.service';
|
|
||||||
|
|
||||||
/** The settings key each instance asset's metadata lives under. */
|
|
||||||
const INSTANCE_KEYS = {
|
|
||||||
logoLight: 'instance.logo',
|
|
||||||
logoDark: 'instance.logoDark',
|
|
||||||
favicon: 'instance.favicon',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instance branding (issue #306): the logo shown at the top of the sidebar and
|
|
||||||
* the favicon served to the browser.
|
|
||||||
*
|
|
||||||
* The api stores and serves bytes; it never decodes them. Validation is the
|
|
||||||
* PNG signature, the IHDR dimensions and the size cap — see
|
|
||||||
* `packages/shared/src/branding.ts` for why that line is drawn there.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class BrandingService {
|
|
||||||
constructor(
|
|
||||||
private readonly settings: InstanceSettingsService,
|
|
||||||
private readonly storage: BrandingStorageService,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly quotas: QuotaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
static logoKey(variant: LogoVariant): string {
|
|
||||||
return `instance-logo-${variant}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static faviconKey(size: FaviconSize): string {
|
|
||||||
return `instance-favicon-${size}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pond assets share the directory and the naming rules (issue #307); the
|
|
||||||
* pond id keeps them apart and makes purge a prefix delete. */
|
|
||||||
static pondLogoKey(pondId: string, variant: LogoVariant): string {
|
|
||||||
return `pond-${pondId}-logo-${variant}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static pondFaviconKey(pondId: string, size: FaviconSize): string {
|
|
||||||
return `pond-${pondId}-favicon-${size}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every branding file a pond can own — the purge deletes exactly this set
|
|
||||||
* (issue #307). The purge standard is absolute: after it, nothing
|
|
||||||
* referencing the pond survives, rows or files. */
|
|
||||||
static pondKeys(pondId: string): string[] {
|
|
||||||
return [
|
|
||||||
...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)),
|
|
||||||
...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, size)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rejects anything that is not a PNG within the caps, before a byte is
|
|
||||||
* written. SVG gets its own message: an operator who tried one deserves to
|
|
||||||
* learn that it is refused on purpose, not that "the file is broken".
|
|
||||||
*/
|
|
||||||
private assertUsablePng(bytes: Buffer, maxEdge: number): { width: number; height: number } {
|
|
||||||
if (bytes.length === 0) throw new BadRequestException({ code: 'branding_file_empty' });
|
|
||||||
if (bytes.length > MAX_BRANDING_BYTES) {
|
|
||||||
throw new BadRequestException({ code: 'branding_file_too_large' });
|
|
||||||
}
|
|
||||||
if (looksLikeSvg(bytes)) throw new BadRequestException({ code: 'branding_svg_rejected' });
|
|
||||||
if (!hasPngMagic(bytes)) throw new BadRequestException({ code: 'branding_not_a_png' });
|
|
||||||
const size = pngDimensions(bytes);
|
|
||||||
if (!size) throw new BadRequestException({ code: 'branding_not_a_png' });
|
|
||||||
if (size.width > maxEdge || size.height > maxEdge) {
|
|
||||||
throw new BadRequestException({ code: 'branding_image_too_large' });
|
|
||||||
}
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reserve the pond's storage for a branding asset, releasing what the asset
|
|
||||||
* it replaces occupied. Doing it in that order means replacing a logo with
|
|
||||||
* one of the same size costs nothing — otherwise every re-upload would eat
|
|
||||||
* the quota again, which is how "a pond admin fills the disk with logos"
|
|
||||||
* happens.
|
|
||||||
*/
|
|
||||||
private async chargeQuota(
|
|
||||||
pond: { id: string; ownerId: string },
|
|
||||||
bytes: number,
|
|
||||||
previous: BrandingAsset | null,
|
|
||||||
): Promise<void> {
|
|
||||||
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
|
|
||||||
try {
|
|
||||||
await this.quotas.checkAndConsume(pond.id, pond.ownerId, bytes);
|
|
||||||
} catch (error) {
|
|
||||||
// Put the released reservation back: a refused upload must not leave
|
|
||||||
// the pond with MORE room than before.
|
|
||||||
if (previous?.byteSize) {
|
|
||||||
await this.quotas.checkAndConsume(pond.id, pond.ownerId, previous.byteSize);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
|
|
||||||
return {
|
|
||||||
// Short digest: it only has to change when the bytes change, and it
|
|
||||||
// travels in every logo URL.
|
|
||||||
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
|
|
||||||
byteSize: bytes.length,
|
|
||||||
...size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async view(): Promise<BrandingView> {
|
|
||||||
const [logo, logoDark, favicon, instanceName] = await Promise.all([
|
|
||||||
this.settings.get(INSTANCE_KEYS.logoLight),
|
|
||||||
this.settings.get(INSTANCE_KEYS.logoDark),
|
|
||||||
this.settings.get(INSTANCE_KEYS.favicon),
|
|
||||||
this.settings.get('instance.name'),
|
|
||||||
]);
|
|
||||||
return { logo, logoDark, favicon, instanceName };
|
|
||||||
}
|
|
||||||
|
|
||||||
async setLogo(admin: User, variant: LogoVariant, bytes: Buffer): Promise<BrandingView> {
|
|
||||||
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
|
|
||||||
await this.storage.save(BrandingService.logoKey(variant), bytes);
|
|
||||||
await this.settings.set(
|
|
||||||
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
|
|
||||||
this.assetOf(bytes, size),
|
|
||||||
admin.id,
|
|
||||||
);
|
|
||||||
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'set');
|
|
||||||
return this.view();
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearLogo(admin: User, variant: LogoVariant): Promise<BrandingView> {
|
|
||||||
await this.storage.remove(BrandingService.logoKey(variant));
|
|
||||||
await this.settings.set(
|
|
||||||
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
|
|
||||||
null,
|
|
||||||
admin.id,
|
|
||||||
);
|
|
||||||
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'cleared');
|
|
||||||
return this.view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Both favicon sizes arrive together: the browser produced them from one
|
|
||||||
* source on the same canvas, and the api cannot resize. Storing them as a
|
|
||||||
* pair keeps the tab icon and the home-screen icon from ever showing two
|
|
||||||
* different images.
|
|
||||||
*/
|
|
||||||
async setFavicon(admin: User, files: Record<FaviconSize, Buffer>): Promise<BrandingView> {
|
|
||||||
const sizes = Object.entries(files).map(([declared, bytes]) => {
|
|
||||||
const size = this.assertUsablePng(bytes, 512);
|
|
||||||
const expected = Number(declared);
|
|
||||||
if (size.width !== expected || size.height !== expected) {
|
|
||||||
throw new BadRequestException({ code: 'branding_favicon_not_square' });
|
|
||||||
}
|
|
||||||
return { expected: expected as FaviconSize, bytes, size };
|
|
||||||
});
|
|
||||||
for (const entry of sizes) {
|
|
||||||
await this.storage.save(BrandingService.faviconKey(entry.expected), entry.bytes);
|
|
||||||
}
|
|
||||||
// The 32px variant identifies the pair — it is what the tab shows.
|
|
||||||
const small = sizes.find((entry) => entry.expected === 32)!;
|
|
||||||
await this.settings.set(INSTANCE_KEYS.favicon, this.assetOf(small.bytes, small.size), admin.id);
|
|
||||||
await this.record(admin, 'favicon', 'set');
|
|
||||||
return this.view();
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearFavicon(admin: User): Promise<BrandingView> {
|
|
||||||
await this.storage.remove(BrandingService.faviconKey(32));
|
|
||||||
await this.storage.remove(BrandingService.faviconKey(180));
|
|
||||||
await this.settings.set(INSTANCE_KEYS.favicon, null, admin.id);
|
|
||||||
await this.record(admin, 'favicon', 'cleared');
|
|
||||||
return this.view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bytes to serve for a logo variant, or null when none is stored. */
|
|
||||||
logoBytes(variant: LogoVariant): Promise<Buffer | null> {
|
|
||||||
return this.storage.read(BrandingService.logoKey(variant));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The favicon bytes: the uploaded one, else the shipped default. The
|
|
||||||
* `<link rel="icon">` in index.html is static, so this route must always
|
|
||||||
* answer with an image — a 404 there would leave the browser's generic
|
|
||||||
* icon for good.
|
|
||||||
*/
|
|
||||||
async faviconBytes(size: FaviconSize): Promise<{ bytes: Buffer; uploaded: boolean }> {
|
|
||||||
const stored = await this.storage.read(BrandingService.faviconKey(size));
|
|
||||||
if (stored) return { bytes: stored, uploaded: true };
|
|
||||||
const bytes = await readFile(join(__dirname, '../../assets', `default-favicon-${size}.png`));
|
|
||||||
return { bytes, uploaded: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The pond's own branding, defaulted — one place reads the settings blob. */
|
|
||||||
async pondBranding(pondId: string): Promise<PondBranding> {
|
|
||||||
const pond = await this.prisma.pond.findUnique({
|
|
||||||
where: { id: pondId },
|
|
||||||
select: { settings: true },
|
|
||||||
});
|
|
||||||
if (!pond) throw new NotFoundException();
|
|
||||||
return pondSettingsSchema.parse(pond.settings ?? {}).branding;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async writePondBranding(
|
|
||||||
actor: User,
|
|
||||||
pondId: string,
|
|
||||||
next: PondBranding,
|
|
||||||
asset: 'logo' | 'logoDark' | 'favicon',
|
|
||||||
change: 'set' | 'cleared',
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
const pond = await this.prisma.pond.findUniqueOrThrow({
|
|
||||||
where: { id: pondId },
|
|
||||||
select: { settings: true },
|
|
||||||
});
|
|
||||||
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
|
||||||
await this.prisma.pond.update({
|
|
||||||
where: { id: pondId },
|
|
||||||
data: { settings: { ...settings, branding: next } as object },
|
|
||||||
});
|
|
||||||
await this.audit.record({
|
|
||||||
action: 'branding.changed',
|
|
||||||
actorId: actor.id,
|
|
||||||
targetType: 'pond',
|
|
||||||
targetId: pondId,
|
|
||||||
details: { scope: 'pond', pondId, asset, change },
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pond logo, charged to the pond's storage quota (issue #307).
|
|
||||||
*
|
|
||||||
* Without the charge, branding would be a way around the quota — and
|
|
||||||
* replacing a logo repeatedly would let a pond admin consume disk with no
|
|
||||||
* ceiling. Charged BEFORE the write, like attachments, so a race never
|
|
||||||
* leaves bytes on the volume without a reservation; the bytes a replaced
|
|
||||||
* asset frees are released first, so re-uploading the same logo is free
|
|
||||||
* rather than cumulative.
|
|
||||||
*/
|
|
||||||
async setPondLogo(
|
|
||||||
actor: User,
|
|
||||||
pond: { id: string; ownerId: string },
|
|
||||||
variant: LogoVariant,
|
|
||||||
bytes: Buffer,
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
|
|
||||||
const current = await this.pondBranding(pond.id);
|
|
||||||
const previous = variant === 'dark' ? current.logoDark : current.logo;
|
|
||||||
await this.chargeQuota(pond, bytes.length, previous);
|
|
||||||
await this.storage.save(BrandingService.pondLogoKey(pond.id, variant), bytes);
|
|
||||||
const asset = this.assetOf(bytes, size);
|
|
||||||
return this.writePondBranding(
|
|
||||||
actor,
|
|
||||||
pond.id,
|
|
||||||
variant === 'dark' ? { ...current, logoDark: asset } : { ...current, logo: asset },
|
|
||||||
variant === 'dark' ? 'logoDark' : 'logo',
|
|
||||||
'set',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearPondLogo(
|
|
||||||
actor: User,
|
|
||||||
pond: { id: string; ownerId: string },
|
|
||||||
variant: LogoVariant,
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
const current = await this.pondBranding(pond.id);
|
|
||||||
const previous = variant === 'dark' ? current.logoDark : current.logo;
|
|
||||||
await this.storage.remove(BrandingService.pondLogoKey(pond.id, variant));
|
|
||||||
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
|
|
||||||
return this.writePondBranding(
|
|
||||||
actor,
|
|
||||||
pond.id,
|
|
||||||
variant === 'dark' ? { ...current, logoDark: null } : { ...current, logo: null },
|
|
||||||
variant === 'dark' ? 'logoDark' : 'logo',
|
|
||||||
'cleared',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setPondFavicon(
|
|
||||||
actor: User,
|
|
||||||
pond: { id: string; ownerId: string },
|
|
||||||
files: Record<FaviconSize, Buffer>,
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
const checked = Object.entries(files).map(([declared, bytes]) => {
|
|
||||||
const size = this.assertUsablePng(bytes, 512);
|
|
||||||
const expected = Number(declared);
|
|
||||||
if (size.width !== expected || size.height !== expected) {
|
|
||||||
throw new BadRequestException({ code: 'branding_favicon_not_square' });
|
|
||||||
}
|
|
||||||
return { expected: expected as FaviconSize, bytes, size };
|
|
||||||
});
|
|
||||||
const current = await this.pondBranding(pond.id);
|
|
||||||
const total = checked.reduce((sum, entry) => sum + entry.bytes.length, 0);
|
|
||||||
await this.chargeQuota(pond, total, current.favicon);
|
|
||||||
for (const entry of checked) {
|
|
||||||
await this.storage.save(BrandingService.pondFaviconKey(pond.id, entry.expected), entry.bytes);
|
|
||||||
}
|
|
||||||
const small = checked.find((entry) => entry.expected === 32)!;
|
|
||||||
// The pair is charged together, so the stored size is the pair's — that
|
|
||||||
// is what a later release has to give back.
|
|
||||||
const asset = { ...this.assetOf(small.bytes, small.size), byteSize: total };
|
|
||||||
return this.writePondBranding(actor, pond.id, { ...current, favicon: asset }, 'favicon', 'set');
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearPondFavicon(
|
|
||||||
actor: User,
|
|
||||||
pond: { id: string; ownerId: string },
|
|
||||||
): Promise<PondBranding> {
|
|
||||||
const current = await this.pondBranding(pond.id);
|
|
||||||
for (const size of FAVICON_SIZES) {
|
|
||||||
await this.storage.remove(BrandingService.pondFaviconKey(pond.id, size));
|
|
||||||
}
|
|
||||||
if (current.favicon?.byteSize) await this.quotas.release(pond.id, current.favicon.byteSize);
|
|
||||||
return this.writePondBranding(
|
|
||||||
actor,
|
|
||||||
pond.id,
|
|
||||||
{ ...current, favicon: null },
|
|
||||||
'favicon',
|
|
||||||
'cleared',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bytes for a pond asset — null when the pond has none at that slot, which
|
|
||||||
* is what makes the caller fall back to the instance level. */
|
|
||||||
pondLogoBytes(pondId: string, variant: LogoVariant): Promise<Buffer | null> {
|
|
||||||
return this.storage.read(BrandingService.pondLogoKey(pondId, variant));
|
|
||||||
}
|
|
||||||
|
|
||||||
pondFaviconBytes(pondId: string, size: FaviconSize): Promise<Buffer | null> {
|
|
||||||
return this.storage.read(BrandingService.pondFaviconKey(pondId, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Removes every branding file of a pond (issue #307's purge obligation). */
|
|
||||||
async removePondAssets(pondId: string): Promise<void> {
|
|
||||||
for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
private record(
|
|
||||||
admin: User,
|
|
||||||
asset: 'logo' | 'logoDark' | 'favicon',
|
|
||||||
action: 'set' | 'cleared',
|
|
||||||
): Promise<unknown> {
|
|
||||||
// `scope` is here from the start so the pond-level change (#307) is the
|
|
||||||
// same event with a different scope, not a second id in the catalogue.
|
|
||||||
return this.audit.record({
|
|
||||||
action: 'branding.changed',
|
|
||||||
actorId: admin.id,
|
|
||||||
targetType: 'setting',
|
|
||||||
targetId: `instance.${asset}`,
|
|
||||||
details: { scope: 'instance', asset, change: action },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,256 +0,0 @@
|
|||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { deflateSync } from 'node:zlib';
|
|
||||||
|
|
||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import {
|
|
||||||
createTestPrisma,
|
|
||||||
deletePondsWhere,
|
|
||||||
grantOwnerAdmin,
|
|
||||||
hasTestDb,
|
|
||||||
uniqueSuffix,
|
|
||||||
} from '../testing/test-db';
|
|
||||||
import { TrashService } from '../trash/trash.service';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
import { BrandingService } from './branding.service';
|
|
||||||
import { BrandingStorageService } from './branding-storage.service';
|
|
||||||
|
|
||||||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
||||||
let c = n;
|
|
||||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
||||||
return c >>> 0;
|
|
||||||
});
|
|
||||||
function crc32(buf: Buffer): number {
|
|
||||||
let c = 0xffffffff;
|
|
||||||
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
|
||||||
return (c ^ 0xffffffff) >>> 0;
|
|
||||||
}
|
|
||||||
function chunk(type: string, data: Buffer): Buffer {
|
|
||||||
const length = Buffer.alloc(4);
|
|
||||||
length.writeUInt32BE(data.length);
|
|
||||||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
||||||
const crc = Buffer.alloc(4);
|
|
||||||
crc.writeUInt32BE(crc32(body));
|
|
||||||
return Buffer.concat([length, body, crc]);
|
|
||||||
}
|
|
||||||
/** A real PNG — the api reads the IHDR, so the header has to be genuine. */
|
|
||||||
function png(size: number): Buffer {
|
|
||||||
const ihdr = Buffer.alloc(13);
|
|
||||||
ihdr.writeUInt32BE(size, 0);
|
|
||||||
ihdr.writeUInt32BE(size, 4);
|
|
||||||
ihdr[8] = 8;
|
|
||||||
ihdr[9] = 6;
|
|
||||||
return Buffer.concat([
|
|
||||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
||||||
chunk('IHDR', ihdr),
|
|
||||||
chunk('IDAT', deflateSync(Buffer.alloc(size * (size * 4 + 1)))),
|
|
||||||
chunk('IEND', Buffer.alloc(0)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.skipIf(!hasTestDb)('pond branding (e2e, issue #307)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let storage: BrandingStorageService;
|
|
||||||
let brandingDir: string;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const password = 'teichmarke mit eigenem logo 1';
|
|
||||||
const owner = { username: `pb-${suffix}` };
|
|
||||||
const member = { username: `pbm-${suffix}` };
|
|
||||||
let ownerCookie: string;
|
|
||||||
let memberCookie: string;
|
|
||||||
let pondId: string;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-pondbranding-'));
|
|
||||||
process.env.BRANDING_DIR = brandingDir;
|
|
||||||
app = await createTestApp();
|
|
||||||
storage = app.get(BrandingStorageService);
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
const tokens = app.get(AuthTokensService);
|
|
||||||
// Verification through the endpoint, not `markEmailVerified`: only this
|
|
||||||
// path creates the personal pond these tests brand.
|
|
||||||
const verify = async (userId: string): Promise<void> => {
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/verify-email')
|
|
||||||
.send({ token: await tokens.issue(userId, 'EMAIL_VERIFICATION', 600) })
|
|
||||||
.expect(204);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ownerUser = await users.createUser({
|
|
||||||
username: owner.username,
|
|
||||||
email: `${owner.username}@example.org`,
|
|
||||||
displayName: `Pond Branding Owner ${suffix}`,
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await verify(ownerUser.id);
|
|
||||||
const memberUser = await users.createUser({
|
|
||||||
username: member.username,
|
|
||||||
email: `${member.username}@example.org`,
|
|
||||||
displayName: `Pond Branding Member ${suffix}`,
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await verify(memberUser.id);
|
|
||||||
|
|
||||||
const login = async (username: string): Promise<string> =>
|
|
||||||
sessionCookieOf(
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: username, password })
|
|
||||||
.expect(200),
|
|
||||||
);
|
|
||||||
ownerCookie = await login(owner.username);
|
|
||||||
memberCookie = await login(member.username);
|
|
||||||
|
|
||||||
pondId = (
|
|
||||||
await prisma.pond.findFirstOrThrow({ where: { ownerId: ownerUser.id, type: 'PERSONAL' } })
|
|
||||||
).id;
|
|
||||||
// A reader on the same pond: may see it, may not administer it. Through
|
|
||||||
// the API, not a raw row — the permission cache would not see the row
|
|
||||||
// (the documented rule for grants in tests).
|
|
||||||
await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/grants`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.send({
|
|
||||||
subjectType: 'user',
|
|
||||||
subjectId: memberUser.id,
|
|
||||||
role: 'reader',
|
|
||||||
scopeType: 'pond',
|
|
||||||
effect: 'allow',
|
|
||||||
})
|
|
||||||
.expect(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.roleGrant.deleteMany({
|
|
||||||
where: { pond: { owner: { username: { contains: suffix } } } },
|
|
||||||
});
|
|
||||||
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
await rm(brandingDir, { recursive: true, force: true });
|
|
||||||
delete process.env.BRANDING_DIR;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores a pond logo, reports it, and serves it under the pond scope', async () => {
|
|
||||||
const view = await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', png(64), 'logo.png')
|
|
||||||
.expect(201);
|
|
||||||
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
|
|
||||||
|
|
||||||
const served = await api()
|
|
||||||
.get(`/api/v1/branding/logo?variant=light&pond=${pondId}`)
|
|
||||||
.expect(200);
|
|
||||||
expect(served.headers['content-type']).toContain('image/png');
|
|
||||||
|
|
||||||
// Without the pond scope the instance level answers — 404 here, since no
|
|
||||||
// instance logo is set. The two levels never leak into each other.
|
|
||||||
await api().get('/api/v1/branding/logo?variant=light').expect(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('charges the pond quota and gives the bytes back when the logo is replaced', async () => {
|
|
||||||
const usageOf = async (): Promise<number> =>
|
|
||||||
Number(
|
|
||||||
(
|
|
||||||
await prisma.pondUsage.findUnique({
|
|
||||||
where: { pondId },
|
|
||||||
select: { storageBytesUsed: true },
|
|
||||||
})
|
|
||||||
)?.storageBytesUsed ?? 0,
|
|
||||||
);
|
|
||||||
const before = await usageOf();
|
|
||||||
|
|
||||||
const big = png(120);
|
|
||||||
await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', big, 'logo.png')
|
|
||||||
.expect(201);
|
|
||||||
const afterUpload = await usageOf();
|
|
||||||
expect(afterUpload).toBe(before + big.length);
|
|
||||||
|
|
||||||
// Replacing releases the old reservation first — otherwise re-uploading
|
|
||||||
// the same logo would eat the quota again and again.
|
|
||||||
await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', big, 'logo.png')
|
|
||||||
.expect(201);
|
|
||||||
expect(await usageOf()).toBe(afterUpload);
|
|
||||||
|
|
||||||
await api()
|
|
||||||
.delete(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.expect(200);
|
|
||||||
expect(await usageOf()).toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses SVG at the pond level too — the rules do not relax for a pond admin', async () => {
|
|
||||||
const res = await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', Buffer.from('<svg xmlns="x"><script/></svg>'), 'x.png')
|
|
||||||
.expect(400);
|
|
||||||
expect(res.body.code).toBe('branding_svg_rejected');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a member read the pond branding but not change it', async () => {
|
|
||||||
await api().get(`/api/v1/ponds/${pondId}/branding`).set('Cookie', memberCookie).expect(200);
|
|
||||||
await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
|
||||||
.set('Cookie', memberCookie)
|
|
||||||
.attach('file', png(32), 'x.png')
|
|
||||||
.expect(403);
|
|
||||||
await api()
|
|
||||||
.delete(`/api/v1/ponds/${pondId}/branding/favicon`)
|
|
||||||
.set('Cookie', memberCookie)
|
|
||||||
.expect(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('purging the pond removes its branding files', async () => {
|
|
||||||
// A pond of its own, so the purge does not take the shared fixture with it.
|
|
||||||
const ownerRow = await prisma.user.findFirstOrThrow({ where: { username: owner.username } });
|
|
||||||
const created = await prisma.pond.create({
|
|
||||||
data: {
|
|
||||||
name: `Purge Branding ${suffix}`,
|
|
||||||
slug: `purge-branding-${suffix}`,
|
|
||||||
type: 'SHARED',
|
|
||||||
ownerId: ownerRow.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Raw grant row, before this pond's first permission query — the
|
|
||||||
// documented exception to "grants through the API".
|
|
||||||
await grantOwnerAdmin(prisma, created.id, ownerRow.id);
|
|
||||||
await api()
|
|
||||||
.post(`/api/v1/ponds/${created.id}/branding/logo?variant=light`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', png(48), 'logo.png')
|
|
||||||
.expect(201);
|
|
||||||
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).not.toBeNull();
|
|
||||||
|
|
||||||
await prisma.pond.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
|
|
||||||
const trash = app.get(TrashService);
|
|
||||||
await trash.purgePondNow(ownerRow, created.id);
|
|
||||||
|
|
||||||
// The purge standard is absolute: after it nothing referencing the pond
|
|
||||||
// survives — rows OR files.
|
|
||||||
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { maskTokenParam } from './mask-token-param';
|
|
||||||
|
|
||||||
describe('maskTokenParam (issue #191)', () => {
|
|
||||||
it('masks a token as the only query parameter', () => {
|
|
||||||
expect(maskTokenParam('/api/v1/public/p/feed.xml?token=dt_feed_abc123')).toBe(
|
|
||||||
'/api/v1/public/p/feed.xml?token=[redacted]',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('masks a token between other parameters and stops at delimiters', () => {
|
|
||||||
expect(maskTokenParam('/x?a=1&token=secret&b=2')).toBe('/x?a=1&token=[redacted]&b=2');
|
|
||||||
expect(maskTokenParam('/x?token=secret#frag')).toBe('/x?token=[redacted]#frag');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves URLs without a token parameter untouched', () => {
|
|
||||||
expect(maskTokenParam('/api/v1/ponds?filter=token')).toBe('/api/v1/ponds?filter=token');
|
|
||||||
expect(maskTokenParam('/api/v1/readyz')).toBe('/api/v1/readyz');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes undefined through', () => {
|
|
||||||
expect(maskTokenParam(undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Masks credential-bearing `token` query parameters before a URL reaches
|
|
||||||
* the request log (issue #191): feed tokens travel in the query string
|
|
||||||
* because feed readers cannot send headers, and the api's own log must
|
|
||||||
* not become the place where that long-lived credential is stored.
|
|
||||||
*/
|
|
||||||
export function maskTokenParam<T extends string | undefined>(url: T): T {
|
|
||||||
return url?.replace(/([?&]token=)[^&#]*/gi, '$1[redacted]') as T;
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { AppModule } from '../app.module';
|
|
||||||
|
|
||||||
// Boots the AppModule without a database (like health.e2e.test.ts): the
|
|
||||||
// middleware under test runs before any route logic, so the always-on
|
|
||||||
// healthz endpoint is a representative response (issue #197).
|
|
||||||
describe('security response headers & CORS (e2e, issue #197)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
const appOrigin = 'http://localhost:5173'; // APP_BASE_URL default origin
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent';
|
|
||||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
||||||
app = moduleRef.createNestApplication();
|
|
||||||
app.setGlobalPrefix('api/v1');
|
|
||||||
await app.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stamps the full header set on a representative response', async () => {
|
|
||||||
const res = await request(app.getHttpServer()).get('/api/v1/healthz').expect(200);
|
|
||||||
expect(res.headers['strict-transport-security']).toBe('max-age=31536000');
|
|
||||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
||||||
expect(res.headers['referrer-policy']).toBe('no-referrer');
|
|
||||||
// SAMEORIGIN, not DENY — the plugin sandbox frame is embedded
|
|
||||||
// same-origin (plugins.e2e.db.test.ts asserts the frame side).
|
|
||||||
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
|
||||||
expect(res.headers['permissions-policy']).toBe(
|
|
||||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stamps the headers on error responses too (unknown route)', async () => {
|
|
||||||
const res = await request(app.getHttpServer()).get('/api/v1/does-not-exist').expect(404);
|
|
||||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
||||||
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('grants a foreign origin nothing (no ACAO), while varying on Origin', async () => {
|
|
||||||
const res = await request(app.getHttpServer())
|
|
||||||
.get('/api/v1/healthz')
|
|
||||||
.set('Origin', 'https://attacker.example')
|
|
||||||
.expect(200);
|
|
||||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
||||||
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
|
|
||||||
expect(res.headers.vary).toContain('Origin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("echoes only the app's own origin, with the credentials rule stated", async () => {
|
|
||||||
const res = await request(app.getHttpServer())
|
|
||||||
.get('/api/v1/healthz')
|
|
||||||
.set('Origin', appOrigin)
|
|
||||||
.expect(200);
|
|
||||||
expect(res.headers['access-control-allow-origin']).toBe(appOrigin);
|
|
||||||
expect(res.headers['access-control-allow-credentials']).toBe('true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a foreign preflight ungranted (no CORS response headers)', async () => {
|
|
||||||
const res = await request(app.getHttpServer())
|
|
||||||
.options('/api/v1/healthz')
|
|
||||||
.set('Origin', 'https://attacker.example')
|
|
||||||
.set('Access-Control-Request-Method', 'POST');
|
|
||||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
||||||
expect(res.headers['access-control-allow-methods']).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security response headers and the CORS stance for every api response
|
|
||||||
* (issue #197). Hand-rolled instead of `helmet`: the header set is small
|
|
||||||
* enough to own, every value below is a deliberate decision, and the api
|
|
||||||
* gains no transitive dependency. Wired via the AppModule's
|
|
||||||
* MiddlewareConsumer so the test harness (createTestApp) exercises the
|
|
||||||
* exact production middleware — rationale per header in
|
|
||||||
* docs/architecture/security.md §Security response headers & CORS.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SecurityHeadersMiddleware implements NestMiddleware {
|
|
||||||
/** The one origin the SPA is served from; the only origin CORS ever echoes. */
|
|
||||||
private readonly allowedOrigin: string;
|
|
||||||
|
|
||||||
constructor(config: AppConfig) {
|
|
||||||
this.allowedOrigin = new URL(config.env.APP_BASE_URL).origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
use(req: Request, res: Response, next: NextFunction): void {
|
|
||||||
// No includeSubDomains: the api cannot speak for sibling subdomains it
|
|
||||||
// does not control (e.g. a support desk on the same apex). Browsers
|
|
||||||
// ignore HSTS over plain http, so sending it unconditionally is safe.
|
|
||||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
||||||
// Page paths are permission-scoped knowledge — leak them to no one.
|
|
||||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
||||||
// SAMEORIGIN, deliberately not DENY: the plugin sandbox (ADR 0008)
|
|
||||||
// embeds /api/v1/plugins/<id>/<version>/frame same-origin, and the
|
|
||||||
// frame's own CSP carries no frame-ancestors — this header governs.
|
|
||||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
||||||
// Deny the powerful features outright; nothing in the app uses them.
|
|
||||||
res.setHeader(
|
|
||||||
'Permissions-Policy',
|
|
||||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
||||||
);
|
|
||||||
|
|
||||||
// CORS: no foreign origin is granted anything — only the app's own
|
|
||||||
// origin is ever echoed (where browsers do not consult CORS anyway, as
|
|
||||||
// same-origin; the echo states the decision rather than enabling a
|
|
||||||
// caller). Same-origin requests never preflight, so no OPTIONS
|
|
||||||
// handling is needed. Vary on every response keeps caches honest.
|
|
||||||
res.vary('Origin');
|
|
||||||
if (req.headers.origin === this.allowedOrigin) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', this.allowedOrigin);
|
|
||||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import { Controller, Delete, Get, Param, Put, Req } from '@nestjs/common';
|
|
||||||
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
|
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
|
||||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
|
||||||
import { FavoritesService } from './favorites.service';
|
|
||||||
|
|
||||||
/** Star/unstar pages + the per-pond favorites of the account (issue #132). */
|
|
||||||
@Controller()
|
|
||||||
export class FavoritesController {
|
|
||||||
constructor(private readonly favorites: FavoritesService) {}
|
|
||||||
|
|
||||||
@Get('ponds/:pondId/favorites')
|
|
||||||
@AuthenticatedOnly()
|
|
||||||
async list(
|
|
||||||
@Param('pondId') pondId: string,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<PageFavoritesView> {
|
|
||||||
return this.favorites.listForPond(request.user!, pondId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put('pages/:id/favorite')
|
|
||||||
@AuthenticatedOnly()
|
|
||||||
async favorite(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<FavoriteStateView> {
|
|
||||||
return this.favorites.favorite(request.user!, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('pages/:id/favorite')
|
|
||||||
@AuthenticatedOnly()
|
|
||||||
async unfavorite(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Req() request: AuthedRequest,
|
|
||||||
): Promise<FavoriteStateView> {
|
|
||||||
return this.favorites.unfavorite(request.user!, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
|
|
||||||
import { PrismaClient, User } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Personal favorites end to end (issue #132): per-user round-trip and
|
|
||||||
* isolation, read-gated starring (#60 semantics), idempotency, and the
|
|
||||||
* trash/restore/purge lifecycle (a star survives the trash, purge cascades
|
|
||||||
* it away).
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('favorites (e2e, issue #132)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
const password = 'sterne fuer seiten 1';
|
|
||||||
const users: Record<string, User> = {};
|
|
||||||
const cookies: Record<string, string> = {};
|
|
||||||
let pondId: string;
|
|
||||||
|
|
||||||
const api = () => request(app.getHttpServer());
|
|
||||||
|
|
||||||
async function makeUser(handle: string): Promise<void> {
|
|
||||||
const service = app.get(UsersService);
|
|
||||||
const username = `fav-${handle}-${suffix}`;
|
|
||||||
const user = await service.createUser({
|
|
||||||
username,
|
|
||||||
email: `${username}@example.org`,
|
|
||||||
displayName: `Fav ${handle}`,
|
|
||||||
password,
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
await service.markEmailVerified(user.id);
|
|
||||||
users[handle] = user;
|
|
||||||
cookies[handle] = sessionCookieOf(
|
|
||||||
await api()
|
|
||||||
.post('/api/v1/auth/login')
|
|
||||||
.send({ usernameOrEmail: username, password })
|
|
||||||
.expect(200),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPage(cookie: string, title: string): Promise<string> {
|
|
||||||
const res = await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
|
||||||
.set('Cookie', cookie)
|
|
||||||
.send({ title })
|
|
||||||
.expect(201);
|
|
||||||
return (res.body as { id: string }).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function favoritesOf(cookie: string): Promise<string[]> {
|
|
||||||
const res = await api()
|
|
||||||
.get(`/api/v1/ponds/${pondId}/favorites`)
|
|
||||||
.set('Cookie', cookie)
|
|
||||||
.expect(200);
|
|
||||||
return (res.body as PageFavoritesView).pageIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
await prisma.rateLimit.deleteMany({});
|
|
||||||
app = await createTestApp();
|
|
||||||
for (const handle of ['owner', 'member', 'outsider']) await makeUser(handle);
|
|
||||||
|
|
||||||
const pond = await prisma.pond.create({
|
|
||||||
data: {
|
|
||||||
slug: `fav-pond-${suffix}`,
|
|
||||||
name: 'Favorite Pond',
|
|
||||||
type: 'SHARED',
|
|
||||||
ownerId: users.owner!.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
pondId = pond.id;
|
|
||||||
for (const [handle, role] of [
|
|
||||||
['owner', 'POND_ADMIN'],
|
|
||||||
['member', 'EDITOR'],
|
|
||||||
] as const) {
|
|
||||||
await prisma.roleGrant.create({
|
|
||||||
data: {
|
|
||||||
pondId,
|
|
||||||
subjectType: 'USER',
|
|
||||||
subjectId: users[handle]!.id,
|
|
||||||
role,
|
|
||||||
scopeType: 'POND',
|
|
||||||
effect: 'ALLOW',
|
|
||||||
createdBy: users.owner!.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
const ids = Object.values(users).map((u) => u.id);
|
|
||||||
await prisma.pageFavorite.deleteMany({ where: { userId: { in: ids } } });
|
|
||||||
await prisma.roleGrant.deleteMany({ where: { pondId } });
|
|
||||||
await prisma.page.deleteMany({ where: { pondId } });
|
|
||||||
await prisma.pond.deleteMany({ where: { id: pondId } });
|
|
||||||
await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } });
|
|
||||||
await prisma.session.deleteMany({ where: { userId: { in: ids } } });
|
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } });
|
|
||||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trips the star per user and stays idempotent', async () => {
|
|
||||||
const pageId = await createPage(cookies.owner!, 'Starred page');
|
|
||||||
|
|
||||||
const on = (
|
|
||||||
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200)
|
|
||||||
).body as FavoriteStateView;
|
|
||||||
expect(on.favorite).toBe(true);
|
|
||||||
// Starring twice is fine — still exactly one favorite.
|
|
||||||
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200);
|
|
||||||
|
|
||||||
expect(await favoritesOf(cookies.member!)).toEqual([pageId]);
|
|
||||||
// Personal, not pond-wide: the owner's list stays empty.
|
|
||||||
expect(await favoritesOf(cookies.owner!)).toEqual([]);
|
|
||||||
|
|
||||||
const off = (
|
|
||||||
await api()
|
|
||||||
.delete(`/api/v1/pages/${pageId}/favorite`)
|
|
||||||
.set('Cookie', cookies.member!)
|
|
||||||
.expect(200)
|
|
||||||
).body as FavoriteStateView;
|
|
||||||
expect(off.favorite).toBe(false);
|
|
||||||
expect(await favoritesOf(cookies.member!)).toEqual([]);
|
|
||||||
// Unstarring an unstarred page is a no-op, not an error.
|
|
||||||
await api()
|
|
||||||
.delete(`/api/v1/pages/${pageId}/favorite`)
|
|
||||||
.set('Cookie', cookies.member!)
|
|
||||||
.expect(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gates starring and the pond list behind read access (404, #60)', async () => {
|
|
||||||
const pageId = await createPage(cookies.owner!, 'Hidden page');
|
|
||||||
await api()
|
|
||||||
.put(`/api/v1/pages/${pageId}/favorite`)
|
|
||||||
.set('Cookie', cookies.outsider!)
|
|
||||||
.expect(404);
|
|
||||||
await api()
|
|
||||||
.get(`/api/v1/ponds/${pondId}/favorites`)
|
|
||||||
.set('Cookie', cookies.outsider!)
|
|
||||||
.expect(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides trashed favorites, revives them on restore, cascades on purge', async () => {
|
|
||||||
const pageId = await createPage(cookies.owner!, 'Cycling page');
|
|
||||||
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200);
|
|
||||||
|
|
||||||
// Trash: the page drops out of the favorites list, the row stays.
|
|
||||||
await prisma.page.update({
|
|
||||||
where: { id: pageId },
|
|
||||||
data: { deletedAt: new Date(), deletedBy: users.owner!.id },
|
|
||||||
});
|
|
||||||
expect(await favoritesOf(cookies.member!)).toEqual([]);
|
|
||||||
expect(await prisma.pageFavorite.count({ where: { pageId } })).toBe(1);
|
|
||||||
|
|
||||||
// Restore: the star is back without re-starring.
|
|
||||||
await prisma.page.update({ where: { id: pageId }, data: { deletedAt: null, deletedBy: null } });
|
|
||||||
expect(await favoritesOf(cookies.member!)).toEqual([pageId]);
|
|
||||||
|
|
||||||
// Purge: the FK cascade removes the favorite rows for good.
|
|
||||||
await prisma.page.update({
|
|
||||||
where: { id: pageId },
|
|
||||||
data: { deletedAt: new Date(), deletedBy: users.owner!.id },
|
|
||||||
});
|
|
||||||
await api().delete(`/api/v1/pages/${pageId}/purge`).set('Cookie', cookies.owner!).expect(204);
|
|
||||||
expect(await prisma.pageFavorite.count({ where: { pageId } })).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { PermissionsModule } from '../permissions/permissions.module';
|
|
||||||
|
|
||||||
import { FavoritesController } from './favorites.controller';
|
|
||||||
import { FavoritesService } from './favorites.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PermissionsModule],
|
|
||||||
controllers: [FavoritesController],
|
|
||||||
providers: [FavoritesService],
|
|
||||||
})
|
|
||||||
export class FavoritesModule {}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
|
|
||||||
import { User } from '@prisma/client';
|
|
||||||
|
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Personal page favorites (issue #132): a per-user star, deliberately NOT
|
|
||||||
* pond-wide (see the planning pivot on the issue). Starring needs read
|
|
||||||
* access to a live page (404 hides what the user cannot see, #60) — it is
|
|
||||||
* a note-to-self, not a page modification, so write access is NOT required.
|
|
||||||
* Both directions are idempotent, mirroring the watches service.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class FavoritesService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly permissions: PermissionService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async favorite(user: User, pageId: string): Promise<FavoriteStateView> {
|
|
||||||
const page = await this.prisma.page.findFirst({
|
|
||||||
where: { id: pageId, deletedAt: null },
|
|
||||||
select: { id: true, pondId: true },
|
|
||||||
});
|
|
||||||
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
await this.prisma.pageFavorite.upsert({
|
|
||||||
where: { userId_pageId: { userId: user.id, pageId } },
|
|
||||||
create: { userId: user.id, pageId },
|
|
||||||
update: {},
|
|
||||||
});
|
|
||||||
return { favorite: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async unfavorite(user: User, pageId: string): Promise<FavoriteStateView> {
|
|
||||||
await this.prisma.pageFavorite.deleteMany({ where: { userId: user.id, pageId } });
|
|
||||||
return { favorite: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The user's own favorites within one pond, sliced to pages they can
|
|
||||||
* still read — a revoked page must not confirm its continued existence. */
|
|
||||||
async listForPond(user: User, pondId: string): Promise<PageFavoritesView> {
|
|
||||||
if (!(await this.permissions.canSeePond(user, pondId))) throw new NotFoundException();
|
|
||||||
const rows = await this.prisma.pageFavorite.findMany({
|
|
||||||
where: { userId: user.id, page: { pondId, deletedAt: null } },
|
|
||||||
select: { pageId: true, page: { select: { id: true, pondId: true } } },
|
|
||||||
});
|
|
||||||
const pageIds: string[] = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
if (await this.permissions.canAccessPage(user, row.page, 'read')) pageIds.push(row.pageId);
|
|
||||||
}
|
|
||||||
return { pageIds };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
|
|
||||||
import { INestApplication, InternalServerErrorException } from '@nestjs/common';
|
|
||||||
import { PrismaClient, User } from '@prisma/client';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
|
||||||
import { createTestApp } from '../testing/test-app';
|
|
||||||
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
import { FileStorageService } from './file-storage.service';
|
|
||||||
import { FilesService } from './files.service';
|
|
||||||
|
|
||||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
||||||
const pngBuffer = (payload: string): Buffer => Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]);
|
|
||||||
const sha256 = (buffer: Buffer): string => createHash('sha256').update(buffer).digest('hex');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attachment integrity (issue #199): uploads store the SHA-256 of the
|
|
||||||
* written bytes, downloads verify it and fail closed (audited) on mismatch,
|
|
||||||
* and the nightly backfill hashes pre-#199 rows idempotently, reporting
|
|
||||||
* unreadable files instead of skipping them.
|
|
||||||
*/
|
|
||||||
describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let prisma: PrismaClient;
|
|
||||||
let files: FilesService;
|
|
||||||
let storage: FileStorageService;
|
|
||||||
let user: User;
|
|
||||||
let pondId: string;
|
|
||||||
const suffix = uniqueSuffix();
|
|
||||||
|
|
||||||
async function uploadPng(payload: string): Promise<{ id: string; bytes: Buffer }> {
|
|
||||||
const bytes = pngBuffer(payload);
|
|
||||||
const view = await files.upload(user, pondId, {
|
|
||||||
buffer: bytes,
|
|
||||||
size: bytes.length,
|
|
||||||
originalname: `${payload}.png`,
|
|
||||||
});
|
|
||||||
return { id: view.id, bytes };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
prisma = createTestPrisma();
|
|
||||||
app = await createTestApp();
|
|
||||||
files = app.get(FilesService);
|
|
||||||
storage = app.get(FileStorageService);
|
|
||||||
|
|
||||||
const users = app.get(UsersService);
|
|
||||||
user = await users.createUser({
|
|
||||||
username: `ines-integrity-${suffix}`,
|
|
||||||
email: `ines-integrity-${suffix}@example.org`,
|
|
||||||
displayName: `Ines Integrity ${suffix}`,
|
|
||||||
password: 'jedes byte bleibt wie es war 1',
|
|
||||||
locale: 'en',
|
|
||||||
});
|
|
||||||
// Verification via the endpoint (not markEmailVerified) because only the
|
|
||||||
// endpoint creates the personal pond the uploads go into.
|
|
||||||
const token = await app.get(AuthTokensService).issue(user.id, 'EMAIL_VERIFICATION', 600);
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post('/api/v1/auth/verify-email')
|
|
||||||
.send({ token })
|
|
||||||
.expect(204);
|
|
||||||
const pond = await prisma.pond.findFirstOrThrow({ where: { ownerId: user.id } });
|
|
||||||
pondId = pond.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.auditEntry.deleteMany({
|
|
||||||
where: { action: 'file.integrity_failed', details: { path: ['pondId'], equals: pondId } },
|
|
||||||
});
|
|
||||||
await prisma.attachment.deleteMany({ where: { pondId } });
|
|
||||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
|
||||||
await prisma.roleGrant.deleteMany({ where });
|
|
||||||
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
||||||
await prisma.$disconnect();
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores the hash of the written bytes at upload', async () => {
|
|
||||||
const { id, bytes } = await uploadPng('honest-upload');
|
|
||||||
const row = await prisma.attachment.findUniqueOrThrow({ where: { id } });
|
|
||||||
expect(row.sha256).toBe(sha256(bytes));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serves an intact file and fails closed, audited, on a tampered one', async () => {
|
|
||||||
const { id, bytes } = await uploadPng('will-be-tampered');
|
|
||||||
|
|
||||||
// Intact: the download succeeds and streams the exact bytes.
|
|
||||||
const intact = await files.download(null, id, { actorId: null, sessionKey: 'anon' });
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of intact.stream) chunks.push(chunk as Buffer);
|
|
||||||
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);
|
|
||||||
|
|
||||||
// Tampered on disk (row untouched): fail closed with the dedicated code.
|
|
||||||
await storage.save(pondId, id, pngBuffer('evil-replacement'));
|
|
||||||
const failure = await files
|
|
||||||
.download(null, id, { actorId: null, sessionKey: 'anon' })
|
|
||||||
.catch((error: unknown) => error);
|
|
||||||
expect(failure).toBeInstanceOf(InternalServerErrorException);
|
|
||||||
expect((failure as InternalServerErrorException).getResponse()).toMatchObject({
|
|
||||||
code: 'attachment_integrity_failure',
|
|
||||||
});
|
|
||||||
|
|
||||||
// The mismatch is on the audit trail with both hashes.
|
|
||||||
const audit = await prisma.auditEntry.findFirst({
|
|
||||||
where: { action: 'file.integrity_failed', targetId: id },
|
|
||||||
});
|
|
||||||
expect(audit).not.toBeNull();
|
|
||||||
expect(audit!.details).toMatchObject({
|
|
||||||
expected: sha256(bytes),
|
|
||||||
actual: sha256(pngBuffer('evil-replacement')),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('backfills missing hashes idempotently and reports unreadable files', async () => {
|
|
||||||
const readable = await uploadPng('backfill-me');
|
|
||||||
const unreadable = await uploadPng('bytes-will-vanish');
|
|
||||||
await prisma.attachment.updateMany({
|
|
||||||
where: { id: { in: [readable.id, unreadable.id] } },
|
|
||||||
data: { sha256: null },
|
|
||||||
});
|
|
||||||
await storage.delete(pondId, unreadable.id);
|
|
||||||
|
|
||||||
// A null-hash row is served unverified (pre-#199 status quo).
|
|
||||||
const unverified = await files.download(null, readable.id, {
|
|
||||||
actorId: null,
|
|
||||||
sessionKey: 'anon',
|
|
||||||
});
|
|
||||||
expect(unverified.attachment.sha256).toBeNull();
|
|
||||||
|
|
||||||
const first = await files.backfillHashes();
|
|
||||||
expect(first.hashed).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(first.unreadable).toBeGreaterThanOrEqual(1);
|
|
||||||
const rehashed = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
|
|
||||||
expect(rehashed.sha256).toBe(sha256(readable.bytes));
|
|
||||||
|
|
||||||
// The unreadable row keeps its null hash — reported, retried next run,
|
|
||||||
// never silently marked done.
|
|
||||||
const vanished = await prisma.attachment.findUniqueOrThrow({ where: { id: unreadable.id } });
|
|
||||||
expect(vanished.sha256).toBeNull();
|
|
||||||
|
|
||||||
// Idempotent: a second run finds nothing new to hash here.
|
|
||||||
const second = await files.backfillHashes();
|
|
||||||
const third = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
|
|
||||||
expect(third.sha256).toBe(sha256(readable.bytes));
|
|
||||||
expect(second.unreadable).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { access, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
import { access, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Readable } from 'node:stream';
|
import type { Readable } from 'node:stream';
|
||||||
|
|
||||||
@ -30,13 +30,6 @@ export class FileStorageService {
|
|||||||
return createReadStream(this.pathFor(pondId, fileId));
|
return createReadStream(this.pathFor(pondId, fileId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The complete stored bytes. Used where the caller must see the whole
|
|
||||||
* object before serving a single byte of it — integrity verification
|
|
||||||
* (issue #199) cannot work on a stream that is already leaving. */
|
|
||||||
read(pondId: string, fileId: string): Promise<Buffer> {
|
|
||||||
return readFile(this.pathFor(pondId, fileId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the file's bytes are actually on disk. Used by the pond export to
|
/** Whether the file's bytes are actually on disk. Used by the pond export to
|
||||||
* skip an attachment whose bytes are missing (data drift) rather than crash
|
* skip an attachment whose bytes are missing (data drift) rather than crash
|
||||||
* the archive stream (issue #65). */
|
* the archive stream (issue #65). */
|
||||||
@ -53,37 +46,4 @@ export class FileStorageService {
|
|||||||
async delete(pondId: string, fileId: string): Promise<void> {
|
async delete(pondId: string, fileId: string): Promise<void> {
|
||||||
await rm(this.pathFor(pondId, fileId), { force: true });
|
await rm(this.pathFor(pondId, fileId), { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Every stored file with its modification time, for the orphan sweep's
|
|
||||||
* volume↔database direction (issue #194, ADR 0011). A missing uploads
|
|
||||||
* directory is an empty volume, not an error.
|
|
||||||
*/
|
|
||||||
async listStored(): Promise<{ pondId: string; fileId: string; mtimeMs: number }[]> {
|
|
||||||
const root = this.config.env.UPLOADS_DIR;
|
|
||||||
const result: { pondId: string; fileId: string; mtimeMs: number }[] = [];
|
|
||||||
let pondDirs: string[];
|
|
||||||
try {
|
|
||||||
pondDirs = await readdir(root);
|
|
||||||
} catch {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
for (const pondId of pondDirs) {
|
|
||||||
let files: string[];
|
|
||||||
try {
|
|
||||||
files = await readdir(join(root, pondId));
|
|
||||||
} catch {
|
|
||||||
continue; // not a directory or vanished mid-walk
|
|
||||||
}
|
|
||||||
for (const fileId of files) {
|
|
||||||
try {
|
|
||||||
const info = await stat(join(root, pondId, fileId));
|
|
||||||
if (info.isFile()) result.push({ pondId, fileId, mtimeMs: info.mtimeMs });
|
|
||||||
} catch {
|
|
||||||
// vanished mid-walk — the next sweep sees the truth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,6 @@ import {
|
|||||||
RequiresPagePermission,
|
RequiresPagePermission,
|
||||||
RequiresPondRole,
|
RequiresPondRole,
|
||||||
} from '../permissions/permission.decorators';
|
} from '../permissions/permission.decorators';
|
||||||
import { readActorOf } from '../read-trail/read-actor';
|
|
||||||
|
|
||||||
import { FilesService } from './files.service';
|
import { FilesService } from './files.service';
|
||||||
|
|
||||||
@ -91,18 +90,14 @@ export class FilesController {
|
|||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const { attachment, stream, inline, downloadName } = await this.files.download(
|
const { attachment, stream, inline } = await this.files.download(request.user ?? null, fileId);
|
||||||
request.user ?? null,
|
|
||||||
fileId,
|
|
||||||
readActorOf(request),
|
|
||||||
);
|
|
||||||
response.set('X-Content-Type-Options', 'nosniff');
|
response.set('X-Content-Type-Options', 'nosniff');
|
||||||
// Attachments are immutable — a new upload always gets a new id.
|
// Attachments are immutable — a new upload always gets a new id.
|
||||||
response.set('Cache-Control', 'private, max-age=31536000, immutable');
|
response.set('Cache-Control', 'private, max-age=31536000, immutable');
|
||||||
const kind = inline ? 'inline' : 'attachment';
|
const kind = inline ? 'inline' : 'attachment';
|
||||||
return new StreamableFile(stream, {
|
return new StreamableFile(stream, {
|
||||||
type: attachment.mimeType,
|
type: attachment.mimeType,
|
||||||
disposition: `${kind}; filename="${encodeURIComponent(downloadName)}"`,
|
disposition: `${kind}; filename="${encodeURIComponent(attachment.fileName)}"`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -301,6 +301,7 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
|
|||||||
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
|
||||||
const stillThere = await prisma.attachment.findUnique({ where: { id: uploaded.body.id } });
|
const stillThere = await prisma.attachment.findUnique({ where: { id: uploaded.body.id } });
|
||||||
expect(stillThere).not.toBeNull();
|
expect(stillThere).not.toBeNull();
|
||||||
|
expect(stillThere?.deletedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists a page attachment for the page and links it (#61)', async () => {
|
it('lists a page attachment for the page and links it (#61)', async () => {
|
||||||
@ -327,121 +328,6 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
|
|||||||
expect(item.pageTitle).toBe(`Page Files ${suffix}`);
|
expect(item.pageTitle).toBe(`Page Files ${suffix}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefixes downloads of classified attachments; unset pageId fails closed (issue #212)', async () => {
|
|
||||||
const page = await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.send({ title: `Classified Files ${suffix}` })
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
const uploaded = await api()
|
|
||||||
.post(`/api/v1/pages/${page.body.id}/files`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', Buffer.from('%PDF-1.4 classified content'), 'geheim.pdf')
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
// Unclassified page: unchanged filename.
|
|
||||||
const openServed = await api()
|
|
||||||
.get(`/api/v1/media/${uploaded.body.id}`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.buffer(true)
|
|
||||||
.parse(binaryParser as unknown as ParseCallback)
|
|
||||||
.expect(200);
|
|
||||||
expect(openServed.headers['content-disposition']).toContain('filename="geheim.pdf"');
|
|
||||||
|
|
||||||
// Classified page: the documented VS-NfD_ prefix.
|
|
||||||
await prisma.page.update({
|
|
||||||
where: { id: page.body.id as string },
|
|
||||||
data: { classification: 'VS_NFD' },
|
|
||||||
});
|
|
||||||
const served = await api()
|
|
||||||
.get(`/api/v1/media/${uploaded.body.id}`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.buffer(true)
|
|
||||||
.parse(binaryParser as unknown as ParseCallback)
|
|
||||||
.expect(200);
|
|
||||||
expect(served.headers['content-disposition']).toContain('filename="VS-NfD_geheim.pdf"');
|
|
||||||
|
|
||||||
// pageId unset (paste-then-insert): fails closed to the pond's highest
|
|
||||||
// level — the pond now contains a classified page, so the orphan upload
|
|
||||||
// is served with the prefix too.
|
|
||||||
const orphan = await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/files`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', Buffer.from('%PDF-1.4 orphan bytes'), 'lose-datei.pdf')
|
|
||||||
.expect(201);
|
|
||||||
const orphanServed = await api()
|
|
||||||
.get(`/api/v1/media/${orphan.body.id}`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.buffer(true)
|
|
||||||
.parse(binaryParser as unknown as ParseCallback)
|
|
||||||
.expect(200);
|
|
||||||
expect(orphanServed.headers['content-disposition']).toContain(
|
|
||||||
'filename="VS-NfD_lose-datei.pdf"',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Back to all-open: the orphan serves unprefixed again.
|
|
||||||
await prisma.page.update({
|
|
||||||
where: { id: page.body.id as string },
|
|
||||||
data: { classification: 'UNCLASSIFIED' },
|
|
||||||
});
|
|
||||||
const openOrphan = await api()
|
|
||||||
.get(`/api/v1/media/${orphan.body.id}`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.buffer(true)
|
|
||||||
.parse(binaryParser as unknown as ParseCallback)
|
|
||||||
.expect(200);
|
|
||||||
expect(openOrphan.headers['content-disposition']).toContain('filename="lose-datei.pdf"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks uploads to classified pages server-side when the policy says so (issue #213)', async () => {
|
|
||||||
const settings = app.get(InstanceSettingsService);
|
|
||||||
const page = await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.send({ title: `Blocked Uploads ${suffix}` })
|
|
||||||
.expect(201);
|
|
||||||
await prisma.page.update({
|
|
||||||
where: { id: page.body.id as string },
|
|
||||||
data: { classification: 'VS_NFD' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Default policy `warn`: the upload is allowed (the UI shows the notice).
|
|
||||||
await api()
|
|
||||||
.post(`/api/v1/pages/${page.body.id}/files`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', Buffer.from('%PDF-1.4 warned upload'), 'warned.pdf')
|
|
||||||
.expect(201);
|
|
||||||
|
|
||||||
await settings.set('classification.uploadPolicy', 'block', 'test');
|
|
||||||
try {
|
|
||||||
// Enforced server-side, not only in the UI.
|
|
||||||
const blocked = await api()
|
|
||||||
.post(`/api/v1/pages/${page.body.id}/files`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', Buffer.from('%PDF-1.4 blocked upload'), 'blocked.pdf')
|
|
||||||
.expect(403);
|
|
||||||
expect((blocked.body as { code: string }).code).toBe('classified_upload_blocked');
|
|
||||||
|
|
||||||
// Unclassified pages stay uploadable under `block`.
|
|
||||||
const open = await api()
|
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.send({ title: `Open Uploads ${suffix}` })
|
|
||||||
.expect(201);
|
|
||||||
await api()
|
|
||||||
.post(`/api/v1/pages/${open.body.id}/files`)
|
|
||||||
.set('Cookie', ownerCookie)
|
|
||||||
.attach('file', Buffer.from('%PDF-1.4 open upload'), 'open.pdf')
|
|
||||||
.expect(201);
|
|
||||||
} finally {
|
|
||||||
await settings.set('classification.uploadPolicy', 'warn', 'test');
|
|
||||||
await prisma.instanceSetting.deleteMany({
|
|
||||||
where: { key: 'classification.uploadPolicy' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('pond file manager reports usage, orphans, and page links (#61)', async () => {
|
it('pond file manager reports usage, orphans, and page links (#61)', async () => {
|
||||||
const page = await api()
|
const page = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user