Compare commits

..

No commits in common. "main" and "issue-302-pond-start-page" have entirely different histories.

159 changed files with 401 additions and 9738 deletions

View File

@ -107,24 +107,6 @@ jobs:
exit 1 exit 1
fi fi
# A fresh named volume inherits the ownership of the image directory it
# is mounted over. Every /data/… path the api image defaults to must
# therefore be pre-created AND chowned to `node`, or the non-root user
# cannot write to it — found on a real deploy in #303, where the env
# entry was added but the mkdir/chown line was not.
- name: api image pre-creates its data directories node-owned
run: |
set -euo pipefail
dirs=$(grep -oE '[A-Z_]+_DIR=/data/[a-z]+' apps/api/Dockerfile | cut -d= -f2 | sort -u)
bad=0
for d in $dirs; do
grep -q "mkdir -p .*$d" apps/api/Dockerfile || {
echo "$d is not pre-created in apps/api/Dockerfile"; bad=1; }
grep -q "chown -R node:node .*$d" apps/api/Dockerfile || {
echo "$d is not chowned to node in apps/api/Dockerfile"; bad=1; }
done
exit "$bad"
- name: Set up pnpm - name: Set up pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@ -345,16 +327,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
- name: Reset login rate limit before admin-settings pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run admin-settings pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
- name: Reset login rate limit before admin-quotas pack - name: Reset login rate limit before admin-quotas pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -375,18 +347,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
- name: Reset login rate limit before invitations pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# Invitations (issue #332) need the mail catcher like the auth pack:
# the invite link and the follow-up verification both travel by mail.
- name: Run invitations pack
run: |
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts
- name: Reset login rate limit before permission-matrix pack - name: Reset login rate limit before permission-matrix pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
@ -660,14 +620,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
# Das a11y-Pack kostet seit #301 einen Login mehr (der Reflow-Zaun);
# damit reicht das Budget nicht mehr bis in die VS-NfD-Packs → hier
# zusätzlich zurücksetzen (siehe Hinweis oben).
- name: Reset login rate limit before the VS-NfD packs
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# VS-NfD-Markierungen im Modus `marked` (issue #244). # VS-NfD-Markierungen im Modus `marked` (issue #244).
- name: Run VS-NfD marking pack - name: Run VS-NfD marking pack
run: | run: |

View File

@ -29,19 +29,18 @@ ARG APP_VERSION=0.0.0-dev
# Default the data dirs to the writable, node-owned locations created below, so # Default the data dirs to the writable, node-owned locations created below, so
# the image works out of the box even where compose does not set them; compose # the image works out of the box even where compose does not set them; compose
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR). # still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
WORKDIR /app WORKDIR /app
COPY --from=build --chown=node:node /out /app COPY --from=build --chown=node:node /out /app
# Generate the Prisma client for this image's platform. # Generate the Prisma client for this image's platform.
RUN node node_modules/prisma/build/index.js generate RUN node node_modules/prisma/build/index.js generate
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts # A fresh named volume mounted at /data/uploads or /data/plugins is created
# or /data/branding is created
# root-owned; pre-creating them here (Docker copies an image directory's # root-owned; pre-creating them here (Docker copies an image directory's
# ownership into a new volume on first mount) lets the non-root `node` user # ownership into a new volume on first mount) lets the non-root `node` user
# write to them. /data/backups is mounted read-only here, but pre-creating it # write to them. /data/backups is mounted read-only here, but pre-creating it
# node-owned keeps the shared `backups` volume writable for the backup # node-owned keeps the shared `backups` volume writable for the backup
# sidecar even when the api container is the one that initializes it. # sidecar even when the api container is the one that initializes it.
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups RUN mkdir -p /data/uploads /data/plugins /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/secrets /data/backups
USER node USER node
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --retries=3 \

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

View File

