Compare commits

...

17 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
101 changed files with 4405 additions and 181 deletions

View File

@ -345,6 +345,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
- name: Reset login rate limit before admin-settings pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run admin-settings pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
- name: Reset login rate limit before admin-quotas pack - name: Reset login rate limit before admin-quotas pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -365,6 +375,18 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
- name: Reset login rate limit before invitations pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# Invitations (issue #332) need the mail catcher like the auth pack:
# the invite link and the follow-up verification both travel by mail.
- name: Run invitations pack
run: |
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts
- name: Reset login rate limit before permission-matrix pack - name: Reset login rate limit before permission-matrix pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

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

@ -68,10 +68,34 @@ model User {
notifications Notification[] notifications Notification[]
favorites PageFavorite[] favorites PageFavorite[]
customFonts CustomFont[] customFonts CustomFont[]
invitations Invitation[] @relation("InvitationsSent")
@@map("users") @@map("users")
} }
/// Peer invitations (issue #332): a user invites an e-mail address; the
/// token allows exactly one registration even while registration is
/// closed. Only the SHA-256 hash of the token is stored (auth-tokens
/// pattern); revoked/accepted rows are kept so the settings UI can show
/// history. "Open" (pending, unexpired) rows count against the per-user
/// quota `invitations.maxOpenPerUser`.
model Invitation {
id String @id @default(uuid())
inviterId String @map("inviter_id")
email String
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
acceptedAt DateTime? @map("accepted_at")
acceptedUserId String? @map("accepted_user_id")
createdAt DateTime @default(now()) @map("created_at")
inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade)
@@index([inviterId])
@@map("invitations")
}
/// Persistent audit trail (issue #86, security.md §Logging): auth events and /// Persistent audit trail (issue #86, security.md §Logging): auth events and
/// admin actions — grants, member roles, plugin installs, quota and settings /// admin actions — grants, member roles, plugin installs, quota and settings
/// changes, setup steps, manual job triggers. Written by AuditService, which /// changes, setup steps, manual job triggers. Written by AuditService, which

View File

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

View File

@ -12,9 +12,11 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
AdminCreateUserInput,
AdminUserListQuery, AdminUserListQuery,
AdminUserListView, AdminUserListView,
AdminUserView, AdminUserView,
adminCreateUserSchema,
adminUserListQuerySchema, adminUserListQuerySchema,
setSiteAdminSchema, setSiteAdminSchema,
setUserDisabledSchema, setUserDisabledSchema,
@ -31,6 +33,14 @@ import { UserAdminService } from './user-admin.service';
export class UserAdminController { export class UserAdminController {
constructor(private readonly users: UserAdminService) {} constructor(private readonly users: UserAdminService) {}
@Post()
async create(
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
@Req() request: AuthedRequest,
): Promise<AdminUserView> {
return this.users.createUser(request.user!, input);
}
@Get() @Get()
async list( async list(
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery, @Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,

View File

@ -69,6 +69,64 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
await app.close(); await app.close();
}); });
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
const username = `ua-created-${suffix}`;
const res = await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.admin1!)
.send({
username,
email: `${username}@example.org`,
displayName: 'UA Created',
password,
locale: 'de',
})
.expect(201);
const created = res.body as { id: string; status: string };
ids.created = created.id;
// No verification hop: the admin vouched for the address.
expect(created.status).toBe('ACTIVE');
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
// The personal pond exists exactly like after self-registration.
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
});
it('rejects duplicate usernames with a field-level conflict', async () => {
await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.admin1!)
.send({
username: `ua-created-${suffix}`,
email: `ua-created-other-${suffix}@example.org`,
displayName: 'UA Dup',
password,
locale: 'en',
})
.expect(409)
.expect((r) =>
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
'validation.taken',
]),
);
});
it('refuses creation for non-admins', async () => {
await api()
.post('/api/v1/admin/users')
.set('Cookie', cookies.bob!)
.send({
username: `ua-sneak-${suffix}`,
email: `ua-sneak-${suffix}@example.org`,
displayName: 'UA Sneak',
password,
locale: 'en',
})
.expect(403);
});
it('lists and searches users (Site-Admin only)', async () => { it('lists and searches users (Site-Admin only)', async () => {
const res = await api() const res = await api()
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`) .get(`/api/v1/admin/users?q=ua-bob-${suffix}`)

View File

@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { import {
AdminCreateUserInput,
AdminUserListQuery, AdminUserListQuery,
AdminUserListView, AdminUserListView,
AdminUserStatus, AdminUserStatus,
@ -10,7 +11,9 @@ import { PinoLogger } from 'nestjs-pino';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { PondsService } from '../ponds/ponds.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { PseudonymizationService } from './pseudonymization.service'; import { PseudonymizationService } from './pseudonymization.service';
/** /**
@ -27,12 +30,33 @@ export class UserAdminService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly pseudonymizer: PseudonymizationService, private readonly pseudonymizer: PseudonymizationService,
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly users: UsersService,
private readonly ponds: PondsService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(UserAdminService.name); this.logger.setContext(UserAdminService.name);
} }
/**
* Creates an account on behalf of a user (issue #331). The e-mail is
* marked verified immediately the admin vouches for the address and
* the personal pond is provisioned exactly like the verify-email path
* does, so the account is indistinguishable from a self-registered one.
*/
async createUser(actor: User, input: AdminCreateUserInput): Promise<AdminUserView> {
const user = await this.users.createUser(input);
const verified = await this.users.markEmailVerified(user.id);
await this.ponds.ensurePersonalPond(verified);
await this.audit.record({
action: 'user.created_by_admin',
actorId: actor.id,
targetType: 'user',
targetId: user.id,
});
return this.viewOf(verified, await this.pondCountOf(user.id));
}
async list(query: AdminUserListQuery): Promise<AdminUserListView> { async list(query: AdminUserListQuery): Promise<AdminUserListView> {
const q = query.q?.trim(); const q = query.q?.trim();
const where: Prisma.UserWhereInput = q const where: Prisma.UserWhereInput = q

View File

@ -29,6 +29,9 @@ export const AUDIT_EVENTS = {
'file.integrity_failed': { severity: 'critical' }, 'file.integrity_failed': { severity: 'critical' },
'grant.created': { severity: 'notice' }, 'grant.created': { severity: 'notice' },
'grant.deleted': { severity: 'notice' }, 'grant.deleted': { severity: 'notice' },
'invitation.accepted': { severity: 'notice' },
'invitation.created': { severity: 'info' },
'invitation.revoked': { severity: 'info' },
'job.triggered': { severity: 'info' }, 'job.triggered': { severity: 'info' },
'member.added': { severity: 'notice' }, 'member.added': { severity: 'notice' },
'member.removed': { severity: 'notice' }, 'member.removed': { severity: 'notice' },
@ -53,6 +56,7 @@ export const AUDIT_EVENTS = {
'setup.completed': { severity: 'info' }, 'setup.completed': { severity: 'info' },
'setup.preseeded': { severity: 'info' }, 'setup.preseeded': { severity: 'info' },
'setup.smtp_stored': { severity: 'info' }, 'setup.smtp_stored': { severity: 'info' },
'user.created_by_admin': { severity: 'notice' },
'user.deleted': { severity: 'notice' }, 'user.deleted': { severity: 'notice' },
'user.disabled_set': { severity: 'notice' }, 'user.disabled_set': { severity: 'notice' },
'user.pseudonymized': { severity: 'notice' }, 'user.pseudonymized': { severity: 'notice' },

View File

@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { GrantsModule } from '../grants/grants.module'; import { GrantsModule } from '../grants/grants.module';
import { InvitationsModule } from '../invitations/invitations.module';
import { MailModule } from '../mail/mail.module'; import { MailModule } from '../mail/mail.module';
import { PondsModule } from '../ponds/ponds.module'; import { PondsModule } from '../ponds/ponds.module';
@ -18,7 +19,7 @@ import { ProxyIdentityService } from './proxy-identity.service';
import { SessionsModule } from './sessions.module'; import { SessionsModule } from './sessions.module';
@Module({ @Module({
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule], imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
controllers: [AuthController, OidcController], controllers: [AuthController, OidcController],
providers: [ providers: [
AuthService, AuthService,

View File

@ -9,6 +9,7 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { InvitationsService } from '../invitations/invitations.service';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { PondsService } from '../ponds/ponds.service'; import { PondsService } from '../ponds/ponds.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
@ -33,6 +34,7 @@ export class AuthService {
private readonly sessions: SessionsService, private readonly sessions: SessionsService,
private readonly mail: MailService, private readonly mail: MailService,
private readonly ponds: PondsService, private readonly ponds: PondsService,
private readonly invitations: InvitationsService,
private readonly rateLimits: RateLimitService, private readonly rateLimits: RateLimitService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly config: AppConfig, private readonly config: AppConfig,
@ -43,10 +45,37 @@ export class AuthService {
} }
async signup(input: SignupInput): Promise<void> { async signup(input: SignupInput): Promise<void> {
if ((await this.settings.get('auth.registrationMode')) === 'closed') { // An invitation token (issue #332) lets exactly one signup through a
// closed registration. Claimed atomically BEFORE the account exists;
// rolled back if the signup fails (duplicate username), so the invitee
// can retry with the same link.
const invitation = input.invitationToken
? await this.invitations.redeem(input.invitationToken)
: null;
if (input.invitationToken && !invitation) {
throw new BadRequestException({ code: 'token_invalid' });
}
if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') {
throw new ForbiddenException({ code: 'registration_closed' }); throw new ForbiddenException({ code: 'registration_closed' });
} }
const user = await this.users.createUser(input); let user: User;
try {
user = await this.users.createUser(input);
} catch (error) {
if (invitation) await this.invitations.unredeem(invitation.id);
throw error;
}
if (invitation) {
await this.invitations.markAccepted(invitation.id, user.id);
await this.audit.record({
action: 'invitation.accepted',
actorId: user.id,
targetType: 'invitation',
targetId: invitation.id,
});
}
// The invite link proves nothing about the mailbox (it can be
// forwarded), so the usual verification mail still applies.
await this.sendVerificationMail(user); await this.sendVerificationMail(user);
await this.audit.record({ action: 'auth.signup', actorId: user.id }); await this.audit.record({ action: 'auth.signup', actorId: user.id });
} }

View File

@ -3,6 +3,8 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
NotFoundException,
Param,
Post, Post,
Query, Query,
Req, Req,
@ -19,11 +21,14 @@ import {
LOGO_VARIANTS, LOGO_VARIANTS,
LogoVariant, LogoVariant,
MAX_BRANDING_BYTES, MAX_BRANDING_BYTES,
PondBranding,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard'; import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.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'; import { BrandingService } from './branding.service';
function parseVariant(value: unknown): LogoVariant { function parseVariant(value: unknown): LogoVariant {
@ -52,8 +57,19 @@ export class BrandingController {
@Public() @Public()
@Get('logo') @Get('logo')
async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise<void> { async logo(
const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light')); @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 // No shipped default: without a logo the app renders the instance NAME as
// text, so an empty answer here is the honest one. // text, so an empty answer here is the honest one.
if (!bytes) { if (!bytes) {
@ -69,12 +85,21 @@ export class BrandingController {
@Public() @Public()
@Get('favicon') @Get('favicon')
async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise<void> { async favicon(
@Query('size') size: string | undefined,
@Query('pond') pondId: string | undefined,
@Res() res: Response,
): Promise<void> {
const wanted = Number(size ?? 32); const wanted = Number(size ?? 32);
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) { if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
throw new BadRequestException({ code: 'bad_request' }); throw new BadRequestException({ code: 'bad_request' });
} }
const { bytes, uploaded } = await this.branding.faviconBytes(wanted as FaviconSize); 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'); res.setHeader('Content-Type', 'image/png');
// The `<link rel="icon">` href is a constant in index.html, so this URL // 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 // cannot carry a hash — revalidation is the only way a replaced favicon
@ -133,3 +158,96 @@ export class BrandingAdminController {
return this.branding.clearFavicon(request.user!); return this.branding.clearFavicon(request.user!);
} }
} }
/**
* Pond-level branding (issue #307). The uploader here is an ordinary Pond
* Admin rather than the operator, so the security rules of #306 are not
* relaxed by a single line: SVG refused, magic bytes checked server-side,
* size caps enforced, content type pinned on serving, no image parsing.
*
* 404/403 policy: a user who cannot see the pond gets 404 from the pond-role
* guard, one who can see but not administer it gets 403.
*/
@Controller('ponds/:pondId/branding')
export class PondBrandingController {
constructor(
private readonly branding: BrandingService,
private readonly prisma: PrismaService,
) {}
/** The pond row the quota is charged to. */
private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> {
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { id: true, ownerId: true },
});
if (!pond) throw new NotFoundException();
return pond;
}
@Get()
@RequiresPondRole('reader', { idParam: 'pondId' })
view(@Param('pondId') pondId: string): Promise<PondBranding> {
return this.branding.pondBranding(pondId);
}
@Post('logo')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setLogo(
@Param('pondId') pondId: string,
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<PondBranding> {
const file = files?.find((entry) => entry.fieldname === 'file');
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
return this.branding.setPondLogo(
request.user!,
await this.pondOf(pondId),
parseVariant(variant ?? 'light'),
file.buffer,
);
}
@Delete('logo')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async clearLogo(
@Param('pondId') pondId: string,
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
): Promise<PondBranding> {
return this.branding.clearPondLogo(
request.user!,
await this.pondOf(pondId),
parseVariant(variant ?? 'light'),
);
}
@Post('favicon')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setFavicon(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<PondBranding> {
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
const collected = {} as Record<FaviconSize, Buffer>;
for (const size of FAVICON_SIZES) {
const bytes = byField.get(`png-${size}`);
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
collected[size] = bytes;
}
return this.branding.setPondFavicon(request.user!, await this.pondOf(pondId), collected);
}
@Delete('favicon')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async clearFavicon(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondBranding> {
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
}
}

View File

@ -1,6 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { BrandingAdminController, BrandingController } from './branding.controller'; 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 { BrandingStorageService } from './branding-storage.service';
import { BrandingService } from './branding.service'; import { BrandingService } from './branding.service';
@ -8,7 +15,8 @@ import { BrandingService } from './branding.service';
* the pond-level override (#307) can build on the same storage and the same * the pond-level override (#307) can build on the same storage and the same
* resolution path instead of a parallel one. */ * resolution path instead of a parallel one. */
@Module({ @Module({
controllers: [BrandingController, BrandingAdminController], imports: [PermissionsModule, QuotasModule],
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
providers: [BrandingService, BrandingStorageService], providers: [BrandingService, BrandingStorageService],
exports: [BrandingService, BrandingStorageService], exports: [BrandingService, BrandingStorageService],
}) })

View File

@ -2,12 +2,16 @@ import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { import {
BrandingAsset, BrandingAsset,
BrandingView, BrandingView,
FAVICON_SIZES,
FaviconSize, FaviconSize,
LOGO_VARIANTS,
LogoVariant, LogoVariant,
PondBranding,
pondSettingsSchema,
MAX_BRANDING_BYTES, MAX_BRANDING_BYTES,
MAX_LOGO_EDGE, MAX_LOGO_EDGE,
hasPngMagic, hasPngMagic,
@ -17,6 +21,8 @@ import {
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { AuditService } from '../audit/audit.service'; 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 { InstanceSettingsService } from '../settings/instance-settings.service';
import { BrandingStorageService } from './branding-storage.service'; import { BrandingStorageService } from './branding-storage.service';
@ -41,6 +47,8 @@ export class BrandingService {
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly storage: BrandingStorageService, private readonly storage: BrandingStorageService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly prisma: PrismaService,
private readonly quotas: QuotaService,
) {} ) {}
static logoKey(variant: LogoVariant): string { static logoKey(variant: LogoVariant): string {
@ -51,6 +59,26 @@ export class BrandingService {
return `instance-favicon-${size}`; 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 * 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 * written. SVG gets its own message: an operator who tried one deserves to
@ -71,11 +99,37 @@ export class BrandingService {
return size; 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 { private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
return { return {
// Short digest: it only has to change when the bytes change, and it // Short digest: it only has to change when the bytes change, and it
// travels in every logo URL. // travels in every logo URL.
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16), hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
byteSize: bytes.length,
...size, ...size,
}; };
} }
@ -164,6 +218,150 @@ export class BrandingService {
return { bytes, uploaded: false }; 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( private record(
admin: User, admin: User,
asset: 'logo' | 'logoDark' | 'favicon', asset: 'logo' | 'logoDark' | 'favicon',

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

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

View File

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

View File

@ -26,48 +26,56 @@ describe('sort-key helpers (issue #45)', () => {
* pattern repeatedly drop the last page between the first two must never * pattern repeatedly drop the last page between the first two must never
* collide and never overflow the key length, because the caller rebalances * collide and never overflow the key length, because the caller rebalances
* when {@link nextKeyOrRebalance} returns null. * 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)', () => { it(
// Start with five pages in a fixed order. '10.000 adversarial reorders never collide or overflow (rebalance verified)',
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key })); { timeout: 30_000 },
let rebalances = 0; () => {
// 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 rebalance = (): void => {
const keys = evenlySpacedKeys(order.length); const keys = evenlySpacedKeys(order.length);
order = order.map((page, i) => ({ ...page, key: keys[i]! })); order = order.map((page, i) => ({ ...page, key: keys[i]! }));
rebalances += 1; rebalances += 1;
}; };
for (let i = 0; i < 10_000; i += 1) { for (let i = 0; i < 10_000; i += 1) {
// Move the last page to sit between the first and second — the tightest // Move the last page to sit between the first and second — the tightest
// possible gap, which is what grows key length fastest. // possible gap, which is what grows key length fastest.
const moved = order[order.length - 1]!; const moved = order[order.length - 1]!;
const rest = order.slice(0, -1); const rest = order.slice(0, -1);
const afterKey = rest[0]!.key; const afterKey = rest[0]!.key;
const beforeKey = rest[1]!.key; const beforeKey = rest[1]!.key;
const key = nextKeyOrRebalance(afterKey, beforeKey); const key = nextKeyOrRebalance(afterKey, beforeKey);
if (key === null) { if (key === null) {
// Rebalance keeps the CURRENT order, then retry the move once. // Rebalance keeps the CURRENT order, then retry the move once.
rebalance(); rebalance();
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key); const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
expect(k2).not.toBeNull(); expect(k2).not.toBeNull();
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)]; order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
} else { } else {
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)]; 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 adversarial pattern must have forced at least one rebalance.
// the intended array order. expect(rebalances).toBeGreaterThan(0);
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);
});
}); });

View File

@ -21,6 +21,9 @@ import { PrismaService } from '../prisma/prisma.service';
*/ */
export const INSTANCE_SETTINGS = { export const INSTANCE_SETTINGS = {
'auth.registrationMode': z.enum(['open', 'closed']).default('open'), '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.name': z.string().trim().min(1).max(60).default('Dorfteich'),
'instance.defaultLocale': z.enum(['de', 'en']).default('en'), 'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
// Branding assets (issue #306). Metadata only — the PNG bytes live under // Branding assets (issue #306). Metadata only — the PNG bytes live under

View File

@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
expect(locked.body.code).toBe('setup_locked'); 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 { interface FakeSmtpServer {

View File

@ -15,6 +15,7 @@ import {
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { ZodError } from 'zod';
import { SessionsService } from '../auth/sessions.service'; import { SessionsService } from '../auth/sessions.service';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit {
if (!(await this.state.isPending())) return; if (!(await this.state.isPending())) return;
// Fails the boot loudly on invalid values — a half-seeded instance // Fails the boot loudly on invalid values — a half-seeded instance
// would be much harder to diagnose than a startup error. // would be much harder to diagnose than a startup error. Translated
const input = setupAdminInputSchema.parse({ // 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, username: env.SETUP_ADMIN_USERNAME,
email: env.SETUP_ADMIN_EMAIL, email: env.SETUP_ADMIN_EMAIL,
password: env.SETUP_ADMIN_PASSWORD, password: env.SETUP_ADMIN_PASSWORD,
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME, displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
locale: env.SETUP_DEFAULT_LOCALE, 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); const admin = await this.createAdmin(input);
if (env.SETUP_INSTANCE_NAME) { if (env.SETUP_INSTANCE_NAME) {
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id); 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; 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,5 +1,6 @@
import { Module, OnModuleInit } from '@nestjs/common'; import { Module, OnModuleInit } from '@nestjs/common';
import { BrandingModule } from '../branding/branding.module';
import { CommonModule } from '../common/common.module'; import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
import { PagesModule } from '../pages/pages.module'; import { PagesModule } from '../pages/pages.module';
@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
@Module({ @Module({
imports: [ imports: [
BrandingModule,
CommonModule, CommonModule,
PondsModule, PondsModule,
QuotasModule, QuotasModule,

View File

@ -4,6 +4,7 @@ import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { BrandingService } from '../branding/branding.service';
import { ClockService } from '../common/clock.service'; import { ClockService } from '../common/clock.service';
import { SearchProvider } from '../search/search.provider'; import { SearchProvider } from '../search/search.provider';
import { PagesService } from '../pages/pages.service'; import { PagesService } from '../pages/pages.service';
@ -32,6 +33,7 @@ export class TrashService {
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly storage: FileStorageService, private readonly storage: FileStorageService,
private readonly branding: BrandingService,
private readonly clock: ClockService, private readonly clock: ClockService,
private readonly watches: WatchesService, private readonly watches: WatchesService,
private readonly audit: AuditService, private readonly audit: AuditService,
@ -183,6 +185,9 @@ export class TrashService {
for (const attachment of attachments) { for (const attachment of attachments) {
await this.storage.delete(pondId, attachment.id); 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 = ( const pageIds = (
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } }) await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
).map((page) => page.id); ).map((page) => page.id);

View File

@ -87,6 +87,9 @@ for (const scheme of SCHEMES) {
await page.emulateMedia({ colorScheme: scheme }); await page.emulateMedia({ colorScheme: scheme });
await page.goto('/settings'); await page.goto('/settings');
await page.waitForLoadState('networkidle'); 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})`); await expectClean(page, `/settings (${scheme})`);
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den // Lizenzseite im selben Kontext (issue #304: sie trägt seit den
@ -123,6 +126,11 @@ for (const scheme of SCHEMES) {
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl. // erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
await page.locator('.branding .crop-field input[type="file"]').first().waitFor(); await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
await expectClean(page, `/admin (${scheme})`); 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(); 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(); 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(); 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 }) => { test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`); const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);

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

@ -108,6 +108,102 @@ test('a plain-text paste is not mangled into rich structure', async ({ browser }
await context.close(); 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 }) => { test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`); const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);

View File

@ -28,8 +28,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
await expect(nav).toBeVisible(); await expect(nav).toBeVisible();
const links = nav.locator('.settings-nav__link'); const links = nav.locator('.settings-nav__link');
// Profile, password, sessions, watches, API tokens, feed tokens, data export. // Profile, password, sessions, watches, API tokens, feed tokens, data export.
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild). // 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild),
await expect(links).toHaveCount(9); // 10 seit #332 (Einladungen).
await expect(links).toHaveCount(10);
// Jump to the last section: it scrolls into view and becomes active. // Jump to the last section: it scrolls into view and becomes active.
const last = links.last(); 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 // select value — compliant choice clears it, violating choice brings it
// back, no save in between. // back, no save in between.
const regField = page.locator('label.field', { const regField = page.locator('label.field', {
has: page.locator('select[name="auth.registrationMode"]'), has: page.locator('select[name="registrationMode"]'),
}); });
const regSelect = regField.locator('select'); const regSelect = regField.locator('select');
await regSelect.selectOption('open'); 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 // Value-listed control: the compliant registration mode keeps only its
// compliant choice (seed leaves it open = violating? then all options). // 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 regField = page.locator('label.field', { has: regSelect });
const optionCount = await regSelect.locator('option').count(); const optionCount = await regSelect.locator('option').count();
const marked = await regField.locator('.vs-nfd-mark').count(); const marked = await regField.locator('.vs-nfd-mark').count();

