Compare commits

..

No commits in common. "main" and "feat/197-security-headers" have entirely different histories.

358 changed files with 1053 additions and 21757 deletions

View File

@ -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

View File

@ -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%';" | \
@ -660,37 +535,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts 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). # Hell/Dunkel/System-Umschalter (issue #180).
- name: Run theme pack - name: Run theme pack
run: | run: |
@ -730,7 +574,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 +598,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 +628,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

View File

@ -55,7 +55,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 \

View File

@ -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 \

View File

@ -1 +0,0 @@
22.15.1

View File

@ -27,3 +27,13 @@ AA) — nicht nachträglich. Kurzfassung; Details und Begründung in
machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern. machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
Verstöße gelten in Review und Abnahme als Funktionsfehler. Verstöße gelten in Review und Abnahme als Funktionsfehler.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

View File

@ -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 \

View File

@ -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.

View File

@ -30,7 +30,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",

View File

@ -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);

View File

@ -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;

View File

@ -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';

View File

@ -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");

View File

@ -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");

View File

@ -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";

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -31,71 +31,41 @@ enum UserStatus {
/// Account profile. Login methods live in UserIdentity (OIDC-ready, /// Account profile. Login methods live in UserIdentity (OIDC-ready,
/// ADR 0007); Site Admin is a user flag, all other roles are grants. /// ADR 0007); Site Admin is a user flag, all other roles are grants.
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String @unique username String @unique
email String @unique email String @unique
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")
/// E-mail digest cadence (issue #95): hourly | daily | off. /// E-mail digest cadence (issue #95): hourly | daily | off.
digestFrequency String @default("hourly") @map("digest_frequency") digestFrequency String @default("hourly") @map("digest_frequency")
status UserStatus @default(PENDING_VERIFICATION) status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at") emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at") lastLoginAt DateTime? @map("last_login_at")
identities UserIdentity[] identities UserIdentity[]
sessions Session[] sessions Session[]
authTokens AuthToken[] authTokens AuthToken[]
apiTokens ApiToken[] apiTokens ApiToken[]
feedTokens FeedToken[] feedTokens FeedToken[]
mentionRows PageMention[] mentionRows PageMention[]
ponds Pond[] ponds Pond[]
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
conversionJobs ConversionJob[] conversionJobs ConversionJob[]
auditEntries AuditEntry[] auditEntries AuditEntry[]
comments Comment[] comments Comment[]
watches Watch[] watches Watch[]
notifications Notification[] notifications Notification[]
favorites PageFavorite[] 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 +91,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
@ -262,14 +186,14 @@ model Pond {
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") deletedBy String? @map("deleted_by")
owner User @relation(fields: [ownerId], references: [id]) owner User @relation(fields: [ownerId], references: [id])
usage PondUsage? usage PondUsage?
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
labels Label[] labels Label[]
grants RoleGrant[] grants RoleGrant[]
conversionJobs ConversionJob[] conversionJobs ConversionJob[]
pondPlugins PondPlugin[] pondPlugins PondPlugin[]
@@index([ownerId]) @@index([ownerId])
@@map("ponds") @@map("ponds")
@ -315,10 +239,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")
@ -340,30 +260,19 @@ model RoleGrant {
/// page never changes its URL or breaks wikilinks. Trashed pages keep their /// page never changes its URL or breaks wikilinks. Trashed pages keep their
/// `parentId` (restore re-attaches to the nearest live ancestor, issue #107); /// `parentId` (restore re-attaches to the nearest live ancestor, issue #107);
/// `SetNull` is only the FK backstop — purge promotes children explicitly. /// `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") 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") deletedAt DateTime? @map("deleted_at")
deletedAt DateTime? @map("deleted_at") 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) parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
@ -485,12 +394,12 @@ model CollabOpenSession {
/// built from the Yjs state via the shared editor schema. `outline` is the /// built from the Yjs state via the shared editor schema. `outline` is the
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb. /// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
model PageContentCache { model PageContentCache {
pageId String @id @map("page_id") pageId String @id @map("page_id")
plainText String @map("plain_text") plainText String @map("plain_text")
markdown String markdown String
html String html String
outline Json outline Json
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
/// Weighted full-text search vector (title A, labels B, body C; issue #49, /// Weighted full-text search vector (title A, labels B, body C; issue #49,
/// ADR 0010). Maintained by the SearchProvider and the collab persistence /// ADR 0010). Maintained by the SearchProvider and the collab persistence
/// hook (both write it with the same weighting). The GIN index is added in /// hook (both write it with the same weighting). The GIN index is added in
@ -641,11 +550,6 @@ 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")
pond Pond @relation(fields: [pondId], references: [id]) pond Pond @relation(fields: [pondId], references: [id])
@ -838,11 +742,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 +754,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,11 +763,10 @@ 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 /// Kind-specific job options (issue #117): a vault import carries
/// `{parentPageId, labelIds, frontmatterMode}`; a PDF/DOCX/ODT export of a /// `{parentPageId, labelIds, frontmatterMode}`. Null for other kinds.
/// classified page carries `{marking}` (issues #208/#209). Null otherwise.
options Json? 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")
@ -879,9 +778,9 @@ model ConversionJob {
sourceName String? @map("source_name") sourceName String? @map("source_name")
resultPageId String? @map("result_page_id") resultPageId String? @map("result_page_id")
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade) pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull) page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
@@index([status, createdAt]) @@index([status, createdAt])
@@map("conversion_jobs") @@map("conversion_jobs")
@ -910,8 +809,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 +835,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")
}

View File

@ -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).

View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** 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})`);

View File

@ -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}`);
}

View 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

View File

@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { BackupModule } from '../backup/backup.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 { SearchModule } from '../search/search.module';
@ -20,15 +19,7 @@ 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, BackupModule, SearchModule],
QuotasModule,
UsersModule,
AuthModule,
SchedulerModule,
BackupModule,
SearchModule,
PondsModule,
],
controllers: [ controllers: [
AdminSettingsController, AdminSettingsController,
BackupAdminController, BackupAdminController,

View File

@ -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[]> {
@ -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();

View File

@ -7,13 +7,10 @@ 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,
@ -163,66 +160,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 } },

View File

@ -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,

View File

@ -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}`)

View File

@ -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',

View File

@ -6,7 +6,6 @@ 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 { 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 { maskTokenParam } from './common/mask-token-param';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware'; import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
@ -18,7 +17,6 @@ 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 { 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';
@ -34,7 +32,6 @@ import { PrismaModule } from './prisma/prisma.module';
import { PublicApiModule } from './public-api/public-api.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';
@ -50,7 +47,6 @@ import { VersionsModule } from './versions/versions.module';
ConfigModule, ConfigModule,
PrismaModule, PrismaModule,
AuditModule, AuditModule,
ReadTrailModule,
RateLimitModule, RateLimitModule,
MailModule, MailModule,
SettingsModule, SettingsModule,
@ -83,8 +79,6 @@ import { VersionsModule } from './versions/versions.module';
PublicModule, PublicModule,
PublicApiModule, PublicApiModule,
McpModule, McpModule,
BrandingModule,
FontsModule,
ImportExportModule, ImportExportModule,
PluginsModule, PluginsModule,
AuthModule, AuthModule,

View File

@ -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;

View File

@ -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}`);
}
});
});

View File

@ -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 {

View File

@ -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,14 +20,12 @@ 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, sessionAbsoluteMs } 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
@ -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(
@ -141,7 +121,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 +131,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(

View File

@ -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();

View File

@ -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;
@ -98,38 +84,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 // Attach the user whenever the cookie is valid — public routes may
// authenticates, its header IS the identity for this request — a // still want to know who is asking.
// session cookie riding along never escalates beyond it, and an if (rawToken) {
// 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
// still want to know who is asking.
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;

View File

@ -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',
);
}
}
}

View File

@ -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 });
} }

View File

@ -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);
});
});

View File

@ -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';
}

View File

@ -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);
}
}
});
});

View File

@ -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_)}`);
}
}
}

View File

@ -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);
});
});

View File

@ -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;
}
}
}

View File

@ -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();
}
});
});

View File

@ -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();
}

View File

@ -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 });
}
}

View File

@ -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));
}
}

View File

@ -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');
});
});

View File

@ -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 {}

View File

@ -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 },
});
}
}

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

@ -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, readdir, rm, stat, 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). */

View File

@ -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)}"`,
}); });
} }

View File