@ -1,45 +0,0 @@
-- #303: operator-uploaded font families (ADR 0016 §#303).
-- The bytes live on disk under CUSTOM_FONTS_DIR; these rows record only what
-- the upload form stated, because the api never parses the font file.
CREATE TABLE "custom_fonts" (
"id" TEXT NOT NULL,
"family" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"category" TEXT NOT NULL,
"licence" TEXT NOT NULL,
"licence_url" TEXT,
"uploaded_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "custom_fonts_pkey" PRIMARY KEY ("id")
);
-- Both unique: `family` keeps `fonts.<slot>.family` in pond settings
-- unambiguous, `slug` owns a directory under CUSTOM_FONTS_DIR.
CREATE UNIQUE INDEX "custom_fonts_family_key" ON "custom_fonts"("family");
CREATE UNIQUE INDEX "custom_fonts_slug_key" ON "custom_fonts"("slug");
ALTER TABLE "custom_fonts" ADD CONSTRAINT "custom_fonts_uploaded_by_fkey"
FOREIGN KEY ("uploaded_by") REFERENCES "users"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE TABLE "custom_font_weights" (
"id" TEXT NOT NULL,
"font_id" TEXT NOT NULL,
"weight" INTEGER NOT NULL,
"has_woff" BOOLEAN NOT NULL DEFAULT false,
"byte_size" INTEGER NOT NULL,
CONSTRAINT "custom_font_weights_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "custom_font_weights_font_id_weight_key"
ON "custom_font_weights"("font_id", "weight");
-- Deleting a family takes its weights with it; the files on disk are removed
-- by the service in the same operation.
ALTER TABLE "custom_font_weights" ADD CONSTRAINT "custom_font_weights_font_id_fkey"
FOREIGN KEY ("font_id") REFERENCES "custom_fonts"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,26 +0,0 @@
-- Peer invitations (issue #332): a user invites an e-mail address; the token
-- allows exactly one registration even while registration is closed.
-- CreateTable
CREATE TABLE "invitations" (
"id" TEXT NOT NULL,
"inviter_id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
"accepted_at" TIMESTAMP(3),
"accepted_user_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "invitations_token_hash_key" ON "invitations"("token_hash");
-- CreateIndex
CREATE INDEX "invitations_inviter_id_idx" ON "invitations"("inviter_id");
-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -31,71 +31,46 @@ enum UserStatus {
/// Account profile. Login methods live in UserIdentity (OIDC-ready, /// Account profile. Login methods live in UserIdentity (OIDC-ready,
/// ADR 0007); Site Admin is a user flag, all other roles are grants. /// ADR 0007); Site Admin is a user flag, all other roles are grants.
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String @unique username String @unique
email String @unique email String @unique
displayName String @map("display_name") displayName String @map("display_name")
locale String @default("en") locale String @default("en")
isSiteAdmin Boolean @default(false) @map("is_site_admin") isSiteAdmin Boolean @default(false) @map("is_site_admin")
/// True when the flag was last SET by the IdP claim mapping (issue #217): /// True when the flag was last SET by the IdP claim mapping (issue #217):
/// only then may the mapping revoke it again on a later login. A manual /// only then may the mapping revoke it again on a later login. A manual
/// admin toggle clears the marker, so hand-granted admins are never /// admin toggle clears the marker, so hand-granted admins are never
/// demoted by a missing claim. /// demoted by a missing claim.
isSiteAdminManaged Boolean @default(false) @map("is_site_admin_managed") isSiteAdminManaged Boolean @default(false) @map("is_site_admin_managed")
/// Auto-watch preferences (issue #93): watch pages I create / comment on. /// Auto-watch preferences (issue #93): watch pages I create / comment on.
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages") autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment") autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
/// E-mail digest cadence (issue #95): hourly | daily | off. /// E-mail digest cadence (issue #95): hourly | daily | off.
digestFrequency String @default("hourly") @map("digest_frequency") digestFrequency String @default("hourly") @map("digest_frequency")
status UserStatus @default(PENDING_VERIFICATION) status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at") emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at") lastLoginAt DateTime? @map("last_login_at")
identities UserIdentity[] identities UserIdentity[]
sessions Session[] sessions Session[]
authTokens AuthToken[] authTokens AuthToken[]
apiTokens ApiToken[] apiTokens ApiToken[]
feedTokens FeedToken[] feedTokens FeedToken[]
mentionRows PageMention[] mentionRows PageMention[]
ponds Pond[] ponds Pond[]
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
conversionJobs ConversionJob[] conversionJobs ConversionJob[]
auditEntries AuditEntry[] auditEntries AuditEntry[]
comments Comment[] comments Comment[]
watches Watch[] watches Watch[]
notifications Notification[] notifications Notification[]
favorites PageFavorite[] favorites PageFavorite[]
customFonts CustomFont[]
invitations Invitation[] @relation("InvitationsSent")
@@map("users") @@map("users")
} }
/// Peer invitations (issue #332): a user invites an e-mail address; the
/// token allows exactly one registration even while registration is
/// closed. Only the SHA-256 hash of the token is stored (auth-tokens
/// pattern); revoked/accepted rows are kept so the settings UI can show
/// history. "Open" (pending, unexpired) rows count against the per-user
/// quota `invitations.maxOpenPerUser`.
model Invitation {
id String @id @default(uuid())
inviterId String @map("inviter_id")
email String
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
acceptedAt DateTime? @map("accepted_at")
acceptedUserId String? @map("accepted_user_id")
createdAt DateTime @default(now()) @map("created_at")
inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade)
@@index([inviterId])
@@map("invitations")
}
/// Persistent audit trail (issue #86, security.md §Logging): auth events and /// Persistent audit trail (issue #86, security.md §Logging): auth events and
/// admin actions — grants, member roles, plugin installs, quota and settings /// admin actions — grants, member roles, plugin installs, quota and settings
/// changes, setup steps, manual job triggers. Written by AuditService, which /// changes, setup steps, manual job triggers. Written by AuditService, which
@ -132,32 +107,32 @@ model AuditEntry {
/// lives per partition there (a partitioned parent cannot carry it without /// lives per partition there (a partitioned parent cannot carry it without
/// the partition key); `db push` test databases get it on the plain table. /// the partition key); `db push` test databases get it on the plain table.
model ReadEvent { model ReadEvent {
id String @default(uuid()) id String @default(uuid())
occurredAt DateTime @default(now()) @map("occurred_at") occurredAt DateTime @default(now()) @map("occurred_at")
/// Null = anonymous reader (public grant); `sessionKey` still names the /// Null = anonymous reader (public grant); `sessionKey` still names the
/// browsing session, so the anonymous marker is explicit, not an accident. /// browsing session, so the anonymous marker is explicit, not an accident.
actorId String? @map("actor_id") actorId String? @map("actor_id")
/// `session:<id>` for cookie sessions, `token:<id>` for PATs, `job:<id>` /// `session:<id>` for cookie sessions, `token:<id>` for PATs, `job:<id>`
/// for background builds (account data export), `anon` for anonymous /// for background builds (account data export), `anon` for anonymous
/// visitors — the dedup-window key basis (#223). /// visitors — the dedup-window key basis (#223).
sessionKey String @map("session_key") sessionKey String @map("session_key")
pageId String? @map("page_id") pageId String? @map("page_id")
pondId String @map("pond_id") pondId String @map("pond_id")
/// Which read surface fired: `page_view` | `no_js_shell` | `public_api` | /// Which read surface fired: `page_view` | `no_js_shell` | `public_api` |
/// `attachment` | `export` | `collab_join` (READ_CHANNELS union in code). /// `attachment` | `export` | `collab_join` (READ_CHANNELS union in code).
channel String channel String
/// Classification at read time — a later reclassification must not /// Classification at read time — a later reclassification must not
/// rewrite history (ADR 0023). /// rewrite history (ADR 0023).
classification String classification String
details Json? details Json?
/// Dedup window (issue #223): `<sessionKey>:<pageId|->:<channel>` plus the /// Dedup window (issue #223): `<sessionKey>:<pageId|->:<channel>` plus the
/// aligned bucket `floor(epoch / windowSeconds)`. The unique pair makes /// aligned bucket `floor(epoch / windowSeconds)`. The unique pair makes
/// concurrent duplicate reads collapse race-free (insert or P2002-skip). /// concurrent duplicate reads collapse race-free (insert or P2002-skip).
dedupKey String @map("dedup_key") dedupKey String @map("dedup_key")
windowBucket BigInt @map("window_bucket") windowBucket BigInt @map("window_bucket")
/// Window length the event was recorded under — the row itself states it /// Window length the event was recorded under — the row itself states it
/// represents up to this many seconds, so the evidence is not overread. /// represents up to this many seconds, so the evidence is not overread.
windowSeconds Int @map("window_seconds") windowSeconds Int @map("window_seconds")
@@id([id, occurredAt]) @@id([id, occurredAt])
@@unique([dedupKey, windowBucket]) @@unique([dedupKey, windowBucket])
@ -262,14 +237,14 @@ model Pond {
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") deletedBy String? @map("deleted_by")
owner User @relation(fields: [ownerId], references: [id]) owner User @relation(fields: [ownerId], references: [id])
usage PondUsage? usage PondUsage?
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
labels Label[] labels Label[]
grants RoleGrant[] grants RoleGrant[]
conversionJobs ConversionJob[] conversionJobs ConversionJob[]
pondPlugins PondPlugin[] pondPlugins PondPlugin[]
@@index([ownerId]) @@index([ownerId])
@@map("ponds") @@map("ponds")
@ -485,12 +460,12 @@ model CollabOpenSession {
/// built from the Yjs state via the shared editor schema. `outline` is the /// built from the Yjs state via the shared editor schema. `outline` is the
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb. /// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
model PageContentCache { model PageContentCache {
pageId String @id @map("page_id") pageId String @id @map("page_id")
plainText String @map("plain_text") plainText String @map("plain_text")
markdown String markdown String
html String html String
outline Json outline Json
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
/// Weighted full-text search vector (title A, labels B, body C; issue #49, /// Weighted full-text search vector (title A, labels B, body C; issue #49,
/// ADR 0010). Maintained by the SearchProvider and the collab persistence /// ADR 0010). Maintained by the SearchProvider and the collab persistence
/// hook (both write it with the same weighting). The GIN index is added in /// hook (both write it with the same weighting). The GIN index is added in
@ -879,9 +854,9 @@ model ConversionJob {
sourceName String? @map("source_name") sourceName String? @map("source_name")
resultPageId String? @map("result_page_id") resultPageId String? @map("result_page_id")
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade) pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull) page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
@@index([status, createdAt]) @@index([status, createdAt])
@@map("conversion_jobs") @@map("conversion_jobs")
@ -938,48 +913,3 @@ model PondPlugin {
@@id([pondId, pluginId]) @@id([pondId, pluginId])
@@map("pond_plugins") @@map("pond_plugins")
} }
/// An operator-uploaded font family (issue #303, ADR 0016 §#303). The bytes
/// live on disk under CUSTOM_FONTS_DIR — this row only records what the
/// upload form stated, because the api never parses the font file itself.
/// Additive to the compile-time catalog: a family whose name or slug
/// collides with a catalog entry is rejected, so `fonts.<slot>.family` in a
/// pond's settings stays unambiguous.
model CustomFont {
id String @id @default(uuid())
/// CSS `font-family` name, as typed by the uploader.
family String @unique
/// URL/file-safe form; names the directory under CUSTOM_FONTS_DIR.
slug String @unique
/// Drives the system fallback stack, like FontCatalogEntry.category.
category String
/// Free-text licence label, e.g. "Commercial — Foundry XY". Required so
/// an attribution obligation can be met on the font catalogue page.
licence String
licenceUrl String? @map("licence_url")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
uploader User @relation(fields: [uploadedBy], references: [id])
weights CustomFontWeight[]
@@map("custom_fonts")
}
/// One weight of a custom family. Style is always `normal`: the PDF
/// `@font-face` builder emits only that, and browsers synthesise oblique —
/// italic uploads are a follow-up, not a silent half-feature.
model CustomFontWeight {
id String @id @default(uuid())
fontId String @map("font_id")
weight Int
/// Whether a legacy WOFF was supplied next to the required WOFF2.
hasWoff Boolean @default(false) @map("has_woff")
byteSize Int @map("byte_size")
font CustomFont @relation(fields: [fontId], references: [id], onDelete: Cascade)
@@unique([fontId, weight])
@@map("custom_font_weights")
}

View File

@ -1,127 +0,0 @@
#!/usr/bin/env node
/**
* Generates the shipped default favicons (issue #306):
* `apps/api/assets/default-favicon-32.png` and `-180.png`.
*
* The api serves these whenever an operator has not uploaded one, so an
* instance always has a tab icon the `<link rel="icon">` in index.html is
* static and its resource must never 404.
*
* Drawn here rather than pulled in as a binary: the whole toolchain must
* survive the `--network none` offline build (96-offline-build-protokoll.md),
* and adding an image library for one 32×32 icon would be the tail wagging
* the dog. Node's own zlib is enough to write a PNG.
*
* Motif: a pond seen from above the accent-green disc with two ripples.
*
* Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit
* script and binaries together.
*/
import { deflateSync } from 'node:zlib';
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Brand green — the same value as index.html's light `theme-color`. */
const GREEN = [0x2f, 0x6f, 0x4f];
const LIGHT = [0xe8, 0xf2, 0xec];
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
}
/** Minimal RGBA PNG writer — no filtering, one IDAT. */
function encodePng(size, rgba) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type RGBA
const raw = Buffer.alloc(size * (size * 4 + 1));
for (let y = 0; y < size; y += 1) {
raw[y * (size * 4 + 1)] = 0; // filter: none
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
/**
* Colour at one point of the unit square, in continuous coordinates the
* caller supersamples it, which is where the anti-aliasing comes from.
*/
function sample(x, y) {
const dx = x - 0.5;
const dy = y - 0.5;
const r = Math.hypot(dx, dy);
if (r > 0.48) return null; // outside the disc: transparent
// Two ripples spreading from a point struck slightly above centre — rings
// rather than a bullseye, which is why the centre stays green and the
// spacing widens outward the way real ripples do.
const rr = Math.hypot(dx, dy + 0.06);
const onRing = (radius, width) => Math.abs(rr - radius) < width;
if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT;
return GREEN;
}
function render(size) {
const SS = 4; // supersampling factor
const out = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
let r = 0;
let g = 0;
let b = 0;
let a = 0;
for (let sy = 0; sy < SS; sy += 1) {
for (let sx = 0; sx < SS; sx += 1) {
const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size);
if (c) {
r += c[0];
g += c[1];
b += c[2];
a += 255;
}
}
}
const n = SS * SS;
const covered = a / 255;
const i = (y * size + x) * 4;
// Premultiplied average of the covered samples only, so the edge fades
// in alpha rather than towards black.
out[i] = covered ? Math.round(r / covered) : 0;
out[i + 1] = covered ? Math.round(g / covered) : 0;
out[i + 2] = covered ? Math.round(b / covered) : 0;
out[i + 3] = Math.round(a / n);
}
}
return out;
}
const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets');
for (const size of [32, 180]) {
const file = join(assets, `default-favicon-${size}.png`);
writeFileSync(file, encodePng(size, render(size)));
console.log(`wrote ${file}`);
}

View File

@ -11,17 +11,9 @@ import {
} from '../settings/instance-settings.service'; } from '../settings/instance-settings.service';
import { SiteAdminGuard } from './site-admin.guard'; import { SiteAdminGuard } from './site-admin.guard';
// Lifecycle markers and file-backed metadata, not configuration: never // Lifecycle markers, not configuration: never editable through this
// editable through this endpoint. The setup lock must be irreversible // endpoint (the setup lock must be irreversible, issue #80).
// (issue #80), and the branding entries only describe bytes on disk const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
// (issue #306) — writing one by hand would claim an asset that is not
// there. Both have their own write paths.
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set([
'setup.completedAt',
'instance.logo',
'instance.logoDark',
'instance.favicon',
]);
// Partial update: any subset of the known settings, each validated by // Partial update: any subset of the known settings, each validated by
// its own schema inside the service (double validation is fine — this // its own schema inside the service (double validation is fine — this

View File

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

View File

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

View File

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

View File

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

View File

@ -29,9 +29,6 @@ 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' },
@ -43,20 +40,15 @@ export const AUDIT_EVENTS = {
'plugin.mode_set': { severity: 'notice' }, 'plugin.mode_set': { severity: 'notice' },
'plugin.pond_toggled': { severity: 'info' }, 'plugin.pond_toggled': { severity: 'info' },
'plugin.uninstalled': { severity: 'notice' }, 'plugin.uninstalled': { severity: 'notice' },
'pond.archived': { severity: 'notice' },
'pond.purged': { severity: 'notice' }, 'pond.purged': { severity: 'notice' },
'quota.override_cleared': { severity: 'notice' }, 'quota.override_cleared': { severity: 'notice' },
'quota.override_set': { severity: 'notice' }, 'quota.override_set': { severity: 'notice' },
'read_trail.pruned': { severity: 'info' }, 'read_trail.pruned': { severity: 'info' },
'settings.changed': { severity: 'notice' }, 'settings.changed': { severity: 'notice' },
'branding.changed': { severity: 'notice' },
'font.uploaded': { severity: 'notice' },
'font.deleted': { severity: 'notice' },
'setup.admin_created': { severity: 'notice' }, 'setup.admin_created': { severity: 'notice' },
'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,7 +3,6 @@ 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';
@ -19,7 +18,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, InvitationsModule], imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule],
controllers: [AuthController, OidcController], controllers: [AuthController, OidcController],
providers: [ providers: [
AuthService, AuthService,

View File

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

View File

@ -1,50 +0,0 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable } from '@nestjs/common';
import { AppConfig } from '../config/app-config.service';
/**
* Filesystem binding for branding assets (issue #306; pond overrides #307).
*
* One flat directory of PNGs named by a caller-supplied key
* (`instance-logo-light`, later `pond-<id>-favicon-32`). Flat because there
* are a handful of files per instance and the backup archives the directory
* as a whole a tree would buy nothing and cost a traversal question.
*
* The key is constrained here rather than trusted from the route: it is the
* only thing between a request parameter and a path.
*/
@Injectable()
export class BrandingStorageService {
constructor(private readonly config: AppConfig) {}
/** Lowercase, digits and dashes only no dot, so no `..`, and no slash,
* so the file cannot leave the directory whatever a caller sends. */
private pathFor(key: string): string {
if (!/^[a-z0-9-]{1,120}$/.test(key)) throw new Error(`invalid branding key: ${key}`);
return join(this.config.env.BRANDING_DIR, `${key}.png`);
}
async save(key: string, bytes: Buffer): Promise<void> {
await mkdir(this.config.env.BRANDING_DIR, { recursive: true });
await writeFile(this.pathFor(key), bytes);
}
/** The bytes, or null when the file is absent a missing asset is a normal
* state here (nothing uploaded, or metadata and disk drifted after a
* partial restore), and every caller has a fallback. */
async read(key: string): Promise<Buffer | null> {
try {
return await readFile(this.pathFor(key));
} catch {
return null;
}
}
/** Idempotent: removing what is not there is success. */
async remove(key: string): Promise<void> {
await rm(this.pathFor(key), { force: true });
}
}

View File

@ -1,253 +0,0 @@
import {
BadRequestException,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Query,
Req,
Res,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
BrandingView,
FAVICON_SIZES,
FaviconSize,
LOGO_VARIANTS,
LogoVariant,
MAX_BRANDING_BYTES,
PondBranding,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { RequiresPondRole } from '../permissions/permission.decorators';
import { PrismaService } from '../prisma/prisma.service';
import { BrandingService } from './branding.service';
function parseVariant(value: unknown): LogoVariant {
if (!LOGO_VARIANTS.includes(value as LogoVariant)) {
throw new BadRequestException({ code: 'bad_request' });
}
return value as LogoVariant;
}
/**
* Public branding surface (issue #306).
*
* Unauthenticated by design and worth stating plainly in the admin UI: the
* login screen carries the branding and the browser fetches the favicon before
* anyone signs in, so an operator's logo IS visible to anonymous visitors.
*/
@Controller('branding')
export class BrandingController {
constructor(private readonly branding: BrandingService) {}
@Public()
@Get()
view(): Promise<BrandingView> {
return this.branding.view();
}
@Public()
@Get('logo')
async logo(
@Query('variant') variant: string | undefined,
@Query('pond') pondId: string | undefined,
@Res() res: Response,
): Promise<void> {
const wanted = parseVariant(variant ?? 'light');
// A pond scope serves the pond's own bytes and nothing else: the caller
// already resolved WHICH level applies (`resolveBranding`), so silently
// falling back here would mix variants across levels — exactly what #307
// forbids.
const bytes = pondId
? await this.branding.pondLogoBytes(pondId, wanted)
: await this.branding.logoBytes(wanted);
// No shipped default: without a logo the app renders the instance NAME as
// text, so an empty answer here is the honest one.
if (!bytes) {
res.status(404).json({ code: 'not_found', message: 'no logo' });
return;
}
res.setHeader('Content-Type', 'image/png');
// The caller puts the content hash in the query string, so a given URL
// never changes what it points at.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(bytes);
}
@Public()
@Get('favicon')
async favicon(
@Query('size') size: string | undefined,
@Query('pond') pondId: string | undefined,
@Res() res: Response,
): Promise<void> {
const wanted = Number(size ?? 32);
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
throw new BadRequestException({ code: 'bad_request' });
}
const pondBytes = pondId
? await this.branding.pondFaviconBytes(pondId, wanted as FaviconSize)
: null;
const { bytes, uploaded } = pondBytes
? { bytes: pondBytes, uploaded: true }
: await this.branding.faviconBytes(wanted as FaviconSize);
res.setHeader('Content-Type', 'image/png');
// The `<link rel="icon">` href is a constant in index.html, so this URL
// cannot carry a hash — revalidation is the only way a replaced favicon
// ever reaches a browser that already has one.
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('ETag', `"${uploaded ? 'custom' : 'default'}-${bytes.length}"`);
res.send(bytes);
}
}
/** Site-Admin management of the instance branding (issue #306). */
@Controller('admin/branding')
@UseGuards(SiteAdminGuard)
export class BrandingAdminController {
constructor(private readonly branding: BrandingService) {}
@Post('logo')
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setLogo(
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<BrandingView> {
const file = files?.find((entry) => entry.fieldname === 'file');
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
return this.branding.setLogo(request.user!, parseVariant(variant ?? 'light'), file.buffer);
}
@Delete('logo')
clearLogo(
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
): Promise<BrandingView> {
return this.branding.clearLogo(request.user!, parseVariant(variant ?? 'light'));
}
@Post('favicon')
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setFavicon(
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<BrandingView> {
// Field names are the pixel sizes the browser rendered: `png-32`, `png-180`.
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
const collected = {} as Record<FaviconSize, Buffer>;
for (const size of FAVICON_SIZES) {
const bytes = byField.get(`png-${size}`);
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
collected[size] = bytes;
}
return this.branding.setFavicon(request.user!, collected);
}
@Delete('favicon')
clearFavicon(@Req() request: AuthedRequest): Promise<BrandingView> {
return this.branding.clearFavicon(request.user!);
}
}
/**
* Pond-level branding (issue #307). The uploader here is an ordinary Pond
* Admin rather than the operator, so the security rules of #306 are not
* relaxed by a single line: SVG refused, magic bytes checked server-side,
* size caps enforced, content type pinned on serving, no image parsing.
*
* 404/403 policy: a user who cannot see the pond gets 404 from the pond-role
* guard, one who can see but not administer it gets 403.
*/
@Controller('ponds/:pondId/branding')
export class PondBrandingController {
constructor(
private readonly branding: BrandingService,
private readonly prisma: PrismaService,
) {}
/** The pond row the quota is charged to. */
private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> {
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { id: true, ownerId: true },
});
if (!pond) throw new NotFoundException();
return pond;
}
@Get()
@RequiresPondRole('reader', { idParam: 'pondId' })
view(@Param('pondId') pondId: string): Promise<PondBranding> {
return this.branding.pondBranding(pondId);
}
@Post('logo')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setLogo(
@Param('pondId') pondId: string,
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<PondBranding> {
const file = files?.find((entry) => entry.fieldname === 'file');
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
return this.branding.setPondLogo(
request.user!,
await this.pondOf(pondId),
parseVariant(variant ?? 'light'),
file.buffer,
);
}
@Delete('logo')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async clearLogo(
@Param('pondId') pondId: string,
@Query('variant') variant: string | undefined,
@Req() request: AuthedRequest,
): Promise<PondBranding> {
return this.branding.clearPondLogo(
request.user!,
await this.pondOf(pondId),
parseVariant(variant ?? 'light'),
);
}
@Post('favicon')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
async setFavicon(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<PondBranding> {
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
const collected = {} as Record<FaviconSize, Buffer>;
for (const size of FAVICON_SIZES) {
const bytes = byField.get(`png-${size}`);
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
collected[size] = bytes;
}
return this.branding.setPondFavicon(request.user!, await this.pondOf(pondId), collected);
}
@Delete('favicon')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async clearFavicon(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PondBranding> {
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
}
}

View File

@ -1,250 +0,0 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* A real PNG of `size`×`size`, built the same way the shipped default is
* the api reads the IHDR, so the header has to be genuine.
*/
async function png(size: number): Promise<Buffer> {
const { deflateSync } = await import('node:zlib');
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc32 = (buf: Buffer): number => {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type: string, data: Buffer): Buffer => {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const raw = Buffer.alloc(size * (size * 4 + 1));
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
describe.skipIf(!hasTestDb)('instance branding (e2e, issue #306)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let brandingDir: string;
const suffix = uniqueSuffix();
const password = 'markenzeichen mit teich 1';
const admin = { username: `ba-${suffix}` };
const plain = { username: `bp-${suffix}` };
let adminCookie: string;
let plainCookie: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
// A real directory: the point is that bytes land somewhere and come back.
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-branding-'));
process.env.BRANDING_DIR = brandingDir;
app = await createTestApp();
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: `Branding Admin ${suffix}`,
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
const plainUser = await users.createUser({
username: plain.username,
email: `${plain.username}@example.org`,
displayName: `Branding Plain ${suffix}`,
password,
locale: 'en',
});
await users.markEmailVerified(plainUser.id);
const login = async (username: string): Promise<string> =>
sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
adminCookie = await login(admin.username);
plainCookie = await login(plain.username);
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: { in: ['instance.logo', 'instance.logoDark', 'instance.favicon'] } },
});
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
await rm(brandingDir, { recursive: true, force: true });
delete process.env.BRANDING_DIR;
});
it('serves the shipped default favicon before anything is uploaded', async () => {
// The `<link rel="icon">` in index.html is a constant — this route must
// never 404, or the browser keeps its generic icon for good.
const res = await api().get('/api/v1/branding/favicon').expect(200);
expect(res.headers['content-type']).toContain('image/png');
expect(res.body.subarray(0, 8).toString('latin1')).toContain('PNG');
});
it('stores a logo, reports it, and serves the bytes without a session', async () => {
const bytes = await png(64);
const view = await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.attach('file', bytes, 'logo.png')
.expect(201);
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
expect(view.body.logoDark).toBeNull();
// On disk, under the key the pond override (#307) will extend.
const onDisk = await readFile(join(brandingDir, 'instance-logo-light.png'));
expect(onDisk.length).toBe(bytes.length);
// Anonymous: the login screen carries the branding.
const served = await api().get('/api/v1/branding/logo?variant=light').expect(200);
expect(served.headers['content-type']).toContain('image/png');
const anon = await api().get('/api/v1/branding').expect(200);
expect(anon.body.logo.hash).toBe(view.body.logo.hash);
expect(anon.body.instanceName).toBeTruthy();
});
it('answers 404 for a logo variant that was never uploaded', async () => {
// No shipped default for the logo: without one the app renders the
// instance NAME, so an empty answer is the honest one.
await api().get('/api/v1/branding/logo?variant=dark').expect(404);
});
it('rejects an SVG with its own message, not a generic one', async () => {
const res = await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.attach('file', Buffer.from('<?xml version="1.0"?><svg xmlns="..."><script/></svg>'), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_svg_rejected');
});
it('rejects bytes that are not a PNG at all', async () => {
const res = await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.attach('file', Buffer.from('GIF89a and then some'), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_not_a_png');
});
it('rejects a logo larger than the maximum edge', async () => {
const res = await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.attach('file', await png(600), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_image_too_large');
});
it('takes both favicon sizes together and serves each back', async () => {
await api()
.post('/api/v1/admin/branding/favicon')
.set('Cookie', adminCookie)
.attach('png-32', await png(32), 'f32.png')
.attach('png-180', await png(180), 'f180.png')
.expect(201);
for (const size of [32, 180]) {
const res = await api().get(`/api/v1/branding/favicon?size=${size}`).expect(200);
expect(res.body.length).toBe((await png(size)).length);
}
});
it('refuses a favicon whose bytes do not match the size they claim', async () => {
const res = await api()
.post('/api/v1/admin/branding/favicon')
.set('Cookie', adminCookie)
.attach('png-32', await png(64), 'f32.png')
.attach('png-180', await png(180), 'f180.png')
.expect(400);
expect(res.body.code).toBe('branding_favicon_not_square');
});
it('clears an asset and falls back again', async () => {
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', adminCookie).expect(200);
const view = await api().get('/api/v1/branding').expect(200);
expect(view.body.favicon).toBeNull();
// Back to the shipped default rather than a 404.
await api().get('/api/v1/branding/favicon').expect(200);
await api()
.delete('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', adminCookie)
.expect(200);
await api().get('/api/v1/branding/logo?variant=light').expect(404);
});
it('keeps management away from a non-admin, but not reading', async () => {
await api()
.post('/api/v1/admin/branding/logo?variant=light')
.set('Cookie', plainCookie)
.attach('file', await png(32), 'x.png')
.expect(403);
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', plainCookie).expect(403);
await api().get('/api/v1/branding').set('Cookie', plainCookie).expect(200);
});
it('audits every branding change with scope, asset and direction', async () => {
await api()
.post('/api/v1/admin/branding/logo?variant=dark')
.set('Cookie', adminCookie)
.attach('file', await png(48), 'logo.png')
.expect(201);
const entry = await prisma.auditEntry.findFirst({
where: { action: 'branding.changed', targetId: 'instance.logoDark' },
orderBy: { at: 'desc' },
});
expect(entry).not.toBeNull();
expect(entry!.details).toMatchObject({ scope: 'instance', asset: 'logoDark', change: 'set' });
});
it('refuses to write branding metadata through the settings endpoint', async () => {
// The metadata describes bytes on disk; hand-writing it would claim an
// asset that is not there, so the settings PATCH does not accept it.
const res = await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'instance.logo': { hash: 'deadbeefdeadbeef', width: 10, height: 10 } })
.expect(400);
expect(res.body.code).toBe('bad_request');
});
});

View File

@ -1,23 +0,0 @@
import { Module } from '@nestjs/common';
import { PermissionsModule } from '../permissions/permissions.module';
import { QuotasModule } from '../quotas/quotas.module';
import {
BrandingAdminController,
BrandingController,
PondBrandingController,
} from './branding.controller';
import { BrandingStorageService } from './branding-storage.service';
import { BrandingService } from './branding.service';
/** Instance branding logo and favicon (issue #306). Exports the services so
* the pond-level override (#307) can build on the same storage and the same
* resolution path instead of a parallel one. */
@Module({
imports: [PermissionsModule, QuotasModule],
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
providers: [BrandingService, BrandingStorageService],
exports: [BrandingService, BrandingStorageService],
})
export class BrandingModule {}

View File

@ -1,380 +0,0 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
BrandingAsset,
BrandingView,
FAVICON_SIZES,
FaviconSize,
LOGO_VARIANTS,
LogoVariant,
PondBranding,
pondSettingsSchema,
MAX_BRANDING_BYTES,
MAX_LOGO_EDGE,
hasPngMagic,
looksLikeSvg,
pngDimensions,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { BrandingStorageService } from './branding-storage.service';
/** The settings key each instance asset's metadata lives under. */
const INSTANCE_KEYS = {
logoLight: 'instance.logo',
logoDark: 'instance.logoDark',
favicon: 'instance.favicon',
} as const;
/**
* Instance branding (issue #306): the logo shown at the top of the sidebar and
* the favicon served to the browser.
*
* The api stores and serves bytes; it never decodes them. Validation is the
* PNG signature, the IHDR dimensions and the size cap see
* `packages/shared/src/branding.ts` for why that line is drawn there.
*/
@Injectable()
export class BrandingService {
constructor(
private readonly settings: InstanceSettingsService,
private readonly storage: BrandingStorageService,
private readonly audit: AuditService,
private readonly prisma: PrismaService,
private readonly quotas: QuotaService,
) {}
static logoKey(variant: LogoVariant): string {
return `instance-logo-${variant}`;
}
static faviconKey(size: FaviconSize): string {
return `instance-favicon-${size}`;
}
/** Pond assets share the directory and the naming rules (issue #307); the
* pond id keeps them apart and makes purge a prefix delete. */
static pondLogoKey(pondId: string, variant: LogoVariant): string {
return `pond-${pondId}-logo-${variant}`;
}
static pondFaviconKey(pondId: string, size: FaviconSize): string {
return `pond-${pondId}-favicon-${size}`;
}
/** Every branding file a pond can own the purge deletes exactly this set
* (issue #307). The purge standard is absolute: after it, nothing
* referencing the pond survives, rows or files. */
static pondKeys(pondId: string): string[] {
return [
...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)),
...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, size)),
];
}
/**
* Rejects anything that is not a PNG within the caps, before a byte is
* written. SVG gets its own message: an operator who tried one deserves to
* learn that it is refused on purpose, not that "the file is broken".
*/
private assertUsablePng(bytes: Buffer, maxEdge: number): { width: number; height: number } {
if (bytes.length === 0) throw new BadRequestException({ code: 'branding_file_empty' });
if (bytes.length > MAX_BRANDING_BYTES) {
throw new BadRequestException({ code: 'branding_file_too_large' });
}
if (looksLikeSvg(bytes)) throw new BadRequestException({ code: 'branding_svg_rejected' });
if (!hasPngMagic(bytes)) throw new BadRequestException({ code: 'branding_not_a_png' });
const size = pngDimensions(bytes);
if (!size) throw new BadRequestException({ code: 'branding_not_a_png' });
if (size.width > maxEdge || size.height > maxEdge) {
throw new BadRequestException({ code: 'branding_image_too_large' });
}
return size;
}
/**
* Reserve the pond's storage for a branding asset, releasing what the asset
* it replaces occupied. Doing it in that order means replacing a logo with
* one of the same size costs nothing otherwise every re-upload would eat
* the quota again, which is how "a pond admin fills the disk with logos"
* happens.
*/
private async chargeQuota(
pond: { id: string; ownerId: string },
bytes: number,
previous: BrandingAsset | null,
): Promise<void> {
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
try {
await this.quotas.checkAndConsume(pond.id, pond.ownerId, bytes);
} catch (error) {
// Put the released reservation back: a refused upload must not leave
// the pond with MORE room than before.
if (previous?.byteSize) {
await this.quotas.checkAndConsume(pond.id, pond.ownerId, previous.byteSize);
}
throw error;
}
}
private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
return {
// Short digest: it only has to change when the bytes change, and it
// travels in every logo URL.
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
byteSize: bytes.length,
...size,
};
}
async view(): Promise<BrandingView> {
const [logo, logoDark, favicon, instanceName] = await Promise.all([
this.settings.get(INSTANCE_KEYS.logoLight),
this.settings.get(INSTANCE_KEYS.logoDark),
this.settings.get(INSTANCE_KEYS.favicon),
this.settings.get('instance.name'),
]);
return { logo, logoDark, favicon, instanceName };
}
async setLogo(admin: User, variant: LogoVariant, bytes: Buffer): Promise<BrandingView> {
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
await this.storage.save(BrandingService.logoKey(variant), bytes);
await this.settings.set(
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
this.assetOf(bytes, size),
admin.id,
);
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'set');
return this.view();
}
async clearLogo(admin: User, variant: LogoVariant): Promise<BrandingView> {
await this.storage.remove(BrandingService.logoKey(variant));
await this.settings.set(
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
null,
admin.id,
);
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'cleared');
return this.view();
}
/**
* Both favicon sizes arrive together: the browser produced them from one
* source on the same canvas, and the api cannot resize. Storing them as a
* pair keeps the tab icon and the home-screen icon from ever showing two
* different images.
*/
async setFavicon(admin: User, files: Record<FaviconSize, Buffer>): Promise<BrandingView> {
const sizes = Object.entries(files).map(([declared, bytes]) => {
const size = this.assertUsablePng(bytes, 512);
const expected = Number(declared);
if (size.width !== expected || size.height !== expected) {
throw new BadRequestException({ code: 'branding_favicon_not_square' });
}
return { expected: expected as FaviconSize, bytes, size };
});
for (const entry of sizes) {
await this.storage.save(BrandingService.faviconKey(entry.expected), entry.bytes);
}
// The 32px variant identifies the pair — it is what the tab shows.
const small = sizes.find((entry) => entry.expected === 32)!;
await this.settings.set(INSTANCE_KEYS.favicon, this.assetOf(small.bytes, small.size), admin.id);
await this.record(admin, 'favicon', 'set');
return this.view();
}
async clearFavicon(admin: User): Promise<BrandingView> {
await this.storage.remove(BrandingService.faviconKey(32));
await this.storage.remove(BrandingService.faviconKey(180));
await this.settings.set(INSTANCE_KEYS.favicon, null, admin.id);
await this.record(admin, 'favicon', 'cleared');
return this.view();
}
/** The bytes to serve for a logo variant, or null when none is stored. */
logoBytes(variant: LogoVariant): Promise<Buffer | null> {
return this.storage.read(BrandingService.logoKey(variant));
}
/**
* The favicon bytes: the uploaded one, else the shipped default. The
* `<link rel="icon">` in index.html is static, so this route must always
* answer with an image a 404 there would leave the browser's generic
* icon for good.
*/
async faviconBytes(size: FaviconSize): Promise<{ bytes: Buffer; uploaded: boolean }> {
const stored = await this.storage.read(BrandingService.faviconKey(size));
if (stored) return { bytes: stored, uploaded: true };
const bytes = await readFile(join(__dirname, '../../assets', `default-favicon-${size}.png`));
return { bytes, uploaded: false };
}
/** The pond's own branding, defaulted — one place reads the settings blob. */
async pondBranding(pondId: string): Promise<PondBranding> {
const pond = await this.prisma.pond.findUnique({
where: { id: pondId },
select: { settings: true },
});
if (!pond) throw new NotFoundException();
return pondSettingsSchema.parse(pond.settings ?? {}).branding;
}
private async writePondBranding(
actor: User,
pondId: string,
next: PondBranding,
asset: 'logo' | 'logoDark' | 'favicon',
change: 'set' | 'cleared',
): Promise<PondBranding> {
const pond = await this.prisma.pond.findUniqueOrThrow({
where: { id: pondId },
select: { settings: true },
});
const settings = pondSettingsSchema.parse(pond.settings ?? {});
await this.prisma.pond.update({
where: { id: pondId },
data: { settings: { ...settings, branding: next } as object },
});
await this.audit.record({
action: 'branding.changed',
actorId: actor.id,
targetType: 'pond',
targetId: pondId,
details: { scope: 'pond', pondId, asset, change },
});
return next;
}
/**
* A pond logo, charged to the pond's storage quota (issue #307).
*
* Without the charge, branding would be a way around the quota and
* replacing a logo repeatedly would let a pond admin consume disk with no
* ceiling. Charged BEFORE the write, like attachments, so a race never
* leaves bytes on the volume without a reservation; the bytes a replaced
* asset frees are released first, so re-uploading the same logo is free
* rather than cumulative.
*/
async setPondLogo(
actor: User,
pond: { id: string; ownerId: string },
variant: LogoVariant,
bytes: Buffer,
): Promise<PondBranding> {
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
const current = await this.pondBranding(pond.id);
const previous = variant === 'dark' ? current.logoDark : current.logo;
await this.chargeQuota(pond, bytes.length, previous);
await this.storage.save(BrandingService.pondLogoKey(pond.id, variant), bytes);
const asset = this.assetOf(bytes, size);
return this.writePondBranding(
actor,
pond.id,
variant === 'dark' ? { ...current, logoDark: asset } : { ...current, logo: asset },
variant === 'dark' ? 'logoDark' : 'logo',
'set',
);
}
async clearPondLogo(
actor: User,
pond: { id: string; ownerId: string },
variant: LogoVariant,
): Promise<PondBranding> {
const current = await this.pondBranding(pond.id);
const previous = variant === 'dark' ? current.logoDark : current.logo;
await this.storage.remove(BrandingService.pondLogoKey(pond.id, variant));
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
return this.writePondBranding(
actor,
pond.id,
variant === 'dark' ? { ...current, logoDark: null } : { ...current, logo: null },
variant === 'dark' ? 'logoDark' : 'logo',
'cleared',
);
}
async setPondFavicon(
actor: User,
pond: { id: string; ownerId: string },
files: Record<FaviconSize, Buffer>,
): Promise<PondBranding> {
const checked = Object.entries(files).map(([declared, bytes]) => {
const size = this.assertUsablePng(bytes, 512);
const expected = Number(declared);
if (size.width !== expected || size.height !== expected) {
throw new BadRequestException({ code: 'branding_favicon_not_square' });
}
return { expected: expected as FaviconSize, bytes, size };
});
const current = await this.pondBranding(pond.id);
const total = checked.reduce((sum, entry) => sum + entry.bytes.length, 0);
await this.chargeQuota(pond, total, current.favicon);
for (const entry of checked) {
await this.storage.save(BrandingService.pondFaviconKey(pond.id, entry.expected), entry.bytes);
}
const small = checked.find((entry) => entry.expected === 32)!;
// The pair is charged together, so the stored size is the pair's — that
// is what a later release has to give back.
const asset = { ...this.assetOf(small.bytes, small.size), byteSize: total };
return this.writePondBranding(actor, pond.id, { ...current, favicon: asset }, 'favicon', 'set');
}
async clearPondFavicon(
actor: User,
pond: { id: string; ownerId: string },
): Promise<PondBranding> {
const current = await this.pondBranding(pond.id);
for (const size of FAVICON_SIZES) {
await this.storage.remove(BrandingService.pondFaviconKey(pond.id, size));
}
if (current.favicon?.byteSize) await this.quotas.release(pond.id, current.favicon.byteSize);
return this.writePondBranding(
actor,
pond.id,
{ ...current, favicon: null },
'favicon',
'cleared',
);
}
/** Bytes for a pond asset null when the pond has none at that slot, which
* is what makes the caller fall back to the instance level. */
pondLogoBytes(pondId: string, variant: LogoVariant): Promise<Buffer | null> {
return this.storage.read(BrandingService.pondLogoKey(pondId, variant));
}
pondFaviconBytes(pondId: string, size: FaviconSize): Promise<Buffer | null> {
return this.storage.read(BrandingService.pondFaviconKey(pondId, size));
}
/** Removes every branding file of a pond (issue #307's purge obligation). */
async removePondAssets(pondId: string): Promise<void> {
for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key);
}
private record(
admin: User,
asset: 'logo' | 'logoDark' | 'favicon',
action: 'set' | 'cleared',
): Promise<unknown> {
// `scope` is here from the start so the pond-level change (#307) is the
// same event with a different scope, not a second id in the catalogue.
return this.audit.record({
action: 'branding.changed',
actorId: admin.id,
targetType: 'setting',
targetId: `instance.${asset}`,
details: { scope: 'instance', asset, change: action },
});
}
}