View File

@ -1,50 +1,57 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { logoUrl, useBranding } from './use-branding'; import { useCurrentPondRoute } from '../layout/use-pond-route';
import { logoUrl, usePondFavicon, useResolvedBranding } from './use-branding';
import { usePondId } from './use-pond-id';
/** /**
* The instance identity at the top of the sidebar (issue #306): the uploaded * The identity at the top of the sidebar (issues #306/#307): the pond's own
* logo as a link home, or the instance name as text when nothing is uploaded. * logo when it has one, else the instance's, else the instance name as text.
* *
* Its accessible name is the INSTANCE NAME, never "logo": for a screen reader * Its accessible name follows the LEVEL the logo came from a pond logo is
* this is the link home, and a link's name has to say where it goes. The * named by the pond, an instance logo by the instance. For a screen reader
* images are therefore `alt=""` the link is already named. * 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]`), * 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 * not by JavaScript: `theme-init.js` resolves the theme before first paint, so
* the correct logo is the one painted rather than the one that appears after a * the correct logo is the one painted. A logo set belongs to ONE level and is
* flash. Without a dark variant the light one carries both themes the * never mixed across levels see `resolveBranding`.
* operator's own asset, shown unchanged, rather than a substitute they did
* not choose (the rule #307 extends to ponds).
*/ */
export function BrandLogo(): React.JSX.Element | null { export function BrandLogo(): React.JSX.Element | null {
const branding = useBranding(); const { pondSlug } = useCurrentPondRoute();
if (!branding) return null; const { pondId, pondName } = usePondId(pondSlug);
const { logo, logoDark, instanceName } = branding; 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 ( return (
<Link to="/" className="brand-logo" aria-label={instanceName}> <Link to="/" className="brand-logo" aria-label={name}>
{logo ? ( {resolved.logo || resolved.logoDark ? (
<> <>
<img {resolved.logo && (
className={`brand-logo__img brand-logo__img--light${logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('light', logo.hash)}
width={logo.width}
height={logo.height}
alt=""
/>
{logoDark && (
<img <img
className="brand-logo__img brand-logo__img--dark" className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('dark', logoDark.hash)} src={logoUrl('light', resolved.logo.hash, logoPond)}
width={logoDark.width} width={resolved.logo.width}
height={logoDark.height} 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="" alt=""
/> />
)} )}
</> </>
) : ( ) : (
<span className="brand-logo__name">{instanceName}</span> <span className="brand-logo__name">{name}</span>
)} )}
</Link> </Link>
); );

View File

@ -1,5 +1,6 @@
import { BrandingView } from '@dorfteich/shared'; import { BrandingView, PondBranding, ResolvedBranding, resolveBranding } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
@ -21,9 +22,65 @@ export function useBranding(): BrandingView | undefined {
} }
/** URL of a logo variant, with the content hash so a replaced logo is never /** URL of a logo variant, with the content hash so a replaced logo is never
* served from cache. */ * served from cache. `pondId` scopes it to a pond's own asset (issue #307);
export function logoUrl(variant: 'light' | 'dark', hash: string): string { * the route never falls back on its own the CALLER decided which level
return `/api/v1/branding/logo?variant=${variant}&v=${hash}`; * 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> { export function useInvalidateBranding(): () => Promise<void> {

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

@ -127,6 +127,8 @@ export function Toolbar({
canAddColumn: e.can().addColumnAfter(), canAddColumn: e.can().addColumnAfter(),
canDeleteColumn: e.can().deleteColumn(), canDeleteColumn: e.can().deleteColumn(),
canDeleteTable: e.can().deleteTable(), canDeleteTable: e.can().deleteTable(),
canMergeCells: e.can().mergeCells(),
canSplitCell: e.can().splitCell(),
canToggleHeaderRow: e.can().toggleHeaderRow(), canToggleHeaderRow: e.can().toggleHeaderRow(),
canUndo: e.can().undo(), canUndo: e.can().undo(),
canRedo: e.can().redo(), canRedo: e.can().redo(),
@ -270,7 +272,10 @@ export function Toolbar({
disabled={!state.canDeleteColumn} disabled={!state.canDeleteColumn}
onClick={() => editor.chain().focus().deleteColumn().run()} 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>
<ToolbarButton <ToolbarButton
label={t('toolbar.table.addRowBefore')} label={t('toolbar.table.addRowBefore')}
@ -291,7 +296,23 @@ export function Toolbar({
disabled={!state.canDeleteRow} disabled={!state.canDeleteRow}
onClick={() => editor.chain().focus().deleteRow().run()} 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>
<ToolbarButton <ToolbarButton
label={t('toolbar.table.toggleHeaderRow')} label={t('toolbar.table.toggleHeaderRow')}

View File

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

View File

@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { looksLikeMarkdown } from './markdown-clipboard'; import { htmlIsStyledPlainText, looksLikeMarkdown } from './markdown-clipboard';
describe('looksLikeMarkdown (issue #30)', () => { describe('looksLikeMarkdown (issue #30)', () => {
it('recognizes a heading + list document', () => { it('recognizes a heading + list document', () => {
@ -31,3 +32,21 @@ describe('looksLikeMarkdown (issue #30)', () => {
expect(looksLikeMarkdown(' \n ')).toBe(false); expect(looksLikeMarkdown(' \n ')).toBe(false);
}); });
}); });
describe('htmlIsStyledPlainText (issue #339)', () => {
it('recognizes VS-Code-style syntax-highlighting HTML as styled plain text', () => {
const vsCode =
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
'<div><span>| --- | --- |</span></div></div>';
expect(htmlIsStyledPlainText(vsCode)).toBe(true);
});
it('keeps rich-text clipboard HTML on the HTML paste path', () => {
expect(htmlIsStyledPlainText('<table><tr><td>a</td></tr></table>')).toBe(false);
expect(htmlIsStyledPlainText('<p><strong>bold</strong> prose</p>')).toBe(false);
expect(htmlIsStyledPlainText('<ul><li>one</li></ul>')).toBe(false);
expect(htmlIsStyledPlainText('<p><a href="https://example.org">link</a></p>')).toBe(false);
expect(htmlIsStyledPlainText('<pre><code>x</code></pre>')).toBe(false);
});
});

View File

@ -26,6 +26,25 @@ export function looksLikeMarkdown(text: string): boolean {
return matches.length >= 2; return matches.length >= 2;
} }
/** Elements whose presence means the clipboard HTML carries real structure
* or semantics that ProseMirror's HTML paste should interpret. */
const STRUCTURAL_HTML =
'table, ul, ol, li, h1, h2, h3, h4, h5, h6, blockquote, pre, code, a, img, b, strong, i, em, u, s';
/**
* Code editors (VS Code with copyWithSyntaxHighlighting, similar tools) put
* an HTML flavor on the clipboard that is nothing but the plain text wrapped
* in styled div/span containers. Treating that as "real HTML" made the paste
* ignore the Markdown heuristic below, so a Markdown table copied out of
* VS Code arrived as verbatim text while the same text from a plain editor
* converted fine (issue #339). Only HTML without any structural element is
* declared equivalent to the plain text anything from a rich-text source
* keeps going through ProseMirror's own HTML paste.
*/
export function htmlIsStyledPlainText(html: string): boolean {
return new DOMParser().parseFromString(html, 'text/html').querySelector(STRUCTURAL_HTML) === null;
}
/** /**
* Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying * Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying
* puts Markdown on `text/plain` alongside the browser's own HTML (so * puts Markdown on `text/plain` alongside the browser's own HTML (so
@ -65,8 +84,11 @@ export const MarkdownClipboard = Extension.create({
} }
}, },
handlePaste(view, event) { handlePaste(view, event) {
// Inside a code block pasted text is code, never a document —
// converting there would split the block around rich nodes.
if (view.state.selection.$from.parent.type.spec.code) return false;
const html = event.clipboardData?.getData('text/html'); const html = event.clipboardData?.getData('text/html');
if (html && html.trim() !== '') return false; if (html && html.trim() !== '' && !htmlIsStyledPlainText(html)) return false;
const text = event.clipboardData?.getData('text/plain'); const text = event.clipboardData?.getData('text/plain');
if (!text || !looksLikeMarkdown(text)) return false; if (!text || !looksLikeMarkdown(text)) return false;

View File

@ -0,0 +1,68 @@
import { markdownToDoc } from '@dorfteich/shared';
import { Extension } from '@tiptap/core';
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { Plugin, Selection } from '@tiptap/pm/state';
/** A `| … |` pipe row — the same signal `looksLikeMarkdown` uses. */
const PIPE_ROW = /^\|.+\|\s*$/;
/** The GFM header separator (`| --- | :--- |`). Three dashes minimum keeps
* accidental short rows like `|-|` from ever triggering a conversion. */
const SEPARATOR_ROW = /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/;
/**
* Hand-typed Markdown tables (issue #339): pressing Enter at the end of a
* separator row whose previous sibling is a pipe row replaces the two
* paragraphs with a real table. TipTap input rules cannot express this
* they only see text inside a single textblock, and a table needs two.
* Conversion is refused inside existing tables (the schema would allow the
* nested table, the reader could not make sense of it). Body rows are then
* typed cell-wise Tab in the last cell appends a row (#338).
*/
export const MarkdownTableInput = Extension.create({
name: 'markdownTableInput',
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handleKeyDown(view, event) {
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey)
return false;
const { $from, empty } = view.state.selection;
if (!empty || $from.parent.type.name !== 'paragraph') return false;
if ($from.parentOffset !== $from.parent.content.size) return false;
if (!SEPARATOR_ROW.test($from.parent.textContent)) return false;
for (let depth = $from.depth - 1; depth > 0; depth -= 1) {
if ($from.node(depth).type.spec.tableRole) return false;
}
const container = $from.node($from.depth - 1);
const index = $from.index($from.depth - 1);
if (index === 0) return false;
const headerRow = container.child(index - 1);
if (headerRow.type.name !== 'paragraph' || !PIPE_ROW.test(headerRow.textContent))
return false;
let table: ProseMirrorNode;
try {
const parsed = markdownToDoc(`${headerRow.textContent}\n${$from.parent.textContent}`);
if (parsed.childCount !== 1 || parsed.firstChild?.type.name !== 'table') return false;
// Re-hydrated against the live schema — same identity dance as
// in markdown-clipboard.ts.
table = ProseMirrorNode.fromJSON(view.state.schema, parsed.firstChild.toJSON());
} catch {
return false;
}
const start = $from.before($from.depth) - headerRow.nodeSize;
const end = $from.after($from.depth);
const tr = view.state.tr.replaceWith(start, end, table);
tr.setSelection(Selection.near(tr.doc.resolve(start), 1));
view.dispatch(tr.scrollIntoView());
return true;
},
},
}),
];
},
});

View File

@ -1,4 +1,6 @@
import { Node } from '@tiptap/core'; import { Node } from '@tiptap/core';
import { GapCursor } from '@tiptap/pm/gapcursor';
import { Selection } from '@tiptap/pm/state';
import type { Node as PMNode, Schema } from 'prosemirror-model'; import type { Node as PMNode, Schema } from 'prosemirror-model';
import { import {
addColumnAfter, addColumnAfter,
@ -8,6 +10,9 @@ import {
deleteColumn, deleteColumn,
deleteRow, deleteRow,
deleteTable, deleteTable,
goToNextCell,
mergeCells,
splitCell,
tableEditing, tableEditing,
toggleHeaderRow, toggleHeaderRow,
} from 'prosemirror-tables'; } from 'prosemirror-tables';
@ -34,6 +39,10 @@ declare module '@tiptap/core' {
addRowAfter: () => ReturnType; addRowAfter: () => ReturnType;
deleteRow: () => ReturnType; deleteRow: () => ReturnType;
deleteTable: () => ReturnType; deleteTable: () => ReturnType;
mergeCells: () => ReturnType;
splitCell: () => ReturnType;
goToNextCell: () => ReturnType;
goToPreviousCell: () => ReturnType;
toggleHeaderRow: () => ReturnType; toggleHeaderRow: () => ReturnType;
}; };
} }
@ -65,6 +74,37 @@ export const Table = Node.create({
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [tableEditing()]; return [tableEditing()];
}, },
addKeyboardShortcuts() {
return {
// Word-style navigation (issue #338): Tab moves cell-wise and appends
// a new row from the last cell. Outside a table every branch returns
// false, so Tab keeps its browser default (focus moves on) and the
// editor is no keyboard trap — from inside a table the arrow keys
// lead out via the gap cursor (#335), then Tab leaves the editor.
Tab: () => {
if (this.editor.commands.goToNextCell()) return true;
if (!this.editor.can().addRowAfter()) return false;
return this.editor.chain().addRowAfter().goToNextCell().run();
},
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
// The documented exit (aria-describedby hint, #338): the gap cursor is
// only reachable per arrow key from the table's edge cells, so Escape
// is the exit that works from EVERY cell. Falls back to a gap cursor
// when no textblock follows the table (#335 guarantees the position).
Escape: () =>
this.editor.commands.command(({ state, dispatch }) => {
const { $head } = state.selection;
for (let depth = $head.depth; depth > 0; depth -= 1) {
if ($head.node(depth).type.spec.tableRole !== 'table') continue;
const $after = state.doc.resolve($head.after(depth));
const selection = Selection.findFrom($after, 1, true) ?? new GapCursor($after);
if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
return true;
}
return false;
}),
};
},
addCommands() { addCommands() {
return { return {
insertTable: insertTable:
@ -101,6 +141,22 @@ export const Table = Node.create({
() => () =>
({ state, dispatch }) => ({ state, dispatch }) =>
deleteTable(state, dispatch), deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) =>
mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) =>
splitCell(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) =>
goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) =>
goToNextCell(-1)(state, dispatch),
toggleHeaderRow: toggleHeaderRow:
() => () =>
({ state, dispatch }) => ({ state, dispatch }) =>

View File

@ -10,6 +10,7 @@ import deFiles from '@dorfteich/shared/i18n/de/files.json';
import deFont from '@dorfteich/shared/i18n/de/font.json'; import deFont from '@dorfteich/shared/i18n/de/font.json';
import deGraph from '@dorfteich/shared/i18n/de/graph.json'; import deGraph from '@dorfteich/shared/i18n/de/graph.json';
import deImport from '@dorfteich/shared/i18n/de/import.json'; import deImport from '@dorfteich/shared/i18n/de/import.json';
import deInvitations from '@dorfteich/shared/i18n/de/invitations.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json';
@ -39,6 +40,7 @@ import enFiles from '@dorfteich/shared/i18n/en/files.json';
import enFont from '@dorfteich/shared/i18n/en/font.json'; import enFont from '@dorfteich/shared/i18n/en/font.json';
import enGraph from '@dorfteich/shared/i18n/en/graph.json'; import enGraph from '@dorfteich/shared/i18n/en/graph.json';
import enImport from '@dorfteich/shared/i18n/en/import.json'; import enImport from '@dorfteich/shared/i18n/en/import.json';
import enInvitations from '@dorfteich/shared/i18n/en/invitations.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json';
@ -85,6 +87,7 @@ void i18n
font: enFont, font: enFont,
graph: enGraph, graph: enGraph,
import: enImport, import: enImport,
invitations: enInvitations,
labels: enLabels, labels: enLabels,
legal: enLegal, legal: enLegal,
links: enLinks, links: enLinks,
@ -116,6 +119,7 @@ void i18n
font: deFont, font: deFont,
graph: deGraph, graph: deGraph,
import: deImport, import: deImport,
invitations: deInvitations,
labels: deLabels, labels: deLabels,
legal: deLegal, legal: deLegal,
links: deLinks, links: deLinks,

View File

@ -1,22 +1,33 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useBranding } from '../branding/use-branding';
const APP_NAME = 'Dorfteich'; const APP_NAME = 'Dorfteich';
/** /**
* Route-specific document title (issue #163, WCAG 2.4.2): joins the given * Route-specific document title (issue #163, WCAG 2.4.2): joins the given
* parts with the app name ("Page — Pond — Dorfteich"). Empty/undefined * parts with the instance name ("Page — Pond — My Wiki"). Empty/undefined
* parts are skipped, so callers can pass still-loading data directly. * parts are skipped, so callers can pass still-loading data directly.
* Falls back to the bare app name on unmount. * Falls back to the bare instance name on unmount.
*
* The trailing name is the OPERATOR'S instance name, not the product name
* (issue #323) same reasoning as the TopBar brand (issue #306). Until
* the branding 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.
*/ */
export function useDocumentTitle(...parts: (string | null | undefined)[]): void { export function useDocumentTitle(...parts: (string | null | undefined)[]): void {
const joined = [...parts.filter(Boolean), APP_NAME].join(' — '); const appName = useBranding()?.instanceName.trim() || APP_NAME;
const joined = [...parts.filter(Boolean), appName].join(' — ');
useEffect(() => { useEffect(() => {
document.title = joined; document.title = joined;
}, [joined]); }, [joined]);
useEffect( useEffect(
// On unmount only in effect: `joined` always changes with `appName`,
// so the title effect above re-runs right after this cleanup.
() => () => { () => () => {
document.title = APP_NAME; document.title = appName;
}, },
[], [appName],
); );
} }

View File

@ -5,10 +5,17 @@ import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { BRANDING_KEY } from '../branding/use-branding';
import { Field, FormError, FormSuccess } from '../components/forms'; import { Field, FormError, FormSuccess } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout'; import { SettingsLayout } from '../components/SettingsLayout';
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import {
GENERAL_FORM_FIELDS,
GeneralSettingsForm,
toFormValues,
toSettingsPatch,
} from './admin-settings-form';
import { BrandingManager } from './BrandingManager'; import { BrandingManager } from './BrandingManager';
import { CustomFontManager } from './CustomFontManager'; import { CustomFontManager } from './CustomFontManager';
import { PluginManager } from './PluginManager'; import { PluginManager } from './PluginManager';
@ -18,6 +25,7 @@ import { UserManager } from './UserManager';
import { useDocumentTitle } from '../lib/use-document-title'; import { useDocumentTitle } from '../lib/use-document-title';
interface InstanceSettings { interface InstanceSettings {
'auth.registrationMode': 'open' | 'closed'; 'auth.registrationMode': 'open' | 'closed';
'invitations.maxOpenPerUser': number;
'instance.name': string; 'instance.name': string;
'instance.defaultLocale': 'de' | 'en'; 'instance.defaultLocale': 'de' | 'en';
'quota.editorsPerPond': number; 'quota.editorsPerPond': number;
@ -51,15 +59,23 @@ export function AdminSettingsPage(): React.JSX.Element {
queryFn: () => apiGet<InstanceSettings>('/admin/settings'), queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
}); });
const form = useForm<InstanceSettings>({ values: settings.data }); // Dot-free field names with an explicit mapping to the dotted settings
// keys — see admin-settings-form.ts for why the names must not contain
// dots (issue #322).
const form = useForm<GeneralSettingsForm>({
values: settings.data ? toFormValues(settings.data) : undefined,
});
const vsNfd = useVsNfdMarking(); const vsNfd = useVsNfdMarking();
const onSubmit = form.handleSubmit(async (input) => { const onSubmit = form.handleSubmit(async (input) => {
setError(null); setError(null);
setSaved(false); setSaved(false);
try { try {
await apiPatch('/admin/settings', input); await apiPatch('/admin/settings', toSettingsPatch(input));
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }); await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
// The TopBar takes the instance name from the public branding query;
// without this it keeps the old name until its staleTime runs out.
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
setSaved(true); setSaved(true);
} catch (err) { } catch (err) {
setError(err); setError(err);
@ -79,6 +95,10 @@ export function AdminSettingsPage(): React.JSX.Element {
<section className="settings-section"> <section className="settings-section">
<h2>{t('settings:admin.general')}</h2> <h2>{t('settings:admin.general')}</h2>
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) || {(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
vsNfd.hides(
'invitations.maxOpenPerUser',
settings.data['invitations.maxOpenPerUser'],
) ||
vsNfd.hides( vsNfd.hides(
'classification.newPageDefault', 'classification.newPageDefault',
settings.data['classification.newPageDefault'], settings.data['classification.newPageDefault'],
@ -91,37 +111,53 @@ export function AdminSettingsPage(): React.JSX.Element {
<FormError error={error} /> <FormError error={error} />
<FormSuccess message={saved ? t('settings:admin.saved') : null} /> <FormSuccess message={saved ? t('settings:admin.saved') : null} />
<Field label={t('settings:admin.instanceName')}> <Field label={t('settings:admin.instanceName')}>
<input type="text" {...form.register('instance.name')} /> <input type="text" {...form.register('instanceName')} />
</Field> </Field>
<Field label={t('settings:admin.defaultLocale')}> <Field label={t('settings:admin.defaultLocale')}>
<select {...form.register('instance.defaultLocale')}> <select {...form.register('defaultLocale')}>
<option value="de">{t('settings:profile.locales.de')}</option> <option value="de">{t('settings:profile.locales.de')}</option>
<option value="en">{t('settings:profile.locales.en')}</option> <option value="en">{t('settings:profile.locales.en')}</option>
</select> </select>
</Field> </Field>
<Field <Field
label={t('settings:admin.registrationMode')} label={t('settings:admin.registrationMode')}
marking={vsNfd.markingFor( marking={vsNfd.markingFor('auth.registrationMode', form.watch('registrationMode'))}
'auth.registrationMode',
form.watch('auth.registrationMode'),
)}
> >
<select {...form.register('auth.registrationMode')}> <select {...form.register('registrationMode')}>
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && ( {!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
<option value="open">{t('settings:admin.registrationOpen')}</option> <option value="open">{t('settings:admin.registrationOpen')}</option>
)} )}
<option value="closed">{t('settings:admin.registrationClosed')}</option> <option value="closed">{t('settings:admin.registrationClosed')}</option>
</select> </select>
</Field> </Field>
{!vsNfd.hides(
'invitations.maxOpenPerUser',
settings.data['invitations.maxOpenPerUser'],
) && (
<Field
label={t('settings:admin.invitationsMaxOpen')}
hint={t('settings:admin.invitationsMaxOpenHelp')}
marking={vsNfd.markingFor(
'invitations.maxOpenPerUser',
form.watch('invitationsMaxOpenPerUser'),
)}
>
<input
type="number"
min={0}
{...form.register('invitationsMaxOpenPerUser', { valueAsNumber: true })}
/>
</Field>
)}
<Field <Field
label={t('settings:admin.newPageClassification')} label={t('settings:admin.newPageClassification')}
hint={t('settings:admin.newPageClassificationHelp')} hint={t('settings:admin.newPageClassificationHelp')}
marking={vsNfd.markingFor( marking={vsNfd.markingFor(
'classification.newPageDefault', 'classification.newPageDefault',
form.watch('classification.newPageDefault'), form.watch('newPageClassification'),
)} )}
> >
<select {...form.register('classification.newPageDefault')}> <select {...form.register('newPageClassification')}>
{!vsNfd.hides( {!vsNfd.hides(
'classification.newPageDefault', 'classification.newPageDefault',
settings.data['classification.newPageDefault'], settings.data['classification.newPageDefault'],
@ -136,12 +172,9 @@ export function AdminSettingsPage(): React.JSX.Element {
<Field <Field
label={t('settings:admin.uploadPolicy')} label={t('settings:admin.uploadPolicy')}
hint={t('settings:admin.uploadPolicyHelp')} hint={t('settings:admin.uploadPolicyHelp')}
marking={vsNfd.markingFor( marking={vsNfd.markingFor('classification.uploadPolicy', form.watch('uploadPolicy'))}
'classification.uploadPolicy',
form.watch('classification.uploadPolicy'),
)}
> >
<select {...form.register('classification.uploadPolicy')}> <select {...form.register('uploadPolicy')}>
{!vsNfd.hides( {!vsNfd.hides(
'classification.uploadPolicy', 'classification.uploadPolicy',
settings.data['classification.uploadPolicy'], settings.data['classification.uploadPolicy'],
@ -160,15 +193,18 @@ export function AdminSettingsPage(): React.JSX.Element {
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
{( {(
[ [
'quota.editorsPerPond', 'quotaEditorsPerPond',
'quota.readersPerPond', 'quotaReadersPerPond',
'quota.additionalPonds', 'quotaAdditionalPonds',
'quota.storageBytes', 'quotaStorageBytes',
'quota.maxFileBytes', 'quotaMaxFileBytes',
] as const ] as const
).map((key) => ( ).map((field) => (
<Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}> <Field
<input type="number" min={0} {...form.register(key, { valueAsNumber: true })} /> key={field}
label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[GENERAL_FORM_FIELDS[field]]}`)}
>
<input type="number" min={0} {...form.register(field, { valueAsNumber: true })} />
</Field> </Field>
))} ))}
<button type="submit" className="button" disabled={form.formState.isSubmitting}> <button type="submit" className="button" disabled={form.formState.isSubmitting}>

View File

@ -0,0 +1,140 @@
import type { InvitationListView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Field, FormError } from '../components/forms';
import { apiDelete, apiGet, apiPost } from '../lib/api';
const INVITATIONS_QUERY_KEY = ['users', 'me', 'invitations'] as const;
/**
* Peer invitations in the user settings (issue #332): invite an e-mail
* address, see your invitations with their status, revoke open ones. The
* quota line shows how many of the instance-wide per-user allowance are
* in use; with a quota of 0 the section explains that inviting is off.
*/
export function InvitationsSection(): React.JSX.Element {
const { t } = useTranslation('invitations');
const queryClient = useQueryClient();
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const list = useQuery({
queryKey: INVITATIONS_QUERY_KEY,
queryFn: () => apiGet<InvitationListView>('/invitations'),
});
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setSent(false);
setBusy(true);
try {
await apiPost('/invitations', { email });
setEmail('');
setSent(true);
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
};
const revoke = async (id: string): Promise<void> => {
setError(null);
await apiDelete(`/invitations/${id}`);
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
};
const data = list.data;
const disabled = data?.maxOpen === 0;
return (
<section className="settings-section invitations">
<h2>{t('section.title')}</h2>
{disabled ? (
<p>{t('section.disabled')}</p>
) : (
<>
<p className="invitations__intro">{t('section.intro')}</p>
{data && (
<p className="invitations__quota">
{t('section.quota', { open: data.open, max: data.maxOpen })}
</p>
)}
<form onSubmit={(e) => void submit(e)} noValidate className="invitations__form">
<FormError error={error} />
{/* Scoped status region: a bare getByRole('status') must stay
unambiguous for other specs (lesson from #304/legal). */}
<p className="invitations__sent" role="status">
{sent ? t('form.sent') : ''}
</p>
<Field label={t('form.email')}>
<input
type="email"
value={email}
required
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<button type="submit" className="button" disabled={busy || email.length === 0}>
{t('form.submit')}
</button>
</form>
{data && data.invitations.length === 0 && <p>{t('section.empty')}</p>}
{data && data.invitations.length > 0 && (
<div
className="table-scroll"
// A scroll container is only operable by keyboard once it is
// focusable; role+name keep it from being an unlabelled stop.
tabIndex={0}
role="region"
aria-label={t('section.title')}
>
<table className="table invitations__table">
<thead>
<tr>
<th>{t('columns.email')}</th>
<th>{t('columns.status')}</th>
<th>{t('columns.created')}</th>
<th>{t('columns.expires')}</th>
<th>{t('columns.actions')}</th>
</tr>
</thead>
<tbody>
{data.invitations.map((invitation) => (
<tr
key={invitation.id}
className="invitation-row"
data-email={invitation.email}
>
<td>{invitation.email}</td>
<td className="invitation-row__status">{t(`status.${invitation.status}`)}</td>
<td>{new Date(invitation.createdAt).toLocaleDateString()}</td>
<td>{new Date(invitation.expiresAt).toLocaleDateString()}</td>
<td>
{invitation.status === 'pending' && (
<button
type="button"
className="linklike invitation-row__revoke"
onClick={() => void revoke(invitation.id)}
>
{t('actions.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</section>
);
}

View File

@ -227,7 +227,9 @@ function PageEditor({
attributes: { attributes: {
role: canEdit ? 'textbox' : 'document', role: canEdit ? 'textbox' : 'document',
'aria-label': t('contentLabel'), 'aria-label': t('contentLabel'),
...(canEdit ? { 'aria-multiline': 'true' } : {}), ...(canEdit
? { 'aria-multiline': 'true', 'aria-describedby': 'editor-keyboard-hint' }
: {}),
}, },
}, },
}); });
@ -361,6 +363,13 @@ function PageEditor({
/> />
)} )}
<EditorContent editor={editor} className="editor-content" /> <EditorContent editor={editor} className="editor-content" />
{/* Referenced via aria-describedby in edit mode: Tab is captured
inside tables (#338), so the way out must be discoverable. */}
{canEdit && (
<p id="editor-keyboard-hint" className="visually-hidden">
{t('keyboardHint')}
</p>
)}
{canEdit && <WikilinkAutocomplete editor={editor} />} {canEdit && <WikilinkAutocomplete editor={editor} />}
{canEdit && <MentionAutocomplete editor={editor} />} {canEdit && <MentionAutocomplete editor={editor} />}
</div> </div>

View File

@ -21,6 +21,7 @@ import { StartPageSetting } from '../ponds/StartPageSetting';
import { SidebarViewSetting } from '../layout/SidebarViewSetting'; import { SidebarViewSetting } from '../layout/SidebarViewSetting';
import { MemberManager } from '../members/MemberManager'; import { MemberManager } from '../members/MemberManager';
import { DeletePondSection } from '../ponds/DeletePondSection'; import { DeletePondSection } from '../ponds/DeletePondSection';
import { PondBrandingSection } from '../ponds/PondBrandingSection';
import { PondPluginSettings } from '../plugins/PondPluginSettings'; import { PondPluginSettings } from '../plugins/PondPluginSettings';
import { PondThemeSection } from '../theme/PondThemeSection'; import { PondThemeSection } from '../theme/PondThemeSection';
@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tMembers } = useTranslation('members'); const { t: tMembers } = useTranslation('members');
const { t: tErrors } = useTranslation('errors'); const { t: tErrors } = useTranslation('errors');
const { t: tFiles } = useTranslation('files'); const { t: tFiles } = useTranslation('files');
const { t: tBranding } = useTranslation('branding');
const { t: tExport } = useTranslation('export'); const { t: tExport } = useTranslation('export');
const { t: tComments } = useTranslation('comments'); const { t: tComments } = useTranslation('comments');
const { t: tApiTokens } = useTranslation('apiTokens'); const { t: tApiTokens } = useTranslation('apiTokens');
@ -114,6 +116,8 @@ export function PondSettingsPage(): React.JSX.Element {
pondSlug={pondSlug} pondSlug={pondSlug}
theme={pond.data.settings.theme} theme={pond.data.settings.theme}
/> />
<h3>{tBranding('admin.title')}</h3>
<PondBrandingSection pondId={pond.data.id} />
</section> </section>
)} )}
{canModify && <PondPluginSettings pondId={pond.data.id} />} {canModify && <PondPluginSettings pondId={pond.data.id} />}

View File

@ -138,21 +138,26 @@ function QuotaRow({
<td>{t(`keys.${line.key}`)}</td> <td>{t(`keys.${line.key}`)}</td>
<td>{line.instanceDefault}</td> <td>{line.instanceDefault}</td>
<td className="quota-row__override"> <td className="quota-row__override">
<input {/* Flex lives on the inner div: a td with display:flex stops behaving
type="number" like a table cell and its bottom border no longer meets the row's
min={0} (same fix as the user list's actions cell, #177). */}
className="quota-row__input" <div className="quota-row__override-inner">
value={draft} <input
onChange={(e) => onDraft(e.target.value)} type="number"
/> min={0}
<button type="button" className="linklike" onClick={onSet}> className="quota-row__input"
{t('overrides.set')} value={draft}
</button> onChange={(e) => onDraft(e.target.value)}
{line.override !== null && ( />
<button type="button" className="linklike" onClick={onClear}> <button type="button" className="linklike" onClick={onSet}>
{t('overrides.clear')} {t('overrides.set')}
</button> </button>
)} {line.override !== null && (
<button type="button" className="linklike" onClick={onClear}>
{t('overrides.clear')}
</button>
)}
</div>
</td> </td>
<td className="quota-row__effective">{line.effective}</td> <td className="quota-row__effective">{line.effective}</td>
<td className="quota-row__usage"> <td className="quota-row__usage">

View File

@ -17,6 +17,7 @@ import { SettingsLayout } from '../components/SettingsLayout';
import { useDataExport } from '../export/use-data-export'; import { useDataExport } from '../export/use-data-export';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { ApiTokensSection } from '../api-tokens/ApiTokensSection'; import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
import { InvitationsSection } from './InvitationsSection';
import { FeedTokensSection } from '../api-tokens/FeedTokensSection'; import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
import { WatchesSection } from '../watches/WatchesSection'; import { WatchesSection } from '../watches/WatchesSection';
@ -45,6 +46,7 @@ export function SettingsPage(): React.JSX.Element {
<PasswordSection /> <PasswordSection />
<SessionsSection /> <SessionsSection />
<WatchesSection /> <WatchesSection />
<InvitationsSection />
<ApiTokensSection /> <ApiTokensSection />
<FeedTokensSection /> <FeedTokensSection />
<AppearanceSection /> <AppearanceSection />

View File

@ -1,12 +1,18 @@
import type { AdminUserListView, AdminUserView } from '@dorfteich/shared'; import { zodResolver } from '@hookform/resolvers/zod';
import type { AdminCreateUserFormInput, AdminUserListView, AdminUserView } from '@dorfteich/shared';
import { adminCreateUserSchema } from '@dorfteich/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react'; import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useId, useRef, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { IconButton } from '../components/IconButton'; import { IconButton } from '../components/IconButton';
import { Field, FormError, applyFieldErrors } from '../components/forms';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
import { useModalFocus } from '../lib/use-modal-focus';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -21,6 +27,8 @@ export function UserManager(): React.JSX.Element {
const { user: me } = useAuth(); const { user: me } = useAuth();
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const createButtonRef = useRef<HTMLButtonElement>(null);
const query = useQuery({ const query = useQuery({
queryKey: ['admin', 'users', q, page], queryKey: ['admin', 'users', q, page],
@ -42,6 +50,24 @@ export function UserManager(): React.JSX.Element {
return ( return (
<section className="settings-section user-manager"> <section className="settings-section user-manager">
<h2>{t('title')}</h2> <h2>{t('title')}</h2>
<button
type="button"
ref={createButtonRef}
className="button user-manager__create"
onClick={() => setCreating(true)}
>
{t('create.button')}
</button>
{creating && (
<CreateUserDialog
onClose={() => setCreating(false)}
onCreated={() => {
setCreating(false);
void query.refetch();
}}
returnFocusRef={createButtonRef}
/>
)}
<input <input
className="user-manager__search" className="user-manager__search"
type="search" type="search"
@ -92,6 +118,98 @@ export function UserManager(): React.JSX.Element {
); );
} }
/**
* Direct account creation by a Site Admin (issue #331): same field rules as
* self-registration, but the account is active immediately no verification
* mail hop. Field names stay flat (no dots) react-hook-form treats dots as
* path separators (#322).
*/
function CreateUserDialog({
onClose,
onCreated,
returnFocusRef,
}: {
onClose: () => void;
onCreated: () => void;
returnFocusRef: React.RefObject<HTMLElement | null>;
}): React.JSX.Element {
const { t, i18n } = useTranslation('users');
const [error, setError] = useState<unknown>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useDismissable(dialogRef, true, onClose);
useModalFocus(dialogRef, returnFocusRef);
const form = useForm<AdminCreateUserFormInput>({
resolver: zodResolver(adminCreateUserSchema) as Resolver<AdminCreateUserFormInput>,
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
});
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
await apiPost('/admin/users', input);
onCreated();
} catch (err) {
setError(err);
applyFieldErrors(err, (name, fieldError) =>
form.setError(name as keyof AdminCreateUserFormInput, fieldError),
);
}
});
return (
<div className="modal-overlay">
<div
className="modal create-user-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={dialogRef}
>
<h2 className="modal__title" id={titleId}>
{t('create.title')}
</h2>
<p>{t('create.intro')}</p>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<Field label={t('create.username')} error={form.formState.errors.username?.message}>
<input type="text" autoComplete="off" {...form.register('username')} />
</Field>
<Field label={t('create.email')} error={form.formState.errors.email?.message}>
<input type="email" autoComplete="off" {...form.register('email')} />
</Field>
<Field label={t('create.displayName')} error={form.formState.errors.displayName?.message}>
<input type="text" autoComplete="off" {...form.register('displayName')} />
</Field>
<Field
label={t('create.password')}
hint={t('create.passwordHint')}
error={form.formState.errors.password?.message}
>
<input type="password" autoComplete="new-password" {...form.register('password')} />
</Field>
<Field label={t('create.locale')}>
<select {...form.register('locale')}>
<option value="de">{t('create.localeDe')}</option>
<option value="en">{t('create.localeEn')}</option>
</select>
</Field>
<div className="modal__actions">
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('create.submit')}
</button>
<button type="button" className="linklike" onClick={onClose}>
{t('create.cancel')}
</button>
</div>
</form>
</div>
</div>
);
}
function UserRow({ function UserRow({
user, user,
isSelf, isSelf,

View File

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
GENERAL_FORM_FIELDS,
GeneralSettingsForm,
toFormValues,
toSettingsPatch,
} from './admin-settings-form';
describe('admin general settings form model (issue #322)', () => {
// The regression this file exists for: a dotted field name makes
// react-hook-form nest the typed value and the strict PATCH schema
// reject the body — the form then looks fine but never saves.
it('uses no dots in any form field name', () => {
for (const field of Object.keys(GENERAL_FORM_FIELDS)) {
expect(field).not.toContain('.');
}
});
it('round-trips settings through form values back to a flat patch', () => {
const settings = {
'instance.name': 'My Wiki',
'instance.defaultLocale': 'de',
'auth.registrationMode': 'closed',
'invitations.maxOpenPerUser': 5,
'classification.newPageDefault': 'unclassified',
'classification.uploadPolicy': 'warn',
'quota.editorsPerPond': 5,
'quota.readersPerPond': 50,
'quota.additionalPonds': 0,
'quota.storageBytes': 1024,
'quota.maxFileBytes': 25,
};
expect(toSettingsPatch(toFormValues(settings))).toEqual(settings);
});
it('patches only the settings this form edits, under their dotted keys', () => {
const input: GeneralSettingsForm = {
instanceName: 'Renamed',
defaultLocale: 'en',
registrationMode: 'open',
invitationsMaxOpenPerUser: 5,
newPageClassification: 'vs_nfd',
uploadPolicy: 'block',
quotaEditorsPerPond: 1,
quotaReadersPerPond: 2,
quotaAdditionalPonds: 3,
quotaStorageBytes: 4,
quotaMaxFileBytes: 5,
};
const patch = toSettingsPatch(input);
expect(patch['instance.name']).toBe('Renamed');
expect(Object.keys(patch).sort()).toEqual(Object.values(GENERAL_FORM_FIELDS).slice().sort());
});
});

View File

@ -0,0 +1,62 @@
/**
* Form model of the general + quota cards on the admin settings page.
*
* Field names MUST NOT contain dots: react-hook-form treats a dot in a
* field name as a nested-path separator. A field registered under its
* settings key ('instance.name') DISPLAYS fine RHF's getter falls back
* to the literal flat key but typing writes the value into a nested
* object ({ instance: { name } }), which the api's strict PATCH schema
* rejects, so nothing ever saved (issue #322). This mapping is the single
* place tying a dot-free field name to its dotted settings key; the
* converters below translate in both directions.
*/
export const GENERAL_FORM_FIELDS = {
instanceName: 'instance.name',
defaultLocale: 'instance.defaultLocale',
registrationMode: 'auth.registrationMode',
invitationsMaxOpenPerUser: 'invitations.maxOpenPerUser',
newPageClassification: 'classification.newPageDefault',
uploadPolicy: 'classification.uploadPolicy',
quotaEditorsPerPond: 'quota.editorsPerPond',
quotaReadersPerPond: 'quota.readersPerPond',
quotaAdditionalPonds: 'quota.additionalPonds',
quotaStorageBytes: 'quota.storageBytes',
quotaMaxFileBytes: 'quota.maxFileBytes',
} as const;
export type GeneralFormField = keyof typeof GENERAL_FORM_FIELDS;
export type GeneralFormSettingKey = (typeof GENERAL_FORM_FIELDS)[GeneralFormField];
export interface GeneralSettingsForm {
instanceName: string;
defaultLocale: 'de' | 'en';
registrationMode: 'open' | 'closed';
invitationsMaxOpenPerUser: number;
newPageClassification: 'unclassified' | 'vs_nfd';
uploadPolicy: 'warn' | 'block';
quotaEditorsPerPond: number;
quotaReadersPerPond: number;
quotaAdditionalPonds: number;
quotaStorageBytes: number;
quotaMaxFileBytes: number;
}
/** The settings this form reads and writes, keyed by their dotted names. */
export type GeneralFormSettings = Record<GeneralFormSettingKey, unknown>;
export function toFormValues(settings: GeneralFormSettings): GeneralSettingsForm {
return Object.fromEntries(
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [field, settings[key]]),
) as unknown as GeneralSettingsForm;
}
/** Flat dotted keys, exactly what PATCH /admin/settings expects. */
export function toSettingsPatch(input: GeneralSettingsForm): GeneralFormSettings {
return Object.fromEntries(
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [
key,
input[field as GeneralFormField],
]),
) as GeneralFormSettings;
}

View File

@ -1,10 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { SignupFormInput, signupInputSchema } from '@dorfteich/shared'; import { InvitationPreview, SignupFormInput, signupInputSchema } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form'; import { Resolver, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { Field, FormError, applyFieldErrors } from '../../components/forms'; import { Field, FormError, applyFieldErrors } from '../../components/forms';
import { apiGet, apiPost } from '../../lib/api'; import { apiGet, apiPost } from '../../lib/api';
@ -21,6 +21,19 @@ export function SignupPage(): React.JSX.Element {
queryFn: () => apiGet<{ mode: 'open' | 'closed' }>('/auth/registration'), queryFn: () => apiGet<{ mode: 'open' | 'closed' }>('/auth/registration'),
}); });
// An invitation link (issue #332) carries ?invitation=<token>: a valid
// one lets this signup through even while registration is closed.
const [searchParams] = useSearchParams();
const invitationToken = searchParams.get('invitation');
const invitation = useQuery({
queryKey: ['invitation-preview', invitationToken],
queryFn: () => apiPost<InvitationPreview>('/invitations/preview', { token: invitationToken }),
enabled: Boolean(invitationToken),
retry: false,
staleTime: Infinity,
});
const invited = Boolean(invitationToken) && invitation.isSuccess;
type SignupFormValues = SignupFormInput; type SignupFormValues = SignupFormInput;
// One confined cast: RHF cannot express Zod's input/output split // One confined cast: RHF cannot express Zod's input/output split
// (locale is optional on input, defaulted on output) without it. // (locale is optional on input, defaulted on output) without it.
@ -29,10 +42,22 @@ export function SignupPage(): React.JSX.Element {
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' }, defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
}); });
// Prefill the invited address; it stays editable (the mailbox is
// verified separately either way).
const setValue = form.setValue;
useEffect(() => {
if (invitation.data) setValue('email', invitation.data.email);
}, [invitation.data, setValue]);
const onSubmit = form.handleSubmit(async (input) => { const onSubmit = form.handleSubmit(async (input) => {
setError(null); setError(null);
try { try {
await apiPost('/auth/signup', input); // Only a previewed-valid token rides along; a broken link falls
// back to a plain signup instead of failing the whole form.
await apiPost('/auth/signup', {
...input,
invitationToken: invited ? invitationToken : undefined,
});
setRegistered(input.email); setRegistered(input.email);
} catch (err) { } catch (err) {
setError(err); setError(err);
@ -42,10 +67,25 @@ export function SignupPage(): React.JSX.Element {
} }
}); });
if (registration.data?.mode === 'closed') { // With a pending invitation check, neither the closed screen nor the
// form should flash — hold the decision until the preview settles.
if (registration.data?.mode === 'closed' && invitationToken && invitation.isPending) {
return ( return (
<div className="auth-card"> <div className="auth-card">
<h1>{t('auth:signup.title')}</h1> <h1>{t('auth:signup.title')}</h1>
</div>
);
}
if (registration.data?.mode === 'closed' && !invited) {
return (
<div className="auth-card">
<h1>{t('auth:signup.title')}</h1>
{invitationToken && invitation.isError && (
<p className="form-banner form-banner--error signup-invitation__invalid">
{t('invitations:signup.invalid')}
</p>
)}
<p className="form-banner">{t('auth:signup.closed')}</p> <p className="form-banner">{t('auth:signup.closed')}</p>
<p className="auth-card__links"> <p className="auth-card__links">
<Link to="/login">{t('auth:signup.loginLink')}</Link> <Link to="/login">{t('auth:signup.loginLink')}</Link>
@ -74,6 +114,19 @@ export function SignupPage(): React.JSX.Element {
<div className="auth-card"> <div className="auth-card">
<h1>{t('auth:signup.title')}</h1> <h1>{t('auth:signup.title')}</h1>
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
{invited && invitation.data && (
<p className="form-banner signup-invitation__banner">
{t('invitations:signup.banner', {
inviterName: invitation.data.inviterName,
email: invitation.data.email,
})}
</p>
)}
{invitationToken && invitation.isError && (
<p className="form-banner form-banner--error signup-invitation__invalid">
{t('invitations:signup.invalid')}
</p>
)}
<FormError error={error} /> <FormError error={error} />
<Field <Field
label={t('auth:signup.username')} label={t('auth:signup.username')}

View File

@ -0,0 +1,234 @@
import { LogoVariant, MAX_LOGO_EDGE, PondBranding } from '@dorfteich/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CropField } from '../branding/CropField';
import { canvasToPngFile, drawCrop } from '../branding/crop';
import { logoUrl } from '../branding/use-branding';
import { FormError, FormSuccess } from '../components/forms';
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
const FAVICON_SIZES = [32, 180] as const;
/**
* A pond's own logo and favicon (issue #307).
*
* Reuses the instance screen's crop control rather than growing a second,
* drag-only one: the uploader here is an ordinary Pond Admin, and the
* keyboard operability is not theirs to lose.
*/
export function PondBrandingSection({ pondId }: { pondId: string }): React.JSX.Element {
const { t } = useTranslation('branding');
const queryClient = useQueryClient();
const branding = useQuery({
queryKey: ['pond', pondId, 'branding'],
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId}/branding`),
});
const view = branding.data;
const refresh = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'branding'] });
};
return (
<div className="branding pond-branding">
<p>{t('pond.intro')}</p>
<p>{t('pond.quotaNote')}</p>
<PondLogoSlot
variant="light"
pondId={pondId}
asset={view?.logo ?? null}
onChanged={refresh}
/>
<PondLogoSlot
variant="dark"
pondId={pondId}
asset={view?.logoDark ?? null}
onChanged={refresh}
/>
{view?.logo && !view.logoDark && (
// Advisory, exactly as on the instance screen: a pond that uploads
// only a light logo shows THAT logo in dark mode — it does not borrow
// the instance's dark one. Nothing is blocked.
<p className="branding__warning" role="note">
<span aria-hidden="true"> </span>
{t('pond.darkMissing')}
</p>
)}
<PondFaviconSlot pondId={pondId} present={Boolean(view?.favicon)} onChanged={refresh} />
</div>
);
}
function PondLogoSlot({
pondId,
variant,
asset,
onChanged,
}: {
pondId: string;
variant: LogoVariant;
asset: { hash: string; width: number; height: number } | null;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('branding');
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [done, setDone] = useState(false);
const upload = useMutation({
mutationFn: async () => {
const canvas = canvasRef.current;
if (!canvas) throw new Error('no image');
const form = new FormData();
form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`));
return apiPostForm<PondBranding>(`/ponds/${pondId}/branding/logo?variant=${variant}`, form);
},
onSuccess: async () => {
setDone(true);
await onChanged();
},
});
const reset = useMutation({
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/logo?variant=${variant}`),
onSuccess: async () => {
setDone(false);
await onChanged();
},
});
return (
<div className="branding__slot" data-pond-logo-variant={variant}>
<h3>{t(`admin.logo.${variant}`)}</h3>
<FormError error={upload.error ?? reset.error} />
<FormSuccess message={done ? t('admin.logo.saved') : null} />
{asset ? (
<div className="branding__current">
<img
src={logoUrl(variant, asset.hash, pondId)}
alt={t('admin.logo.currentAlt')}
className={`branding__preview branding__preview--${variant}`}
/>
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
{/* A real, labelled control not an empty file field standing in
for "remove". */}
<button
type="button"
className="button button--outline"
onClick={() => reset.mutate()}
disabled={reset.isPending}
>
{t('pond.reset')}
</button>
</div>
) : (
<p>{t('pond.inherited')}</p>
)}
<CropField
idPrefix={`pond-logo-${variant}`}
square={false}
maxEdge={MAX_LOGO_EDGE}
onChange={(canvas) => {
canvasRef.current = canvas;
setDone(false);
}}
/>
<button
type="button"
className="button"
onClick={() => upload.mutate()}
disabled={upload.isPending}
>
{t('admin.logo.submit')}
</button>
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
</div>
);
}
function PondFaviconSlot({
pondId,
present,
onChanged,
}: {
pondId: string;
present: boolean;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('branding');
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [done, setDone] = useState(false);
const upload = useMutation({
mutationFn: async () => {
const source = canvasRef.current;
if (!source) throw new Error('no image');
const form = new FormData();
for (const size of FAVICON_SIZES) {
const scratch = document.createElement('canvas');
drawCrop(
source,
{ x: 0, y: 0, width: source.width, height: source.height },
{ width: size, height: size },
scratch,
);
form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`));
}
return apiPostForm<PondBranding>(`/ponds/${pondId}/branding/favicon`, form);
},
onSuccess: async () => {
setDone(true);
await onChanged();
},
});
const reset = useMutation({
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/favicon`),
onSuccess: async () => {
setDone(false);
await onChanged();
},
});
return (
<div className="branding__slot" data-branding-slot="pond-favicon">
<h3>{t('admin.favicon.title')}</h3>
<p>{t('pond.faviconHint')}</p>
<FormError error={upload.error ?? reset.error} />
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
<p>{present ? t('admin.favicon.present') : t('pond.inherited')}</p>
{present && (
<button
type="button"
className="button button--outline"
onClick={() => reset.mutate()}
disabled={reset.isPending}
>
{t('pond.reset')}
</button>
)}
<CropField
idPrefix="pond-favicon"
square
maxEdge={180}
onChange={(canvas) => {
canvasRef.current = canvas;
setDone(false);
}}
/>
<button
type="button"
className="button"
onClick={() => upload.mutate()}
disabled={upload.isPending}
>
{t('admin.favicon.submit')}
</button>
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
</div>
);
}

View File

@ -1732,6 +1732,42 @@ button {
outline: none; outline: none;
} }
/* Gap cursor (issue #335): the blinking bar prosemirror-gapcursor renders at
positions adjacent to block nodes without a text position of their own
(e.g. a table as the page's only block). The upstream package does not
ship its stylesheet through our import path, so these rules replace it. */
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor::after {
content: '';
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid var(--color-text);
animation: dt-gapcursor-blink 1.1s steps(2, start) infinite;
}
@keyframes dt-gapcursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.ProseMirror-gapcursor::after {
animation: none;
}
}
.editor-content h1, .editor-content h1,
.editor-content h2, .editor-content h2,
.editor-content h3, .editor-content h3,
@ -3048,7 +3084,7 @@ ul[data-type='task_list'] li p:last-of-type {
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
.quota-row__override { .quota-row__override-inner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);

View File

@ -2,14 +2,17 @@
# next to docker-compose.yml and adjust the values. # next to docker-compose.yml and adjust the values.
# --- required --------------------------------------------------------------- # --- required ---------------------------------------------------------------
# PostgreSQL password for the `dorfteich` database user. # PostgreSQL password for the `dorfteich` database user. URL-SAFE
# characters only (generate with `openssl rand -hex 24`): the compose file
# interpolates it into DATABASE_URL unescaped, so base64's `/`, `+`, `=`
# break the URL — db stays healthy while api/collab/backup restart-loop.
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=change-me
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token # ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
# purpose (collaboration tokens, digest unsubscribe links) derives its own # purpose (collaboration tokens, digest unsubscribe links) derives its own
# HKDF subkey from this value — nothing signs with it directly. The api and # HKDF subkey from this value — nothing signs with it directly. The api and
# collab services share this one value; use a long random string # collab services share this one value; use a long random string
# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all # (e.g. `openssl rand -hex 24`). Min length 16. Rotating it rotates all
# derived keys at once and invalidates outstanding tokens. # derived keys at once and invalidates outstanding tokens.
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
@ -52,7 +55,7 @@ COMPOSE_PROJECT_NAME=dorfteich
# --- public URL + mail -------------------------------------------------------- # --- public URL + mail --------------------------------------------------------
# Public base URL of the stage (scheme + host). E-mail links and the CSRF # Public base URL of the stage (scheme + host). E-mail links and the CSRF
# origin check are derived from it — it must match what browsers use. # origin check are derived from it — it must match what browsers use.
APP_BASE_URL=https://test.dorfteich.cloud APP_BASE_URL=https://wiki.example.com
# SMTP relay for outgoing mail (verification, password reset). Optional: # SMTP relay for outgoing mail (verification, password reset). Optional:
# leave everything unset and configure the relay in the browser during the # leave everything unset and configure the relay in the browser during the
@ -76,10 +79,36 @@ SMTP_FROM=Dorfteich <wiki@example.com>
#CADDY_HTTP_PORT=80 #CADDY_HTTP_PORT=80
#CADDY_HTTPS_PORT=443 #CADDY_HTTPS_PORT=443
# --- external authentication (issues #214#216, ADR 0021) ---------------------
# Deploy-level on purpose — a Site Admin cannot change these. All unset =
# local username/password login only. Full reference:
# docs/architecture/security.md §External authentication.
# OIDC (Authorization Code + PKCE) is enabled iff ISSUER + CLIENT_ID are set;
# CLIENT_SECRET stays empty for a public client. Redirect URI to register at
# the IdP: $APP_BASE_URL/api/v1/auth/oidc/callback
#OIDC_ISSUER=https://idp.example.com/realms/example
#OIDC_CLIENT_ID=dorfteich-web
#OIDC_CLIENT_SECRET=
#OIDC_SCOPES=openid profile email
#OIDC_PROVIDER_LABEL=Single Sign-On
# Hard switch (#216): false turns EVERY local credential flow off (404) —
# sign-in only via OIDC or the trusted proxy. Complete the first-run setup
# BEFORE flipping it.
#AUTH_LOCAL_ENABLED=false
# Perimeter authentication (#215): identity from a proxy header, honoured
# only when the TCP peer is on the allowlist; the proxy MUST strip the
# header from incoming traffic. Unset = feature off, the header is inert.
#AUTH_PROXY_HEADER=X-Auth-User
#AUTH_PROXY_TRUSTED_PEERS=10.0.0.5
#AUTH_PROXY_MAP=username
#AUTH_PROXY_MODE=plain
#AUTH_PROXY_DN_ATTRIBUTE=CN
# --- backups (ADR 0015, issue #83) -------------------------------------------- # --- backups (ADR 0015, issue #83) --------------------------------------------
# The backup sidecar dumps the database and archives the uploads/plugins # The backup sidecar dumps the database and archives the data volumes
# volumes nightly onto the `backups` volume; restore via # (uploads, plugins, custom fonts, branding) nightly onto the `backups`
# deploy/backup/restore.sh <backup-id>. All values optional. # volume; restore via deploy/backup/restore.sh <backup-id>. All values
# optional.
# Daily run time HH:MM in TZ (default 03:00; set TZ for stage-local time, # Daily run time HH:MM in TZ (default 03:00; set TZ for stage-local time,
# e.g. TZ=Europe/Berlin — unset means UTC). # e.g. TZ=Europe/Berlin — unset means UTC).
#TZ=Europe/Berlin #TZ=Europe/Berlin
@ -129,6 +158,9 @@ SMTP_FROM=Dorfteich <wiki@example.com>
# wizard. Automated deploys can skip it entirely by pre-seeding the Site # wizard. Automated deploys can skip it entirely by pre-seeding the Site
# Admin here; the wizard then completes and locks itself at first boot. # Admin here; the wizard then completes and locks itself at first boot.
# All three SETUP_ADMIN_* values are required for pre-seeding to trigger. # All three SETUP_ADMIN_* values are required for pre-seeding to trigger.
# The wizard's validation applies: the password needs at least 10
# characters — a violation fails the boot with a message naming the
# variable (deliberate: no half-seeded instance).
#SETUP_ADMIN_USERNAME=admin #SETUP_ADMIN_USERNAME=admin
#SETUP_ADMIN_EMAIL=admin@example.com #SETUP_ADMIN_EMAIL=admin@example.com
#SETUP_ADMIN_PASSWORD=change-me-please #SETUP_ADMIN_PASSWORD=change-me-please

View File

@ -61,6 +61,21 @@ services:
# VS-NfD hardening-profile mode (issue #243, ADR 0027): # VS-NfD hardening-profile mode (issue #243, ADR 0027):
# off | marked | hidden | enforced. Empty = off — no marking anywhere. # off | marked | hidden | enforced. Empty = off — no marking anywhere.
VS_NFD_MODE: ${VS_NFD_MODE:-} VS_NFD_MODE: ${VS_NFD_MODE:-}
# External authentication (issues #214#216, ADR 0021) — deploy-level
# on purpose, out of a Site Admin's reach. All empty = local
# username/password login only. Reference: security.md §External
# authentication.
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_SCOPES: ${OIDC_SCOPES:-}
OIDC_PROVIDER_LABEL: ${OIDC_PROVIDER_LABEL:-}
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-}
AUTH_PROXY_HEADER: ${AUTH_PROXY_HEADER:-}
AUTH_PROXY_TRUSTED_PEERS: ${AUTH_PROXY_TRUSTED_PEERS:-}
AUTH_PROXY_MAP: ${AUTH_PROXY_MAP:-}
AUTH_PROXY_MODE: ${AUTH_PROXY_MODE:-}
AUTH_PROXY_DN_ATTRIBUTE: ${AUTH_PROXY_DN_ATTRIBUTE:-}
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes # SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
# the relay to the secret store on the `secrets` volume (issue #80); # the relay to the secret store on the `secrets` volume (issue #80);
# values set here in the stage .env always win over the store. # values set here in the stage .env always win over the store.

View File

@ -1,6 +1,9 @@
# Audit event catalogue # Audit event catalogue
**Catalogue version 1.8 (2026-08-01; 1.8 adds `pond.archived`, **Catalogue version 1.10 (2026-08-05; 1.10 adds `invitation.created`,
`invitation.revoked` and `invitation.accepted`, issue #332;
1.9 added `user.created_by_admin`,
issue #331; 1.8 added `pond.archived`,
issue #305; 1.7 added `branding.changed`, issue #305; 1.7 added `branding.changed`,
issue #306; 1.6 added `font.uploaded` and issue #306; 1.6 added `font.uploaded` and
`font.deleted`, issue #303; 1.5 added `plugin.rejected`, `font.deleted`, issue #303; 1.5 added `plugin.rejected`,
@ -77,6 +80,14 @@ failure), `warning` = feeds detection (suspicious or destructive),
| `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` | | `auth.identity_linked` | OIDC identity linked to an existing account via the explicit link flow (issue #214) | notice | the linking user | — | `provider` |
| `auth.proxy_rejected` | Proxy-auth header received from a peer outside the allowlist — spoof attempt (issue #215) | warning | `null` (unauthenticated) | — | `peer`, `header` | | `auth.proxy_rejected` | Proxy-auth header received from a peer outside the allowlist — spoof attempt (issue #215) | warning | `null` (unauthenticated) | — | `peer`, `header` |
### Invitations (`invitation.*`, issue #332)
| Id | Trigger | Severity | Actor | Target | Fields |
| --------------------- | ---------------------------------------------------------------- | -------- | ----------------- | ------------ | ------ |
| `invitation.created` | User invites an e-mail address (mail with signup link sent) | info | the inviting user | `invitation` | — |
| `invitation.revoked` | Open invitation withdrawn by its creator | info | the inviting user | `invitation` | — |
| `invitation.accepted` | Signup completed through an invitation link (closed-mode bypass) | notice | the new user | `invitation` | — |
### Access & membership (`grant.*`, `member.*`) ### Access & membership (`grant.*`, `member.*`)
| Id | Trigger | Severity | Actor | Target | Fields | | Id | Trigger | Severity | Actor | Target | Fields |
@ -91,6 +102,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
| Id | Trigger | Severity | Actor | Target | Fields | | Id | Trigger | Severity | Actor | Target | Fields |
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- | | -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| `user.created_by_admin` | Site Admin creates an account directly (#331) | notice | the admin | `user` | — |
| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) | | `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) |
| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) | | `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) |
| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — | | `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |

View File

@ -19,22 +19,31 @@ work, that is a bug (issue #88).
## Install ## Install
1. Create a directory and fetch the two reference files from the repository 1. Create a directory and fetch the reference files from the repository
(`deploy/compose/`): `docker-compose.yml`, `.env.example` — plus (`deploy/compose/`): `docker-compose.yml`, `.env.example` — plus
`Caddyfile` if you want the `caddy` profile. `Caddyfile` if you want the `caddy` profile.
```sh ```sh
mkdir dorfteich && cd dorfteich mkdir dorfteich && cd dorfteich
# copy docker-compose.yml, .env.example (and Caddyfile) here base=https://gitea.101010.cloud/stwaidele/dorfteich/raw/branch/main/deploy/compose
curl -fsSLO "$base/docker-compose.yml"
curl -fsSLO "$base/.env.example"
curl -fsSLO "$base/Caddyfile" # only for the caddy profile
cp .env.example .env && chmod 600 .env cp .env.example .env && chmod 600 .env
``` ```
2. Edit `.env` — the minimum: 2. Edit `.env` — the minimum:
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings - `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings —
(`openssl rand -base64 32`). generate both with `openssl rand -hex 24`. Stick to URL-safe
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: characters for the database password (hex is): it is interpolated
releases are semver tags (`v1.2.3`); until the first public release, into a connection URL, and a `/`, `+` or `=` from base64 output
`test` tracks the latest verified build. breaks it in a confusing way (db healthy, everything else
restart-looping — see Troubleshooting).
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: pin
the latest release tag (semver, e.g. `v0.14.0`) — the
[release list](https://gitea.101010.cloud/stwaidele/dorfteich/releases)
is authoritative. Moving tags like `test`/`int` track our stages and
are not meant for third-party installs.
- `APP_BASE_URL=https://wiki.example.com` — must be exactly what - `APP_BASE_URL=https://wiki.example.com` — must be exactly what
browsers will use; e-mail links and the CSRF origin check derive browsers will use; e-mail links and the CSRF origin check derive
from it. from it.
@ -76,12 +85,18 @@ and health endpoints with `503 setup_required` — that is not an error.
Unattended installs skip the wizard by pre-seeding: set the Unattended installs skip the wizard by pre-seeding: set the
`SETUP_ADMIN_*` variables in `.env` before the first start (see `SETUP_ADMIN_*` variables in `.env` before the first start (see
`.env.example`). `.env.example`). The same validation as in the wizard applies —
`SETUP_ADMIN_PASSWORD` needs **at least 10 characters** — and an invalid
value deliberately fails the boot with a message naming the variable
(a half-seeded instance would be harder to diagnose).
## Updating ## Updating
```sh ```sh
# edit .env: TAG=v1.3.0 # 1. take a backup first — the pre-update set is the guaranteed way back:
# Admin → System → "Back up now", or:
docker compose run --rm -e BACKUP_RUN_ONCE=1 backup
# 2. edit .env: TAG=v1.3.0
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
``` ```
@ -89,14 +104,17 @@ Database migrations run automatically at api start. Release notes flag
releases with a `migration` label and any manual steps. **Downgrade releases with a `migration` label and any manual steps. **Downgrade
window: one minor release** — `TAG` back + `pull` + `up -d` is supported window: one minor release** — `TAG` back + `pull` + `up -d` is supported
one step back; further back, restore the backup taken before the update one step back; further back, restore the backup taken before the update
instead (the nightly sidecar gives you one at most 24 h old). instead. Rollback paths — including what to do when a migration itself
fails — are in
[`docs/operations/update-runbook.md`](../operations/update-runbook.md).
## Backups & restore ## Backups & restore
Enabled by default (ADR 0015): the `backup` sidecar dumps the database and Enabled by default (ADR 0015): the `backup` sidecar dumps the database and
archives the uploads/plugins volumes nightly at `BACKUP_TIME` onto the archives the data volumes (uploads, plugins, custom fonts, branding)
`backups` volume, prunes by `BACKUP_RETENTION_DAYS`, writes `status.json`, nightly at `BACKUP_TIME` onto the `backups` volume, prunes by
and — with `BACKUP_MAIL_TO` set — mails you on failure. `BACKUP_RETENTION_DAYS`, writes `status.json`, and — with
`BACKUP_MAIL_TO` set — mails you on failure.
- On-demand backup: `docker compose run --rm -e BACKUP_RUN_ONCE=1 backup`, - On-demand backup: `docker compose run --rm -e BACKUP_RUN_ONCE=1 backup`,
or the **Back up now** button under _Admin → System_. or the **Back up now** button under _Admin → System_.
@ -138,6 +156,19 @@ If the app itself is gone, use the operator path in
`docs/operations/restore-runbook.md` instead — it documents fetching a `docs/operations/restore-runbook.md` instead — it documents fetching a
bundle from Nextcloud by hand. bundle from Nextcloud by hand.
## External authentication (optional)
Local username/password accounts work out of the box — nothing to
configure. Deployments with an existing identity provider can add OpenID
Connect login (`OIDC_ISSUER` + `OIDC_CLIENT_ID` in `.env` enable it), let
an authenticating reverse proxy assert identities (`AUTH_PROXY_*`), and —
after the first-run setup is complete — turn local credentials off
entirely with `AUTH_LOCAL_ENABLED=false`. All of this is deploy-level by
design: a Site Admin cannot change it from the UI. The variables are
documented in `.env.example`; semantics and the trust model are in
[`docs/architecture/security.md`](../architecture/security.md)
(§External authentication).
## Public REST API ## Public REST API
Scripts and integrations can talk to the instance through a Scripts and integrations can talk to the instance through a
@ -161,6 +192,15 @@ and the OpenAPI document: [public-api.md](public-api.md).
mutations fail with 403 `csrf_origin_mismatch``APP_BASE_URL` does not mutations fail with 403 `csrf_origin_mismatch``APP_BASE_URL` does not
match the URL in the browser (scheme and host must be identical). match the URL in the browser (scheme and host must be identical).
E-mail links point at the wrong host → same variable. E-mail links point at the wrong host → same variable.
- api, collab **and** backup restart-looping while `db` is healthy →
`POSTGRES_PASSWORD` contains characters that break the connection URL
(base64's `/`, `+`, `=`); regenerate with `openssl rand -hex 24` and
recreate the stack. The db container looks fine because only its
clients build a URL from the password.
- api restart-looping right after the first start with a
`Pre-seeding failed` (or `validation.password.tooShort`) message →
`SETUP_ADMIN_PASSWORD` is shorter than 10 characters; fix `.env` and
recreate the api container.
- Wizard reappears after a restart → the database volume was not - Wizard reappears after a restart → the database volume was not
persisted; never run without the `db-data` volume. persisted; never run without the `db-data` volume.
- `docker compose ps` shows `unhealthy` → that container's liveness check - `docker compose ps` shows `unhealthy` → that container's liveness check

View File

@ -29,6 +29,7 @@ Settings-Cache ist in-process (operations.md).
| Setting | Referenzwert | Default | Warum | | Setting | Referenzwert | Default | Warum |
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. | | `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
| `invitations.maxOpenPerUser` | `0` | `5` | **explizit setzen** — Einladungen (#332) öffnen einen kontrollierten Registrierungsweg an `auth.registrationMode=closed` vorbei; in der Referenzkonfiguration bleibt der Nutzerkreis allein Sache des Betreibers (Konten legt der Site-Admin an, #331). `0` schaltet das Einladen ab (403 `invitations_disabled`). |
| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). | | `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). |
| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. | | `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. |
| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. | | `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. |

View File

@ -20,6 +20,8 @@ import { fileURLToPath } from 'node:url';
import { build } from 'esbuild'; import { build } from 'esbuild';
import { zipSync } from 'fflate'; import { zipSync } from 'fflate';
import { thirdPartyNotices } from '../third-party-licenses.mjs';
const DRAWIO_VERSION = '30.3.6'; const DRAWIO_VERSION = '30.3.6';
const DRAWIO_TARBALL = `https://github.com/jgraph/drawio/archive/refs/tags/v${DRAWIO_VERSION}.tar.gz`; const DRAWIO_TARBALL = `https://github.com/jgraph/drawio/archive/refs/tags/v${DRAWIO_VERSION}.tar.gz`;
@ -27,12 +29,16 @@ const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
const vendor = join(root, 'vendor'); const vendor = join(root, 'vendor');
const webapp = join(vendor, `drawio-${DRAWIO_VERSION}`, 'src', 'main', 'webapp'); const webapp = join(vendor, `drawio-${DRAWIO_VERSION}`, 'src', 'main', 'webapp');
// Apache-2.0 requires a copy of the license with any redistribution (§4(a)),
// so the tarball's root LICENSE ships in the ZIP (issue #345).
const licenseFile = join(vendor, `drawio-${DRAWIO_VERSION}`, 'LICENSE');
// --- 1. Fetch + unpack the pinned draw.io release (cached in vendor/) ----- // --- 1. Fetch + unpack the pinned draw.io release (cached in vendor/) -----
// In CI the vendor fetch is skipped (network + 60 MB — the fonts-build // In CI the vendor fetch is skipped (network + 60 MB — the fonts-build
// lesson): the controller bundle still builds, only the installable ZIP // lesson): the controller bundle still builds, only the installable ZIP
// needs a dev machine (or a pre-populated vendor/ cache). // needs a dev machine (or a pre-populated vendor/ cache). The LICENSE guard
if (!existsSync(webapp)) { // also heals vendor/ caches unpacked before #345 added it.
if (!existsSync(webapp) || !existsSync(licenseFile)) {
if (process.env.CI) { if (process.env.CI) {
console.log('CI: skipping draw.io vendor fetch — bundling plugin.js only, no ZIP'); console.log('CI: skipping draw.io vendor fetch — bundling plugin.js only, no ZIP');
await bundleController(); await bundleController();
@ -44,9 +50,18 @@ if (!existsSync(webapp)) {
console.log(`fetching draw.io v${DRAWIO_VERSION}`); console.log(`fetching draw.io v${DRAWIO_VERSION}`);
execFileSync('curl', ['-sfL', '-o', tarball, DRAWIO_TARBALL], { stdio: 'inherit' }); execFileSync('curl', ['-sfL', '-o', tarball, DRAWIO_TARBALL], { stdio: 'inherit' });
} }
execFileSync('tar', ['-xzf', tarball, '-C', vendor, `drawio-${DRAWIO_VERSION}/src/main/webapp`], { execFileSync(
stdio: 'inherit', 'tar',
}); [
'-xzf',
tarball,
'-C',
vendor,
`drawio-${DRAWIO_VERSION}/src/main/webapp`,
`drawio-${DRAWIO_VERSION}/LICENSE`,
],
{ stdio: 'inherit' },
);
} }
// --- 2. Select the runtime subset ----------------------------------------- // --- 2. Select the runtime subset -----------------------------------------
@ -103,20 +118,32 @@ const drawioFiles = collect(webapp, '');
// --- 3. Bundle the plugin controller --------------------------------------- // --- 3. Bundle the plugin controller ---------------------------------------
async function bundleController() { async function bundleController() {
mkdirSync(join(root, 'dist'), { recursive: true }); mkdirSync(join(root, 'dist'), { recursive: true });
await build({ const result = await build({
entryPoints: [join(root, 'src/plugin.ts')], entryPoints: [join(root, 'src/plugin.ts')],
bundle: true, bundle: true,
format: 'esm', format: 'esm',
outfile: join(root, 'dist/plugin.js'), outfile: join(root, 'dist/plugin.js'),
minify: true, minify: true,
metafile: true,
}); });
return result.metafile;
} }
await bundleController(); const metafile = await bundleController();
// --- 4. Pack the ZIP -------------------------------------------------------- // --- 4. Pack the ZIP --------------------------------------------------------
const files = { const files = {
'manifest.json': readFileSync(join(root, 'manifest.json')), 'manifest.json': readFileSync(join(root, 'manifest.json')),
'plugin.js': readFileSync(join(root, 'dist/plugin.js')), 'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
'licenses/drawio-LICENSE.txt': readFileSync(licenseFile),
'licenses/THIRD-PARTY-NOTICES.txt': Buffer.from(
thirdPartyNotices(metafile, [
{
title: `draw.io ${DRAWIO_VERSION} (bundled webapp under assets/drawio/)`,
license: 'Apache-2.0',
note: `Source: ${DRAWIO_TARBALL} — full license text in licenses/drawio-LICENSE.txt.`,
},
]),
),
}; };
for (const name of readdirSync(join(root, 'i18n'))) { for (const name of readdirSync(join(root, 'i18n'))) {
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name)); files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));

View File

@ -20,6 +20,8 @@ import { fileURLToPath } from 'node:url';
import { build } from 'esbuild'; import { build } from 'esbuild';
import { zipSync } from 'fflate'; import { zipSync } from 'fflate';
import { thirdPartyNotices } from '../third-party-licenses.mjs';
const root = dirname(fileURLToPath(import.meta.url)); const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@ -73,7 +75,7 @@ for (const sub of ASSET_SUBDIRS) {
// --- 2. Bundle the plugin controller (React + Excalidraw) ------------------ // --- 2. Bundle the plugin controller (React + Excalidraw) ------------------
mkdirSync(join(root, 'dist'), { recursive: true }); mkdirSync(join(root, 'dist'), { recursive: true });
await build({ const buildResult = await build({
entryPoints: [join(root, 'src/plugin.tsx')], entryPoints: [join(root, 'src/plugin.tsx')],
bundle: true, bundle: true,
format: 'esm', format: 'esm',
@ -89,6 +91,7 @@ await build({
'process.env.NODE_ENV': '"production"', 'process.env.NODE_ENV': '"production"',
'process.env.IS_PREACT': '"false"', 'process.env.IS_PREACT': '"false"',
}, },
metafile: true,
}); });
// --- 3. Pack the ZIP -------------------------------------------------------- // --- 3. Pack the ZIP --------------------------------------------------------
@ -103,6 +106,29 @@ for (const name of readdirSync(join(root, 'i18n'))) {
// and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH. // and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH.
Object.assign(files, assetFiles); Object.assign(files, assetFiles);
// License texts for the redistributed material (issue #345): bundled npm
// packages come from the metafile; the shipped fonts have no license files
// upstream at all, so the texts are curated in licenses/ (see FONT-NOTICES.md)
// — @excalidraw/excalidraw ships no LICENSE file either, hence the committed
// MIT text instead of the metafile fallback line.
for (const name of readdirSync(join(root, 'licenses'))) {
files[`licenses/${name}`] = readFileSync(join(root, 'licenses', name));
}
files['licenses/THIRD-PARTY-NOTICES.txt'] = Buffer.from(
thirdPartyNotices(buildResult.metafile, [
{
title: 'Excalidraw (bundled into plugin.js)',
license: 'MIT',
note: 'Full license text in licenses/excalidraw-MIT.txt.',
},
{
title: 'Fonts (shipped under fonts/)',
license: 'OFL-1.1 and MIT, per family',
note: 'Attribution table in licenses/FONT-NOTICES.md; per-font license texts alongside it.',
},
]),
);
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`); const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
rmSync(target, { force: true }); rmSync(target, { force: true });
writeFileSync(target, zipSync(files, { level: 6 })); writeFileSync(target, zipSync(files, { level: 6 }));

View File

@ -0,0 +1,95 @@
Copyright 2020 The Assistant Project Authors (https://github.com/hafontia/Assistant).
Copyright 2010 The Source Sans Pro Authors (https://github.com/adobe-fonts/source-sans-pro), with Reserved Font Name 'Source'.
Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,94 @@
Copyright (c) 2019 - Present, Microsoft Corporation,
with Reserved Font Name Cascadia Code.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Shannon Miwa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,22 @@
# Font notices
The Excalidraw plugin package ships the font files that Excalidraw's
prod build loads at runtime (`fonts/`). Neither the npm package nor the
Excalidraw repository ships license files next to the fonts, so the
attributions are collected here (issue #345); each referenced text in
this directory carries the font's own copyright statement.
| Font family | License | Text | Upstream |
| --------------- | ------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
| Assistant | OFL-1.1 | `Assistant-OFL.txt` | https://github.com/hafontia/Assistant |
| Cascadia Code | OFL-1.1 | `CascadiaCode-OFL.txt` | https://github.com/microsoft/cascadia-code |
| Comic Shanns | MIT | `ComicShanns-MIT.txt` | https://github.com/shannpersand/comic-shanns |
| Excalifont | MIT | `excalidraw-MIT.txt` | Published as part of https://github.com/excalidraw/excalidraw (no separate font license upstream) |
| Liberation Sans | OFL-1.1 | `Liberation-OFL.txt` | https://github.com/liberationfonts/liberation-fonts |
| Lilita One | OFL-1.1 | `LilitaOne-OFL.txt` | https://fonts.google.com/specimen/Lilita+One |
| Nunito | OFL-1.1 | `Nunito-OFL.txt` | https://github.com/googlefonts/nunito |
| Virgil | OFL-1.1 | `Virgil-OFL.txt` | https://github.com/excalidraw/virgil |
| Xiaolai | OFL-1.1 | `Xiaolai-OFL.txt` | https://github.com/lxgw/kose-font |
The SIL Open Font License permits use, redistribution, and bundling
with software; it applies to the font files, not to this plugin's code.

View File

@ -0,0 +1,102 @@
Digitized data copyright (c) 2010 Google Corporation
with Reserved Font Arimo, Tinos and Cousine.
Copyright (c) 2012 Red Hat, Inc.
with Reserved Font Name Liberation.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
worldwide development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to provide
a free and open framework in which fonts may be shared and improved in
partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves.
The fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such.
This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components
as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting ? in part or in whole ?
any of the components of the Original Version, by changing formats or
by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer
or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this license, and must not be distributed
under any other license. The requirement for fonts to remain under
this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,94 @@
Copyright (c) 2011 Juan Montoreano (juan@remolacha.biz),
with Reserved Font Name Lilita
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,45 @@
Copyright (c) 2021 - Present, Ellinor Rapp, with Reserved Font Name Virgil.
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: [scripts.sil.org/OFL](https://scripts.sil.org/OFL).
# SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
## PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
## DEFINITIONS
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
## PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5. The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
## TERMINATION
This license becomes null and void if any of the above conditions are not met.
## DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,94 @@
Copyright 2020-2024 LXGW (https://github.com/lxgw/kose-font)
Copyright 2014 Nozomi Seto (https://ja.osdn.net/projects/setofont/)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Excalidraw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -9,21 +9,27 @@ import { fileURLToPath } from 'node:url';
import { build } from 'esbuild'; import { build } from 'esbuild';
import { zipSync } from 'fflate'; import { zipSync } from 'fflate';
import { thirdPartyNotices } from '../third-party-licenses.mjs';
const root = dirname(fileURLToPath(import.meta.url)); const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
mkdirSync(join(root, 'dist'), { recursive: true }); mkdirSync(join(root, 'dist'), { recursive: true });
await build({ const buildResult = await build({
entryPoints: [join(root, 'src/plugin.ts')], entryPoints: [join(root, 'src/plugin.ts')],
bundle: true, bundle: true,
format: 'esm', format: 'esm',
outfile: join(root, 'dist/plugin.js'), outfile: join(root, 'dist/plugin.js'),
minify: true, minify: true,
metafile: true,
}); });
const files = { const files = {
'manifest.json': readFileSync(join(root, 'manifest.json')), 'manifest.json': readFileSync(join(root, 'manifest.json')),
'plugin.js': readFileSync(join(root, 'dist/plugin.js')), 'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
// mermaid and its transitive dependencies are bundled into plugin.js; their
// license texts ship with the package they belong to (issue #345).
'licenses/THIRD-PARTY-NOTICES.txt': Buffer.from(thirdPartyNotices(buildResult.metafile)),
}; };
for (const name of readdirSync(join(root, 'i18n'))) { for (const name of readdirSync(join(root, 'i18n'))) {
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name)); files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));

View File

@ -0,0 +1,82 @@
// Third-party license notices for plugin ZIPs (issue #345). A plugin that
// redistributes third-party material must ship the license texts alongside it
// (Apache-2.0 §4(a), MIT's notice clause, OFL §2). The bundled-package list is
// derived from the esbuild metafile — the set of files that actually ended up
// in plugin.js — so the notices can never drift from the bundle the way a
// hand-maintained list would. Non-bundled material (vendored webapps, copied
// font assets) cannot appear in a metafile; callers pass those as `extras`.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
const LICENSE_FILE_PATTERN = /^(licen[cs]e|copying|notice)(\.|$)/i;
/** Walk up from `file` to the nearest package.json that names a package. */
function packageRootOf(file) {
let dir = dirname(resolve(file));
while (dir !== dirname(dir)) {
const pj = join(dir, 'package.json');
if (existsSync(pj)) {
try {
const parsed = JSON.parse(readFileSync(pj, 'utf8'));
if (parsed.name) return { dir, pkg: parsed };
} catch {
// unreadable package.json (e.g. a fixture) — keep walking up
}
}
dir = dirname(dir);
}
return null;
}
function shippedLicenseText(dir) {
const names = readdirSync(dir).filter((name) => LICENSE_FILE_PATTERN.test(name));
return names
.sort()
.map((name) => readFileSync(join(dir, name), 'utf8').trim())
.join('\n\n');
}
/**
* All third-party npm packages whose files the metafile lists as bundle
* inputs, deduplicated by name@version. First-party `@dorfteich/*` packages
* are covered by the repository LICENSE and skipped.
*/
export function bundledPackages(metafile) {
const seen = new Map();
for (const input of Object.keys(metafile.inputs)) {
if (!input.split(sep).includes('node_modules') && !input.includes('/node_modules/')) continue;
const found = packageRootOf(input);
if (!found || found.pkg.name.startsWith('@dorfteich/')) continue;
const key = `${found.pkg.name}@${found.pkg.version}`;
if (!seen.has(key)) {
seen.set(key, {
name: found.pkg.name,
version: found.pkg.version,
license: typeof found.pkg.license === 'string' ? found.pkg.license : 'see license text',
text: shippedLicenseText(found.dir),
});
}
}
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Renders `licenses/THIRD-PARTY-NOTICES.txt` for a plugin ZIP: one section per
* bundled package (license expression + the license file it ships), then one
* per caller-supplied extra ({ title, license, note?, text? }).
*/
export function thirdPartyNotices(metafile, extras = []) {
const rule = '='.repeat(72);
const sections = [
'THIRD-PARTY NOTICES\n\nThis plugin package redistributes the third-party components listed\nbelow, each under its own license.\n',
];
for (const pkg of bundledPackages(metafile)) {
const body = pkg.text || `License: ${pkg.license} (no license file shipped in the npm package)`;
sections.push(`${rule}\n${pkg.name} ${pkg.version}${pkg.license}\n${rule}\n\n${body}\n`);
}
for (const extra of extras) {
const parts = [extra.note, extra.text].filter(Boolean).join('\n\n');
sections.push(`${rule}\n${extra.title}${extra.license}\n${rule}\n\n${parts}\n`);
}
return sections.join('\n');
}

View File

@ -37,5 +37,13 @@
"size": "Kantenlänge des Ausschnitts (px)", "size": "Kantenlänge des Ausschnitts (px)",
"reset": "Ausschnitt zurücksetzen", "reset": "Ausschnitt zurücksetzen",
"result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)." "result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)."
},
"pond": {
"intro": "Dieser Teich kann ein eigenes Logo und ein eigenes Favicon führen. Beides überschreibt das der Instanz — nur für diesen Teich.",
"quotaNote": "Die Dateien zählen auf das Speicher-Kontingent dieses Teichs, wie Anhänge auch.",
"inherited": "Nichts hinterlegt — es gilt die Einstellung der Instanz.",
"reset": "Auf die Instanz-Einstellung zurücksetzen",
"darkMissing": "Für den Dunkelmodus ist kein eigenes Teich-Logo hinterlegt. Dann wird dort das helle Logo dieses Teichs verwendet — NICHT das dunkle Logo der Instanz. Ein Logo-Satz gehört zu einer Ebene und wird nie ebenenübergreifend gemischt. Das ist ein Hinweis, keine Sperre.",
"faviconHint": "Das Favicon wird beim Betreten des Teichs im Tab gesetzt und beim Verlassen wieder zurückgestellt. Beim direkten Öffnen eines Teich-Links erscheint kurz das Instanz-Favicon, bevor es wechselt."
} }
} }

View File

@ -76,6 +76,8 @@
"addRowBefore": "Zeile davor einfügen", "addRowBefore": "Zeile davor einfügen",
"addRowAfter": "Zeile danach einfügen", "addRowAfter": "Zeile danach einfügen",
"deleteRow": "Zeile löschen", "deleteRow": "Zeile löschen",
"mergeCells": "Zellen verbinden",
"splitCell": "Zelle teilen",
"toggleHeaderRow": "Kopfzeile umschalten", "toggleHeaderRow": "Kopfzeile umschalten",
"deleteTable": "Tabelle löschen" "deleteTable": "Tabelle löschen"
}, },
@ -192,5 +194,6 @@
"due": "Zieldatum", "due": "Zieldatum",
"start": "Startdatum" "start": "Startdatum"
}, },
"contentLabel": "Seiteninhalt" "contentLabel": "Seiteninhalt",
"keyboardHint": "In Tabellen wechselt die Tabulatortaste zur nächsten Zelle und legt in der letzten Zelle eine neue Zeile an; Umschalt+Tab geht zurück. Escape stellt den Cursor hinter die Tabelle; außerhalb von Tabellen verlässt die Tabulatortaste den Editor."
} }

