Compare commits
76 Commits
feat/202-s
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9c70287c | |||
| f142289813 | |||
| c17ab41a33 | |||
| 3bf9363c34 | |||
| b1165a37e6 | |||
| 69563348ca | |||
| 7e17a2dba6 | |||
| c2a4dde5cc | |||
| 9cf7b85b93 | |||
| 64f2deb40f | |||
| 20677ea247 | |||
| 6999b3dd73 | |||
| 4d6a27194f | |||
| 9f754649d4 | |||
| d2be1116bc | |||
| 78258c4f9b | |||
| a327126fac | |||
| 3310ae3926 | |||
| 8752cf0c5a | |||
| 6377faf332 | |||
| 942f7b13d3 | |||
| ee6a11f9b0 | |||
| f8c241b11a | |||
| 485c8fa538 | |||
| b96997501a | |||
| 5164801676 | |||
| 69882ecbea | |||
| 2422f3a28f | |||
| b65339ae13 | |||
| 194f144797 | |||
| 9fce824a8e | |||
| 18c2ed0bfe | |||
| f938ee9880 | |||
| f9149eba13 | |||
| 30fd1ff53b | |||
| 45f1925917 | |||
| 5a4a99196e | |||
| 1f56f34113 | |||
| 9b7acab294 | |||
| 404a3741c8 | |||
| 4d9f913845 | |||
| 0d95e1304e | |||
| 5fdef95f67 | |||
| da5fd7c770 | |||
| 18239e2fa9 | |||
| ccffcaadd6 | |||
| 4f6596e8a2 | |||
| a758c9d78b | |||
| 2f7ba65eef | |||
| 6aac785841 | |||
| 13f0311d8e | |||
| 4c7f001cab | |||
| 5796b7a5dd | |||
| 4af5e6e81f | |||
| 2bdb0ec2cf | |||
| fd4fd60c99 | |||
| 05a979bac3 | |||
| 919201d9b0 | |||
| 87c1c5ee88 | |||
| 2c6eff85f3 | |||
| 040f3fbeae | |||
| f0c6af4412 | |||
| 868b79c8bc | |||
| e505fc74dc | |||
| 521ea514b4 | |||
| 68497046e9 | |||
| 74a9e495e4 | |||
| c2df7c0c23 | |||
| 809e071f14 | |||
| adceca7358 | |||
| 488d0d06f1 | |||
| 183faf7710 | |||
| db4f517e44 | |||
| 000d110727 | |||
| c4c84b33f9 | |||
| 74970f6073 |
@ -91,6 +91,40 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
exit "$bad"
|
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
|
||||||
|
|
||||||
@ -176,7 +210,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Start api, collab, and static web server
|
- name: Start api, collab, and static web server
|
||||||
run: |
|
run: |
|
||||||
(cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &)
|
# VS_NFD_MODE=marked: the marking pack and the a11y admin scan
|
||||||
|
# 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
|
||||||
@ -302,6 +345,16 @@ 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%';" | \
|
||||||
@ -322,6 +375,18 @@ 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%';" | \
|
||||||
@ -595,6 +660,37 @@ 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: |
|
||||||
@ -658,13 +754,16 @@ 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: |
|
||||||
# Clear any leftovers from an earlier interrupted run so the named
|
# Sidecar names carry THIS job container's id: parallel runs on the
|
||||||
# containers never collide, and nothing leaks on the shared host.
|
# shared host must not collide on a fixed name (a fixed-name rm -f
|
||||||
docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
|
# here even killed a sibling run's live sidecars — run 547).
|
||||||
JOB_ID=$(cat /etc/hostname)
|
JOB_ID=$(cat /etc/hostname)
|
||||||
docker run -d --name fidelity-pandoc \
|
echo "PANDOC_NAME=fidelity-pandoc-${JOB_ID}" >> "$GITHUB_ENV"
|
||||||
|
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 \
|
docker run -d --name "fidelity-gotenberg-${JOB_ID}" \
|
||||||
--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
|
||||||
@ -688,15 +787,15 @@ jobs:
|
|||||||
- name: Dump sidecar logs on failure
|
- name: Dump sidecar logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
echo '--- pandoc ---'; docker logs fidelity-pandoc 2>&1 | tail -30 || true
|
echo '--- pandoc ---'; docker logs "$PANDOC_NAME" 2>&1 | tail -30 || true
|
||||||
echo '--- gotenberg ---'; docker logs fidelity-gotenberg 2>&1 | tail -30 || true
|
echo '--- gotenberg ---'; docker logs "$GOTENBERG_NAME" 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 and break re-runs on the container name.
|
# memory until the next run.
|
||||||
- name: Stop sidecars
|
- name: Stop sidecars
|
||||||
if: always()
|
if: always()
|
||||||
run: docker rm -f fidelity-pandoc fidelity-gotenberg 2>/dev/null || true
|
run: docker rm -f "$PANDOC_NAME" "$GOTENBERG_NAME" 2>/dev/null || true
|
||||||
|
|
||||||
images:
|
images:
|
||||||
name: Build container images
|
name: Build container images
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@ -27,13 +27,3 @@ 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).
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ 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.1-alpine
|
||||||
@ -28,18 +29,19 @@ 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 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 CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding 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 or /data/plugins is created
|
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
|
||||||
|
# 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/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/secrets /data/backups
|
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
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
|
|||||||
45
apps/api/assets/README.md
Normal file
45
apps/api/assets/README.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# 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`.
|
||||||
BIN
apps/api/assets/default-favicon-180.png
Normal file
BIN
apps/api/assets/default-favicon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/api/assets/default-favicon-32.png
Normal file
BIN
apps/api/assets/default-favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 683 B |
BIN
apps/api/assets/reference-vs-nfd.docx
Normal file
BIN
apps/api/assets/reference-vs-nfd.docx
Normal file
Binary file not shown.
BIN
apps/api/assets/reference-vs-nfd.odt
Normal file
BIN
apps/api/assets/reference-vs-nfd.odt
Normal file
Binary file not shown.
@ -30,6 +30,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- #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;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
-- #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';
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
-- #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");
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
-- #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");
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
-- #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";
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- #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;
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
-- #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;
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
-- #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;
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
-- 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;
|
||||||
@ -31,41 +31,71 @@ 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
|
||||||
@ -91,6 +121,52 @@ 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
|
||||||
@ -186,14 +262,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")
|
||||||
@ -239,6 +315,10 @@ 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")
|
||||||
|
|
||||||
@ -260,19 +340,30 @@ 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")
|
||||||
createdBy String @map("created_by")
|
classification PageClassification @default(UNCLASSIFIED)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdBy String @map("created_by")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deletedBy String? @map("deleted_by")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
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)
|
||||||
@ -394,12 +485,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
|
||||||
@ -550,6 +641,11 @@ 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])
|
||||||
@ -770,7 +866,8 @@ model ConversionJob {
|
|||||||
/// other job kind, whose payload the general retention (#233) prunes.
|
/// other job kind, whose payload the general retention (#233) prunes.
|
||||||
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}`. Null for other kinds.
|
/// `{parentPageId, labelIds, frontmatterMode}`; a PDF/DOCX/ODT export of a
|
||||||
|
/// 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")
|
||||||
@ -782,9 +879,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")
|
||||||
@ -813,6 +910,8 @@ 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`.
|
||||||
@ -839,3 +938,48 @@ model PondPlugin {
|
|||||||
@@id([pondId, pluginId])
|
@@id([pondId, pluginId])
|
||||||
@@map("pond_plugins")
|
@@map("pond_plugins")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An operator-uploaded font family (issue #303, ADR 0016 §#303). The bytes
|
||||||
|
/// live on disk under CUSTOM_FONTS_DIR — this row only records what the
|
||||||
|
/// upload form stated, because the api never parses the font file itself.
|
||||||
|
/// Additive to the compile-time catalog: a family whose name or slug
|
||||||
|
/// collides with a catalog entry is rejected, so `fonts.<slot>.family` in a
|
||||||
|
/// pond's settings stays unambiguous.
|
||||||
|
model CustomFont {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
/// CSS `font-family` name, as typed by the uploader.
|
||||||
|
family String @unique
|
||||||
|
/// URL/file-safe form; names the directory under CUSTOM_FONTS_DIR.
|
||||||
|
slug String @unique
|
||||||
|
/// Drives the system fallback stack, like FontCatalogEntry.category.
|
||||||
|
category String
|
||||||
|
/// Free-text licence label, e.g. "Commercial — Foundry XY". Required so
|
||||||
|
/// an attribution obligation can be met on the font catalogue page.
|
||||||
|
licence String
|
||||||
|
licenceUrl String? @map("licence_url")
|
||||||
|
uploadedBy String @map("uploaded_by")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
uploader User @relation(fields: [uploadedBy], references: [id])
|
||||||
|
weights CustomFontWeight[]
|
||||||
|
|
||||||
|
@@map("custom_fonts")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One weight of a custom family. Style is always `normal`: the PDF
|
||||||
|
/// `@font-face` builder emits only that, and browsers synthesise oblique —
|
||||||
|
/// italic uploads are a follow-up, not a silent half-feature.
|
||||||
|
model CustomFontWeight {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
fontId String @map("font_id")
|
||||||
|
weight Int
|
||||||
|
/// Whether a legacy WOFF was supplied next to the required WOFF2.
|
||||||
|
hasWoff Boolean @default(false) @map("has_woff")
|
||||||
|
byteSize Int @map("byte_size")
|
||||||
|
|
||||||
|
font CustomFont @relation(fields: [fontId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([fontId, weight])
|
||||||
|
@@map("custom_font_weights")
|
||||||
|
}
|
||||||
|
|||||||
@ -311,6 +311,36 @@ 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).
|
||||||
|
|||||||
118
apps/api/scripts/gen-classified-reference-docs.mjs
Normal file
118
apps/api/scripts/gen-classified-reference-docs.mjs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Regenerate the classified reference documents (issue #209, ADR 0022):
|
||||||
|
* `apps/api/assets/reference-vs-nfd.docx` / `.odt`.
|
||||||
|
*
|
||||||
|
* The DOCX/ODT export of a classified page passes these to pandoc via
|
||||||
|
* `--reference-doc`; pandoc copies the reference's page setup — including
|
||||||
|
* headers and footers — into its output, which is how the VS-NfD marking
|
||||||
|
* repeats on every page in Word and LibreOffice without being deletable
|
||||||
|
* body text.
|
||||||
|
*
|
||||||
|
* The binaries are DERIVED files: base = the default reference documents of
|
||||||
|
* the PINNED pandoc (`pandoc/core:3.6`, the exact sidecar the stages run),
|
||||||
|
* plus a header and footer carrying the marking. Never edit the binaries by
|
||||||
|
* hand — edit this script and re-run it (Docker required):
|
||||||
|
*
|
||||||
|
* node apps/api/scripts/gen-classified-reference-docs.mjs
|
||||||
|
*
|
||||||
|
* The marking wording comes from @dorfteich/shared (single source, ADR
|
||||||
|
* 0022); the shared package must be built (`pnpm --filter @dorfteich/shared
|
||||||
|
* build`).
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { classificationMarking } from '@dorfteich/shared';
|
||||||
|
import { strToU8, strFromU8, unzipSync, zipSync } from 'fflate';
|
||||||
|
|
||||||
|
const PANDOC_IMAGE = 'pandoc/core:3.6';
|
||||||
|
const MARKING = classificationMarking('vs_nfd');
|
||||||
|
const outDir = join(dirname(fileURLToPath(import.meta.url)), '../assets');
|
||||||
|
|
||||||
|
function defaultReference(name) {
|
||||||
|
return execFileSync('docker', ['run', '--rm', PANDOC_IMAGE, '--print-default-data-file', name], {
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DOCX: add word/header1.xml + word/footer1.xml, register them in the
|
||||||
|
* content types and document relationships, and reference them from the
|
||||||
|
* document's sectPr — Word repeats them on every page. */
|
||||||
|
function patchDocx(bytes) {
|
||||||
|
const zip = unzipSync(new Uint8Array(bytes));
|
||||||
|
const marking = escapeXml(MARKING);
|
||||||
|
|
||||||
|
const partXml = (root) =>
|
||||||
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
||||||
|
`<w:${root} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
||||||
|
`<w:p><w:pPr><w:jc w:val="center"/></w:pPr>` +
|
||||||
|
`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${marking}</w:t></w:r>` +
|
||||||
|
`</w:p></w:${root}>`;
|
||||||
|
zip['word/header1.xml'] = strToU8(partXml('hdr'));
|
||||||
|
zip['word/footer1.xml'] = strToU8(partXml('ftr'));
|
||||||
|
|
||||||
|
const types = strFromU8(zip['[Content_Types].xml']);
|
||||||
|
zip['[Content_Types].xml'] = strToU8(
|
||||||
|
types.replace(
|
||||||
|
'</Types>',
|
||||||
|
'<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml" />' +
|
||||||
|
'<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" />' +
|
||||||
|
'</Types>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rels = strFromU8(zip['word/_rels/document.xml.rels']);
|
||||||
|
zip['word/_rels/document.xml.rels'] = strToU8(
|
||||||
|
rels.replace(
|
||||||
|
'</Relationships>',
|
||||||
|
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Id="rIdVsNfdHeader" Target="header1.xml" />' +
|
||||||
|
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Id="rIdVsNfdFooter" Target="footer1.xml" />' +
|
||||||
|
'</Relationships>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const doc = strFromU8(zip['word/document.xml']);
|
||||||
|
if (!doc.includes('<w:sectPr>')) throw new Error('reference.docx has no sectPr');
|
||||||
|
zip['word/document.xml'] = strToU8(
|
||||||
|
doc.replace(
|
||||||
|
'<w:sectPr>',
|
||||||
|
'<w:sectPr>' +
|
||||||
|
'<w:headerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:type="default" r:id="rIdVsNfdHeader" />' +
|
||||||
|
'<w:footerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:type="default" r:id="rIdVsNfdFooter" />',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return zipSync(zip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ODT: give the Standard master page a header with the marking and put the
|
||||||
|
* marking next to the existing page number in its footer — LibreOffice
|
||||||
|
* repeats master-page headers/footers on every page. */
|
||||||
|
function patchOdt(bytes) {
|
||||||
|
const zip = unzipSync(new Uint8Array(bytes));
|
||||||
|
const marking = escapeXml(MARKING);
|
||||||
|
const styles = strFromU8(zip['styles.xml']);
|
||||||
|
if (!styles.includes('<style:footer>')) throw new Error('reference.odt has no footer');
|
||||||
|
const patched = styles
|
||||||
|
.replace(
|
||||||
|
'<style:footer>',
|
||||||
|
`<style:header><text:p text:style-name="MP1">${marking}</text:p></style:header><style:footer>`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'<style:footer>\n <text:p text:style-name="MP1">',
|
||||||
|
`<style:footer>\n <text:p text:style-name="MP1">${marking} · `,
|
||||||
|
);
|
||||||
|
zip['styles.xml'] = strToU8(patched);
|
||||||
|
return zipSync(zip);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
writeFileSync(join(outDir, 'reference-vs-nfd.docx'), patchDocx(defaultReference('reference.docx')));
|
||||||
|
writeFileSync(join(outDir, 'reference-vs-nfd.odt'), patchOdt(defaultReference('reference.odt')));
|
||||||
|
console.log(`generated reference-vs-nfd.docx/.odt in ${outDir} (marking: ${MARKING})`);
|
||||||
127
apps/api/scripts/gen-default-favicon.mjs
Normal file
127
apps/api/scripts/gen-default-favicon.mjs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generates the shipped default favicons (issue #306):
|
||||||
|
* `apps/api/assets/default-favicon-32.png` and `-180.png`.
|
||||||
|
*
|
||||||
|
* The api serves these whenever an operator has not uploaded one, so an
|
||||||
|
* instance always has a tab icon — the `<link rel="icon">` in index.html is
|
||||||
|
* static and its resource must never 404.
|
||||||
|
*
|
||||||
|
* Drawn here rather than pulled in as a binary: the whole toolchain must
|
||||||
|
* survive the `--network none` offline build (96-offline-build-protokoll.md),
|
||||||
|
* and adding an image library for one 32×32 icon would be the tail wagging
|
||||||
|
* the dog. Node's own zlib is enough to write a PNG.
|
||||||
|
*
|
||||||
|
* Motif: a pond seen from above — the accent-green disc with two ripples.
|
||||||
|
*
|
||||||
|
* Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit
|
||||||
|
* script and binaries together.
|
||||||
|
*/
|
||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
/** Brand green — the same value as index.html's light `theme-color`. */
|
||||||
|
const GREEN = [0x2f, 0x6f, 0x4f];
|
||||||
|
const LIGHT = [0xe8, 0xf2, 0xec];
|
||||||
|
|
||||||
|
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
return c >>> 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length);
|
||||||
|
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(body));
|
||||||
|
return Buffer.concat([length, body, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal RGBA PNG writer — no filtering, one IDAT. */
|
||||||
|
function encodePng(size, rgba) {
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 6; // colour type RGBA
|
||||||
|
const raw = Buffer.alloc(size * (size * 4 + 1));
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
raw[y * (size * 4 + 1)] = 0; // filter: none
|
||||||
|
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
||||||
|
}
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colour at one point of the unit square, in continuous coordinates — the
|
||||||
|
* caller supersamples it, which is where the anti-aliasing comes from.
|
||||||
|
*/
|
||||||
|
function sample(x, y) {
|
||||||
|
const dx = x - 0.5;
|
||||||
|
const dy = y - 0.5;
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r > 0.48) return null; // outside the disc: transparent
|
||||||
|
// Two ripples spreading from a point struck slightly above centre — rings
|
||||||
|
// rather than a bullseye, which is why the centre stays green and the
|
||||||
|
// spacing widens outward the way real ripples do.
|
||||||
|
const rr = Math.hypot(dx, dy + 0.06);
|
||||||
|
const onRing = (radius, width) => Math.abs(rr - radius) < width;
|
||||||
|
if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT;
|
||||||
|
return GREEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(size) {
|
||||||
|
const SS = 4; // supersampling factor
|
||||||
|
const out = Buffer.alloc(size * size * 4);
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
let a = 0;
|
||||||
|
for (let sy = 0; sy < SS; sy += 1) {
|
||||||
|
for (let sx = 0; sx < SS; sx += 1) {
|
||||||
|
const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size);
|
||||||
|
if (c) {
|
||||||
|
r += c[0];
|
||||||
|
g += c[1];
|
||||||
|
b += c[2];
|
||||||
|
a += 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const n = SS * SS;
|
||||||
|
const covered = a / 255;
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
// Premultiplied average of the covered samples only, so the edge fades
|
||||||
|
// in alpha rather than towards black.
|
||||||
|
out[i] = covered ? Math.round(r / covered) : 0;
|
||||||
|
out[i + 1] = covered ? Math.round(g / covered) : 0;
|
||||||
|
out[i + 2] = covered ? Math.round(b / covered) : 0;
|
||||||
|
out[i + 3] = Math.round(a / n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets');
|
||||||
|
for (const size of [32, 180]) {
|
||||||
|
const file = join(assets, `default-favicon-${size}.png`);
|
||||||
|
writeFileSync(file, encodePng(size, render(size)));
|
||||||
|
console.log(`wrote ${file}`);
|
||||||
|
}
|
||||||
@ -11,9 +11,17 @@ 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, not configuration: never editable through this
|
// Lifecycle markers and file-backed metadata, not configuration: never
|
||||||
// endpoint (the setup lock must be irreversible, issue #80).
|
// editable through this endpoint. The setup lock must be irreversible
|
||||||
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
|
// (issue #80), and the branding entries only describe bytes on disk
|
||||||
|
// (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
|
||||||
|
|||||||
@ -2,6 +2,7 @@ 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';
|
||||||
@ -19,7 +20,15 @@ import { UserAdminController } from './user-admin.controller';
|
|||||||
import { UserAdminService } from './user-admin.service';
|
import { UserAdminService } from './user-admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
|
imports: [
|
||||||
|
QuotasModule,
|
||||||
|
UsersModule,
|
||||||
|
AuthModule,
|
||||||
|
SchedulerModule,
|
||||||
|
BackupModule,
|
||||||
|
SearchModule,
|
||||||
|
PondsModule,
|
||||||
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminSettingsController,
|
AdminSettingsController,
|
||||||
BackupAdminController,
|
BackupAdminController,
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -18,7 +23,17 @@ import { SystemAdminService } from './system-admin.service';
|
|||||||
@Controller('admin/system')
|
@Controller('admin/system')
|
||||||
@UseGuards(SiteAdminGuard)
|
@UseGuards(SiteAdminGuard)
|
||||||
export class SystemAdminController {
|
export class SystemAdminController {
|
||||||
constructor(private readonly system: SystemAdminService) {}
|
constructor(
|
||||||
|
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[]> {
|
||||||
@ -45,6 +60,15 @@ export class SystemAdminController {
|
|||||||
return this.system.auditLog(query);
|
return this.system.auditLog(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read-access trail queries (issue #224): "who read page X", "what did
|
||||||
|
* user Y read" — Site-Admin only, like the audit viewer above. */
|
||||||
|
@Get('read-events')
|
||||||
|
async readEvents(
|
||||||
|
@Query(new ZodValidationPipe(readEventListQuerySchema)) query: ReadEventListQuery,
|
||||||
|
): Promise<ReadEventListView> {
|
||||||
|
return this.system.readEvents(query);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('storage')
|
@Get('storage')
|
||||||
async storage(): Promise<StorageOverviewView> {
|
async storage(): Promise<StorageOverviewView> {
|
||||||
return this.system.storage();
|
return this.system.storage();
|
||||||
|
|||||||
@ -7,10 +7,13 @@ 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,
|
||||||
@ -160,6 +163,66 @@ export class SystemAdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Site-Admin query path over the read-access trail (issue #224,
|
||||||
|
* ADR 0023) — evidence nobody can read is not evidence. Answers "who read
|
||||||
|
* page X" and "what did user Y read" within a period. API-only by design
|
||||||
|
* (no panel yet): the trail is an examiner's tool, not a daily screen —
|
||||||
|
* documented in data-model.md §read_events.
|
||||||
|
*/
|
||||||
|
async readEvents(query: ReadEventListQuery): Promise<ReadEventListView> {
|
||||||
|
const where: Prisma.ReadEventWhereInput = {};
|
||||||
|
if (query.pageId) where.pageId = query.pageId;
|
||||||
|
if (query.actor) {
|
||||||
|
const actor = await this.prisma.user.findUnique({ where: { username: query.actor } });
|
||||||
|
// An unknown username matches nothing rather than everything.
|
||||||
|
where.actorId = actor?.id ?? '00000000-0000-0000-0000-000000000000';
|
||||||
|
}
|
||||||
|
if (query.channel) where.channel = query.channel;
|
||||||
|
if (query.from || query.to) {
|
||||||
|
where.occurredAt = {
|
||||||
|
...(query.from ? { gte: query.from } : {}),
|
||||||
|
...(query.to ? { lte: query.to } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await this.prisma.readEvent.count({ where });
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / READ_EVENT_PAGE_SIZE));
|
||||||
|
const page = Math.min(query.page, pageCount);
|
||||||
|
const events = await this.prisma.readEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { occurredAt: 'desc' },
|
||||||
|
skip: (page - 1) * READ_EVENT_PAGE_SIZE,
|
||||||
|
take: READ_EVENT_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
// No FK on actor_id (evidence outlives accounts) — resolve what still
|
||||||
|
// exists in one query, show the bare id otherwise.
|
||||||
|
const actorIds = [...new Set(events.map((e) => e.actorId).filter((id): id is string => !!id))];
|
||||||
|
const actors = actorIds.length
|
||||||
|
? await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: actorIds } },
|
||||||
|
select: { id: true, username: true, displayName: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const actorById = new Map(actors.map((a) => [a.id, a]));
|
||||||
|
return {
|
||||||
|
entries: events.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
occurredAt: event.occurredAt.toISOString(),
|
||||||
|
actor: event.actorId ? (actorById.get(event.actorId) ?? null) : null,
|
||||||
|
pageId: event.pageId,
|
||||||
|
pondId: event.pondId,
|
||||||
|
channel: event.channel,
|
||||||
|
classification: event.classification,
|
||||||
|
windowSeconds: event.windowSeconds,
|
||||||
|
details: (event.details as Record<string, unknown> | null) ?? null,
|
||||||
|
})),
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async storage(): Promise<StorageOverviewView> {
|
async storage(): Promise<StorageOverviewView> {
|
||||||
const usages = await this.prisma.pondUsage.findMany({
|
const usages = await this.prisma.pondUsage.findMany({
|
||||||
where: { pond: { deletedAt: null } },
|
where: { pond: { deletedAt: null } },
|
||||||
|
|||||||
@ -12,9 +12,11 @@ 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,
|
||||||
@ -31,6 +33,14 @@ import { UserAdminService } from './user-admin.service';
|
|||||||
export class UserAdminController {
|
export class UserAdminController {
|
||||||
constructor(private readonly users: UserAdminService) {}
|
constructor(private readonly users: UserAdminService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<AdminUserView> {
|
||||||
|
return this.users.createUser(request.user!, input);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,13 +62,71 @@ 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 prisma.pond.deleteMany({ where: { ownerId: { in: all } } });
|
await deletePondsWhere(prisma, { ownerId: { in: all } });
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
|
||||||
|
const username = `ua-created-${suffix}`;
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/users')
|
||||||
|
.set('Cookie', cookies.admin1!)
|
||||||
|
.send({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: 'UA Created',
|
||||||
|
password,
|
||||||
|
locale: 'de',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const created = res.body as { id: string; status: string };
|
||||||
|
ids.created = created.id;
|
||||||
|
// No verification hop: the admin vouched for the address.
|
||||||
|
expect(created.status).toBe('ACTIVE');
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200);
|
||||||
|
// The personal pond exists exactly like after self-registration.
|
||||||
|
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate usernames with a field-level conflict', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/users')
|
||||||
|
.set('Cookie', cookies.admin1!)
|
||||||
|
.send({
|
||||||
|
username: `ua-created-${suffix}`,
|
||||||
|
email: `ua-created-other-${suffix}@example.org`,
|
||||||
|
displayName: 'UA Dup',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
})
|
||||||
|
.expect(409)
|
||||||
|
.expect((r) =>
|
||||||
|
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
|
||||||
|
'validation.taken',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses creation for non-admins', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/users')
|
||||||
|
.set('Cookie', cookies.bob!)
|
||||||
|
.send({
|
||||||
|
username: `ua-sneak-${suffix}`,
|
||||||
|
email: `ua-sneak-${suffix}@example.org`,
|
||||||
|
displayName: 'UA Sneak',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
it('lists and searches users (Site-Admin only)', async () => {
|
it('lists and searches users (Site-Admin only)', async () => {
|
||||||
const res = await api()
|
const res = await api()
|
||||||
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
AdminCreateUserInput,
|
||||||
AdminUserListQuery,
|
AdminUserListQuery,
|
||||||
AdminUserListView,
|
AdminUserListView,
|
||||||
AdminUserStatus,
|
AdminUserStatus,
|
||||||
@ -10,7 +11,9 @@ 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,12 +30,33 @@ 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
|
||||||
@ -115,7 +139,9 @@ 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 },
|
||||||
data: { isSiteAdmin: value },
|
// A manual toggle takes ownership of the flag: the IdP mapping
|
||||||
|
// (#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',
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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';
|
||||||
@ -17,6 +18,7 @@ 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';
|
||||||
@ -32,6 +34,7 @@ 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';
|
||||||
@ -47,6 +50,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
ConfigModule,
|
ConfigModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
ReadTrailModule,
|
||||||
RateLimitModule,
|
RateLimitModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
@ -79,6 +83,8 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
PublicApiModule,
|
PublicApiModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
|
BrandingModule,
|
||||||
|
FontsModule,
|
||||||
ImportExportModule,
|
ImportExportModule,
|
||||||
PluginsModule,
|
PluginsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
75
apps/api/src/audit/audit-actions.ts
Normal file
75
apps/api/src/audit/audit-actions.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
48
apps/api/src/audit/audit-catalogue.test.ts
Normal file
48
apps/api/src/audit/audit-catalogue.test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,9 +4,13 @@ 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, e.g. `grant.created` — the UI translates it. */
|
/** Stable dot-namespaced id from the catalogue (issue #201,
|
||||||
action: string;
|
* docs/architecture/audit-events.md) — the UI translates it, SIEM rules
|
||||||
|
* 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;
|
||||||
@ -37,7 +41,15 @@ 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(
|
||||||
{ actor: actorId ?? null, targetType, targetId, ...details },
|
// `severity` is the catalogue's routing hint for SIEM rules (#201) —
|
||||||
|
// pino's own `level` stays 30/info so log transport is unaffected.
|
||||||
|
{
|
||||||
|
severity: AUDIT_EVENTS[action].severity,
|
||||||
|
actor: actorId ?? null,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
...details,
|
||||||
|
},
|
||||||
`audit: ${action}`,
|
`audit: ${action}`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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,
|
||||||
@ -20,12 +21,14 @@ 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
|
||||||
@ -36,6 +39,7 @@ 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. */
|
||||||
@ -45,8 +49,21 @@ 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> {
|
||||||
@ -55,6 +72,7 @@ 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(
|
||||||
@ -65,6 +83,7 @@ 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(
|
||||||
@ -78,6 +97,7 @@ 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(
|
||||||
@ -121,6 +141,7 @@ 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(
|
||||||
@ -131,6 +152,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('reset-password')
|
@Post('reset-password')
|
||||||
|
@LocalCredentialFlow()
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
|
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
|
||||||
async resetPassword(
|
async resetPassword(
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import request from 'supertest';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
SetMetadata,
|
SetMetadata,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
createParamDecorator,
|
createParamDecorator,
|
||||||
@ -13,6 +14,7 @@ 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';
|
||||||
@ -21,6 +23,18 @@ 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;
|
||||||
@ -84,19 +98,38 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach the user whenever the cookie is valid — public routes may
|
// Trusted-proxy identity first (issue #215): when the perimeter
|
||||||
// still want to know who is asking.
|
// authenticates, its header IS the identity for this request — a
|
||||||
if (rawToken) {
|
// session cookie riding along never escalates beyond it, and an
|
||||||
|
// untrusted peer carrying the header is rejected inside resolve().
|
||||||
|
const proxyUser = await this.proxyIdentity.resolve(request);
|
||||||
|
if (proxyUser) {
|
||||||
|
request.user = proxyUser;
|
||||||
|
} else if (rawToken) {
|
||||||
|
// Attach the user whenever the cookie is valid — public routes may
|
||||||
|
// 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;
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Logger, Module, OnModuleInit } 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';
|
||||||
@ -8,18 +12,42 @@ 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],
|
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, OidcController],
|
||||||
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],
|
exports: [AuthTokensService, AuthService, OidcService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule implements OnModuleInit {
|
||||||
|
constructor(
|
||||||
|
private readonly config: AppConfig,
|
||||||
|
private readonly oidc: OidcService,
|
||||||
|
private readonly proxyIdentity: ProxyIdentityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
// #216: local auth off without ANY external path means nobody can ever
|
||||||
|
// sign in — loudly stated at boot, because the operator will otherwise
|
||||||
|
// discover it at the login screen.
|
||||||
|
if (!this.config.env.AUTH_LOCAL_ENABLED && !this.oidc.enabled && !this.proxyIdentity.enabled) {
|
||||||
|
new Logger(AuthModule.name).warn(
|
||||||
|
'AUTH_LOCAL_ENABLED=false with neither OIDC nor proxy authentication configured — no sign-in path exists',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ 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';
|
||||||
@ -33,6 +34,7 @@ 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,
|
||||||
@ -43,10 +45,37 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async signup(input: SignupInput): Promise<void> {
|
async signup(input: SignupInput): Promise<void> {
|
||||||
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
|
// An invitation token (issue #332) lets exactly one signup through a
|
||||||
|
// 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' });
|
||||||
}
|
}
|
||||||
const user = await this.users.createUser(input);
|
let user: User;
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
272
apps/api/src/auth/claim-mapping.e2e.db.test.ts
Normal file
272
apps/api/src/auth/claim-mapping.e2e.db.test.ts
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
161
apps/api/src/auth/claim-mapping.service.ts
Normal file
161
apps/api/src/auth/claim-mapping.service.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
157
apps/api/src/auth/local-auth-switch.e2e.db.test.ts
Normal file
157
apps/api/src/auth/local-auth-switch.e2e.db.test.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
106
apps/api/src/auth/oidc.controller.ts
Normal file
106
apps/api/src/auth/oidc.controller.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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_)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
351
apps/api/src/auth/oidc.e2e.db.test.ts
Normal file
351
apps/api/src/auth/oidc.e2e.db.test.ts
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
359
apps/api/src/auth/oidc.service.ts
Normal file
359
apps/api/src/auth/oidc.service.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
apps/api/src/auth/proxy-identity.e2e.db.test.ts
Normal file
157
apps/api/src/auth/proxy-identity.e2e.db.test.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
102
apps/api/src/auth/proxy-identity.service.ts
Normal file
102
apps/api/src/auth/proxy-identity.service.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
50
apps/api/src/branding/branding-storage.service.ts
Normal file
50
apps/api/src/branding/branding-storage.service.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
253
apps/api/src/branding/branding.controller.ts
Normal file
253
apps/api/src/branding/branding.controller.ts
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
250
apps/api/src/branding/branding.e2e.db.test.ts
Normal file
250
apps/api/src/branding/branding.e2e.db.test.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
23
apps/api/src/branding/branding.module.ts
Normal file
23
apps/api/src/branding/branding.module.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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 {}
|
||||||
380
apps/api/src/branding/branding.service.ts
Normal file
380
apps/api/src/branding/branding.service.ts
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
152
apps/api/src/files/attachment-integrity.e2e.db.test.ts
Normal file
152
apps/api/src/files/attachment-integrity.e2e.db.test.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import { INestApplication, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { PrismaClient, User } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
|
import { createTestApp } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
import { FileStorageService } from './file-storage.service';
|
||||||
|
import { FilesService } from './files.service';
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const pngBuffer = (payload: string): Buffer => Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]);
|
||||||
|
const sha256 = (buffer: Buffer): string => createHash('sha256').update(buffer).digest('hex');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachment integrity (issue #199): uploads store the SHA-256 of the
|
||||||
|
* written bytes, downloads verify it and fail closed (audited) on mismatch,
|
||||||
|
* and the nightly backfill hashes pre-#199 rows idempotently, reporting
|
||||||
|
* unreadable files instead of skipping them.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let files: FilesService;
|
||||||
|
let storage: FileStorageService;
|
||||||
|
let user: User;
|
||||||
|
let pondId: string;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
|
||||||
|
async function uploadPng(payload: string): Promise<{ id: string; bytes: Buffer }> {
|
||||||
|
const bytes = pngBuffer(payload);
|
||||||
|
const view = await files.upload(user, pondId, {
|
||||||
|
buffer: bytes,
|
||||||
|
size: bytes.length,
|
||||||
|
originalname: `${payload}.png`,
|
||||||
|
});
|
||||||
|
return { id: view.id, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
app = await createTestApp();
|
||||||
|
files = app.get(FilesService);
|
||||||
|
storage = app.get(FileStorageService);
|
||||||
|
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
user = await users.createUser({
|
||||||
|
username: `ines-integrity-${suffix}`,
|
||||||
|
email: `ines-integrity-${suffix}@example.org`,
|
||||||
|
displayName: `Ines Integrity ${suffix}`,
|
||||||
|
password: 'jedes byte bleibt wie es war 1',
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
// Verification via the endpoint (not markEmailVerified) because only the
|
||||||
|
// endpoint creates the personal pond the uploads go into.
|
||||||
|
const token = await app.get(AuthTokensService).issue(user.id, 'EMAIL_VERIFICATION', 600);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/verify-email')
|
||||||
|
.send({ token })
|
||||||
|
.expect(204);
|
||||||
|
const pond = await prisma.pond.findFirstOrThrow({ where: { ownerId: user.id } });
|
||||||
|
pondId = pond.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.auditEntry.deleteMany({
|
||||||
|
where: { action: 'file.integrity_failed', details: { path: ['pondId'], equals: pondId } },
|
||||||
|
});
|
||||||
|
await prisma.attachment.deleteMany({ where: { pondId } });
|
||||||
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
|
await prisma.roleGrant.deleteMany({ where });
|
||||||
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the hash of the written bytes at upload', async () => {
|
||||||
|
const { id, bytes } = await uploadPng('honest-upload');
|
||||||
|
const row = await prisma.attachment.findUniqueOrThrow({ where: { id } });
|
||||||
|
expect(row.sha256).toBe(sha256(bytes));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves an intact file and fails closed, audited, on a tampered one', async () => {
|
||||||
|
const { id, bytes } = await uploadPng('will-be-tampered');
|
||||||
|
|
||||||
|
// Intact: the download succeeds and streams the exact bytes.
|
||||||
|
const intact = await files.download(null, id, { actorId: null, sessionKey: 'anon' });
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of intact.stream) chunks.push(chunk as Buffer);
|
||||||
|
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);
|
||||||
|
|
||||||
|
// Tampered on disk (row untouched): fail closed with the dedicated code.
|
||||||
|
await storage.save(pondId, id, pngBuffer('evil-replacement'));
|
||||||
|
const failure = await files
|
||||||
|
.download(null, id, { actorId: null, sessionKey: 'anon' })
|
||||||
|
.catch((error: unknown) => error);
|
||||||
|
expect(failure).toBeInstanceOf(InternalServerErrorException);
|
||||||
|
expect((failure as InternalServerErrorException).getResponse()).toMatchObject({
|
||||||
|
code: 'attachment_integrity_failure',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The mismatch is on the audit trail with both hashes.
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'file.integrity_failed', targetId: id },
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
expect(audit!.details).toMatchObject({
|
||||||
|
expected: sha256(bytes),
|
||||||
|
actual: sha256(pngBuffer('evil-replacement')),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills missing hashes idempotently and reports unreadable files', async () => {
|
||||||
|
const readable = await uploadPng('backfill-me');
|
||||||
|
const unreadable = await uploadPng('bytes-will-vanish');
|
||||||
|
await prisma.attachment.updateMany({
|
||||||
|
where: { id: { in: [readable.id, unreadable.id] } },
|
||||||
|
data: { sha256: null },
|
||||||
|
});
|
||||||
|
await storage.delete(pondId, unreadable.id);
|
||||||
|
|
||||||
|
// A null-hash row is served unverified (pre-#199 status quo).
|
||||||
|
const unverified = await files.download(null, readable.id, {
|
||||||
|
actorId: null,
|
||||||
|
sessionKey: 'anon',
|
||||||
|
});
|
||||||
|
expect(unverified.attachment.sha256).toBeNull();
|
||||||
|
|
||||||
|
const first = await files.backfillHashes();
|
||||||
|
expect(first.hashed).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(first.unreadable).toBeGreaterThanOrEqual(1);
|
||||||
|
const rehashed = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
|
||||||
|
expect(rehashed.sha256).toBe(sha256(readable.bytes));
|
||||||
|
|
||||||
|
// The unreadable row keeps its null hash — reported, retried next run,
|
||||||
|
// never silently marked done.
|
||||||
|
const vanished = await prisma.attachment.findUniqueOrThrow({ where: { id: unreadable.id } });
|
||||||
|
expect(vanished.sha256).toBeNull();
|
||||||
|
|
||||||
|
// Idempotent: a second run finds nothing new to hash here.
|
||||||
|
const second = await files.backfillHashes();
|
||||||
|
const third = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
|
||||||
|
expect(third.sha256).toBe(sha256(readable.bytes));
|
||||||
|
expect(second.unreadable).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { access, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
import { access, mkdir, readdir, readFile, 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,6 +30,13 @@ 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). */
|
||||||
|
|||||||
@ -27,6 +27,7 @@ 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';
|
||||||
|
|
||||||
@ -90,14 +91,18 @@ 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 } = await this.files.download(request.user ?? null, fileId);
|
const { attachment, stream, inline, downloadName } = await this.files.download(
|
||||||
|
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(attachment.fileName)}"`,
|
disposition: `${kind}; filename="${encodeURIComponent(downloadName)}"`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -327,6 +327,121 @@ 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`)
|
||||||
|
|||||||
@ -24,6 +24,7 @@ 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 {
|
||||||
@ -32,6 +33,10 @@ 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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import type { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
PayloadTooLargeException,
|
PayloadTooLargeException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -12,15 +14,19 @@ 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';
|
||||||
@ -34,6 +40,11 @@ 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. */
|
||||||
@ -51,6 +62,8 @@ 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);
|
||||||
@ -153,6 +166,9 @@ 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(
|
||||||
@ -192,19 +208,136 @@ 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: this.storage.createReadStream(attachment.pondId, attachment.id),
|
stream: Readable.from(buffer),
|
||||||
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({
|
||||||
|
|||||||
55
apps/api/src/fonts/custom-font-storage.service.ts
Normal file
55
apps/api/src/fonts/custom-font-storage.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
164
apps/api/src/fonts/custom-fonts.controller.ts
Normal file
164
apps/api/src/fonts/custom-fonts.controller.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
apps/api/src/fonts/custom-fonts.e2e.db.test.ts
Normal file
244
apps/api/src/fonts/custom-fonts.e2e.db.test.ts
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
249
apps/api/src/fonts/custom-fonts.service.ts
Normal file
249
apps/api/src/fonts/custom-fonts.service.ts
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/fonts/fonts.module.ts
Normal file
14
apps/api/src/fonts/fonts.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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 {}
|
||||||
@ -152,7 +152,14 @@ 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(user: User, pondId: string, grant: Grant): Promise<GrantView> {
|
async createGrant(
|
||||||
|
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, {
|
||||||
@ -169,7 +176,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, ...columns },
|
data: { pondId, createdBy: user.id, origin: options.origin ?? 'manual', ...columns },
|
||||||
});
|
});
|
||||||
await this.accessChanged(pondId);
|
await this.accessChanged(pondId);
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
@ -185,6 +192,7 @@ 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);
|
||||||
@ -195,7 +203,12 @@ 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(user: User, pondId: string, grantId: string): Promise<void> {
|
async deleteGrant(
|
||||||
|
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();
|
||||||
|
|
||||||
@ -213,7 +226,12 @@ export class GrantsService {
|
|||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
targetType: 'pond',
|
targetType: 'pond',
|
||||||
targetId: pondId,
|
targetId: pondId,
|
||||||
details: { grantId, subjectId: grant.subjectId, role: grant.role },
|
details: {
|
||||||
|
grantId,
|
||||||
|
subjectId: grant.subjectId,
|
||||||
|
role: grant.role,
|
||||||
|
...(options.origin === 'idp' ? { origin: 'idp_mapping' } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -17,8 +19,8 @@ export const apiI18n: I18n = createInstance();
|
|||||||
|
|
||||||
void apiI18n.init({
|
void apiI18n.init({
|
||||||
resources: {
|
resources: {
|
||||||
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
|
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds },
|
||||||
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
|
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds },
|
||||||
},
|
},
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['de', 'en'],
|
supportedLngs: ['de', 'en'],
|
||||||
|
|||||||
39
apps/api/src/import-export/classified-markdown.test.ts
Normal file
39
apps/api/src/import-export/classified-markdown.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
43
apps/api/src/import-export/classified-markdown.ts
Normal file
43
apps/api/src/import-export/classified-markdown.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, 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 prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { 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();
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
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';
|
||||||
@ -66,10 +69,34 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy {
|
|||||||
to: job.targetFormat,
|
to: job.targetFormat,
|
||||||
input: Buffer.from(conversionInputOf(job)),
|
input: Buffer.from(conversionInputOf(job)),
|
||||||
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);
|
||||||
@ -161,9 +188,13 @@ 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(conversionInputOf(job)).toString('utf8'),
|
Buffer.from(conversionInputOf(job)).toString('utf8'),
|
||||||
|
{ marking },
|
||||||
),
|
),
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -95,7 +95,14 @@ export class DataExportService implements DataExportProcessor {
|
|||||||
orderBy: { slug: 'asc' },
|
orderBy: { slug: 'asc' },
|
||||||
});
|
});
|
||||||
for (const pond of ponds) {
|
for (const pond of ponds) {
|
||||||
await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`);
|
// The build runs in the conversion worker, outside any request — the
|
||||||
|
// 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();
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
|
import {
|
||||||
|
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
|
||||||
@ -15,7 +24,41 @@ import { ExportService } from './export.service';
|
|||||||
*/
|
*/
|
||||||
@Controller()
|
@Controller()
|
||||||
export class ExportController {
|
export class ExportController {
|
||||||
constructor(private readonly exports: ExportService) {}
|
constructor(
|
||||||
|
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,
|
||||||
@ -27,7 +70,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);
|
await this.exports.streamPondMarkdownZip(request.user!, pondId, response, readActorOf(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
@ -39,6 +82,42 @@ 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(request.user!, pageId, input.format);
|
return this.exports.enqueuePageExport(
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
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';
|
||||||
@ -8,6 +10,8 @@ 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
|
||||||
@ -70,4 +74,68 @@ 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -31,8 +31,10 @@ 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> {
|
||||||
@ -42,9 +44,11 @@ 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): Promise<Buffer> {
|
renderHtmlToPdf(html: string, options?: { marking?: string | null }): 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'));
|
||||||
}
|
}
|
||||||
@ -189,6 +193,123 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
|||||||
expect(target).toContain(``);
|
expect(target).toContain(``);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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`,
|
||||||
|
);
|
||||||
|
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, {
|
||||||
@ -327,6 +448,30 @@ 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 () => {
|
||||||
@ -377,6 +522,35 @@ 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 () => {
|
||||||
|
|||||||
@ -6,7 +6,12 @@ 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';
|
||||||
@ -16,11 +21,14 @@ 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,
|
||||||
@ -47,6 +55,8 @@ 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);
|
||||||
@ -59,7 +69,12 @@ 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(user: User, pondId: string, res: Response): Promise<void> {
|
async streamPondMarkdownZip(
|
||||||
|
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();
|
||||||
|
|
||||||
@ -72,7 +87,7 @@ export class ExportService {
|
|||||||
res.destroy(error);
|
res.destroy(error);
|
||||||
});
|
});
|
||||||
archive.pipe(res);
|
archive.pipe(res);
|
||||||
await this.appendPondMarkdown(archive, user, pond);
|
await this.appendPondMarkdown(archive, user, pond, '', read);
|
||||||
await archive.finalize();
|
await archive.finalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +103,8 @@ export class ExportService {
|
|||||||
archive: archiver.Archiver,
|
archive: archiver.Archiver,
|
||||||
user: User,
|
user: User,
|
||||||
pond: { id: string; slug: string },
|
pond: { id: string; slug: string },
|
||||||
prefix = '',
|
prefix: string,
|
||||||
|
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({
|
||||||
@ -108,6 +124,21 @@ 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>();
|
||||||
@ -131,15 +162,64 @@ 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 markdown = markdownForZip(
|
const level = page.classification.toLowerCase() as PageClassification;
|
||||||
page.contentCache?.markdown ?? '',
|
for (const id of imageFileIds(page.contentCache?.markdown ?? '')) {
|
||||||
readableSlugs,
|
const current = mediaClassification.get(id) ?? 'unclassified';
|
||||||
mediaNameById,
|
if (classificationRank(level) > classificationRank(current)) {
|
||||||
|
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
|
||||||
@ -168,14 +248,18 @@ 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);
|
if (format === 'pdf') return this.enqueuePdfExport(user, pageId, read);
|
||||||
|
|
||||||
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
|
||||||
@ -184,6 +268,10 @@ 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}`,
|
||||||
@ -191,6 +279,7 @@ 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 },
|
||||||
@ -206,7 +295,27 @@ 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 byte→byte render the worker can retry.
|
* up front keeps the job a plain byte→byte render the worker can retry.
|
||||||
*/
|
*/
|
||||||
private async enqueuePdfExport(user: User, pageId: string): Promise<ConversionJobView> {
|
/** One `export` event for a classified page leaving as a document (#222). */
|
||||||
|
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: {
|
||||||
@ -215,6 +324,7 @@ 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
|
||||||
@ -226,12 +336,19 @@ 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',
|
||||||
@ -239,6 +356,7 @@ 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 },
|
||||||
@ -259,12 +377,18 @@ export class ExportService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Base64 `@font-face` rules for the pond's three fonts, read from the
|
/** Base64 `@font-face` rules for the pond's three fonts. Catalog families
|
||||||
* catalog baked into the image (ADR 0016). A font file that is absent (a
|
* come from the directory baked into the image (ADR 0016); operator-uploaded
|
||||||
* native dev run without `FONTS_DIR` populated) is skipped — the render falls
|
* ones from `CUSTOM_FONTS_DIR` (issue #303) — same on-disk layout, so only
|
||||||
* back to the system stack rather than failing. */
|
* the base directory differs. A font file that is absent (a native dev run
|
||||||
|
* 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[] = [];
|
||||||
@ -272,8 +396,10 @@ 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 slug = fontSlug(slot.family);
|
const customSlug = customSlugs.get(slot.family);
|
||||||
const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
|
const slug = customSlug ?? fontSlug(slot.family);
|
||||||
|
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(
|
||||||
@ -281,7 +407,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: catalog font file missing, using fallback');
|
this.logger.warn({ font: key }, 'pdf export: font file missing, using fallback');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return faces.join('\n');
|
return faces.join('\n');
|
||||||
|
|||||||
@ -27,6 +27,40 @@ 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
@ -38,7 +72,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): Promise<Buffer>;
|
abstract renderHtmlToPdf(html: string, options?: RenderPdfOptions): Promise<Buffer>;
|
||||||
abstract reachable(): Promise<boolean>;
|
abstract reachable(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,16 +97,33 @@ export class GotenbergHttpRenderer extends GotenbergRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderHtmlToPdf(html: string): Promise<Buffer> {
|
async renderHtmlToPdf(html: string, options: RenderPdfOptions = {}): 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');
|
||||||
form.append('files', new Blob([FOOTER_HTML], { type: 'text/html' }), 'footer.html');
|
// A marked page (issue #208) gets the classification as a per-page
|
||||||
|
// 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', '0.6');
|
form.append('marginTop', marking ? '0.8' : '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');
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common';
|
|||||||
|
|
||||||
import { CommonModule } from '../common/common.module';
|
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';
|
||||||
@ -14,7 +15,7 @@ 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 } from './export.controller';
|
import { ExportController, PondArchiveAdminController } 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';
|
||||||
@ -22,6 +23,7 @@ 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. */
|
||||||
@ -40,18 +42,26 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
FontsModule,
|
||||||
LabelsModule,
|
LabelsModule,
|
||||||
PagesModule,
|
PagesModule,
|
||||||
PluginsModule,
|
PluginsModule,
|
||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
],
|
],
|
||||||
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
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).
|
||||||
|
|||||||
@ -24,6 +24,7 @@ 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,
|
||||||
@ -544,12 +545,24 @@ 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 {
|
||||||
const markdown = await this.storeEmbeddedImages(rawMarkdown, user, pondId, storedFileIds);
|
// Our own classified export wraps the content in frontmatter + marking
|
||||||
|
// 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(user, pondId, title, state);
|
const page = await this.pages.createWithState(
|
||||||
|
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) {
|
||||||
|
|||||||
@ -17,6 +17,11 @@ 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 {
|
||||||
@ -141,6 +146,14 @@ 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,
|
||||||
});
|
});
|
||||||
|
|||||||
51
apps/api/src/import-export/pdf-html.test.ts
Normal file
51
apps/api/src/import-export/pdf-html.test.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { PondFonts, fontStack } from '@dorfteich/shared';
|
import { FontCatalogEntry, PondFonts, fontStack } from '@dorfteich/shared';
|
||||||
|
|
||||||
export interface PdfHtmlParams {
|
export interface PdfHtmlParams {
|
||||||
title: string;
|
title: string;
|
||||||
@ -8,6 +8,12 @@ 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
|
||||||
@ -35,6 +41,7 @@ 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>
|
||||||
@ -44,9 +51,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)};
|
--font-heading: ${fontStack(fonts.heading.family, extra)};
|
||||||
--font-body: ${fontStack(fonts.body.family)};
|
--font-body: ${fontStack(fonts.body.family, extra)};
|
||||||
--font-mono: ${fontStack(fonts.mono.family)};
|
--font-mono: ${fontStack(fonts.mono.family, extra)};
|
||||||
}
|
}
|
||||||
html { font-size: 11pt; }
|
html { font-size: 11pt; }
|
||||||
body {
|
body {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { DEFAULT_FONTS, PondFonts } from '@dorfteich/shared';
|
import { DEFAULT_FONTS, PondFonts, classificationMarking } 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 and page count from PDF bytes. */
|
/** Extract the concatenated text, per-page texts and page count from PDF bytes. */
|
||||||
async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number }> {
|
async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number; pageTexts: string[] }> {
|
||||||
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 };
|
return { text: result.text, pages: result.total, pageTexts: result.pages.map((p) => p.text) };
|
||||||
} finally {
|
} finally {
|
||||||
await parser.destroy();
|
await parser.destroy();
|
||||||
}
|
}
|
||||||
@ -69,5 +69,36 @@ 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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/api/src/invitations/invitations.controller.ts
Normal file
50
apps/api/src/invitations/invitations.controller.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
246
apps/api/src/invitations/invitations.e2e.db.test.ts
Normal file
246
apps/api/src/invitations/invitations.e2e.db.test.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/api/src/invitations/invitations.module.ts
Normal file
13
apps/api/src/invitations/invitations.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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 {}
|
||||||
199
apps/api/src/invitations/invitations.service.ts
Normal file
199
apps/api/src/invitations/invitations.service.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { apiI18n } from '../i18n/api-i18n';
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
|
||||||
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
|
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation';
|
||||||
|
|
||||||
export interface RenderedMail {
|
export interface RenderedMail {
|
||||||
subject: string;
|
subject: string;
|
||||||
@ -15,14 +15,15 @@ export interface RenderedMail {
|
|||||||
*/
|
*/
|
||||||
export function renderMail(
|
export function renderMail(
|
||||||
template: MailTemplate,
|
template: MailTemplate,
|
||||||
params: { displayName: string; link: string },
|
// Extra keys (e.g. inviterName, #332) interpolate into the body text.
|
||||||
|
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`);
|
const body = t(`${template}.body`, params);
|
||||||
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');
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export class MailService {
|
|||||||
async enqueue(
|
async enqueue(
|
||||||
to: string,
|
to: string,
|
||||||
template: MailTemplate,
|
template: MailTemplate,
|
||||||
params: { displayName: string; link: string },
|
params: { displayName: string; link: string } & Record<string, string>,
|
||||||
locale: 'de' | 'en',
|
locale: 'de' | 'en',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const rendered = renderMail(template, params, locale);
|
const rendered = renderMail(template, params, locale);
|
||||||
|
|||||||
@ -112,7 +112,9 @@ 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');
|
||||||
return asJson(await this.publicApi.getPage(user, input.pond!, input.page!));
|
// The shared getPage also records the read-trail event (#222) —
|
||||||
|
// 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);
|
||||||
|
|||||||
@ -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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -97,7 +97,7 @@ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => {
|
|||||||
const ids = Object.values(userIds);
|
const ids = Object.values(userIds);
|
||||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
||||||
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } });
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } });
|
||||||
await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
await deletePondsWhere(prisma, { ownerId: { in: ids } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@ -3,11 +3,7 @@ import { createHmac } from 'node:crypto';
|
|||||||
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import { signUnsubscribeToken, verifyUnsubscribeToken } from './unsubscribe-token';
|
||||||
LEGACY_VERIFY_UNTIL,
|
|
||||||
signUnsubscribeToken,
|
|
||||||
verifyUnsubscribeToken,
|
|
||||||
} from './unsubscribe-token';
|
|
||||||
|
|
||||||
const secret = 'test-secret-at-least-16-chars-long';
|
const secret = 'test-secret-at-least-16-chars-long';
|
||||||
const TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
const TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
||||||
@ -50,23 +46,15 @@ describe('unsubscribe token', () => {
|
|||||||
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
|
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a pre-separation legacy token inside the dual-verify window', () => {
|
it('rejects pre-separation legacy tokens — the dual-verify window is gone (#296)', () => {
|
||||||
const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000;
|
const now = Date.now();
|
||||||
expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe(
|
// Unexpired on its own terms — rejected because only the subkey verifies.
|
||||||
'u1',
|
expect(verifyUnsubscribeToken(legacyToken('u1', now - 1000), secret, now)).toBeNull();
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a legacy token once the dual-verify window has closed', () => {
|
it('verifies freshly minted tokens via the subkey', () => {
|
||||||
const afterWindow = LEGACY_VERIFY_UNTIL + 1000;
|
const now = Date.now();
|
||||||
// Unexpired on its own terms — rejected purely because the window closed.
|
const token = signUnsubscribeToken('u1', secret, now);
|
||||||
const token = legacyToken('u1', afterWindow - 1000);
|
expect(verifyUnsubscribeToken(token, secret, now + 1000)).toBe('u1');
|
||||||
expect(verifyUnsubscribeToken(token, secret, afterWindow)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verifies freshly minted tokens via the subkey, independent of the window', () => {
|
|
||||||
const afterWindow = LEGACY_VERIFY_UNTIL + 24 * 60 * 60 * 1000;
|
|
||||||
const token = signUnsubscribeToken('u1', secret, afterWindow);
|
|
||||||
expect(verifyUnsubscribeToken(token, secret, afterWindow + 1000)).toBe('u1');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,26 +13,17 @@ import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
|||||||
|
|
||||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||||
|
|
||||||
/**
|
// The pre-#188 dual-verify window (root secret + `digest-unsubscribe.`
|
||||||
* Dual-verify window (#188, ADR 0020): before the key separation, tokens
|
// prefix) was removed EARLY by operator decision at the ADR 0020
|
||||||
* were HMACed with the root secret over a `digest-unsubscribe.` prefix.
|
// acceptance (issue #296): links in mails sent before the key separation
|
||||||
* Those links live in digest mails that are already sent and stay valid
|
// no longer work — recipients use the in-app notification settings.
|
||||||
* for their full 90-day TTL, so verification accepts the legacy derivation
|
// Verification is subkey-only; the regression test pins that the legacy
|
||||||
* until every pre-separation token has expired. Tokens are only ever
|
// derivation can never verify again.
|
||||||
* SIGNED with the new subkey; the legacy path is verify-only and goes dead
|
|
||||||
* automatically on the date below (last possible legacy expiry, rounded up).
|
|
||||||
*/
|
|
||||||
export const LEGACY_VERIFY_UNTIL = Date.parse('2026-11-01T00:00:00Z');
|
|
||||||
const LEGACY_PURPOSE = 'digest-unsubscribe';
|
|
||||||
|
|
||||||
function signature(body: string, rootSecret: string): Buffer {
|
function signature(body: string, rootSecret: string): Buffer {
|
||||||
return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest();
|
return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacySignature(body: string, rootSecret: string): Buffer {
|
|
||||||
return createHmac('sha256', rootSecret).update(`${LEGACY_PURPOSE}.${body}`).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function signUnsubscribeToken(userId: string, rootSecret: string, now = Date.now()): string {
|
export function signUnsubscribeToken(userId: string, rootSecret: string, now = Date.now()): string {
|
||||||
const body = Buffer.from(
|
const body = Buffer.from(
|
||||||
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
|
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
|
||||||
@ -59,10 +50,7 @@ export function verifyUnsubscribeToken(
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const current = matches(provided, signature(body, rootSecret));
|
if (!matches(provided, signature(body, rootSecret))) return null;
|
||||||
const legacy =
|
|
||||||
!current && now < LEGACY_VERIFY_UNTIL && matches(provided, legacySignature(body, rootSecret));
|
|
||||||
if (!current && !legacy) return null;
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
254
apps/api/src/pages/classification-inheritance.e2e.db.test.ts
Normal file
254
apps/api/src/pages/classification-inheritance.e2e.db.test.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { CreateGrantInput } from '@dorfteich/shared';
|
||||||
|
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 { PondsService } from '../ponds/ponds.service';
|
||||||
|
import { TrashService } from '../trash/trash.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classification inheritance in the page tree (issue #205, ADR 0022):
|
||||||
|
* children inherit, moves can only raise (audited), lowering needs the
|
||||||
|
* dedicated capability (pond-wide Pond Admin) and is audited — and no
|
||||||
|
* move-like path (reposition, promote-on-trash, promote-on-purge) ever
|
||||||
|
* lowers a level as a side effect.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('classification inheritance (e2e, issue #205)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'vererbte einstufung 123';
|
||||||
|
|
||||||
|
const userIds: Record<string, string> = {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
let pondId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
async function makeUser(handle: string): Promise<void> {
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const username = `${handle}-inherit-${suffix}`;
|
||||||
|
const user = await users.createUser({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: `${handle} ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
userIds[handle] = user.id;
|
||||||
|
await users.markEmailVerified(user.id);
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200);
|
||||||
|
cookies[handle] = sessionCookieOf(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPage(title: string, parentId?: string): Promise<{ id: string }> {
|
||||||
|
const res = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ title, ...(parentId ? { parentId } : {}) })
|
||||||
|
.expect(201);
|
||||||
|
return res.body as { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classificationOf(pageId: string): Promise<string> {
|
||||||
|
const res = await api()
|
||||||
|
.get(`/api/v1/pages/${pageId}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.expect(200);
|
||||||
|
return (res.body as { classification: string }).classification;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
|
||||||
|
for (const handle of ['owner', 'editor']) {
|
||||||
|
await makeUser(handle);
|
||||||
|
}
|
||||||
|
await app
|
||||||
|
.get(PondsService)
|
||||||
|
.ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } }));
|
||||||
|
await prisma.quotaOverride.create({
|
||||||
|
data: {
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: userIds.owner!,
|
||||||
|
quotaKey: 'additional_ponds',
|
||||||
|
value: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pond = await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ name: `Inherit Pond ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
pondId = (pond.body as { id: string }).id;
|
||||||
|
|
||||||
|
const editorGrant: CreateGrantInput = {
|
||||||
|
subjectType: 'user',
|
||||||
|
subjectId: userIds.editor!,
|
||||||
|
role: 'editor',
|
||||||
|
scopeType: 'pond',
|
||||||
|
scopeId: undefined,
|
||||||
|
effect: 'allow',
|
||||||
|
};
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/grants`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send(editorGrant)
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const ids = Object.values(userIds);
|
||||||
|
await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } });
|
||||||
|
await prisma.page.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
||||||
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
||||||
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
||||||
|
await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('any writer may raise; a new page inherits its parent level', async () => {
|
||||||
|
const parent = await createPage(`Classified Parent ${suffix}`);
|
||||||
|
// The EDITOR raises — raising is ordinary editorial work, no admin needed.
|
||||||
|
const raised = await api()
|
||||||
|
.patch(`/api/v1/pages/${parent.id}`)
|
||||||
|
.set('Cookie', cookies.editor!)
|
||||||
|
.send({ classification: 'vs_nfd' })
|
||||||
|
.expect(200);
|
||||||
|
expect((raised.body as { classification: string }).classification).toBe('vs_nfd');
|
||||||
|
const raiseAudit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'page.classification_raised', targetId: parent.id },
|
||||||
|
});
|
||||||
|
expect(raiseAudit?.actorId).toBe(userIds.editor);
|
||||||
|
expect(raiseAudit?.details).toMatchObject({ from: 'unclassified', to: 'vs_nfd' });
|
||||||
|
|
||||||
|
// Inherit on create: the child starts at the parent's level, not the
|
||||||
|
// instance default.
|
||||||
|
const child = await createPage(`Inherited Child ${suffix}`, parent.id);
|
||||||
|
expect(await classificationOf(child.id)).toBe('vs_nfd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moving under a higher-classified parent raises the whole moved subtree, audited', async () => {
|
||||||
|
const top = await createPage(`Secret Top ${suffix}`);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${top.id}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ classification: 'vs_nfd' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const movedRoot = await createPage(`Open Subtree ${suffix}`);
|
||||||
|
const movedChild = await createPage(`Open Leaf ${suffix}`, movedRoot.id);
|
||||||
|
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${movedRoot.id}/position`)
|
||||||
|
.set('Cookie', cookies.editor!)
|
||||||
|
.send({ afterId: null, beforeId: null, parentId: top.id })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(await classificationOf(movedRoot.id)).toBe('vs_nfd');
|
||||||
|
expect(await classificationOf(movedChild.id)).toBe('vs_nfd');
|
||||||
|
for (const pageId of [movedRoot.id, movedChild.id]) {
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'page.classification_raised', targetId: pageId },
|
||||||
|
});
|
||||||
|
expect(audit?.details).toMatchObject({ from: 'unclassified', to: 'vs_nfd', trigger: 'move' });
|
||||||
|
expect(audit?.actorId).toBe(userIds.editor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowering is denied without the dedicated capability and audited with it', async () => {
|
||||||
|
const page = await createPage(`To Lower ${suffix}`);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${page.id}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ classification: 'vs_nfd' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// The editor may write the page but holds no pond-admin role → 403.
|
||||||
|
const denied = await api()
|
||||||
|
.patch(`/api/v1/pages/${page.id}`)
|
||||||
|
.set('Cookie', cookies.editor!)
|
||||||
|
.send({ classification: 'unclassified' })
|
||||||
|
.expect(403);
|
||||||
|
expect((denied.body as { code: string }).code).toBe('classification_lower_forbidden');
|
||||||
|
expect(await classificationOf(page.id)).toBe('vs_nfd');
|
||||||
|
|
||||||
|
// The owner (Pond Admin) may lower — and the sensitive direction is audited.
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${page.id}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ classification: 'unclassified' })
|
||||||
|
.expect(200);
|
||||||
|
expect(await classificationOf(page.id)).toBe('unclassified');
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'page.classification_lowered', targetId: page.id },
|
||||||
|
});
|
||||||
|
expect(audit?.actorId).toBe(userIds.owner);
|
||||||
|
expect(audit?.details).toMatchObject({ from: 'vs_nfd', to: 'unclassified', trigger: 'edit' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no move-like path lowers as a side effect: reposition, trash-promote, purge-promote', async () => {
|
||||||
|
// Moving a classified page under an unclassified parent keeps its level.
|
||||||
|
const openParent = await createPage(`Open Parent ${suffix}`);
|
||||||
|
const classified = await createPage(`Stays Classified ${suffix}`);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${classified.id}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ classification: 'vs_nfd' })
|
||||||
|
.expect(200);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${classified.id}/position`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ afterId: null, beforeId: null, parentId: openParent.id })
|
||||||
|
.expect(200);
|
||||||
|
expect(await classificationOf(classified.id)).toBe('vs_nfd');
|
||||||
|
|
||||||
|
// Trash-promote: trashing the classified middle page re-attaches its
|
||||||
|
// classified child to the open grandparent — the child keeps its level.
|
||||||
|
const middle = await createPage(`Classified Middle ${suffix}`, openParent.id);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${middle.id}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ classification: 'vs_nfd' })
|
||||||
|
.expect(200);
|
||||||
|
const grandchild = await createPage(`Classified Leaf ${suffix}`, middle.id);
|
||||||
|
expect(await classificationOf(grandchild.id)).toBe('vs_nfd');
|
||||||
|
await api().delete(`/api/v1/pages/${middle.id}`).set('Cookie', cookies.owner!).expect(204);
|
||||||
|
const promoted = await prisma.page.findUniqueOrThrow({ where: { id: grandchild.id } });
|
||||||
|
expect(promoted.parentId).toBe(openParent.id);
|
||||||
|
expect(promoted.classification).toBe('VS_NFD');
|
||||||
|
|
||||||
|
// Purge-promote: a subtree-trashed child still points at its trashed
|
||||||
|
// parent; purging the parent promotes it — again without lowering.
|
||||||
|
const purgeParent = await createPage(`Purge Parent ${suffix}`, openParent.id);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${purgeParent.id}`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.send({ classification: 'vs_nfd' })
|
||||||
|
.expect(200);
|
||||||
|
const purgeChild = await createPage(`Purge Leaf ${suffix}`, purgeParent.id);
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/pages/${purgeParent.id}?mode=subtree`)
|
||||||
|
.set('Cookie', cookies.owner!)
|
||||||
|
.expect(204);
|
||||||
|
const ownerUser = await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } });
|
||||||
|
await app.get(TrashService).purgeNow(ownerUser, purgeParent.id);
|
||||||
|
const promotedAfterPurge = await prisma.page.findUniqueOrThrow({
|
||||||
|
where: { id: purgeChild.id },
|
||||||
|
});
|
||||||
|
expect(promotedAfterPurge.parentId).toBe(openParent.id);
|
||||||
|
expect(promotedAfterPurge.classification).toBe('VS_NFD');
|
||||||
|
});
|
||||||
|
});
|
||||||
164
apps/api/src/pages/classification.e2e.db.test.ts
Normal file
164
apps/api/src/pages/classification.e2e.db.test.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classification as first-class page metadata (issue #204, ADR 0022): the
|
||||||
|
* enum field, the instance default for new pages, its presence in the page
|
||||||
|
* representations the frontend already loads — and the ADR's explicit claim
|
||||||
|
* that the field is a marking, NOT a protection mechanism (permissions are
|
||||||
|
* unchanged by it).
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('page classification (e2e, issue #204)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'eingestufte seiten 123';
|
||||||
|
|
||||||
|
const admin = { username: `carla-class-${suffix}`, displayName: `Carla Class ${suffix}` };
|
||||||
|
const outsider = { username: `oskar-class-${suffix}`, displayName: `Oskar Out ${suffix}` };
|
||||||
|
let adminCookie: string;
|
||||||
|
let outsiderCookie: string;
|
||||||
|
let pondId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
async function loginOf(username: string): Promise<string> {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200);
|
||||||
|
return sessionCookieOf(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const tokens = app.get(AuthTokensService);
|
||||||
|
|
||||||
|
const adminUser = await users.createUser({
|
||||||
|
username: admin.username,
|
||||||
|
email: `${admin.username}@example.org`,
|
||||||
|
displayName: admin.displayName,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
const verifyToken = await tokens.issue(adminUser.id, 'EMAIL_VERIFICATION', 600);
|
||||||
|
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
|
||||||
|
// Site admin so the test can flip the instance default over the real
|
||||||
|
// admin API (which also invalidates the settings cache).
|
||||||
|
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||||
|
adminCookie = await loginOf(admin.username);
|
||||||
|
|
||||||
|
const outsiderUser = await users.createUser({
|
||||||
|
username: outsider.username,
|
||||||
|
email: `${outsider.username}@example.org`,
|
||||||
|
displayName: outsider.displayName,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(outsiderUser.id);
|
||||||
|
outsiderCookie = await loginOf(outsider.username);
|
||||||
|
|
||||||
|
const ponds = await api().get('/api/v1/ponds').set('Cookie', adminCookie).expect(200);
|
||||||
|
pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Shared DB: leave no settings key behind for other suites.
|
||||||
|
await prisma.instanceSetting.deleteMany({ where: { key: 'classification.newPageDefault' } });
|
||||||
|
await prisma.page.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('new pages default to unclassified and carry the field in every page representation', async () => {
|
||||||
|
const created = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ title: `Open Notes ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
expect(created.body.classification).toBe('unclassified');
|
||||||
|
|
||||||
|
const fetched = await api()
|
||||||
|
.get(`/api/v1/pages/${created.body.id}`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(fetched.body.classification).toBe('unclassified');
|
||||||
|
|
||||||
|
const list = await api()
|
||||||
|
.get(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
const listed = list.body.find((p: { id: string }) => p.id === created.body.id);
|
||||||
|
expect(listed.classification).toBe('unclassified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the instance setting supplies the default for newly created pages', async () => {
|
||||||
|
await api()
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ 'classification.newPageDefault': 'vs_nfd' })
|
||||||
|
.expect(200);
|
||||||
|
try {
|
||||||
|
const created = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ title: `Classified Notes ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
expect(created.body.classification).toBe('vs_nfd');
|
||||||
|
} finally {
|
||||||
|
await api()
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ 'classification.newPageDefault': 'unclassified' })
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classification changes no permission decision (ADR 0022: marking, not protection)', async () => {
|
||||||
|
const open = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ title: `Perm Open ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const classified = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ title: `Perm Classified ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await prisma.page.update({
|
||||||
|
where: { id: classified.body.id },
|
||||||
|
data: { classification: 'VS_NFD' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The owner reads and writes a classified page exactly like an open one —
|
||||||
|
// no extra capability appears on the read/write path.
|
||||||
|
await api().get(`/api/v1/pages/${classified.body.id}`).set('Cookie', adminCookie).expect(200);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/pages/${classified.body.id}`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ title: `Perm Classified 2 ${suffix}` })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// And a stranger is denied identically for both (404, existence hidden) —
|
||||||
|
// the marking neither grants nor removes access.
|
||||||
|
await api().get(`/api/v1/pages/${open.body.id}`).set('Cookie', outsiderCookie).expect(404);
|
||||||
|
await api()
|
||||||
|
.get(`/api/v1/pages/${classified.body.id}`)
|
||||||
|
.set('Cookie', outsiderCookie)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -39,6 +39,7 @@ import {
|
|||||||
RequiresPagePermission,
|
RequiresPagePermission,
|
||||||
RequiresPondRole,
|
RequiresPondRole,
|
||||||
} from '../permissions/permission.decorators';
|
} from '../permissions/permission.decorators';
|
||||||
|
import { readActorOf } from '../read-trail/read-actor';
|
||||||
import { PagesService } from './pages.service';
|
import { PagesService } from './pages.service';
|
||||||
|
|
||||||
/** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */
|
/** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */
|
||||||
@ -75,7 +76,7 @@ export class PagesController {
|
|||||||
@Get('pages/:id')
|
@Get('pages/:id')
|
||||||
@RequiresPagePermission('read', { idParam: 'id' })
|
@RequiresPagePermission('read', { idParam: 'id' })
|
||||||
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
|
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
|
||||||
return this.pages.getState(request.user!, id);
|
return this.pages.getState(request.user!, id, readActorOf(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggles one task-list checkbox (issue #153). Applied asynchronously
|
/** Toggles one task-list checkbox (issue #153). Applied asynchronously
|
||||||
@ -106,7 +107,7 @@ export class PagesController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
): Promise<CollabTokenResponse> {
|
): Promise<CollabTokenResponse> {
|
||||||
return this.pages.issueCollabToken(request.user ?? null, id);
|
return this.pages.issueCollabToken(request.user ?? null, id, readActorOf(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Markdown export (issue #30) — downloads `<slug>.md`. */
|
/** Markdown export (issue #30) — downloads `<slug>.md`. */
|
||||||
@ -117,7 +118,11 @@ export class PagesController {
|
|||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id);
|
const { slug, markdown } = await this.pages.exportMarkdown(
|
||||||
|
request.user!,
|
||||||
|
id,
|
||||||
|
readActorOf(request),
|
||||||
|
);
|
||||||
response.set('Content-Type', 'text/markdown; charset=utf-8');
|
response.set('Content-Type', 'text/markdown; charset=utf-8');
|
||||||
response.set('Content-Disposition', `attachment; filename="${slug}.md"`);
|
response.set('Content-Disposition', `attachment; filename="${slug}.md"`);
|
||||||
return markdown;
|
return markdown;
|
||||||
@ -133,7 +138,7 @@ export class PagesController {
|
|||||||
@Param('slug') slug: string,
|
@Param('slug') slug: string,
|
||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
): Promise<PageStateView> {
|
): Promise<PageStateView> {
|
||||||
return this.pages.getStateBySlug(request.user!, pondId, slug);
|
return this.pages.getStateBySlug(request.user!, pondId, slug, readActorOf(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
|
||||||
import { WatchesModule } from '../watches/watches.module';
|
import { WatchesModule } from '../watches/watches.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
|
|
||||||
@ -10,7 +9,7 @@ import { PluginApiController } from './plugin-api.controller';
|
|||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PondsModule, SearchModule, WatchesModule],
|
imports: [SearchModule, WatchesModule],
|
||||||
controllers: [PagesController, PluginApiController],
|
controllers: [PagesController, PluginApiController],
|
||||||
providers: [PagesService, TasksService],
|
providers: [PagesService, TasksService],
|
||||||
exports: [PagesService, TasksService],
|
exports: [PagesService, TasksService],
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
CreatePageInput,
|
CreatePageInput,
|
||||||
MAX_PAGE_DEPTH,
|
MAX_PAGE_DEPTH,
|
||||||
OutlineEntry,
|
OutlineEntry,
|
||||||
|
PageClassification,
|
||||||
PageDeleteMode,
|
PageDeleteMode,
|
||||||
PageListItemView,
|
PageListItemView,
|
||||||
PageListQuery,
|
PageListQuery,
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
TaskToggleRequest,
|
TaskToggleRequest,
|
||||||
TreeItem,
|
TreeItem,
|
||||||
UpdatePageInput,
|
UpdatePageInput,
|
||||||
|
classificationRank,
|
||||||
collectSubtreeIds,
|
collectSubtreeIds,
|
||||||
nodeDepth,
|
nodeDepth,
|
||||||
pondSettingsSchema,
|
pondSettingsSchema,
|
||||||
@ -32,9 +34,16 @@ import { Page, Prisma, User } from '@prisma/client';
|
|||||||
import { generateKeyBetween } from 'fractional-indexing';
|
import { generateKeyBetween } from 'fractional-indexing';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
ReadTrailService,
|
||||||
|
type ReadActor,
|
||||||
|
type ReadChannel,
|
||||||
|
} from '../read-trail/read-trail.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
import { WatchesService } from '../watches/watches.service';
|
import { WatchesService } from '../watches/watches.service';
|
||||||
import { SearchProvider } from '../search/search.provider';
|
import { SearchProvider } from '../search/search.provider';
|
||||||
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
||||||
@ -56,6 +65,15 @@ function contentCacheData(
|
|||||||
* (realtime-collaboration.md). 60 s is the ceiling the story specifies. */
|
* (realtime-collaboration.md). 60 s is the ceiling the story specifies. */
|
||||||
const COLLAB_TOKEN_TTL_SECONDS = 60;
|
const COLLAB_TOKEN_TTL_SECONDS = 60;
|
||||||
|
|
||||||
|
/** DB ↔ API spelling of the classification enum (ADR 0022): Prisma stores
|
||||||
|
* SCREAMING_SNAKE, every representation outside the schema is lowercase. */
|
||||||
|
function toDbClassification(value: PageClassification): Page['classification'] {
|
||||||
|
return value === 'vs_nfd' ? 'VS_NFD' : 'UNCLASSIFIED';
|
||||||
|
}
|
||||||
|
function fromDbClassification(value: Page['classification']): PageClassification {
|
||||||
|
return value.toLowerCase() as PageClassification;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PagesService {
|
export class PagesService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -65,10 +83,31 @@ export class PagesService {
|
|||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly search: SearchProvider,
|
private readonly search: SearchProvider,
|
||||||
private readonly watches: WatchesService,
|
private readonly watches: WatchesService,
|
||||||
|
private readonly settings: InstanceSettingsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly readTrail: ReadTrailService,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(PagesService.name);
|
this.logger.setContext(PagesService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Records the read-trail event for a classified page (issue #222) — a
|
||||||
|
* no-op for unclassified pages, a hard failure when recording fails. */
|
||||||
|
private async recordRead(
|
||||||
|
page: Pick<Page, 'id' | 'pondId' | 'classification'>,
|
||||||
|
read: ReadActor,
|
||||||
|
channel: ReadChannel,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (page.classification !== 'VS_NFD') return;
|
||||||
|
await this.readTrail.record({
|
||||||
|
...read,
|
||||||
|
pageId: page.id,
|
||||||
|
pondId: page.pondId,
|
||||||
|
channel,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
viewOf(page: Page): PageView {
|
viewOf(page: Page): PageView {
|
||||||
return {
|
return {
|
||||||
id: page.id,
|
id: page.id,
|
||||||
@ -77,6 +116,7 @@ export class PagesService {
|
|||||||
title: page.title,
|
title: page.title,
|
||||||
slug: page.slug,
|
slug: page.slug,
|
||||||
sortKey: page.sortKey,
|
sortKey: page.sortKey,
|
||||||
|
classification: fromDbClassification(page.classification),
|
||||||
createdAt: page.createdAt.toISOString(),
|
createdAt: page.createdAt.toISOString(),
|
||||||
updatedAt: page.updatedAt.toISOString(),
|
updatedAt: page.updatedAt.toISOString(),
|
||||||
deletedAt: page.deletedAt?.toISOString() ?? null,
|
deletedAt: page.deletedAt?.toISOString() ?? null,
|
||||||
@ -258,8 +298,9 @@ export class PagesService {
|
|||||||
state: Uint8Array<ArrayBuffer>,
|
state: Uint8Array<ArrayBuffer>,
|
||||||
parentId: string | null = null,
|
parentId: string | null = null,
|
||||||
presetSlug?: string,
|
presetSlug?: string,
|
||||||
|
atLeastClassification: PageClassification | null = null,
|
||||||
): Promise<Page> {
|
): Promise<Page> {
|
||||||
return this.insertPage(user, pondId, title, state, parentId, presetSlug);
|
return this.insertPage(user, pondId, title, state, parentId, presetSlug, atLeastClassification);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -284,6 +325,7 @@ export class PagesService {
|
|||||||
state: Uint8Array<ArrayBuffer>,
|
state: Uint8Array<ArrayBuffer>,
|
||||||
parentId: string | null = null,
|
parentId: string | null = null,
|
||||||
presetSlug?: string,
|
presetSlug?: string,
|
||||||
|
atLeastClassification: PageClassification | null = null,
|
||||||
): Promise<Page> {
|
): Promise<Page> {
|
||||||
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();
|
||||||
@ -299,6 +341,30 @@ export class PagesService {
|
|||||||
});
|
});
|
||||||
const sortKey = generateKeyBetween(last?.sortKey ?? null, null);
|
const sortKey = generateKeyBetween(last?.sortKey ?? null, null);
|
||||||
const content = deriveContent(state);
|
const content = deriveContent(state);
|
||||||
|
// A new page starts at the instance-wide default level (ADR 0022, #204),
|
||||||
|
// raised to its parent's level when that is higher (#205) — and to an
|
||||||
|
// imported document's own level (#210): content must not escape its
|
||||||
|
// marking by traveling through an export/import. A subpage of classified
|
||||||
|
// content must never begin unmarked.
|
||||||
|
let classification: PageClassification = await this.settings.get(
|
||||||
|
'classification.newPageDefault',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
atLeastClassification &&
|
||||||
|
classificationRank(atLeastClassification) > classificationRank(classification)
|
||||||
|
) {
|
||||||
|
classification = atLeastClassification;
|
||||||
|
}
|
||||||
|
if (parentId) {
|
||||||
|
const parent = await this.prisma.page.findUniqueOrThrow({
|
||||||
|
where: { id: parentId },
|
||||||
|
select: { classification: true },
|
||||||
|
});
|
||||||
|
const parentLevel = fromDbClassification(parent.classification);
|
||||||
|
if (classificationRank(parentLevel) > classificationRank(classification)) {
|
||||||
|
classification = parentLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const page = await this.prisma.page.create({
|
const page = await this.prisma.page.create({
|
||||||
data: {
|
data: {
|
||||||
@ -307,6 +373,7 @@ export class PagesService {
|
|||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
sortKey,
|
sortKey,
|
||||||
|
classification: toDbClassification(classification),
|
||||||
ydocState: state,
|
ydocState: state,
|
||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
contentCache: { create: contentCacheData(content) },
|
contentCache: { create: contentCacheData(content) },
|
||||||
@ -357,8 +424,9 @@ export class PagesService {
|
|||||||
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
|
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getState(_user: User, id: string): Promise<PageStateView> {
|
async getState(_user: User, id: string, read: ReadActor): Promise<PageStateView> {
|
||||||
const page = await this.findLivePage(id);
|
const page = await this.findLivePage(id);
|
||||||
|
await this.recordRead(page, read, 'page_view');
|
||||||
return this.stateViewOf(page);
|
return this.stateViewOf(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -372,12 +440,21 @@ export class PagesService {
|
|||||||
* guard has already granted read access via a `public` grant, so they receive
|
* guard has already granted read access via a `public` grant, so they receive
|
||||||
* an `ro` token with a `null` subject.
|
* an `ro` token with a `null` subject.
|
||||||
*/
|
*/
|
||||||
async issueCollabToken(user: User | null, id: string): Promise<CollabTokenResponse> {
|
async issueCollabToken(
|
||||||
|
user: User | null,
|
||||||
|
id: string,
|
||||||
|
read: ReadActor,
|
||||||
|
): Promise<CollabTokenResponse> {
|
||||||
const page = await this.findLivePage(id);
|
const page = await this.findLivePage(id);
|
||||||
|
|
||||||
const canWrite = await this.permissions.canAccessPage(user, page, 'write');
|
const canWrite = await this.permissions.canAccessPage(user, page, 'write');
|
||||||
const mode = canWrite ? 'rw' : 'ro';
|
const mode = canWrite ? 'rw' : 'ro';
|
||||||
const userId = user?.id ?? null;
|
const userId = user?.id ?? null;
|
||||||
|
// Token issuance is the api-side proxy for the collab WS join (ADR 0023):
|
||||||
|
// the collab server has no permission context, and the 60 s token TTL
|
||||||
|
// makes a live session re-request one per minute — per-minute granularity
|
||||||
|
// the dedup window (#223) then collapses.
|
||||||
|
await this.recordRead(page, read, 'collab_join', { mode });
|
||||||
const token = await signCollabToken(
|
const token = await signCollabToken(
|
||||||
{ userId, pageId: page.id, mode },
|
{ userId, pageId: page.id, mode },
|
||||||
this.config.env.COLLAB_TOKEN_SECRET,
|
this.config.env.COLLAB_TOKEN_SECRET,
|
||||||
@ -389,13 +466,19 @@ export class PagesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The trashed-page hint for editors (issue #31) moved into the guard. */
|
/** The trashed-page hint for editors (issue #31) moved into the guard. */
|
||||||
async getStateBySlug(_user: User, pondId: string, slug: string): Promise<PageStateView> {
|
async getStateBySlug(
|
||||||
|
_user: User,
|
||||||
|
pondId: string,
|
||||||
|
slug: string,
|
||||||
|
read: ReadActor,
|
||||||
|
): Promise<PageStateView> {
|
||||||
const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } });
|
const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } });
|
||||||
if (!page) throw new NotFoundException();
|
if (!page) throw new NotFoundException();
|
||||||
|
await this.recordRead(page, read, 'page_view');
|
||||||
return this.stateViewOf(page);
|
return this.stateViewOf(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(_user: User, id: string, input: UpdatePageInput): Promise<PageView> {
|
async update(user: User, id: string, input: UpdatePageInput): Promise<PageView> {
|
||||||
const page = await this.findLivePage(id);
|
const page = await this.findLivePage(id);
|
||||||
|
|
||||||
let slug = page.slug;
|
let slug = page.slug;
|
||||||
@ -411,10 +494,36 @@ export class PagesService {
|
|||||||
slug = normalized;
|
slug = normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Classification change (#205, ADR 0022): raising is ordinary editorial
|
||||||
|
// work (the write guard has run); LOWERING is the sensitive direction and
|
||||||
|
// needs the dedicated capability from the central permission model.
|
||||||
|
// Both directions are audited with old value, new value, actor and page.
|
||||||
|
const currentLevel = fromDbClassification(page.classification);
|
||||||
|
const targetLevel = input.classification;
|
||||||
|
const levelChanges = targetLevel !== undefined && targetLevel !== currentLevel;
|
||||||
|
if (levelChanges && classificationRank(targetLevel) < classificationRank(currentLevel)) {
|
||||||
|
const mayLower = await this.permissions.canLowerClassification(user, page.pondId);
|
||||||
|
if (!mayLower) throw new ForbiddenException({ code: 'classification_lower_forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.page.update({
|
const updated = await this.prisma.page.update({
|
||||||
where: { id: page.id },
|
where: { id: page.id },
|
||||||
data: { title: input.title, slug },
|
data: {
|
||||||
|
title: input.title,
|
||||||
|
slug,
|
||||||
|
...(levelChanges ? { classification: toDbClassification(targetLevel) } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
if (levelChanges) {
|
||||||
|
const raised = classificationRank(targetLevel) > classificationRank(currentLevel);
|
||||||
|
await this.audit.record({
|
||||||
|
action: raised ? 'page.classification_raised' : 'page.classification_lowered',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'page',
|
||||||
|
targetId: page.id,
|
||||||
|
details: { from: currentLevel, to: targetLevel, trigger: 'edit', pondId: page.pondId },
|
||||||
|
});
|
||||||
|
}
|
||||||
// Renaming to a slug pages already link to resolves those phantom links (#47).
|
// Renaming to a slug pages already link to resolves those phantom links (#47).
|
||||||
if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id);
|
if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id);
|
||||||
// A changed title changes the (weighted) search entry (#49).
|
// A changed title changes the (weighted) search entry (#49).
|
||||||
@ -436,7 +545,7 @@ export class PagesService {
|
|||||||
* Requires write access; the sort mode does not have to be `manual` (the key
|
* Requires write access; the sort mode does not have to be `manual` (the key
|
||||||
* is stored regardless, just not applied in other modes).
|
* is stored regardless, just not applied in other modes).
|
||||||
*/
|
*/
|
||||||
async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
async reposition(user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
||||||
const page = await this.findLivePage(id);
|
const page = await this.findLivePage(id);
|
||||||
const { afterId, beforeId } = input;
|
const { afterId, beforeId } = input;
|
||||||
if (afterId === id || beforeId === id) {
|
if (afterId === id || beforeId === id) {
|
||||||
@ -446,8 +555,22 @@ export class PagesService {
|
|||||||
// An absent `parentId` leaves the parent untouched; a present one (page id
|
// An absent `parentId` leaves the parent untouched; a present one (page id
|
||||||
// or null-for-root) reparents atomically with the placement (issue #106).
|
// or null-for-root) reparents atomically with the placement (issue #106).
|
||||||
const parentId = input.parentId === page.parentId ? undefined : input.parentId;
|
const parentId = input.parentId === page.parentId ? undefined : input.parentId;
|
||||||
|
// Moving under a higher-classified parent raises the moved subtree to
|
||||||
|
// that level (#205, ADR 0022) — never the other way around: a move can
|
||||||
|
// raise but must not lower as a side effect. Plan before placing (the
|
||||||
|
// tree walk needs the pre-move tree), apply after.
|
||||||
|
let raisePlan: { subtreeIds: string[]; to: PageClassification } | null = null;
|
||||||
if (parentId != null) {
|
if (parentId != null) {
|
||||||
this.assertValidParent(await this.livePageTree(page.pondId), parentId, id);
|
const tree = await this.livePageTree(page.pondId);
|
||||||
|
this.assertValidParent(tree, parentId, id);
|
||||||
|
const parent = await this.prisma.page.findUniqueOrThrow({
|
||||||
|
where: { id: parentId },
|
||||||
|
select: { classification: true },
|
||||||
|
});
|
||||||
|
const parentLevel = fromDbClassification(parent.classification);
|
||||||
|
if (classificationRank(parentLevel) > 0) {
|
||||||
|
raisePlan = { subtreeIds: [...collectSubtreeIds(tree, id)], to: parentLevel };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [afterPage, beforePage] = await Promise.all([
|
const [afterPage, beforePage] = await Promise.all([
|
||||||
@ -468,14 +591,59 @@ export class PagesService {
|
|||||||
if (beforeId && !beforePage) throw new NotFoundException();
|
if (beforeId && !beforePage) throw new NotFoundException();
|
||||||
|
|
||||||
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
|
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
|
||||||
|
let placed: PageView;
|
||||||
if (key !== null) {
|
if (key !== null) {
|
||||||
const updated = await this.prisma.page.update({
|
const updated = await this.prisma.page.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { sortKey: key, parentId },
|
data: { sortKey: key, parentId },
|
||||||
});
|
});
|
||||||
return this.viewOf(updated);
|
placed = this.viewOf(updated);
|
||||||
|
} else {
|
||||||
|
placed = await this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId);
|
||||||
|
}
|
||||||
|
if (raisePlan) {
|
||||||
|
await this.raiseSubtree(user, page.pondId, raisePlan.subtreeIds, raisePlan.to);
|
||||||
|
return this.viewOf(await this.prisma.page.findUniqueOrThrow({ where: { id } }));
|
||||||
|
}
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raise every page of a moved subtree that sits below `to` (#205). The
|
||||||
|
* whole subtree is covered, not just its root: descendants must not end
|
||||||
|
* up below the level of the tree above them. With the current two-level
|
||||||
|
* vocabulary "below `to`" is exactly `UNCLASSIFIED` rows. Every raised
|
||||||
|
* page gets its own audit record (old value, new value, actor, page).
|
||||||
|
*/
|
||||||
|
private async raiseSubtree(
|
||||||
|
user: User,
|
||||||
|
pondId: string,
|
||||||
|
subtreeIds: string[],
|
||||||
|
to: PageClassification,
|
||||||
|
): Promise<void> {
|
||||||
|
const below = await this.prisma.page.findMany({
|
||||||
|
where: { id: { in: subtreeIds }, classification: { not: toDbClassification(to) } },
|
||||||
|
select: { id: true, classification: true },
|
||||||
|
});
|
||||||
|
if (below.length === 0) return;
|
||||||
|
await this.prisma.page.updateMany({
|
||||||
|
where: { id: { in: below.map((p) => p.id) } },
|
||||||
|
data: { classification: toDbClassification(to) },
|
||||||
|
});
|
||||||
|
for (const raised of below) {
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'page.classification_raised',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'page',
|
||||||
|
targetId: raised.id,
|
||||||
|
details: {
|
||||||
|
from: fromDbClassification(raised.classification),
|
||||||
|
to,
|
||||||
|
trigger: 'move',
|
||||||
|
pondId,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -520,8 +688,16 @@ export class PagesService {
|
|||||||
* `page_content_cache.markdown` (refreshed on every state save, #23)
|
* `page_content_cache.markdown` (refreshed on every state save, #23)
|
||||||
* rather than re-decoding the Yjs state, so export always matches what
|
* rather than re-decoding the Yjs state, so export always matches what
|
||||||
* the app itself considers the page's current Markdown representation. */
|
* the app itself considers the page's current Markdown representation. */
|
||||||
async exportMarkdown(_user: User, id: string): Promise<{ slug: string; markdown: string }> {
|
async exportMarkdown(
|
||||||
|
_user: User,
|
||||||
|
id: string,
|
||||||
|
read: ReadActor,
|
||||||
|
// The plugin content route reuses this markdown path but is a page view
|
||||||
|
// in the trail's terms (issue #222), not a download.
|
||||||
|
channel: ReadChannel = 'export',
|
||||||
|
): Promise<{ slug: string; markdown: string }> {
|
||||||
const page = await this.findLivePage(id);
|
const page = await this.findLivePage(id);
|
||||||
|
await this.recordRead(page, read, channel);
|
||||||
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
||||||
return { slug: page.slug, markdown: cache?.markdown ?? '' };
|
return { slug: page.slug, markdown: cache?.markdown ?? '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { PluginPageContent, PluginPageMeta, PluginPageSummary } from '@dorf
|
|||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||||
|
import { readActorOf } from '../read-trail/read-actor';
|
||||||
|
|
||||||
import { PagesService } from './pages.service';
|
import { PagesService } from './pages.service';
|
||||||
|
|
||||||
@ -45,7 +46,12 @@ export class PluginApiController {
|
|||||||
@Param('pageId') pageId: string,
|
@Param('pageId') pageId: string,
|
||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
): Promise<PluginPageContent> {
|
): Promise<PluginPageContent> {
|
||||||
const { markdown } = await this.pages.exportMarkdown(request.user!, pageId);
|
const { markdown } = await this.pages.exportMarkdown(
|
||||||
|
request.user!,
|
||||||
|
pageId,
|
||||||
|
readActorOf(request),
|
||||||
|
'page_view',
|
||||||
|
);
|
||||||
return { markdown };
|
return { markdown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,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, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,6 +23,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
const userIds: Record<string, string> = {};
|
const userIds: Record<string, string> = {};
|
||||||
const cookies: Record<string, string> = {};
|
const cookies: Record<string, string> = {};
|
||||||
let pondId: string;
|
let pondId: string;
|
||||||
|
let startPageId: string;
|
||||||
let openPageId: string;
|
let openPageId: string;
|
||||||
let secretPageId: string;
|
let secretPageId: string;
|
||||||
|
|
||||||
@ -71,6 +72,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
.send({ name: `PlugApi Pond ${suffix}` })
|
.send({ name: `PlugApi Pond ${suffix}` })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
pondId = pond.body.id;
|
pondId = pond.body.id;
|
||||||
|
// Every pond created through the api starts with a page (issue #302);
|
||||||
|
// a "full reader sees everything" assertion has to include it rather
|
||||||
|
// than pretend the pond began empty.
|
||||||
|
startPageId = pond.body.settings.startPageId as string;
|
||||||
|
|
||||||
const open = await api()
|
const open = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
@ -128,10 +133,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
|
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.page.deleteMany({ where: { pondId } });
|
await prisma.page.deleteMany({ where: { pondId } });
|
||||||
await prisma.pond.deleteMany({ where: { id: pondId } });
|
await deletePondsWhere(prisma, { id: pondId });
|
||||||
const ids = Object.values(userIds);
|
const ids = Object.values(userIds);
|
||||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
||||||
await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
await deletePondsWhere(prisma, { ownerId: { in: ids } });
|
||||||
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
@ -144,7 +149,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
.set('Cookie', cookies.owner!)
|
.set('Cookie', cookies.owner!)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(
|
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(
|
||||||
[openPageId, secretPageId].sort(),
|
[startPageId, openPageId, secretPageId].sort(),
|
||||||
);
|
);
|
||||||
expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) });
|
expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) });
|
||||||
// Label *names* travel with each summary (issue #77, page-index filter).
|
// Label *names* travel with each summary (issue #77, page-index filter).
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user