@ -327,121 +327,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`)

View File

@ -24,7 +24,6 @@ export class FilesModule implements OnModuleInit {
constructor( constructor(
private readonly scheduler: SchedulerService, private readonly scheduler: SchedulerService,
private readonly sweep: OrphanSweepService, private readonly sweep: OrphanSweepService,
private readonly files: FilesService,
) {} ) {}
onModuleInit(): void { onModuleInit(): void {
@ -33,10 +32,6 @@ export class FilesModule implements OnModuleInit {
cadenceSeconds: ORPHAN_SWEEP_CADENCE_SECONDS, cadenceSeconds: ORPHAN_SWEEP_CADENCE_SECONDS,
run: async () => { run: async () => {
await this.sweep.sweep(); await this.sweep.sweep();
// Same nightly volume walk, same domain: hash rows that predate
// #199 until none remain (idempotent, bounded batch) — a separate
// scheduled job would outlive its purpose.
await this.files.backfillHashes();
}, },
}); });
} }

View File

@ -1,11 +1,9 @@
import { createHash, randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream'; import type { Readable } from 'node:stream';
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
InternalServerErrorException,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
} from '@nestjs/common'; } from '@nestjs/common';
@ -14,19 +12,15 @@ import {
AttachmentListItemView, AttachmentListItemView,
AttachmentView, AttachmentView,
PondFilesView, PondFilesView,
PageClassification,
SVG_MIME_TYPE, SVG_MIME_TYPE,
classificationFilenamePrefix,
fileExtension, fileExtension,
isImageMimeType, isImageMimeType,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { Attachment, User } from '@prisma/client'; import { Attachment, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service'; import { QuotaService } from '../quotas/quota.service';
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
import { FileStorageService } from './file-storage.service'; import { FileStorageService } from './file-storage.service';
@ -40,11 +34,6 @@ export interface FileDownload {
* else office files, PDFs, and SVG is always sent as a download so it * else office files, PDFs, and SVG is always sent as a download so it
* can never execute inline (ADR 0011, security.md §Uploads). */ * can never execute inline (ADR 0011, security.md §Uploads). */
inline: boolean; inline: boolean;
/** The filename for the Content-Disposition (issue #212, ADR 0022): the
* original name, prefixed `VS-NfD_` when the attachment's effective
* classification is vs_nfd the one marker an arbitrary binary can
* carry. The file's CONTENT stays unmarked (documented residual risk). */
downloadName: string;
} }
/** What the upload bytes resolved to after allowlist + SVG handling. */ /** What the upload bytes resolved to after allowlist + SVG handling. */
@ -62,8 +51,6 @@ export class FilesService {
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly storage: FileStorageService, private readonly storage: FileStorageService,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(FilesService.name); this.logger.setContext(FilesService.name);
@ -166,9 +153,6 @@ export class FilesService {
sizeBytes, sizeBytes,
storagePath: `${pond.id}/${id}`, storagePath: `${pond.id}/${id}`,
uploadedBy: user.id, uploadedBy: user.id,
// Integrity hash (issue #199): computed from the exact in-memory
// bytes that were just written — never by re-reading the disk.
sha256: createHash('sha256').update(resolved.buffer).digest('hex'),
}, },
}); });
this.logger.info( this.logger.info(
@ -208,136 +192,19 @@ export class FilesService {
): Promise<AttachmentView> { ): Promise<AttachmentView> {
const page = await this.prisma.page.findFirst({ where: { id: pageId } }); const page = await this.prisma.page.findFirst({ where: { id: pageId } });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
// Attaching to a classified page (issue #213, ADR 0022): the file will
// inherit a classification its content cannot carry (#212). The UI warns;
// the instance can harden the warning into a server-side block — enforced
// HERE, not only client-side.
if (page.classification === 'VS_NFD') {
const policy = await this.settings.get('classification.uploadPolicy');
if (policy === 'block') {
throw new ForbiddenException({ code: 'classified_upload_blocked' });
}
}
return this.upload(user, page.pondId, file, page.id); return this.upload(user, page.pondId, file, page.id);
} }
/** async download(_user: User | null, id: string): Promise<FileDownload> {
* Serve an attachment, verifying its integrity first (issue #199): the
* whole object is read and hashed BEFORE the first byte leaves a stream
* cannot be un-sent, so verification must precede serving. Memory is
* bounded by the `max_file_bytes` quota that gated the upload. A mismatch
* fails closed with its own error code and lands in the audit trail (a
* security event, not content activity); the operator's move is a restore
* from backup (runbook). Rows that predate #199 (sha256 still null until
* the nightly backfill reaches them) are served unverified that is the
* pre-#199 status quo, not a downgrade.
*/
async download(_user: User | null, id: string, read: ReadActor): Promise<FileDownload> {
const attachment = await this.prisma.attachment.findFirst({ where: { id } }); const attachment = await this.prisma.attachment.findFirst({ where: { id } });
if (!attachment) throw new NotFoundException(); if (!attachment) throw new NotFoundException();
const buffer = await this.storage.read(attachment.pondId, attachment.id).catch(() => null);
if (!buffer) throw new NotFoundException();
if (attachment.sha256) {
const actual = createHash('sha256').update(buffer).digest('hex');
if (actual !== attachment.sha256) {
await this.audit.record({
action: 'file.integrity_failed',
targetType: 'attachment',
targetId: attachment.id,
details: { pondId: attachment.pondId, expected: attachment.sha256, actual },
});
throw new InternalServerErrorException({ code: 'attachment_integrity_failure' });
}
}
const classification = await this.effectiveClassification(attachment);
// Read trail (issue #222): a download whose effective classification is
// vs_nfd (#212 semantics — page level, pond max when page-less) is a read
// of classified content. `pageId` may be null for pond-level files; the
// attachment id in `details` keeps the object identifiable.
if (classification === 'vs_nfd') {
await this.readTrail.record({
...read,
pageId: attachment.pageId,
pondId: attachment.pondId,
channel: 'attachment',
details: { attachmentId: attachment.id },
});
}
return { return {
attachment, attachment,
stream: Readable.from(buffer), stream: this.storage.createReadStream(attachment.pondId, attachment.id),
inline: isImageMimeType(attachment.mimeType), inline: isImageMimeType(attachment.mimeType),
downloadName: `${classificationFilenamePrefix(classification)}${attachment.fileName}`,
}; };
} }
/**
* The classification an attachment inherits (issue #212, ADR 0022): its
* page's level. An attachment whose `pageId` is still unset
* (paste-then-insert, pond-level files) FAILS CLOSED to the highest level
* of any live page in its pond it could belong to any of them, so it is
* treated as classified as the most classified candidate. In an all-open
* pond that is `unclassified`, so nothing gets marked noise.
*/
private async effectiveClassification(attachment: Attachment): Promise<PageClassification> {
if (attachment.pageId) {
const page = await this.prisma.page.findUnique({
where: { id: attachment.pageId },
select: { classification: true },
});
if (page) return page.classification.toLowerCase() as PageClassification;
// Page row gone but link set (race with purge): fall through to the
// pond-wide fail-closed answer below.
}
const classified = await this.prisma.page.findFirst({
where: { pondId: attachment.pondId, deletedAt: null, classification: 'VS_NFD' },
select: { id: true },
});
return classified ? 'vs_nfd' : 'unclassified';
}
/**
* Hash attachments that predate #199 (sha256 null), a bounded batch per
* nightly run until none remain idempotent by construction (hashed rows
* stop matching). An unreadable file is reported (log + count) and left
* null so the next run retries it; the orphan sweep is the mechanism that
* eventually explains truly missing bytes.
*/
async backfillHashes(limit = 1000): Promise<{ hashed: number; unreadable: number }> {
const rows = await this.prisma.attachment.findMany({
where: { sha256: null },
select: { id: true, pondId: true },
take: limit,
});
let hashed = 0;
let unreadable = 0;
for (const row of rows) {
let buffer: Buffer;
try {
buffer = await this.storage.read(row.pondId, row.id);
} catch (error) {
unreadable += 1;
this.logger.error(
{ attachmentId: row.id, pondId: row.pondId, err: error },
'attachment unreadable during hash backfill; will retry next run',
);
continue;
}
await this.prisma.attachment.update({
where: { id: row.id },
data: { sha256: createHash('sha256').update(buffer).digest('hex') },
});
hashed += 1;
}
if (rows.length > 0) {
this.logger.info(
{ hashed, unreadable, batch: rows.length, batchLimit: limit },
'audit: attachment hash backfill progress',
);
}
return { hashed, unreadable };
}
/** Attachments linked to a page, for its attachments section (#61). */ /** Attachments linked to a page, for its attachments section (#61). */
async listForPage(pageId: string): Promise<AttachmentListItemView[]> { async listForPage(pageId: string): Promise<AttachmentListItemView[]> {
const rows = await this.prisma.attachment.findMany({ const rows = await this.prisma.attachment.findMany({

View File

@ -1,55 +0,0 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable } from '@nestjs/common';
import type { FontUploadFormat } from '@dorfteich/shared';
import { AppConfig } from '../config/app-config.service';
/**
* Filesystem binding for operator-uploaded fonts (issue #303, ADR 0016 §#303).
*
* The layout mirrors the baked-in catalog `<slug>/<slug>-<weight>.woff2`
* so the PDF exporter's `@font-face` builder needs no special case beyond
* choosing the directory.
*
* That directory is `CUSTOM_FONTS_DIR`, NOT `FONTS_DIR`: the latter is baked
* into the image, so anything written there disappears on the next deploy and
* never reaches a backup. This one is a sibling of the uploads and plugins
* mounts and travels in the restore set (`apps/backup/src/data-dirs.ts`).
*/
@Injectable()
export class CustomFontStorageService {
constructor(private readonly config: AppConfig) {}
private dirFor(slug: string): string {
return join(this.config.env.CUSTOM_FONTS_DIR, slug);
}
fileNameFor(slug: string, weight: number, format: FontUploadFormat): string {
return `${slug}-${weight}.${format}`;
}
pathFor(slug: string, weight: number, format: FontUploadFormat): string {
return join(this.dirFor(slug), this.fileNameFor(slug, weight, format));
}
async save(slug: string, weight: number, format: FontUploadFormat, bytes: Buffer): Promise<void> {
await mkdir(this.dirFor(slug), { recursive: true });
await writeFile(this.pathFor(slug, weight, format), bytes);
}
read(slug: string, weight: number, format: FontUploadFormat): Promise<Buffer> {
return readFile(this.pathFor(slug, weight, format));
}
/** Removes the family's whole directory. Missing is fine deletion must
* stay idempotent so a half-failed upload can still be cleaned up. */
async deleteFamily(slug: string): Promise<void> {
await rm(this.dirFor(slug), { recursive: true, force: true });
}
async deleteWeight(slug: string, weight: number, format: FontUploadFormat): Promise<void> {
await rm(this.pathFor(slug, weight, format), { force: true });
}
}

View File

@ -1,164 +0,0 @@
import {
BadRequestException,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Req,
Res,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
CustomFontView,
FONT_WEIGHTS,
MAX_FONT_FILE_BYTES,
createCustomFontInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsService, WeightUpload } from './custom-fonts.service';
/** Multipart field names: `woff2-<weight>` and the optional `woff-<weight>`. */
const FILE_FIELD = /^(woff2|woff)-(\d{3})$/;
function parseUploads(files: Express.Multer.File[] | undefined): WeightUpload[] {
const byWeight = new Map<number, WeightUpload>();
for (const file of files ?? []) {
const match = FILE_FIELD.exec(file.fieldname);
if (!match) throw new BadRequestException({ code: 'font_unexpected_field' });
const weight = Number(match[2]);
if (!(FONT_WEIGHTS as readonly number[]).includes(weight)) {
throw new BadRequestException({ code: 'font_weight_invalid' });
}
const entry = byWeight.get(weight) ?? { weight, woff2: Buffer.alloc(0) };
if (match[1] === 'woff2') entry.woff2 = file.buffer;
else entry.woff = file.buffer;
byWeight.set(weight, entry);
}
// A WOFF without its WOFF2 would produce a weight the PDF path cannot
// embed — the exporter reads WOFF2 only.
for (const entry of byWeight.values()) {
if (entry.woff2.length === 0) throw new BadRequestException({ code: 'font_woff2_missing' });
}
return [...byWeight.values()].sort((a, b) => a.weight - b.weight);
}
/** Site-Admin management of operator-uploaded fonts (issue #303). */
@Controller('admin/fonts')
@UseGuards(SiteAdminGuard)
export class CustomFontsAdminController {
constructor(private readonly fonts: CustomFontsService) {}
@Get()
list(): Promise<CustomFontView[]> {
return this.fonts.list();
}
@Post()
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_FONT_FILE_BYTES } }))
async create(
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<CustomFontView> {
// The metadata rides as ordinary multipart fields next to the files.
const input = createCustomFontInputSchema.parse({
family: request.body?.family,
category: request.body?.category,
licence: request.body?.licence,
licenceUrl: request.body?.licenceUrl || null,
});
return this.fonts.create(request.user!, input, parseUploads(files));
}
@Post(':id/weights')
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_FONT_FILE_BYTES } }))
async addWeight(
@Param('id') id: string,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<CustomFontView> {
const uploads = parseUploads(files);
if (uploads.length !== 1) throw new BadRequestException({ code: 'font_one_weight_expected' });
return this.fonts.addWeight(request.user!, id, uploads[0]!);
}
/** How many live ponds still use the family — shown before deleting. */
@Get(':id/usage')
async usage(@Param('id') id: string): Promise<{ pondsAffected: number }> {
const font = (await this.fonts.list()).find((entry) => entry.id === id);
if (!font) throw new BadRequestException({ code: 'not_found' });
return { pondsAffected: await this.fonts.pondsUsing(font.family) };
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.fonts.remove(request.user!, id);
}
}
/**
* Reading side of the uploaded fonts: the family list every signed-in user
* needs, and the bytes themselves.
*
* The listing is NOT site-admin-gated (issue #304): every signed-in user picks
* fonts in their pond's Appearance settings, reads the licence page, and needs
* the `@font-face` rules injected the admin list at `/admin/fonts` carries
* the same data, so gating this one would only force a second, admin-only UI.
*
* The file route is unauthenticated on purpose: a font is referenced from CSS,
* and the login screen carries the pond-independent chrome an authenticated
* font URL would simply not load. The bytes are branding, not content.
*/
@Controller('fonts/custom')
export class CustomFontsFileController {
constructor(
private readonly storage: CustomFontStorageService,
private readonly fonts: CustomFontsService,
) {}
// Explicit access declaration, as every route needs (issue #52's fence
// `route-permissions.e2e.db.test.ts`): a session, no further permission —
// the list says which families exist, which is what the pickers offer.
@AuthenticatedOnly()
@Get()
list(): Promise<CustomFontView[]> {
return this.fonts.list();
}
@Public()
@Get(':slug/:file')
async serve(
@Param('slug') slug: string,
@Param('file') file: string,
@Res() res: Response,
): Promise<void> {
const match = /^([a-z0-9-]+)-(\d{3})\.(woff2|woff)$/.exec(file);
// The slug must match the file's own prefix, so the path cannot be used
// to reach a different family's directory.
if (!match || match[1] !== slug) throw new BadRequestException({ code: 'not_found' });
const known = (await this.fonts.list()).find((entry) => entry.slug === slug);
if (!known) throw new BadRequestException({ code: 'not_found' });
const format = match[3] as 'woff2' | 'woff';
const bytes = await this.storage
.read(slug, Number(match[2]), format)
.catch(() => Promise.reject(new BadRequestException({ code: 'not_found' })));
res.setHeader('Content-Type', format === 'woff2' ? 'font/woff2' : 'font/woff');
// Slug + weight + format identify the bytes; a changed family is a new
// upload under a new id, so a long lifetime is safe.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(bytes);
}
}

View File

@ -1,244 +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, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/** Smallest bytes that pass the magic check — the api never parses further. */
const woff2 = (): Buffer => Buffer.concat([Buffer.from('wOF2'), Buffer.alloc(64)]);
const woff = (): Buffer => Buffer.concat([Buffer.from('wOFF'), Buffer.alloc(64)]);
describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let fontsDir: string;
const suffix = uniqueSuffix();
const password = 'schriftverwaltung mit stil 1';
const admin = { username: `fa-${suffix}`, displayName: `Font Admin ${suffix}` };
const plain = { username: `fp-${suffix}`, displayName: `Font Plain ${suffix}` };
let adminCookie: string;
let plainCookie: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
// A real directory so the storage layer is exercised, not mocked — the
// point of this suite is that bytes actually land somewhere retrievable.
fontsDir = await mkdtemp(join(tmpdir(), 'dorfteich-fonts-'));
process.env.CUSTOM_FONTS_DIR = fontsDir;
app = await createTestApp();
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: admin.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
// additional_ponds defaults to 0 (ADR 0011) and the instance default is
// never raised — the usage test needs a pond, so grant an override.
await prisma.quotaOverride.create({
data: {
subjectType: 'USER',
subjectId: adminUser.id,
quotaKey: 'additional_ponds',
value: 10,
},
});
const plainUser = await users.createUser({
username: plain.username,
email: `${plain.username}@example.org`,
displayName: plain.displayName,
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.customFont.deleteMany({});
const ids = (
await prisma.user.findMany({
where: { username: { contains: suffix } },
select: { id: true },
})
).map((row) => row.id);
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
await rm(fontsDir, { recursive: true, force: true });
delete process.env.CUSTOM_FONTS_DIR;
});
it('uploads a family, writes the bytes, and serves them back', async () => {
const created = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Hausschrift ${suffix}`)
.field('category', 'serif')
.field('licence', 'Commercial — Foundry XY')
.attach('woff2-400', woff2(), 'x.woff2')
.attach('woff-400', woff(), 'x.woff')
.expect(201);
expect(created.body.weights).toEqual([400]);
expect(created.body.licence).toBe('Commercial — Foundry XY');
const slug = created.body.slug as string;
// The bytes are really on disk, in the catalog's layout.
const onDisk = await readFile(join(fontsDir, slug, `${slug}-400.woff2`));
expect(onDisk.subarray(0, 4).toString()).toBe('wOF2');
// …and reachable without a session: a font is fetched from CSS.
const served = await api().get(`/api/v1/fonts/custom/${slug}/${slug}-400.woff2`).expect(200);
expect(served.headers['content-type']).toContain('font/woff2');
});
it('rejects a file that is not a font, whatever it is called', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Fake ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', Buffer.from('\x89PNG\r\n\x1a\n and more'), 'evil.woff2')
.expect(400);
expect(res.body.code).toBe('font_file_not_a_font');
});
it('refuses a family name that a catalog font already owns', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', 'Roboto')
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(409);
expect(res.body.code).toBe('font_family_reserved');
});
it('refuses a weight whose WOFF2 is missing', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `NurWoff ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff-400', woff(), 'x.woff')
.expect(400);
expect(res.body.code).toBe('font_woff2_missing');
});
/**
* Issue #304: an ordinary member picks fonts in their pond's Appearance
* settings and reads the licence page, so the family list cannot be
* Site-Admin-only only the management routes are.
*/
it('lets any signed-in user read the family list, but nobody anonymous', async () => {
await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Leseschrift ${suffix}`)
.field('category', 'monospace')
.field('licence', 'Read me')
.attach('woff2-500', woff2(), 'x.woff2')
.expect(201);
const listed = await api().get('/api/v1/fonts/custom').set('Cookie', plainCookie).expect(200);
const seen = (listed.body as { family: string; weights: number[] }[]).find(
(font) => font.family === `Leseschrift ${suffix}`,
);
expect(seen?.weights).toEqual([500]);
await api().get('/api/v1/fonts/custom').expect(401);
});
it('keeps every management route away from a non-admin', async () => {
await api().get('/api/v1/admin/fonts').set('Cookie', plainCookie).expect(403);
await api()
.post('/api/v1/admin/fonts')
.set('Cookie', plainCookie)
.field('family', `Nope ${suffix}`)
.field('category', 'serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(403);
});
it('counts the ponds a family is used by, and deletion leaves them working', async () => {
const created = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Zählschrift ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(201);
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', adminCookie)
.send({ name: `Schriftteich ${suffix}` })
.expect(201);
await api()
.patch(`/api/v1/ponds/${pond.body.id}`)
.set('Cookie', adminCookie)
.send({ fonts: { body: { family: `Zählschrift ${suffix}`, weight: 400 } } })
.expect(200);
const usage = await api()
.get(`/api/v1/admin/fonts/${created.body.id}/usage`)
.set('Cookie', adminCookie)
.expect(200);
expect(usage.body.pondsAffected).toBe(1);
// Deletion is never blocked by usage.
await api()
.delete(`/api/v1/admin/fonts/${created.body.id}`)
.set('Cookie', adminCookie)
.expect(204);
// The pond still resolves — it keeps the stored family name and falls
// back to the system stack, rather than breaking.
const after = await api()
.get(`/api/v1/ponds/${pond.body.slug}`)
.set('Cookie', adminCookie)
.expect(200);
expect(after.body.settings.fonts.body.family).toBe(`Zählschrift ${suffix}`);
expect(
await api().get('/api/v1/admin/fonts').set('Cookie', adminCookie).expect(200),
).toBeTruthy();
const audit = await prisma.auditEntry.findFirst({
where: { action: 'font.deleted', targetId: created.body.id },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({ pondsAffected: 1 });
});
});

View File

@ -1,249 +0,0 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CreateCustomFontInput,
CustomFontView,
FONT_CATALOG,
FontCategory,
FontUploadFormat,
MAX_FONT_FILE_BYTES,
MAX_FONT_WEIGHTS,
fontSlug,
hasFontMagic,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { CustomFontStorageService } from './custom-font-storage.service';
/** One weight's bytes as they arrive from the controller. */
export interface WeightUpload {
weight: number;
woff2: Buffer;
woff?: Buffer;
}
/**
* Operator-uploaded font families (issue #303, ADR 0016 §#303).
*
* Site-Admin-only, additive to the compile-time catalog, and deliberately
* incurious about the files: the api validates the magic number and the size
* and then stores the bytes. Family, category and licence come from the form.
*/
@Injectable()
export class CustomFontsService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: CustomFontStorageService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(CustomFontsService.name);
}
/**
* Rejects bytes that are not what they claim to be, before anything is
* written. Deliberately the ONLY inspection: parsing the font would gain
* metadata the form already carries, at the price of a known
* memory-safety surface (ADR 0016 §#303).
*/
private assertUsableFont(bytes: Buffer, format: FontUploadFormat): void {
if (bytes.length === 0) throw new BadRequestException({ code: 'font_file_empty' });
if (bytes.length > MAX_FONT_FILE_BYTES) {
throw new BadRequestException({ code: 'font_file_too_large' });
}
if (!hasFontMagic(bytes, format)) {
throw new BadRequestException({ code: 'font_file_not_a_font' });
}
}
/**
* A custom family must not collide with a catalog one, by name or by slug:
* a pond stores `fonts.<slot>.family` as a plain string, so two families
* answering to the same name would make the PDF path embed whichever file
* it happened to find.
*/
private async assertNameIsFree(family: string, slug: string): Promise<void> {
const catalogHit = FONT_CATALOG.some(
(entry) => entry.family === family || fontSlug(entry.family) === slug,
);
if (catalogHit) throw new ConflictException({ code: 'font_family_reserved' });
const existing = await this.prisma.customFont.findFirst({
where: { OR: [{ family }, { slug }] },
select: { id: true },
});
if (existing) throw new ConflictException({ code: 'font_family_exists' });
}
private viewOf(font: {
id: string;
family: string;
slug: string;
category: string;
licence: string;
licenceUrl: string | null;
createdAt: Date;
weights: { weight: number }[];
}): CustomFontView {
return {
id: font.id,
family: font.family,
slug: font.slug,
category: font.category as FontCategory,
licence: font.licence,
licenceUrl: font.licenceUrl,
weights: font.weights.map((row) => row.weight).sort((a, b) => a - b),
createdAt: font.createdAt.toISOString(),
};
}
async list(): Promise<CustomFontView[]> {
const fonts = await this.prisma.customFont.findMany({
orderBy: { family: 'asc' },
include: { weights: { select: { weight: true } } },
});
return fonts.map((font) => this.viewOf(font));
}
async create(
admin: User,
input: CreateCustomFontInput,
uploads: WeightUpload[],
): Promise<CustomFontView> {
if (uploads.length === 0) throw new BadRequestException({ code: 'font_no_weights' });
if (uploads.length > MAX_FONT_WEIGHTS) {
throw new BadRequestException({ code: 'font_too_many_weights' });
}
for (const upload of uploads) {
this.assertUsableFont(upload.woff2, 'woff2');
if (upload.woff) this.assertUsableFont(upload.woff, 'woff');
}
const slug = fontSlug(input.family);
if (!slug) throw new BadRequestException({ code: 'font_family_unusable' });
await this.assertNameIsFree(input.family, slug);
// Row first, then bytes: a row without files is repairable (re-upload the
// weight), while files without a row would be invisible litter.
const font = await this.prisma.customFont.create({
data: {
family: input.family,
slug,
category: input.category,
licence: input.licence,
licenceUrl: input.licenceUrl,
uploadedBy: admin.id,
weights: {
create: uploads.map((upload) => ({
weight: upload.weight,
hasWoff: Boolean(upload.woff),
byteSize: upload.woff2.length,
})),
},
},
include: { weights: { select: { weight: true } } },
});
for (const upload of uploads) {
await this.storage.save(slug, upload.weight, 'woff2', upload.woff2);
if (upload.woff) await this.storage.save(slug, upload.weight, 'woff', upload.woff);
}
await this.audit.record({
action: 'font.uploaded',
actorId: admin.id,
targetType: 'font',
targetId: font.id,
details: { family: font.family },
});
return this.viewOf(font);
}
async addWeight(admin: User, fontId: string, upload: WeightUpload): Promise<CustomFontView> {
this.assertUsableFont(upload.woff2, 'woff2');
if (upload.woff) this.assertUsableFont(upload.woff, 'woff');
const font = await this.prisma.customFont.findUnique({
where: { id: fontId },
include: { weights: { select: { weight: true } } },
});
if (!font) throw new NotFoundException();
if (font.weights.length >= MAX_FONT_WEIGHTS) {
throw new BadRequestException({ code: 'font_too_many_weights' });
}
if (font.weights.some((row) => row.weight === upload.weight)) {
throw new ConflictException({ code: 'font_weight_exists' });
}
await this.prisma.customFontWeight.create({
data: {
fontId,
weight: upload.weight,
hasWoff: Boolean(upload.woff),
byteSize: upload.woff2.length,
},
});
await this.storage.save(font.slug, upload.weight, 'woff2', upload.woff2);
if (upload.woff) await this.storage.save(font.slug, upload.weight, 'woff', upload.woff);
await this.audit.record({
action: 'font.uploaded',
actorId: admin.id,
targetType: 'font',
targetId: fontId,
details: { family: font.family, weight: upload.weight },
});
const updated = await this.prisma.customFont.findUniqueOrThrow({
where: { id: fontId },
include: { weights: { select: { weight: true } } },
});
return this.viewOf(updated);
}
/**
* How many live ponds still name this family in any of their three font
* slots. Shown before deletion those ponds keep working (an unknown
* family falls back to the system stack) but they visibly change.
*/
async pondsUsing(family: string): Promise<number> {
const rows = await this.prisma.$queryRaw<{ count: bigint }[]>`
SELECT count(*)::bigint AS count
FROM ponds
WHERE deleted_at IS NULL
AND (settings #>> '{fonts,heading,family}' = ${family}
OR settings #>> '{fonts,body,family}' = ${family}
OR settings #>> '{fonts,mono,family}' = ${family})
`;
return Number(rows[0]?.count ?? 0);
}
/**
* Deletion is never blocked by usage. `fontStack` already yields the system
* fallback for an unknown family, so affected ponds degrade rather than
* break, and re-uploading the family restores them but the count travels
* into the audit entry so the change is not silent.
*/
async remove(admin: User, fontId: string): Promise<void> {
const font = await this.prisma.customFont.findUnique({ where: { id: fontId } });
if (!font) throw new NotFoundException();
const pondsAffected = await this.pondsUsing(font.family);
await this.prisma.customFont.delete({ where: { id: fontId } });
await this.storage.deleteFamily(font.slug);
await this.audit.record({
action: 'font.deleted',
actorId: admin.id,
targetType: 'font',
targetId: fontId,
details: { family: font.family, pondsAffected },
});
this.logger.info({ fontId, family: font.family, pondsAffected }, 'custom font deleted');
}
}