View File

@ -9,6 +9,9 @@
"rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.", "rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.",
"internal_error": "Interner Serverfehler.", "internal_error": "Interner Serverfehler.",
"registration_closed": "Die Registrierung ist auf dieser Instanz derzeit geschlossen.", "registration_closed": "Die Registrierung ist auf dieser Instanz derzeit geschlossen.",
"invitations_disabled": "Einladungen sind auf dieser Instanz deaktiviert.",
"invitation_quota_reached": "Du hast die Höchstzahl offener Einladungen erreicht. Widerrufe eine offene Einladung oder warte, bis eine angenommen wurde oder abgelaufen ist.",
"invitation_already_accepted": "Diese Einladung wurde bereits angenommen und kann nicht mehr widerrufen werden.",
"token_invalid": "Dieser Link ist ungültig oder abgelaufen.", "token_invalid": "Dieser Link ist ungültig oder abgelaufen.",
"login_failed": "Benutzername/E-Mail oder Passwort ist falsch.", "login_failed": "Benutzername/E-Mail oder Passwort ist falsch.",
"login_backoff": "Zu viele Fehlversuche — bitte warte ein paar Minuten.", "login_backoff": "Zu viele Fehlversuche — bitte warte ein paar Minuten.",

View File

@ -0,0 +1,34 @@
{
"section": {
"title": "Einladungen",
"intro": "Lade Personen per E-Mail ein. Der Link erlaubt genau eine Registrierung — auch wenn die Selbst-Registrierung geschlossen ist — und ist 14 Tage gültig.",
"quota": "{{open}} von {{max}} offenen Einladungen belegt.",
"disabled": "Einladungen sind auf dieser Instanz deaktiviert.",
"empty": "Noch keine Einladungen."
},
"form": {
"email": "E-Mail-Adresse",
"submit": "Einladen",
"sent": "Einladung verschickt."
},
"columns": {
"email": "E-Mail",
"status": "Status",
"created": "Eingeladen am",
"expires": "Gültig bis",
"actions": "Aktionen"
},
"status": {
"pending": "Offen",
"accepted": "Angenommen",
"revoked": "Widerrufen",
"expired": "Abgelaufen"
},
"actions": {
"revoke": "Widerrufen"
},
"signup": {
"banner": "{{inviterName}} lädt dich ein ({{email}}). Mit dieser Einladung kannst du dir jetzt ein Konto anlegen.",
"invalid": "Dieser Einladungslink ist ungültig, abgelaufen oder wurde bereits verwendet."
}
}