View File

@ -1,256 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { deflateSync } from 'node:zlib';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import {
createTestPrisma,
deletePondsWhere,
grantOwnerAdmin,
hasTestDb,
uniqueSuffix,
} from '../testing/test-db';
import { TrashService } from '../trash/trash.service';
import { UsersService } from '../users/users.service';
import { BrandingService } from './branding.service';
import { BrandingStorageService } from './branding-storage.service';
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf: Buffer): number {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type: string, data: Buffer): Buffer {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
}
/** A real PNG — the api reads the IHDR, so the header has to be genuine. */
function png(size: number): Buffer {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8;
ihdr[9] = 6;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(Buffer.alloc(size * (size * 4 + 1)))),
chunk('IEND', Buffer.alloc(0)),
]);
}
describe.skipIf(!hasTestDb)('pond branding (e2e, issue #307)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let storage: BrandingStorageService;
let brandingDir: string;
const suffix = uniqueSuffix();
const password = 'teichmarke mit eigenem logo 1';
const owner = { username: `pb-${suffix}` };
const member = { username: `pbm-${suffix}` };
let ownerCookie: string;
let memberCookie: string;
let pondId: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-pondbranding-'));
process.env.BRANDING_DIR = brandingDir;
app = await createTestApp();
storage = app.get(BrandingStorageService);
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
// Verification through the endpoint, not `markEmailVerified`: only this
// path creates the personal pond these tests brand.
const verify = async (userId: string): Promise<void> => {
await api()
.post('/api/v1/auth/verify-email')
.send({ token: await tokens.issue(userId, 'EMAIL_VERIFICATION', 600) })
.expect(204);
};
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: `Pond Branding Owner ${suffix}`,
password,
locale: 'en',
});
await verify(ownerUser.id);
const memberUser = await users.createUser({
username: member.username,
email: `${member.username}@example.org`,
displayName: `Pond Branding Member ${suffix}`,
password,
locale: 'en',
});
await verify(memberUser.id);
const login = async (username: string): Promise<string> =>
sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
ownerCookie = await login(owner.username);
memberCookie = await login(member.username);
pondId = (
await prisma.pond.findFirstOrThrow({ where: { ownerId: ownerUser.id, type: 'PERSONAL' } })
).id;
// A reader on the same pond: may see it, may not administer it. Through
// the API, not a raw row — the permission cache would not see the row
// (the documented rule for grants in tests).
await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', ownerCookie)
.send({
subjectType: 'user',
subjectId: memberUser.id,
role: 'reader',
scopeType: 'pond',
effect: 'allow',
})
.expect(201);
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
await rm(brandingDir, { recursive: true, force: true });
delete process.env.BRANDING_DIR;
});
it('stores a pond logo, reports it, and serves it under the pond scope', async () => {
const view = await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', png(64), 'logo.png')
.expect(201);
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
const served = await api()
.get(`/api/v1/branding/logo?variant=light&pond=${pondId}`)
.expect(200);
expect(served.headers['content-type']).toContain('image/png');
// Without the pond scope the instance level answers — 404 here, since no
// instance logo is set. The two levels never leak into each other.
await api().get('/api/v1/branding/logo?variant=light').expect(404);
});
it('charges the pond quota and gives the bytes back when the logo is replaced', async () => {
const usageOf = async (): Promise<number> =>
Number(
(
await prisma.pondUsage.findUnique({
where: { pondId },
select: { storageBytesUsed: true },
})
)?.storageBytesUsed ?? 0,
);
const before = await usageOf();
const big = png(120);
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.attach('file', big, 'logo.png')
.expect(201);
const afterUpload = await usageOf();
expect(afterUpload).toBe(before + big.length);
// Replacing releases the old reservation first — otherwise re-uploading
// the same logo would eat the quota again and again.
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.attach('file', big, 'logo.png')
.expect(201);
expect(await usageOf()).toBe(afterUpload);
await api()
.delete(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
.set('Cookie', ownerCookie)
.expect(200);
expect(await usageOf()).toBe(before);
});
it('refuses SVG at the pond level too — the rules do not relax for a pond admin', async () => {
const res = await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('<svg xmlns="x"><script/></svg>'), 'x.png')
.expect(400);
expect(res.body.code).toBe('branding_svg_rejected');
});
it('lets a member read the pond branding but not change it', async () => {
await api().get(`/api/v1/ponds/${pondId}/branding`).set('Cookie', memberCookie).expect(200);
await api()
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
.set('Cookie', memberCookie)
.attach('file', png(32), 'x.png')
.expect(403);
await api()
.delete(`/api/v1/ponds/${pondId}/branding/favicon`)
.set('Cookie', memberCookie)
.expect(403);
});
it('purging the pond removes its branding files', async () => {
// A pond of its own, so the purge does not take the shared fixture with it.
const ownerRow = await prisma.user.findFirstOrThrow({ where: { username: owner.username } });
const created = await prisma.pond.create({
data: {
name: `Purge Branding ${suffix}`,
slug: `purge-branding-${suffix}`,
type: 'SHARED',
ownerId: ownerRow.id,
},
});
// Raw grant row, before this pond's first permission query — the
// documented exception to "grants through the API".
await grantOwnerAdmin(prisma, created.id, ownerRow.id);
await api()
.post(`/api/v1/ponds/${created.id}/branding/logo?variant=light`)
.set('Cookie', ownerCookie)
.attach('file', png(48), 'logo.png')
.expect(201);
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).not.toBeNull();
await prisma.pond.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
const trash = app.get(TrashService);
await trash.purgePondNow(ownerRow, created.id);
// The purge standard is absolute: after it nothing referencing the pond
// survives — rows OR files.
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).toBeNull();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import {
ConversionJobView, ConversionJobView,
ExportFormat, ExportFormat,
PondFonts, PondFonts,
customFontEntries,
fontSlug, fontSlug,
PageClassification, PageClassification,
classificationMarking, classificationMarking,
@ -21,7 +20,6 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service'; import { FileStorageService } from '../files/file-storage.service';
import { CustomFontsService } from '../fonts/custom-fonts.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PluginsService } from '../plugins/plugins.service'; import { PluginsService } from '../plugins/plugins.service';
@ -55,7 +53,6 @@ export class ExportService {
private readonly plugins: PluginsService, private readonly plugins: PluginsService,
private readonly fallbacks: PluginFallbackRenderer, private readonly fallbacks: PluginFallbackRenderer,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly customFonts: CustomFontsService,
private readonly readTrail: ReadTrailService, private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
@ -336,9 +333,6 @@ export class ExportService {
pondName: page.pond.name, pondName: page.pond.name,
bodyHtml, bodyHtml,
fonts, fonts,
// Both the rules and the stack need the uploaded families: embedding a
// face the stack never names would render the system font (issue #304).
customFonts: customFontEntries(await this.customFonts.list()),
fontFaceCss: await this.fontFaceCss(fonts), fontFaceCss: await this.fontFaceCss(fonts),
// Styled sections keep their look in the PDF (#75); a pond without // Styled sections keep their look in the PDF (#75); a pond without
// active style plugins contributes an empty string. // active style plugins contributes an empty string.
@ -377,18 +371,12 @@ export class ExportService {
}); });
} }
/** Base64 `@font-face` rules for the pond's three fonts. Catalog families /** Base64 `@font-face` rules for the pond's three fonts, read from the
* come from the directory baked into the image (ADR 0016); operator-uploaded * catalog baked into the image (ADR 0016). A font file that is absent (a
* ones from `CUSTOM_FONTS_DIR` (issue #303) same on-disk layout, so only * native dev run without `FONTS_DIR` populated) is skipped the render falls
* the base directory differs. A font file that is absent (a native dev run * back to the system stack rather than failing. */
* without `FONTS_DIR` populated, or a family deleted between the settings
* write and the export) is skipped: the render falls back to the system
* stack rather than failing. */
private async fontFaceCss(fonts: PondFonts): Promise<string> { private async fontFaceCss(fonts: PondFonts): Promise<string> {
const slots = [fonts.heading, fonts.body, fonts.mono]; const slots = [fonts.heading, fonts.body, fonts.mono];
const customSlugs = new Map(
(await this.customFonts.list()).map((font) => [font.family, font.slug]),
);
// Dedup identical family+weight so a doc that repeats a font embeds it once. // Dedup identical family+weight so a doc that repeats a font embeds it once.
const seen = new Set<string>(); const seen = new Set<string>();
const faces: string[] = []; const faces: string[] = [];
@ -396,10 +384,8 @@ export class ExportService {
const key = `${slot.family}:${slot.weight}`; const key = `${slot.family}:${slot.weight}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const customSlug = customSlugs.get(slot.family); const slug = fontSlug(slot.family);
const slug = customSlug ?? fontSlug(slot.family); const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
const baseDir = customSlug ? this.config.env.CUSTOM_FONTS_DIR : this.config.env.FONTS_DIR;
const file = join(baseDir, slug, `${slug}-${slot.weight}.woff2`);
try { try {
const bytes = await readFile(file); const bytes = await readFile(file);
faces.push( faces.push(
@ -407,7 +393,7 @@ export class ExportService {
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`, ` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
); );
} catch { } catch {
this.logger.warn({ font: key }, 'pdf export: font file missing, using fallback'); this.logger.warn({ font: key }, 'pdf export: catalog font file missing, using fallback');
} }
} }
return faces.join('\n'); return faces.join('\n');

View File

@ -2,7 +2,6 @@ import { Module, OnModuleInit } from '@nestjs/common';
import { CommonModule } from '../common/common.module'; import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
import { FontsModule } from '../fonts/fonts.module';
import { LabelsModule } from '../labels/labels.module'; import { LabelsModule } from '../labels/labels.module';
import { PagesModule } from '../pages/pages.module'; import { PagesModule } from '../pages/pages.module';
import { PluginsModule } from '../plugins/plugins.module'; import { PluginsModule } from '../plugins/plugins.module';
@ -15,7 +14,7 @@ import { ConversionWorker } from './conversion-worker.service';
import { DATA_EXPORT_PROCESSOR } from './data-export.constants'; import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
import { DataExportController } from './data-export.controller'; import { DataExportController } from './data-export.controller';
import { DataExportService } from './data-export.service'; import { DataExportService } from './data-export.service';
import { ExportController, PondArchiveAdminController } from './export.controller'; import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer'; import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
import { IMPORT_PROCESSOR } from './import.constants'; import { IMPORT_PROCESSOR } from './import.constants';
@ -23,7 +22,6 @@ import { ImportController } from './import.controller';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
import { JobsController } from './jobs.controller'; import { JobsController } from './jobs.controller';
import { PandocConverter, PandocServerConverter } from './pandoc.converter'; import { PandocConverter, PandocServerConverter } from './pandoc.converter';
import { PondArchiveService } from './pond-archive.service';
/** How often expired data-export payloads are purged (#68). Hourly is ample: /** How often expired data-export payloads are purged (#68). Hourly is ample:
* the link's own expiry check already stops downloads the moment it lapses. */ * the link's own expiry check already stops downloads the moment it lapses. */
@ -42,26 +40,18 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
imports: [ imports: [
CommonModule, CommonModule,
FilesModule, FilesModule,
FontsModule,
LabelsModule, LabelsModule,
PagesModule, PagesModule,
PluginsModule, PluginsModule,
SchedulerModule, SchedulerModule,
SettingsModule, SettingsModule,
], ],
controllers: [ controllers: [JobsController, ImportController, ExportController, DataExportController],
JobsController,
ImportController,
ExportController,
PondArchiveAdminController,
DataExportController,
],
providers: [ providers: [
ConversionJobService, ConversionJobService,
ConversionWorker, ConversionWorker,
ImportService, ImportService,
ExportService, ExportService,
PondArchiveService,
DataExportService, DataExportService,
// The worker resolves the import pipeline through this token (never the // The worker resolves the import pipeline through this token (never the
// class), so its file does not import the import service's (avoids a cycle). // class), so its file does not import the import service's (avoids a cycle).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,56 +26,48 @@ 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( it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
'10.000 adversarial reorders never collide or overflow (rebalance verified)', // Start with five pages in a fixed order.
{ timeout: 30_000 }, let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
() => { 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);
}
} }
// The adversarial pattern must have forced at least one rebalance. // Invariants after every move: keys unique, bounded, and consistent with
expect(rebalances).toBeGreaterThan(0); // the intended array order.
}, const keys = order.map((p) => p.key);
); expect(new Set(keys).size).toBe(keys.length);
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
for (let j = 1; j < keys.length; j += 1) {
expect(keys[j - 1]! < keys[j]!).toBe(true);
}
}
// The adversarial pattern must have forced at least one rebalance.
expect(rebalances).toBeGreaterThan(0);
});
}); });

View File

@ -1,10 +1,5 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared';
DEFAULT_ATTACHMENT_EXTENSIONS,
VS_NFD_PROFILE,
brandingAssetSchema,
isVsNfdCompliant,
} from '@dorfteich/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod'; import { z } from 'zod';
@ -21,21 +16,8 @@ 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_DIR and travel in the restore set; `hash` goes into the serving
// URL so a replaced asset is picked up without cache trouble. Null = not
// uploaded: the instance name renders as text, the favicon falls back to
// the shipped default. `logoDark` is optional by design — without it the
// LIGHT logo is used in both themes, because showing the operator's own
// asset unchanged beats substituting one they did not choose (#307).
'instance.logo': brandingAssetSchema.nullable().default(null),
'instance.logoDark': brandingAssetSchema.nullable().default(null),
'instance.favicon': brandingAssetSchema.nullable().default(null),
// Instance-default quotas (ADR 0011); per-user/per-pond overrides live // Instance-default quotas (ADR 0011); per-user/per-pond overrides live
// in quota_overrides and win over these (QuotaService, issue #22). // in quota_overrides and win over these (QuotaService, issue #22).
'quota.editorsPerPond': z.number().int().min(0).default(5), 'quota.editorsPerPond': z.number().int().min(0).default(5),

View File

@ -317,42 +317,6 @@ 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,7 +15,6 @@ 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';
@ -71,23 +70,14 @@ 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. Translated // would be much harder to diagnose than a startup error.
// into operator terms first: the raw ZodError names schema fields and const input = setupAdminInputSchema.parse({
// 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);
@ -233,27 +223,3 @@ 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,6 +1,5 @@
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';
@ -19,7 +18,6 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
@Module({ @Module({
imports: [ imports: [
BrandingModule,
CommonModule, CommonModule,
PondsModule, PondsModule,
QuotasModule, QuotasModule,

View File

@ -4,7 +4,6 @@ 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';
@ -33,7 +32,6 @@ 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,
@ -185,9 +183,6 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,55 +0,0 @@
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,43 +44,3 @@ 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,155 +60,6 @@ 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

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

View File

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

View File

@ -108,102 +108,6 @@ 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,9 +28,8 @@ 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).
// 10 seit #332 (Einladungen). await expect(links).toHaveCount(9);
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="registrationMode"]'), has: page.locator('select[name="auth.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="registrationMode"]'); const regSelect = page.locator('select[name="auth.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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +0,0 @@
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,8 +127,6 @@ 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(),
@ -272,10 +270,7 @@ 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')}
@ -296,23 +291,7 @@ 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,8 +1,6 @@
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';
@ -65,6 +63,4 @@ export const documentExtensions: AnyExtension[] = [
Strikethrough, Strikethrough,
LinkMark, LinkMark,
MarkdownClipboard, MarkdownClipboard,
MarkdownTableInput,
GapCursor,
]; ];

View File

@ -1,20 +0,0 @@
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,7 +1,6 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { htmlIsStyledPlainText, looksLikeMarkdown } from './markdown-clipboard'; import { looksLikeMarkdown } from './markdown-clipboard';
describe('looksLikeMarkdown (issue #30)', () => { describe('looksLikeMarkdown (issue #30)', () => {
it('recognizes a heading + list document', () => { it('recognizes a heading + list document', () => {
@ -32,21 +31,3 @@ 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,25 +26,6 @@ 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
@ -84,11 +65,8 @@ 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() !== '' && !htmlIsStyledPlainText(html)) return false; if (html && html.trim() !== '') 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

@ -1,68 +0,0 @@
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,6 +1,4 @@
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,
@ -10,9 +8,6 @@ import {
deleteColumn, deleteColumn,
deleteRow, deleteRow,
deleteTable, deleteTable,
goToNextCell,
mergeCells,
splitCell,
tableEditing, tableEditing,
toggleHeaderRow, toggleHeaderRow,
} from 'prosemirror-tables'; } from 'prosemirror-tables';
@ -39,10 +34,6 @@ 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;
}; };
} }
@ -74,37 +65,6 @@ 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:
@ -141,22 +101,6 @@ 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

@ -6,7 +6,6 @@ import { Link } from 'react-router-dom';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { apiPatch } from '../lib/api'; import { apiPatch } from '../lib/api';
import { useCustomFontEntries } from './use-custom-fonts';
const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono']; const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono'];
const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace']; const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace'];
@ -30,12 +29,9 @@ export function AppearanceManager({
const [draft, setDraft] = useState<PondFonts>(fonts); const [draft, setDraft] = useState<PondFonts>(fonts);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
// The operator's own families (issue #304) — offered next to the catalog,
// in their own labelled group, and resolvable by `fontEntry`/`fontStack`.
const custom = useCustomFontEntries();
function chooseFamily(slot: keyof PondFonts, family: string): void { function chooseFamily(slot: keyof PondFonts, family: string): void {
const weights = fontEntry(family, custom)?.weights ?? []; const weights = fontEntry(family)?.weights ?? [];
// Keep the current weight if the new family offers it, else its first. // Keep the current weight if the new family offers it, else its first.
const weight = weights.includes(draft[slot].weight) ? draft[slot].weight : (weights[0] ?? 400); const weight = weights.includes(draft[slot].weight) ? draft[slot].weight : (weights[0] ?? 400);
setDraft((prev) => ({ ...prev, [slot]: { family, weight } })); setDraft((prev) => ({ ...prev, [slot]: { family, weight } }));
@ -66,7 +62,7 @@ export function AppearanceManager({
<FormError error={error} /> <FormError error={error} />
{SLOTS.map((slot) => { {SLOTS.map((slot) => {
const value = draft[slot]; const value = draft[slot];
const weights = fontEntry(value.family, custom)?.weights ?? [value.weight]; const weights = fontEntry(value.family)?.weights ?? [value.weight];
return ( return (
<div className={`appearance__slot appearance__slot--${slot}`} key={slot}> <div className={`appearance__slot appearance__slot--${slot}`} key={slot}>
<span className="appearance__slot-label">{t(`slots.${slot}`)}</span> <span className="appearance__slot-label">{t(`slots.${slot}`)}</span>
@ -76,18 +72,8 @@ export function AppearanceManager({
value={value.family} value={value.family}
onChange={(event) => chooseFamily(slot, event.target.value)} onChange={(event) => chooseFamily(slot, event.target.value)}
> >
{/* Bundled and uploaded families are told apart by the group
they sit in, not by a badge (issue #304): the grouping is
then part of the control's semantics a screen reader
announces it on entering, and the native mobile select
keeps it. Within each source the category grouping of the
catalog is preserved, so a custom family appears under its
own category exactly like a bundled one. */}
{CATEGORIES.map((category) => ( {CATEGORIES.map((category) => (
<optgroup <optgroup key={category} label={t(`category.${category}`)}>
key={category}
label={t('group.bundled', { category: t(`category.${category}`) })}
>
{FONT_CATALOG.filter((font) => font.category === category).map((font) => ( {FONT_CATALOG.filter((font) => font.category === category).map((font) => (
<option key={font.family} value={font.family}> <option key={font.family} value={font.family}>
{font.family} {font.family}
@ -95,22 +81,6 @@ export function AppearanceManager({
))} ))}
</optgroup> </optgroup>
))} ))}
{CATEGORIES.filter((category) =>
custom.some((font) => font.category === category),
).map((category) => (
<optgroup
key={`custom-${category}`}
label={t('group.custom', { category: t(`category.${category}`) })}
>
{custom
.filter((font) => font.category === category)
.map((font) => (
<option key={font.family} value={font.family}>
{font.family}
</option>
))}
</optgroup>
))}
</select> </select>
</label> </label>
<label className="appearance__field"> <label className="appearance__field">
@ -128,7 +98,7 @@ export function AppearanceManager({
</label> </label>
<p <p
className="appearance__preview" className="appearance__preview"
style={{ fontFamily: fontStack(value.family, custom), fontWeight: value.weight }} style={{ fontFamily: fontStack(value.family), fontWeight: value.weight }}
> >
{t('preview')} {t('preview')}
</p> </p>

View File

@ -1,40 +0,0 @@
import { useCustomFonts } from './use-custom-fonts';
/**
* A family name is free text the operator typed. It ends up inside a CSS
* string, so quote and backslash are escaped and everything that could end
* the declaration, the rule or the `<style>` element is dropped. Site Admins
* are trusted with far more than this, but a rule that silently breaks the
* whole stylesheet on an apostrophe would be a bug either way.
*/
function cssFamily(family: string): string {
return family.replace(/[\\'<>{};\r\n]/g, '');
}
/**
* `@font-face` rules for the operator-uploaded families (issue #304).
*
* Catalog families are declared in the generated `public/fonts/catalog.css`,
* which the build writes and `index.html` links. Uploaded ones only exist at
* runtime, so their rules are injected here same shape, same `swap`
* behaviour, bytes from the api's public font route.
*
* Without this the pickers would offer families the browser cannot resolve:
* `fontStack` names them, nothing declares them, and the text renders in the
* system fallback.
*/
export function CustomFontFaces(): React.JSX.Element | null {
const fonts = useCustomFonts();
if (fonts.length === 0) return null;
const css = fonts
.flatMap((font) =>
font.weights.map(
(weight) =>
`@font-face { font-family: '${cssFamily(font.family)}'; font-style: normal;` +
` font-weight: ${weight}; font-display: swap;` +
` src: url('/api/v1/fonts/custom/${font.slug}/${font.slug}-${weight}.woff2') format('woff2'); }`,
),
)
.join('\n');
return <style data-custom-fonts="">{css}</style>;
}

View File

@ -1,28 +1,19 @@
import { FontCatalogEntry, PondSettings, fontStack } from '@dorfteich/shared'; import { PondSettings, fontStack } from '@dorfteich/shared';
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { useCustomFontEntries } from './use-custom-fonts';
/** The CSS custom properties a pond's font choice sets on its content root /** The CSS custom properties a pond's font choice sets on its content root
* (ADR 0016). The content CSS reads these; a family that fails to load falls * (ADR 0016). The content CSS reads these; a family that fails to load falls
* back to the category's system stack (`fontStack`). * back to the category's system stack (`fontStack`). */
* export function pondFontVariables(fonts: PondSettings['fonts']): CSSProperties {
* `custom` carries the operator-uploaded families (issue #304): `fontStack`
* cannot tell an uploaded family from a deleted one, so without them a pond
* set to its operator's own font would render in the system fallback. */
export function pondFontVariables(
fonts: PondSettings['fonts'],
custom: readonly FontCatalogEntry[] = [],
): CSSProperties {
// Overrides the same custom properties the app-wide CSS already reads // Overrides the same custom properties the app-wide CSS already reads
// (tokens.css), so headings, body, and code inside the scope re-resolve to // (tokens.css), so headings, body, and code inside the scope re-resolve to
// the pond's fonts without any per-element rules. // the pond's fonts without any per-element rules.
return { return {
'--font-heading': fontStack(fonts.heading.family, custom), '--font-heading': fontStack(fonts.heading.family),
'--font-weight-heading': String(fonts.heading.weight), '--font-weight-heading': String(fonts.heading.weight),
'--font-body': fontStack(fonts.body.family, custom), '--font-body': fontStack(fonts.body.family),
'--font-weight-body': String(fonts.body.weight), '--font-weight-body': String(fonts.body.weight),
'--font-mono': fontStack(fonts.mono.family, custom), '--font-mono': fontStack(fonts.mono.family),
'--font-weight-mono': String(fonts.mono.weight), '--font-weight-mono': String(fonts.mono.weight),
} as CSSProperties; } as CSSProperties;
} }
@ -41,9 +32,8 @@ export function PondFontScope({
fonts: PondSettings['fonts']; fonts: PondSettings['fonts'];
children: ReactNode; children: ReactNode;
}): React.JSX.Element { }): React.JSX.Element {
const custom = useCustomFontEntries();
return ( return (
<div className="pond-font-scope" style={pondFontVariables(fonts, custom)}> <div className="pond-font-scope" style={pondFontVariables(fonts)}>
{children} {children}
</div> </div>
); );

View File

@ -1,39 +0,0 @@
import { CustomFontView, FontCatalogEntry, customFontEntries } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api';
export const CUSTOM_FONTS_KEY = ['fonts', 'custom'];
/**
* The instance's operator-uploaded font families (issues #303/#304).
*
* Every font-aware surface needs them: the pickers offer them, the licence
* page attributes them, `fontStack` needs them to NAME the family instead of
* falling through to the system stack, and `CustomFontFaces` turns them into
* `@font-face` rules. One query key, so they are fetched once per session and
* shared.
*
* Only fetched while signed in the endpoint requires a session, and asking
* on the login screen would produce a 401 for nothing.
*/
export function useCustomFonts(): CustomFontView[] {
const { user } = useAuth();
const query = useQuery({
queryKey: CUSTOM_FONTS_KEY,
queryFn: () => apiGet<CustomFontView[]>('/fonts/custom'),
enabled: Boolean(user),
// Uploading a font is a rare Site-Admin act; the manager invalidates the
// key itself, so a long life here costs nothing.
staleTime: 5 * 60 * 1000,
});
return query.data ?? [];
}
/** The same families in the shape `fontEntry`/`fontStack` accept. */
export function useCustomFontEntries(): FontCatalogEntry[] {
const fonts = useCustomFonts();
return useMemo(() => customFontEntries(fonts), [fonts]);
}

View File

@ -1,6 +1,5 @@
import deAccess from '@dorfteich/shared/i18n/de/access.json'; import deAccess from '@dorfteich/shared/i18n/de/access.json';
import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deAuth from '@dorfteich/shared/i18n/de/auth.json';
import deBranding from '@dorfteich/shared/i18n/de/branding.json';
import deComments from '@dorfteich/shared/i18n/de/comments.json'; import deComments from '@dorfteich/shared/i18n/de/comments.json';
import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json';
import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.json';
@ -10,7 +9,6 @@ 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';
@ -30,7 +28,6 @@ import deWatches from '@dorfteich/shared/i18n/de/watches.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAccess from '@dorfteich/shared/i18n/en/access.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json';
import enAuth from '@dorfteich/shared/i18n/en/auth.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json';
import enBranding from '@dorfteich/shared/i18n/en/branding.json';
import enComments from '@dorfteich/shared/i18n/en/comments.json'; import enComments from '@dorfteich/shared/i18n/en/comments.json';
import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json';
import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json';
@ -40,7 +37,6 @@ 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';
@ -83,11 +79,9 @@ void i18n
editor: enEditor, editor: enEditor,
export: enExport, export: enExport,
files: enFiles, files: enFiles,
branding: enBranding,
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,
@ -115,11 +109,9 @@ void i18n
editor: deEditor, editor: deEditor,
export: deExport, export: deExport,
files: deFiles, files: deFiles,
branding: deBranding,
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

@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { CustomFontFaces } from '../fonts/CustomFontFaces';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
import { Footer } from './Footer'; import { Footer } from './Footer';
import { PageActionsSlotContext } from './page-actions'; import { PageActionsSlotContext } from './page-actions';
@ -56,9 +55,6 @@ export function AppLayout(): React.JSX.Element {
<SidebarChromeContext.Provider value={setForcedHidden}> <SidebarChromeContext.Provider value={setForcedHidden}>
<PageActionsSlotContext.Provider value={actionsSlot}> <PageActionsSlotContext.Provider value={actionsSlot}>
<div className="app"> <div className="app">
{/* Declares the operator-uploaded families (#304) for every screen
below pickers, previews, editor and read view alike. */}
<CustomFontFaces />
{/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */} {/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */}
<a className="skip-link" href="#main"> <a className="skip-link" href="#main">
{t('layout.skipToContent')} {t('layout.skipToContent')}

View File

@ -29,7 +29,6 @@ import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl'; import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips'; import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels'; import { usePondLabels } from '../labels/use-pond-labels';
import { BrandLogo } from '../branding/BrandLogo';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
import { NewPageForm } from './NewPageForm'; import { NewPageForm } from './NewPageForm';
@ -72,11 +71,6 @@ export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element
aria-label={t('layout.sidebar.label')} aria-label={t('layout.sidebar.label')}
> >
{resizer} {resizer}
{/* The instance identity sits ABOVE the pond section, not inside it:
the sidebar has no pond header outside a pond, and the logo is the
link home it must not disappear on /admin or the start page
(issue #306). The pond name below stays the heading. */}
<BrandLogo />
{!pond.data ? ( {!pond.data ? (
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p> <p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
) : ( ) : (

View File

@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { useBranding } from '../branding/use-branding';
import { IconButton, IconLink } from '../components/IconButton'; import { IconButton, IconLink } from '../components/IconButton';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { isTypingTarget } from '../lib/keyboard'; import { isTypingTarget } from '../lib/keyboard';
@ -68,9 +67,6 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
enabled: Boolean(user && pondSlug), enabled: Boolean(user && pondSlug),
}); });
const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId); const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
// Instance identity (issue #306) — shared query, also read by the sidebar
// logo and reachable without a session (the login screen carries it).
const branding = useBranding();
async function handleLogout(): Promise<void> { async function handleLogout(): Promise<void> {
setMenuOpen(false); setMenuOpen(false);
@ -105,12 +101,8 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
> >
<Menu aria-hidden /> <Menu aria-hidden />
</IconButton> </IconButton>
{/* The operator's instance name, not the product name (issue #306):
an operator who uploaded their own logo does not expect "Dorfteich"
to stay in the chrome. `instance.name` defaults to "Dorfteich", so
an untouched instance looks exactly as before. */}
<Link to="/" className="topbar__brand"> <Link to="/" className="topbar__brand">
{branding?.instanceName ?? 'Dorfteich'} Dorfteich
</Link> </Link>
{user && <PondSwitcher />} {user && <PondSwitcher />}
{isPondOwner && pondSlug && ( {isPondOwner && pondSlug && (

View File

@ -90,27 +90,6 @@ export async function apiUploadFile<T>(
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
/** Multipart upload of a whole form (issue #304's font upload: several files
* plus metadata in one request). `apiUploadFile` above covers the single-file
* case; this one takes the `FormData` the caller assembled. */
export async function apiPostForm<T>(path: string, form: FormData): Promise<T> {
let response: Response;
try {
response = await fetch(`/api/v1${path}`, { method: 'POST', body: form });
} catch {
throw new ApiError(0, { code: 'network', message: 'network error' });
}
if (!response.ok) {
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
throw new ApiError(
response.status,
parsed ?? { code: `http_${response.status}`, message: response.statusText },
);
}
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export function fetchHealth(): Promise<HealthResponse> { export function fetchHealth(): Promise<HealthResponse> {
return apiGet<HealthResponse>('/healthz'); return apiGet<HealthResponse>('/healthz');
} }

View File

@ -1,33 +1,22 @@
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 instance name ("Page — Pond — My Wiki"). Empty/undefined * parts with the app name ("Page — Pond — Dorfteich"). 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 instance name on unmount. * Falls back to the bare app 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 appName = useBranding()?.instanceName.trim() || APP_NAME; const joined = [...parts.filter(Boolean), APP_NAME].join(' — ');
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 = appName; document.title = APP_NAME;
}, },
[appName], [],
); );
} }

View File

@ -5,19 +5,10 @@ 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 { CustomFontManager } from './CustomFontManager';
import { PluginManager } from './PluginManager'; import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager'; import { QuotaManager } from './QuotaManager';
import { UserManager } from './UserManager'; import { UserManager } from './UserManager';
@ -25,7 +16,6 @@ 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;
@ -59,23 +49,15 @@ export function AdminSettingsPage(): React.JSX.Element {
queryFn: () => apiGet<InstanceSettings>('/admin/settings'), queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
}); });
// Dot-free field names with an explicit mapping to the dotted settings const form = useForm<InstanceSettings>({ values: settings.data });
// 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', toSettingsPatch(input)); await apiPatch('/admin/settings', 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);
@ -95,10 +77,6 @@ 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'],
@ -111,53 +89,37 @@ 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('instanceName')} /> <input type="text" {...form.register('instance.name')} />
</Field> </Field>
<Field label={t('settings:admin.defaultLocale')}> <Field label={t('settings:admin.defaultLocale')}>
<select {...form.register('defaultLocale')}> <select {...form.register('instance.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('auth.registrationMode', form.watch('registrationMode'))} marking={vsNfd.markingFor(
'auth.registrationMode',
form.watch('auth.registrationMode'),
)}
> >
<select {...form.register('registrationMode')}> <select {...form.register('auth.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('newPageClassification'), form.watch('classification.newPageDefault'),
)} )}
> >
<select {...form.register('newPageClassification')}> <select {...form.register('classification.newPageDefault')}>
{!vsNfd.hides( {!vsNfd.hides(
'classification.newPageDefault', 'classification.newPageDefault',
settings.data['classification.newPageDefault'], settings.data['classification.newPageDefault'],
@ -172,9 +134,12 @@ 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('classification.uploadPolicy', form.watch('uploadPolicy'))} marking={vsNfd.markingFor(
'classification.uploadPolicy',
form.watch('classification.uploadPolicy'),
)}
> >
<select {...form.register('uploadPolicy')}> <select {...form.register('classification.uploadPolicy')}>
{!vsNfd.hides( {!vsNfd.hides(
'classification.uploadPolicy', 'classification.uploadPolicy',
settings.data['classification.uploadPolicy'], settings.data['classification.uploadPolicy'],
@ -193,18 +158,15 @@ export function AdminSettingsPage(): React.JSX.Element {
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
{( {(
[ [
'quotaEditorsPerPond', 'quota.editorsPerPond',
'quotaReadersPerPond', 'quota.readersPerPond',
'quotaAdditionalPonds', 'quota.additionalPonds',
'quotaStorageBytes', 'quota.storageBytes',
'quotaMaxFileBytes', 'quota.maxFileBytes',
] as const ] as const
).map((field) => ( ).map((key) => (
<Field <Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
key={field} <input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
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}>
@ -218,8 +180,6 @@ export function AdminSettingsPage(): React.JSX.Element {
<LandingSettingsForm settings={settings.data} /> <LandingSettingsForm settings={settings.data} />
<LegalSettingsForm settings={settings.data} /> <LegalSettingsForm settings={settings.data} />
<BrandingManager />
<CustomFontManager />
<PluginManager /> <PluginManager />
<QuotaManager /> <QuotaManager />
<UserManager /> <UserManager />
@ -497,10 +457,7 @@ function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.
} }
return ( return (
// Named class so the e2e can scope its success-message assertion to this <section className="settings-section">
// form: /admin has more than one live region since #304 (upload progress),
// and a page-wide getByRole('status') became ambiguous.
<section className="settings-section legal-settings">
<h2>{t('admin.title')}</h2> <h2>{t('admin.title')}</h2>
<p className="field__hint">{t('admin.hint')}</p> <p className="field__hint">{t('admin.hint')}</p>
<form onSubmit={(event) => void onSubmit(event)} noValidate> <form onSubmit={(event) => void onSubmit(event)} noValidate>

View File

@ -1,228 +0,0 @@
import { BrandingView, LogoVariant, MAX_LOGO_EDGE } from '@dorfteich/shared';
import { useMutation, useQuery } 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, useInvalidateBranding } from '../branding/use-branding';
import { FormError, FormSuccess } from '../components/forms';
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
/** The favicon is uploaded as the pair the browser rendered see the api's
* reasoning: it cannot resize, and one source must not become two icons. */
const FAVICON_SIZES = [32, 180] as const;
/**
* Site-Admin branding management (issue #306): the instance logo (light and
* an optional dark variant) and the favicon.
*
* The images are prepared in the browser see `branding/crop.ts` for why the
* api never decodes one.
*/
export function BrandingManager(): React.JSX.Element {
const { t } = useTranslation('branding');
const invalidate = useInvalidateBranding();
const branding = useQuery({
queryKey: ['admin', 'branding'],
queryFn: () => apiGet<BrandingView>('/branding'),
});
const view = branding.data;
const refresh = async (): Promise<void> => {
await branding.refetch();
await invalidate();
};
return (
<section className="settings-section branding">
<h2>{t('admin.title')}</h2>
<p>{t('admin.intro')}</p>
{/* An operator may not expect their logo to be readable by anyone who
opens the login page so say it, rather than let them find out. */}
<p>{t('admin.publicNote')}</p>
<LogoSection variant="light" asset={view?.logo ?? null} onChanged={refresh} />
<LogoSection variant="dark" asset={view?.logoDark ?? null} onChanged={refresh} />
{view?.logo && !view.logoDark && (
// Advisory, never blocking (issue #306): it names the consequence and
// the operator may decide their logo works on both surfaces.
<p className="branding__warning" role="note">
<span aria-hidden="true"> </span>
{t('admin.darkMissing')}
</p>
)}
<FaviconSection present={Boolean(view?.favicon)} onChanged={refresh} />
</section>
);
}
function LogoSection({
variant,
asset,
onChanged,
}: {
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<BrandingView>(`/admin/branding/logo?variant=${variant}`, form);
},
onSuccess: async () => {
setDone(true);
await onChanged();
},
});
const remove = useMutation({
mutationFn: () => apiDelete(`/admin/branding/logo?variant=${variant}`),
onSuccess: async () => {
setDone(false);
await onChanged();
},
});
return (
<div className="branding__slot" data-logo-variant={variant}>
<h3>{t(`admin.logo.${variant}`)}</h3>
<p>{t(`admin.logo.${variant}Hint`)}</p>
<FormError error={upload.error ?? remove.error} />
<FormSuccess message={done ? t('admin.logo.saved') : null} />
{asset ? (
<div className="branding__current">
<img
src={logoUrl(variant, asset.hash)}
alt={t('admin.logo.currentAlt')}
className={`branding__preview branding__preview--${variant}`}
/>
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
<button
type="button"
className="button button--outline"
onClick={() => remove.mutate()}
disabled={remove.isPending}
>
{t('admin.logo.remove')}
</button>
</div>
) : (
<p>{t('admin.logo.none')}</p>
)}
<CropField
idPrefix={`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 FaviconSection({
present,
onChanged,
}: {
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();
// One source, both sizes, rendered from the same crop — the tab icon
// and the home-screen icon can then never disagree.
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<BrandingView>('/admin/branding/favicon', form);
},
onSuccess: async () => {
setDone(true);
await onChanged();
},
});
const remove = useMutation({
mutationFn: () => apiDelete('/admin/branding/favicon'),
onSuccess: async () => {
setDone(false);
await onChanged();
},
});
return (
<div className="branding__slot" data-branding-slot="favicon">
<h3>{t('admin.favicon.title')}</h3>
<p>{t('admin.favicon.hint')}</p>
<FormError error={upload.error ?? remove.error} />
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
<p>{present ? t('admin.favicon.present') : t('admin.favicon.default')}</p>
{present && (
<button
type="button"
className="button button--outline"
onClick={() => remove.mutate()}
disabled={remove.isPending}
>
{t('admin.favicon.remove')}
</button>
)}
<CropField
idPrefix="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

@ -1,425 +0,0 @@
import {
CustomFontView,
FONT_CATEGORIES,
FONT_WEIGHTS,
FontCategory,
MAX_FONT_FILE_BYTES,
fontStack,
customFontEntries,
} from '@dorfteich/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Field, FormError, FormSuccess } from '../components/forms';
import { CUSTOM_FONTS_KEY } from '../fonts/use-custom-fonts';
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
/** One weight the operator is about to upload. `woff` is optional the api
* rejects a WOFF without its WOFF2 because the PDF path reads WOFF2 only. */
interface WeightDraft {
weight: number;
woff2: File | null;
woff: File | null;
}
const MAX_MIB = Math.round(MAX_FONT_FILE_BYTES / (1024 * 1024));
/** Preselects the weight the operator most likely wants next: Regular for the
* first row, then upwards from the heaviest one already chosen (Regular
* Bold is the usual second file), and only after that whatever is left. The
* selection stays free this is a starting point, not a rule. */
function emptyDraft(used: number[]): WeightDraft {
const free = FONT_WEIGHTS.filter((weight) => !used.includes(weight));
const heaviest = Math.max(0, ...used);
const next = free.includes(400) ? 400 : (free.find((w) => w > heaviest) ?? free[0] ?? 400);
return { weight: next, woff2: null, woff: null };
}
function appendWeight(form: FormData, draft: WeightDraft): void {
if (draft.woff2) form.append(`woff2-${draft.weight}`, draft.woff2);
if (draft.woff) form.append(`woff-${draft.weight}`, draft.woff);
}
/**
* Site-Admin management of operator-uploaded font families (issue #304,
* backend #303, ADR 0016 §#303).
*
* The whole cycle lives here: upload a family with its licence and one file
* per weight, see what is installed, add a weight later, and delete a family
* after being told how many ponds still use it.
*
* Deleting is never blocked (the api's decision): an unknown family falls back
* to the system stack, so the affected ponds change appearance rather than
* break. The confirmation therefore names the consequence in text instead of
* refusing.
*/
export function CustomFontManager(): React.JSX.Element {
const { t } = useTranslation('font');
const queryClient = useQueryClient();
const fonts = useQuery({
queryKey: ['admin', 'fonts'],
queryFn: () => apiGet<CustomFontView[]>('/admin/fonts'),
});
const installed = fonts.data ?? [];
const entries = customFontEntries(installed);
const invalidate = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'fonts'] });
// The pickers, the licence page and the injected `@font-face` rules read
// the non-admin list — without this they keep the pre-upload state.
await queryClient.invalidateQueries({ queryKey: CUSTOM_FONTS_KEY });
};
return (
<section className="settings-section custom-fonts">
<h2>{t('admin.title')}</h2>
<p>{t('admin.intro')}</p>
<UploadForm onUploaded={invalidate} />
<h3>{t('admin.installed')}</h3>
{installed.length === 0 ? (
<p>{t('admin.empty')}</p>
) : (
<ul className="custom-fonts__list">
{installed.map((font) => (
<FontRow key={font.id} font={font} entries={entries} onChanged={invalidate} />
))}
</ul>
)}
</section>
);
}
function UploadForm({ onUploaded }: { onUploaded: () => Promise<void> }): React.JSX.Element {
const { t } = useTranslation('font');
const [family, setFamily] = useState('');
const [category, setCategory] = useState<FontCategory>('sans-serif');
const [licence, setLicence] = useState('');
const [licenceUrl, setLicenceUrl] = useState('');
const [weights, setWeights] = useState<WeightDraft[]>([emptyDraft([])]);
const [done, setDone] = useState(false);
const upload = useMutation({
mutationFn: async () => {
const form = new FormData();
form.append('family', family.trim());
form.append('category', category);
form.append('licence', licence.trim());
if (licenceUrl.trim()) form.append('licenceUrl', licenceUrl.trim());
for (const draft of weights) appendWeight(form, draft);
return apiPostForm<CustomFontView>('/admin/fonts', form);
},
onSuccess: async () => {
setFamily('');
setLicence('');
setLicenceUrl('');
setWeights([emptyDraft([])]);
setDone(true);
await onUploaded();
},
});
const ready = family.trim() !== '' && licence.trim() !== '' && weights.some((w) => w.woff2);
return (
<form
className="custom-fonts__upload"
noValidate
onSubmit={(event) => {
event.preventDefault();
setDone(false);
upload.mutate();
}}
>
<h3>{t('admin.upload.title')}</h3>
<FormError error={upload.error} />
{/* role="status", so completion reaches assistive technology instead of
being a colour change in the corner. */}
<FormSuccess message={done ? t('admin.upload.done') : null} />
<Field label={t('admin.upload.family')} hint={t('admin.upload.familyHint')}>
<input
type="text"
value={family}
maxLength={80}
autoComplete="off"
onChange={(event) => setFamily(event.target.value)}
/>
</Field>
<Field label={t('admin.upload.category')}>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FontCategory)}
>
{FONT_CATEGORIES.map((value) => (
<option key={value} value={value}>
{t(`category.${value}`)}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.licence')} hint={t('admin.upload.licenceHint')}>
<input
type="text"
value={licence}
maxLength={200}
onChange={(event) => setLicence(event.target.value)}
/>
</Field>
<Field label={t('admin.upload.licenceUrl')}>
<input
type="url"
value={licenceUrl}
maxLength={500}
placeholder="https://"
onChange={(event) => setLicenceUrl(event.target.value)}
/>
</Field>
<fieldset className="custom-fonts__weights">
<legend>{t('admin.upload.weights')}</legend>
{/* The accepted formats are stated up front, not only when a rejected
upload comes back an operator should not have to fail to learn
the requirement. */}
<p>{t('admin.upload.formatHint', { max: MAX_MIB })}</p>
{weights.map((draft, index) => (
<div className="custom-fonts__weight" key={index}>
<Field label={t('admin.upload.weight')}>
<select
value={draft.weight}
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, weight: Number(event.target.value) } : entry,
),
)
}
>
{FONT_WEIGHTS.map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.woff2', { weight: draft.weight })}>
<input
type="file"
accept=".woff2,font/woff2"
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, woff2: event.target.files?.[0] ?? null } : entry,
),
)
}
/>
</Field>
<Field label={t('admin.upload.woff', { weight: draft.weight })}>
<input
type="file"
accept=".woff,font/woff"
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, woff: event.target.files?.[0] ?? null } : entry,
),
)
}
/>
</Field>
{weights.length > 1 && (
<button
type="button"
className="linklike"
onClick={() => setWeights((prev) => prev.filter((_, i) => i !== index))}
>
{t('admin.upload.removeWeight', { weight: draft.weight })}
</button>
)}
</div>
))}
<button
type="button"
className="button button--outline"
onClick={() =>
setWeights((prev) => [...prev, emptyDraft(prev.map((entry) => entry.weight))])
}
>
{t('admin.upload.addWeight')}
</button>
</fieldset>
<button type="submit" className="button" disabled={!ready || upload.isPending}>
{t('admin.upload.submit')}
</button>
{/* Announced, not just spun: an 8 MiB face over a slow link takes long
enough that silence reads as failure. */}
<p role="status" className="custom-fonts__status">
{upload.isPending ? t('admin.upload.uploading') : ''}
</p>
</form>
);
}
function FontRow({
font,
entries,
onChanged,
}: {
font: CustomFontView;
entries: ReturnType<typeof customFontEntries>;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('font');
const [confirming, setConfirming] = useState(false);
const [adding, setAdding] = useState<WeightDraft | null>(null);
const confirmRef = useRef<HTMLButtonElement | null>(null);
const deleteRef = useRef<HTMLButtonElement | null>(null);
const usage = useQuery({
queryKey: ['admin', 'fonts', font.id, 'usage'],
queryFn: () => apiGet<{ pondsAffected: number }>(`/admin/fonts/${font.id}/usage`),
enabled: confirming,
});
// The confirmation appears below the button that opened it; without moving
// focus a keyboard user would have to hunt for it, and a screen reader would
// never learn it exists.
useEffect(() => {
if (confirming) confirmRef.current?.focus();
}, [confirming, usage.data]);
const addWeight = useMutation({
mutationFn: async (draft: WeightDraft) => {
const form = new FormData();
appendWeight(form, draft);
return apiPostForm<CustomFontView>(`/admin/fonts/${font.id}/weights`, form);
},
onSuccess: async () => {
setAdding(null);
await onChanged();
},
});
const remove = useMutation({
mutationFn: () => apiDelete(`/admin/fonts/${font.id}`),
onSuccess: async () => {
setConfirming(false);
await onChanged();
},
});
return (
<li className="custom-fonts__item" data-font-slug={font.slug}>
<p className="custom-fonts__name" style={{ fontFamily: fontStack(font.family, entries) }}>
{font.family}
</p>
<p className="custom-fonts__meta">
{t(`category.${font.category}`)} · {t('catalog.weights')}: {font.weights.join(', ')} ·{' '}
{font.licenceUrl ? (
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
{font.licence}
</a>
) : (
font.licence
)}
</p>
<FormError error={remove.error ?? addWeight.error} />
<div className="custom-fonts__actions">
<button
type="button"
className="linklike"
onClick={() => setAdding((prev) => (prev ? null : emptyDraft(font.weights)))}
aria-expanded={adding !== null}
>
{t('admin.addWeight.toggle')}
</button>
<button
type="button"
className="linklike"
ref={deleteRef}
onClick={() => setConfirming(true)}
aria-expanded={confirming}
>
{t('admin.delete.start', { family: font.family })}
</button>
</div>
{adding && (
<form
className="custom-fonts__add-weight"
noValidate
onSubmit={(event) => {
event.preventDefault();
addWeight.mutate(adding);
}}
>
<Field label={t('admin.upload.weight')}>
<select
value={adding.weight}
onChange={(event) => setAdding({ ...adding, weight: Number(event.target.value) })}
>
{FONT_WEIGHTS.filter((weight) => !font.weights.includes(weight)).map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.woff2', { weight: adding.weight })}>
<input
type="file"
accept=".woff2,font/woff2"
onChange={(event) => setAdding({ ...adding, woff2: event.target.files?.[0] ?? null })}
/>
</Field>
<Field label={t('admin.upload.woff', { weight: adding.weight })}>
<input
type="file"
accept=".woff,font/woff"
onChange={(event) => setAdding({ ...adding, woff: event.target.files?.[0] ?? null })}
/>
</Field>
<button type="submit" className="button" disabled={!adding.woff2 || addWeight.isPending}>
{t('admin.addWeight.submit')}
</button>
<p role="status">{addWeight.isPending ? t('admin.upload.uploading') : ''}</p>
</form>
)}
{confirming && (
<div className="custom-fonts__confirm">
{/* The count comes from the api; until it arrives the consequence is
still stated, so the text never reads as "nothing will happen". */}
<p>
{usage.data
? t('admin.delete.usage', { count: usage.data.pondsAffected })
: t('admin.delete.usageLoading')}
</p>
<p>{t('admin.delete.consequence')}</p>
<button
type="button"
className="button button--danger"
ref={confirmRef}
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('admin.delete.confirm')}
</button>
<button
type="button"
className="button button--outline"
onClick={() => {
setConfirming(false);
deleteRef.current?.focus();
}}
>
{t('admin.delete.cancel')}
</button>
</div>
)}
</li>
);
}

View File

@ -1,29 +1,19 @@
import { CustomFontView, FONT_CATALOG, FontCatalogEntry, fontStack } from '@dorfteich/shared'; import { FONT_CATALOG, fontStack } from '@dorfteich/shared';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCustomFontEntries, useCustomFonts } from '../fonts/use-custom-fonts';
import { useDocumentTitle } from '../lib/use-document-title'; import { useDocumentTitle } from '../lib/use-document-title';
/**
/** One licence table. Both sources carry the same four columns; only where the * Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
* licence text comes from differs a catalog entry names a licence id we * family with its license, rendered in the font itself. The self-hosting is the
* ship, an uploaded family whatever the operator typed. */ * GDPR guarantee this page is the human-readable attribution surface.
function LicenceTable({ */
label, export function FontCatalogPage(): React.JSX.Element {
rows,
}: {
label: string;
rows: {
key: string;
family: string;
category: string;
weights: number[];
licence: React.ReactNode;
stack: string;
}[];
}): React.JSX.Element {
const { t } = useTranslation('font'); const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
return ( return (
<div className="table-scroll" tabIndex={0} role="region" aria-label={label}> <div className="font-catalog">
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<table className="font-catalog__table"> <table className="font-catalog__table">
<thead> <thead>
<tr> <tr>
@ -34,12 +24,16 @@ function LicenceTable({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row) => ( {FONT_CATALOG.map((font) => (
<tr key={row.key}> <tr key={font.family}>
<td style={{ fontFamily: row.stack }}>{row.family}</td> <td style={{ fontFamily: fontStack(font.family) }}>{font.family}</td>
<td>{row.category}</td> <td>{t(`category.${font.category}`)}</td>
<td>{row.weights.join(', ')}</td> <td>{font.weights.join(', ')}</td>
<td>{row.licence}</td> <td>
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
{font.license}
</a>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -47,72 +41,3 @@ function LicenceTable({
</div> </div>
); );
} }
/**
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
* family with its license, rendered in the font itself. The self-hosting is the
* GDPR guarantee this page is the human-readable attribution surface.
*
* Since issue #304 the operator's own uploaded families are listed too, with
* the licence label and link recorded at upload. That is what makes an
* attribution obligation satisfiable: many commercial font licences require
* naming the foundry or the licence, and an operator who cannot point at such
* a page cannot comply.
*/
export function FontCatalogPage(): React.JSX.Element {
const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
const customFonts: CustomFontView[] = useCustomFonts();
const customEntries: FontCatalogEntry[] = useCustomFontEntries();
return (
<div className="font-catalog">
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<h2>{t('catalog.bundledHeading')}</h2>
<p>{t('catalog.bundledIntro')}</p>
<LicenceTable
label={t('catalog.bundledHeading')}
rows={FONT_CATALOG.map((font) => ({
key: font.family,
family: font.family,
category: t(`category.${font.category}`),
weights: font.weights,
stack: fontStack(font.family),
licence: (
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
{font.license}
</a>
),
}))}
/>
<h2>{t('catalog.customHeading')}</h2>
<p>{t('catalog.customIntro')}</p>
{customFonts.length === 0 ? (
<p>{t('catalog.customEmpty')}</p>
) : (
<LicenceTable
label={t('catalog.customHeading')}
rows={customFonts.map((font) => ({
key: font.id,
family: font.family,
category: t(`category.${font.category}`),
weights: font.weights,
stack: fontStack(font.family, customEntries),
// A licence URL is optional — without one the label stands alone
// rather than becoming a link to nowhere.
licence: font.licenceUrl ? (
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
{font.licence}
</a>
) : (
font.licence
),
}))}
/>
)}
</div>
);
}

View File

@ -1,140 +0,0 @@
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,9 +227,7 @@ function PageEditor({
attributes: { attributes: {
role: canEdit ? 'textbox' : 'document', role: canEdit ? 'textbox' : 'document',
'aria-label': t('contentLabel'), 'aria-label': t('contentLabel'),
...(canEdit ...(canEdit ? { 'aria-multiline': 'true' } : {}),
? { 'aria-multiline': 'true', 'aria-describedby': 'editor-keyboard-hint' }
: {}),
}, },
}, },
}); });
@ -363,13 +361,6 @@ 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,7 +21,6 @@ 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';
@ -40,7 +39,6 @@ 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');
@ -116,8 +114,6 @@ 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,26 +138,21 @@ 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">
{/* Flex lives on the inner div: a td with display:flex stops behaving <input
like a table cell and its bottom border no longer meets the row's type="number"
(same fix as the user list's actions cell, #177). */} min={0}
<div className="quota-row__override-inner"> className="quota-row__input"
<input value={draft}
type="number" onChange={(e) => onDraft(e.target.value)}
min={0} />
className="quota-row__input" <button type="button" className="linklike" onClick={onSet}>
value={draft} {t('overrides.set')}
onChange={(e) => onDraft(e.target.value)} </button>
/> {line.override !== null && (
<button type="button" className="linklike" onClick={onSet}> <button type="button" className="linklike" onClick={onClear}>
{t('overrides.set')} {t('overrides.clear')}
</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,7 +17,6 @@ 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';
@ -46,7 +45,6 @@ export function SettingsPage(): React.JSX.Element {
<PasswordSection /> <PasswordSection />
<SessionsSection /> <SessionsSection />
<WatchesSection /> <WatchesSection />
<InvitationsSection />
<ApiTokensSection /> <ApiTokensSection />
<FeedTokensSection /> <FeedTokensSection />
<AppearanceSection /> <AppearanceSection />
@ -387,56 +385,41 @@ function SessionsSection(): React.JSX.Element {
return ( return (
<section className="settings-section"> <section className="settings-section">
<h2>{t('settings:sessions.title')}</h2> <h2>{t('settings:sessions.title')}</h2>
{/* A table cannot shrink below its min-content width, so the user-agent <table className="table">
column pushed the whole page into horizontal scrolling at 320px <thead>
(issue #301, WCAG 1.4.10). It scrolls inside its own box instead <tr>
the content stays reachable, which `overflow: hidden` would not. */} <th>{t('settings:sessions.device')}</th>
<div <th>{t('settings:sessions.created')}</th>
className="table-scroll" <th>{t('settings:sessions.lastSeen')}</th>
// A scroll container is only operable by keyboard once it is <th>
// focusable; role+name keep it from being an unlabelled stop. <span className="visually-hidden">{t('common:tableActions')}</span>
tabIndex={0} </th>
role="region" </tr>
aria-label={t('settings:sessions.title')} </thead>
> <tbody>
<table className="table"> {(sessions.data ?? []).map((session) => (
<thead> <tr key={session.id}>
<tr> <td>
<th>{t('settings:sessions.device')}</th> {session.userAgent ?? '—'}
<th>{t('settings:sessions.created')}</th> {session.current && <span className="badge">{t('settings:sessions.current')}</span>}
<th>{t('settings:sessions.lastSeen')}</th> </td>
<th> <td>{formatTime(session.createdAt)}</td>
<span className="visually-hidden">{t('common:tableActions')}</span> <td>{formatTime(session.lastSeenAt)}</td>
</th> <td>
{!session.current && (
<button
type="button"
className="linklike"
onClick={() => revoke.mutate(session.id)}
>
{t('settings:sessions.revoke')}
</button>
)}
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{(sessions.data ?? []).map((session) => ( </table>
<tr key={session.id}>
<td>
{session.userAgent ?? '—'}
{session.current && (
<span className="badge">{t('settings:sessions.current')}</span>
)}
</td>
<td>{formatTime(session.createdAt)}</td>
<td>{formatTime(session.lastSeenAt)}</td>
<td>
{!session.current && (
<button
type="button"
className="linklike"
onClick={() => revoke.mutate(session.id)}
>
{t('settings:sessions.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{others.length > 0 ? ( {others.length > 0 ? (
<button type="button" className="button" onClick={() => revokeOthers.mutate()}> <button type="button" className="button" onClick={() => revokeOthers.mutate()}>
{t('settings:sessions.revokeAll')} {t('settings:sessions.revokeAll')}

View File

@ -1,18 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod'; import type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
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, useId, useRef, useState } from 'react'; import { useEffect, 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;
@ -27,8 +21,6 @@ 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],
@ -50,24 +42,6 @@ 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"
@ -118,98 +92,6 @@ 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

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

@ -1,62 +0,0 @@
/**
* 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;
}

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