View File

@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsAdminController, CustomFontsFileController } from './custom-fonts.controller';
import { CustomFontsService } from './custom-fonts.service';
/** Operator-uploaded fonts (issue #303, ADR 0016 §#303). Exports the service
* so the PDF exporter can resolve a pond's font to a custom family. */
@Module({
controllers: [CustomFontsAdminController, CustomFontsFileController],
providers: [CustomFontsService, CustomFontStorageService],
exports: [CustomFontsService, CustomFontStorageService],
})
export class FontsModule {}

View File

@ -152,14 +152,7 @@ export class GrantsService {
* pond_admin only at pond scope for a user subject, no extra admins on a * pond_admin only at pond scope for a user subject, no extra admins on a
* personal pond, and scope/subject must exist here. Rejects duplicates. * personal pond, and scope/subject must exist here. Rejects duplicates.
*/ */
async createGrant( async createGrant(user: User, pondId: string, grant: Grant): Promise<GrantView> {
user: User,
pondId: string,
grant: Grant,
// `idp` when the claim mapping writes (issue #217): the row is marked
// as mapping-owned and the audit entry names the origin.
options: { origin?: 'manual' | 'idp' } = {},
): Promise<GrantView> {
const pond = await this.requireLivePond(pondId); const pond = await this.requireLivePond(pondId);
const invalid = grantValidationError(grant, { const invalid = grantValidationError(grant, {
@ -176,7 +169,7 @@ export class GrantsService {
if (existing) throw new ConflictException({ code: 'grant_exists' }); if (existing) throw new ConflictException({ code: 'grant_exists' });
const created = await this.prisma.roleGrant.create({ const created = await this.prisma.roleGrant.create({
data: { pondId, createdBy: user.id, origin: options.origin ?? 'manual', ...columns }, data: { pondId, createdBy: user.id, ...columns },
}); });
await this.accessChanged(pondId); await this.accessChanged(pondId);
await this.audit.record({ await this.audit.record({
@ -192,7 +185,6 @@ export class GrantsService {
scope: grant.scopeType, scope: grant.scopeType,
scopeId: grant.scopeId, scopeId: grant.scopeId,
effect: grant.effect, effect: grant.effect,
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
}, },
}); });
return GrantsService.viewOf(created); return GrantsService.viewOf(created);
@ -203,12 +195,7 @@ export class GrantsService {
* grant is protected deleting it would leave the pond unmanageable * grant is protected deleting it would leave the pond unmanageable
* (only a Site Admin could recover it). * (only a Site Admin could recover it).
*/ */
async deleteGrant( async deleteGrant(user: User, pondId: string, grantId: string): Promise<void> {
user: User,
pondId: string,
grantId: string,
options: { origin?: 'manual' | 'idp' } = {},
): Promise<void> {
const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } }); const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } });
if (!grant) throw new NotFoundException(); if (!grant) throw new NotFoundException();
@ -226,12 +213,7 @@ export class GrantsService {
actorId: user.id, actorId: user.id,
targetType: 'pond', targetType: 'pond',
targetId: pondId, targetId: pondId,
details: { details: { grantId, subjectId: grant.subjectId, role: grant.role },
grantId,
subjectId: grant.subjectId,
role: grant.role,
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
},
}); });
} }

View File

@ -1,12 +1,10 @@
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deMails from '@dorfteich/shared/i18n/de/mails.json'; import deMails from '@dorfteich/shared/i18n/de/mails.json';
import dePonds from '@dorfteich/shared/i18n/de/ponds.json';
import deTasks from '@dorfteich/shared/i18n/de/tasks.json'; import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enMails from '@dorfteich/shared/i18n/en/mails.json'; import enMails from '@dorfteich/shared/i18n/en/mails.json';
import enPonds from '@dorfteich/shared/i18n/en/ponds.json';
import enTasks from '@dorfteich/shared/i18n/en/tasks.json'; import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
import { createInstance, type i18n as I18n } from 'i18next'; import { createInstance, type i18n as I18n } from 'i18next';
@ -19,8 +17,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({ void apiI18n.init({
resources: { resources: {
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds }, en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds }, de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
}, },
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['de', 'en'], supportedLngs: ['de', 'en'],

View File

@ -1,39 +0,0 @@
import { describe, expect, it } from 'vitest';
import { markClassifiedMarkdown, parseClassifiedMarkdown } from './classified-markdown';
const MARKING = 'VS NUR FÜR DEN DIENSTGEBRAUCH';
describe('classified markdown marking (issue #210)', () => {
it('wraps a classified page in frontmatter and top+bottom imprint', () => {
const marked = markClassifiedMarkdown('# Title\n\nBody.\n', 'vs_nfd');
expect(marked).toBe(
`---\nclassification: vs_nfd\n---\n\n${MARKING}\n\n# Title\n\nBody.\n\n${MARKING}\n`,
);
});
it('leaves unclassified markdown untouched', () => {
expect(markClassifiedMarkdown('# Title\n\nBody.\n', 'unclassified')).toBe('# Title\n\nBody.\n');
});
it('parse is the inverse of mark', () => {
const original = '# Title\n\nBody.\n';
const { markdown, classification } = parseClassifiedMarkdown(
markClassifiedMarkdown(original, 'vs_nfd'),
);
expect(classification).toBe('vs_nfd');
expect(markdown).toBe(original);
});
it('passes documents without our frontmatter through unchanged', () => {
for (const raw of [
'# Plain\n\nNo frontmatter.\n',
'---\ntitle: Foreign frontmatter\ntags: [a]\n---\n\n# Doc\n',
`${MARKING}\n\nJust an imprint line without frontmatter.\n`,
]) {
const { markdown, classification } = parseClassifiedMarkdown(raw);
expect(classification).toBeNull();
expect(markdown).toBe(raw);
}
});
});

View File

@ -1,43 +0,0 @@
import { PageClassification, classificationMarking } from '@dorfteich/shared';
/**
* VS-NfD marking of exported Markdown (issue #210, ADR 0022): a classified
* page's `.md` carries the level machine-readably in YAML frontmatter AND
* human-visibly as the marking line at the top and bottom of the file.
* Unclassified pages pass through untouched no marking, no frontmatter.
*/
export function markClassifiedMarkdown(
markdown: string,
classification: PageClassification,
): string {
const marking = classificationMarking(classification);
if (!marking) return markdown;
return `---\nclassification: ${classification}\n---\n\n${marking}\n\n${markdown.trimEnd()}\n\n${marking}\n`;
}
/**
* Inverse of {@link markClassifiedMarkdown} for the import side: recognizes
* exactly the frontmatter block we generate (a lone `classification:` key)
* and the marking lines around the body, so a round-trip re-import yields
* the original content and the page starts at the imported level (content
* must not escape its marking by traveling through a ZIP). Anything else
* foreign frontmatter, hand-written documents passes through unchanged.
*/
export function parseClassifiedMarkdown(raw: string): {
markdown: string;
classification: PageClassification | null;
} {
const match = raw.match(/^---\nclassification: (vs_nfd|unclassified)\n---\n\n/);
if (!match) return { markdown: raw, classification: null };
const classification = match[1] as PageClassification;
let body = raw.slice(match[0].length);
const marking = classificationMarking(classification);
if (marking) {
if (body.startsWith(`${marking}\n\n`)) body = body.slice(marking.length + 2);
const trimmed = body.trimEnd();
if (trimmed.endsWith(`\n\n${marking}`)) {
body = `${trimmed.slice(0, -(marking.length + 2)).trimEnd()}\n`;
}
}
return { markdown: body, classification };
}

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.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';
import { ConversionJobService } from './conversion-job.service'; import { ConversionJobService } from './conversion-job.service';
@ -108,7 +108,7 @@ describe.skipIf(!hasTestDb)('conversion job queue (e2e, issue #62)', () => {
// grant); clear those before the users they reference. // grant); clear those before the users they reference.
const where = { pond: { owner: { username: { contains: suffix } } } }; const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.roleGrant.deleteMany({ where }); await prisma.roleGrant.deleteMany({ where });
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.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();

View File

@ -3,15 +3,11 @@ import { ConversionJob, ConversionJobStatus as PrismaStatus } from '@prisma/clie
import { ConversionJobStatus, ConversionJobView } from '@dorfteich/shared'; import { ConversionJobStatus, ConversionJobView } from '@dorfteich/shared';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { ClockService } from '../common/clock.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { ConversionWorker } from './conversion-worker.service'; import { ConversionWorker } from './conversion-worker.service';
import { MAX_CONVERSION_INPUT_BYTES } from './pandoc.converter'; import { MAX_CONVERSION_INPUT_BYTES } from './pandoc.converter';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export interface EnqueueConversion { export interface EnqueueConversion {
ownerId: string; ownerId: string;
kind: string; kind: string;
@ -54,8 +50,6 @@ export class ConversionJobService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly worker: ConversionWorker, private readonly worker: ConversionWorker,
private readonly settings: InstanceSettingsService,
private readonly clock: ClockService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(ConversionJobService.name); this.logger.setContext(ConversionJobService.name);
@ -112,35 +106,6 @@ export class ConversionJobService {
}; };
} }
/**
* Null the raw payload bytes of jobs that finished longer ago than
* `conversion.payloadRetentionDays` (issue #233) every kind, input AND
* result. Only terminal jobs are touched: a PENDING row and a crashed
* RUNNING row awaiting stale-lock recovery keep their input so the worker
* can still (re)process them. The row itself survives for status/audit;
* `updatedAt` marks completion because a terminal row is never written
* again (the payload guard below keeps this run from re-matching rows).
*/
async pruneExpiredPayloads(): Promise<number> {
const retentionDays = await this.settings.get('conversion.payloadRetentionDays');
const cutoff = new Date(this.clock.now().getTime() - retentionDays * MS_PER_DAY);
const result = await this.prisma.conversionJob.updateMany({
where: {
status: { in: ['SUCCEEDED', 'FAILED'] },
updatedAt: { lt: cutoff },
OR: [{ input: { not: null } }, { result: { not: null } }],
},
data: { input: null, result: null, resultMimeType: null },
});
if (result.count > 0) {
this.logger.info(
{ pruned: result.count, cutoff: cutoff.toISOString(), retentionDays },
'audit: conversion job payloads pruned',
);
}
return result.count;
}
private async ownedJob(id: string, userId: string): Promise<ConversionJob> { private async ownedJob(id: string, userId: string): Promise<ConversionJob> {
const job = await this.prisma.conversionJob.findFirst({ where: { id, ownerId: userId } }); const job = await this.prisma.conversionJob.findFirst({ where: { id, ownerId: userId } });
if (!job) throw new NotFoundException(); if (!job) throw new NotFoundException();

View File

@ -1,120 +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 { UsersService } from '../users/users.service';
import { ConversionJobService } from './conversion-job.service';
const DAY = 24 * 60 * 60 * 1000;
/**
* Conversion payload retention (issue #233): finished jobs past
* `conversion.payloadRetentionDays` lose their raw input/result bytes while
* the row survives for status; pending and stale-RUNNING rows (the worker's
* lock-recovery path) keep their payload untouched.
*/
describe.skipIf(!hasTestDb)('conversion payload prune (e2e, issue #233)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let ownerId: string;
const suffix = uniqueSuffix();
const bytes = () => new Uint8Array(Buffer.from(`payload-${suffix}`));
async function jobRow(
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED',
ageDays: number,
lockedAt: Date | null = null,
) {
return prisma.conversionJob.create({
data: {
ownerId,
kind: `test_prune_${suffix}`,
sourceFormat: 'markdown',
targetFormat: 'html',
input: bytes(),
status,
result: status === 'SUCCEEDED' ? bytes() : null,
resultMimeType: status === 'SUCCEEDED' ? 'text/html' : null,
errorCode: status === 'FAILED' ? 'conversion_failed' : null,
lockedAt,
updatedAt: new Date(Date.now() - ageDays * DAY),
},
});
}
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: 'conversion.payloadRetentionDays' },
create: { key: 'conversion.payloadRetentionDays', value: 10 },
update: { value: 10 },
});
app = await createTestApp();
const user = await app.get(UsersService).createUser({
username: `pia-prune-${suffix}`,
email: `pia-prune-${suffix}@example.org`,
displayName: `Pia Prune ${suffix}`,
password: 'bytes verschwinden fristgerecht 1',
locale: 'en',
});
ownerId = user.id;
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: 'conversion.payloadRetentionDays' },
});
await prisma.conversionJob.deleteMany({ where: { kind: `test_prune_${suffix}` } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('prunes finished jobs past the period, keeps everything else', async () => {
const oldSucceeded = await jobRow('SUCCEEDED', 15);
const oldFailed = await jobRow('FAILED', 15);
const freshSucceeded = await jobRow('SUCCEEDED', 5);
const oldPending = await jobRow('PENDING', 15);
// A crashed run the worker's stale-lock recovery will pick up again —
// its input must survive or the retry would fail (#233 acceptance).
const oldStaleRunning = await jobRow('RUNNING', 15, new Date(Date.now() - 15 * DAY));
// >=: the shared suite database may hold other files' aged rows.
const pruned = await app.get(ConversionJobService).pruneExpiredPayloads();
expect(pruned).toBeGreaterThanOrEqual(2);
const byId = new Map(
(await prisma.conversionJob.findMany({ where: { kind: `test_prune_${suffix}` } })).map(
(job) => [job.id, job],
),
);
// The finished rows survive with status and error code, only bytes-free.
expect(byId.get(oldSucceeded.id)).toMatchObject({
status: 'SUCCEEDED',
input: null,
result: null,
resultMimeType: null,
});
expect(byId.get(oldFailed.id)).toMatchObject({
status: 'FAILED',
errorCode: 'conversion_failed',
input: null,
result: null,
});
expect(byId.get(freshSucceeded.id)!.input).not.toBeNull();
expect(byId.get(freshSucceeded.id)!.result).not.toBeNull();
expect(byId.get(oldPending.id)!.input).not.toBeNull();
expect(byId.get(oldStaleRunning.id)!.input).not.toBeNull();
});
it('is a no-op when nothing is due', async () => {
expect(await app.get(ConversionJobService).pruneExpiredPayloads()).toBe(0);
});
});

View File