View File

@ -16,6 +16,12 @@
"action": "Neues Passwort setzen", "action": "Neues Passwort setzen",
"expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast." "expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast."
}, },
"invitation": {
"subject": "Du bist eingeladen: Dorfteich",
"body": "{{inviterName}} lädt dich zu einem Dorfteich ein — einem gemeinsamen Ort für Seiten und Notizen. Über diesen Link kannst du dir ein Konto anlegen:",
"action": "Einladung annehmen",
"expiry": "Der Link ist 14 Tage gültig und kann nur einmal verwendet werden."
},
"smtpTest": { "smtpTest": {
"subject": "SMTP-Testnachricht", "subject": "SMTP-Testnachricht",
"body": "diese Testnachricht bestätigt, dass dein Dorfteich E-Mails über den konfigurierten SMTP-Server versenden kann. Deine Instanz erreichst du hier:", "body": "diese Testnachricht bestätigt, dass dein Dorfteich E-Mails über den konfigurierten SMTP-Server versenden kann. Deine Instanz erreichst du hier:",

View File

@ -32,5 +32,20 @@
}, },
"prev": "Zurück", "prev": "Zurück",
"next": "Weiter", "next": "Weiter",
"page": "Seite {{page}}" "page": "Seite {{page}}",
"create": {
"button": "Person anlegen",
"title": "Person anlegen",
"intro": "Das Konto ist sofort aktiv — es wird keine Bestätigungs-E-Mail verschickt. Teile das Passwort auf einem sicheren Weg mit.",
"username": "Benutzername",
"email": "E-Mail",
"displayName": "Anzeigename",
"password": "Anfangspasswort",
"passwordHint": "Mindestens 10 Zeichen. Die Person sollte es nach dem ersten Login ändern.",
"locale": "Sprache",
"localeDe": "Deutsch",
"localeEn": "Englisch",
"submit": "Anlegen",
"cancel": "Abbrechen"
}
} }

