Compare commits

..

39 Commits

Author SHA1 Message Date
cc9c70287c Ship third-party license texts in plugin ZIPs (#345)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m51s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Successful in 9m28s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m57s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m7s
CI / Import/export fidelity gate (push) Successful in 57s
Restore drill / Restore the latest backup into a scratch stack (push) Failing after 17s
The drawio, excalidraw, and mermaid plugin packages redistribute
third-party material (the draw.io webapp, the Excalidraw editor and its
fonts, mermaid and its dependency tree) without the license texts their
licenses require. Every affected ZIP now carries a licenses/ directory:

- licenses/THIRD-PARTY-NOTICES.txt is generated from the esbuild
  metafile (packages/plugins/third-party-licenses.mjs), so the notice
  list is derived from what actually lands in plugin.js and cannot
  drift the way a hand-maintained list would.
- drawio additionally extracts the upstream LICENSE from the pinned
  release tarball (Apache-2.0 requires the text with redistribution);
  the extraction guard also heals vendor/ caches from before this
  change. The CI fast path (no vendor fetch, no ZIP) is unchanged.
- excalidraw additionally commits curated texts (MIT for Excalidraw,
  per-font OFL-1.1/MIT with each font's own copyright statement, plus
  a FONT-NOTICES.md attribution table), because neither the npm
  package nor upstream ships any license files for them.

The api-side package validator accepts additional ZIP entries, so
installed plugins are unaffected beyond the new files.

Closes #345

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-16 19:00:26 +02:00
f142289813 Recognize Markdown tables on paste and while typing (#339)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m40s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 9m30s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 6m55s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m19s
CI / Import/export fidelity gate (push) Successful in 57s
Release / Build release images and notes (push) Successful in 2m44s
Release / Release-candidate operations QA (push) Successful in 42s
Prod deploy / Deploy the released images to Prod (push) Successful in 15s
The paste conversion (issue #30) already handled tables, but any
text/html flavor on the clipboard bypassed it. Code editors (VS Code
with copyWithSyntaxHighlighting) ship the plain text a second time as
styled div/span HTML, so a Markdown table copied there arrived verbatim
while the same text from a plain editor converted fine. Clipboard HTML
without a single structural element (table/list/heading/link/emphasis/
code...) is now treated as equivalent to the plain text; anything from a
rich-text source keeps going through ProseMirror's HTML paste.

Pasting inside a code block never converts anymore -- text is code
there, and the conversion would have split the block around rich nodes.

Hand-typed tables: pressing Enter at the end of a GFM separator row
whose previous sibling is a pipe row replaces the two paragraphs with a
real table (input rules cannot express this -- they see only one
textblock). Conversion is refused inside existing tables; body rows are
then typed cell-wise, with Tab appending rows (#338).

Closes #339

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:32:25 +02:00
c17ab41a33 Word-style Tab navigation in tables with an accessible exit (#338)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m39s
CI / Build container images (pull_request) Successful in 4m32s
CI / Auth e2e pack (pull_request) Successful in 10m11s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Successful in 24s
CD / Smoke tests against Test (push) Has been cancelled
CD / Promote to Int (push) Has been skipped
CD / Deploy to Test (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Tab used to fall through to the browser's focus navigation everywhere.
Inside tables it now moves cell-wise (Shift-Tab backwards) and appends a
new row from the last cell, Word-style. Outside tables every branch
returns false, so Tab keeps leaving the editor.

Capturing Tab inside tables needs a documented way out (WCAG 2.1.2):
Escape places the cursor after the table -- unlike the arrow keys, which
reach the gap cursor (#335) only from the table's edge cells, it works
from every cell, including from a cell selection. When no textblock
follows the table it falls back to the gap cursor position. The
mechanism is announced to assistive tech via an aria-describedby hint
on the editor surface (visually hidden, de+en).

e2e: cell round trip per Tab/Shift-Tab with typed markers, row append
from the last cell, and the full keyboard-only exit (Escape, then Tab
leaves the editor). The table specs now settle briefly after the insert
-- right after it the collab sync can swallow a click's selection
update, which had the markers landing in stale selections.

Closes #338

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:32:24 +02:00
3bf9363c34 Merge and split table cells (#337)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m18s
CI / Build container images (pull_request) Successful in 4m41s
CI / Auth e2e pack (pull_request) Successful in 10m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Successful in 33s
CI / Lint, typecheck, test (push) Has been cancelled
prosemirror-tables already ships mergeCells/splitCell and the schema
(tableNodes) already carries colspan/rowspan -- only the controls were
missing. Adds the two commands, toolbar buttons whose enabled state
follows the selection (merge needs a multi-cell selection, split a
merged cell), and de+en labels.

Both render paths now carry the spans: docToHtml emits colspan/rowspan
(read mode, exports via the HTML path), and the markdown serializer pads
a colspan with empty cells so every row keeps the table's column count
-- rowspan stays lossy there, GFM cannot express it.

e2e drives merge and split through the toolbar; the cell selection is
made per Shift+Click because a keypress in the same tick as the
preceding click races the editor's post-click rendering (keyboard cell
selection itself works, verified interactively with a settled editor).

Closes #337

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:32:14 +02:00
b1165a37e6 Unambiguous delete row/column toolbar icons (#336)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m52s
CI / Build container images (pull_request) Successful in 1m12s
CI / Auth e2e pack (pull_request) Successful in 9m24s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
The delete buttons paired the minus-box with a double arrow (bidirectional
arrows next to the symbol) which reads as "resize/expand", not "delete".
Replace them with axis stripes plus the x delete marker that deleteTable
already established: vertical stripes with x for delete column, horizontal
stripes with x for delete row. Labels/tooltips were correct all along and
stay unchanged.

Closes #336

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 20:44:37 +02:00
69563348ca Gap cursor for block-edge positions (#335)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m52s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Successful in 9m25s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
A table (or any other block node without a text position of its own) as
the page's first, last, or only block was unreachable from before/after:
neither mouse nor arrow keys could place the cursor there, so no
paragraph could be created around it.

- add the prosemirror-gapcursor plugin as a TipTap extension (via
  @tiptap/pm, no new dependency; schema-neutral, so the editorSchema
  drift fence is unaffected)
- style the gap cursor bar in base.css -- the upstream package does not
  ship its stylesheet through our import path; the blink animation
  honors prefers-reduced-motion
- e2e: keyboard-only round trip that creates paragraphs before and
  after a lone table

Closes #335

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 20:41:23 +02:00
7e17a2dba6 settings-nav fence: 10 sections since the invitations section (#332)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m52s
CI / Auth e2e pack (pull_request) Successful in 9m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Build container images (pull_request) Successful in 1m14s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m26s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 7m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m19s
CI / Import/export fidelity gate (push) Successful in 59s
Release / Build release images and notes (push) Successful in 2m54s
Release / Release-candidate operations QA (push) Successful in 49s
Prod deploy / Deploy the released images to Prod (push) Successful in 19s
2026-08-05 13:06:32 +02:00
c2a4dde5cc Invitation flow with per-user quota (#332)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m50s
CI / Build container images (pull_request) Successful in 3m57s
CI / Auth e2e pack (pull_request) Failing after 6m18s
CI / Import/export fidelity gate (pull_request) Has been skipped
Any authenticated user can invite an e-mail address; the mailed
single-use token lets exactly one signup through even while
registration is closed. Open (pending, unexpired) invitations count
against the new instance setting invitations.maxOpenPerUser (default 5,
0 disables inviting) — plus a 20/day per-user rate limit so a
revoke-and-recreate loop cannot become a mail cannon. Only the SHA-256
token hash is stored (auth-tokens pattern); a failed signup (taken
username) un-redeems the token so the invitee can retry.

Surfaces: invitations section in the user settings (list, invite,
revoke, quota line; wide table in a focusable .table-scroll region),
signup page reads ?invitation=<token> (preview banner, e-mail prefill,
closed-mode gate opens only for a previewed-valid token), admin general
card gets the quota field (flat RHF name per #322; VS-NfD marked and
hideable).

Governance: audit actions invitation.created/revoked/accepted
(catalogue 1.10), VS-NfD profile entry (compliant: 0) + hardening-guide
row, i18n de+en including the invitation mail template.

Tests: api e2e-db (mail link, closed-mode single-use signup with
un-redeem on failure, quota + revoke frees slot, quota 0 = 403, auth
matrix), new web e2e pack invitations.spec.ts (full UI loop through
Mailpit, wired into ci.yml with its own rate-limit reset), a11y scan
waits for the new section. Full api suite (107 files / 607 tests),
auth/admin-settings/a11y packs green against a fresh local stack.

Closes #332
2026-08-05 12:44:20 +02:00
9cf7b85b93 Admin can create user accounts directly (#331)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m47s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
POST /admin/users (Site-Admin guard) creates an account with the same
field rules as self-registration, but active immediately: the admin
vouches for the address, so the e-mail is marked verified and the
personal pond is provisioned exactly like the verify-email path does
(markEmailVerified alone would skip the pond).

The user manager gains a create dialog (useModalFocus/useDismissable,
Field wiring, flat RHF field names per the #322 lesson). New audit
action user.created_by_admin, catalogue bumped to 1.9.

Tests: api e2e-db (create + immediate login + personal pond, duplicate
username 409, non-admin 403), web e2e through the dialog, and the
admin a11y scan now opens the dialog too. Both packs verified locally
against a fresh stack.

Closes #331
2026-08-05 12:23:01 +02:00
64f2deb40f Quota override cell stays a table cell, flex on an inner wrapper (#329)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m54s
CI / Build container images (pull_request) Successful in 1m25s
CI / Auth e2e pack (pull_request) Successful in 9m27s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 25s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m53s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m0s
CI / Import/export fidelity gate (push) Successful in 59s
Same defect and same fix as the user list's actions cell (#177):
display:flex directly on the override td removed its table-cell
behaviour, so the cell stopped growing to row height and its bottom
border no longer met the row's — visibly uneven separator lines
(Stefan's screenshot from the self-hosting walkthrough). The flex
layout now lives on .quota-row__override-inner.

Measured locally like #177: bottom-delta across all cells of every
quota row was 24–49 px before, 0 px after (override set, so the cell
carries input + two link buttons); admin-quotas e2e pack green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 12:44:17 +02:00
20677ea247 Self-hosting findings: URL-safe password advice, operator-readable pre-seed errors (#324, #325)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m58s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 9m12s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
CD / Promote to Int (push) Blocked by required conditions
Two findings from Stefan's manual clean install per the guide, both
ending in an api restart loop that was hard to diagnose:

- #324: the guide recommended `openssl rand -base64 32` for
  POSTGRES_PASSWORD, but the compose interpolates the password unescaped
  into DATABASE_URL — base64's `/`, `+`, `=` break the URL. Misleadingly,
  db stays healthy (it gets the password as a plain env var) while
  api/collab/backup crash. Guide and .env.example now recommend
  `openssl rand -hex 24` for both secrets and say why; Troubleshooting
  gained the symptom line.
- #325: SETUP_ADMIN_PASSWORD's minimum (10 chars,
  packages/shared/src/auth.ts) was undocumented, and a violation crashed
  the boot with a raw ZodError naming schema fields and i18n keys.
  Failing the boot stays — deliberately, no half-seeded instance — but
  preseedFromEnv now translates validation errors into operator terms
  ("Pre-seeding failed: SETUP_ADMIN_PASSWORD must be at least 10
  characters. Fix .env and recreate the api container."). Documented in
  the guide's first-run section, .env.example, and Troubleshooting; new
  test pins the message and that nothing is half-seeded afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 12:44:16 +02:00
6999b3dd73 Document title follows the configured instance name (#323)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m58s
CI / Build container images (pull_request) Successful in 1m26s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CD / Build and push images (push) Successful in 13s
CD / Deploy to Test (push) Successful in 14s
CI / Lint, typecheck, test (push) Successful in 7m31s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m24s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 9m25s
CI / Import/export fidelity gate (push) Successful in 55s
useDocumentTitle pinned APP_NAME = 'Dorfteich', so every route title —
tab, bookmarks, the window title a screen reader announces (WCAG 2.4.2)
— named the product instead of the operator's instance. The trailing
name now comes from the public branding query, exactly like the TopBar
brand (#306); until the query resolves (or when it cannot, e.g.
maintenance mode) the shipped default keeps the title stable, so an
untouched instance reads exactly as before. The static index.html title
stays the pre-JS placeholder — server-rendering it is #179's territory,
deliberately out of scope (recorded in the issue).

The admin-settings e2e now also asserts the title carries the new name
right after saving, without a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 11:43:05 +02:00
4d6a27194f Follow the field rename in vs-nfd-marking locators
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m0s
CI / Build container images (pull_request) Successful in 1m23s
CI / Auth e2e pack (pull_request) Successful in 9m0s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
The pack addresses the registration-mode select by its DOM name
attribute, which react-hook-form derives from the field name — now
`registrationMode` (dot-free, see admin-settings-form.ts). Caught by CI
run 713; the pack needs VS_NFD_MODE stages and was not part of the local
verification set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 11:42:55 +02:00
9f754649d4 Fix admin general settings form: dot-free field names, flat PATCH keys (#322)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m42s
CI / Build container images (pull_request) Successful in 1m20s
CI / Auth e2e pack (pull_request) Failing after 10m3s
CI / Import/export fidelity gate (pull_request) Has been skipped
The general and quota cards registered their react-hook-form fields under
the dotted settings keys. RHF treats dots as nested-path separators, so
the form DISPLAYED fine (its getter falls back to the literal flat key)
but typing nested the value ({ instance: { name } }) and the api's strict
PATCH schema rejected the body — none of these fields ever saved through
the UI, on any instance. Found by Stefan on a fresh self-hosted install.

- admin-settings-form.ts: dot-free form model with one explicit mapping
  to the dotted settings keys and converters in both directions; the
  submit now also carries ONLY the settings these cards edit, so the
  internal branding metadata keys never ride along.
- Saving invalidates the branding query too — the TopBar reads the
  instance name from it and kept the old name until its staleTime ran out.
- admin-settings.spec.ts (new e2e pack, registered in ci.yml): drives the
  rename THROUGH THE FORM — success message, TopBar update without
  reload, value survives reload, api returns it. Verified locally to fail
  against the unfixed page and pass against the fix. Every existing
  admin-settings test patched the api directly, which is why this bug was
  invisible to CI.
- admin-settings-form.test.ts pins that no form field name contains a dot
  and the mapping round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 11:18:26 +02:00
d2be1116bc Refresh the self-hosting guide for the first public release (#320)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m43s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Successful in 8m52s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m42s
CI / Import/export fidelity gate (push) Successful in 58s
- TAG guidance points at pinned release tags (e.g. v0.14.0) instead of
  the pre-release `test` tag; concrete curl commands fetch the three
  reference files.
- Backup wording (guide + .env.example) names all four data volumes in
  the restore set (uploads, plugins, custom fonts, branding).
- Updating section states the back-up-first step and links the update
  runbook.
- Pass the external-authentication variables (OIDC_*, AUTH_LOCAL_ENABLED,
  AUTH_PROXY_*) through the reference compose and document them in
  .env.example: they were documented in security.md but unreachable from
  .env. Empty values count as unset (app-config.service.ts), so the block
  is inert until configured.
- New guide section "External authentication (optional)"; neutral
  APP_BASE_URL example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-03 12:34:30 +02:00
78258c4f9b test: give the 10k-iteration sort-key property test its own timeout
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m45s
CI / Build container images (pull_request) Successful in 2m51s
CI / Auth e2e pack (pull_request) Successful in 8m54s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m52s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m43s
Release / Build release images and notes (push) Successful in 2m48s
CI / Import/export fidelity gate (push) Successful in 56s
Release / Release-candidate operations QA (push) Successful in 56s
Prod deploy / Deploy the released images to Prod (push) Successful in 17s
Under parallel CI load the test repeatedly exceeded the default 5000 ms
per-test timeout (run 685 on main, run 699 on an unrelated PR); the
identical test passed on rerun. Locally it finishes in about 1.3 s, so
30 s is generous headroom, not a mask for a regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-02 14:01:14 +02:00
a327126fac #307: pond-level branding overrides the instance logo and favicon
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m8s
CI / Build container images (pull_request) Successful in 4m3s
CI / Auth e2e pack (pull_request) Successful in 9m1s
CI / Import/export fidelity gate (pull_request) Successful in 1m3s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m49s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m42s
CI / Import/export fidelity gate (push) Successful in 58s
Built on #306's storage, serving and crop control — a layer, not a parallel
implementation. `resolveBranding` in shared is the ONE place that answers
"which asset applies here?", and both the sidebar logo and the favicon swap
read it.

The decision most likely to be "fixed" by accident, so it is pinned by name
in `branding.test.ts`: **a logo set belongs to one level and variants are
never mixed across levels.** A pond that uploaded only a light logo shows THAT
logo in dark mode; it does not borrow the instance's dark variant. Decided
2026-08-01 — a logo silently swapping to a different image when the viewer
switches theme is a change nobody ordered, and a design that looks wrong is
more honest than one that is quietly substituted. Only a pond with no logo at
all inherits the instance's set, again as a set. The settings screen warns
about a missing dark variant; it never blocks.

Consequences that fall out of that rule and are easy to get wrong:

- The serving route does NOT fall back when given a pond scope. The caller
  already decided which level applies; a "helpful" fallback in the route
  would mix variants across levels behind the resolver's back.
- The logo link's accessible name follows the LEVEL: a pond logo is named by
  the pond, an instance logo by the instance. It is the link home, and a
  link's name has to say where it goes.

- **Charged to the pond's storage quota**, before the write, like attachments.
  Without it branding would be a way around the quota, and replacing a logo
  repeatedly would consume disk with no ceiling. The replaced asset's bytes
  are released FIRST, so re-uploading the same logo costs nothing — and a
  refused upload puts the released reservation back, so a rejection cannot
  leave the pond with more room than it had.
- **Purge removes the branding files.** The purge standard is absolute: after
  it nothing referencing the pond survives, rows or files. Asserted against
  the real purge path, not the new code alone.
- Security unchanged from #306 and not relaxed because the uploader is now an
  ordinary Pond Admin: SVG refused, magic bytes and IHDR checked server-side,
  size caps, content type pinned, no image parsing.
- The favicon swap is driven by the RESOLVED pond, never the raw route
  parameter — an unreadable or unknown slug must not leave a stale icon in
  the tab. That it happens after first paint is accepted and stated in the
  code and the UI: avoiding it would mean server-rendering index.html, which
  is #179's territory.

Same audit id as #306 (`branding.changed`) with `scope: 'pond'` — the catalogue
already carries the field, so no version bump.

Verified: api suite 105 files / 592 tests green; 5 pond-branding e2e tests
(pond scope serves the pond's bytes while the instance level still 404s, the
quota is charged and released exactly, SVG refused at pond level, a reader may
read but not change, purge deletes the files); 9 shared unit tests on the
resolution order including both mixing directions.
2026-08-01 20:44:02 +02:00
3310ae3926 #305: a full pond archive before deletion and before purge
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m19s
CI / Build container images (pull_request) Successful in 1m23s
CI / Auth e2e pack (pull_request) Successful in 8m55s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m28s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m52s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m6s
CI / Import/export fidelity gate (push) Successful in 58s
Deleting a pond already had a strict prompt — typing the pond name, stricter
than a confirm dialog. That was never the gap. The gap is that the person who
deletes it loses access the moment they do: the pond leaves their view, only a
Site Admin can bring it back, and the export is no longer reachable for them.
So the archive is offered INSIDE the deletion flow, before the button.

What it contains, and why it is not the existing export:

- Every page the requester may read, as Markdown, as before.
- **Every attachment of the pond**, not only the embedded ones. An
  attachment nobody put on a page would otherwise vanish unnoticed — which
  is the whole reason this issue exists.
- `manifest.json`: pond settings (EFFECTIVE, defaults filled in — a
  preservation format must not require its reader to know Dorfteich's
  defaults), labels, the page hierarchy and sort keys, comments, and
  attachment metadata including the #199 hash so a reader can verify bytes.
  It extends the #210 manifest rather than adding a second descriptor, and
  carries an explicit `formatVersion`.
- `README.txt`, because the manifest is for machines: whoever unpacks a
  folder of Markdown a year from now must not believe they hold a one-click
  restore.

Decisions worth naming:

- **"Complete" describes the RESULT, not the route.** A pond admin who may
  read every page gets `complete: true`; only an archive that actually
  leaves pages out is incomplete. The Site-Admin route skips the read filter
  (an archive taken before an irreversible purge must not depend on which
  ponds the operator happens to be a member of) — those are two different
  questions and the first version of this conflated them.
- **The omission is named before the download**, with its number, in the UI
  and in the manifest. An archive silently missing content is worse than no
  archive, because it ends the search.
- **Not downloading stays allowed.** A pond of test pages should not require
  one, and the server cannot tell whether a file arrived anyway — so the
  finality is stated in text instead of enforced.
- **A plain link, not fetch-into-a-blob.** The api streams the ZIP; buffering
  a whole pond in the tab to draw a progress bar would trade memory for
  cosmetics. The browser reports progress and completion; what it cannot say
  — that the archive is being BUILT — is announced in a live region.
- Read trail unchanged in kind (ADR 0023): one `export` event per classified
  page before any classified byte enters the stream. Attachments never travel
  without their page, so the same events cover them.
- New audit action `pond.archived` (catalogue v1.7) with page and attachment
  counts, omitted pages, and completeness.

Format documented in `docs/architecture/pond-archive-format.md`, including
what is deliberately NOT in it (history, permissions, trash).

Verified by hand, not only asserted: a real pond's archive downloaded and
unpacked — README, manifest, three page files, the media file; the manifest's
effective settings, per-page classification, the VS-NfD frontmatter and
marking preserved in the classified page's Markdown, and the attachment's
sha256 present. Plus six api tests (including that an unembedded attachment
travels and that a Site Admin gets a complete archive without membership) and
the a11y pack 11/11 in both schemes, which now also scans the pond settings
screen.

Not done, because there is nothing to attach it to: the Site Admin's purge
dialog (#193) exists only as an api endpoint — there is no pond-trash UI in
the web app. The api half is here and tested, so it becomes a link when that
screen is built.
2026-08-01 20:24:35 +02:00
8752cf0c5a #179: negotiate the SPA shell's lang attribute in nginx
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m13s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 9m25s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m40s
CD / Promote to Int (push) Successful in 15s
CI / Lint, typecheck, test (push) Successful in 7m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m45s
CI / Import/export fidelity gate (push) Successful in 55s
`apps/web/index.html` carries a hard `lang="en"`. The app corrects it at
runtime (#163), but nginx answers every SPA route with that same file, so a
crawler or a no-JS visit — `/public/...` on prod is exactly that — saw `en`
for German content, permanently. WCAG 3.1.1 is about the delivered document,
not the one JavaScript later fixes.

nginx-only, no backend involved: a `map` on `Accept-Language` and a
`sub_filter` in the index.html path. Only the FIRST tag decides, which is
what "the browser's preferred language" means and mirrors #163 — `de-CH`
counts as German, `en-US,de` does not.

`Vary: Accept-Language` is new. The response now genuinely depends on a
request header, and without it a shared cache could hand one language's copy
to the other. Everything else in the location is untouched: same CSP, same
`Cache-Control: no-cache`, same `nosniff`.

The known limit is documented in the config rather than worked around: nginx
cannot read `instance.defaultLocale`, so an unlisted or absent
Accept-Language yields `en` even on a German instance. For public content
that is not the authoritative rendering anyway — the api's server shell
(`/api/v1/public/...`) already renders those with the instance locale.

Verified against a real nginx 1.27 (the image the stage runs) with the
config mounted as-is: `de-DE,de;q=0.9,en;q=0.8` and `de` yield `lang="de"`;
`en-US,en;q=0.9`, `en-US,en;q=0.9,de;q=0.8`, `fr-FR,fr` and a request with no
header yield `lang="en"`; an SPA route (`/public/teich/seite`) negotiates the
same way; the gzipped response is rewritten too, and a JavaScript asset comes
through byte-identical.
2026-08-01 20:01:53 +02:00
6377faf332 #306: instance branding — logo and favicon, cropped in the browser
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m28s
CI / Build container images (pull_request) Successful in 2m7s
CI / Auth e2e pack (pull_request) Successful in 9m37s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Build and push images (push) Successful in 23s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m47s
CD / Promote to Int (push) Successful in 16s
CI / Lint, typecheck, test (push) Successful in 7m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m41s
CI / Import/export fidelity gate (push) Successful in 1m12s
An instance had no way to look like itself: the top bar said "Dorfteich"
whatever the operator called their instance, `instance.name` was never
rendered in the running app at all, and there was no favicon anywhere —
`index.html` had no `<link rel="icon">` and `public/` held only fonts and
theme-init.js.

Where the line is drawn, and why:

- **The api never decodes an image.** Cropping, scaling and the conversion
  to PNG happen on a canvas in the browser; the api checks the PNG
  signature, reads the IHDR dimensions at their fixed offsets and enforces
  the caps. An image library would put a decoder in front of
  attacker-supplied bytes AND would have to be carried through the
  `--network none` offline build. Reading two big-endian integers is not
  decoding.
- **SVG is refused**, with its own error message rather than a generic
  "not a PNG": it can carry script, and serving it from our own origin
  would be a cross-site-scripting vector. An operator who tried one should
  learn that it is deliberate.
- **The crop is driven by number inputs, not by dragging.** A drag-only
  cropper excludes keyboard and switch users outright; a number input is
  arrow-key operable and screen-reader readable without any custom aria.
  The resulting pixel size is stated in text, not only drawn as a frame.
- **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has
  already resolved `data-theme` before first paint, so the correct logo is
  the one painted rather than the one that appears after a flash. Without a
  dark variant the LIGHT logo carries both themes — the operator's own
  asset shown unchanged beats one they did not choose (the rule #307
  extends to ponds). The settings screen warns; it never blocks.
- **The favicon link is static, its resource dynamic.** index.html stays a
  static file and the api answers with the uploaded icon or a shipped
  default — that route must never 404, or the browser keeps its generic
  icon for good. The default is generated by a script from Node's own zlib
  (`gen-default-favicon.mjs`), for the same offline-build reason.
- Both favicon sizes are uploaded together: one source, one crop, so the
  tab icon and the home-screen icon can never disagree.
- Branding is served WITHOUT a session, because the login screen carries it
  and the browser fetches the favicon before anyone signs in. The admin
  screen says so — an operator may not expect their logo to be public.
- The metadata is not writable through the settings endpoint: it describes
  bytes on disk, and hand-writing it would claim an asset that is not
  there.

`./data/branding` follows the three-step rule #303 paid for: env default +
`data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the
`mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and
closes the hole that made #303's variant invisible: the nightly archive
skips a missing directory WORDLESSLY, so the fence now demands that every
`*_DIR` the backup env declares actually travels in the archive. Verified
against the real defect — removing the line fails it by name.

Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start
so #307 is the same event with a different scope, not a second id.

Verified: api suite 103 files green (a lone `public-api` ECONNRESET under
local parallel load, green in isolation — the documented local flake);
branding suite 12 tests against a real directory; crop arithmetic unit
tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the
new section (overflow 0); and the whole flow walked in the browser: upload
→ crop 780×180 → stored as 512×118 → logo in the sidebar linking home with
the instance name as its accessible name → topbar wordmark following
`instance.name` → light logo still shown under `data-theme="dark"`.
2026-08-01 19:30:52 +02:00
942f7b13d3 #304: scope the legal spec's status locator to its own form
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m31s
CI / Build container images (pull_request) Successful in 1m15s
CI / Auth e2e pack (pull_request) Successful in 8m57s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CD / Build and push images (push) Successful in 36s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m24s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Failing after 7m12s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
The font manager's upload live regions made `getByRole('status')` ambiguous
on /admin, and legal.spec.ts — which asserts the legal form's success message
— started failing in the e2e pack. That is the documented trap in CLAUDE.md:
a new label or region makes an existing page-wide locator ambiguous, and the
fix is to scope the SPEC, not to drop the region a screen reader needs.

The section gets a named class for exactly that purpose.

Verified locally against the running stack: legal, fonts, admin-users,
admin-quotas and the a11y pack all pass.
2026-08-01 19:05:18 +02:00
ee6a11f9b0 #304: declare the font-list route's access rule explicitly
Some checks failed
CI / Build container images (pull_request) Successful in 3m51s
CI / Lint, typecheck, test (pull_request) Successful in 6m35s
CI / Auth e2e pack (pull_request) Failing after 3m6s
CI / Import/export fidelity gate (pull_request) Has been skipped
The route-permission fence (#52) failed in CI, not locally: I had run the
fonts and import-export suites, not the full api suite, and that fence needs
a database. `@AuthenticatedOnly()` is the rule the route always meant — a
session, no further permission.

Re-verified with the FULL api suite against a fresh database: 103 files /
575 tests passed.
2026-08-01 18:46:47 +02:00
f8c241b11a #304: custom fonts in the pickers, an admin screen, and the licence page
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 6m26s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
The backend from #303 could store an operator's font but nothing could
choose one: no list endpoint outside the Site-Admin routes, no @font-face
rules for a family that only exists at runtime, and no management UI.

Found while wiring it up — a real defect in #303, invisible to its tests:
`fontStack` cannot tell an uploaded family from a deleted one, so the PDF
exporter embedded the face and then never named it. Every export of a pond
using an operator font rendered in the system font while the job reported
success. Both `fontStack` call sites now take the uploaded families
(`buildPdfHtml`, `pondFontVariables`); `pdf-html.test.ts` pins the
regression from both sides. Verified against a real Gotenberg: with the
families the PDF embeds PlayfairDisplay-Bold, without them NotoSans-Bold —
that was the whole bug, in one diff of two PDFs.

- `GET /fonts/custom` is readable by any signed-in user, not Site Admins
  only: the pickers, the licence page and the injected `@font-face` rules
  all need it, and gating it would have forced a second, admin-only UI.
- Bundled and uploaded families are told apart by their `<optgroup>`, not
  by a badge — the grouping is then part of the control's semantics, so a
  screen reader announces it and the native mobile select keeps it. Within
  each source the catalog's category grouping is preserved.
- The delete confirmation names how many ponds use the family and what
  happens to them; focus moves to it and back on cancel. Deletion stays
  unblocked (the api's decision, #303) — the ponds degrade, they do not
  break.
- The licence page grew a second table. That is what makes an attribution
  obligation satisfiable: a commercial licence that requires naming the
  foundry needs a page to name it on.

Verified in the browser end to end (upload two weights → listed and
rendered in its own font → chosen in a pond → page renders in it → deleted
→ pond falls back): api suite for fonts/export 77 passed, a11y pack 11/11
locally in both schemes, lint/typecheck/i18n:check green.
2026-08-01 18:32:46 +02:00
485c8fa538 #303 follow-up: the fonts volume must mount node-owned
All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m49s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m35s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m30s
CI / Import/export fidelity gate (push) Successful in 57s
CI / Build container images (pull_request) Successful in 2m52s
CI / Lint, typecheck, test (pull_request) Successful in 6m28s
CI / Import/export fidelity gate (pull_request) Successful in 57s
Found on the real deploy, not in any test: `/data/fonts` in the running
api container was `root:root` and the non-root `node` user could not
write to it. Every upload would have failed with EACCES at runtime while
the api reported ready.

The api Dockerfile already explains the mechanism for uploads and
plugins — Docker copies an image directory's ownership into a fresh named
volume on first mount — and pre-creates them chowned. #303 added
`CUSTOM_FONTS_DIR` to the ENV but not to that mkdir/chown line.

Adds a CI fence so it cannot recur: every `/data/…` path the api image
defaults to must also appear in the mkdir AND the chown. Verified against
the actual defect — removing `/data/fonts` from the chown makes it fail.
2026-08-01 15:12:06 +02:00
b96997501a #303: operator-uploaded fonts — storage, API, PDF embedding, backup
All checks were successful
CI / Build container images (pull_request) Successful in 3m53s
CI / Auth e2e pack (pull_request) Successful in 8m42s
CI / Auth e2e pack (push) Successful in 8m41s
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Deploy to Test (push) Successful in 16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m41s
CI / Build container images (push) Has been skipped
CI / Import/export fidelity gate (push) Successful in 52s
An operator holding a font licence could only use it by baking the file
into a custom image, which tied every change to a rebuild and left the
file out of the backup.

ADR 0016 said there is no runtime font management. It also listed this
exact case under "Alternatives considered" — *may become a Site-Admin-
level feature later*. The amendment takes that option and answers the two
objections it raised: licensing risk (Site Admins only, licence recorded
with the family) and file-format attack surface (magic-byte check and a
size cap, never a parse).

- `CUSTOM_FONTS_DIR` (default `./data/fonts`) — a sibling of uploads and
  plugins, NOT inside the image-baked `FONTS_DIR`, where a deploy would
  overwrite it and no backup would ever see it.
- One list of data directories (`apps/backup/src/data-dirs.ts`) now feeds
  both the nightly archive and the restore, so they cannot drift. #306 and
  #307 add one line each instead of a second mechanism.
- Both Dockerfiles bake the path. The backup image sets its volume paths
  itself ("self-sufficient without compose env" — #71's lesson) and reads
  no *_DIR from compose; without the ENV entry the archive would have
  skipped the directory silently.
- The PDF path already read WOFF2 from disk at request time, so it only
  had to pick the other base directory for a custom family.
- `fontStack`/`fontEntry` take the instance's uploaded families as an
  argument — they are runtime data. The catalog is searched first, and a
  colliding family name is rejected at upload, so a custom font can never
  shadow a catalog one.
- Deletion is never blocked by usage: an unknown family already falls back
  to the system stack, so affected ponds degrade instead of breaking. The
  count of affected ponds travels into the audit entry.
- Audit catalogue v1.6 (`font.uploaded`, `font.deleted`).

Verified: api full suite against a fresh database, 102 files / 571 tests.
The upload suite writes into a real temp directory and reads the bytes
back off disk, so the storage layer is exercised rather than mocked.
2026-08-01 14:49:13 +02:00
5164801676 #301: reset the login rate limit before the VS-NfD packs
All checks were successful
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m32s
CI / Auth e2e pack (push) Successful in 8m30s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Successful in 8m42s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CI / Lint, typecheck, test (pull_request) Successful in 6m24s
CD / Build and push images (push) Successful in 18s
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Deploy to Test (push) Successful in 14s
CI 665: the reflow guard itself passed; the run died two packs later on
`fixture login for fixture-admin failed: 429`.

The a11y pack costs one more login since this branch added the reflow
test, and that was enough to exhaust the budget before the VS-NfD packs.
Same trap the workflow already documents for the content and collab
packs — it just needed one more reset, in the place the extra login
pushed it over.
2026-08-01 12:31:30 +02:00
69882ecbea #301: the token tables need the same scroll wrapper
The sorted report finally named it: `table.api-tokens__table` at 833px
wide, with its `.visually-hidden` heading reaching right=737 — exactly
the document's scrollWidth. Same mechanism as the sessions table, a
second table I had not wrapped.

Locally the API-tokens table was empty and therefore narrow, which is why
this only ever appeared in CI. With a token present it reproduces:
without the wrapper 345px of page overflow, with it none.

The feed-token table gets the same treatment — it is built the same way
and would fail as soon as someone holds a feed token with a long name.

The "[in fitting scroller]" marker in the report is misleading for these:
`main.main` is a scroller, but it is `position: static`, so it never
clipped the absolutely positioned heading. Only a positioned ancestor
does — which is what `.table-scroll` now is.

Verified locally against a real stack, with a wide token table present:
reflow guard green, whole a11y pack green in both colour schemes.
2026-08-01 12:31:30 +02:00
2422f3a28f #301: sort the reflow report so the culprit cannot be buried
CI still reports 737 while the local stack is now clean, and the box list
was capped at 15 entries — all of them nav links clipped by their own
scroller. Whatever pushes the page in CI sits past that cap.

The list is now sorted by reach, marks each entry as either clipped by a
fitting scroller or actually pushing the page, and shows 40.
2026-08-01 12:31:30 +02:00
b65339ae13 #301: the overflow was an escaping visually-hidden heading
Found by standing up the local stack instead of guessing through CI.
The DOM tree under `.app-body` shows it in one line:

  span.visually-hidden rect=[342,343] pos=absolute

Its right edge is 343, and `.app-body` reports scrollWidth 343 against a
320 client. The table's actions column carries a `.visually-hidden`
heading, which is `position: absolute`. `.table-scroll` was `position:
static`, so it was NOT that span's containing block — the span escaped
the scroller's clipping, kept its static position out at the table's
right edge, and pushed the page.

`position: relative` on the wrapper makes it the containing block, and
the span is clipped like the rest of the table.

This is one cause behind both numbers: 23px locally, matching the
original report, and 417px in CI, where different font metrics make the
table wider and carry the span further out. Chasing them as separate
problems is what cost three CI rounds.

Verified locally against a real stack: the reflow guard passes and the
whole a11y pack is green, 11 tests in both colour schemes.
2026-08-01 12:31:30 +02:00
194f144797 #301: dump raw box metrics from the reflow guard
Two rounds now reported no element past the viewport edge while the
document still claimed 417px of overflow — a combination that rules out
every hypothesis I had, including my own filter.

So stop inferring. The guard now prints the html/body metrics, every
element whose own content is wider than its box (with its overflow-x, so
the intentional scrollers are distinguishable), and every box reaching
past the edge with no filtering at all. Diagnostics ride in the assertion
message, not the compared value, so they show up even when they match.
2026-08-01 12:31:30 +02:00
9fce824a8e #301: make the reflow guard report the ancestor chain
The previous run came back with an empty offender list and an unchanged
417px overflow: the filter treated everything under a scroll container as
innocent, including the container that was itself too wide. A scroller
only absolves its children when the scroller fits.

It now reports the chain from body down to the widest offender with each
box's width, so the first element wider than the viewport is visible
instead of inferred.
2026-08-01 12:31:30 +02:00
18c2ed0bfe #301: the real culprit was the jump nav, not the wide content
The first attempt fixed plausible suspects. CI measured the actual page
and named something else: six `.settings-nav__link` buttons, 417px of
page-level overflow at 320px.

`.settings-nav` already had `overflow-x: auto`, but as a flex child it
also had the default `min-width: auto` — the min-content width of the
whole jump strip. That forced the column wider than the viewport, so its
own overflow rule never had anything to scroll. `min-width: 0` is exactly
the case CLAUDE.md warns about under Reflow.

The guard now ignores elements that sit inside a scroll container. Such
content is *meant* to be wider than the viewport — reporting it buried
the one finding that mattered under twelve lines of noise, and the cap
truncated the list before it could show anything else.

The table wrapper and the wrapping settings rows from the first commit
stay. Neither was the cause here, but a table cannot shrink below its
min-content width and those rows cannot wrap on their own, so both are
hardening that holds regardless of content.
2026-08-01 12:31:30 +02:00
f938ee9880 #301: stop /settings scrolling horizontally at 320px
WCAG 2.1 SC 1.4.10 asks for no two-dimensional scrolling down to 320px,
which is also what 400% zoom on a 1280px screen produces. The layout
skeleton was already hardened for this in #165; the overflow came from
content inside the sections.

- The sessions table cannot shrink below its min-content width — four
  columns, one of them the full user-agent string. It now scrolls inside
  its own container rather than pushing the page. The container is
  focusable with a role and a name, because a scroll area that only a
  mouse can reach trades one barrier for another.
- `.settings-checkbox` rows may wrap. The accent swatches have a fixed
  size and cannot shrink, so an unwrappable row set a floor for the whole
  page width.

Adds a reflow guard to the a11y pack. axe does not cover 1.4.10 — the
criterion is not derivable from the DOM — so this is a separate check,
and it names the overflowing elements when it trips instead of only
reporting that something overflows.
2026-08-01 12:31:30 +02:00
f9149eba13 #302: the vault import test reaches its page through the sidebar
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 8m49s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 35s
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Deploy to Test (push) Successful in 14s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m49s
CI / Import/export fidelity gate (push) Successful in 1m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m30s
The Obsidian fixture vault contains a note called "Startseite", and the
pond now creates one too — the seeded fixtures use locale `de`. Two
consequences, and the second is the one that mattered:

- the unscoped title locator matched two sidebar entries;
- `/p/<pond>/startseite` no longer belongs to the imported note. The
  pond's own start page took that slug, so the import landed on a
  suffixed one and the test was about to assert against the wrong page.

Both are fixed by scoping to the mount page and navigating through the
sidebar instead of guessing a slug. The test stays meaningful: it then
clicks a wikilink inside the page content, which the empty auto-created
start page would not have.

CI caught this; the local run passed it. Worth remembering that a
title-based locator can go green by luck.
2026-08-01 11:29:54 +02:00
30fd1ff53b #302: the permission matrix counts the start page
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m26s
CI / Auth e2e pack (pull_request) Failing after 6m12s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 1m20s
Every pond created through the api now carries one, and the matrix pond
is created that way. The start page is an ordinary page with no grant of
its own, so it follows the pond-wide permissions: the three member
subjects each see one more, the label-restricted editor too, and the
outsider — who reaches only the explicitly public page — still sees one.

The 429 in the same run was the login rate limit, reached through the
retries of this failure rather than on its own.
2026-08-01 08:25:51 +02:00
45f1925917 #302: configurable pond start page, created with every new pond
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m28s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Failing after 4m1s
CI / Build container images (pull_request) Successful in 4m3s
Opening a pond landed on whatever sorted first in the sidebar — stable,
but a rule nobody could see, and one whose target moved as soon as
someone added a page ahead of it. New ponds landed on the empty-pond hint
instead of anything useful.

- `startPageId` joins the pond settings. No migration: `Pond.settings` is
  already jsonb. It stores an id, not a slug, so renaming or moving the
  page keeps it working.
- `PondHomePage` prefers it, but only when the page is in this user's
  page list. That list already holds just what they may see, so a start
  page hidden by a page-scoped grant — or trashed — falls back silently
  instead of landing them on a 404, and it costs no extra request.
- Both creation paths give the pond a start page, titled from the
  creator's stored locale. It happens after the creating transaction
  commits: the owner's grant is written inside it and permissions cache
  per pond, so creating the page any earlier would ask about rights the
  grant has not published yet. A failure is logged, not fatal — a pond
  without a start page still works.

`PagesModule` imported `PondsModule` without using it. Removing that
vestigial edge let PondsModule depend on PagesModule in the honest
direction instead of tying the two together with forwardRef.

Every pond created through the api now owns a page, which broke eight
suites whose teardown deleted ponds directly — `Page.pond` deliberately
has no cascade, because a real purge removes contents explicitly and
audits it. A shared `deletePondsWhere` helper deletes pages first. Two
tests that counted pages now account for the start page rather than
pretending the pond began empty.
2026-08-01 08:06:35 +02:00
5a4a99196e #300: route icon-only controls through IconButton/IconLink
All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 3m51s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m32s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 58s
The notification bell sat higher and larger than search and the theme
toggle next to it. The cause was not the glyph: `.notifications-bell__button`
carried its own rules with neither flex centring nor an icon size, so the
svg was laid out inline on the text baseline and rendered at lucide's
24px default instead of the 1.15rem the shared `.icon-button` enforces.

Route every icon-only control through the shared components instead:

- `IconLink` joins `IconButton`, sharing one class helper. Three controls
  navigate (pond settings, graph, trash) and are links, not buttons —
  without a link twin they would have stayed the one group gluing the
  class on by hand.
- 17 hand-applied `className="icon-button …"` usages across nine files
  now go through the components, which is what enforces the accessible
  name on a control that shows only an icon.
- The bell's unread count reaches assistive technology. The badge sits
  inside the control, so `aria-label` hid it and a screen reader
  announced "Notifications" without ever saying how many.

An ESLint rule keeps it that way: `icon-button` on a raw button, anchor
or Link is now an error, in both string and template-literal form.

The plugin uninstall button keeps a title that differs from its name (it
explains why a required plugin is locked); IconButton spreads rest last,
so the explicit title still wins.

Also drops the graphify block from CLAUDE.md — it duplicates the
workspace-level instructions.
2026-08-01 06:56:13 +02:00
1f56f34113 #296: remove the unsubscribe-token dual-verify window early
All checks were successful
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 12s
Release / Build release images and notes (push) Successful in 3m31s
Release / Release-candidate operations QA (push) Successful in 46s
CI / Build container images (push) Has been skipped
Prod deploy / Deploy the released images to Prod (push) Successful in 58s
CI / Import/export fidelity gate (push) Successful in 59s
CI / Lint, typecheck, test (push) Successful in 6m40s
CI / Auth e2e pack (push) Successful in 8m21s
Restore drill / Restore the latest backup into a scratch stack (push) Successful in 1m18s
CI / Build container images (pull_request) Successful in 2m53s
CI / Auth e2e pack (pull_request) Successful in 8m34s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 14s
Operator decision at the ADR 0020 acceptance: verification is
subkey-only now instead of waiting for the stated 2026-11-01 expiry.
Links in digest mails sent before the #188 key separation stop working;
recipients use the in-app notification settings. A regression test pins
that the legacy derivation (root key + purpose prefix) can never verify
again; security.md records the removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 23:03:03 +02:00
9b7acab294 #232: plugin allowlist with SHA-256 hash pinning
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m27s
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m30s
The install path records the SHA-256 of the delivered bundle ZIP
(plugins.bundle_hash; pre-#232 installs show it as unknown until
reinstalled). plugins.allowlist in instance_settings names permitted
ids with their pinned hashes: empty (default) = not enforced, existing
instances unchanged; non-empty = installs of unlisted or deviating
bundles are rejected (plugin_not_pinned / plugin_hash_mismatch, 403),
and an installed plugin outside the list or with a deviating hash does
not load — absent from pond mount lists, frame/assets 404. Every
rejection is audited (plugin.rejected, catalogue v1.5). A version bump
changes the hash and therefore requires an explicit re-pin — the
intended friction (ADR 0025). Admin UI shows observed vs pinned hash
per plugin with pin/re-pin/unpin. Scope stated honestly in
plugin-architecture.md: the pin answers "is this the reviewed bundle";
post-install disk tampering is platform integrity (ADR 0019), sandbox
containment stays the sandbox's job. Hardening guide row + catalog
advisory triage; residual risk R-03 resolved. e2e: empty-allowlist
compatibility, pinned load, unpinned and tampered installs rejected and
audited, pin drift blocks loading while the admin still sees the
mismatch, version bump needs re-pin. Full api suite 101 files / 561
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 21:26:36 +02:00
206 changed files with 10800 additions and 671 deletions

View File

@ -107,6 +107,24 @@ jobs:
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
uses: pnpm/action-setup@v4
@ -327,6 +345,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
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
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -347,6 +375,18 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
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
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -620,6 +660,14 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
# Das a11y-Pack kostet seit #301 einen Login mehr (der Reflow-Zaun);
# damit reicht das Budget nicht mehr bis in die VS-NfD-Packs → hier
# zusätzlich zurücksetzen (siehe Hinweis oben).
- name: Reset login rate limit before the VS-NfD packs
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# VS-NfD-Markierungen im Modus `marked` (issue #244).
- name: Run VS-NfD marking pack
run: |

View File

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

View File

@ -29,18 +29,19 @@ ARG APP_VERSION=0.0.0-dev
# 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
# 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
COPY --from=build --chown=node:node /out /app
# Generate the Prisma client for this image's platform.
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
# 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
# node-owned keeps the shared `backups` volume writable for the backup
# 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
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

View File

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

View File

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

View File

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

View File

@ -31,46 +31,71 @@ enum UserStatus {
/// Account profile. Login methods live in UserIdentity (OIDC-ready,
/// ADR 0007); Site Admin is a user flag, all other roles are grants.
model User {
id String @id @default(uuid())
username String @unique
email String @unique
displayName String @map("display_name")
locale String @default("en")
isSiteAdmin Boolean @default(false) @map("is_site_admin")
id String @id @default(uuid())
username String @unique
email String @unique
displayName String @map("display_name")
locale String @default("en")
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")
isSiteAdminManaged Boolean @default(false) @map("is_site_admin_managed")
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
/// E-mail digest cadence (issue #95): hourly | daily | off.
digestFrequency String @default("hourly") @map("digest_frequency")
status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at")
digestFrequency String @default("hourly") @map("digest_frequency")
status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at")
identities UserIdentity[]
sessions Session[]
authTokens AuthToken[]
apiTokens ApiToken[]
feedTokens FeedToken[]
mentionRows PageMention[]
ponds Pond[]
pages Page[]
attachments Attachment[]
conversionJobs ConversionJob[]
auditEntries AuditEntry[]
comments Comment[]
watches Watch[]
notifications Notification[]
favorites PageFavorite[]
identities UserIdentity[]
sessions Session[]
authTokens AuthToken[]
apiTokens ApiToken[]
feedTokens FeedToken[]
mentionRows PageMention[]
ponds Pond[]
pages Page[]
attachments Attachment[]
conversionJobs ConversionJob[]
auditEntries AuditEntry[]
comments Comment[]
watches Watch[]
notifications Notification[]
favorites PageFavorite[]
customFonts CustomFont[]
invitations Invitation[] @relation("InvitationsSent")
@@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
/// admin actions — grants, member roles, plugin installs, quota and settings
/// changes, setup steps, manual job triggers. Written by AuditService, which
@ -107,32 +132,32 @@ model AuditEntry {
/// 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")
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")
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")
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
channel String
/// Classification at read time — a later reclassification must not
/// rewrite history (ADR 0023).
classification String
details Json?
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")
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")
windowSeconds Int @map("window_seconds")
@@id([id, occurredAt])
@@unique([dedupKey, windowBucket])
@ -237,14 +262,14 @@ model Pond {
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
owner User @relation(fields: [ownerId], references: [id])
usage PondUsage?
pages Page[]
attachments Attachment[]
labels Label[]
grants RoleGrant[]
owner User @relation(fields: [ownerId], references: [id])
usage PondUsage?
pages Page[]
attachments Attachment[]
labels Label[]
grants RoleGrant[]
conversionJobs ConversionJob[]
pondPlugins PondPlugin[]
pondPlugins PondPlugin[]
@@index([ownerId])
@@map("ponds")
@ -460,12 +485,12 @@ model CollabOpenSession {
/// built from the Yjs state via the shared editor schema. `outline` is the
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
model PageContentCache {
pageId String @id @map("page_id")
plainText String @map("plain_text")
markdown String
html String
outline Json
updatedAt DateTime @updatedAt @map("updated_at")
pageId String @id @map("page_id")
plainText String @map("plain_text")
markdown String
html String
outline Json
updatedAt DateTime @updatedAt @map("updated_at")
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
/// hook (both write it with the same weighting). The GIN index is added in
@ -854,9 +879,9 @@ model ConversionJob {
sourceName String? @map("source_name")
resultPageId String? @map("result_page_id")
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
@@index([status, createdAt])
@@map("conversion_jobs")
@ -885,6 +910,8 @@ model Plugin {
mode PluginInstanceMode @default(DISABLED)
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
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")
updatedAt DateTime @updatedAt @map("updated_at")
/// Set when uninstalled; active queries filter `removedAt: null`.
@ -911,3 +938,48 @@ model PondPlugin {
@@id([pondId, pluginId])
@@map("pond_plugins")
}
/// An operator-uploaded font family (issue #303, ADR 0016 §#303). The bytes
/// live on disk under CUSTOM_FONTS_DIR — this row only records what the
/// upload form stated, because the api never parses the font file itself.
/// Additive to the compile-time catalog: a family whose name or slug
/// collides with a catalog entry is rejected, so `fonts.<slot>.family` in a
/// pond's settings stays unambiguous.
model CustomFont {
id String @id @default(uuid())
/// CSS `font-family` name, as typed by the uploader.
family String @unique
/// URL/file-safe form; names the directory under CUSTOM_FONTS_DIR.
slug String @unique
/// Drives the system fallback stack, like FontCatalogEntry.category.
category String
/// Free-text licence label, e.g. "Commercial — Foundry XY". Required so
/// an attribution obligation can be met on the font catalogue page.
licence String
licenceUrl String? @map("licence_url")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
uploader User @relation(fields: [uploadedBy], references: [id])
weights CustomFontWeight[]
@@map("custom_fonts")
}
/// One weight of a custom family. Style is always `normal`: the PDF
/// `@font-face` builder emits only that, and browsers synthesise oblique —
/// italic uploads are a follow-up, not a silent half-feature.
model CustomFontWeight {
id String @id @default(uuid())
fontId String @map("font_id")
weight Int
/// Whether a legacy WOFF was supplied next to the required WOFF2.
hasWoff Boolean @default(false) @map("has_woff")
byteSize Int @map("byte_size")
font CustomFont @relation(fields: [fontId], references: [id], onDelete: Cascade)
@@unique([fontId, weight])
@@map("custom_font_weights")
}

View File

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

View File

@ -11,9 +11,17 @@ import {
} from '../settings/instance-settings.service';
import { SiteAdminGuard } from './site-admin.guard';
// Lifecycle markers, not configuration: never editable through this
// endpoint (the setup lock must be irreversible, issue #80).
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
// Lifecycle markers and file-backed metadata, not configuration: never
// editable through this endpoint. The setup lock must be irreversible
// (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
// its own schema inside the service (double validation is fine — this

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BackupModule } from '../backup/backup.module';
import { PondsModule } from '../ponds/ponds.module';
import { QuotasModule } from '../quotas/quotas.module';
import { SchedulerModule } from '../scheduler/scheduler.module';
import { SearchModule } from '../search/search.module';
@ -19,7 +20,15 @@ import { UserAdminController } from './user-admin.controller';
import { UserAdminService } from './user-admin.service';
@Module({
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
imports: [
QuotasModule,
UsersModule,
AuthModule,
SchedulerModule,
BackupModule,
SearchModule,
PondsModule,
],
controllers: [
AdminSettingsController,
BackupAdminController,

View File

@ -12,9 +12,11 @@ import {
UseGuards,
} from '@nestjs/common';
import {
AdminCreateUserInput,
AdminUserListQuery,
AdminUserListView,
AdminUserView,
adminCreateUserSchema,
adminUserListQuerySchema,
setSiteAdminSchema,
setUserDisabledSchema,
@ -31,6 +33,14 @@ import { UserAdminService } from './user-admin.service';
export class UserAdminController {
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()
async list(
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,

View File

@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service';
import { 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';
/**
@ -62,13 +62,71 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
afterAll(async () => {
const all = Object.values(ids);
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.user.deleteMany({ where: { id: { in: all } } });
await prisma.$disconnect();
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 () => {
const res = await api()
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)

View File

@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
AdminCreateUserInput,
AdminUserListQuery,
AdminUserListView,
AdminUserStatus,
@ -10,7 +11,9 @@ import { PinoLogger } from 'nestjs-pino';
import { AuthService } from '../auth/auth.service';
import { AuditService } from '../audit/audit.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { PseudonymizationService } from './pseudonymization.service';
/**
@ -27,12 +30,33 @@ export class UserAdminService {
private readonly prisma: PrismaService,
private readonly pseudonymizer: PseudonymizationService,
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly ponds: PondsService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
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> {
const q = query.q?.trim();
const where: Prisma.UserWhereInput = q

View File

@ -6,6 +6,7 @@ import { AdminModule } from './admin/admin.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { BackupModule } from './backup/backup.module';
import { BrandingModule } from './branding/branding.module';
import { ApiExceptionFilter } from './common/api-exception.filter';
import { maskTokenParam } from './common/mask-token-param';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
@ -17,6 +18,7 @@ import { FilesModule } from './files/files.module';
import { GrantsModule } from './grants/grants.module';
import { HealthModule } from './health/health.module';
import { HomeModule } from './home/home.module';
import { FontsModule } from './fonts/fonts.module';
import { ImportExportModule } from './import-export/import-export.module';
import { LabelsModule } from './labels/labels.module';
import { LegalModule } from './legal/legal.module';
@ -81,6 +83,8 @@ import { VersionsModule } from './versions/versions.module';
PublicModule,
PublicApiModule,
McpModule,
BrandingModule,
FontsModule,
ImportExportModule,
PluginsModule,
AuthModule,

View File

@ -29,6 +29,9 @@ export const AUDIT_EVENTS = {
'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' },
@ -36,18 +39,24 @@ export const AUDIT_EVENTS = {
'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' },

View File

@ -4,7 +4,7 @@ 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 { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
let app: INestApplication;
@ -46,7 +46,7 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
afterAll(async () => {
// 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.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
await prisma.$disconnect();

View File

@ -3,6 +3,7 @@ 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 { PondsModule } from '../ponds/ponds.module';
@ -18,7 +19,7 @@ import { ProxyIdentityService } from './proxy-identity.service';
import { SessionsModule } from './sessions.module';
@Module({
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule],
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
controllers: [AuthController, OidcController],
providers: [
AuthService,

View File

@ -9,6 +9,7 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { InvitationsService } from '../invitations/invitations.service';
import { MailService } from '../mail/mail.service';
import { PondsService } from '../ponds/ponds.service';
import { AuditService } from '../audit/audit.service';
@ -33,6 +34,7 @@ export class AuthService {
private readonly sessions: SessionsService,
private readonly mail: MailService,
private readonly ponds: PondsService,
private readonly invitations: InvitationsService,
private readonly rateLimits: RateLimitService,
private readonly audit: AuditService,
private readonly config: AppConfig,
@ -43,10 +45,37 @@ export class AuthService {
}
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' });
}
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.audit.record({ action: 'auth.signup', actorId: user.id });
}

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

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

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

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

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

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

View File

@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp } 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 { FileStorageService } from './file-storage.service';
@ -74,7 +74,7 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
await prisma.attachment.deleteMany({ where: { pondId } });
const where = { pond: { owner: { username: { contains: suffix } } } };
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.$disconnect();
await app.close();

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

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

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

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

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

View File

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

View File

@ -5,7 +5,7 @@ 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 { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.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.
const where = { pond: { owner: { username: { contains: suffix } } } };
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.$disconnect();
await app.close();

View File

@ -1,5 +1,10 @@
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
import {
ConversionJobView,
PageExportInput,
PondArchivePreview,
pageExportInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { AuthedRequest } from '../auth/auth.guard';
@ -7,7 +12,10 @@ import { ZodValidationPipe } from '../common/zod-validation.pipe';
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 { PondArchiveService } from './pond-archive.service';
/**
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
@ -16,7 +24,41 @@ import { ExportService } from './export.service';
*/
@Controller()
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
* `reader` role is "may see the pond"; the service filters to readable pages,
@ -48,3 +90,34 @@ export class ExportController {
);
}
}
/**
* The Site Admin's archive from the purge dialog (issue #305, #193).
*
* Separate controller because it must NOT carry `@RequiresPondRole`: a Site
* Admin purging a trashed pond is usually not a member of it, and the last
* archive before an irreversible purge must not depend on that. It is
* therefore complete by construction the read filter is skipped.
*/
@Controller('admin/trash')
@UseGuards(SiteAdminGuard)
export class PondArchiveAdminController {
constructor(private readonly archives: PondArchiveService) {}
@Get('ponds/:pondId/archive/preview')
archivePreview(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondArchivePreview> {
return this.archives.preview(request.user!, pondId, true);
}
@Get('ponds/:pondId/archive')
async archive(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@Res() response: Response,
): Promise<void> {
await this.archives.stream(request.user!, pondId, response, readActorOf(request), true);
}
}

View File

@ -6,6 +6,7 @@ import {
ConversionJobView,
ExportFormat,
PondFonts,
customFontEntries,
fontSlug,
PageClassification,
classificationMarking,
@ -20,6 +21,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service';
import { CustomFontsService } from '../fonts/custom-fonts.service';
import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PluginsService } from '../plugins/plugins.service';
@ -53,6 +55,7 @@ export class ExportService {
private readonly plugins: PluginsService,
private readonly fallbacks: PluginFallbackRenderer,
private readonly config: AppConfig,
private readonly customFonts: CustomFontsService,
private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger,
) {
@ -333,6 +336,9 @@ export class ExportService {
pondName: page.pond.name,
bodyHtml,
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),
// Styled sections keep their look in the PDF (#75); a pond without
// active style plugins contributes an empty string.
@ -371,12 +377,18 @@ export class ExportService {
});
}
/** Base64 `@font-face` rules for the pond's three fonts, read from the
* catalog baked into the image (ADR 0016). A font file that is absent (a
* native dev run without `FONTS_DIR` populated) is skipped the render falls
* back to the system stack rather than failing. */
/** Base64 `@font-face` rules for the pond's three fonts. Catalog families
* come from the directory baked into the image (ADR 0016); operator-uploaded
* ones from `CUSTOM_FONTS_DIR` (issue #303) same on-disk layout, so only
* 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> {
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.
const seen = new Set<string>();
const faces: string[] = [];
@ -384,8 +396,10 @@ export class ExportService {
const key = `${slot.family}:${slot.weight}`;
if (seen.has(key)) continue;
seen.add(key);
const slug = fontSlug(slot.family);
const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
const customSlug = customSlugs.get(slot.family);
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 {
const bytes = await readFile(file);
faces.push(
@ -393,7 +407,7 @@ export class ExportService {
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
);
} 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');

View File

@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common';
import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module';
import { FontsModule } from '../fonts/fonts.module';
import { LabelsModule } from '../labels/labels.module';
import { PagesModule } from '../pages/pages.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 { DataExportController } from './data-export.controller';
import { DataExportService } from './data-export.service';
import { ExportController } from './export.controller';
import { ExportController, PondArchiveAdminController } from './export.controller';
import { ExportService } from './export.service';
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
import { IMPORT_PROCESSOR } from './import.constants';
@ -22,6 +23,7 @@ import { ImportController } from './import.controller';
import { ImportService } from './import.service';
import { JobsController } from './jobs.controller';
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
import { PondArchiveService } from './pond-archive.service';
/** 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. */
@ -40,18 +42,26 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
imports: [
CommonModule,
FilesModule,
FontsModule,
LabelsModule,
PagesModule,
PluginsModule,
SchedulerModule,
SettingsModule,
],
controllers: [JobsController, ImportController, ExportController, DataExportController],
controllers: [
JobsController,
ImportController,
ExportController,
PondArchiveAdminController,
DataExportController,
],
providers: [
ConversionJobService,
ConversionWorker,
ImportService,
ExportService,
PondArchiveService,
DataExportService,
// 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).

View 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");
});
});

View File

@ -1,4 +1,4 @@
import { PondFonts, fontStack } from '@dorfteich/shared';
import { FontCatalogEntry, PondFonts, fontStack } from '@dorfteich/shared';
export interface PdfHtmlParams {
title: string;
@ -8,6 +8,12 @@ export interface PdfHtmlParams {
fonts: PondFonts;
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
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
* at install time (scoped selectors, no external fetches, no `</style>`).
* 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 {
const { fonts } = params;
const extra = params.customFonts ?? [];
return `<!doctype html>
<html lang="en">
<head>
@ -44,9 +51,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
${params.fontFaceCss}
@page { size: A4; }
:root {
--font-heading: ${fontStack(fonts.heading.family)};
--font-body: ${fontStack(fonts.body.family)};
--font-mono: ${fontStack(fonts.mono.family)};
--font-heading: ${fontStack(fonts.heading.family, extra)};
--font-body: ${fontStack(fonts.body.family, extra)};
--font-mono: ${fontStack(fonts.mono.family, extra)};
}
html { font-size: 11pt; }
body {

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

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

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

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

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

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

View File

@ -1,6 +1,6 @@
import { apiI18n } from '../i18n/api-i18n';
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation';
export interface RenderedMail {
subject: string;
@ -15,14 +15,15 @@ export interface RenderedMail {
*/
export function renderMail(
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',
): RenderedMail {
const t = (key: string, options: Record<string, string> = {}): string =>
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
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 expiry = t(`${template}.expiry`);
const ignore = t('common.ignoreHint');

View File

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

View File

@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service';
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';
/**
@ -97,7 +97,7 @@ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => {
const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
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.$disconnect();
await app.close();

View File

@ -3,11 +3,7 @@ import { createHmac } from 'node:crypto';
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
import { describe, expect, it } from 'vitest';
import {
LEGACY_VERIFY_UNTIL,
signUnsubscribeToken,
verifyUnsubscribeToken,
} from './unsubscribe-token';
import { signUnsubscribeToken, verifyUnsubscribeToken } from './unsubscribe-token';
const secret = 'test-secret-at-least-16-chars-long';
const TTL_MS = 90 * 24 * 60 * 60 * 1000;
@ -50,23 +46,15 @@ describe('unsubscribe token', () => {
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
});
it('accepts a pre-separation legacy token inside the dual-verify window', () => {
const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000;
expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe(
'u1',
);
it('rejects pre-separation legacy tokens — the dual-verify window is gone (#296)', () => {
const now = Date.now();
// Unexpired on its own terms — rejected because only the subkey verifies.
expect(verifyUnsubscribeToken(legacyToken('u1', now - 1000), secret, now)).toBeNull();
});
it('rejects a legacy token once the dual-verify window has closed', () => {
const afterWindow = LEGACY_VERIFY_UNTIL + 1000;
// Unexpired on its own terms — rejected purely because the window closed.
const token = legacyToken('u1', afterWindow - 1000);
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');
it('verifies freshly minted tokens via the subkey', () => {
const now = Date.now();
const token = signUnsubscribeToken('u1', secret, now);
expect(verifyUnsubscribeToken(token, secret, now + 1000)).toBe('u1');
});
});

View File

@ -13,26 +13,17 @@ import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
const TTL_SECONDS = 90 * 24 * 60 * 60;
/**
* Dual-verify window (#188, ADR 0020): before the key separation, tokens
* were HMACed with the root secret over a `digest-unsubscribe.` prefix.
* Those links live in digest mails that are already sent and stay valid
* for their full 90-day TTL, so verification accepts the legacy derivation
* until every pre-separation token has expired. Tokens are only ever
* 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';
// The pre-#188 dual-verify window (root secret + `digest-unsubscribe.`
// prefix) was removed EARLY by operator decision at the ADR 0020
// acceptance (issue #296): links in mails sent before the key separation
// no longer work — recipients use the in-app notification settings.
// Verification is subkey-only; the regression test pins that the legacy
// derivation can never verify again.
function signature(body: string, rootSecret: string): Buffer {
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 {
const body = Buffer.from(
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
@ -59,10 +50,7 @@ export function verifyUnsubscribeToken(
} catch {
return null;
}
const current = matches(provided, signature(body, rootSecret));
const legacy =
!current && now < LEGACY_VERIFY_UNTIL && matches(provided, legacySignature(body, rootSecret));
if (!current && !legacy) return null;
if (!matches(provided, signature(body, rootSecret))) return null;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
userId?: string;

View File

@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module';
import { WatchesModule } from '../watches/watches.module';
import { SearchModule } from '../search/search.module';
@ -10,7 +9,7 @@ import { PluginApiController } from './plugin-api.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [PondsModule, SearchModule, WatchesModule],
imports: [SearchModule, WatchesModule],
controllers: [PagesController, PluginApiController],
providers: [PagesService, TasksService],
exports: [PagesService, TasksService],

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service';
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';
/**
@ -23,6 +23,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
const userIds: Record<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let startPageId: string;
let openPageId: string;
let secretPageId: string;
@ -71,6 +72,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
.send({ name: `PlugApi Pond ${suffix}` })
.expect(201);
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()
.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.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await deletePondsWhere(prisma, { id: pondId });
const ids = Object.values(userIds);
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.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
@ -144,7 +149,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
.set('Cookie', cookies.owner!)
.expect(200);
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) });
// Label *names* travel with each summary (issue #77, page-index filter).

View File

@ -26,48 +26,56 @@ describe('sort-key helpers (issue #45)', () => {
* pattern repeatedly drop the last page between the first two must never
* collide and never overflow the key length, because the caller rebalances
* when {@link nextKeyOrRebalance} returns null.
*
* Under parallel CI load the 10.000 iterations have repeatedly exceeded the
* default 5 s per-test timeout (runs 685, 699 same code passed on rerun),
* so this test carries its own budget.
*/
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
// Start with five pages in a fixed order.
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
let rebalances = 0;
it(
'10.000 adversarial reorders never collide or overflow (rebalance verified)',
{ timeout: 30_000 },
() => {
// Start with five pages in a fixed order.
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
let rebalances = 0;
const rebalance = (): void => {
const keys = evenlySpacedKeys(order.length);
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
rebalances += 1;
};
const rebalance = (): void => {
const keys = evenlySpacedKeys(order.length);
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
rebalances += 1;
};
for (let i = 0; i < 10_000; i += 1) {
// Move the last page to sit between the first and second — the tightest
// possible gap, which is what grows key length fastest.
const moved = order[order.length - 1]!;
const rest = order.slice(0, -1);
const afterKey = rest[0]!.key;
const beforeKey = rest[1]!.key;
for (let i = 0; i < 10_000; i += 1) {
// Move the last page to sit between the first and second — the tightest
// possible gap, which is what grows key length fastest.
const moved = order[order.length - 1]!;
const rest = order.slice(0, -1);
const afterKey = rest[0]!.key;
const beforeKey = rest[1]!.key;
const key = nextKeyOrRebalance(afterKey, beforeKey);
if (key === null) {
// Rebalance keeps the CURRENT order, then retry the move once.
rebalance();
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
expect(k2).not.toBeNull();
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
} else {
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
const key = nextKeyOrRebalance(afterKey, beforeKey);
if (key === null) {
// Rebalance keeps the CURRENT order, then retry the move once.
rebalance();
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
expect(k2).not.toBeNull();
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
} else {
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
}
// Invariants after every move: keys unique, bounded, and consistent with
// the intended array order.
const keys = order.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
for (let j = 1; j < keys.length; j += 1) {
expect(keys[j - 1]! < keys[j]!).toBe(true);
}
}
// Invariants after every move: keys unique, bounded, and consistent with
// the intended array order.
const keys = order.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
for (let j = 1; j < keys.length; j += 1) {
expect(keys[j - 1]! < keys[j]!).toBe(true);
}
}
// The adversarial pattern must have forced at least one rebalance.
expect(rebalances).toBeGreaterThan(0);
});
// The adversarial pattern must have forced at least one rebalance.
expect(rebalances).toBeGreaterThan(0);
},
);
});

View File

@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service';
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';
/**
@ -120,11 +120,11 @@ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => {
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await deletePondsWhere(prisma, { id: pondId });
// Personal ponds (and their grants) before their users.
const ids = Object.values(userIds);
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.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();

View File

@ -2,6 +2,7 @@ import {
BadRequestException,
Body,
ConflictException,
ForbiddenException,
Controller,
Delete,
Get,
@ -40,6 +41,10 @@ function toHttpException(error: PluginPackageError): HttpException {
case 'plugin_version_not_higher':
case 'plugin_not_optional':
return new ConflictException(body);
// Hash pinning (#232): the upload is well-formed, the policy says no.
case 'plugin_not_pinned':
case 'plugin_hash_mismatch':
return new ForbiddenException(body);
case 'plugin_too_large':
return new PayloadTooLargeException(body);
default:

View File

@ -96,7 +96,9 @@ export class PluginAssetsController {
@Param('version') version: string,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const plugin = await this.plugins.get(id);
// getServable enforces the hash-pinning allowlist (#232): a blocked
// plugin 404s here exactly like a missing one, and is audited.
const plugin = await this.plugins.getServable(id);
if (!plugin || plugin.version !== version) throw new NotFoundException();
// Asset base is built from the configured public origin, not the request
@ -122,8 +124,9 @@ export class PluginAssetsController {
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
// Only serve assets for an installed, current version — a removed plugin or
// a stale version pointer must not leak files.
const plugin = await this.plugins.get(id);
// a stale version pointer must not leak files. getServable additionally
// enforces the hash-pinning allowlist (#232).
const plugin = await this.plugins.getServable(id);
if (!plugin || plugin.version !== version) throw new NotFoundException();
const rest = (request.params as Record<string, unknown>).rest;

View File

@ -0,0 +1,172 @@
import { createHash } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { zipSync } from 'fflate';
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, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const enc = (text: string) => new TextEncoder().encode(text);
function manifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'pin-me',
name: 'Pin Me',
version: '1.0.0',
apiVersion: '1',
kind: 'code',
extensionPoints: [{ type: 'pageTool', id: 'pin', title: { de: 'Pin', en: 'Pin' } }],
permissions: [],
license: 'MIT',
...overrides,
};
}
function pluginZip(m: Record<string, unknown>, bundle = 'export default {}'): Buffer {
return Buffer.from(
zipSync({ 'manifest.json': enc(JSON.stringify(m)), 'plugin.js': enc(bundle) }),
);
}
const sha256 = (buffer: Buffer) => createHash('sha256').update(buffer).digest('hex');
/**
* Hash-pinning allowlist (issue #232, ADR 0025): with a non-empty
* `plugins.allowlist`, only pinned ids with the exact bundle hash install
* and load; tampered or unpinned bundles fail closed with a stable code,
* every rejection is audited, and a version bump requires an explicit
* re-pin. Empty allowlist = unchanged behaviour (backward compatible).
*/
describe.skipIf(!hasTestDb)('plugin hash pinning (e2e, issue #232)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let settings: InstanceSettingsService;
const suffix = uniqueSuffix();
const password = 'gepinnt ist gepinnt 1';
let adminCookie: string;
let adminId: string;
const api = () => request(app.getHttpServer());
const zipV1 = pluginZip(manifest());
const zipV1Tampered = pluginZip(manifest(), 'export default { evil: true }');
const zipV2 = pluginZip(manifest({ version: '1.1.0' }));
async function setAllowlist(entries: { id: string; sha256: string }[]): Promise<void> {
await settings.set('plugins.allowlist', entries, adminId);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
app = await createTestApp();
settings = app.get(InstanceSettingsService);
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: `pin-admin-${suffix}`,
email: `pin-admin-${suffix}@example.org`,
displayName: 'Pin Admin',
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
adminId = adminUser.id;
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `pin-admin-${suffix}`, password })
.expect(200),
);
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await prisma.auditEntry.deleteMany({ where: { targetId: { in: ['pin-me', 'stranger'] } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('empty allowlist: installs record the hash, nothing is enforced', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV1, 'pin-me.zip')
.expect(201);
expect(res.body.pinning).toBe('not_enforced');
expect(res.body.bundleSha256).toBe(sha256(zipV1));
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
});
it('pinned hash: plugin stays loadable and reports pinned', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
expect(list.body[0].pinning).toBe('pinned');
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
});
it('unpinned plugin: install rejected with the stable code and audited', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(manifest({ id: 'stranger', name: 'Stranger' })), 'stranger.zip')
.expect(403);
expect(res.body.code).toBe('plugin_not_pinned');
const audit = await prisma.auditEntry.findFirst({
where: { action: 'plugin.rejected', targetId: 'stranger' },
});
expect(audit).not.toBeNull();
});
it('tampered bundle: same id + pin, different bytes — install rejected', async () => {
// A tampered re-delivery of the pinned version arrives as an update
// attempt; the hash gate must fire before any version comparison.
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV1Tampered, 'pin-me.zip')
.expect(403);
expect(res.body.code).toBe('plugin_hash_mismatch');
});
it('pin changed away from the installed hash: plugin no longer loads, audited', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1Tampered) }]);
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
// The admin still SEES the plugin, with the deviation named…
expect(list.body[0].pinning).toBe('mismatch');
// …but nothing serves it: frame 404s and the rejection is audited.
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(404);
const audit = await prisma.auditEntry.findFirst({
where: { action: 'plugin.rejected', targetId: 'pin-me' },
});
expect(audit).not.toBeNull();
});
it('version bump requires an explicit re-pin', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
const rejected = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV2, 'pin-me-1.1.zip')
.expect(403);
expect(rejected.body.code).toBe('plugin_hash_mismatch');
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV2) }]);
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV2, 'pin-me-1.1.zip')
.expect(201);
expect(res.body.pinning).toBe('pinned');
await api().get(`/api/v1/plugins/pin-me/1.1.0/frame`).expect(200);
});
});

View File

@ -9,7 +9,7 @@ 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 { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { PluginStorageService } from './plugin-storage.service';
@ -456,7 +456,7 @@ describe.skipIf(!hasTestDb)('plugins kill switch (e2e, issue #200)', () => {
await prisma.plugin.deleteMany({ where: { id: pluginId } });
const where = { pond: { owner: { username: { contains: suffix } } } };
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 app.close();
});

View File

@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
@ -12,6 +14,7 @@ import type {
import { ClockService } from '../common/clock.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service';
@ -44,6 +47,7 @@ export class PluginsService {
private readonly storage: PluginStorageService,
private readonly clock: ClockService,
private readonly audit: AuditService,
private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PluginsService.name);
@ -58,6 +62,32 @@ export class PluginsService {
/** `actor` is absent for dropzone installs (watcher, no session). */
async install(zip: Buffer, actor?: User): Promise<PluginView> {
const { manifest, files } = this.packages.parse(zip);
const bundleHash = createHash('sha256').update(zip).digest('hex');
// Hash pinning (#232, ADR 0025): while the allowlist is non-empty,
// only listed ids with the exact pinned bundle hash may install.
// Fail closed with a distinct code per cause; every rejection is
// audited so a tampering attempt leaves a trace.
const allowlist = await this.settings.get('plugins.allowlist');
if (allowlist.length > 0) {
const pin = allowlist.find((entry) => entry.id === manifest.id);
if (!pin || pin.sha256 !== bundleHash) {
const reason = pin ? 'hash_mismatch' : 'not_pinned';
await this.audit.record({
action: 'plugin.rejected',
actorId: actor?.id,
targetType: 'plugin',
targetId: manifest.id,
details: { surface: 'install', reason, version: manifest.version, bundleHash },
});
throw new PluginPackageError(
pin ? 'plugin_hash_mismatch' : 'plugin_not_pinned',
pin
? `Bundle hash ${bundleHash} does not match the pinned hash for ${manifest.id}`
: `Plugin ${manifest.id} is not on the allowlist`,
);
}
}
const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
const isActiveUpdate = existing !== null && existing.removedAt === null;
@ -81,6 +111,7 @@ export class PluginsService {
apiVersion: manifest.apiVersion,
kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
},
update: {
name: manifest.name,
@ -88,6 +119,7 @@ export class PluginsService {
apiVersion: manifest.apiVersion,
kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
// Reinstalling a previously removed plugin clears the tombstone.
removedAt: null,
},
@ -105,7 +137,7 @@ export class PluginsService {
targetId: manifest.id,
details: { version: manifest.version, update: isActiveUpdate },
});
return this.toView(record);
return this.toView(record, await this.settings.get('plugins.allowlist'));
}
/**
@ -131,7 +163,7 @@ export class PluginsService {
targetId: id,
details: { mode },
});
return this.toView(updated);
return this.toView(updated, await this.settings.get('plugins.allowlist'));
}
/**
@ -182,9 +214,18 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }),
]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
return plugins
.filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true))
.map((p) => this.toView(p));
const allowlist = await this.settings.get('plugins.allowlist');
return (
plugins
.filter(
(p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true),
)
// Hash pinning (#232): a plugin outside the allowlist, or with a
// deviating bundle hash, does not load — it simply never appears in
// the pond's mount list. Existing blocks render their fallback.
.filter((p) => this.loadable(this.verdict(p, allowlist)))
.map((p) => this.toView(p, allowlist))
);
}
/**
@ -219,7 +260,11 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }),
]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true }));
const allowlist = await this.settings.get('plugins.allowlist');
return plugins.map((p) => ({
plugin: this.toView(p, allowlist),
enabled: enabled.get(p.id) === true,
}));
}
/**
@ -287,19 +332,66 @@ export class PluginsService {
where: { removedAt: null },
orderBy: { name: 'asc' },
});
return plugins.map((plugin) => this.toView(plugin));
const allowlist = await this.settings.get('plugins.allowlist');
return plugins.map((plugin) => this.toView(plugin, allowlist));
}
/** One installed plugin, or `null` if absent/removed. */
async get(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null;
return this.toView(plugin);
return this.toView(plugin, await this.settings.get('plugins.allowlist'));
}
private toView(plugin: Plugin): PluginView {
/**
* Pinning verdict against `plugins.allowlist` (#232). The observed hash
* is the one recorded at install: post-install tampering with unpacked
* files on disk is platform integrity (ADR 0019), not this check's
* scope the pin answers "is this the reviewed bundle".
*/
private verdict(
plugin: Plugin,
allowlist: { id: string; sha256: string }[],
): PluginView['pinning'] {
if (allowlist.length === 0) return 'not_enforced';
const pin = allowlist.find((entry) => entry.id === plugin.id);
if (!pin) return 'unpinned';
return plugin.bundleHash === pin.sha256 ? 'pinned' : 'mismatch';
}
private loadable(verdict: PluginView['pinning']): boolean {
return verdict === 'not_enforced' || verdict === 'pinned';
}
/**
* The load gate for code-serving surfaces (frame + assets, #232): an
* installed plugin outside the allowlist, or with a deviating bundle
* hash, does not load 404 like a missing plugin, and the rejection is
* audited (unlike the silent list filtering, an asset request proves
* something actively referenced the blocked plugin).
*/
async getServable(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null;
const allowlist = await this.settings.get('plugins.allowlist');
const verdict = this.verdict(plugin, allowlist);
if (!this.loadable(verdict)) {
await this.audit.record({
action: 'plugin.rejected',
targetType: 'plugin',
targetId: id,
details: { surface: 'load', reason: verdict, version: plugin.version },
});
return null;
}
return this.toView(plugin, allowlist);
}
private toView(plugin: Plugin, allowlist: { id: string; sha256: string }[]): PluginView {
const manifest = plugin.manifest as unknown as PluginManifest;
return {
bundleSha256: plugin.bundleHash,
pinning: this.verdict(plugin, allowlist),
id: plugin.id,
name: plugin.name,
version: plugin.version,

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } 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 { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
import { PondAccessNotifier } from './pond-access-notifier.service';
@ -81,7 +81,7 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
await prisma.quotaOverride.deleteMany({
where: { subjectId: { in: users.map((u) => u.id) } },
});
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.$disconnect();
await app.close();
@ -106,6 +106,107 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1);
});
it('gives a new shared pond a start page and points settings at it (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Startseitenteich ${suffix}` })
.expect(201);
const pages = await api()
.get(`/api/v1/ponds/${created.body.id}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pages.body).toHaveLength(1);
// The title follows the creator's stored locale — this owner is 'de'.
expect(pages.body[0].title).toBe('Startseite');
const pond = await api()
.get(`/api/v1/ponds/${created.body.slug}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pond.body.settings.startPageId).toBe(pages.body[0].id);
});
it("titles the start page in the creator's locale (issue #302)", async () => {
// The owner is 'de' and got "Startseite" above; an 'en' account must get
// the English title. Without both halves the test would pass on a
// hardcoded string just as happily.
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const username = `ellie-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Ellie English ${suffix}`,
password,
locale: 'en',
});
const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token }).expect(204);
const pond = await prisma.pond.findFirstOrThrow({
where: { ownerId: user.id, type: 'PERSONAL' },
});
const pages = await prisma.page.findMany({ where: { pondId: pond.id } });
expect(pages.map((page) => page.title)).toEqual(['Home']);
});
it('gives the personal pond a start page too (issue #302)', async () => {
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
const personal = res.body.find((p: { type: string }) => p.type === 'personal');
const pages = await api()
.get(`/api/v1/ponds/${personal.id}/pages`)
.set('Cookie', ownerCookie)
.expect(200);
expect(pages.body).toHaveLength(1);
expect(personal.settings.startPageId).toBe(pages.body[0].id);
});
it('changes the start page without losing other settings (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Wechselteich ${suffix}` })
.expect(201);
await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ commentPolicy: 'editors' })
.expect(200);
const second = await api()
.post(`/api/v1/ponds/${created.body.id}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Zweite ${suffix}` })
.expect(201);
const updated = await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ startPageId: second.body.id })
.expect(200);
expect(updated.body.settings.startPageId).toBe(second.body.id);
// The neighbouring key must survive the merge — settings hold only
// deviations, so an assigning write would silently reset it.
expect(updated.body.settings.commentPolicy).toBe('editors');
});
it('clears the start page back to the sort-order default (issue #302)', async () => {
const created = await api()
.post('/api/v1/ponds')
.set('Cookie', ownerCookie)
.send({ name: `Leerteich ${suffix}` })
.expect(201);
const cleared = await api()
.patch(`/api/v1/ponds/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ startPageId: null })
.expect(200);
expect(cleared.body.settings.startPageId).toBeNull();
});
it('creates shared ponds with deterministic slug suffixes', async () => {
const name = `Gartenteich ${suffix}`;
const first = await api()

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { PagesModule } from '../pages/pages.module';
import { QuotasModule } from '../quotas/quotas.module';
import { SearchModule } from '../search/search.module';
@ -8,7 +9,7 @@ import { PondsController } from './ponds.controller';
import { PondsService } from './ponds.service';
@Module({
imports: [QuotasModule, SearchModule],
imports: [PagesModule, QuotasModule, SearchModule],
controllers: [PondsController],
providers: [PondsService, PondAccessNotifier],
exports: [PondsService, PondAccessNotifier],

View File

@ -9,6 +9,8 @@ import {
import { Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { apiI18n } from '../i18n/api-i18n';
import { PagesService } from '../pages/pages.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
@ -23,11 +25,52 @@ export class PondsService {
private readonly quotas: QuotaService,
private readonly accessNotifier: PondAccessNotifier,
private readonly search: SearchProvider,
private readonly pages: PagesService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PondsService.name);
}
/**
* Every new pond opens on a start page instead of the empty-pond hint
* (issue #302). Deliberately AFTER the creating transaction commits: the
* owner's POND_ADMIN grant is written inside it and the permission layer
* caches per pond, so creating the page in the same transaction would ask
* about rights the grant has not yet published.
*
* Goes through PagesService so the page carries every invariant a page
* needs unique slug, appended sort key, derived content cache, search
* indexing, and an `emptyPageState()` the collab server can bind to. A
* hand-rolled insert here would produce a page the editor cannot open.
*
* Failure is logged, not fatal: a pond without a start page simply falls
* back to the historical behaviour, which is a working state. Losing the
* whole pond over its first page would not be.
*/
private async createStartPage(owner: User, pondId: string): Promise<void> {
try {
const title = apiI18n.t('ponds:startPage.title', {
lng: owner.locale === 'de' ? 'de' : 'en',
});
const page = await this.pages.create(owner, pondId, { title });
const pond = await this.prisma.pond.findUniqueOrThrow({
where: { id: pondId },
select: { settings: true },
});
// Merge rather than assign: stored settings hold only deviations from
// the defaults, and overwriting the object would drop them.
await this.prisma.pond.update({
where: { id: pondId },
data: { settings: { ...(pond.settings as object), startPageId: page.id } },
});
} catch (error) {
this.logger.error(
{ pondId, err: error instanceof Error ? error.message : String(error) },
'start page for new pond could not be created',
);
}
}
viewOf(pond: Pond): PondView {
return {
id: pond.id,
@ -105,7 +148,12 @@ export class PondsService {
return created;
});
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
return this.viewOf(pond);
await this.createStartPage(owner, pond.id);
// Re-read: the row captured in the transaction predates the start page,
// so returning it would hand the caller `startPageId: null` for a pond
// that has one.
const withStartPage = await this.prisma.pond.findUniqueOrThrow({ where: { id: pond.id } });
return this.viewOf(withStartPage);
}
/**
@ -128,6 +176,7 @@ export class PondsService {
return created;
});
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
await this.createStartPage(user, pond.id);
}
async listVisible(user: User): Promise<PondView[]> {
@ -157,7 +206,8 @@ export class PondsService {
input.commentPolicy !== undefined ||
input.apiEnabled !== undefined ||
input.mcpEnabled !== undefined ||
input.theme !== undefined;
input.theme !== undefined ||
input.startPageId !== undefined;
const settings = !settingsChanged
? undefined
: {
@ -169,6 +219,7 @@ export class PondsService {
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
...(input.theme !== undefined ? { theme: input.theme } : {}),
...(input.startPageId !== undefined ? { startPageId: input.startPageId } : {}),
};
const updated = await this.prisma.pond.update({
where: { id },

View File

@ -1,5 +1,10 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared';
import {
DEFAULT_ATTACHMENT_EXTENSIONS,
VS_NFD_PROFILE,
brandingAssetSchema,
isVsNfdCompliant,
} from '@dorfteich/shared';
import { Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod';
@ -16,8 +21,21 @@ import { PrismaService } from '../prisma/prisma.service';
*/
export const INSTANCE_SETTINGS = {
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
// Peer invitations (issue #332): max OPEN (pending, unexpired)
// invitations per user; 0 turns inviting off entirely.
'invitations.maxOpenPerUser': z.number().int().min(0).default(5),
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
// Branding assets (issue #306). Metadata only — the PNG bytes live under
// BRANDING_DIR and travel in the restore set; `hash` goes into the serving
// URL so a replaced asset is picked up without cache trouble. Null = not
// uploaded: the instance name renders as text, the favicon falls back to
// the shipped default. `logoDark` is optional by design — without it the
// LIGHT logo is used in both themes, because showing the operator's own
// asset unchanged beats substituting one they did not choose (#307).
'instance.logo': brandingAssetSchema.nullable().default(null),
'instance.logoDark': brandingAssetSchema.nullable().default(null),
'instance.favicon': brandingAssetSchema.nullable().default(null),
// Instance-default quotas (ADR 0011); per-user/per-pond overrides live
// in quota_overrides and win over these (QuotaService, issue #22).
'quota.editorsPerPond': z.number().int().min(0).default(5),
@ -140,6 +158,26 @@ export const INSTANCE_SETTINGS = {
// (an image fallback degrades to neutral text: its bytes live on the
// disabled asset surface).
'plugins.enabled': z.boolean().default(true),
// Plugin allowlist with SHA-256 hash pinning (issue #232, ADR 0025).
// Empty (the default) = pinning is NOT enforced — plugins load as
// before, which keeps existing instances working. Non-empty = only the
// listed plugin ids load, and only while the installed bundle's
// observed hash equals the pinned one; installs of unlisted or
// mismatching bundles are rejected, loads fail closed (assets/frame
// 404, audited `plugin.rejected`). A version bump changes the bundle
// hash, so it requires an explicit re-pin — the intended friction.
'plugins.allowlist': z
.array(
z.object({
id: z.string().min(1),
sha256: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-f0-9]{64}$/),
}),
)
.default([]),
// Atom feed master switch (issue #191). Default ON: feeds predate the
// switch, so existing instances and their subscribed readers keep
// working; the VS-NfD reference configuration (#227) turns it off.

View File

@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
expect(locked.body.code).toBe('setup_locked');
});
});
describe('env pre-seeding with invalid values (issue #325)', () => {
const dbName = `dorfteich_preseed_bad_${suffix}`;
let app: INestApplication;
const badEnv = {
SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`,
SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`,
SETUP_ADMIN_PASSWORD: 'short',
} as const;
beforeAll(async () => {
const url = await createFreshDatabase(dbName);
process.env.TEST_DATABASE_URL = url;
process.env.SECRETS_FILE = join(
mkdtempSync(join(tmpdir(), 'dorfteich-preseed-bad-')),
'secrets.env',
);
Object.assign(process.env, badEnv);
app = await createTestApp();
}, 60_000);
afterAll(async () => {
for (const key of Object.keys(badEnv)) delete process.env[key];
await app.close();
await dropDatabase(dbName);
});
it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => {
await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow(
/SETUP_ADMIN_PASSWORD must be at least 10 characters/,
);
// Fail-fast left nothing half-seeded: the wizard is still pending.
const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200);
expect(status.body.status).toBe('required');
});
});
});
interface FakeSmtpServer {

View File

@ -15,6 +15,7 @@ import {
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { ZodError } from 'zod';
import { SessionsService } from '../auth/sessions.service';
import { AppConfig } from '../config/app-config.service';
@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit {
if (!(await this.state.isPending())) return;
// Fails the boot loudly on invalid values — a half-seeded instance
// would be much harder to diagnose than a startup error.
const input = setupAdminInputSchema.parse({
// would be much harder to diagnose than a startup error. Translated
// into operator terms first: the raw ZodError names schema fields and
// i18n keys, not the SETUP_* variable to fix (issue #325).
const parsed = setupAdminInputSchema.safeParse({
username: env.SETUP_ADMIN_USERNAME,
email: env.SETUP_ADMIN_EMAIL,
password: env.SETUP_ADMIN_PASSWORD,
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
locale: env.SETUP_DEFAULT_LOCALE,
});
if (!parsed.success) {
throw new Error(
`Pre-seeding failed: ${describePreseedIssues(parsed.error)}. ` +
'Fix .env and recreate the api container.',
);
}
const input = parsed.data;
const admin = await this.createAdmin(input);
if (env.SETUP_INSTANCE_NAME) {
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
@ -223,3 +233,27 @@ export class SetupService implements OnModuleInit {
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
}
}
/** The env variable behind each schema field of the pre-seeded admin. */
const PRESEED_FIELD_TO_ENV: Record<string, string> = {
username: 'SETUP_ADMIN_USERNAME',
email: 'SETUP_ADMIN_EMAIL',
password: 'SETUP_ADMIN_PASSWORD',
displayName: 'SETUP_ADMIN_DISPLAY_NAME',
locale: 'SETUP_DEFAULT_LOCALE',
};
function describePreseedIssues(error: ZodError): string {
return error.issues
.map((issue) => {
const variable = PRESEED_FIELD_TO_ENV[String(issue.path[0])] ?? String(issue.path[0]);
if (issue.code === 'too_small' && issue.type === 'string') {
return `${variable} must be at least ${issue.minimum} characters`;
}
if (issue.code === 'invalid_string' && issue.validation === 'email') {
return `${variable} is not a valid e-mail address`;
}
return `${variable} is invalid (${issue.message})`;
})
.join('; ');
}

View File

@ -1,4 +1,4 @@
import { PrismaClient } from '@prisma/client';
import { Prisma, PrismaClient } from '@prisma/client';
/** True when database-backed tests can run (see vitest.global-setup.ts). */
export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL);
@ -40,3 +40,27 @@ export async function grantOwnerAdmin(
},
});
}
/**
* Deletes the ponds matching `where`, their pages first.
*
* `Page.pond` deliberately carries no `onDelete: Cascade` a real purge
* (TrashService) removes a pond's contents explicitly and audits it, and a
* silent database cascade would hide that. Since issue #302 every pond
* created through the api starts with a page, so teardowns that went
* straight for `pond.deleteMany` now hit the foreign key.
*
* Page-owned rows (updates, comments, links, ) do cascade from the page.
*/
export async function deletePondsWhere(
prisma: PrismaClient,
where: Prisma.PondWhereInput,
): Promise<void> {
const pondIds = (await prisma.pond.findMany({ where, select: { id: true } })).map(
(pond) => pond.id,
);
if (pondIds.length === 0) return;
await prisma.attachment.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
}

View File

@ -231,7 +231,10 @@ describe.skipIf(!hasTestDb)('pond purge (e2e, issue #193)', () => {
where: { action: 'pond.purged', targetId: pondId },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 2, attachments: 1 });
// Two pages created here plus the pond's own start page (issue #302) —
// the audit records what was actually removed, so the count moves with
// the pond's real contents rather than with what the test typed out.
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 3, attachments: 1 });
});
it('purges due ponds on the retention path with an audit event', async () => {

View File

@ -1,5 +1,6 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { BrandingModule } from '../branding/branding.module';
import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module';
import { PagesModule } from '../pages/pages.module';
@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
@Module({
imports: [
BrandingModule,
CommonModule,
PondsModule,
QuotasModule,

View File

@ -4,6 +4,7 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { BrandingService } from '../branding/branding.service';
import { ClockService } from '../common/clock.service';
import { SearchProvider } from '../search/search.provider';
import { PagesService } from '../pages/pages.service';
@ -32,6 +33,7 @@ export class TrashService {
private readonly settings: InstanceSettingsService,
private readonly quotas: QuotaService,
private readonly storage: FileStorageService,
private readonly branding: BrandingService,
private readonly clock: ClockService,
private readonly watches: WatchesService,
private readonly audit: AuditService,
@ -183,6 +185,9 @@ export class TrashService {
for (const attachment of attachments) {
await this.storage.delete(pondId, attachment.id);
}
// The pond's branding files (issue #307). The purge standard is absolute:
// after it, nothing referencing the pond survives — rows OR files.
await this.branding.removePondAssets(pondId);
const pageIds = (
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
).map((page) => page.id);

View File

@ -20,6 +20,7 @@ ENV NODE_ENV=production APP_VERSION=${APP_VERSION} \
# Baked-in volume paths (self-sufficient without compose env, like the
# api image's PLUGINS_DIR — issue #71's lesson).
BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \
CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding \
SECRETS_FILE=/data/secrets/secrets.env
# pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the
# volume archives, tzdata so BACKUP_TIME honors a configured TZ, and

View File

@ -0,0 +1,30 @@
import { backupEnvSchema } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest';
import { dataDirs } from './data-dirs.js';
/**
* The fence against the failure #303 hit and #306 could repeat: a new data
* directory gets its env entry but not its line here, and the nightly archive
* skips it WORDLESSLY (`createArchive` tolerates missing directories on
* purpose). Nobody notices until a restore comes up short.
*
* Every `*_DIR` the backup sidecar knows must therefore travel in the archive.
* `BACKUPS_DIR` is the exception by definition it is where the archive is
* written, not something archived into it.
*/
const NOT_DATA = new Set(['BACKUPS_DIR']);
describe('data directories (issues #303/#306)', () => {
it('archives every *_DIR the backup env declares', () => {
const env = backupEnvSchema.parse({ DATABASE_URL: 'postgresql://x/y' });
const values = env as unknown as Record<string, unknown>;
const declared = Object.keys(env).filter((key) => key.endsWith('_DIR') && !NOT_DATA.has(key));
const archived = dataDirs(env);
expect(declared.length).toBeGreaterThan(0);
for (const key of declared) {
expect(archived, `${key} is missing from dataDirs()`).toContain(values[key]);
}
});
});

View File

@ -0,0 +1,18 @@
import type { BackupEnv } from '@dorfteich/shared';
/**
* The data directories that travel in a restore set's archive (ADR 0015).
*
* ONE list, used by the nightly archive AND by the restore they must not
* drift, or a backup would carry something the restore never puts back.
* Adding a new persistent data directory is a one-line change here plus the
* env entry and the compose mount.
*
* All of them must share a parent directory: `archiveBase` derives the tar
* root from that and throws otherwise.
*/
export function dataDirs(
env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR' | 'BRANDING_DIR'>,
): string[] {
return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR, env.BRANDING_DIR];
}

View File

@ -6,6 +6,7 @@ import { pino } from 'pino';
import { createArchive } from './archive.js';
import { createCommandListener } from './commands.js';
import { dataDirs } from './data-dirs.js';
import { loadBackupEnv } from './config.js';
import { sendFailureMail } from './mail.js';
import { mirrorSets, resolveMirrorConfig } from './mirror.js';
@ -50,7 +51,7 @@ async function buildRunnerDeps(trigger: 'scheduled' | 'manual'): Promise<RunnerD
retentionDays: settings.localRetentionDays ?? env.BACKUP_RETENTION_DAYS,
now: () => new Date(),
dump: (outFile) => pgDump(env.DATABASE_URL, outFile),
archive: (outFile) => createArchive(outFile, [env.UPLOADS_DIR, env.PLUGINS_DIR]),
archive: (outFile) => createArchive(outFile, dataDirs(env)),
onFailure: async (run) => {
const sent = await sendFailureMail(env, run);
if (!sent)

View File

@ -5,18 +5,27 @@ import type { BackupEnv } from '@dorfteich/shared';
import { extractArchive } from './archive.js';
import { archiveFileName, dumpFileName } from './backup-set.js';
import { dataDirs } from './data-dirs.js';
import { pgRestore } from './pg.js';
import type { RemoteLogger } from './remote.js';
/**
* Restores one local set into the live database and data volumes:
* `pg_restore --clean --if-exists` of the dump, then the volume archive
* back over the uploads/plugins mounts. Shared by the operator CLI
* back over the data mounts (see data-dirs.ts). Shared by the operator CLI
* (restore.js via restore.sh) and the in-app restore orchestrator (#103)
* one restore path, exercised by drills and the app alike.
*/
export async function performRestore(
env: Pick<BackupEnv, 'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR'>,
env: Pick<
BackupEnv,
| 'BACKUPS_DIR'
| 'DATABASE_URL'
| 'UPLOADS_DIR'
| 'PLUGINS_DIR'
| 'CUSTOM_FONTS_DIR'
| 'BRANDING_DIR'
>,
backupId: string,
log: RemoteLogger,
): Promise<void> {
@ -29,6 +38,6 @@ export async function performRestore(
}
log.info({ backupId }, 'restoring database dump');
await pgRestore(env.DATABASE_URL, dumpFile);
log.info({ backupId }, 'restoring uploads/plugins archive');
await extractArchive(archiveFile, [env.UPLOADS_DIR, env.PLUGINS_DIR]);
log.info({ backupId }, 'restoring the data-directory archive');
await extractArchive(archiveFile, dataDirs(env));
}

View File

@ -87,7 +87,27 @@ for (const scheme of SCHEMES) {
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Einladungs-Abschnitt (issue #332) gerendert — sonst liefe der Scan
// auch grün, wenn die Sektion gar nicht erscheint.
await page.locator('.invitations').waitFor();
await expectClean(page, `/settings (${scheme})`);
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
// eigenen Schriften zwei Tabellen samt Scroll-Regionen). Bewusst
// KEIN eigener Test — jeder zusätzliche Login im Pack bringt die
// CI zwei Packs später ans Rate-Limit (Lehre aus #301).
await page.goto('/fonts');
await page.waitForLoadState('networkidle');
await expectClean(page, `/fonts (${scheme})`);
// Teich-Einstellungen im selben Kontext (fixture-user besitzt den
// Fixture-Teich): dort sitzt seit issue #305 das Archiv-Angebot in der
// Löschzone. Wieder KEIN eigener Test — zusätzliche Logins kippen die
// CI zwei Packs später am Rate-Limit (Lehre aus #301).
await page.goto('/p/content-fixtures/settings');
await page.waitForLoadState('networkidle');
await page.locator('.pond-archive__download').waitFor();
await expectClean(page, `Teich-Einstellungen (${scheme})`);
await context.close();
});
@ -99,8 +119,111 @@ for (const scheme of SCHEMES) {
await page.waitForLoadState('networkidle');
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
await page.locator('.user-manager__table .user-row').first().waitFor();
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
// Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
await expectClean(page, `/admin (${scheme})`);
// Anlage-Dialog (issue #331) im selben Kontext öffnen und mitscannen —
// wieder KEIN eigener Test (Rate-Limit-Lehre aus #301).
await page.locator('.user-manager__create').click();
await page.locator('.create-user-dialog').waitFor();
await expectClean(page, `/admin Anlage-Dialog (${scheme})`);
await context.close();
});
});
}
/**
* Reflow (WCAG 2.1 SC 1.4.10, issue #301): bei 320 px CSS-Breite was 400 %
* Zoom auf 1280 px entspricht darf die Seite nicht seitenweit horizontal
* scrollen. axe prüft das NICHT, das Kriterium ist nicht maschinell aus dem
* DOM ableitbar; deshalb ein eigener Zaun.
*
* Schlägt er an, nennt er die überstehenden Elemente. Ohne diese Diagnose
* weiß man nur DASS es überläuft und muss im Browser bisektieren.
*/
const NARROW = { width: 320, height: 800 };
async function expectNoHorizontalScroll(page: Page, label: string): Promise<void> {
const report = await page.evaluate(() => {
const doc = document.documentElement;
const limit = doc.clientWidth;
const describe = (el: Element): string => {
const cls =
el.className && typeof el.className === 'string'
? `.${el.className.trim().split(/\s+/).join('.')}`
: '';
return `${el.tagName.toLowerCase()}${cls}`;
};
// Every element whose own content is wider than its box. One of these is
// the source; the ones that scroll it away on purpose are marked.
const overflowing: string[] = [];
for (const el of Array.from(document.querySelectorAll('*'))) {
if (el.scrollWidth > el.clientWidth + 1 && el.clientWidth > 0) {
const overflowX = getComputedStyle(el).overflowX;
overflowing.push(
`${describe(el)} client=${el.clientWidth} scroll=${el.scrollWidth} overflow-x=${overflowX}`,
);
}
}
/** Content inside a scroll container may exceed the viewport that is
* the remedy. But only when the CONTAINER fits: a scroller that is
* itself too wide still pushes the page. */
const insideFittingScroller = (el: Element): boolean => {
for (let node = el.parentElement; node && node !== doc; node = node.parentElement) {
const ox = getComputedStyle(node).overflowX;
if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') {
return node.getBoundingClientRect().right <= limit + 1;
}
}
return false;
};
// Widest reach first, so a long tail of clipped children cannot bury the
// one box that actually pushes the page.
const past = Array.from(document.querySelectorAll('body *'))
.map((el) => ({ el, rect: el.getBoundingClientRect() }))
.filter(({ rect }) => rect.width > 0 && rect.right > limit + 1)
.sort((a, b) => b.rect.right - a.rect.right)
.map(
({ el, rect }) =>
`${describe(el)} right=${Math.round(rect.right)} w=${Math.round(rect.width)}` +
`${insideFittingScroller(el) ? ' [in fitting scroller]' : ' <-- pushes page'}`,
);
return {
overflowBy: doc.scrollWidth - limit,
viewport: `html client=${limit} scroll=${doc.scrollWidth} | body client=${document.body.clientWidth} scroll=${document.body.scrollWidth} rect=${Math.round(document.body.getBoundingClientRect().width)}`,
overflowing: overflowing.slice(0, 15),
past: past.slice(0, 40),
};
});
const diagnosis = [
`${label}: horizontaler Überlauf bei 320 px`,
report.viewport,
`eigener Inhaltsüberlauf: ${JSON.stringify(report.overflowing, null, 1)}`,
`Boxen über dem Rand: ${JSON.stringify(report.past, null, 1)}`,
].join('\n');
expect({ overflowBy: report.overflowBy }, diagnosis).toEqual({ overflowBy: 0 });
}
test.describe('reflow at 320px', () => {
test('user settings do not scroll horizontally at 320px', async ({ browser }) => {
const context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage();
await page.setViewportSize(NARROW);
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Die Sitzungstabelle rendert asynchron und ist der breiteste Inhalt —
// ohne sie misst der Zaun eine halb aufgebaute Seite.
await page.locator('.table tbody tr').first().waitFor();
await expectNoHorizontalScroll(page, '/settings');
await context.close();
});
});

View File

@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* The general admin settings card saves THROUGH THE FORM (issue #322).
*
* This must drive the UI, not the api: the bug it fences was invisible to
* every api-level test react-hook-form nested the dotted field names on
* input, the strict PATCH schema rejected the body, and the form looked
* fine while never saving. Verified end to end: success message, the value
* survives a full reload, the api returns it, and the TopBar picks it up
* without a reload (branding query invalidation).
*/
test('instance name changed in the general settings form persists', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const before = (
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
)['instance.name'] as string;
const newName = `Renamed ${Date.now()}`;
const nameLabel = /^(Instance name|Name der Instanz)$/;
const page = await admin.newPage();
try {
await page.goto('/admin');
const generalCard = page
.locator('section.settings-section')
.filter({ has: page.getByLabel(nameLabel) });
await page.getByLabel(nameLabel).fill(newName);
await generalCard.getByRole('button', { name: /^(Save|Speichern)$/ }).click();
// Scoped to the card: the page has several forms with status regions.
await expect(generalCard.getByRole('status')).toHaveText(/^(Saved\.|Gespeichert\.)$/);
// The TopBar and the document title show the new name without a reload —
// the save invalidates the branding query both read from (issue #323).
await expect(page.locator('.topbar__brand')).toHaveText(newName);
await expect(page).toHaveTitle(new RegExp(`${newName}$`));
// The proof the form really persisted: the value survives a reload and
// the api returns it.
await page.reload();
await expect(page.getByLabel(nameLabel)).toHaveValue(newName);
const stored = (
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
)['instance.name'];
expect(stored).toBe(newName);
} finally {
await admin.request.patch('/api/v1/admin/settings', {
data: { 'instance.name': before },
});
await admin.close();
}
});

View File

@ -44,3 +44,43 @@ test('disabling a user in the admin UI blocks their login, enabling restores it'
await admin.close();
}
});
/**
* Direct account creation (issue #331): the dialog creates an active account
* the new user logs in immediately, no verification hop. The account stays
* in the e2e database; the unique name keeps reruns independent.
*/
test('creating a user in the admin UI yields an account that can log in at once', async ({
browser,
}) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const username = `created-${Date.now()}`;
const password = 'ein sicheres anfangspasswort';
const page = await admin.newPage();
await page.goto('/admin');
await page.locator('.user-manager__create').click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel(/username|benutzername/i).fill(username);
await dialog.getByLabel(/e-mail/i).fill(`${username}@example.org`);
await dialog.getByLabel(/display name|anzeigename/i).fill('Created via UI');
await dialog.getByLabel(/initial password|anfangspasswort/i).fill(password);
await dialog.getByRole('button', { name: /^create$|^anlegen$/i }).click();
await expect(dialog).toBeHidden();
// The list refetches; the fresh account is findable.
await page.locator('.user-manager__search').fill(username);
const row = page.locator(`.user-row[data-username="${username}"]`);
await expect(row).toBeVisible();
await expect(row.locator('.user-row__status')).toHaveText(/active|aktiv/i);
await admin.close();
// No verification mail hop: login works right away.
const ctx = await request.newContext({ baseURL: BASE_URL });
const res = await ctx.post('/api/v1/auth/login', {
data: { usernameOrEmail: username, password },
});
expect(res.status()).toBe(200);
await ctx.dispose();
});

View File

@ -60,6 +60,155 @@ test('typing persists across reload and undo/redo work', async ({ browser }) =>
await context.close();
});
test('gap cursor reaches positions before and after a lone table (issue #335)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Gapcursor ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const status = page.locator('.editor-connection');
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
const content = page.locator('.ProseMirror');
await content.click();
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
await expect(content.locator('table')).toBeVisible();
// Inserting into the empty page replaces the placeholder paragraph — the
// table really is the only block, which is the situation of issue #335.
await expect(content.locator(':scope > p')).toHaveCount(0);
// Right after the insert the collab sync can still swallow a click's
// selection update; interact only against a settled editor (established
// pattern, see a11y.spec.ts). The typed markers below verify each click
// really placed the cursor where the locator points.
await page.waitForTimeout(500);
// Keyboard only: ArrowUp from the first cell lands on the gap cursor
// before the table; typing there materializes a paragraph.
await content.locator('th').first().click();
await page.keyboard.type('in');
await expect(content.locator('th').first()).toHaveText('in');
await page.keyboard.press('ArrowUp');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.type('above');
await expect(content.locator(':scope > :first-child')).toHaveText('above');
// Same for the position after the table.
await content.locator('td').last().click();
await page.keyboard.type('z');
await expect(content.locator('td').last()).toHaveText('z');
await page.keyboard.press('ArrowDown');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.type('below');
await expect(content.locator(':scope > :last-child')).toHaveText('below');
await context.close();
});
test('cells can be merged and split from the toolbar (issue #337)', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MergeSplit ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const status = page.locator('.editor-connection');
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
const content = page.locator('.ProseMirror');
await content.click();
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
await expect(content.locator('table')).toBeVisible();
const mergeButton = page.getByRole('button', { name: /merge cells|zellen verbinden/i });
const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i });
await expect(mergeButton).toBeDisabled();
await expect(splitButton).toBeDisabled();
// Settle before clicking into cells — see the gap cursor test.
await page.waitForTimeout(500);
// Extending the selection across the cell border turns it into a cell
// selection (prosemirror-tables), which is what merge operates on.
// Shift+Click, not Shift+ArrowRight: a keypress fired in the same tick as
// the preceding click races the editor's post-click rendering and gets
// dropped — no human types that fast (works fine interactively).
await content.locator('td').first().click();
await content
.locator('td')
.nth(1)
.click({ modifiers: ['Shift'] });
await expect(content.locator('.selectedCell')).toHaveCount(2);
await expect(mergeButton).toBeEnabled();
await mergeButton.click();
await expect(content.locator('td[colspan="2"]')).toHaveCount(1);
// Splitting the merged cell restores the row's full cell count.
await content.locator('td[colspan="2"]').click();
await expect(splitButton).toBeEnabled();
await splitButton.click();
await expect(content.locator('td[colspan="2"]')).toHaveCount(0);
await expect(content.locator('tr').nth(1).locator('td')).toHaveCount(3);
await context.close();
});
test('Tab navigates table cells, extends the table, and never traps focus (issue #338)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E TableTab ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const status = page.locator('.editor-connection');
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
const content = page.locator('.ProseMirror');
await content.click();
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
const rows = content.locator('tr');
await expect(rows).toHaveCount(3);
// Settle before clicking into cells — see the gap cursor test.
await page.waitForTimeout(500);
// Tab moves to the next cell, Shift+Tab back. Typed markers prove where
// the cursor really is (the typing assertions also settle the editor
// between keypresses — see the merge test on click/key races).
await content.locator('th').first().click();
await page.keyboard.type('one');
await expect(content.locator('th').first()).toHaveText('one');
await page.keyboard.press('Tab');
await page.keyboard.type('two');
await expect(content.locator('th').nth(1)).toHaveText('two');
await page.keyboard.press('Shift+Tab');
await page.keyboard.type('back');
await expect(content.locator('th').first()).toContainText('back');
// Tab in the last cell appends a row and moves into it (Word behavior).
const lastCell = rows.nth(2).locator('td').nth(2);
await lastCell.click();
await page.keyboard.type('z');
await expect(lastCell).toHaveText('z');
await page.keyboard.press('Tab');
await expect(rows).toHaveCount(4);
await page.keyboard.type('new');
await expect(rows.nth(3).locator('td').first()).toHaveText('new');
// No keyboard trap (WCAG 2.1.2): Escape works from EVERY cell (the gap
// cursor is only reachable per arrow key from edge cells) and places the
// cursor after the table; once outside, Tab leaves the editor entirely.
// The mechanism is announced via the editor's aria-describedby hint.
await page.keyboard.press('Escape');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.press('Tab');
await expect(content).not.toBeFocused();
await context.close();
});
test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);

View File

@ -93,11 +93,19 @@ test('a pond admin imports an Obsidian vault through the settings dialog', async
await expect(
mountItem.locator('.sidebar__tree-children .sidebar__page', { hasText: 'Projekte' }),
).toBeVisible();
await expect(page.locator('.sidebar__page:text-is("Startseite")')).toBeVisible();
// Scoped to the mount: the pond has its own "Startseite" since issue #302,
// so an unscoped title match now finds two entries.
const importedHome = mountItem
.locator('.sidebar__tree-children .sidebar__page')
.filter({ hasText: /^Startseite$/ })
.first();
await expect(importedHome).toBeVisible();
// A rewritten Obsidian link navigates to the right imported page, and the
// display text still reads like the original note name.
await page.goto(`/p/${pond.slug}/startseite`);
// display text still reads like the original note name. Reached through the
// sidebar rather than by slug — `/startseite` belongs to the pond's own
// start page, so the imported note landed on a suffixed slug.
await importedHome.click();
await page.locator('.editor-content a.wikilink', { hasText: 'Projekt A' }).click();
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/projekt-a$`));

View File

@ -0,0 +1,93 @@
import { expect, test } from '@playwright/test';
import { contextForUser, latestMailFor, tokenFromMail } from './helpers';
/**
* Peer invitations (issue #332), the full loop through the UI: a user
* invites an address, registration is closed, the invitee registers
* through the mailed link anyway, verifies, and the inviter sees the
* invitation accepted. Needs Mailpit like the auth pack.
*/
const MAILPIT_URL = process.env.E2E_MAILPIT_URL;
test.skip(!MAILPIT_URL, 'requires a Mailpit instance (E2E_MAILPIT_URL)');
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
test('invite -> closed registration -> signup through the link -> accepted', async ({
browser,
page,
}) => {
const stamp = Date.now().toString(36);
const invitee = `invited-${stamp}@dorfteich.test`;
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const inviter = await contextForUser(browser, BASE_URL, 'fixture-user');
await admin.request.patch('/api/v1/admin/settings', {
data: { 'auth.registrationMode': 'closed' },
});
try {
// Invite through the settings UI.
const settingsPage = await inviter.newPage();
await settingsPage.goto('/settings');
const section = settingsPage.locator('.invitations');
await section.getByLabel(/e-mail/i).fill(invitee);
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
await expect(section.locator('.invitations__sent')).toHaveText(/sent|verschickt/i);
const row = section.locator(`.invitation-row[data-email="${invitee}"]`);
await expect(row.locator('.invitation-row__status')).toHaveText(/open|offen/i);
// Plain signup is closed…
await page.goto('/signup');
await expect(page.locator('.form-banner')).toHaveText(/closed|geschlossen/i);
// …but the mailed link opens the form, inviter banner and prefill included.
const mail = await latestMailFor(MAILPIT_URL!, invitee);
const invitationToken = /invitation=([A-Za-z0-9_-]+)/.exec(mail.text)?.[1];
expect(invitationToken).toBeTruthy();
await page.goto(`/signup?invitation=${invitationToken}`);
await expect(page.locator('.signup-invitation__banner')).toBeVisible();
await expect(page.getByLabel(/e-mail/i)).toHaveValue(invitee);
const username = `invited-${stamp}`;
await page.getByLabel(/username|benutzername/i).fill(username);
await page.getByLabel(/display name|anzeigename/i).fill('Invited Guest');
await page.getByLabel(/^password|^passwort/i).fill('ein einladungs passwort 1');
await page.getByRole('button', { name: /register|registrieren/i }).click();
await expect(page.getByRole('heading', { name: /inbox|postfach/i })).toBeVisible();
// The usual verification still applies (the link proves nothing about
// the mailbox). Two mails went to this address — poll for the second.
let verifyToken = '';
await expect(async () => {
const verifyMail = await latestMailFor(MAILPIT_URL!, invitee);
expect(verifyMail.text).toContain('/verify-email');
verifyToken = tokenFromMail(verifyMail.text);
}).toPass();
await page.goto(`/verify-email?token=${verifyToken}`);
await expect(page.getByRole('heading', { name: /confirmed|bestätigt/i })).toBeVisible();
// The inviter sees the acceptance; the used link is dead.
await settingsPage.reload();
await expect(
settingsPage
.locator(`.invitation-row[data-email="${invitee}"]`)
.locator('.invitation-row__status'),
).toHaveText(/accepted|angenommen/i);
await page.goto(`/signup?invitation=${invitationToken}`);
await expect(page.locator('.signup-invitation__invalid')).toBeVisible();
// Revoke flow through the UI: a second invitation dies by revoke.
const second = `revoked-${stamp}@dorfteich.test`;
await section.getByLabel(/e-mail/i).fill(second);
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
const secondRow = section.locator(`.invitation-row[data-email="${second}"]`);
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/open|offen/i);
await secondRow.getByRole('button', { name: /revoke|widerrufen/i }).click();
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/revoked|widerrufen/i);
} finally {
await admin.request.patch('/api/v1/admin/settings', {
data: { 'auth.registrationMode': 'open' },
});
await admin.close();
await inviter.close();
}
});

View File

@ -63,7 +63,11 @@ test('the admin form previews and publishes the privacy policy', async ({ browse
await expect(editor.locator('.legal-editor__preview strong')).toHaveText('only what is needed');
await page.getByRole('button', { name: /save legal pages|rechtsseiten speichern/i }).click();
await expect(page.getByRole('status')).toBeVisible();
// Auf den Abschnitt gescopet: seit der Schriftverwaltung (#304) hat /admin
// weitere Live-Regionen (Upload-Fortschritt), und ein seitenweites
// getByRole('status') wäre mehrdeutig. Gemeint war immer die
// Erfolgsmeldung DIESES Formulars.
await expect(page.locator('.legal-settings').getByRole('status')).toBeVisible();
await admin.close();
const anonymous = await browser.newContext({ baseURL: BASE_URL });

View File

@ -108,6 +108,102 @@ test('a plain-text paste is not mangled into rich structure', async ({ browser }
await context.close();
});
test('a Markdown table pasted with code-editor styling HTML becomes a table (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TablePaste ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
// VS Code (copyWithSyntaxHighlighting) ships the plain text a second time
// as styled div/span HTML — exactly the flavor that used to shadow the
// Markdown conversion.
await page.evaluate(() => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
dataTransfer.setData(
'text/html',
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
'<div><span style="color:#d4d4d4;">| --- | --- |</span></div>' +
'<div><span style="color:#d4d4d4;">| 1 | 2 |</span></div></div>',
);
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
const content = page.locator('.ProseMirror');
await expect(content.locator('table')).toHaveCount(1);
await expect(content.locator('th').first()).toHaveText('A');
await expect(content.locator('td').first()).toHaveText('1');
await context.close();
});
test('a Markdown table pasted into a code block stays verbatim text (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD CodePaste ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
await page.getByRole('button', { name: /code block|codeblock/i }).click();
await page.evaluate(() => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
const content = page.locator('.ProseMirror');
await expect(content.locator('table')).toHaveCount(0);
await expect(content.locator('pre')).toContainText('| A | B |');
await context.close();
});
test('typing a Markdown table header plus separator creates a table (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TableType ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
const content = page.locator('.ProseMirror');
await content.click();
await page.keyboard.type('| Name | Rolle |');
await page.keyboard.press('Enter');
await page.keyboard.type('| --- | --- |');
await expect(content).toContainText('| --- | --- |');
await page.keyboard.press('Enter');
await expect(content.locator('table')).toHaveCount(1);
await expect(content.locator('th').first()).toHaveText('Name');
await expect(content).not.toContainText('| --- | --- |');
// The cursor lands in the table; Tab from the last header cell appends the
// first body row (#338), so typing continues seamlessly.
await page.keyboard.type('x');
await expect(content.locator('th').first()).toContainText('x');
await context.close();
});
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);

View File

@ -173,10 +173,14 @@ test('page read & edit — the 404-vs-403 policy holds per subject', async () =>
});
test('sidebar list is filtered to each subjects visible pages', async () => {
expect(await listCount(f.admin.request, f.pondId)).toBe(3);
expect(await listCount(f.owner.request, f.pondId)).toBe(3);
expect(await listCount(f.reader.request, f.pondId)).toBe(3);
expect(await listCount(f.editor.request, f.pondId)).toBe(2); // secret hidden
// Three fixture pages plus the pond's own start page (issue #302), which
// every pond created through the api now carries. It is an ordinary page
// with no grant of its own, so it follows the pond-wide permissions: the
// outsider, who reaches only the explicitly public page, still sees one.
expect(await listCount(f.admin.request, f.pondId)).toBe(4);
expect(await listCount(f.owner.request, f.pondId)).toBe(4);
expect(await listCount(f.reader.request, f.pondId)).toBe(4);
expect(await listCount(f.editor.request, f.pondId)).toBe(3); // secret hidden
expect(await listCount(f.outsider.request, f.pondId)).toBe(1); // only the public page
// Anonymous cannot hit the authenticated list endpoint at all.
expect(await listCount(f.anon, f.pondId)).toBe(401);

View File

@ -28,8 +28,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
await expect(nav).toBeVisible();
const links = nav.locator('.settings-nav__link');
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild).
await expect(links).toHaveCount(9);
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild),
// 10 seit #332 (Einladungen).
await expect(links).toHaveCount(10);
// Jump to the last section: it scrolls into view and becomes active.
const last = links.last();

View File

@ -34,7 +34,7 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
// select value — compliant choice clears it, violating choice brings it
// back, no save in between.
const regField = page.locator('label.field', {
has: page.locator('select[name="auth.registrationMode"]'),
has: page.locator('select[name="registrationMode"]'),
});
const regSelect = regField.locator('select');
await regSelect.selectOption('open');
@ -72,7 +72,7 @@ test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({
// Value-listed control: the compliant registration mode keeps only its
// compliant choice (seed leaves it open = violating? then all options).
const regSelect = page.locator('select[name="auth.registrationMode"]');
const regSelect = page.locator('select[name="registrationMode"]');
const regField = page.locator('label.field', { has: regSelect });
const optionCount = await regSelect.locator('option').count();
const marked = await regField.locator('.vs-nfd-mark').count();

View File

@ -10,6 +10,13 @@
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#2f6f4f" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#10161d" />
<title>Dorfteich</title>
<!-- Static link, dynamic resource (issue #306): the api answers with the
operator's favicon or the shipped default, so this href never has to
change and index.html stays a static file. An attribute like `lang`
cannot be indirected this way — that is #179's problem, not this
one's. -->
<link rel="icon" type="image/png" href="/api/v1/branding/favicon" />
<link rel="apple-touch-icon" href="/api/v1/branding/favicon?size=180" />
<!-- Classic (non-module) script: executes during head parsing, before
first paint and before the deferred module bundle. External file
because the prod CSP forbids inline scripts (issue #180). -->

View File

@ -1,3 +1,23 @@
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
# SPA route which nginx answers with index.html would see `en` forever,
# even for German content.
#
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
# German; `en-US,de` does not, because that visitor asked for English first.
#
# Known limit, documented rather than worked around: nginx does not know
# `instance.defaultLocale` from the database, so a visitor with no (or an
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
# content that is not the authoritative rendering anyway the api's server
# shell (`/api/v1/public/...`) renders those with the instance locale.
map $http_accept_language $spa_lang {
default en;
~*^de de;
}
# SPA serving: static assets with long-lived caching, everything else
# falls back to index.html (client-side routing).
server {
@ -34,6 +54,17 @@ server {
# zero-third-party-request guarantee (security.md) is unaffected.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
# The shell's language (issue #179). Only the html document is
# rewritten, and only its first match `<html lang="en">` is the
# first and only occurrence in index.html. Everything else this
# location serves passes through untouched.
sub_filter_types text/html;
sub_filter_once on;
sub_filter 'lang="en"' 'lang="$spa_lang"';
# The response now depends on a request header, so shared caches must
# not serve one language's copy to the other. This location is
# `no-cache` anyway; the header states the dependency correctly.
add_header Vary "Accept-Language" always;
try_files $uri /index.html;
}
}

View File

@ -16,6 +16,7 @@ import { ApiError, apiGet } from '../lib/api';
import { usePondLabels } from '../labels/use-pond-labels';
import { usePondMembers } from '../members/use-pond-members';
import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
import { IconButton } from '../components/IconButton';
type ScopedType = 'label' | 'page';
@ -238,15 +239,14 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{error}
</p>
)}
<button
className="icon-button rule-add__submit"
<IconButton
className="rule-add__submit"
type="submit"
disabled={!canSubmit}
aria-label={t('add.submit')}
title={t('add.submit')}
label={t('add.submit')}
>
<Plus aria-hidden />
</button>
</IconButton>
</form>
{rules.length === 0 ? (
@ -260,15 +260,13 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{group.rules.map((rule) => (
<li key={rule.id} className="rule-item">
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
<button
type="button"
className="icon-button rule-remove"
aria-label={t('remove')}
title={t('remove')}
<IconButton
className="rule-remove"
label={t('remove')}
onClick={() => void mutations.remove(rule.id)}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</li>
))}
</ul>

View File

@ -172,52 +172,54 @@ function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element {
return (
<>
<FormError error={error} />
<table className="table api-tokens__table">
<thead>
<tr>
<th>{t('fields.name')}</th>
<th>{t('fields.scope')}</th>
<th>{t('fields.ponds')}</th>
<th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th>
<th>{t('list.expires')}</th>
<th>{t('list.status')}</th>
<th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr>
</thead>
<tbody>
{tokens.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
<td>
{token.ponds.length === 0
? t('list.allPonds')
: token.ponds.map((pond) => pond.name).join(', ')}
</td>
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
<td>
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
</td>
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
<td>{t(`list.${statusOf(token)}`)}</td>
<td>
{!token.revokedAt && (
<button
type="button"
className="button api-tokens__revoke"
onClick={() => void revoke(token.id)}
>
{t('list.revoke')}
</button>
)}
</td>
<div className="table-scroll" tabIndex={0} role="region" aria-label={t('section.title')}>
<table className="table api-tokens__table">
<thead>
<tr>
<th>{t('fields.name')}</th>
<th>{t('fields.scope')}</th>
<th>{t('fields.ponds')}</th>
<th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th>
<th>{t('list.expires')}</th>
<th>{t('list.status')}</th>
<th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{tokens.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
<td>
{token.ponds.length === 0
? t('list.allPonds')
: token.ponds.map((pond) => pond.name).join(', ')}
</td>
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
<td>
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
</td>
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
<td>{t(`list.${statusOf(token)}`)}</td>
<td>
{!token.revokedAt && (
<button
type="button"
className="button api-tokens__revoke"
onClick={() => void revoke(token.id)}
>
{t('list.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

View File

@ -85,32 +85,38 @@ export function FeedTokensSection(): React.JSX.Element {
)}
{tokens.data && tokens.data.length === 0 && <p>{t('feed.empty')}</p>}
{tokens.data && tokens.data.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('fields.name')}</th>
<th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th>
<th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr>
</thead>
<tbody>
{tokens.data.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{formatTime(token.createdAt)}</td>
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
<td>
<button type="button" className="linklike" onClick={() => void remove(token.id)}>
{t('feed.delete')}
</button>
</td>
<div className="table-scroll" tabIndex={0} role="region" aria-label={t('feed.title')}>
<table className="table">
<thead>
<tr>
<th>{t('fields.name')}</th>
<th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th>
<th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{tokens.data.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{formatTime(token.createdAt)}</td>
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
<td>
<button
type="button"
className="linklike"
onClick={() => void remove(token.id)}
>
{t('feed.delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);

View File

@ -0,0 +1,58 @@
import { Link } from 'react-router-dom';
import { useCurrentPondRoute } from '../layout/use-pond-route';
import { logoUrl, usePondFavicon, useResolvedBranding } from './use-branding';
import { usePondId } from './use-pond-id';
/**
* The identity at the top of the sidebar (issues #306/#307): the pond's own
* logo when it has one, else the instance's, else the instance name as text.
*
* Its accessible name follows the LEVEL the logo came from a pond logo is
* named by the pond, an instance logo by the instance. For a screen reader
* this is the link home, and a link's name has to say where it goes; keeping
* the instance name on a pond logo would announce the wrong destination.
*
* Both variants are rendered and one is hidden by CSS (`:root[data-theme]`),
* not by JavaScript: `theme-init.js` resolves the theme before first paint, so
* the correct logo is the one painted. A logo set belongs to ONE level and is
* never mixed across levels see `resolveBranding`.
*/
export function BrandLogo(): React.JSX.Element | null {
const { pondSlug } = useCurrentPondRoute();
const { pondId, pondName } = usePondId(pondSlug);
const { resolved, instanceName, pondId: logoPond } = useResolvedBranding(pondId);
usePondFavicon(pondId, resolved.faviconLevel === 'pond');
const name = resolved.logoLevel === 'pond' ? (pondName ?? instanceName) : instanceName;
if (!instanceName && resolved.logoLevel === 'none') return null;
return (
<Link to="/" className="brand-logo" aria-label={name}>
{resolved.logo || resolved.logoDark ? (
<>
{resolved.logo && (
<img
className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('light', resolved.logo.hash, logoPond)}
width={resolved.logo.width}
height={resolved.logo.height}
alt=""
/>
)}
{resolved.logoDark && (
<img
className={`brand-logo__img brand-logo__img--dark${resolved.logo ? '' : ' brand-logo__img--both'}`}
src={logoUrl('dark', resolved.logoDark.hash, logoPond)}
width={resolved.logoDark.width}
height={resolved.logoDark.height}
alt=""
/>
)}
</>
) : (
<span className="brand-logo__name">{name}</span>
)}
</Link>
);
}

View File

@ -0,0 +1,173 @@
import { BRANDING_SOURCE_TYPES } from '@dorfteich/shared';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Field } from '../components/forms';
import { CropRect, clampCrop, drawCrop, initialCrop, loadImage, outputSize } from './crop';
/**
* Pick an image, crop it, see the result (issue #306).
*
* The crop is driven by NUMBER INPUTS, not by dragging. A drag-only cropper
* excludes keyboard and switch users outright, and a number input is
* arrow-key operable, screen-reader readable and announces its value without
* any custom aria plumbing the accessible option is also the simpler one.
* The preview canvas is a picture of the result, never the control.
*
* The resulting pixel dimensions are stated in TEXT next to it, so the outcome
* does not depend on seeing the frame.
*/
export function CropField({
idPrefix,
square,
maxEdge,
onChange,
}: {
idPrefix: string;
/** Favicons are square by construction; a logo keeps its own proportions. */
square: boolean;
maxEdge: number;
/** Called with the rendering canvas whenever the crop changes, so the
* parent can encode PNGs from it on submit. Null = nothing selected. */
onChange: (canvas: HTMLCanvasElement | null) => void;
}): React.JSX.Element {
const { t } = useTranslation('branding');
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [crop, setCrop] = useState<CropRect | null>(null);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const out = image && crop ? outputSize(crop, maxEdge) : null;
// Held in a ref so the redraw depends on the crop alone: callers pass an
// inline arrow, whose identity changes every render and would otherwise
// repaint the canvas on every keystroke in the surrounding form.
const notifyRef = useRef(onChange);
notifyRef.current = onChange;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !image || !crop) {
notifyRef.current(null);
return;
}
// Derived inside the effect: `outputSize` returns a fresh object every
// render, so as a dependency it would never compare equal.
drawCrop(image, crop, outputSize(crop, maxEdge), canvas);
notifyRef.current(canvas);
}, [image, crop, maxEdge]);
async function choose(file: File | undefined): Promise<void> {
setError(null);
if (!file) {
setImage(null);
setCrop(null);
return;
}
if (!(BRANDING_SOURCE_TYPES as readonly string[]).includes(file.type)) {
setImage(null);
setCrop(null);
// SVG is the one an operator is most likely to try, and it is refused
// on purpose (it can carry script) — say which types work instead.
setError(file.type === 'image/svg+xml' ? 'branding_svg_rejected' : 'branding_not_an_image');
return;
}
try {
const loaded = await loadImage(file);
setImage(loaded);
setCrop(initialCrop(loaded, square));
} catch {
setError('branding_not_an_image');
}
}
function update(patch: Partial<CropRect>): void {
if (!image || !crop) return;
const next = { ...crop, ...patch };
// A square crop has one size, so width and height move together.
if (square && patch.width !== undefined) next.height = patch.width;
setCrop(clampCrop(next, image));
}
return (
<div className="crop-field">
<Field label={t('crop.file')} hint={t('crop.fileHint')} error={error ?? undefined}>
<input
type="file"
accept={BRANDING_SOURCE_TYPES.join(',')}
onChange={(event) => void choose(event.target.files?.[0])}
/>
</Field>
{image && crop && out && (
<>
<div className="crop-field__controls">
<Field label={t('crop.x')}>
<input
type="number"
id={`${idPrefix}-x`}
min={0}
max={image.width - crop.width}
value={crop.x}
onChange={(event) => update({ x: Number(event.target.value) })}
/>
</Field>
<Field label={t('crop.y')}>
<input
type="number"
id={`${idPrefix}-y`}
min={0}
max={image.height - crop.height}
value={crop.y}
onChange={(event) => update({ y: Number(event.target.value) })}
/>
</Field>
<Field label={square ? t('crop.size') : t('crop.width')}>
<input
type="number"
id={`${idPrefix}-w`}
min={1}
max={square ? Math.min(image.width, image.height) : image.width}
value={crop.width}
onChange={(event) => update({ width: Number(event.target.value) })}
/>
</Field>
{!square && (
<Field label={t('crop.height')}>
<input
type="number"
id={`${idPrefix}-h`}
min={1}
max={image.height}
value={crop.height}
onChange={(event) => update({ height: Number(event.target.value) })}
/>
</Field>
)}
<button
type="button"
className="linklike"
onClick={() => setCrop(initialCrop(image, square))}
>
{t('crop.reset')}
</button>
</div>
<div className="crop-field__preview">
<canvas ref={canvasRef} className="crop-field__canvas" />
{/* The outcome in words: the frame alone would leave a
keyboard-only or screen-reader user guessing. */}
<p className="crop-field__result" role="status">
{t('crop.result', {
width: out.width,
height: out.height,
sourceWidth: image.width,
sourceHeight: image.height,
})}
</p>
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { clampCrop, initialCrop, outputSize } from './crop';
/**
* The crop arithmetic (issue #306). Pure functions on purpose: the canvas
* work is a thin shell around these, and getting the bounds wrong is what
* would let a number input produce a rectangle outside the image.
*/
describe('initialCrop', () => {
it('takes the whole image when the aspect is free', () => {
expect(initialCrop({ width: 900, height: 300 }, false)).toEqual({
x: 0,
y: 0,
width: 900,
height: 300,
});
});
it('centres the largest square that fits', () => {
expect(initialCrop({ width: 900, height: 300 }, true)).toEqual({
x: 300,
y: 0,
width: 300,
height: 300,
});
});
});
describe('outputSize', () => {
it('scales the long edge down to the bound and keeps the ratio', () => {
expect(outputSize({ x: 0, y: 0, width: 900, height: 300 }, 512)).toEqual({
width: 512,
height: 171,
});
});
it('never scales UP — enlarging would only invent pixels', () => {
expect(outputSize({ x: 0, y: 0, width: 120, height: 40 }, 512)).toEqual({
width: 120,
height: 40,
});
});
});
describe('clampCrop', () => {
const source = { width: 200, height: 100 };
it('keeps the rectangle inside the image', () => {
expect(clampCrop({ x: 190, y: 90, width: 50, height: 50 }, source)).toEqual({
x: 150,
y: 50,
width: 50,
height: 50,
});
});
it('never lets a size fall below one pixel or exceed the source', () => {
expect(clampCrop({ x: 0, y: 0, width: 0, height: 999 }, source)).toEqual({
x: 0,
y: 0,
width: 1,
height: 100,
});
});
it('accepts a negative offset by pulling it back to the edge', () => {
expect(clampCrop({ x: -30, y: -5, width: 20, height: 20 }, source)).toMatchObject({
x: 0,
y: 0,
});
});
});

View File

@ -0,0 +1,106 @@
/**
* Client-side image preparation for branding uploads (issue #306).
*
* Cropping, scaling and the conversion to PNG happen here on a canvas; the
* api receives finished bytes and never decodes an image. That keeps a
* decoder away from attacker-supplied bytes and keeps `sharp` (and its
* platform binaries) out of the `--network none` offline build.
*/
export interface CropRect {
x: number;
y: number;
width: number;
height: number;
}
/** Reads a file into an `HTMLImageElement`, rejecting what the browser cannot
* decode the first line of defence, before anything reaches the api. */
export function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('image_undecodable'));
};
image.src = url;
});
}
/** The crop the editor starts with: the largest centred rectangle of the
* wanted aspect that fits the source. */
export function initialCrop(source: { width: number; height: number }, square: boolean): CropRect {
if (!square) return { x: 0, y: 0, width: source.width, height: source.height };
const size = Math.min(source.width, source.height);
return {
x: Math.round((source.width - size) / 2),
y: Math.round((source.height - size) / 2),
width: size,
height: size,
};
}
/** Output size for a crop: scaled down so the longest edge fits `maxEdge`,
* never scaled UP enlarging would only invent pixels. */
export function outputSize(crop: CropRect, maxEdge: number): { width: number; height: number } {
const longest = Math.max(crop.width, crop.height);
const factor = longest > maxEdge ? maxEdge / longest : 1;
return {
width: Math.max(1, Math.round(crop.width * factor)),
height: Math.max(1, Math.round(crop.height * factor)),
};
}
/** Keeps a crop inside the source and above 1px, so number inputs cannot
* produce a rectangle the canvas would refuse. */
export function clampCrop(crop: CropRect, source: { width: number; height: number }): CropRect {
const width = Math.min(Math.max(1, Math.round(crop.width)), source.width);
const height = Math.min(Math.max(1, Math.round(crop.height)), source.height);
return {
width,
height,
x: Math.min(Math.max(0, Math.round(crop.x)), source.width - width),
y: Math.min(Math.max(0, Math.round(crop.y)), source.height - height),
};
}
/** Renders the crop into a canvas at the given output size. */
export function drawCrop(
image: CanvasImageSource,
crop: CropRect,
out: { width: number; height: number },
canvas: HTMLCanvasElement,
): void {
canvas.width = out.width;
canvas.height = out.height;
const context = canvas.getContext('2d');
if (!context) return;
context.clearRect(0, 0, out.width, out.height);
context.imageSmoothingQuality = 'high';
context.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, out.width, out.height);
}
/**
* The canvas contents as PNG bytes.
*
* PNG regardless of the source format which is why the form states that a
* JPEG source cannot gain transparency: the alpha channel exists in the
* output, but every pixel of a JPEG is opaque, so the background stays.
* Conversion cannot invent what was never in the file.
*/
export function canvasToPngFile(canvas: HTMLCanvasElement, name: string): Promise<File> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('canvas_encode_failed'));
return;
}
resolve(new File([blob], name, { type: 'image/png' }));
}, 'image/png');
});
}

View File

@ -0,0 +1,91 @@
import { BrandingView, PondBranding, ResolvedBranding, resolveBranding } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { apiGet } from '../lib/api';
export const BRANDING_KEY = ['branding'];
/**
* The instance branding in force (issue #306).
*
* Public, so the login screen carries it too an operator's logo IS visible
* to anonymous visitors, which the admin screen says out loud.
*/
export function useBranding(): BrandingView | undefined {
return useQuery({
queryKey: BRANDING_KEY,
queryFn: () => apiGet<BrandingView>('/branding'),
// Branding changes are rare and the manager invalidates the key itself.
staleTime: 5 * 60 * 1000,
}).data;
}
/** URL of a logo variant, with the content hash so a replaced logo is never
* served from cache. `pondId` scopes it to a pond's own asset (issue #307);
* the route never falls back on its own the CALLER decided which level
* applies, and a silent fallback here would mix variants across levels. */
export function logoUrl(variant: 'light' | 'dark', hash: string, pondId?: string): string {
const pond = pondId ? `&pond=${pondId}` : '';
return `/api/v1/branding/logo?variant=${variant}&v=${hash}${pond}`;
}
/**
* The branding in force here: the pond's own, else the instance's, else the
* default (issue #307). One helper, in shared, so api and web cannot drift.
*/
export function useResolvedBranding(pondId?: string): {
resolved: ResolvedBranding;
instanceName: string;
/** Which level the logo came from the asset URLs need the pond scope
* exactly when the pond supplied it. */
pondId?: string;
} {
const instance = useBranding();
const pond = useQuery({
queryKey: ['pond', pondId, 'branding'],
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId!}/branding`),
enabled: Boolean(pondId),
staleTime: 5 * 60 * 1000,
});
const base = instance ?? { logo: null, logoDark: null, favicon: null, instanceName: '' };
const resolved = resolveBranding(base, pond.data ?? null);
return {
resolved,
instanceName: base.instanceName,
pondId: resolved.logoLevel === 'pond' ? pondId : undefined,
};
}
/**
* Points the document's icon at the pond's favicon while a pond is open, and
* back at the instance's on leaving (issue #307).
*
* Accepted and worth stating: the swap necessarily happens AFTER first paint,
* so opening a pond link directly shows the instance favicon briefly before it
* changes. Avoiding that would mean server-rendering index.html, which is
* #179's territory and deliberately out of scope here. In a pinned tab where
* telling ponds apart matters most the tab is already open, so the swap is
* the normal case rather than the exception.
*
* Driven by the RESOLVED pond, never by the raw route parameter: an unreadable
* or non-existent pond slug must not leave a stale icon in the tab.
*/
export function usePondFavicon(pondId: string | undefined, hasPondFavicon: boolean): void {
useEffect(() => {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) return undefined;
const instanceHref = '/api/v1/branding/favicon';
link.href = pondId && hasPondFavicon ? `${instanceHref}?pond=${pondId}` : instanceHref;
return () => {
link.href = instanceHref;
};
}, [pondId, hasPondFavicon]);
}
export function useInvalidateBranding(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
};
}

View File

@ -0,0 +1,21 @@
import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { apiGet } from '../lib/api';
/**
* The current pond's id and name from its slug (issue #307).
*
* Shares the sidebar's query key, so the pond is fetched once. Returns
* nothing for an unreadable or unknown slug which is exactly why the
* favicon swap is driven by this and not by the raw route parameter: a bad
* slug must not leave a stale icon in the tab.
*/
export function usePondId(pondSlug: string | null): { pondId?: string; pondName?: string } {
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug!}`),
enabled: Boolean(pondSlug),
});
return { pondId: pond.data?.id, pondName: pond.data?.name };
}

View File

@ -1,4 +1,14 @@
import type { ButtonHTMLAttributes } from 'react';
import { Link, type LinkProps } from 'react-router-dom';
/** The shared class list behind both controls (issue #300): one place decides
* what an icon-only control looks like, so the box, the icon size, the hover
* and the focus ring cannot drift apart between a button and a link. */
function iconClasses(active: boolean | undefined, className: string | undefined): string {
return ['icon-button', active ? 'icon-button--active' : '', className ?? '']
.filter(Boolean)
.join(' ');
}
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Localized accessible name; also shown as the hover tooltip. */
@ -15,12 +25,42 @@ export function IconButton({
children,
...rest
}: IconButtonProps): React.JSX.Element {
const classes = ['icon-button', active ? 'icon-button--active' : '', className ?? '']
.filter(Boolean)
.join(' ');
return (
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
<button
type="button"
className={iconClasses(active, className)}
aria-label={label}
title={label}
{...rest}
>
{children}
</button>
);
}
interface IconLinkProps extends LinkProps {
/** Localized accessible name; also shown as the hover tooltip. */
label: string;
active?: boolean;
}
/**
* The navigating twin of {@link IconButton} (issue #300). An icon-only control
* that goes somewhere is a link, not a button but it has to look and focus
* exactly like one, which is why both share {@link iconClasses}. Without it the
* three navigating icons (pond settings, graph, trash) stayed the one group
* that had to glue the class on by hand.
*/
export function IconLink({
label,
active,
className,
children,
...rest
}: IconLinkProps): React.JSX.Element {
return (
<Link className={iconClasses(active, className)} aria-label={label} title={label} {...rest}>
{children}
</Link>
);
}

View File

@ -127,6 +127,8 @@ export function Toolbar({
canAddColumn: e.can().addColumnAfter(),
canDeleteColumn: e.can().deleteColumn(),
canDeleteTable: e.can().deleteTable(),
canMergeCells: e.can().mergeCells(),
canSplitCell: e.can().splitCell(),
canToggleHeaderRow: e.can().toggleHeaderRow(),
canUndo: e.can().undo(),
canRedo: e.can().redo(),
@ -270,7 +272,10 @@ export function Toolbar({
disabled={!state.canDeleteColumn}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{/* Axis stripes + the × delete marker (already established by
deleteTable's ): the earlier / double arrows read as
"resize/expand", not "delete" (issue #336). */}
×
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addRowBefore')}
@ -291,7 +296,23 @@ export function Toolbar({
disabled={!state.canDeleteRow}
onClick={() => editor.chain().focus().deleteRow().run()}
>
×
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.mergeCells')}
disabled={!state.canMergeCells}
onClick={() => editor.chain().focus().mergeCells().run()}
>
{/* Arrows collapsing onto / leaving a cell border: merge removes
the border between selected cells, split restores it. */}
|
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.splitCell')}
disabled={!state.canSplitCell}
onClick={() => editor.chain().focus().splitCell().run()}
>
|
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.toggleHeaderRow')}

View File

@ -1,6 +1,8 @@
import type { AnyExtension } from '@tiptap/core';
import { GapCursor } from './gap-cursor';
import { MarkdownClipboard } from './markdown-clipboard';
import { MarkdownTableInput } from './markdown-table-input';
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
import { Image } from './nodes/image';
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
@ -63,4 +65,6 @@ export const documentExtensions: AnyExtension[] = [
Strikethrough,
LinkMark,
MarkdownClipboard,
MarkdownTableInput,
GapCursor,
];

View File

@ -0,0 +1,20 @@
import { Extension } from '@tiptap/core';
import { gapCursor } from '@tiptap/pm/gapcursor';
/**
* Cursor position adjacent to block nodes that offer no text position of
* their own without it a table (or code block, image, ) as the page's
* first, last, or only block is unreachable from before/after, and no
* paragraph can be created there (issue #335). Wraps prosemirror-gapcursor,
* which also handles the arrow-key navigation into the gap positions; the
* bar itself is styled in `styles/base.css` (`.ProseMirror-gapcursor`)
* because the upstream package does not ship its stylesheet through this
* entry point.
*/
export const GapCursor = Extension.create({
name: 'gapCursor',
addProseMirrorPlugins() {
return [gapCursor()];
},
});

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