@ -1,6 +1,3 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { ConversionJob } from '@prisma/client'; import { ConversionJob } from '@prisma/client';
@ -17,12 +14,7 @@ import {
} from './data-export.constants'; } from './data-export.constants';
import { GotenbergRenderer, RenderError } from './gotenberg.renderer'; import { GotenbergRenderer, RenderError } from './gotenberg.renderer';
import { IMPORT_PROCESSOR, ImportProcessor, isImportKind } from './import.constants'; import { IMPORT_PROCESSOR, ImportProcessor, isImportKind } from './import.constants';
import { import { ConversionError, ConversionResult, PandocConverter } from './pandoc.converter';
ConversionError,
ConversionResult,
conversionInputOf,
PandocConverter,
} from './pandoc.converter';
/** How often the worker sweeps for pending jobs on its own the safety net /** How often the worker sweeps for pending jobs on its own the safety net
* that makes a queued conversion survive an API restart even if no new * that makes a queued conversion survive an API restart even if no new
@ -67,36 +59,12 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
const result: ConversionResult = await this.converter.convert({ const result: ConversionResult = await this.converter.convert({
from: job.sourceFormat, from: job.sourceFormat,
to: job.targetFormat, to: job.targetFormat,
input: Buffer.from(conversionInputOf(job)), input: Buffer.from(job.input),
standalone: job.standalone, standalone: job.standalone,
referenceDoc: await this.classifiedReferenceDoc(job),
}); });
return { bytes: result.output, mimeType: result.mimeType }; return { bytes: result.output, mimeType: result.mimeType };
} }
/**
* The classified reference document for a marked docx/odt export (issue
* #209, ADR 0022): pandoc copies its header/footer which carry the
* VS-NfD marking into the output, so the marking repeats on every page
* in Word/LibreOffice and is not deletable body text. Only present when
* the enqueue put a `marking` into the job options; the binaries ship in
* `apps/api/assets/` (see `scripts/gen-classified-reference-docs.mjs`).
*/
private async classifiedReferenceDoc(
job: ConversionJob,
): Promise<{ name: string; bytes: Buffer } | undefined> {
const marked = Boolean((job.options as { marking?: string } | null)?.marking);
if (!marked || (job.targetFormat !== 'docx' && job.targetFormat !== 'odt')) return undefined;
const name = `reference-vs-nfd.${job.targetFormat}`;
const cached = this.referenceDocs.get(name);
if (cached) return { name, bytes: cached };
const bytes = await readFile(join(__dirname, '../../assets', name));
this.referenceDocs.set(name, bytes);
return { name, bytes };
}
private readonly referenceDocs = new Map<string, Buffer>();
onModuleInit(): void { onModuleInit(): void {
if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly
this.timer = setInterval(() => this.drainSafely(), SWEEP_MS); this.timer = setInterval(() => this.drainSafely(), SWEEP_MS);
@ -188,14 +156,8 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
.build(job); .build(job);
expiresAt = new Date(Date.now() + DATA_EXPORT_TTL_MS); expiresAt = new Date(Date.now() + DATA_EXPORT_TTL_MS);
} else if (job.targetFormat === 'pdf') { } else if (job.targetFormat === 'pdf') {
// A classified page's export carries its marking as a job option
// (issue #208) — Gotenberg repeats it in header/footer of every page.
const marking = (job.options as { marking?: string } | null)?.marking ?? null;
output = { output = {
bytes: await this.renderer.renderHtmlToPdf( bytes: await this.renderer.renderHtmlToPdf(Buffer.from(job.input).toString('utf8')),
Buffer.from(conversionInputOf(job)).toString('utf8'),
{ marking },
),
mimeType: 'application/pdf', mimeType: 'application/pdf',
}; };
} else { } else {

View File

@ -95,14 +95,7 @@ export class DataExportService implements DataExportProcessor {
orderBy: { slug: 'asc' }, orderBy: { slug: 'asc' },
}); });
for (const pond of ponds) { for (const pond of ponds) {
// The build runs in the conversion worker, outside any request — the await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`);
// read-trail session key (#222) is the job itself: `job:<id>` names the
// one download this build feeds, so the dedup window (#223) has a
// stable, honest key.
await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`, {
actorId: user.id,
sessionKey: `job:${job.id}`,
});
} }
await archive.finalize(); await archive.finalize();

View File

@ -1,21 +1,12 @@
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
import { import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
ConversionJobView,
PageExportInput,
PondArchivePreview,
pageExportInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
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 { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { readActorOf } from '../read-trail/read-actor';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { PondArchiveService } from './pond-archive.service';
/** /**
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and * Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
@ -24,41 +15,7 @@ import { PondArchiveService } from './pond-archive.service';
*/ */
@Controller() @Controller()
export class ExportController { export class ExportController {
constructor( constructor(private readonly exports: ExportService) {}
private readonly exports: ExportService,
private readonly archives: PondArchiveService,
) {}
/**
* How much of the pond this requester's archive would contain (issue #305).
* Asked before the download so the UI can name the number of omitted pages:
* an archive silently missing content is worse than no archive, because it
* ends the search.
*/
@Get('ponds/:pondId/archive/preview')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
archivePreview(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondArchivePreview> {
return this.archives.preview(request.user!, pondId, false);
}
/**
* The full archive: every readable page, EVERY attachment, and a versioned
* manifest with settings, labels, comments and the hierarchy (issue #305).
* Pond-Admin, because it is the deletion flow's last resort a reader who
* wants their own copy has the Markdown export.
*/
@Get('ponds/:pondId/archive')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async pondArchive(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@Res() response: Response,
): Promise<void> {
await this.archives.stream(request.user!, pondId, response, readActorOf(request), false);
}
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The /** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
* `reader` role is "may see the pond"; the service filters to readable pages, * `reader` role is "may see the pond"; the service filters to readable pages,
@ -70,7 +27,7 @@ export class ExportController {
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@Res() response: Response, @Res() response: Response,
): Promise<void> { ): Promise<void> {
await this.exports.streamPondMarkdownZip(request.user!, pondId, response, readActorOf(request)); await this.exports.streamPondMarkdownZip(request.user!, pondId, response);
} }
/** Enqueue a `.docx`/`.odt` export of one page; poll `GET /jobs/:id` and /** Enqueue a `.docx`/`.odt` export of one page; poll `GET /jobs/:id` and
@ -82,42 +39,6 @@ export class ExportController {
@Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput, @Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<ConversionJobView> { ): Promise<ConversionJobView> {
return this.exports.enqueuePageExport( return this.exports.enqueuePageExport(request.user!, pageId, input.format);
request.user!,
pageId,
input.format,
readActorOf(request),
);
}
}
/**
* The Site Admin's archive from the purge dialog (issue #305, #193).
*
* Separate controller because it must NOT carry `@RequiresPondRole`: a Site
* Admin purging a trashed pond is usually not a member of it, and the last
* archive before an irreversible purge must not depend on that. It is
* therefore complete by construction the read filter is skipped.
*/
@Controller('admin/trash')
@UseGuards(SiteAdminGuard)
export class PondArchiveAdminController {
constructor(private readonly archives: PondArchiveService) {}
@Get('ponds/:pondId/archive/preview')
archivePreview(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondArchivePreview> {
return this.archives.preview(request.user!, pondId, true);
}
@Get('ponds/:pondId/archive')
async archive(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@Res() response: Response,
): Promise<void> {
await this.archives.stream(request.user!, pondId, response, readActorOf(request), true);
} }
} }

View File

@ -1,8 +1,6 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { classificationMarking } from '@dorfteich/shared';
import { strFromU8, unzipSync } from 'fflate';
import { beforeAll, describe, expect, it, TestContext } from 'vitest'; import { beforeAll, describe, expect, it, TestContext } from 'vitest';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
@ -10,8 +8,6 @@ import { AppConfig } from '../config/app-config.service';
import { markdownForDocument } from './export-markdown'; import { markdownForDocument } from './export-markdown';
import { PandocServerConverter } from './pandoc.converter'; import { PandocServerConverter } from './pandoc.converter';
const MARKING = classificationMarking('vs_nfd')!;
/** /**
* Export fidelity regression (issue #69, ADR 0009): exports the committed * Export fidelity regression (issue #69, ADR 0009): exports the committed
* Markdown corpus to `.docx`/`.odt` through the real pinned pandoc and reads * Markdown corpus to `.docx`/`.odt` through the real pinned pandoc and reads
@ -74,68 +70,4 @@ describe('export fidelity corpus (real pandoc, issue #69)', () => {
}); });
} }
} }
// The classified reference documents (issue #209, ADR 0022): a marked
// export must carry the VS-NfD marking in the document's own header/footer
// definition (repeats per page in Word/LibreOffice, not deletable body
// text); an unmarked export must not. Asserted structurally against the
// real pinned pandoc; the body round-trip above stays untouched by the
// reference doc (headers are outside the content pandoc reads back).
it('a marked docx export carries the marking in header1.xml/footer1.xml; unmarked does not', async (ctx: TestContext) => {
if (!reachable) ctx.skip();
const referenceDoc = {
name: 'reference-vs-nfd.docx',
bytes: readFileSync(join(process.cwd(), 'assets/reference-vs-nfd.docx')),
};
const marked = await converter.convert({
from: 'gfm',
to: 'docx',
input: Buffer.from('# Marked\n\nbody', 'utf8'),
standalone: true,
referenceDoc,
});
const parts = unzipSync(new Uint8Array(marked.output));
const header = strFromU8(parts['word/header1.xml']!);
const footer = strFromU8(parts['word/footer1.xml']!);
expect(header).toContain(MARKING);
expect(footer).toContain(MARKING);
expect(strFromU8(parts['word/document.xml']!)).toContain('headerReference');
const unmarked = await converter.convert({
from: 'gfm',
to: 'docx',
input: Buffer.from('# Open\n\nbody', 'utf8'),
standalone: true,
});
const openParts = unzipSync(new Uint8Array(unmarked.output));
expect(openParts['word/header1.xml']).toBeUndefined();
});
it('a marked odt export carries the marking in its master-page header/footer; unmarked does not', async (ctx: TestContext) => {
if (!reachable) ctx.skip();
const referenceDoc = {
name: 'reference-vs-nfd.odt',
bytes: readFileSync(join(process.cwd(), 'assets/reference-vs-nfd.odt')),
};
const marked = await converter.convert({
from: 'gfm',
to: 'odt',
input: Buffer.from('# Marked\n\nbody', 'utf8'),
standalone: true,
referenceDoc,
});
const styles = strFromU8(unzipSync(new Uint8Array(marked.output))['styles.xml']!);
expect(styles).toContain('<style:header>');
const occurrences = styles.split(MARKING).length - 1;
expect(occurrences).toBeGreaterThanOrEqual(2); // header + footer
const unmarked = await converter.convert({
from: 'gfm',
to: 'odt',
input: Buffer.from('# Open\n\nbody', 'utf8'),
standalone: true,
});
const openStyles = strFromU8(unzipSync(new Uint8Array(unmarked.output))['styles.xml']!);
expect(openStyles).not.toContain(MARKING);
});
}); });

View File

@ -31,10 +31,8 @@ const PNG_BASE64 =
class RecordingConverter extends PandocConverter { class RecordingConverter extends PandocConverter {
lastInput = ''; lastInput = '';
lastReferenceDoc: string | null = null;
convert(request: ConversionRequest): Promise<ConversionResult> { convert(request: ConversionRequest): Promise<ConversionResult> {
this.lastInput = request.input.toString('utf8'); this.lastInput = request.input.toString('utf8');
this.lastReferenceDoc = request.referenceDoc?.name ?? null;
return Promise.resolve({ output: Buffer.from('OFFICE-BYTES'), mimeType: 'application/x-test' }); return Promise.resolve({ output: Buffer.from('OFFICE-BYTES'), mimeType: 'application/x-test' });
} }
reachable(): Promise<boolean> { reachable(): Promise<boolean> {
@ -44,11 +42,9 @@ class RecordingConverter extends PandocConverter {
class RecordingRenderer extends GotenbergRenderer { class RecordingRenderer extends GotenbergRenderer {
lastHtml = ''; lastHtml = '';
lastMarking: string | null = null;
failWith: RenderError | null = null; failWith: RenderError | null = null;
renderHtmlToPdf(html: string, options?: { marking?: string | null }): Promise<Buffer> { renderHtmlToPdf(html: string): Promise<Buffer> {
this.lastHtml = html; this.lastHtml = html;
this.lastMarking = options?.marking ?? null;
if (this.failWith) return Promise.reject(this.failWith); if (this.failWith) return Promise.reject(this.failWith);
return Promise.resolve(Buffer.from('%PDF-1.7 fake')); return Promise.resolve(Buffer.from('%PDF-1.7 fake'));
} }
@ -193,123 +189,6 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
expect(target).toContain(`![dot](media/${image.id}.png)`); expect(target).toContain(`![dot](media/${image.id}.png)`);
}); });
it('marks classified pages in the pond ZIP with frontmatter+imprint, ships a manifest, and round-trips (#210)', async () => {
const marking = 'VS NUR FÜR DEN DIENSTGEBRAUCH';
const classifiedSlug = await seedPage(
personalPondId,
'Zip Classified',
'# Zip Classified\n\nclassified body text',
);
const openSlug = await seedPage(personalPondId, 'Zip Open', '# Zip Open\n\nopen body text');
await prisma.page.updateMany({
where: { pondId: personalPondId, slug: classifiedSlug },
data: { classification: 'VS_NFD' },
});
const res = await api()
.get(`/api/v1/ponds/${personalPondId}/export/markdown`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse((r, cb) => {
const chunks: Buffer[] = [];
r.on('data', (c: Buffer) => chunks.push(c));
r.on('end', () => cb(null, Buffer.concat(chunks)));
})
.expect(200);
const entries = zipEntries(res.body as Buffer);
// Machine-readable frontmatter AND the visible imprint, top and bottom.
const marked = Buffer.from(entries[`${classifiedSlug}.md`]!).toString('utf8');
expect(marked.startsWith(`---\nclassification: vs_nfd\n---\n\n${marking}\n\n`)).toBe(true);
expect(marked.trimEnd().endsWith(marking)).toBe(true);
// Unclassified files are unchanged: no frontmatter, no imprint.
const open = Buffer.from(entries[`${openSlug}.md`]!).toString('utf8');
expect(open).not.toContain('classification:');
expect(open).not.toContain(marking);
// The manifest lists every file with its level and states the highest once.
const manifest = JSON.parse(Buffer.from(entries['manifest.json']!).toString('utf8')) as {
classification: string;
files: { path: string; classification: string }[];
};
expect(manifest.classification).toBe('vs_nfd');
expect(manifest.files).toContainEqual({
path: `${classifiedSlug}.md`,
classification: 'vs_nfd',
});
expect(manifest.files).toContainEqual({
path: `${openSlug}.md`,
classification: 'unclassified',
});
// Round-trip: re-importing the marked file must not confuse the importer —
// the page starts at the imported level, the body carries neither the
// frontmatter nor the imprint lines.
const imported = await api()
.post(`/api/v1/ponds/${personalPondId}/import`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from(marked, 'utf8'), 'reimported-classified.md')
.expect(201);
expect(imported.body.status).toBe('succeeded');
const reimported = await prisma.page.findUniqueOrThrow({
where: { id: imported.body.resultPageId as string },
});
expect(reimported.classification).toBe('VS_NFD');
const cache = await prisma.pageContentCache.findUniqueOrThrow({
where: { pageId: reimported.id },
});
expect(cache.markdown).toContain('classified body text');
expect(cache.markdown).not.toContain(marking);
expect(cache.markdown).not.toContain('classification:');
});
it('adds a classification companion for classified media in the ZIP (issue #212)', async () => {
const image = await files.upload({ id: ownerId } as never, personalPondId, {
buffer: Buffer.from(PNG_BASE64, 'base64'),
size: 70,
originalname: 'secret-dot.png',
});
const slug = await seedPage(
personalPondId,
'Zip Media Classified',
`# Zip Media Classified\n\n![dot](${image.id})`,
);
await prisma.page.updateMany({
where: { pondId: personalPondId, slug },
data: { classification: 'VS_NFD' },
});
const res = await api()
.get(`/api/v1/ponds/${personalPondId}/export/markdown`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse((r, cb) => {
const chunks: Buffer[] = [];
r.on('data', (c: Buffer) => chunks.push(c));
r.on('end', () => cb(null, Buffer.concat(chunks)));
})
.expect(200);
const entries = zipEntries(res.body as Buffer);
// Media inherits the highest referencing page's level: sibling companion
// carries the full marking; the manifest lists the media file's level.
const companion = entries[`media/${image.id}.png.classification.txt`];
expect(companion).toBeDefined();
expect(Buffer.from(companion!).toString('utf8')).toContain('VS NUR FÜR DEN DIENSTGEBRAUCH');
const manifest = JSON.parse(Buffer.from(entries['manifest.json']!).toString('utf8')) as {
files: { path: string; classification: string }[];
};
expect(manifest.files).toContainEqual({
path: `media/${image.id}.png`,
classification: 'vs_nfd',
});
await prisma.page.updateMany({
where: { pondId: personalPondId, slug },
data: { classification: 'UNCLASSIFIED' },
});
});
it('skips an attachment whose bytes are missing on disk instead of crashing', async () => { it('skips an attachment whose bytes are missing on disk instead of crashing', async () => {
// An attachment row with no file (data drift): upload then remove the bytes. // An attachment row with no file (data drift): upload then remove the bytes.
const image = await files.upload({ id: ownerId } as never, personalPondId, { const image = await files.upload({ id: ownerId } as never, personalPondId, {
@ -448,30 +327,6 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
.set('Cookie', ownerCookie) .set('Cookie', ownerCookie)
.expect(200); .expect(200);
expect(result.text).toBe('OFFICE-BYTES'); expect(result.text).toBe('OFFICE-BYTES');
// An unclassified page converts without a reference doc (issue #209).
expect(fake.lastReferenceDoc).toBeNull();
});
it('hands pandoc the classified reference doc for a marked page (issue #209)', async () => {
const slug = await seedPage(personalPondId, 'Classified Docx', '# Classified Docx\n\nbody');
const page = await prisma.page.findFirstOrThrow({
where: { pondId: personalPondId, slug },
});
await prisma.page.update({ where: { id: page.id }, data: { classification: 'VS_NFD' } });
const enqueued = await api()
.post(`/api/v1/pages/${page.id}/export`)
.set('Cookie', ownerCookie)
.send({ format: 'docx' })
.expect(201);
await worker.drain();
expect(fake.lastReferenceDoc).toBe('reference-vs-nfd.docx');
const done = await api()
.get(`/api/v1/jobs/${enqueued.body.id}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(done.body.status).toBe('succeeded');
}); });
it('exports a page to PDF: content + image inlined, font CSS, via Gotenberg', async () => { it('exports a page to PDF: content + image inlined, font CSS, via Gotenberg', async () => {
@ -522,35 +377,6 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
.expect(200); .expect(200);
expect(result.headers['content-type']).toContain('application/pdf'); expect(result.headers['content-type']).toContain('application/pdf');
expect((result.body as Buffer).toString('utf8')).toContain('%PDF'); expect((result.body as Buffer).toString('utf8')).toContain('%PDF');
// An unclassified page renders without any marking option (issue #208).
expect(renderer.lastMarking).toBeNull();
});
it('hands the VS-NfD marking of a classified page to the renderer (issue #208)', async () => {
const slug = await seedPage(
personalPondId,
'Classified Pdf',
'# Classified Pdf\n\nbody',
'<p>Classified body.</p>',
);
const page = await prisma.page.findFirstOrThrow({
where: { pondId: personalPondId, slug },
});
await prisma.page.update({ where: { id: page.id }, data: { classification: 'VS_NFD' } });
const enqueued = await api()
.post(`/api/v1/pages/${page.id}/export`)
.set('Cookie', ownerCookie)
.send({ format: 'pdf' })
.expect(201);
await worker.drain();
expect(renderer.lastMarking).toBe('VS NUR FÜR DEN DIENSTGEBRAUCH');
const done = await api()
.get(`/api/v1/jobs/${enqueued.body.id}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(done.body.status).toBe('succeeded');
}); });
it('inlines active section-style plugin CSS into the PDF html (#75)', async () => { it('inlines active section-style plugin CSS into the PDF html (#75)', async () => {

View File

@ -6,12 +6,7 @@ import {
ConversionJobView, ConversionJobView,
ExportFormat, ExportFormat,
PondFonts, PondFonts,
customFontEntries,
fontSlug, fontSlug,
PageClassification,
classificationMarking,
classificationRank,
highestClassification,
pondSettingsSchema, pondSettingsSchema,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { User } from '@prisma/client'; import { User } from '@prisma/client';
@ -21,14 +16,11 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service'; import { FileStorageService } from '../files/file-storage.service';
import { CustomFontsService } from '../fonts/custom-fonts.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PluginsService } from '../plugins/plugins.service'; import { PluginsService } from '../plugins/plugins.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { markClassifiedMarkdown } from './classified-markdown';
import { ConversionJobService } from './conversion-job.service'; import { ConversionJobService } from './conversion-job.service';
import { import {
imageExtension, imageExtension,
@ -55,8 +47,6 @@ export class ExportService {
private readonly plugins: PluginsService, private readonly plugins: PluginsService,
private readonly fallbacks: PluginFallbackRenderer, private readonly fallbacks: PluginFallbackRenderer,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly customFonts: CustomFontsService,
private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(ExportService.name); this.logger.setContext(ExportService.name);
@ -69,12 +59,7 @@ export class ExportService {
* already checked the requester may see the pond; here we filter to the pages * already checked the requester may see the pond; here we filter to the pages
* they may actually read. * they may actually read.
*/ */
async streamPondMarkdownZip( async streamPondMarkdownZip(user: User, pondId: string, res: Response): Promise<void> {
user: User,
pondId: string,
res: Response,
read: ReadActor,
): Promise<void> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException(); if (!pond) throw new NotFoundException();
@ -87,7 +72,7 @@ export class ExportService {
res.destroy(error); res.destroy(error);
}); });
archive.pipe(res); archive.pipe(res);
await this.appendPondMarkdown(archive, user, pond, '', read); await this.appendPondMarkdown(archive, user, pond);
await archive.finalize(); await archive.finalize();
} }
@ -103,8 +88,7 @@ export class ExportService {
archive: archiver.Archiver, archive: archiver.Archiver,
user: User, user: User,
pond: { id: string; slug: string }, pond: { id: string; slug: string },
prefix: string, prefix = '',
read: ReadActor,
): Promise<void> { ): Promise<void> {
const pondId = pond.id; const pondId = pond.id;
const pages = await this.prisma.page.findMany({ const pages = await this.prisma.page.findMany({
@ -124,21 +108,6 @@ export class ExportService {
const readablePages = pages.filter((page) => readableIds.has(page.id)); const readablePages = pages.filter((page) => readableIds.has(page.id));
const readableSlugs = new Set(readablePages.map((page) => page.slug)); const readableSlugs = new Set(readablePages.map((page) => page.slug));
// Read trail (issue #222): the ZIP is a bulk-egress channel — one event
// per classified page it will contain, recorded BEFORE any classified
// bytes enter the stream, so a failed write aborts the download while the
// evidence is still complete (ADR 0023).
for (const page of readablePages) {
if (page.classification !== 'VS_NFD') continue;
await this.readTrail.record({
...read,
pageId: page.id,
pondId,
channel: 'export',
details: { format: 'markdown_zip' },
});
}
// Every image referenced by a readable page — resolved to attachments that // Every image referenced by a readable page — resolved to attachments that
// still exist in this pond, so the media directory matches the rewrites. // still exist in this pond, so the media directory matches the rewrites.
const referenced = new Set<string>(); const referenced = new Set<string>();
@ -162,64 +131,15 @@ export class ExportService {
attachments.map((a) => [a.id, `${a.id}.${imageExtension(a.mimeType)}`]), attachments.map((a) => [a.id, `${a.id}.${imageExtension(a.mimeType)}`]),
); );
// Media inherits the highest classification among the readable pages that
// reference it (fail-closed, ADR 0022 — a shared image is as classified
// as its most classified use).
const mediaClassification = new Map<string, PageClassification>();
for (const page of readablePages) { for (const page of readablePages) {
const level = page.classification.toLowerCase() as PageClassification; const markdown = markdownForZip(
for (const id of imageFileIds(page.contentCache?.markdown ?? '')) { page.contentCache?.markdown ?? '',
const current = mediaClassification.get(id) ?? 'unclassified'; readableSlugs,
if (classificationRank(level) > classificationRank(current)) { mediaNameById,
mediaClassification.set(id, level);
}
}
}
const manifestFiles: { path: string; classification: PageClassification }[] = [];
for (const page of readablePages) {
const level = page.classification.toLowerCase() as PageClassification;
// A classified page's file carries the level in YAML frontmatter and
// the marking line at top and bottom (#210); unclassified files are
// byte-identical to the pre-#210 export.
const markdown = markClassifiedMarkdown(
markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaNameById),
level,
); );
// Page slugs are unique within a pond, so `<slug>.md` never collides. // Page slugs are unique within a pond, so `<slug>.md` never collides.
archive.append(markdown, { name: `${prefix}${page.slug}.md` }); archive.append(markdown, { name: `${prefix}${page.slug}.md` });
manifestFiles.push({ path: `${prefix}${page.slug}.md`, classification: level });
} }
for (const attachment of attachments) {
const mediaLevel = mediaClassification.get(attachment.id) ?? 'unclassified';
manifestFiles.push({
path: `${prefix}media/${mediaNameById.get(attachment.id)!}`,
classification: mediaLevel,
});
// Companion file for classified media (issue #212): the binary itself
// cannot carry the marking, so a sibling text file states it — it
// survives unpacking and copying, where the manifest may be dropped.
const mediaMarking = classificationMarking(mediaLevel);
if (mediaMarking) {
archive.append(`${mediaMarking}\n`, {
name: `${prefix}media/${mediaNameById.get(attachment.id)!}.classification.txt`,
});
}
}
// The archive-level manifest (#210): every file with its level, and the
// highest level contained stated once — the bulk-egress channel stays
// machine-checkable even after the ZIP is unpacked and copied onward.
archive.append(
JSON.stringify(
{
classification: highestClassification(manifestFiles.map((f) => f.classification)),
files: manifestFiles,
},
null,
2,
),
{ name: `${prefix}manifest.json` },
);
for (const attachment of attachments) { for (const attachment of attachments) {
const stream = this.storage.createReadStream(pond.id, attachment.id); const stream = this.storage.createReadStream(pond.id, attachment.id);
// Defence in depth: a file removed between the existence check and the // Defence in depth: a file removed between the existence check and the
@ -248,18 +168,14 @@ export class ExportService {
user: User, user: User,
pageId: string, pageId: string,
format: ExportFormat, format: ExportFormat,
read: ReadActor,
): Promise<ConversionJobView> { ): Promise<ConversionJobView> {
if (format === 'pdf') return this.enqueuePdfExport(user, pageId, read); if (format === 'pdf') return this.enqueuePdfExport(user, pageId);
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null }, where: { id: pageId, deletedAt: null },
include: { contentCache: { select: { markdown: true } } }, include: { contentCache: { select: { markdown: true } } },
}); });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
// Read trail (issue #222): recorded at enqueue — the user's action; the
// worker's later conversion is machinery, not a second read.
await this.recordClassifiedExport(page, read, format);
// Plugin blocks degrade to their fallback text and sections to quoted // Plugin blocks degrade to their fallback text and sections to quoted
// blocks first (#79) — GFM knows neither construct, and pandoc would // blocks first (#79) — GFM knows neither construct, and pandoc would
@ -268,10 +184,6 @@ export class ExportService {
const dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown)); const dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown));
const document = markdownForDocument(markdown, dataUriById); const document = markdownForDocument(markdown, dataUriById);
// A classified page's export records its marking as a job option (#209):
// the worker then hands pandoc the classified reference document whose
// header/footer carry the marking on every page in Word/LibreOffice.
const marking = classificationMarking(page.classification.toLowerCase() as PageClassification);
const job = await this.jobs.enqueue({ const job = await this.jobs.enqueue({
ownerId: user.id, ownerId: user.id,
kind: `export_${format}`, kind: `export_${format}`,
@ -279,7 +191,6 @@ export class ExportService {
to: format, to: format,
input: Buffer.from(document, 'utf8'), input: Buffer.from(document, 'utf8'),
standalone: true, standalone: true,
...(marking ? { options: { marking } } : {}),
}); });
this.logger.info( this.logger.info(
{ jobId: job.id, pageId, format, userId: user.id }, { jobId: job.id, pageId, format, userId: user.id },
@ -295,27 +206,7 @@ export class ExportService {
* the job input; the worker sends it to Gotenberg (`html → pdf`). Building it * the job input; the worker sends it to Gotenberg (`html → pdf`). Building it
* up front keeps the job a plain bytebyte render the worker can retry. * up front keeps the job a plain bytebyte render the worker can retry.
*/ */
/** One `export` event for a classified page leaving as a document (#222). */ private async enqueuePdfExport(user: User, pageId: string): Promise<ConversionJobView> {
private async recordClassifiedExport(
page: { id: string; pondId: string; classification: string },
read: ReadActor,
format: ExportFormat,
): Promise<void> {
if (page.classification !== 'VS_NFD') return;
await this.readTrail.record({
...read,
pageId: page.id,
pondId: page.pondId,
channel: 'export',
details: { format },
});
}
private async enqueuePdfExport(
user: User,
pageId: string,
read: ReadActor,
): Promise<ConversionJobView> {
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null }, where: { id: pageId, deletedAt: null },
include: { include: {
@ -324,7 +215,6 @@ export class ExportService {
}, },
}); });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
await this.recordClassifiedExport(page, read, 'pdf');
const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts; const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts;
// Plugin blocks first become their best static form (#79: stored SVG // Plugin blocks first become their best static form (#79: stored SVG
@ -336,19 +226,12 @@ export class ExportService {
pondName: page.pond.name, pondName: page.pond.name,
bodyHtml, bodyHtml,
fonts, fonts,
// Both the rules and the stack need the uploaded families: embedding a
// face the stack never names would render the system font (issue #304).
customFonts: customFontEntries(await this.customFonts.list()),
fontFaceCss: await this.fontFaceCss(fonts), fontFaceCss: await this.fontFaceCss(fonts),
// Styled sections keep their look in the PDF (#75); a pond without // Styled sections keep their look in the PDF (#75); a pond without
// active style plugins contributes an empty string. // active style plugins contributes an empty string.
sectionStyleCss: await this.plugins.sectionStyleCssForPond(page.pondId), sectionStyleCss: await this.plugins.sectionStyleCssForPond(page.pondId),
}); });
// The VS-NfD marking (issue #208, ADR 0022) travels as a job option so
// the worker can hand it to Gotenberg's per-page header/footer templates
// — an unclassified page carries none and renders exactly as before.
const marking = classificationMarking(page.classification.toLowerCase() as PageClassification);
const job = await this.jobs.enqueue({ const job = await this.jobs.enqueue({
ownerId: user.id, ownerId: user.id,
kind: 'export_pdf', kind: 'export_pdf',
@ -356,7 +239,6 @@ export class ExportService {
to: 'pdf', to: 'pdf',
input: Buffer.from(html, 'utf8'), input: Buffer.from(html, 'utf8'),
standalone: true, standalone: true,
...(marking ? { options: { marking } } : {}),
}); });
this.logger.info( this.logger.info(
{ jobId: job.id, pageId, format: 'pdf', userId: user.id }, { jobId: job.id, pageId, format: 'pdf', userId: user.id },
@ -377,18 +259,12 @@ export class ExportService {
}); });
} }
/** Base64 `@font-face` rules for the pond's three fonts. Catalog families /** Base64 `@font-face` rules for the pond's three fonts, read from the
* come from the directory baked into the image (ADR 0016); operator-uploaded * catalog baked into the image (ADR 0016). A font file that is absent (a
* ones from `CUSTOM_FONTS_DIR` (issue #303) same on-disk layout, so only * native dev run without `FONTS_DIR` populated) is skipped the render falls
* the base directory differs. A font file that is absent (a native dev run * back to the system stack rather than failing. */
* without `FONTS_DIR` populated, or a family deleted between the settings
* write and the export) is skipped: the render falls back to the system
* stack rather than failing. */
private async fontFaceCss(fonts: PondFonts): Promise<string> { private async fontFaceCss(fonts: PondFonts): Promise<string> {
const slots = [fonts.heading, fonts.body, fonts.mono]; const slots = [fonts.heading, fonts.body, fonts.mono];
const customSlugs = new Map(
(await this.customFonts.list()).map((font) => [font.family, font.slug]),
);
// Dedup identical family+weight so a doc that repeats a font embeds it once. // Dedup identical family+weight so a doc that repeats a font embeds it once.
const seen = new Set<string>(); const seen = new Set<string>();
const faces: string[] = []; const faces: string[] = [];
@ -396,10 +272,8 @@ export class ExportService {
const key = `${slot.family}:${slot.weight}`; const key = `${slot.family}:${slot.weight}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const customSlug = customSlugs.get(slot.family); const slug = fontSlug(slot.family);
const slug = customSlug ?? fontSlug(slot.family); const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
const baseDir = customSlug ? this.config.env.CUSTOM_FONTS_DIR : this.config.env.FONTS_DIR;
const file = join(baseDir, slug, `${slug}-${slot.weight}.woff2`);
try { try {
const bytes = await readFile(file); const bytes = await readFile(file);
faces.push( faces.push(
@ -407,7 +281,7 @@ export class ExportService {
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`, ` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
); );
} catch { } catch {
this.logger.warn({ font: key }, 'pdf export: font file missing, using fallback'); this.logger.warn({ font: key }, 'pdf export: catalog font file missing, using fallback');
} }
} }
return faces.join('\n'); return faces.join('\n');

View File

@ -27,40 +27,6 @@ const FOOTER_HTML =
'<span class="pageNumber"></span> / <span class="totalPages"></span>' + '<span class="pageNumber"></span> / <span class="totalPages"></span>' +
'</div></body></html>'; '</div></body></html>';
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Per-page running header carrying the VS-NfD marking (issue #208,
* ADR 0022) rendered by Gotenberg's Chromium header template on EVERY
* page, so a printed/filed PDF stays marked even as single sheets. */
function markingHeaderHtml(marking: string): string {
return (
'<html><head><style>body{margin:0;font:bold 9px system-ui;color:#000;width:100%;' +
'letter-spacing:0.08em;}div{text-align:center;}</style></head><body><div>' +
escapeHtml(marking) +
'</div></body></html>'
);
}
/** Footer variant with the marking next to the existing page numbers. */
function markingFooterHtml(marking: string): string {
return (
'<html><head><style>body{margin:0;font:9px system-ui;color:#64748b;width:100%;}' +
'div{text-align:center;}b{color:#000;letter-spacing:0.08em;}</style></head><body><div>' +
`<b>${escapeHtml(marking)}</b> · ` +
'<span class="pageNumber"></span> / <span class="totalPages"></span>' +
'</div></body></html>'
);
}
/** Options for a render; `marking` = the classification wording to repeat
* in header and footer of every page, `null`/absent = no marking and an
* output byte-identical in layout to the pre-#208 renderer. */
export interface RenderPdfOptions {
marking?: string | null;
}
/** Per ADR 0009: a single render may run for at most 60 s. */ /** Per ADR 0009: a single render may run for at most 60 s. */
const RENDER_TIMEOUT_MS = 60_000; const RENDER_TIMEOUT_MS = 60_000;
@ -72,7 +38,7 @@ const RENDER_TIMEOUT_MS = 60_000;
*/ */
export abstract class GotenbergRenderer { export abstract class GotenbergRenderer {
/** Render a standalone HTML document (fonts/images already inlined) to PDF. */ /** Render a standalone HTML document (fonts/images already inlined) to PDF. */
abstract renderHtmlToPdf(html: string, options?: RenderPdfOptions): Promise<Buffer>; abstract renderHtmlToPdf(html: string): Promise<Buffer>;
abstract reachable(): Promise<boolean>; abstract reachable(): Promise<boolean>;
} }
@ -97,33 +63,16 @@ export class GotenbergHttpRenderer extends GotenbergRenderer {
} }
} }
async renderHtmlToPdf(html: string, options: RenderPdfOptions = {}): Promise<Buffer> { async renderHtmlToPdf(html: string): Promise<Buffer> {
const marking = options.marking ?? null;
const form = new FormData(); const form = new FormData();
// Gotenberg's Chromium route requires the main document to be `index.html`. // Gotenberg's Chromium route requires the main document to be `index.html`.
form.append('files', new Blob([html], { type: 'text/html' }), 'index.html'); form.append('files', new Blob([html], { type: 'text/html' }), 'index.html');
// A marked page (issue #208) gets the classification as a per-page form.append('files', new Blob([FOOTER_HTML], { type: 'text/html' }), 'footer.html');
// running header AND next to the page numbers in the footer; without a
// marking the forms are exactly the pre-#208 ones (unchanged output).
if (marking) {
form.append(
'files',
new Blob([markingHeaderHtml(marking)], { type: 'text/html' }),
'header.html',
);
form.append(
'files',
new Blob([markingFooterHtml(marking)], { type: 'text/html' }),
'footer.html',
);
} else {
form.append('files', new Blob([FOOTER_HTML], { type: 'text/html' }), 'footer.html');
}
// Page geometry: A4 with room at the bottom for the page-number footer. The // Page geometry: A4 with room at the bottom for the page-number footer. The
// document's own `@page`/print CSS controls the rest of the layout. // document's own `@page`/print CSS controls the rest of the layout.
form.append('paperWidth', '8.27'); form.append('paperWidth', '8.27');
form.append('paperHeight', '11.7'); form.append('paperHeight', '11.7');
form.append('marginTop', marking ? '0.8' : '0.6'); form.append('marginTop', '0.6');
form.append('marginBottom', '0.8'); form.append('marginBottom', '0.8');
form.append('marginLeft', '0.7'); form.append('marginLeft', '0.7');
form.append('marginRight', '0.7'); form.append('marginRight', '0.7');

View File

@ -1,21 +1,18 @@
import { Module, OnModuleInit } from '@nestjs/common'; import { Module, OnModuleInit } from '@nestjs/common';
import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
import { FontsModule } from '../fonts/fonts.module';
import { LabelsModule } from '../labels/labels.module'; import { LabelsModule } from '../labels/labels.module';
import { PagesModule } from '../pages/pages.module'; import { PagesModule } from '../pages/pages.module';
import { PluginsModule } from '../plugins/plugins.module'; import { PluginsModule } from '../plugins/plugins.module';
import { SchedulerModule } from '../scheduler/scheduler.module'; import { SchedulerModule } from '../scheduler/scheduler.module';
import { SchedulerService } from '../scheduler/scheduler.service'; import { SchedulerService } from '../scheduler/scheduler.service';
import { SettingsModule } from '../settings/settings.module';
import { ConversionJobService } from './conversion-job.service'; import { ConversionJobService } from './conversion-job.service';
import { ConversionWorker } from './conversion-worker.service'; import { ConversionWorker } from './conversion-worker.service';
import { DATA_EXPORT_PROCESSOR } from './data-export.constants'; import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
import { DataExportController } from './data-export.controller'; import { DataExportController } from './data-export.controller';
import { DataExportService } from './data-export.service'; import { DataExportService } from './data-export.service';
import { ExportController, PondArchiveAdminController } from './export.controller'; import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer'; import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
import { IMPORT_PROCESSOR } from './import.constants'; import { IMPORT_PROCESSOR } from './import.constants';
@ -23,45 +20,24 @@ import { ImportController } from './import.controller';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
import { JobsController } from './jobs.controller'; import { JobsController } from './jobs.controller';
import { PandocConverter, PandocServerConverter } from './pandoc.converter'; import { PandocConverter, PandocServerConverter } from './pandoc.converter';
import { PondArchiveService } from './pond-archive.service';
/** How often expired data-export payloads are purged (#68). Hourly is ample: /** How often expired data-export payloads are purged (#68). Hourly is ample:
* the link's own expiry check already stops downloads the moment it lapses. */ * the link's own expiry check already stops downloads the moment it lapses. */
const EXPORT_PURGE_CADENCE_SECONDS = 60 * 60; const EXPORT_PURGE_CADENCE_SECONDS = 60 * 60;
/** Daily, per operations.md's maintenance-jobs table (issue #233): the
* payload retention works in days, so a tighter cadence buys nothing. */
const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
/** /**
* Import/export orchestration (ADR 0009): the conversion job queue, its worker, * Import/export orchestration (ADR 0009): the conversion job queue, its worker,
* the pandoc-server client (#62), the document import pipeline (#63), the * the pandoc-server client (#62), the document import pipeline (#63), the
* feature exports (#65/#67), and the GDPR account data export (#68). * feature exports (#65/#67), and the GDPR account data export (#68).
*/ */
@Module({ @Module({
imports: [ imports: [FilesModule, LabelsModule, PagesModule, PluginsModule, SchedulerModule],
CommonModule, controllers: [JobsController, ImportController, ExportController, DataExportController],
FilesModule,
FontsModule,
LabelsModule,
PagesModule,
PluginsModule,
SchedulerModule,
SettingsModule,
],
controllers: [
JobsController,
ImportController,
ExportController,
PondArchiveAdminController,
DataExportController,
],
providers: [ providers: [
ConversionJobService, ConversionJobService,
ConversionWorker, ConversionWorker,
ImportService, ImportService,
ExportService, ExportService,
PondArchiveService,
DataExportService, DataExportService,
// The worker resolves the import pipeline through this token (never the // The worker resolves the import pipeline through this token (never the
// class), so its file does not import the import service's (avoids a cycle). // class), so its file does not import the import service's (avoids a cycle).
@ -80,7 +56,6 @@ export class ImportExportModule implements OnModuleInit {
constructor( constructor(
private readonly scheduler: SchedulerService, private readonly scheduler: SchedulerService,
private readonly dataExport: DataExportService, private readonly dataExport: DataExportService,
private readonly jobs: ConversionJobService,
) {} ) {}
onModuleInit(): void { onModuleInit(): void {
@ -89,10 +64,5 @@ export class ImportExportModule implements OnModuleInit {
cadenceSeconds: EXPORT_PURGE_CADENCE_SECONDS, cadenceSeconds: EXPORT_PURGE_CADENCE_SECONDS,
run: () => this.dataExport.purgeExpired().then(() => undefined), run: () => this.dataExport.purgeExpired().then(() => undefined),
}); });
this.scheduler.register({
name: 'conversion-payload-prune',
cadenceSeconds: PAYLOAD_PRUNE_CADENCE_SECONDS,
run: () => this.jobs.pruneExpiredPayloads().then(() => undefined),
});
} }
} }

View File

@ -24,7 +24,6 @@ import { PagesService } from '../pages/pages.service';
import { docToState, emptyPageState } from '../pages/yjs-content'; import { docToState, emptyPageState } from '../pages/yjs-content';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { parseClassifiedMarkdown } from './classified-markdown';
import { ImportProcessor } from './import.constants'; import { ImportProcessor } from './import.constants';
import { import {
ASSET_PLACEHOLDER_PREFIX, ASSET_PLACEHOLDER_PREFIX,
@ -33,7 +32,7 @@ import {
parseVaultZip, parseVaultZip,
planVaultImport, planVaultImport,
} from './obsidian-vault'; } from './obsidian-vault';
import { ConversionError, conversionInputOf, PandocConverter } from './pandoc.converter'; import { ConversionError, PandocConverter } from './pandoc.converter';
import { ConversionJobService } from './conversion-job.service'; import { ConversionJobService } from './conversion-job.service';
/** Source format per accepted upload extension (ADR 0009). `md` is our own /** Source format per accepted upload extension (ADR 0009). `md` is our own
@ -271,7 +270,7 @@ export class ImportService implements ImportProcessor {
const rawMarkdown = await convertImportedDocument( const rawMarkdown = await convertImportedDocument(
this.converter, this.converter,
job.sourceFormat, job.sourceFormat,
Buffer.from(conversionInputOf(job)), Buffer.from(job.input),
); );
const page = await this.createPageFromMarkdown(user, job.pondId, rawMarkdown, job.sourceName); const page = await this.createPageFromMarkdown(user, job.pondId, rawMarkdown, job.sourceName);
await this.prisma.conversionJob.update({ await this.prisma.conversionJob.update({
@ -322,7 +321,7 @@ export class ImportService implements ImportProcessor {
let plan: VaultImportPlan; let plan: VaultImportPlan;
let assetBytes: Map<string, { data: Uint8Array; name: string }>; let assetBytes: Map<string, { data: Uint8Array; name: string }>;
try { try {
const zip = new Uint8Array(conversionInputOf(job)); const zip = new Uint8Array(job.input);
plan = planVaultImport(zip, { plan = planVaultImport(zip, {
frontmatterMode: options.frontmatterMode, frontmatterMode: options.frontmatterMode,
existingSlugs: new Set(existing.map((row) => row.slug)), existingSlugs: new Set(existing.map((row) => row.slug)),
@ -545,24 +544,12 @@ export class ImportService implements ImportProcessor {
// file ids); track what we create so a later failure can be rolled back. // file ids); track what we create so a later failure can be rolled back.
const storedFileIds: string[] = []; const storedFileIds: string[] = [];
try { try {
// Our own classified export wraps the content in frontmatter + marking const markdown = await this.storeEmbeddedImages(rawMarkdown, user, pondId, storedFileIds);
// lines (#210) — strip them and carry the level into the new page, so
// a round-trip neither duplicates the marking nor loses it.
const { markdown: unwrapped, classification } = parseClassifiedMarkdown(rawMarkdown);
const markdown = await this.storeEmbeddedImages(unwrapped, user, pondId, storedFileIds);
const json = markdownToDoc(markdown).toJSON() as unknown as PmNode; const json = markdownToDoc(markdown).toJSON() as unknown as PmNode;
const { title, doc } = this.splitTitle(json, sourceName); const { title, doc } = this.splitTitle(json, sourceName);
const state = docToState(Node.fromJSON(editorSchema, doc)); const state = docToState(Node.fromJSON(editorSchema, doc));
const page = await this.pages.createWithState( const page = await this.pages.createWithState(user, pondId, title, state);
user,
pondId,
title,
state,
null,
undefined,
classification,
);
await this.files.linkAttachmentsToPage(storedFileIds, page.id); await this.files.linkAttachmentsToPage(storedFileIds, page.id);
return page; return page;
} catch (error) { } catch (error) {

View File

@ -17,11 +17,6 @@ export interface ConversionRequest {
/** Line-wrapping of the writer's output. Import uses `none` so a paragraph /** Line-wrapping of the writer's output. Import uses `none` so a paragraph
* stays on one line (no soft breaks inside image alt text or links). */ * stays on one line (no soft breaks inside image alt text or links). */
wrap?: 'none' | 'auto' | 'preserve'; wrap?: 'none' | 'auto' | 'preserve';
/** Reference document for the docx/odt writers (issue #209, ADR 0022):
* pandoc copies its page setup including the header/footer that carry
* the VS-NfD marking into the output. Sent to pandoc-server as an
* in-request file plus the `reference-doc` option. */
referenceDoc?: { name: string; bytes: Buffer };
} }
export interface ConversionResult { export interface ConversionResult {
@ -55,17 +50,6 @@ export class ConversionError extends Error {
} }
} }
/** The job's input bytes. Since #233 the column is nullable the retention
* job prunes finished jobs' payloads. It never touches PENDING/RUNNING rows
* (incl. stale-lock recovery), so a claimed job without input was re-queued
* by hand; fail it finally instead of crashing the worker. */
export function conversionInputOf(job: { input: Uint8Array | null }): Uint8Array {
if (!job.input) {
throw new ConversionError('conversion_failed', false, 'input payload was pruned');
}
return job.input;
}
/** Server-side conversion limits (ADR 0009). Input is checked before the /** Server-side conversion limits (ADR 0009). Input is checked before the
* sidecar call; output is capped while reading the response so a runaway * sidecar call; output is capped while reading the response so a runaway
* conversion can't exhaust memory. */ * conversion can't exhaust memory. */
@ -146,14 +130,6 @@ export class PandocServerConverter extends PandocConverter {
// so these are only present when the import pipeline sets them. // so these are only present when the import pipeline sets them.
...(request.embedResources ? { 'embed-resources': true } : {}), ...(request.embedResources ? { 'embed-resources': true } : {}),
...(request.wrap ? { wrap: request.wrap } : {}), ...(request.wrap ? { wrap: request.wrap } : {}),
...(request.referenceDoc
? {
'reference-doc': request.referenceDoc.name,
files: {
[request.referenceDoc.name]: request.referenceDoc.bytes.toString('base64'),
},
}
: {}),
}), }),
signal: controller.signal, signal: controller.signal,
}); });

View File

@ -1,51 +0,0 @@
import { DEFAULT_FONTS, customFontEntries } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest';
import { buildPdfHtml } from './pdf-html';
const CUSTOM = customFontEntries([
{
id: 'f1',
family: 'Corporate Grotesk',
slug: 'corporate-grotesk',
category: 'sans-serif',
licence: 'Bought from Foundry X',
licenceUrl: null,
weights: [400, 700],
createdAt: '2026-08-01T00:00:00.000Z',
},
]);
function base(family: string): Parameters<typeof buildPdfHtml>[0] {
return {
title: 'T',
pondName: 'P',
bodyHtml: '<p>x</p>',
fonts: { ...DEFAULT_FONTS, body: { family, weight: 400 } },
fontFaceCss: `@font-face { font-family: '${family}'; src: url('data:font/woff2;base64,AA'); }`,
};
}
describe('buildPdfHtml font stacks (issues #303/#304)', () => {
it('names an operator-uploaded family in the CSS stack when it is known', () => {
const html = buildPdfHtml({ ...base('Corporate Grotesk'), customFonts: CUSTOM });
expect(html).toContain("--font-body: 'Corporate Grotesk',");
});
/**
* The regression this pins: the `@font-face` rule for a custom family was
* embedded, but `fontStack` not knowing the family produced the bare
* system fallback, so the rule was never referenced and the PDF rendered in
* the system font while everything reported success.
*/
it('would fall back to the system stack without the uploaded families', () => {
const html = buildPdfHtml(base('Corporate Grotesk'));
expect(html).not.toContain("'Corporate Grotesk',");
expect(html).toContain('--font-body: system-ui');
});
it('leaves catalog families working without any uploaded ones', () => {
const html = buildPdfHtml(base('Lora'));
expect(html).toContain("--font-body: 'Lora', Georgia");
});
});

View File

@ -1,4 +1,4 @@
import { FontCatalogEntry, PondFonts, fontStack } from '@dorfteich/shared'; import { PondFonts, fontStack } from '@dorfteich/shared';
export interface PdfHtmlParams { export interface PdfHtmlParams {
title: string; title: string;
@ -8,12 +8,6 @@ export interface PdfHtmlParams {
fonts: PondFonts; fonts: PondFonts;
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */ /** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
fontFaceCss: string; fontFaceCss: string;
/** The instance's operator-uploaded families (issue #303), so a pond set to
* one gets it NAMED in the `font-family` stack. Without them `fontStack`
* cannot tell a custom family from a typo and yields the bare system
* fallback the `@font-face` rule would then be embedded but never
* referenced, and the PDF would silently render in the system font. */
customFonts?: readonly FontCatalogEntry[];
/** The pond's active section-style plugin CSS (issue #75), already validated /** The pond's active section-style plugin CSS (issue #75), already validated
* at install time (scoped selectors, no external fetches, no `</style>`). * at install time (scoped selectors, no external fetches, no `</style>`).
* Sections of a disabled plugin render neutrally their class matches * Sections of a disabled plugin render neutrally their class matches
@ -41,7 +35,6 @@ function escapeHtml(value: string): string {
*/ */
export function buildPdfHtml(params: PdfHtmlParams): string { export function buildPdfHtml(params: PdfHtmlParams): string {
const { fonts } = params; const { fonts } = params;
const extra = params.customFonts ?? [];
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@ -51,9 +44,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
${params.fontFaceCss} ${params.fontFaceCss}
@page { size: A4; } @page { size: A4; }
:root { :root {
--font-heading: ${fontStack(fonts.heading.family, extra)}; --font-heading: ${fontStack(fonts.heading.family)};
--font-body: ${fontStack(fonts.body.family, extra)}; --font-body: ${fontStack(fonts.body.family)};
--font-mono: ${fontStack(fonts.mono.family, extra)}; --font-mono: ${fontStack(fonts.mono.family)};
} }
html { font-size: 11pt; } html { font-size: 11pt; }
body { body {

View File

@ -1,4 +1,4 @@
import { DEFAULT_FONTS, PondFonts, classificationMarking } from '@dorfteich/shared'; import { DEFAULT_FONTS, PondFonts } from '@dorfteich/shared';
import { PDFParse } from 'pdf-parse'; import { PDFParse } from 'pdf-parse';
import { beforeAll, describe, expect, it, TestContext } from 'vitest'; import { beforeAll, describe, expect, it, TestContext } from 'vitest';
@ -22,12 +22,12 @@ const renderer = new GotenbergHttpRenderer({ env: { GOTENBERG_URL } } as unknown
let reachable = false; let reachable = false;
/** Extract the concatenated text, per-page texts and page count from PDF bytes. */ /** Extract the concatenated text and page count from PDF bytes. */
async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number; pageTexts: string[] }> { async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number }> {
const parser = new PDFParse({ data: new Uint8Array(pdf) }); const parser = new PDFParse({ data: new Uint8Array(pdf) });
try { try {
const result = await parser.getText(); const result = await parser.getText();
return { text: result.text, pages: result.total, pageTexts: result.pages.map((p) => p.text) }; return { text: result.text, pages: result.total };
} finally { } finally {
await parser.destroy(); await parser.destroy();
} }
@ -69,36 +69,5 @@ describe('PDF export smoke (real Gotenberg, issue #69)', () => {
// regression would blow well past that. // regression would blow well past that.
expect(pages).toBeGreaterThanOrEqual(2); expect(pages).toBeGreaterThanOrEqual(2);
expect(pages).toBeLessThanOrEqual(3); expect(pages).toBeLessThanOrEqual(3);
// An unmarked render carries no classification anywhere (issue #208:
// unclassified pages produce an unchanged PDF).
expect(text).not.toContain('DIENSTGEBRAUCH');
});
it('repeats the VS-NfD marking in header and footer of EVERY page (issue #208)', async (ctx: TestContext) => {
if (!reachable) ctx.skip();
const marking = classificationMarking('vs_nfd')!;
const html = buildPdfHtml({
title: 'Marked Fidelity Report',
pondName: 'Fidelity Pond',
bodyHtml:
'<p>First page of classified content.</p>' +
'<div style="page-break-before: always"></div>' +
'<p>Second page of classified content.</p>',
fonts: DEFAULT_FONTS as PondFonts,
fontFaceCss: '',
});
const pdf = await renderer.renderHtmlToPdf(html, { marking });
const { pages, pageTexts } = await readPdf(pdf);
expect(pages).toBeGreaterThanOrEqual(2);
for (const pageText of pageTexts) {
// Once from the running header, once from the footer next to the
// page numbers — on every single page.
const occurrences = pageText.split(marking).length - 1;
expect(occurrences).toBe(2);
}
// The document-level header keeps working alongside the marking.
expect(pageTexts[0]).toContain('Marked Fidelity Report');
expect(pageTexts[0]).toContain('Fidelity Pond');
}); });
}); });

View File

@ -1,250 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { PondArchiveManifest } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import { unzipSync } from 'fflate';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { FilesService } from '../files/files.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
function entries(buffer: Buffer): Record<string, Uint8Array> {
return unzipSync(new Uint8Array(buffer));
}
/** supertest parses text by default — a ZIP has to be collected as bytes. */
function asBinary(req: request.Test): request.Test {
return req.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => cb(null, Buffer.concat(chunks)));
});
}
function manifestOf(buffer: Buffer): PondArchiveManifest {
const raw = entries(buffer)['manifest.json'];
return JSON.parse(Buffer.from(raw!).toString('utf8')) as PondArchiveManifest;
}
/**
* The full pond archive (issue #305). What separates it from the Markdown
* export is exactly what is asserted here: EVERY attachment travels, not only
* the embedded ones, and the manifest carries what Markdown cannot settings,
* labels, comments and the hierarchy.
*/
describe.skipIf(!hasTestDb)('pond archive (e2e, issue #305)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let files: FilesService;
const suffix = uniqueSuffix();
const password = 'archiviere den ganzen teich 1';
const owner = { username: `arch-${suffix}` };
const admin = { username: `archadm-${suffix}` };
let ownerId: string;
let ownerCookie: string;
let adminCookie: string;
let pondId: string;
let parentPageId: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
files = app.get(FilesService);
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: `Archive Owner ${suffix}`,
password,
locale: 'en',
});
ownerId = ownerUser.id;
await api()
.post('/api/v1/auth/verify-email')
.send({ token: await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600) })
.expect(204);
ownerCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: owner.username, password })
.expect(200),
);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: `Archive Admin ${suffix}`,
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: admin.username, password })
.expect(200),
);
pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })).id;
// A parent and a child page, so the hierarchy has something to state.
const parent = await prisma.page.create({
data: {
pondId,
title: 'Archive Parent',
slug: 'archive-parent',
ydocState: new Uint8Array(),
sortKey: 'a',
createdBy: ownerId,
contentCache: {
create: { plainText: 'Parent body', markdown: 'Parent body', html: '', outline: [] },
},
},
});
parentPageId = parent.id;
await prisma.page.create({
data: {
pondId,
parentId: parent.id,
title: 'Archive Child',
slug: 'archive-child',
ydocState: new Uint8Array(),
sortKey: 'b',
createdBy: ownerId,
contentCache: {
create: { plainText: 'Child body', markdown: 'Child body', html: '', outline: [] },
},
},
});
const label = await prisma.label.create({
data: { pondId, name: `Archive Label ${suffix}`, color: '#2f6f4f' },
});
await prisma.pageLabel.create({ data: { pageId: parent.id, labelId: label.id } });
await prisma.comment.create({
data: { pageId: parent.id, authorId: ownerId, body: 'A remark worth keeping.' },
});
});
afterAll(async () => {
const where = { pond: { owner: { username: { contains: suffix } } } };
await prisma.comment.deleteMany({ where: { page: where } });
await prisma.attachment.deleteMany({ where });
await prisma.pageLabel.deleteMany({ where: { page: where } });
await prisma.label.deleteMany({ where });
await prisma.page.deleteMany({ where });
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('contains EVERY attachment, not only the embedded ones', async () => {
// The gap this whole issue exists for: an attachment nobody embedded
// would vanish unnoticed with the Markdown export.
const orphan = await files.upload({ id: ownerId } as never, pondId, {
buffer: Buffer.from(PNG_BASE64, 'base64'),
size: 70,
originalname: 'never-embedded.png',
} as never);
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
.set('Cookie', ownerCookie)
.expect(200);
expect(res.headers['content-type']).toContain('application/zip');
const names = Object.keys(entries(res.body as Buffer));
expect(names).toContain('manifest.json');
expect(names).toContain('README.txt');
expect(names).toContain('pages/archive-parent.md');
expect(names).toContain('pages/archive-child.md');
expect(names.some((name) => name.startsWith(`media/${orphan.id}.`))).toBe(true);
const manifest = manifestOf(res.body as Buffer);
expect(manifest.attachments.map((a) => a.id)).toContain(orphan.id);
// The Markdown export would have shipped no media at all here.
expect(manifest.attachments.length).toBeGreaterThan(0);
});
it('states the hierarchy, labels, comments and settings in the manifest', async () => {
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
.set('Cookie', ownerCookie)
.expect(200);
const manifest = manifestOf(res.body as Buffer);
expect(manifest.kind).toBe('dorfteich-pond-archive');
expect(manifest.formatVersion).toBe(1);
expect(manifest.complete).toBe(true);
expect(manifest.omittedPages).toBe(0);
const child = manifest.pages.find((page) => page.slug === 'archive-child');
// The hierarchy is exactly what a folder of Markdown cannot express.
expect(child?.parentId).toBe(parentPageId);
expect(manifest.labels.some((label) => label.name.includes(suffix))).toBe(true);
expect(manifest.comments.map((comment) => comment.body)).toContain('A remark worth keeping.');
// A display name, not an account id — the archive outlives the account.
expect(manifest.comments[0]?.author).toContain('Archive Owner');
// Pond settings ride along; fonts are always present through the schema
// defaults, so their presence proves the settings object is real.
expect(manifest.pond.settings).toHaveProperty('fonts');
});
it('tells a requester before the download how much they would get', async () => {
const preview = await api()
.get(`/api/v1/ponds/${pondId}/archive/preview`)
.set('Cookie', ownerCookie)
.expect(200);
expect(preview.body.omittedPages).toBe(0);
expect(preview.body.includedPages).toBe(preview.body.totalPages);
expect(preview.body.complete).toBe(true);
});
it('audits the download with counts and completeness', async () => {
await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
.set('Cookie', ownerCookie)
.expect(200);
const entry = await prisma.auditEntry.findFirst({
where: { action: 'pond.archived', targetId: pondId },
orderBy: { at: 'desc' },
});
expect(entry).not.toBeNull();
expect(entry!.details).toMatchObject({ complete: true, omittedPages: 0 });
});
it('gives the Site Admin a complete archive without pond membership', async () => {
// The purge dialog's archive must not depend on which ponds the operator
// happens to be a member of — this admin is a member of none.
const preview = await api()
.get(`/api/v1/admin/trash/ponds/${pondId}/archive/preview`)
.set('Cookie', adminCookie)
.expect(200);
expect(preview.body.complete).toBe(true);
expect(preview.body.omittedPages).toBe(0);
const res = await asBinary(api().get(`/api/v1/admin/trash/ponds/${pondId}/archive`))
.set('Cookie', adminCookie)
.expect(200);
expect(manifestOf(res.body as Buffer).pages.length).toBe(preview.body.totalPages);
});
it('keeps the admin archive away from an ordinary pond admin', async () => {
await api()
.get(`/api/v1/admin/trash/ponds/${pondId}/archive`)
.set('Cookie', ownerCookie)
.expect(403);
});
});

View File

@ -1,395 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
PageClassification,
PondArchiveManifest,
PondArchivePreview,
POND_ARCHIVE_FORMAT_VERSION,
classificationMarking,
highestClassification,
pondSettingsSchema,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import archiver from 'archiver';
import type { Response } from 'express';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { FileStorageService } from '../files/file-storage.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
import { markClassifiedMarkdown } from './classified-markdown';
import { imageExtension, markdownForZip } from './export-markdown';
/** The plain-text note that travels inside the ZIP. The manifest says the
* same thing machine-readably, but a person unpacking a folder of Markdown
* a year from now reads the file lying next to it and must not believe
* they are holding a one-click restore. */
const README = `Dorfteich pond archive (format version ${POND_ARCHIVE_FORMAT_VERSION})
This is a PRESERVATION archive, not a backup you can re-import: Dorfteich has
no importer for it yet. Everything needed to write one later is here and
documented see manifest.json and docs/architecture/pond-archive-format.md in
the Dorfteich repository.
manifest.json pond settings, labels, page hierarchy, comments, attachment
metadata, and the classification of every file
pages/ one Markdown file per page
media/ EVERY attachment of the pond, not only the embedded ones
If manifest.json states "complete": false, the archive was produced by someone
who could not read every page of the pond; "omittedPages" says how many are
missing.
`;
/**
* The full pond archive offered before a pond is deleted (issue #305).
*
* Distinct from the Markdown export (`exportPond`, #65) on purpose: that one
* ships the pages plus the images they embed, which as a LAST resort is not
* enough an attachment nobody embedded would vanish unnoticed. This one adds
* every attachment and a machine-readable sidecar of the things Markdown
* cannot carry: settings, labels, comments and the page hierarchy.
*
* Re-import is deliberately out of scope. The archive is a preservation
* format: complete, versioned and documented, so an importer can be written
* later without guesswork.
*/
@Injectable()
export class PondArchiveService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly storage: FileStorageService,
private readonly readTrail: ReadTrailService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PondArchiveService.name);
}
/**
* Pages in the pond and how many of them this requester may read.
*
* The UI states the difference BEFORE the download: an archive silently
* missing content is worse than no archive, because it ends the search.
* A site admin archiving from the purge dialog reads everything, so their
* preview says nothing is omitted.
*/
async preview(user: User, pondId: string, unfiltered: boolean): Promise<PondArchivePreview> {
const pond = await this.loadPond(pondId);
const pages = await this.readablePages(user, pond.id, unfiltered);
const total = await this.prisma.page.count({ where: { pondId: pond.id, deletedAt: null } });
return {
totalPages: total,
includedPages: pages.length,
omittedPages: total - pages.length,
// "Complete" is a statement about the RESULT, not about the route: a
// pond admin who may read every page gets a complete archive too. Only
// an archive that actually leaves pages out is incomplete.
complete: total === pages.length,
};
}
/** The pond, or 404 — the caller's permission is checked by the route. */
private async loadPond(pondId: string): Promise<{ id: string; slug: string; name: string }> {
// Deliberately including trashed ponds: the purge dialog archives a pond
// that is already in the trash, which is the last moment it exists.
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { id: true, slug: true, name: true },
});
if (!pond) throw new NotFoundException();
return pond;
}
private async readablePages(
user: User,
pondId: string,
unfiltered: boolean,
): Promise<
{
id: string;
slug: string;
title: string;
parentId: string | null;
sortKey: string;
classification: string;
createdAt: Date;
updatedAt: Date;
labels: { labelId: string }[];
contentCache: { markdown: string } | null;
}[]
> {
const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
orderBy: { title: 'asc' },
select: {
id: true,
slug: true,
title: true,
parentId: true,
sortKey: true,
classification: true,
createdAt: true,
updatedAt: true,
labels: { select: { labelId: true } },
contentCache: { select: { markdown: true } },
},
});
if (unfiltered) return pages;
const readable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
'read',
);
return pages.filter((page) => readable.has(page.id));
}
/**
* Stream the archive.
*
* `unfiltered` is the Site-Admin path from the purge dialog: it skips the
* read filter, because the last archive before an irreversible purge must
* not depend on which pages the operator happens to be a member of.
* Whether the RESULT is complete is a separate question, answered by
* comparing what went in with what exists.
*/
async stream(
user: User,
pondId: string,
res: Response,
read: ReadActor,
unfiltered: boolean,
): Promise<void> {
const pond = await this.loadPond(pondId);
const pages = await this.readablePages(user, pond.id, unfiltered);
const totalPages = await this.prisma.page.count({
where: { pondId: pond.id, deletedAt: null },
});
const complete = totalPages === pages.length;
// Read trail (ADR 0023, issue #222's property): one `export` event per
// classified page BEFORE any classified byte enters the stream, so a
// failed write aborts the download with the evidence intact. The added
// attachments carry their page's classification and are covered by the
// same events — they never travel without their page.
for (const page of pages) {
if (page.classification !== 'VS_NFD') continue;
await this.readTrail.record({
...read,
pageId: page.id,
pondId: pond.id,
channel: 'export',
details: { format: 'pond_archive' },
});
}
const [settingsRow, labels, comments, attachmentRows] = await Promise.all([
this.prisma.pond.findUniqueOrThrow({
where: { id: pond.id },
select: { name: true, slug: true, type: true, settings: true, createdAt: true },
}),
this.prisma.label.findMany({
where: { pondId: pond.id },
select: { id: true, name: true, color: true, parentId: true },
orderBy: { name: 'asc' },
}),
this.prisma.comment.findMany({
where: { page: { pondId: pond.id, deletedAt: null } },
orderBy: { createdAt: 'asc' },
select: {
id: true,
pageId: true,
parentId: true,
body: true,
createdAt: true,
editedAt: true,
resolvedAt: true,
author: { select: { displayName: true } },
},
}),
// EVERY attachment of the pond (issue #305) — not only the embedded
// ones the Markdown export ships.
this.prisma.attachment.findMany({
where: { pondId: pond.id },
select: {
id: true,
pageId: true,
fileName: true,
mimeType: true,
sizeBytes: true,
sha256: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
}),
]);
const includedPageIds = new Set(pages.map((page) => page.id));
// An attachment of a page the requester cannot read stays out — the same
// rule the pages follow. Pond-level attachments (no page) are included:
// nothing narrower than the pond governs them.
const visibleAttachments = attachmentRows.filter(
(row) => !row.pageId || includedPageIds.has(row.pageId),
);
const onDisk = await Promise.all(
visibleAttachments.map((row) => this.storage.exists(pond.id, row.id)),
);
const attachments = visibleAttachments.filter((_, index) => onDisk[index]);
const classificationByPage = new Map(
pages.map((page) => [page.id, page.classification.toLowerCase() as PageClassification]),
);
const mediaName = new Map(
attachments.map((row) => [row.id, `${row.id}.${imageExtension(row.mimeType)}`]),
);
const readableSlugs = new Set(pages.map((page) => page.slug));
const archive = archiver('zip', { zlib: { level: 9 } });
res.set('Content-Type', 'application/zip');
res.set('Content-Disposition', `attachment; filename="${pond.slug}-archive.zip"`);
res.set('X-Content-Type-Options', 'nosniff');
archive.on('error', (error) => {
this.logger.error({ pondId: pond.id, err: error.message }, 'pond archive failed');
res.destroy(error);
});
archive.pipe(res);
const files: { path: string; classification: PageClassification }[] = [];
for (const page of pages) {
const level = classificationByPage.get(page.id) ?? 'unclassified';
const markdown = markClassifiedMarkdown(
markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaName),
level,
);
const path = `pages/${page.slug}.md`;
archive.append(markdown, { name: path });
files.push({ path, classification: level });
}
for (const row of attachments) {
// An attachment inherits its page's level (fail-closed, ADR 0022); one
// that belongs to no page inherits the pond's highest, because nothing
// narrower governs it.
const level = row.pageId
? (classificationByPage.get(row.pageId) ?? 'unclassified')
: highestClassification([...classificationByPage.values()]);
const path = `media/${mediaName.get(row.id)!}`;
files.push({ path, classification: level });
// Companion marking (issue #212): binaries cannot carry it themselves,
// and the sibling file survives unpacking where a manifest may not.
const marking = classificationMarking(level);
if (marking) {
archive.append(`${marking}\n`, { name: `${path}.classification.txt` });
files.push({ path: `${path}.classification.txt`, classification: level });
}
}
const manifest: PondArchiveManifest = {
kind: 'dorfteich-pond-archive',
formatVersion: POND_ARCHIVE_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
complete,
omittedPages: totalPages - pages.length,
classification: highestClassification(files.map((file) => file.classification)),
pond: {
name: settingsRow.name,
slug: settingsRow.slug,
type: settingsRow.type,
createdAt: settingsRow.createdAt.toISOString(),
// The EFFECTIVE settings, defaults filled in — a preservation format
// must not require its reader to know Dorfteich's defaults, and the
// stored row only holds what was explicitly set.
settings: pondSettingsSchema.parse(settingsRow.settings ?? {}) as unknown as Record<
string,
unknown
>,
},
labels: labels.map((label) => ({
id: label.id,
name: label.name,
color: label.color,
parentId: label.parentId,
})),
pages: pages.map((page) => ({
id: page.id,
slug: page.slug,
title: page.title,
parentId: page.parentId,
sortKey: page.sortKey,
classification: page.classification.toLowerCase() as PageClassification,
labelIds: page.labels.map((label) => label.labelId),
createdAt: page.createdAt.toISOString(),
updatedAt: page.updatedAt.toISOString(),
file: `pages/${page.slug}.md`,
})),
// Comments of included pages only — a comment is content of its page.
comments: comments
.filter((comment) => includedPageIds.has(comment.pageId))
.map((comment) => ({
id: comment.id,
pageId: comment.pageId,
parentId: comment.parentId,
body: comment.body,
// The display name, not the account: the archive is a document, and
// it should stay readable after the account is gone.
author: comment.author?.displayName ?? null,
createdAt: comment.createdAt.toISOString(),
editedAt: comment.editedAt?.toISOString() ?? null,
resolvedAt: comment.resolvedAt?.toISOString() ?? null,
})),
attachments: attachments.map((row) => ({
id: row.id,
pageId: row.pageId,
fileName: row.fileName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
sha256: row.sha256,
createdAt: row.createdAt.toISOString(),
file: `media/${mediaName.get(row.id)!}`,
})),
files,
};
archive.append(README, { name: 'README.txt' });
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
for (const row of attachments) {
const stream = this.storage.createReadStream(pond.id, row.id);
stream.on('error', (error) =>
this.logger.warn(
{ pondId: pond.id, fileId: row.id, err: error.message },
'pond archive: media read failed',
),
);
archive.append(stream, { name: `media/${mediaName.get(row.id)!}` });
}
// Audited: a whole pond leaving the instance in one file, usually right
// before it is deleted, is exactly the event an operator wants to find
// later. Recorded before finalize so the trail exists even if the
// download is aborted mid-stream.
await this.audit.record({
action: 'pond.archived',
actorId: user.id,
targetType: 'pond',
targetId: pond.id,
details: {
pages: pages.length,
attachments: attachments.length,
omittedPages: totalPages - pages.length,
complete,
},
});
await archive.finalize();
this.logger.info(
{ pondId: pond.id, pages: pages.length, attachments: attachments.length, complete },
'pond archive streamed',
);
}
}

View File

@ -1,50 +0,0 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
import {
CreateInvitationInput,
InvitationListView,
InvitationPreview,
InvitationView,
createInvitationSchema,
invitationPreviewSchema,
} from '@dorfteich/shared';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { InvitationsService } from './invitations.service';
/** Peer invitations (issue #332). */
@AuthenticatedOnly()
@Controller('invitations')
export class InvitationsController {
constructor(private readonly invitations: InvitationsService) {}
@Post()
async create(
@Body(new ZodValidationPipe(createInvitationSchema)) input: CreateInvitationInput,
@Req() request: AuthedRequest,
): Promise<InvitationView> {
return this.invitations.create(request.user!, input.email);
}
@Get()
async list(@Req() request: AuthedRequest): Promise<InvitationListView> {
return this.invitations.list(request.user!);
}
@Delete(':id')
@HttpCode(204)
async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.invitations.revoke(request.user!, id);
}
/** The signup screen's link check — POST keeps the token out of logs. */
@Public()
@Post('preview')
@HttpCode(200)
async preview(
@Body(new ZodValidationPipe(invitationPreviewSchema)) input: { token: string },
): Promise<InvitationPreview> {
return this.invitations.preview(input.token);
}
}

View File

@ -1,246 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { InvitationListView, InvitationPreview, InvitationView } from '@dorfteich/shared';
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 { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Peer invitations end to end (issue #332): inviting mails a single-use
* link, open invitations are quota-bound per user, and a valid token lets
* exactly one signup through a closed registration. Settings written here
* are restored inside each test and the keys are deleted in afterAll
* (shared-DB rule).
*/
describe.skipIf(!hasTestDb)('invitations (e2e, issue #332)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'einladungen sind praktisch 1';
const ids: Record<string, string> = {};
const cookies: Record<string, string> = {};
const api = () => request(app.getHttpServer());
const settings = () => app.get(InstanceSettingsService);
async function makeUser(handle: string): Promise<void> {
const users = app.get(UsersService);
const username = `inv-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Inv ${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),
);
}
/** The raw token only travels in the mail — fish it out of the outbox. */
async function mailedTokenFor(email: string): Promise<string> {
const mail = await prisma.mailOutbox.findFirstOrThrow({
where: { toAddress: email },
orderBy: { createdAt: 'desc' },
});
const match = /invitation=([A-Za-z0-9_-]+)/.exec(mail.textBody);
expect(match).not.toBeNull();
return match![1]!;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
await makeUser('alice');
await makeUser('quota');
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: { in: ['auth.registrationMode', 'invitations.maxOpenPerUser'] } },
});
const all = Object.values(ids);
await prisma.invitation.deleteMany({ where: { inviterId: { in: all } } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.session.deleteMany({ where: { userId: { in: all } } });
await deletePondsWhere(prisma, { ownerId: { 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('invites, lists, and mails a single-use signup link', async () => {
const invitee = `guest-${suffix}@example.org`;
const res = await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: invitee })
.expect(201);
const view = res.body as InvitationView;
expect(view.status).toBe('pending');
const list = (await api().get('/api/v1/invitations').set('Cookie', cookies.alice!).expect(200))
.body as InvitationListView;
expect(list.open).toBe(1);
expect(list.maxOpen).toBe(5);
expect(list.invitations.map((i) => i.id)).toContain(view.id);
// The mail exists and the public preview identifies the inviter.
const token = await mailedTokenFor(invitee);
const preview = (await api().post('/api/v1/invitations/preview').send({ token }).expect(200))
.body as InvitationPreview;
expect(preview.email).toBe(invitee);
expect(preview.inviterName).toBe('Inv alice');
});
it('a valid token passes a closed registration exactly once; a burned signup attempt does not consume it', async () => {
const invitee = `joiner-${suffix}@example.org`;
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: invitee })
.expect(201);
const token = await mailedTokenFor(invitee);
await settings().set('auth.registrationMode', 'closed', ids.alice!);
try {
// Closed without a token: refused.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-blocked-${suffix}`,
email: `inv-blocked-${suffix}@example.org`,
displayName: 'Blocked',
password,
locale: 'en',
})
.expect(403);
// A failing signup (taken username) must NOT burn the token.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-alice-${suffix}`, // taken
email: invitee,
displayName: 'Joiner',
password,
locale: 'en',
invitationToken: token,
})
.expect(409);
// Same link, fresh username: through, despite closed mode.
const username = `inv-joiner-${suffix}`;
await api()
.post('/api/v1/auth/signup')
.send({
username,
email: invitee,
displayName: 'Joiner',
password,
locale: 'en',
invitationToken: token,
})
.expect(201);
const joiner = await prisma.user.findUniqueOrThrow({ where: { username } });
ids.joiner = joiner.id;
// The invitation is tied to the new account…
const accepted = await prisma.invitation.findFirstOrThrow({
where: { acceptedUserId: joiner.id },
});
expect(accepted.acceptedAt).not.toBeNull();
// …and the token is single-use.
await api()
.post('/api/v1/auth/signup')
.send({
username: `inv-replay-${suffix}`,
email: `inv-replay-${suffix}@example.org`,
displayName: 'Replay',
password,
locale: 'en',
invitationToken: token,
})
.expect(400);
} finally {
await settings().set('auth.registrationMode', 'open', ids.alice!);
}
});
it('enforces the open-invitations quota and frees it on revoke', async () => {
await settings().set('invitations.maxOpenPerUser', 2, ids.alice!);
try {
const first = (
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q1-${suffix}@example.org` })
.expect(201)
).body as InvitationView;
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q2-${suffix}@example.org` })
.expect(201);
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q3-${suffix}@example.org` })
.expect(400)
.expect((r) => expect((r.body as { code: string }).code).toBe('invitation_quota_reached'));
// Revoking an open invitation frees the slot…
await api()
.delete(`/api/v1/invitations/${first.id}`)
.set('Cookie', cookies.quota!)
.expect(204);
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.quota!)
.send({ email: `q3-${suffix}@example.org` })
.expect(201);
// …and the revoked token is dead.
const revokedToken = await mailedTokenFor(`q1-${suffix}@example.org`);
await api().post('/api/v1/invitations/preview').send({ token: revokedToken }).expect(400);
} finally {
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
}
});
it('quota 0 disables inviting entirely', async () => {
await settings().set('invitations.maxOpenPerUser', 0, ids.alice!);
try {
await api()
.post('/api/v1/invitations')
.set('Cookie', cookies.alice!)
.send({ email: `off-${suffix}@example.org` })
.expect(403)
.expect((r) => expect((r.body as { code: string }).code).toBe('invitations_disabled'));
} finally {
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
}
});
it('requires a session for create/list/revoke but not for preview', async () => {
await api().post('/api/v1/invitations').send({ email: 'nope@example.org' }).expect(401);
await api().get('/api/v1/invitations').expect(401);
await api()
.post('/api/v1/invitations/preview')
.send({ token: 'x'.repeat(32) })
.expect(400);
});
});

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { MailModule } from '../mail/mail.module';
import { InvitationsController } from './invitations.controller';
import { InvitationsService } from './invitations.service';
@Module({
imports: [MailModule],
controllers: [InvitationsController],
providers: [InvitationsService],
exports: [InvitationsService],
})
export class InvitationsModule {}

View File

@ -1,199 +0,0 @@
import { createHash, randomBytes } from 'node:crypto';
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
InvitationListView,
InvitationPreview,
InvitationStatus,
InvitationView,
} from '@dorfteich/shared';
import { Invitation, User } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { RateLimitService } from '../rate-limit/rate-limit.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
export const INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60;
// Anti-spam backstop besides the open-invitations quota: without it a
// revoke-and-recreate loop would allow unlimited mail volume while never
// exceeding the quota.
const CREATE_LIMIT = { limit: 20, windowSeconds: 24 * 60 * 60 };
/**
* Peer invitations (issue #332). A user invites an e-mail address; the
* mailed single-use token lets exactly one signup through even while
* registration is closed (auth.service). Open (pending, unexpired)
* invitations count against the per-user quota
* `invitations.maxOpenPerUser` 0 turns the feature off. Only the
* SHA-256 hash of the token is stored (auth-tokens pattern); revoked and
* accepted rows are kept so the settings UI can show history.
*/
@Injectable()
export class InvitationsService {
constructor(
private readonly prisma: PrismaService,
private readonly mail: MailService,
private readonly rateLimits: RateLimitService,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
private readonly config: AppConfig,
) {}
async create(user: User, email: string): Promise<InvitationView> {
const maxOpen = (await this.settings.get('invitations.maxOpenPerUser')) as number;
if (maxOpen === 0) throw new ForbiddenException({ code: 'invitations_disabled' });
if ((await this.openCount(user.id)) >= maxOpen) {
throw new BadRequestException({ code: 'invitation_quota_reached' });
}
const limit = await this.rateLimits.hit(
'invitation-create',
user.id,
CREATE_LIMIT.limit,
CREATE_LIMIT.windowSeconds,
);
if (!limit.allowed) {
throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS);
}
const raw = randomBytes(32).toString('base64url');
const row = await this.prisma.invitation.create({
data: {
inviterId: user.id,
email: email.toLowerCase(),
tokenHash: hashToken(raw),
expiresAt: new Date(Date.now() + INVITATION_TTL_SECONDS * 1000),
},
});
// The invitee has no account and no locale yet — the instance default
// decides the mail language. The greeting falls back to the address.
await this.mail.enqueue(
row.email,
'invitation',
{
displayName: row.email,
inviterName: user.displayName,
link: `${this.config.env.APP_BASE_URL}/signup?invitation=${raw}`,
},
(await this.settings.get('instance.defaultLocale')) as 'de' | 'en',
);
await this.audit.record({
action: 'invitation.created',
actorId: user.id,
targetType: 'invitation',
targetId: row.id,
});
return this.viewOf(row);
}
async list(user: User): Promise<InvitationListView> {
const rows = await this.prisma.invitation.findMany({
where: { inviterId: user.id },
orderBy: { createdAt: 'desc' },
take: 100,
});
return {
invitations: rows.map((row) => this.viewOf(row)),
open: await this.openCount(user.id),
maxOpen: (await this.settings.get('invitations.maxOpenPerUser')) as number,
};
}
async revoke(user: User, id: string): Promise<void> {
const row = await this.prisma.invitation.findFirst({
where: { id, inviterId: user.id },
});
if (!row) throw new NotFoundException();
if (row.acceptedAt) throw new BadRequestException({ code: 'invitation_already_accepted' });
if (row.revokedAt) return; // idempotent
await this.prisma.invitation.update({ where: { id }, data: { revokedAt: new Date() } });
await this.audit.record({
action: 'invitation.revoked',
actorId: user.id,
targetType: 'invitation',
targetId: id,
});
}
/** What the signup screen may show for a link before it is used. */
async preview(raw: string): Promise<InvitationPreview> {
const row = await this.prisma.invitation.findUnique({
where: { tokenHash: hashToken(raw) },
include: { inviter: true },
});
if (!row || row.revokedAt || row.acceptedAt || row.expiresAt <= new Date()) {
throw new BadRequestException({ code: 'token_invalid' });
}
return { email: row.email, inviterName: row.inviter.displayName };
}
/**
* Atomically claims the token (only one signup can flip acceptedAt from
* null). Returns the row, or null for unknown/revoked/expired/used
* tokens. The caller un-redeems if the signup fails afterwards.
*/
async redeem(raw: string): Promise<Invitation | null> {
const result = await this.prisma.invitation.updateMany({
where: {
tokenHash: hashToken(raw),
revokedAt: null,
acceptedAt: null,
expiresAt: { gt: new Date() },
},
data: { acceptedAt: new Date() },
});
if (result.count === 0) return null;
return this.prisma.invitation.findUnique({ where: { tokenHash: hashToken(raw) } });
}
/** Ties the redeemed invitation to the account it created. */
async markAccepted(id: string, userId: string): Promise<void> {
await this.prisma.invitation.update({ where: { id }, data: { acceptedUserId: userId } });
}
/** Rolls a redeem back when the signup it gated failed (e.g. duplicate
* username) the invitee must be able to try again with the same link. */
async unredeem(id: string): Promise<void> {
await this.prisma.invitation.updateMany({
where: { id, acceptedUserId: null },
data: { acceptedAt: null },
});
}
private openCount(inviterId: string): Promise<number> {
return this.prisma.invitation.count({
where: { inviterId, revokedAt: null, acceptedAt: null, expiresAt: { gt: new Date() } },
});
}
private viewOf(row: Invitation): InvitationView {
return {
id: row.id,
email: row.email,
status: statusOf(row),
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
};
}
}
function statusOf(row: Invitation): InvitationStatus {
if (row.revokedAt) return 'revoked';
if (row.acceptedAt) return 'accepted';
if (row.expiresAt <= new Date()) return 'expired';
return 'pending';
}
function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}

View File

@ -1,104 +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 { MailRetentionService } from './mail-retention.service';
const DAY = 24 * 60 * 60 * 1000;
/**
* Mail outbox retention (issue #234): SENT and permanently FAILED rows past
* `mail.outboxRetentionDays` are deleted; PENDING rows including a
* failed-but-retryable one belong to the retry loop and stay untouched.
*/
describe.skipIf(!hasTestDb)('mail outbox retention (e2e, issue #234)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const address = (name: string) => `${name}-${suffix}@example.org`;
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: 'mail.outboxRetentionDays' },
create: { key: 'mail.outboxRetentionDays', value: 10 },
update: { value: 10 },
});
app = await createTestApp();
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'mail.outboxRetentionDays' } });
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('deletes terminal rows past the period, keeps the retry loops rows', async () => {
await prisma.mailOutbox.createMany({
data: [
{
toAddress: address('sent-old'),
subject: 's',
textBody: 't',
htmlBody: 'h',
status: 'SENT',
sentAt: new Date(Date.now() - 15 * DAY),
},
{
toAddress: address('sent-fresh'),
subject: 's',
textBody: 't',
htmlBody: 'h',
status: 'SENT',
sentAt: new Date(Date.now() - 5 * DAY),
},
{
// Gave up after MAX_ATTEMPTS; nextAttemptAt records the last try.
toAddress: address('failed-old'),
subject: 's',
textBody: 't',
htmlBody: 'h',
status: 'FAILED',
attempts: 5,
nextAttemptAt: new Date(Date.now() - 15 * DAY),
lastError: 'smtp gone',
},
{
// Failed once but retryable: still PENDING, still the worker's.
toAddress: address('pending-retry'),
subject: 's',
textBody: 't',
htmlBody: 'h',
status: 'PENDING',
attempts: 2,
nextAttemptAt: new Date(Date.now() - 15 * DAY),
lastError: 'transient',
createdAt: new Date(Date.now() - 15 * DAY),
},
],
});
const pruned = await app.get(MailRetentionService).pruneExpired();
expect(pruned).toBeGreaterThanOrEqual(2);
const remaining = await prisma.mailOutbox.findMany({
where: { toAddress: { contains: suffix } },
});
expect(remaining.map((row) => row.toAddress).sort()).toEqual([
address('pending-retry'),
address('sent-fresh'),
]);
});
it('is a no-op when nothing is due', async () => {
expect(await app.get(MailRetentionService).pruneExpired()).toBe(0);
expect(await prisma.mailOutbox.count({ where: { toAddress: { contains: suffix } } })).toBe(2);
});
});

View File

@ -1,49 +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';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Mail outbox retention (issue #234): the daily job deletes outbox rows that
* reached a terminal state longer ago than `mail.outboxRetentionDays`
* SENT rows by their `sentAt`, permanently FAILED rows by their last attempt
* (`nextAttemptAt`, written with the final failure). Digest bodies carry
* page titles, so a sent mail is content-adjacent data whose copy must be
* bounded. PENDING rows are the retry loop's ({@link MailWorker}) alone
* a failed-but-retryable mail stays PENDING and is never deleted here.
*/
@Injectable()
export class MailRetentionService {
constructor(
private readonly prisma: PrismaService,
private readonly settings: InstanceSettingsService,
private readonly clock: ClockService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(MailRetentionService.name);
}
async pruneExpired(): Promise<number> {
const retentionDays = await this.settings.get('mail.outboxRetentionDays');
const cutoff = new Date(this.clock.now().getTime() - retentionDays * MS_PER_DAY);
const { count } = await this.prisma.mailOutbox.deleteMany({
where: {
OR: [
{ status: 'SENT', sentAt: { lt: cutoff } },
{ status: 'FAILED', nextAttemptAt: { lt: cutoff } },
],
},
});
if (count > 0) {
this.logger.info(
{ pruned: count, cutoff: cutoff.toISOString(), retentionDays },
'audit: mail outbox entries pruned',
);
}
return count;
}
}

View File

@ -1,6 +1,6 @@
import { apiI18n } from '../i18n/api-i18n'; import { apiI18n } from '../i18n/api-i18n';
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation'; export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
export interface RenderedMail { export interface RenderedMail {
subject: string; subject: string;
@ -15,15 +15,14 @@ export interface RenderedMail {
*/ */
export function renderMail( export function renderMail(
template: MailTemplate, template: MailTemplate,
// Extra keys (e.g. inviterName, #332) interpolate into the body text. params: { displayName: string; link: string },
params: { displayName: string; link: string } & Record<string, string>,
locale: 'de' | 'en', locale: 'de' | 'en',
): RenderedMail { ): RenderedMail {
const t = (key: string, options: Record<string, string> = {}): string => const t = (key: string, options: Record<string, string> = {}): string =>
apiI18n.t(`mails:${key}`, { lng: locale, ...options }); apiI18n.t(`mails:${key}`, { lng: locale, ...options });
const greeting = t('common.greeting', { displayName: params.displayName }); const greeting = t('common.greeting', { displayName: params.displayName });
const body = t(`${template}.body`, params); const body = t(`${template}.body`);
const action = t(`${template}.action`); const action = t(`${template}.action`);
const expiry = t(`${template}.expiry`); const expiry = t(`${template}.expiry`);
const ignore = t('common.ignoreHint'); const ignore = t('common.ignoreHint');

View File

@ -1,24 +1,13 @@
import { Module, OnModuleInit } from '@nestjs/common'; import { 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 { MailRetentionService } from './mail-retention.service';
import { MAIL_TRANSPORT, MailTransport, MailWorker } from './mail-worker.service'; import { MAIL_TRANSPORT, MailTransport, MailWorker } from './mail-worker.service';
import { MailService } from './mail.service'; import { MailService } from './mail.service';
import { SmtpConfigService } from './smtp-config.service'; import { SmtpConfigService } from './smtp-config.service';
/** Daily, per operations.md's maintenance-jobs table (issue #234). */
const OUTBOX_RETENTION_CADENCE_SECONDS = 24 * 60 * 60;
@Module({ @Module({
imports: [CommonModule, SchedulerModule, SettingsModule],
providers: [ providers: [
MailService, MailService,
MailWorker, MailWorker,
MailRetentionService,
SmtpConfigService, SmtpConfigService,
{ {
// Delegates per send so the wizard's SMTP changes (SmtpConfigService. // Delegates per send so the wizard's SMTP changes (SmtpConfigService.
@ -32,19 +21,4 @@ const OUTBOX_RETENTION_CADENCE_SECONDS = 24 * 60 * 60;
], ],
exports: [MailService, SmtpConfigService], exports: [MailService, SmtpConfigService],
}) })
export class MailModule implements OnModuleInit { export class MailModule {}
constructor(
private readonly scheduler: SchedulerService,
private readonly retention: MailRetentionService,
) {}
onModuleInit(): void {
this.scheduler.register({
name: 'mail-outbox-retention',
cadenceSeconds: OUTBOX_RETENTION_CADENCE_SECONDS,
run: async () => {
await this.retention.pruneExpired();
},
});
}
}

View File

@ -14,7 +14,7 @@ export class MailService {
async enqueue( async enqueue(
to: string, to: string,
template: MailTemplate, template: MailTemplate,
params: { displayName: string; link: string } & Record<string, string>, params: { displayName: string; link: string },
locale: 'de' | 'en', locale: 'de' | 'en',
): Promise<void> { ): Promise<void> {
const rendered = renderMail(template, params, locale); const rendered = renderMail(template, params, locale);

View File

@ -112,9 +112,7 @@ export class McpService {
case 'read_page': case 'read_page':
await this.assertPondExposed(input.pond!, token); await this.assertPondExposed(input.pond!, token);
await this.requirePage(user, input.pond!, input.page!, 'read'); await this.requirePage(user, input.pond!, input.page!, 'read');
// The shared getPage also records the read-trail event (#222) — return asJson(await this.publicApi.getPage(user, input.pond!, input.page!));
// MCP reads of classified pages are covered by the same emission.
return asJson(await this.publicApi.getPage(user, token, input.pond!, input.page!));
case 'search': { case 'search': {
if (input.pond) await this.assertPondExposed(input.pond, token); if (input.pond) await this.assertPondExposed(input.pond, token);

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