View File

@ -37,5 +37,13 @@
"size": "Crop edge length (px)", "size": "Crop edge length (px)",
"reset": "Reset the crop", "reset": "Reset the crop",
"result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)." "result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)."
},
"pond": {
"intro": "This pond can carry its own logo and favicon. Both override the instance's — for this pond only.",
"quotaNote": "The files count against this pond's storage quota, just like attachments.",
"inherited": "Nothing set — the instance's setting applies.",
"reset": "Reset to the instance setting",
"darkMissing": "No separate dark-mode logo is set for this pond. The pond's light logo is then used there — NOT the instance's dark one. A logo set belongs to one level and is never mixed across levels. This is advice, not a block.",
"faviconHint": "The favicon is applied to the tab when the pond is entered and restored on leaving. Opening a pond link directly shows the instance favicon briefly before it changes."
} }
} }

View File

@ -76,6 +76,8 @@
"addRowBefore": "Add row before", "addRowBefore": "Add row before",
"addRowAfter": "Add row after", "addRowAfter": "Add row after",
"deleteRow": "Delete row", "deleteRow": "Delete row",
"mergeCells": "Merge cells",
"splitCell": "Split cell",
"toggleHeaderRow": "Toggle header row", "toggleHeaderRow": "Toggle header row",
"deleteTable": "Delete table" "deleteTable": "Delete table"
}, },
@ -192,5 +194,6 @@
"due": "Due date", "due": "Due date",
"start": "Start date" "start": "Start date"
}, },
"contentLabel": "Page content" "contentLabel": "Page content",
"keyboardHint": "Inside tables, Tab moves to the next cell and creates a new row from the last cell; Shift+Tab moves back. Escape places the cursor after the table; outside tables, Tab leaves the editor."
} }

View File

@ -9,6 +9,9 @@
"rate_limited": "Too many requests — please try again later.", "rate_limited": "Too many requests — please try again later.",
"internal_error": "Internal server error.", "internal_error": "Internal server error.",
"registration_closed": "Registration is currently closed on this instance.", "registration_closed": "Registration is currently closed on this instance.",
"invitations_disabled": "Invitations are disabled on this instance.",
"invitation_quota_reached": "You have reached the maximum number of open invitations. Revoke an open invitation or wait until one is accepted or expires.",
"invitation_already_accepted": "This invitation has already been accepted and can no longer be revoked.",
"token_invalid": "This link is invalid or has expired.", "token_invalid": "This link is invalid or has expired.",
"login_failed": "Username/e-mail or password is incorrect.", "login_failed": "Username/e-mail or password is incorrect.",
"login_backoff": "Too many failed attempts — please wait a few minutes.", "login_backoff": "Too many failed attempts — please wait a few minutes.",

View File

@ -0,0 +1,34 @@
{
"section": {
"title": "Invitations",
"intro": "Invite people by e-mail. The link allows exactly one registration — even while self-registration is closed — and is valid for 14 days.",
"quota": "{{open}} of {{max}} open invitations used.",
"disabled": "Invitations are disabled on this instance.",
"empty": "No invitations yet."
},
"form": {
"email": "E-mail address",
"submit": "Invite",
"sent": "Invitation sent."
},
"columns": {
"email": "E-mail",
"status": "Status",
"created": "Invited on",
"expires": "Valid until",
"actions": "Actions"
},
"status": {
"pending": "Open",
"accepted": "Accepted",
"revoked": "Revoked",
"expired": "Expired"
},
"actions": {
"revoke": "Revoke"
},
"signup": {
"banner": "{{inviterName}} invites you ({{email}}). With this invitation you can create an account now.",
"invalid": "This invitation link is invalid, expired, or has already been used."
}
}

View File

@ -16,6 +16,12 @@
"action": "Set new password", "action": "Set new password",
"expiry": "The link is valid for one hour. Your current password stays valid until you set a new one." "expiry": "The link is valid for one hour. Your current password stays valid until you set a new one."
}, },
"invitation": {
"subject": "You are invited: Dorfteich",
"body": "{{inviterName}} invites you to a Dorfteich - a shared place for pages and notes. Use this link to create your account:",
"action": "Accept invitation",
"expiry": "The link is valid for 14 days and can only be used once."
},
"smtpTest": { "smtpTest": {
"subject": "SMTP test message", "subject": "SMTP test message",
"body": "this test message confirms that your Dorfteich can send e-mail through the configured SMTP server. You can reach your instance here:", "body": "this test message confirms that your Dorfteich can send e-mail through the configured SMTP server. You can reach your instance here:",

View File

@ -32,5 +32,20 @@
}, },
"prev": "Previous", "prev": "Previous",
"next": "Next", "next": "Next",
"page": "Page {{page}}" "page": "Page {{page}}",
"create": {
"button": "Create user",
"title": "Create user",
"intro": "The account is active immediately — no verification mail is sent. Share the password over a secure channel.",
"username": "Username",
"email": "E-mail",
"displayName": "Display name",
"password": "Initial password",
"passwordHint": "At least 10 characters. The user should change it after their first login.",
"locale": "Language",
"localeDe": "German",
"localeEn": "English",
"submit": "Create",
"cancel": "Cancel"
}
} }

View File

@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { passwordSchema, usernameSchema } from './auth';
/** /**
* Site-Admin user management (issue #59): the list + actions an instance * Site-Admin user management (issue #59): the list + actions an instance
* operator uses for support, abuse handling, and GDPR groundwork. Deleting a * operator uses for support, abuse handling, and GDPR groundwork. Deleting a
@ -39,5 +41,21 @@ export type AdminUserListQuery = z.infer<typeof adminUserListQuerySchema>;
export const setUserDisabledSchema = z.object({ disabled: z.boolean() }); export const setUserDisabledSchema = z.object({ disabled: z.boolean() });
export const setSiteAdminSchema = z.object({ isSiteAdmin: z.boolean() }); export const setSiteAdminSchema = z.object({ isSiteAdmin: z.boolean() });
/**
* Direct account creation by a Site Admin (issue #331). Same field rules as
* self-registration, but the account skips e-mail verification: the admin
* vouches for the address, so the user can log in right away.
*/
export const adminCreateUserSchema = z.object({
username: usernameSchema,
email: z.string().email('validation.email.invalid').max(254),
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
password: passwordSchema,
locale: z.enum(['de', 'en']).default('en'),
});
export type AdminCreateUserInput = z.infer<typeof adminCreateUserSchema>;
/** Form-side type: locale is optional before Zod applies its default. */
export type AdminCreateUserFormInput = z.input<typeof adminCreateUserSchema>;
/** The pseudonym a deleted user's authorship shows as. */ /** The pseudonym a deleted user's authorship shows as. */
export const DELETED_USER_DISPLAY_NAME = 'Deleted user'; export const DELETED_USER_DISPLAY_NAME = 'Deleted user';

View File

@ -44,6 +44,9 @@ export const signupInputSchema = z.object({
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80), displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
password: passwordSchema, password: passwordSchema,
locale: z.enum(['de', 'en']).default('en'), locale: z.enum(['de', 'en']).default('en'),
/** Invitation token (issue #332): lets this one signup through even
* while registration is closed. */
invitationToken: z.string().min(16).max(256).optional(),
}); });
export type SignupInput = z.infer<typeof signupInputSchema>; export type SignupInput = z.infer<typeof signupInputSchema>;
/** Form-side type: locale is optional before Zod applies its default. */ /** Form-side type: locale is optional before Zod applies its default. */

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { pngDimensions, hasPngMagic, looksLikeSvg, resolveBranding } from './branding';
const asset = (hash: string) => ({ hash, width: 10, height: 10 });
const NONE = { logo: null, logoDark: null, favicon: null };
/**
* The resolution order (issues #306/#307) lives in one function, so this is
* where the decision that most easily gets "fixed" by accident is pinned:
* a logo set belongs to ONE level and variants are never mixed across levels.
*/
describe('resolveBranding', () => {
it('prefers the pond over the instance', () => {
const resolved = resolveBranding(
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null },
{ ...NONE, logo: asset('bbbbbbbbbbbbbbbb') },
);
expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb');
expect(resolved.logoLevel).toBe('pond');
});
it('keeps a pond on ITS OWN light logo in dark mode, never the instance dark one', () => {
// Decided 2026-08-01: a logo silently swapping to a different image when
// the viewer switches theme is a change nobody ordered. A design that
// looks wrong is more honest than one that is quietly substituted.
const resolved = resolveBranding(
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null },
{ ...NONE, logo: asset('bbbbbbbbbbbbbbbb') },
);
expect(resolved.logo?.hash).toBe('bbbbbbbbbbbbbbbb');
expect(resolved.logoDark).toBeNull();
});
it('inherits BOTH instance variants when the pond has no logo at all', () => {
const resolved = resolveBranding(
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: asset('cccccccccccccccc'), favicon: null },
NONE,
);
expect(resolved.logo?.hash).toBe('aaaaaaaaaaaaaaaa');
expect(resolved.logoDark?.hash).toBe('cccccccccccccccc');
expect(resolved.logoLevel).toBe('instance');
});
it('treats a pond with only a DARK logo as a set of its own too', () => {
const resolved = resolveBranding(
{ logo: asset('aaaaaaaaaaaaaaaa'), logoDark: null, favicon: null },
{ ...NONE, logoDark: asset('dddddddddddddddd') },
);
expect(resolved.logo).toBeNull();
expect(resolved.logoDark?.hash).toBe('dddddddddddddddd');
});
it('falls through to nothing, which is the render-the-name case', () => {
expect(resolveBranding(NONE, NONE).logoLevel).toBe('none');
expect(resolveBranding(NONE, null).logo).toBeNull();
});
it('resolves the favicon per level, down to the shipped default', () => {
expect(resolveBranding(NONE, { ...NONE, favicon: asset('e'.repeat(16)) }).faviconLevel).toBe(
'pond',
);
expect(resolveBranding({ ...NONE, favicon: asset('f'.repeat(16)) }, NONE).faviconLevel).toBe(
'instance',
);
expect(resolveBranding(NONE, NONE).faviconLevel).toBe('default');
});
});
describe('image checks (no decoding)', () => {
const png = (width: number, height: number): Uint8Array => {
const buf = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
buf.write('IHDR', 12, 'latin1');
buf.writeUInt32BE(width, 16);
buf.writeUInt32BE(height, 20);
return buf;
};
it('reads the dimensions out of the IHDR', () => {
expect(pngDimensions(png(512, 128))).toEqual({ width: 512, height: 128 });
});
it('refuses anything that is not a PNG with an IHDR first', () => {
expect(hasPngMagic(Buffer.from('GIF89a'))).toBe(false);
expect(pngDimensions(Buffer.from('GIF89a'))).toBeNull();
// Truncated: a reader that trusted the signature alone would run off the
// end here.
expect(pngDimensions(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
});
it('recognises SVG so the rejection can say why', () => {
expect(looksLikeSvg(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">'))).toBe(true);
expect(looksLikeSvg(Buffer.from('<?xml version="1.0"?>\n<svg>'))).toBe(true);
expect(looksLikeSvg(png(1, 1))).toBe(false);
});
});

View File

@ -81,6 +81,10 @@ export const brandingAssetSchema = z.object({
.regex(/^[a-f0-9]{16,64}$/), .regex(/^[a-f0-9]{16,64}$/),
width: z.number().int().min(1), width: z.number().int().min(1),
height: z.number().int().min(1), height: z.number().int().min(1),
/** Stored bytes. Needed so a pond's quota can be released exactly when the
* asset is replaced or removed (issue #307) optional because instance
* assets predating it are not charged to anything. */
byteSize: z.number().int().min(0).optional(),
}); });
export type BrandingAsset = z.infer<typeof brandingAssetSchema>; export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
@ -96,3 +100,54 @@ export interface BrandingView {
* screen carries the branding. */ * screen carries the branding. */
instanceName: string; instanceName: string;
} }
/** A pond's own branding (issue #307), stored in its settings. Null per slot
* means "not set at this level". */
export const pondBrandingSchema = z.object({
logo: brandingAssetSchema.nullable().default(null),
logoDark: brandingAssetSchema.nullable().default(null),
favicon: brandingAssetSchema.nullable().default(null),
});
export type PondBranding = z.infer<typeof pondBrandingSchema>;
/** What a pond page should actually show. */
export interface ResolvedBranding {
logo: BrandingAsset | null;
logoDark: BrandingAsset | null;
favicon: BrandingAsset | null;
/** Which level the LOGO came from the link's accessible name follows it:
* a pond logo is named by the pond, an instance logo by the instance. */
logoLevel: 'pond' | 'instance' | 'none';
faviconLevel: 'pond' | 'instance' | 'default';
}
/**
* The single place that decides which asset applies (issues #306/#307):
* the pond's own, else the instance's, else the shipped default (favicon) or
* the instance name as text (logo).
*
* **A logo set belongs to one level variants are NEVER mixed across
* levels.** A pond that uploaded only a light logo shows THAT logo in dark
* mode; it does not fall back to 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 the pond admin can see
* it and fix it. Only a pond with NO logo at all inherits the instance's set,
* again as a set.
*/
export function resolveBranding(
instance: Pick<BrandingView, 'logo' | 'logoDark' | 'favicon'>,
pond?: PondBranding | null,
): ResolvedBranding {
const pondHasLogo = Boolean(pond && (pond.logo || pond.logoDark));
const logoLevel = pondHasLogo ? 'pond' : instance.logo || instance.logoDark ? 'instance' : 'none';
const source = pondHasLogo ? pond! : instance;
const faviconLevel = pond?.favicon ? 'pond' : instance.favicon ? 'instance' : 'default';
return {
logo: logoLevel === 'none' ? null : source.logo,
logoDark: logoLevel === 'none' ? null : source.logoDark,
favicon: pond?.favicon ?? instance.favicon ?? null,
logoLevel,
faviconLevel,
};
}

View File

@ -25,6 +25,26 @@ describe('docToHtml (issue #24)', () => {
); );
}); });
it('keeps the cell spans of merged cells (issue #337)', () => {
// Built directly: markdown cannot express merged cells.
const cell = (text: string, attrs: { colspan?: number; rowspan?: number } | null = null) =>
editorSchema.node('table_cell', attrs, [
editorSchema.node('paragraph', null, [editorSchema.text(text)]),
]);
const doc = editorSchema.node('doc', null, [
editorSchema.node('table', null, [
editorSchema.node('table_row', null, [
cell('wide', { colspan: 2 }),
cell('tall', { rowspan: 2 }),
]),
editorSchema.node('table_row', null, [cell('a'), cell('b')]),
]),
]);
expect(docToHtml(doc)).toBe(
'<table><tr><td colspan="2"><p>wide</p></td><td rowspan="2"><p>tall</p></td></tr><tr><td><p>a</p></td><td><p>b</p></td></tr></table>',
);
});
it('allowlists link protocols, neutralizing javascript: hrefs', () => { it('allowlists link protocols, neutralizing javascript: hrefs', () => {
const safe = markdownToDoc('[go](https://example.org)'); const safe = markdownToDoc('[go](https://example.org)');
expect(docToHtml(safe)).toContain('href="https://example.org"'); expect(docToHtml(safe)).toContain('href="https://example.org"');

View File

@ -111,7 +111,12 @@ function renderTable(node: Node): string {
out += '<tr>'; out += '<tr>';
row.forEach((cell) => { row.forEach((cell) => {
const tag = cell.type.name === 'table_header' ? 'th' : 'td'; const tag = cell.type.name === 'table_header' ? 'th' : 'td';
out += `<${tag}>${renderBlocks(cell)}</${tag}>`; // Merged cells (issue #337): without the span attributes the read-mode
// table silently loses the merge the editor shows.
const colspan = cell.attrs.colspan as number;
const rowspan = cell.attrs.rowspan as number;
const spans = `${colspan > 1 ? ` colspan="${colspan}"` : ''}${rowspan > 1 ? ` rowspan="${rowspan}"` : ''}`;
out += `<${tag}${spans}>${renderBlocks(cell)}</${tag}>`;
}); });
out += '</tr>'; out += '</tr>';
}); });

View File

@ -148,6 +148,21 @@ describe('markdown round-trip (issue #24)', () => {
expect(doc.firstChild?.attrs.data).toEqual({}); expect(doc.firstChild?.attrs.data).toEqual({});
}); });
it('pads a colspan cell so every row keeps the column count (issue #337)', () => {
// Built directly: markdown cannot express merged cells, so the export
// is lossy by format — but it must stay a well-formed GFM table.
const schema = markdownToDoc('x').type.schema;
const cell = (type: string, text: string, attrs: { colspan?: number } | null = null) =>
schema.node(type, attrs, [schema.node('paragraph', null, [schema.text(text)])]);
const doc = schema.node('doc', null, [
schema.node('table', null, [
schema.node('table_row', null, [cell('table_header', 'A'), cell('table_header', 'B')]),
schema.node('table_row', null, [cell('table_cell', 'wide', { colspan: 2 })]),
]),
]);
expect(docToMarkdown(doc)).toBe('| A | B |\n| --- | --- |\n| wide | |');
});
it('escalates the fence when the data contains backticks (issue #76)', () => { it('escalates the fence when the data contains backticks (issue #76)', () => {
const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```'); const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```');
const withTicks = doc.type.schema.nodes.plugin_block!.create({ const withTicks = doc.type.schema.nodes.plugin_block!.create({

View File

@ -510,6 +510,10 @@ function renderTable(state: MarkdownSerializerState, node: Node): void {
const cells: string[] = []; const cells: string[] = [];
row.forEach((cell) => { row.forEach((cell) => {
cells.push(cell.textContent.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim()); cells.push(cell.textContent.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim());
// GFM has no cell spans: pad a colspan with empty cells so every row
// keeps the table's column count (rowspan stays lossy — the covered
// rows are simply shorter; issue #337).
for (let extra = 1; extra < (cell.attrs.colspan as number); extra += 1) cells.push('');
}); });
rows.push(cells); rows.push(cells);
}); });

View File

@ -18,6 +18,7 @@ export * from './fonts';
export * from './health'; export * from './health';
export * from './home'; export * from './home';
export * from './i18n-tools'; export * from './i18n-tools';
export * from './invitations';
export * from './labels'; export * from './labels';
export * from './legal'; export * from './legal';
export * from './links'; export * from './links';

View File

@ -0,0 +1,40 @@
import { z } from 'zod';
/**
* Peer invitations (issue #332): a user invites an e-mail address; the
* mailed token allows exactly one registration even while registration
* is closed. A per-user quota on OPEN (pending, unexpired) invitations
* the instance setting `invitations.maxOpenPerUser`, default 5, 0 turns
* the feature off keeps the feature from becoming a spam channel.
*/
export type InvitationStatus = 'pending' | 'accepted' | 'revoked' | 'expired';
export interface InvitationView {
id: string;
email: string;
status: InvitationStatus;
createdAt: string;
expiresAt: string;
}
export interface InvitationListView {
invitations: InvitationView[];
/** Open (pending, unexpired) invitations counted against the quota. */
open: number;
/** The instance-wide per-user quota; 0 = inviting disabled. */
maxOpen: number;
}
export const createInvitationSchema = z.object({
email: z.string().email('validation.email.invalid').max(254),
});
export type CreateInvitationInput = z.infer<typeof createInvitationSchema>;
export const invitationPreviewSchema = z.object({ token: z.string().min(16).max(256) });
/** What the signup screen shows about an invitation link before use. */
export interface InvitationPreview {
email: string;
inviterName: string;
}

View File

@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { pondBrandingSchema } from './branding';
import { COMMENT_POLICIES } from './comments'; import { COMMENT_POLICIES } from './comments';
/** /**
@ -66,6 +68,12 @@ export const pondSettingsSchema = z.object({
* as an id, not a slug, so renaming or moving the page does not break it; * as an id, not a slug, so renaming or moving the page does not break it;
* a dangling id (page trashed) falls back rather than erroring. */ * a dangling id (page trashed) falls back rather than erroring. */
startPageId: z.string().uuid().nullable().default(null), startPageId: z.string().uuid().nullable().default(null),
/** The pond's own logo and favicon (issue #307), overriding the instance's.
* Metadata only the PNG bytes live under `BRANDING_DIR`, like the
* instance's. Written through the branding endpoints, never through
* `PATCH /ponds/:id`: it describes bytes on disk, and hand-writing it would
* claim an asset that is not there. */
branding: pondBrandingSchema.default({ logo: null, logoDark: null, favicon: null }),
/** Who may write comments (issue #91): every reader, or editors only. */ /** Who may write comments (issue #91): every reader, or editors only. */
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'), commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
/** Per-pond opt-in to the public REST API (issue #104, default off): /** Per-pond opt-in to the public REST API (issue #104, default off):

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