Compare commits
41 Commits
issue-245-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9c70287c | |||
| f142289813 | |||
| c17ab41a33 | |||
| 3bf9363c34 | |||
| b1165a37e6 | |||
| 69563348ca | |||
| 7e17a2dba6 | |||
| c2a4dde5cc | |||
| 9cf7b85b93 | |||
| 64f2deb40f | |||
| 20677ea247 | |||
| 6999b3dd73 | |||
| 4d6a27194f | |||
| 9f754649d4 | |||
| d2be1116bc | |||
| 78258c4f9b | |||
| a327126fac | |||
| 3310ae3926 | |||
| 8752cf0c5a | |||
| 6377faf332 | |||
| 942f7b13d3 | |||
| ee6a11f9b0 | |||
| f8c241b11a | |||
| 485c8fa538 | |||
| b96997501a | |||
| 5164801676 | |||
| 69882ecbea | |||
| 2422f3a28f | |||
| b65339ae13 | |||
| 194f144797 | |||
| 9fce824a8e | |||
| 18c2ed0bfe | |||
| f938ee9880 | |||
| f9149eba13 | |||
| 30fd1ff53b | |||
| 45f1925917 | |||
| 5a4a99196e | |||
| 1f56f34113 | |||
| 9b7acab294 | |||
| 404a3741c8 | |||
| 4d9f913845 |
@ -107,6 +107,24 @@ 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
|
||||||
|
|
||||||
@ -327,6 +345,16 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
|
||||||
|
|
||||||
|
- name: Reset login rate limit before admin-settings pack
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
|
- name: Run admin-settings pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before admin-quotas pack
|
- name: Reset login rate limit before admin-quotas pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
@ -347,6 +375,18 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
|
||||||
|
|
||||||
|
- name: Reset login rate limit before invitations pack
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
|
# Invitations (issue #332) need the mail catcher like the auth pack:
|
||||||
|
# the invite link and the follow-up verification both travel by mail.
|
||||||
|
- name: Run invitations pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before permission-matrix pack
|
- name: Reset login rate limit before permission-matrix pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
@ -620,6 +660,14 @@ 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: |
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@ -27,13 +27,3 @@ AA) — nicht nachträglich. Kurzfassung; Details und Begründung in
|
|||||||
machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
|
machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
|
||||||
|
|
||||||
Verstöße gelten in Review und Abnahme als Funktionsfehler.
|
Verstöße gelten in Review und Abnahme als Funktionsfehler.
|
||||||
|
|
||||||
## graphify
|
|
||||||
|
|
||||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
|
||||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
|
||||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
|
||||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
|
||||||
|
|||||||
@ -29,18 +29,19 @@ ARG APP_VERSION=0.0.0-dev
|
|||||||
# Default the data dirs to the writable, node-owned locations created below, so
|
# Default the data dirs to the writable, node-owned locations created below, so
|
||||||
# the image works out of the box even where compose does not set them; compose
|
# the image works out of the box even where compose does not set them; compose
|
||||||
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
|
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
|
||||||
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
|
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build --chown=node:node /out /app
|
COPY --from=build --chown=node:node /out /app
|
||||||
# Generate the Prisma client for this image's platform.
|
# Generate the Prisma client for this image's platform.
|
||||||
RUN node node_modules/prisma/build/index.js generate
|
RUN node node_modules/prisma/build/index.js generate
|
||||||
# A fresh named volume mounted at /data/uploads or /data/plugins is created
|
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
|
||||||
|
# or /data/branding is created
|
||||||
# root-owned; pre-creating them here (Docker copies an image directory's
|
# root-owned; pre-creating them here (Docker copies an image directory's
|
||||||
# ownership into a new volume on first mount) lets the non-root `node` user
|
# ownership into a new volume on first mount) lets the non-root `node` user
|
||||||
# write to them. /data/backups is mounted read-only here, but pre-creating it
|
# write to them. /data/backups is mounted read-only here, but pre-creating it
|
||||||
# node-owned keeps the shared `backups` volume writable for the backup
|
# node-owned keeps the shared `backups` volume writable for the backup
|
||||||
# sidecar even when the api container is the one that initializes it.
|
# sidecar even when the api container is the one that initializes it.
|
||||||
RUN mkdir -p /data/uploads /data/plugins /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/secrets /data/backups
|
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
|
|||||||
BIN
apps/api/assets/default-favicon-180.png
Normal file
BIN
apps/api/assets/default-favicon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/api/assets/default-favicon-32.png
Normal file
BIN
apps/api/assets/default-favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 683 B |
@ -0,0 +1,4 @@
|
|||||||
|
-- #232: SHA-256 of the installed bundle ZIP, observed at install time.
|
||||||
|
-- NULL for plugins installed before this migration — the admin UI says so
|
||||||
|
-- and a reinstall records it.
|
||||||
|
ALTER TABLE "plugins" ADD COLUMN "bundle_hash" TEXT;
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
-- #303: operator-uploaded font families (ADR 0016 §#303).
|
||||||
|
-- The bytes live on disk under CUSTOM_FONTS_DIR; these rows record only what
|
||||||
|
-- the upload form stated, because the api never parses the font file.
|
||||||
|
|
||||||
|
CREATE TABLE "custom_fonts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"family" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"licence" TEXT NOT NULL,
|
||||||
|
"licence_url" TEXT,
|
||||||
|
"uploaded_by" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "custom_fonts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Both unique: `family` keeps `fonts.<slot>.family` in pond settings
|
||||||
|
-- unambiguous, `slug` owns a directory under CUSTOM_FONTS_DIR.
|
||||||
|
CREATE UNIQUE INDEX "custom_fonts_family_key" ON "custom_fonts"("family");
|
||||||
|
CREATE UNIQUE INDEX "custom_fonts_slug_key" ON "custom_fonts"("slug");
|
||||||
|
|
||||||
|
ALTER TABLE "custom_fonts" ADD CONSTRAINT "custom_fonts_uploaded_by_fkey"
|
||||||
|
FOREIGN KEY ("uploaded_by") REFERENCES "users"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE "custom_font_weights" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"font_id" TEXT NOT NULL,
|
||||||
|
"weight" INTEGER NOT NULL,
|
||||||
|
"has_woff" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"byte_size" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "custom_font_weights_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "custom_font_weights_font_id_weight_key"
|
||||||
|
ON "custom_font_weights"("font_id", "weight");
|
||||||
|
|
||||||
|
-- Deleting a family takes its weights with it; the files on disk are removed
|
||||||
|
-- by the service in the same operation.
|
||||||
|
ALTER TABLE "custom_font_weights" ADD CONSTRAINT "custom_font_weights_font_id_fkey"
|
||||||
|
FOREIGN KEY ("font_id") REFERENCES "custom_fonts"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
-- Peer invitations (issue #332): a user invites an e-mail address; the token
|
||||||
|
-- allows exactly one registration even while registration is closed.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "invitations" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"inviter_id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"token_hash" TEXT NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"revoked_at" TIMESTAMP(3),
|
||||||
|
"accepted_at" TIMESTAMP(3),
|
||||||
|
"accepted_user_id" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "invitations_token_hash_key" ON "invitations"("token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "invitations_inviter_id_idx" ON "invitations"("inviter_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviter_id_fkey" FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -31,46 +31,71 @@ enum UserStatus {
|
|||||||
/// Account profile. Login methods live in UserIdentity (OIDC-ready,
|
/// Account profile. Login methods live in UserIdentity (OIDC-ready,
|
||||||
/// ADR 0007); Site Admin is a user flag, all other roles are grants.
|
/// ADR 0007); Site Admin is a user flag, all other roles are grants.
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
locale String @default("en")
|
locale String @default("en")
|
||||||
isSiteAdmin Boolean @default(false) @map("is_site_admin")
|
isSiteAdmin Boolean @default(false) @map("is_site_admin")
|
||||||
/// True when the flag was last SET by the IdP claim mapping (issue #217):
|
/// 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
|
||||||
@ -107,32 +132,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])
|
||||||
@ -237,14 +262,14 @@ model Pond {
|
|||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
deletedBy String? @map("deleted_by")
|
deletedBy String? @map("deleted_by")
|
||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
usage PondUsage?
|
usage PondUsage?
|
||||||
pages Page[]
|
pages Page[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
labels Label[]
|
labels Label[]
|
||||||
grants RoleGrant[]
|
grants RoleGrant[]
|
||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
pondPlugins PondPlugin[]
|
pondPlugins PondPlugin[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("ponds")
|
@@map("ponds")
|
||||||
@ -460,12 +485,12 @@ model CollabOpenSession {
|
|||||||
/// built from the Yjs state via the shared editor schema. `outline` is the
|
/// built from the Yjs state via the shared editor schema. `outline` is the
|
||||||
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
|
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
|
||||||
model PageContentCache {
|
model PageContentCache {
|
||||||
pageId String @id @map("page_id")
|
pageId String @id @map("page_id")
|
||||||
plainText String @map("plain_text")
|
plainText String @map("plain_text")
|
||||||
markdown String
|
markdown String
|
||||||
html String
|
html String
|
||||||
outline Json
|
outline Json
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
|
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
|
||||||
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
|
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
|
||||||
/// hook (both write it with the same weighting). The GIN index is added in
|
/// hook (both write it with the same weighting). The GIN index is added in
|
||||||
@ -854,9 +879,9 @@ model ConversionJob {
|
|||||||
sourceName String? @map("source_name")
|
sourceName String? @map("source_name")
|
||||||
resultPageId String? @map("result_page_id")
|
resultPageId String? @map("result_page_id")
|
||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
|
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
|
||||||
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
|
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@map("conversion_jobs")
|
@@map("conversion_jobs")
|
||||||
@ -885,6 +910,8 @@ model Plugin {
|
|||||||
mode PluginInstanceMode @default(DISABLED)
|
mode PluginInstanceMode @default(DISABLED)
|
||||||
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
|
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
|
||||||
manifest Json
|
manifest Json
|
||||||
|
/// SHA-256 (hex) of the installed bundle ZIP (#232); null = pre-#232 install.
|
||||||
|
bundleHash String? @map("bundle_hash")
|
||||||
installedAt DateTime @default(now()) @map("installed_at")
|
installedAt DateTime @default(now()) @map("installed_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
/// Set when uninstalled; active queries filter `removedAt: null`.
|
/// Set when uninstalled; active queries filter `removedAt: null`.
|
||||||
@ -911,3 +938,48 @@ model PondPlugin {
|
|||||||
@@id([pondId, pluginId])
|
@@id([pondId, pluginId])
|
||||||
@@map("pond_plugins")
|
@@map("pond_plugins")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An operator-uploaded font family (issue #303, ADR 0016 §#303). The bytes
|
||||||
|
/// live on disk under CUSTOM_FONTS_DIR — this row only records what the
|
||||||
|
/// upload form stated, because the api never parses the font file itself.
|
||||||
|
/// Additive to the compile-time catalog: a family whose name or slug
|
||||||
|
/// collides with a catalog entry is rejected, so `fonts.<slot>.family` in a
|
||||||
|
/// pond's settings stays unambiguous.
|
||||||
|
model CustomFont {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
/// CSS `font-family` name, as typed by the uploader.
|
||||||
|
family String @unique
|
||||||
|
/// URL/file-safe form; names the directory under CUSTOM_FONTS_DIR.
|
||||||
|
slug String @unique
|
||||||
|
/// Drives the system fallback stack, like FontCatalogEntry.category.
|
||||||
|
category String
|
||||||
|
/// Free-text licence label, e.g. "Commercial — Foundry XY". Required so
|
||||||
|
/// an attribution obligation can be met on the font catalogue page.
|
||||||
|
licence String
|
||||||
|
licenceUrl String? @map("licence_url")
|
||||||
|
uploadedBy String @map("uploaded_by")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
uploader User @relation(fields: [uploadedBy], references: [id])
|
||||||
|
weights CustomFontWeight[]
|
||||||
|
|
||||||
|
@@map("custom_fonts")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One weight of a custom family. Style is always `normal`: the PDF
|
||||||
|
/// `@font-face` builder emits only that, and browsers synthesise oblique —
|
||||||
|
/// italic uploads are a follow-up, not a silent half-feature.
|
||||||
|
model CustomFontWeight {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
fontId String @map("font_id")
|
||||||
|
weight Int
|
||||||
|
/// Whether a legacy WOFF was supplied next to the required WOFF2.
|
||||||
|
hasWoff Boolean @default(false) @map("has_woff")
|
||||||
|
byteSize Int @map("byte_size")
|
||||||
|
|
||||||
|
font CustomFont @relation(fields: [fontId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([fontId, weight])
|
||||||
|
@@map("custom_font_weights")
|
||||||
|
}
|
||||||
|
|||||||
127
apps/api/scripts/gen-default-favicon.mjs
Normal file
127
apps/api/scripts/gen-default-favicon.mjs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generates the shipped default favicons (issue #306):
|
||||||
|
* `apps/api/assets/default-favicon-32.png` and `-180.png`.
|
||||||
|
*
|
||||||
|
* The api serves these whenever an operator has not uploaded one, so an
|
||||||
|
* instance always has a tab icon — the `<link rel="icon">` in index.html is
|
||||||
|
* static and its resource must never 404.
|
||||||
|
*
|
||||||
|
* Drawn here rather than pulled in as a binary: the whole toolchain must
|
||||||
|
* survive the `--network none` offline build (96-offline-build-protokoll.md),
|
||||||
|
* and adding an image library for one 32×32 icon would be the tail wagging
|
||||||
|
* the dog. Node's own zlib is enough to write a PNG.
|
||||||
|
*
|
||||||
|
* Motif: a pond seen from above — the accent-green disc with two ripples.
|
||||||
|
*
|
||||||
|
* Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit
|
||||||
|
* script and binaries together.
|
||||||
|
*/
|
||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
/** Brand green — the same value as index.html's light `theme-color`. */
|
||||||
|
const GREEN = [0x2f, 0x6f, 0x4f];
|
||||||
|
const LIGHT = [0xe8, 0xf2, 0xec];
|
||||||
|
|
||||||
|
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
return c >>> 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length);
|
||||||
|
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(body));
|
||||||
|
return Buffer.concat([length, body, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal RGBA PNG writer — no filtering, one IDAT. */
|
||||||
|
function encodePng(size, rgba) {
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 6; // colour type RGBA
|
||||||
|
const raw = Buffer.alloc(size * (size * 4 + 1));
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
raw[y * (size * 4 + 1)] = 0; // filter: none
|
||||||
|
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
||||||
|
}
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colour at one point of the unit square, in continuous coordinates — the
|
||||||
|
* caller supersamples it, which is where the anti-aliasing comes from.
|
||||||
|
*/
|
||||||
|
function sample(x, y) {
|
||||||
|
const dx = x - 0.5;
|
||||||
|
const dy = y - 0.5;
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r > 0.48) return null; // outside the disc: transparent
|
||||||
|
// Two ripples spreading from a point struck slightly above centre — rings
|
||||||
|
// rather than a bullseye, which is why the centre stays green and the
|
||||||
|
// spacing widens outward the way real ripples do.
|
||||||
|
const rr = Math.hypot(dx, dy + 0.06);
|
||||||
|
const onRing = (radius, width) => Math.abs(rr - radius) < width;
|
||||||
|
if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT;
|
||||||
|
return GREEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(size) {
|
||||||
|
const SS = 4; // supersampling factor
|
||||||
|
const out = Buffer.alloc(size * size * 4);
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
let a = 0;
|
||||||
|
for (let sy = 0; sy < SS; sy += 1) {
|
||||||
|
for (let sx = 0; sx < SS; sx += 1) {
|
||||||
|
const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size);
|
||||||
|
if (c) {
|
||||||
|
r += c[0];
|
||||||
|
g += c[1];
|
||||||
|
b += c[2];
|
||||||
|
a += 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const n = SS * SS;
|
||||||
|
const covered = a / 255;
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
// Premultiplied average of the covered samples only, so the edge fades
|
||||||
|
// in alpha rather than towards black.
|
||||||
|
out[i] = covered ? Math.round(r / covered) : 0;
|
||||||
|
out[i + 1] = covered ? Math.round(g / covered) : 0;
|
||||||
|
out[i + 2] = covered ? Math.round(b / covered) : 0;
|
||||||
|
out[i + 3] = Math.round(a / n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets');
|
||||||
|
for (const size of [32, 180]) {
|
||||||
|
const file = join(assets, `default-favicon-${size}.png`);
|
||||||
|
writeFileSync(file, encodePng(size, render(size)));
|
||||||
|
console.log(`wrote ${file}`);
|
||||||
|
}
|
||||||
@ -11,9 +11,17 @@ import {
|
|||||||
} from '../settings/instance-settings.service';
|
} from '../settings/instance-settings.service';
|
||||||
import { SiteAdminGuard } from './site-admin.guard';
|
import { SiteAdminGuard } from './site-admin.guard';
|
||||||
|
|
||||||
// Lifecycle markers, not configuration: never editable through this
|
// Lifecycle markers and file-backed metadata, not configuration: never
|
||||||
// endpoint (the setup lock must be irreversible, issue #80).
|
// editable through this endpoint. The setup lock must be irreversible
|
||||||
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
|
// (issue #80), and the branding entries only describe bytes on disk
|
||||||
|
// (issue #306) — writing one by hand would claim an asset that is not
|
||||||
|
// there. Both have their own write paths.
|
||||||
|
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set([
|
||||||
|
'setup.completedAt',
|
||||||
|
'instance.logo',
|
||||||
|
'instance.logoDark',
|
||||||
|
'instance.favicon',
|
||||||
|
]);
|
||||||
|
|
||||||
// Partial update: any subset of the known settings, each validated by
|
// Partial update: any subset of the known settings, each validated by
|
||||||
// its own schema inside the service (double validation is fine — this
|
// its own schema inside the service (double validation is fine — this
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BackupModule } from '../backup/backup.module';
|
import { BackupModule } from '../backup/backup.module';
|
||||||
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
import { QuotasModule } from '../quotas/quotas.module';
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
@ -19,7 +20,15 @@ import { UserAdminController } from './user-admin.controller';
|
|||||||
import { UserAdminService } from './user-admin.service';
|
import { UserAdminService } from './user-admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
|
imports: [
|
||||||
|
QuotasModule,
|
||||||
|
UsersModule,
|
||||||
|
AuthModule,
|
||||||
|
SchedulerModule,
|
||||||
|
BackupModule,
|
||||||
|
SearchModule,
|
||||||
|
PondsModule,
|
||||||
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminSettingsController,
|
AdminSettingsController,
|
||||||
BackupAdminController,
|
BackupAdminController,
|
||||||
|
|||||||
@ -12,9 +12,11 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
AdminCreateUserInput,
|
||||||
AdminUserListQuery,
|
AdminUserListQuery,
|
||||||
AdminUserListView,
|
AdminUserListView,
|
||||||
AdminUserView,
|
AdminUserView,
|
||||||
|
adminCreateUserSchema,
|
||||||
adminUserListQuerySchema,
|
adminUserListQuerySchema,
|
||||||
setSiteAdminSchema,
|
setSiteAdminSchema,
|
||||||
setUserDisabledSchema,
|
setUserDisabledSchema,
|
||||||
@ -31,6 +33,14 @@ import { UserAdminService } from './user-admin.service';
|
|||||||
export class UserAdminController {
|
export class UserAdminController {
|
||||||
constructor(private readonly users: UserAdminService) {}
|
constructor(private readonly users: UserAdminService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<AdminUserView> {
|
||||||
|
return this.users.createUser(request.user!, input);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,13 +62,71 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const all = Object.values(ids);
|
const all = Object.values(ids);
|
||||||
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
||||||
await prisma.pond.deleteMany({ where: { ownerId: { in: all } } });
|
await deletePondsWhere(prisma, { ownerId: { in: all } });
|
||||||
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
|
||||||
|
const username = `ua-created-${suffix}`;
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/users')
|
||||||
|
.set('Cookie', cookies.admin1!)
|
||||||
|
.send({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: 'UA Created',
|
||||||
|
password,
|
||||||
|
locale: 'de',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const created = res.body as { id: string; status: string };
|
||||||
|
ids.created = created.id;
|
||||||
|
// No verification hop: the admin vouched for the address.
|
||||||
|
expect(created.status).toBe('ACTIVE');
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200);
|
||||||
|
// The personal pond exists exactly like after self-registration.
|
||||||
|
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate usernames with a field-level conflict', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/users')
|
||||||
|
.set('Cookie', cookies.admin1!)
|
||||||
|
.send({
|
||||||
|
username: `ua-created-${suffix}`,
|
||||||
|
email: `ua-created-other-${suffix}@example.org`,
|
||||||
|
displayName: 'UA Dup',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
})
|
||||||
|
.expect(409)
|
||||||
|
.expect((r) =>
|
||||||
|
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
|
||||||
|
'validation.taken',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses creation for non-admins', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/users')
|
||||||
|
.set('Cookie', cookies.bob!)
|
||||||
|
.send({
|
||||||
|
username: `ua-sneak-${suffix}`,
|
||||||
|
email: `ua-sneak-${suffix}@example.org`,
|
||||||
|
displayName: 'UA Sneak',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
it('lists and searches users (Site-Admin only)', async () => {
|
it('lists and searches users (Site-Admin only)', async () => {
|
||||||
const res = await api()
|
const res = await api()
|
||||||
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
AdminCreateUserInput,
|
||||||
AdminUserListQuery,
|
AdminUserListQuery,
|
||||||
AdminUserListView,
|
AdminUserListView,
|
||||||
AdminUserStatus,
|
AdminUserStatus,
|
||||||
@ -10,7 +11,9 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
import { PseudonymizationService } from './pseudonymization.service';
|
import { PseudonymizationService } from './pseudonymization.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,12 +30,33 @@ export class UserAdminService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly pseudonymizer: PseudonymizationService,
|
private readonly pseudonymizer: PseudonymizationService,
|
||||||
private readonly auth: AuthService,
|
private readonly auth: AuthService,
|
||||||
|
private readonly users: UsersService,
|
||||||
|
private readonly ponds: PondsService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(UserAdminService.name);
|
this.logger.setContext(UserAdminService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an account on behalf of a user (issue #331). The e-mail is
|
||||||
|
* marked verified immediately — the admin vouches for the address — and
|
||||||
|
* the personal pond is provisioned exactly like the verify-email path
|
||||||
|
* does, so the account is indistinguishable from a self-registered one.
|
||||||
|
*/
|
||||||
|
async createUser(actor: User, input: AdminCreateUserInput): Promise<AdminUserView> {
|
||||||
|
const user = await this.users.createUser(input);
|
||||||
|
const verified = await this.users.markEmailVerified(user.id);
|
||||||
|
await this.ponds.ensurePersonalPond(verified);
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'user.created_by_admin',
|
||||||
|
actorId: actor.id,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
});
|
||||||
|
return this.viewOf(verified, await this.pondCountOf(user.id));
|
||||||
|
}
|
||||||
|
|
||||||
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
|
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
|
||||||
const q = query.q?.trim();
|
const q = query.q?.trim();
|
||||||
const where: Prisma.UserWhereInput = q
|
const where: Prisma.UserWhereInput = q
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { AdminModule } from './admin/admin.module';
|
|||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BackupModule } from './backup/backup.module';
|
import { BackupModule } from './backup/backup.module';
|
||||||
|
import { BrandingModule } from './branding/branding.module';
|
||||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||||
import { maskTokenParam } from './common/mask-token-param';
|
import { maskTokenParam } from './common/mask-token-param';
|
||||||
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
|
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
|
||||||
@ -17,6 +18,7 @@ import { FilesModule } from './files/files.module';
|
|||||||
import { GrantsModule } from './grants/grants.module';
|
import { GrantsModule } from './grants/grants.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { HomeModule } from './home/home.module';
|
import { HomeModule } from './home/home.module';
|
||||||
|
import { FontsModule } from './fonts/fonts.module';
|
||||||
import { ImportExportModule } from './import-export/import-export.module';
|
import { ImportExportModule } from './import-export/import-export.module';
|
||||||
import { LabelsModule } from './labels/labels.module';
|
import { LabelsModule } from './labels/labels.module';
|
||||||
import { LegalModule } from './legal/legal.module';
|
import { LegalModule } from './legal/legal.module';
|
||||||
@ -81,6 +83,8 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
PublicApiModule,
|
PublicApiModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
|
BrandingModule,
|
||||||
|
FontsModule,
|
||||||
ImportExportModule,
|
ImportExportModule,
|
||||||
PluginsModule,
|
PluginsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@ -29,6 +29,9 @@ export const AUDIT_EVENTS = {
|
|||||||
'file.integrity_failed': { severity: 'critical' },
|
'file.integrity_failed': { severity: 'critical' },
|
||||||
'grant.created': { severity: 'notice' },
|
'grant.created': { severity: 'notice' },
|
||||||
'grant.deleted': { severity: 'notice' },
|
'grant.deleted': { severity: 'notice' },
|
||||||
|
'invitation.accepted': { severity: 'notice' },
|
||||||
|
'invitation.created': { severity: 'info' },
|
||||||
|
'invitation.revoked': { severity: 'info' },
|
||||||
'job.triggered': { severity: 'info' },
|
'job.triggered': { severity: 'info' },
|
||||||
'member.added': { severity: 'notice' },
|
'member.added': { severity: 'notice' },
|
||||||
'member.removed': { severity: 'notice' },
|
'member.removed': { severity: 'notice' },
|
||||||
@ -36,18 +39,24 @@ export const AUDIT_EVENTS = {
|
|||||||
'page.classification_lowered': { severity: 'warning' },
|
'page.classification_lowered': { severity: 'warning' },
|
||||||
'page.classification_raised': { severity: 'notice' },
|
'page.classification_raised': { severity: 'notice' },
|
||||||
'plugin.installed': { severity: 'notice' },
|
'plugin.installed': { severity: 'notice' },
|
||||||
|
'plugin.rejected': { severity: 'warning' },
|
||||||
'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' },
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import request from 'supertest';
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
|
||||||
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@ -46,7 +46,7 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Verified users own a personal pond (#21) — remove it before them.
|
// Verified users own a personal pond (#21) — remove it before them.
|
||||||
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core';
|
|||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { GrantsModule } from '../grants/grants.module';
|
import { GrantsModule } from '../grants/grants.module';
|
||||||
|
import { InvitationsModule } from '../invitations/invitations.module';
|
||||||
|
|
||||||
import { MailModule } from '../mail/mail.module';
|
import { MailModule } from '../mail/mail.module';
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
@ -18,7 +19,7 @@ import { ProxyIdentityService } from './proxy-identity.service';
|
|||||||
import { SessionsModule } from './sessions.module';
|
import { SessionsModule } from './sessions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule],
|
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
|
||||||
controllers: [AuthController, OidcController],
|
controllers: [AuthController, OidcController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { User } from '@prisma/client';
|
|||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { InvitationsService } from '../invitations/invitations.service';
|
||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
@ -33,6 +34,7 @@ export class AuthService {
|
|||||||
private readonly sessions: SessionsService,
|
private readonly sessions: SessionsService,
|
||||||
private readonly mail: MailService,
|
private readonly mail: MailService,
|
||||||
private readonly ponds: PondsService,
|
private readonly ponds: PondsService,
|
||||||
|
private readonly invitations: InvitationsService,
|
||||||
private readonly rateLimits: RateLimitService,
|
private readonly rateLimits: RateLimitService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
@ -43,10 +45,37 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async signup(input: SignupInput): Promise<void> {
|
async signup(input: SignupInput): Promise<void> {
|
||||||
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
|
// An invitation token (issue #332) lets exactly one signup through a
|
||||||
|
// closed registration. Claimed atomically BEFORE the account exists;
|
||||||
|
// rolled back if the signup fails (duplicate username), so the invitee
|
||||||
|
// can retry with the same link.
|
||||||
|
const invitation = input.invitationToken
|
||||||
|
? await this.invitations.redeem(input.invitationToken)
|
||||||
|
: null;
|
||||||
|
if (input.invitationToken && !invitation) {
|
||||||
|
throw new BadRequestException({ code: 'token_invalid' });
|
||||||
|
}
|
||||||
|
if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') {
|
||||||
throw new ForbiddenException({ code: 'registration_closed' });
|
throw new ForbiddenException({ code: 'registration_closed' });
|
||||||
}
|
}
|
||||||
const user = await this.users.createUser(input);
|
let user: User;
|
||||||
|
try {
|
||||||
|
user = await this.users.createUser(input);
|
||||||
|
} catch (error) {
|
||||||
|
if (invitation) await this.invitations.unredeem(invitation.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (invitation) {
|
||||||
|
await this.invitations.markAccepted(invitation.id, user.id);
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'invitation.accepted',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'invitation',
|
||||||
|
targetId: invitation.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The invite link proves nothing about the mailbox (it can be
|
||||||
|
// forwarded), so the usual verification mail still applies.
|
||||||
await this.sendVerificationMail(user);
|
await this.sendVerificationMail(user);
|
||||||
await this.audit.record({ action: 'auth.signup', actorId: user.id });
|
await this.audit.record({ action: 'auth.signup', actorId: user.id });
|
||||||
}
|
}
|
||||||
|
|||||||
50
apps/api/src/branding/branding-storage.service.ts
Normal file
50
apps/api/src/branding/branding-storage.service.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem binding for branding assets (issue #306; pond overrides #307).
|
||||||
|
*
|
||||||
|
* One flat directory of PNGs named by a caller-supplied key
|
||||||
|
* (`instance-logo-light`, later `pond-<id>-favicon-32`). Flat because there
|
||||||
|
* are a handful of files per instance and the backup archives the directory
|
||||||
|
* as a whole — a tree would buy nothing and cost a traversal question.
|
||||||
|
*
|
||||||
|
* The key is constrained here rather than trusted from the route: it is the
|
||||||
|
* only thing between a request parameter and a path.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BrandingStorageService {
|
||||||
|
constructor(private readonly config: AppConfig) {}
|
||||||
|
|
||||||
|
/** Lowercase, digits and dashes only — no dot, so no `..`, and no slash,
|
||||||
|
* so the file cannot leave the directory whatever a caller sends. */
|
||||||
|
private pathFor(key: string): string {
|
||||||
|
if (!/^[a-z0-9-]{1,120}$/.test(key)) throw new Error(`invalid branding key: ${key}`);
|
||||||
|
return join(this.config.env.BRANDING_DIR, `${key}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(key: string, bytes: Buffer): Promise<void> {
|
||||||
|
await mkdir(this.config.env.BRANDING_DIR, { recursive: true });
|
||||||
|
await writeFile(this.pathFor(key), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bytes, or null when the file is absent — a missing asset is a normal
|
||||||
|
* state here (nothing uploaded, or metadata and disk drifted after a
|
||||||
|
* partial restore), and every caller has a fallback. */
|
||||||
|
async read(key: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
return await readFile(this.pathFor(key));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent: removing what is not there is success. */
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
await rm(this.pathFor(key), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
253
apps/api/src/branding/branding.controller.ts
Normal file
253
apps/api/src/branding/branding.controller.ts
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
BrandingView,
|
||||||
|
FAVICON_SIZES,
|
||||||
|
FaviconSize,
|
||||||
|
LOGO_VARIANTS,
|
||||||
|
LogoVariant,
|
||||||
|
MAX_BRANDING_BYTES,
|
||||||
|
PondBranding,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||||
|
import { RequiresPondRole } from '../permissions/permission.decorators';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { BrandingService } from './branding.service';
|
||||||
|
|
||||||
|
function parseVariant(value: unknown): LogoVariant {
|
||||||
|
if (!LOGO_VARIANTS.includes(value as LogoVariant)) {
|
||||||
|
throw new BadRequestException({ code: 'bad_request' });
|
||||||
|
}
|
||||||
|
return value as LogoVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public branding surface (issue #306).
|
||||||
|
*
|
||||||
|
* Unauthenticated by design and worth stating plainly in the admin UI: the
|
||||||
|
* login screen carries the branding and the browser fetches the favicon before
|
||||||
|
* anyone signs in, so an operator's logo IS visible to anonymous visitors.
|
||||||
|
*/
|
||||||
|
@Controller('branding')
|
||||||
|
export class BrandingController {
|
||||||
|
constructor(private readonly branding: BrandingService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get()
|
||||||
|
view(): Promise<BrandingView> {
|
||||||
|
return this.branding.view();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('logo')
|
||||||
|
async logo(
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Query('pond') pondId: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const wanted = parseVariant(variant ?? 'light');
|
||||||
|
// A pond scope serves the pond's own bytes and nothing else: the caller
|
||||||
|
// already resolved WHICH level applies (`resolveBranding`), so silently
|
||||||
|
// falling back here would mix variants across levels — exactly what #307
|
||||||
|
// forbids.
|
||||||
|
const bytes = pondId
|
||||||
|
? await this.branding.pondLogoBytes(pondId, wanted)
|
||||||
|
: await this.branding.logoBytes(wanted);
|
||||||
|
// No shipped default: without a logo the app renders the instance NAME as
|
||||||
|
// text, so an empty answer here is the honest one.
|
||||||
|
if (!bytes) {
|
||||||
|
res.status(404).json({ code: 'not_found', message: 'no logo' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
// The caller puts the content hash in the query string, so a given URL
|
||||||
|
// never changes what it points at.
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
res.send(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('favicon')
|
||||||
|
async favicon(
|
||||||
|
@Query('size') size: string | undefined,
|
||||||
|
@Query('pond') pondId: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const wanted = Number(size ?? 32);
|
||||||
|
if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) {
|
||||||
|
throw new BadRequestException({ code: 'bad_request' });
|
||||||
|
}
|
||||||
|
const pondBytes = pondId
|
||||||
|
? await this.branding.pondFaviconBytes(pondId, wanted as FaviconSize)
|
||||||
|
: null;
|
||||||
|
const { bytes, uploaded } = pondBytes
|
||||||
|
? { bytes: pondBytes, uploaded: true }
|
||||||
|
: await this.branding.faviconBytes(wanted as FaviconSize);
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
// The `<link rel="icon">` href is a constant in index.html, so this URL
|
||||||
|
// cannot carry a hash — revalidation is the only way a replaced favicon
|
||||||
|
// ever reaches a browser that already has one.
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('ETag', `"${uploaded ? 'custom' : 'default'}-${bytes.length}"`);
|
||||||
|
res.send(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Site-Admin management of the instance branding (issue #306). */
|
||||||
|
@Controller('admin/branding')
|
||||||
|
@UseGuards(SiteAdminGuard)
|
||||||
|
export class BrandingAdminController {
|
||||||
|
constructor(private readonly branding: BrandingService) {}
|
||||||
|
|
||||||
|
@Post('logo')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||||
|
async setLogo(
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<BrandingView> {
|
||||||
|
const file = files?.find((entry) => entry.fieldname === 'file');
|
||||||
|
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||||
|
return this.branding.setLogo(request.user!, parseVariant(variant ?? 'light'), file.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('logo')
|
||||||
|
clearLogo(
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<BrandingView> {
|
||||||
|
return this.branding.clearLogo(request.user!, parseVariant(variant ?? 'light'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('favicon')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||||
|
async setFavicon(
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<BrandingView> {
|
||||||
|
// Field names are the pixel sizes the browser rendered: `png-32`, `png-180`.
|
||||||
|
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
|
||||||
|
const collected = {} as Record<FaviconSize, Buffer>;
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
const bytes = byField.get(`png-${size}`);
|
||||||
|
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||||
|
collected[size] = bytes;
|
||||||
|
}
|
||||||
|
return this.branding.setFavicon(request.user!, collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('favicon')
|
||||||
|
clearFavicon(@Req() request: AuthedRequest): Promise<BrandingView> {
|
||||||
|
return this.branding.clearFavicon(request.user!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pond-level branding (issue #307). The uploader here is an ordinary Pond
|
||||||
|
* Admin rather than the operator, so the security rules of #306 are not
|
||||||
|
* relaxed by a single line: SVG refused, magic bytes checked server-side,
|
||||||
|
* size caps enforced, content type pinned on serving, no image parsing.
|
||||||
|
*
|
||||||
|
* 404/403 policy: a user who cannot see the pond gets 404 from the pond-role
|
||||||
|
* guard, one who can see but not administer it gets 403.
|
||||||
|
*/
|
||||||
|
@Controller('ponds/:pondId/branding')
|
||||||
|
export class PondBrandingController {
|
||||||
|
constructor(
|
||||||
|
private readonly branding: BrandingService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The pond row the quota is charged to. */
|
||||||
|
private async pondOf(pondId: string): Promise<{ id: string; ownerId: string }> {
|
||||||
|
const pond = await this.prisma.pond.findUnique({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { id: true, ownerId: true },
|
||||||
|
});
|
||||||
|
if (!pond) throw new NotFoundException();
|
||||||
|
return pond;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequiresPondRole('reader', { idParam: 'pondId' })
|
||||||
|
view(@Param('pondId') pondId: string): Promise<PondBranding> {
|
||||||
|
return this.branding.pondBranding(pondId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logo')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||||
|
async setLogo(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const file = files?.find((entry) => entry.fieldname === 'file');
|
||||||
|
if (!file) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||||
|
return this.branding.setPondLogo(
|
||||||
|
request.user!,
|
||||||
|
await this.pondOf(pondId),
|
||||||
|
parseVariant(variant ?? 'light'),
|
||||||
|
file.buffer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('logo')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
async clearLogo(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
return this.branding.clearPondLogo(
|
||||||
|
request.user!,
|
||||||
|
await this.pondOf(pondId),
|
||||||
|
parseVariant(variant ?? 'light'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('favicon')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } }))
|
||||||
|
async setFavicon(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer]));
|
||||||
|
const collected = {} as Record<FaviconSize, Buffer>;
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
const bytes = byField.get(`png-${size}`);
|
||||||
|
if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' });
|
||||||
|
collected[size] = bytes;
|
||||||
|
}
|
||||||
|
return this.branding.setPondFavicon(request.user!, await this.pondOf(pondId), collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('favicon')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
async clearFavicon(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
return this.branding.clearPondFavicon(request.user!, await this.pondOf(pondId));
|
||||||
|
}
|
||||||
|
}
|
||||||
250
apps/api/src/branding/branding.e2e.db.test.ts
Normal file
250
apps/api/src/branding/branding.e2e.db.test.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real PNG of `size`×`size`, built the same way the shipped default is —
|
||||||
|
* the api reads the IHDR, so the header has to be genuine.
|
||||||
|
*/
|
||||||
|
async function png(size: number): Promise<Buffer> {
|
||||||
|
const { deflateSync } = await import('node:zlib');
|
||||||
|
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
return c >>> 0;
|
||||||
|
});
|
||||||
|
const crc32 = (buf: Buffer): number => {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
};
|
||||||
|
const chunk = (type: string, data: Buffer): Buffer => {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length);
|
||||||
|
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(body));
|
||||||
|
return Buffer.concat([length, body, crc]);
|
||||||
|
};
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8;
|
||||||
|
ihdr[9] = 6;
|
||||||
|
const raw = Buffer.alloc(size * (size * 4 + 1));
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(raw)),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('instance branding (e2e, issue #306)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let brandingDir: string;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'markenzeichen mit teich 1';
|
||||||
|
const admin = { username: `ba-${suffix}` };
|
||||||
|
const plain = { username: `bp-${suffix}` };
|
||||||
|
let adminCookie: string;
|
||||||
|
let plainCookie: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
// A real directory: the point is that bytes land somewhere and come back.
|
||||||
|
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-branding-'));
|
||||||
|
process.env.BRANDING_DIR = brandingDir;
|
||||||
|
app = await createTestApp();
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
|
||||||
|
const adminUser = await users.createUser({
|
||||||
|
username: admin.username,
|
||||||
|
email: `${admin.username}@example.org`,
|
||||||
|
displayName: `Branding Admin ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(adminUser.id);
|
||||||
|
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||||
|
|
||||||
|
const plainUser = await users.createUser({
|
||||||
|
username: plain.username,
|
||||||
|
email: `${plain.username}@example.org`,
|
||||||
|
displayName: `Branding Plain ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(plainUser.id);
|
||||||
|
|
||||||
|
const login = async (username: string): Promise<string> =>
|
||||||
|
sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
adminCookie = await login(admin.username);
|
||||||
|
plainCookie = await login(plain.username);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.instanceSetting.deleteMany({
|
||||||
|
where: { key: { in: ['instance.logo', 'instance.logoDark', 'instance.favicon'] } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
await rm(brandingDir, { recursive: true, force: true });
|
||||||
|
delete process.env.BRANDING_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the shipped default favicon before anything is uploaded', async () => {
|
||||||
|
// The `<link rel="icon">` in index.html is a constant — this route must
|
||||||
|
// never 404, or the browser keeps its generic icon for good.
|
||||||
|
const res = await api().get('/api/v1/branding/favicon').expect(200);
|
||||||
|
expect(res.headers['content-type']).toContain('image/png');
|
||||||
|
expect(res.body.subarray(0, 8).toString('latin1')).toContain('PNG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a logo, reports it, and serves the bytes without a session', async () => {
|
||||||
|
const bytes = await png(64);
|
||||||
|
const view = await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', bytes, 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
|
||||||
|
expect(view.body.logoDark).toBeNull();
|
||||||
|
|
||||||
|
// On disk, under the key the pond override (#307) will extend.
|
||||||
|
const onDisk = await readFile(join(brandingDir, 'instance-logo-light.png'));
|
||||||
|
expect(onDisk.length).toBe(bytes.length);
|
||||||
|
|
||||||
|
// Anonymous: the login screen carries the branding.
|
||||||
|
const served = await api().get('/api/v1/branding/logo?variant=light').expect(200);
|
||||||
|
expect(served.headers['content-type']).toContain('image/png');
|
||||||
|
const anon = await api().get('/api/v1/branding').expect(200);
|
||||||
|
expect(anon.body.logo.hash).toBe(view.body.logo.hash);
|
||||||
|
expect(anon.body.instanceName).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 for a logo variant that was never uploaded', async () => {
|
||||||
|
// No shipped default for the logo: without one the app renders the
|
||||||
|
// instance NAME, so an empty answer is the honest one.
|
||||||
|
await api().get('/api/v1/branding/logo?variant=dark').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an SVG with its own message, not a generic one', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', Buffer.from('<?xml version="1.0"?><svg xmlns="..."><script/></svg>'), 'x.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_svg_rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bytes that are not a PNG at all', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', Buffer.from('GIF89a and then some'), 'x.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_not_a_png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a logo larger than the maximum edge', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', await png(600), 'x.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_image_too_large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes both favicon sizes together and serves each back', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/branding/favicon')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('png-32', await png(32), 'f32.png')
|
||||||
|
.attach('png-180', await png(180), 'f180.png')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
for (const size of [32, 180]) {
|
||||||
|
const res = await api().get(`/api/v1/branding/favicon?size=${size}`).expect(200);
|
||||||
|
expect(res.body.length).toBe((await png(size)).length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a favicon whose bytes do not match the size they claim', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/branding/favicon')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('png-32', await png(64), 'f32.png')
|
||||||
|
.attach('png-180', await png(180), 'f180.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_favicon_not_square');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears an asset and falls back again', async () => {
|
||||||
|
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', adminCookie).expect(200);
|
||||||
|
const view = await api().get('/api/v1/branding').expect(200);
|
||||||
|
expect(view.body.favicon).toBeNull();
|
||||||
|
// Back to the shipped default rather than a 404.
|
||||||
|
await api().get('/api/v1/branding/favicon').expect(200);
|
||||||
|
|
||||||
|
await api()
|
||||||
|
.delete('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
await api().get('/api/v1/branding/logo?variant=light').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps management away from a non-admin, but not reading', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=light')
|
||||||
|
.set('Cookie', plainCookie)
|
||||||
|
.attach('file', await png(32), 'x.png')
|
||||||
|
.expect(403);
|
||||||
|
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', plainCookie).expect(403);
|
||||||
|
await api().get('/api/v1/branding').set('Cookie', plainCookie).expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits every branding change with scope, asset and direction', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/branding/logo?variant=dark')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', await png(48), 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
const entry = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'branding.changed', targetId: 'instance.logoDark' },
|
||||||
|
orderBy: { at: 'desc' },
|
||||||
|
});
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.details).toMatchObject({ scope: 'instance', asset: 'logoDark', change: 'set' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write branding metadata through the settings endpoint', async () => {
|
||||||
|
// The metadata describes bytes on disk; hand-writing it would claim an
|
||||||
|
// asset that is not there, so the settings PATCH does not accept it.
|
||||||
|
const res = await api()
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ 'instance.logo': { hash: 'deadbeefdeadbeef', width: 10, height: 10 } })
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('bad_request');
|
||||||
|
});
|
||||||
|
});
|
||||||
23
apps/api/src/branding/branding.module.ts
Normal file
23
apps/api/src/branding/branding.module.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PermissionsModule } from '../permissions/permissions.module';
|
||||||
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BrandingAdminController,
|
||||||
|
BrandingController,
|
||||||
|
PondBrandingController,
|
||||||
|
} from './branding.controller';
|
||||||
|
import { BrandingStorageService } from './branding-storage.service';
|
||||||
|
import { BrandingService } from './branding.service';
|
||||||
|
|
||||||
|
/** Instance branding — logo and favicon (issue #306). Exports the services so
|
||||||
|
* the pond-level override (#307) can build on the same storage and the same
|
||||||
|
* resolution path instead of a parallel one. */
|
||||||
|
@Module({
|
||||||
|
imports: [PermissionsModule, QuotasModule],
|
||||||
|
controllers: [BrandingController, BrandingAdminController, PondBrandingController],
|
||||||
|
providers: [BrandingService, BrandingStorageService],
|
||||||
|
exports: [BrandingService, BrandingStorageService],
|
||||||
|
})
|
||||||
|
export class BrandingModule {}
|
||||||
380
apps/api/src/branding/branding.service.ts
Normal file
380
apps/api/src/branding/branding.service.ts
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
BrandingAsset,
|
||||||
|
BrandingView,
|
||||||
|
FAVICON_SIZES,
|
||||||
|
FaviconSize,
|
||||||
|
LOGO_VARIANTS,
|
||||||
|
LogoVariant,
|
||||||
|
PondBranding,
|
||||||
|
pondSettingsSchema,
|
||||||
|
MAX_BRANDING_BYTES,
|
||||||
|
MAX_LOGO_EDGE,
|
||||||
|
hasPngMagic,
|
||||||
|
looksLikeSvg,
|
||||||
|
pngDimensions,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import { User } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { QuotaService } from '../quotas/quota.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
import { BrandingStorageService } from './branding-storage.service';
|
||||||
|
|
||||||
|
/** The settings key each instance asset's metadata lives under. */
|
||||||
|
const INSTANCE_KEYS = {
|
||||||
|
logoLight: 'instance.logo',
|
||||||
|
logoDark: 'instance.logoDark',
|
||||||
|
favicon: 'instance.favicon',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance branding (issue #306): the logo shown at the top of the sidebar and
|
||||||
|
* the favicon served to the browser.
|
||||||
|
*
|
||||||
|
* The api stores and serves bytes; it never decodes them. Validation is the
|
||||||
|
* PNG signature, the IHDR dimensions and the size cap — see
|
||||||
|
* `packages/shared/src/branding.ts` for why that line is drawn there.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BrandingService {
|
||||||
|
constructor(
|
||||||
|
private readonly settings: InstanceSettingsService,
|
||||||
|
private readonly storage: BrandingStorageService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly quotas: QuotaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static logoKey(variant: LogoVariant): string {
|
||||||
|
return `instance-logo-${variant}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static faviconKey(size: FaviconSize): string {
|
||||||
|
return `instance-favicon-${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pond assets share the directory and the naming rules (issue #307); the
|
||||||
|
* pond id keeps them apart and makes purge a prefix delete. */
|
||||||
|
static pondLogoKey(pondId: string, variant: LogoVariant): string {
|
||||||
|
return `pond-${pondId}-logo-${variant}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static pondFaviconKey(pondId: string, size: FaviconSize): string {
|
||||||
|
return `pond-${pondId}-favicon-${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every branding file a pond can own — the purge deletes exactly this set
|
||||||
|
* (issue #307). The purge standard is absolute: after it, nothing
|
||||||
|
* referencing the pond survives, rows or files. */
|
||||||
|
static pondKeys(pondId: string): string[] {
|
||||||
|
return [
|
||||||
|
...LOGO_VARIANTS.map((variant) => BrandingService.pondLogoKey(pondId, variant)),
|
||||||
|
...FAVICON_SIZES.map((size) => BrandingService.pondFaviconKey(pondId, size)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects anything that is not a PNG within the caps, before a byte is
|
||||||
|
* written. SVG gets its own message: an operator who tried one deserves to
|
||||||
|
* learn that it is refused on purpose, not that "the file is broken".
|
||||||
|
*/
|
||||||
|
private assertUsablePng(bytes: Buffer, maxEdge: number): { width: number; height: number } {
|
||||||
|
if (bytes.length === 0) throw new BadRequestException({ code: 'branding_file_empty' });
|
||||||
|
if (bytes.length > MAX_BRANDING_BYTES) {
|
||||||
|
throw new BadRequestException({ code: 'branding_file_too_large' });
|
||||||
|
}
|
||||||
|
if (looksLikeSvg(bytes)) throw new BadRequestException({ code: 'branding_svg_rejected' });
|
||||||
|
if (!hasPngMagic(bytes)) throw new BadRequestException({ code: 'branding_not_a_png' });
|
||||||
|
const size = pngDimensions(bytes);
|
||||||
|
if (!size) throw new BadRequestException({ code: 'branding_not_a_png' });
|
||||||
|
if (size.width > maxEdge || size.height > maxEdge) {
|
||||||
|
throw new BadRequestException({ code: 'branding_image_too_large' });
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve the pond's storage for a branding asset, releasing what the asset
|
||||||
|
* it replaces occupied. Doing it in that order means replacing a logo with
|
||||||
|
* one of the same size costs nothing — otherwise every re-upload would eat
|
||||||
|
* the quota again, which is how "a pond admin fills the disk with logos"
|
||||||
|
* happens.
|
||||||
|
*/
|
||||||
|
private async chargeQuota(
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
bytes: number,
|
||||||
|
previous: BrandingAsset | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
|
||||||
|
try {
|
||||||
|
await this.quotas.checkAndConsume(pond.id, pond.ownerId, bytes);
|
||||||
|
} catch (error) {
|
||||||
|
// Put the released reservation back: a refused upload must not leave
|
||||||
|
// the pond with MORE room than before.
|
||||||
|
if (previous?.byteSize) {
|
||||||
|
await this.quotas.checkAndConsume(pond.id, pond.ownerId, previous.byteSize);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset {
|
||||||
|
return {
|
||||||
|
// Short digest: it only has to change when the bytes change, and it
|
||||||
|
// travels in every logo URL.
|
||||||
|
hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
|
||||||
|
byteSize: bytes.length,
|
||||||
|
...size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async view(): Promise<BrandingView> {
|
||||||
|
const [logo, logoDark, favicon, instanceName] = await Promise.all([
|
||||||
|
this.settings.get(INSTANCE_KEYS.logoLight),
|
||||||
|
this.settings.get(INSTANCE_KEYS.logoDark),
|
||||||
|
this.settings.get(INSTANCE_KEYS.favicon),
|
||||||
|
this.settings.get('instance.name'),
|
||||||
|
]);
|
||||||
|
return { logo, logoDark, favicon, instanceName };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLogo(admin: User, variant: LogoVariant, bytes: Buffer): Promise<BrandingView> {
|
||||||
|
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
|
||||||
|
await this.storage.save(BrandingService.logoKey(variant), bytes);
|
||||||
|
await this.settings.set(
|
||||||
|
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
|
||||||
|
this.assetOf(bytes, size),
|
||||||
|
admin.id,
|
||||||
|
);
|
||||||
|
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'set');
|
||||||
|
return this.view();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearLogo(admin: User, variant: LogoVariant): Promise<BrandingView> {
|
||||||
|
await this.storage.remove(BrandingService.logoKey(variant));
|
||||||
|
await this.settings.set(
|
||||||
|
variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight,
|
||||||
|
null,
|
||||||
|
admin.id,
|
||||||
|
);
|
||||||
|
await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'cleared');
|
||||||
|
return this.view();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both favicon sizes arrive together: the browser produced them from one
|
||||||
|
* source on the same canvas, and the api cannot resize. Storing them as a
|
||||||
|
* pair keeps the tab icon and the home-screen icon from ever showing two
|
||||||
|
* different images.
|
||||||
|
*/
|
||||||
|
async setFavicon(admin: User, files: Record<FaviconSize, Buffer>): Promise<BrandingView> {
|
||||||
|
const sizes = Object.entries(files).map(([declared, bytes]) => {
|
||||||
|
const size = this.assertUsablePng(bytes, 512);
|
||||||
|
const expected = Number(declared);
|
||||||
|
if (size.width !== expected || size.height !== expected) {
|
||||||
|
throw new BadRequestException({ code: 'branding_favicon_not_square' });
|
||||||
|
}
|
||||||
|
return { expected: expected as FaviconSize, bytes, size };
|
||||||
|
});
|
||||||
|
for (const entry of sizes) {
|
||||||
|
await this.storage.save(BrandingService.faviconKey(entry.expected), entry.bytes);
|
||||||
|
}
|
||||||
|
// The 32px variant identifies the pair — it is what the tab shows.
|
||||||
|
const small = sizes.find((entry) => entry.expected === 32)!;
|
||||||
|
await this.settings.set(INSTANCE_KEYS.favicon, this.assetOf(small.bytes, small.size), admin.id);
|
||||||
|
await this.record(admin, 'favicon', 'set');
|
||||||
|
return this.view();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearFavicon(admin: User): Promise<BrandingView> {
|
||||||
|
await this.storage.remove(BrandingService.faviconKey(32));
|
||||||
|
await this.storage.remove(BrandingService.faviconKey(180));
|
||||||
|
await this.settings.set(INSTANCE_KEYS.favicon, null, admin.id);
|
||||||
|
await this.record(admin, 'favicon', 'cleared');
|
||||||
|
return this.view();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bytes to serve for a logo variant, or null when none is stored. */
|
||||||
|
logoBytes(variant: LogoVariant): Promise<Buffer | null> {
|
||||||
|
return this.storage.read(BrandingService.logoKey(variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The favicon bytes: the uploaded one, else the shipped default. The
|
||||||
|
* `<link rel="icon">` in index.html is static, so this route must always
|
||||||
|
* answer with an image — a 404 there would leave the browser's generic
|
||||||
|
* icon for good.
|
||||||
|
*/
|
||||||
|
async faviconBytes(size: FaviconSize): Promise<{ bytes: Buffer; uploaded: boolean }> {
|
||||||
|
const stored = await this.storage.read(BrandingService.faviconKey(size));
|
||||||
|
if (stored) return { bytes: stored, uploaded: true };
|
||||||
|
const bytes = await readFile(join(__dirname, '../../assets', `default-favicon-${size}.png`));
|
||||||
|
return { bytes, uploaded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pond's own branding, defaulted — one place reads the settings blob. */
|
||||||
|
async pondBranding(pondId: string): Promise<PondBranding> {
|
||||||
|
const pond = await this.prisma.pond.findUnique({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
if (!pond) throw new NotFoundException();
|
||||||
|
return pondSettingsSchema.parse(pond.settings ?? {}).branding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writePondBranding(
|
||||||
|
actor: User,
|
||||||
|
pondId: string,
|
||||||
|
next: PondBranding,
|
||||||
|
asset: 'logo' | 'logoDark' | 'favicon',
|
||||||
|
change: 'set' | 'cleared',
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const pond = await this.prisma.pond.findUniqueOrThrow({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
||||||
|
await this.prisma.pond.update({
|
||||||
|
where: { id: pondId },
|
||||||
|
data: { settings: { ...settings, branding: next } as object },
|
||||||
|
});
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'branding.changed',
|
||||||
|
actorId: actor.id,
|
||||||
|
targetType: 'pond',
|
||||||
|
targetId: pondId,
|
||||||
|
details: { scope: 'pond', pondId, asset, change },
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pond logo, charged to the pond's storage quota (issue #307).
|
||||||
|
*
|
||||||
|
* Without the charge, branding would be a way around the quota — and
|
||||||
|
* replacing a logo repeatedly would let a pond admin consume disk with no
|
||||||
|
* ceiling. Charged BEFORE the write, like attachments, so a race never
|
||||||
|
* leaves bytes on the volume without a reservation; the bytes a replaced
|
||||||
|
* asset frees are released first, so re-uploading the same logo is free
|
||||||
|
* rather than cumulative.
|
||||||
|
*/
|
||||||
|
async setPondLogo(
|
||||||
|
actor: User,
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
variant: LogoVariant,
|
||||||
|
bytes: Buffer,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE);
|
||||||
|
const current = await this.pondBranding(pond.id);
|
||||||
|
const previous = variant === 'dark' ? current.logoDark : current.logo;
|
||||||
|
await this.chargeQuota(pond, bytes.length, previous);
|
||||||
|
await this.storage.save(BrandingService.pondLogoKey(pond.id, variant), bytes);
|
||||||
|
const asset = this.assetOf(bytes, size);
|
||||||
|
return this.writePondBranding(
|
||||||
|
actor,
|
||||||
|
pond.id,
|
||||||
|
variant === 'dark' ? { ...current, logoDark: asset } : { ...current, logo: asset },
|
||||||
|
variant === 'dark' ? 'logoDark' : 'logo',
|
||||||
|
'set',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearPondLogo(
|
||||||
|
actor: User,
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
variant: LogoVariant,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const current = await this.pondBranding(pond.id);
|
||||||
|
const previous = variant === 'dark' ? current.logoDark : current.logo;
|
||||||
|
await this.storage.remove(BrandingService.pondLogoKey(pond.id, variant));
|
||||||
|
if (previous?.byteSize) await this.quotas.release(pond.id, previous.byteSize);
|
||||||
|
return this.writePondBranding(
|
||||||
|
actor,
|
||||||
|
pond.id,
|
||||||
|
variant === 'dark' ? { ...current, logoDark: null } : { ...current, logo: null },
|
||||||
|
variant === 'dark' ? 'logoDark' : 'logo',
|
||||||
|
'cleared',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPondFavicon(
|
||||||
|
actor: User,
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
files: Record<FaviconSize, Buffer>,
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const checked = Object.entries(files).map(([declared, bytes]) => {
|
||||||
|
const size = this.assertUsablePng(bytes, 512);
|
||||||
|
const expected = Number(declared);
|
||||||
|
if (size.width !== expected || size.height !== expected) {
|
||||||
|
throw new BadRequestException({ code: 'branding_favicon_not_square' });
|
||||||
|
}
|
||||||
|
return { expected: expected as FaviconSize, bytes, size };
|
||||||
|
});
|
||||||
|
const current = await this.pondBranding(pond.id);
|
||||||
|
const total = checked.reduce((sum, entry) => sum + entry.bytes.length, 0);
|
||||||
|
await this.chargeQuota(pond, total, current.favicon);
|
||||||
|
for (const entry of checked) {
|
||||||
|
await this.storage.save(BrandingService.pondFaviconKey(pond.id, entry.expected), entry.bytes);
|
||||||
|
}
|
||||||
|
const small = checked.find((entry) => entry.expected === 32)!;
|
||||||
|
// The pair is charged together, so the stored size is the pair's — that
|
||||||
|
// is what a later release has to give back.
|
||||||
|
const asset = { ...this.assetOf(small.bytes, small.size), byteSize: total };
|
||||||
|
return this.writePondBranding(actor, pond.id, { ...current, favicon: asset }, 'favicon', 'set');
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearPondFavicon(
|
||||||
|
actor: User,
|
||||||
|
pond: { id: string; ownerId: string },
|
||||||
|
): Promise<PondBranding> {
|
||||||
|
const current = await this.pondBranding(pond.id);
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
await this.storage.remove(BrandingService.pondFaviconKey(pond.id, size));
|
||||||
|
}
|
||||||
|
if (current.favicon?.byteSize) await this.quotas.release(pond.id, current.favicon.byteSize);
|
||||||
|
return this.writePondBranding(
|
||||||
|
actor,
|
||||||
|
pond.id,
|
||||||
|
{ ...current, favicon: null },
|
||||||
|
'favicon',
|
||||||
|
'cleared',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes for a pond asset — null when the pond has none at that slot, which
|
||||||
|
* is what makes the caller fall back to the instance level. */
|
||||||
|
pondLogoBytes(pondId: string, variant: LogoVariant): Promise<Buffer | null> {
|
||||||
|
return this.storage.read(BrandingService.pondLogoKey(pondId, variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
pondFaviconBytes(pondId: string, size: FaviconSize): Promise<Buffer | null> {
|
||||||
|
return this.storage.read(BrandingService.pondFaviconKey(pondId, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes every branding file of a pond (issue #307's purge obligation). */
|
||||||
|
async removePondAssets(pondId: string): Promise<void> {
|
||||||
|
for (const key of BrandingService.pondKeys(pondId)) await this.storage.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(
|
||||||
|
admin: User,
|
||||||
|
asset: 'logo' | 'logoDark' | 'favicon',
|
||||||
|
action: 'set' | 'cleared',
|
||||||
|
): Promise<unknown> {
|
||||||
|
// `scope` is here from the start so the pond-level change (#307) is the
|
||||||
|
// same event with a different scope, not a second id in the catalogue.
|
||||||
|
return this.audit.record({
|
||||||
|
action: 'branding.changed',
|
||||||
|
actorId: admin.id,
|
||||||
|
targetType: 'setting',
|
||||||
|
targetId: `instance.${asset}`,
|
||||||
|
details: { scope: 'instance', asset, change: action },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
256
apps/api/src/branding/pond-branding.e2e.db.test.ts
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import {
|
||||||
|
createTestPrisma,
|
||||||
|
deletePondsWhere,
|
||||||
|
grantOwnerAdmin,
|
||||||
|
hasTestDb,
|
||||||
|
uniqueSuffix,
|
||||||
|
} from '../testing/test-db';
|
||||||
|
import { TrashService } from '../trash/trash.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
import { BrandingService } from './branding.service';
|
||||||
|
import { BrandingStorageService } from './branding-storage.service';
|
||||||
|
|
||||||
|
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
return c >>> 0;
|
||||||
|
});
|
||||||
|
function crc32(buf: Buffer): number {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
function chunk(type: string, data: Buffer): Buffer {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length);
|
||||||
|
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(body));
|
||||||
|
return Buffer.concat([length, body, crc]);
|
||||||
|
}
|
||||||
|
/** A real PNG — the api reads the IHDR, so the header has to be genuine. */
|
||||||
|
function png(size: number): Buffer {
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(size, 0);
|
||||||
|
ihdr.writeUInt32BE(size, 4);
|
||||||
|
ihdr[8] = 8;
|
||||||
|
ihdr[9] = 6;
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(Buffer.alloc(size * (size * 4 + 1)))),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('pond branding (e2e, issue #307)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let storage: BrandingStorageService;
|
||||||
|
let brandingDir: string;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'teichmarke mit eigenem logo 1';
|
||||||
|
const owner = { username: `pb-${suffix}` };
|
||||||
|
const member = { username: `pbm-${suffix}` };
|
||||||
|
let ownerCookie: string;
|
||||||
|
let memberCookie: string;
|
||||||
|
let pondId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-pondbranding-'));
|
||||||
|
process.env.BRANDING_DIR = brandingDir;
|
||||||
|
app = await createTestApp();
|
||||||
|
storage = app.get(BrandingStorageService);
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const tokens = app.get(AuthTokensService);
|
||||||
|
// Verification through the endpoint, not `markEmailVerified`: only this
|
||||||
|
// path creates the personal pond these tests brand.
|
||||||
|
const verify = async (userId: string): Promise<void> => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/verify-email')
|
||||||
|
.send({ token: await tokens.issue(userId, 'EMAIL_VERIFICATION', 600) })
|
||||||
|
.expect(204);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownerUser = await users.createUser({
|
||||||
|
username: owner.username,
|
||||||
|
email: `${owner.username}@example.org`,
|
||||||
|
displayName: `Pond Branding Owner ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await verify(ownerUser.id);
|
||||||
|
const memberUser = await users.createUser({
|
||||||
|
username: member.username,
|
||||||
|
email: `${member.username}@example.org`,
|
||||||
|
displayName: `Pond Branding Member ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await verify(memberUser.id);
|
||||||
|
|
||||||
|
const login = async (username: string): Promise<string> =>
|
||||||
|
sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
ownerCookie = await login(owner.username);
|
||||||
|
memberCookie = await login(member.username);
|
||||||
|
|
||||||
|
pondId = (
|
||||||
|
await prisma.pond.findFirstOrThrow({ where: { ownerId: ownerUser.id, type: 'PERSONAL' } })
|
||||||
|
).id;
|
||||||
|
// A reader on the same pond: may see it, may not administer it. Through
|
||||||
|
// the API, not a raw row — the permission cache would not see the row
|
||||||
|
// (the documented rule for grants in tests).
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/grants`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({
|
||||||
|
subjectType: 'user',
|
||||||
|
subjectId: memberUser.id,
|
||||||
|
role: 'reader',
|
||||||
|
scopeType: 'pond',
|
||||||
|
effect: 'allow',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.roleGrant.deleteMany({
|
||||||
|
where: { pond: { owner: { username: { contains: suffix } } } },
|
||||||
|
});
|
||||||
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
await rm(brandingDir, { recursive: true, force: true });
|
||||||
|
delete process.env.BRANDING_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a pond logo, reports it, and serves it under the pond scope', async () => {
|
||||||
|
const view = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', png(64), 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
|
||||||
|
|
||||||
|
const served = await api()
|
||||||
|
.get(`/api/v1/branding/logo?variant=light&pond=${pondId}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(served.headers['content-type']).toContain('image/png');
|
||||||
|
|
||||||
|
// Without the pond scope the instance level answers — 404 here, since no
|
||||||
|
// instance logo is set. The two levels never leak into each other.
|
||||||
|
await api().get('/api/v1/branding/logo?variant=light').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges the pond quota and gives the bytes back when the logo is replaced', async () => {
|
||||||
|
const usageOf = async (): Promise<number> =>
|
||||||
|
Number(
|
||||||
|
(
|
||||||
|
await prisma.pondUsage.findUnique({
|
||||||
|
where: { pondId },
|
||||||
|
select: { storageBytesUsed: true },
|
||||||
|
})
|
||||||
|
)?.storageBytesUsed ?? 0,
|
||||||
|
);
|
||||||
|
const before = await usageOf();
|
||||||
|
|
||||||
|
const big = png(120);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', big, 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
const afterUpload = await usageOf();
|
||||||
|
expect(afterUpload).toBe(before + big.length);
|
||||||
|
|
||||||
|
// Replacing releases the old reservation first — otherwise re-uploading
|
||||||
|
// the same logo would eat the quota again and again.
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', big, 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(await usageOf()).toBe(afterUpload);
|
||||||
|
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/ponds/${pondId}/branding/logo?variant=dark`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(await usageOf()).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses SVG at the pond level too — the rules do not relax for a pond admin', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', Buffer.from('<svg xmlns="x"><script/></svg>'), 'x.png')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('branding_svg_rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a member read the pond branding but not change it', async () => {
|
||||||
|
await api().get(`/api/v1/ponds/${pondId}/branding`).set('Cookie', memberCookie).expect(200);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/branding/logo?variant=light`)
|
||||||
|
.set('Cookie', memberCookie)
|
||||||
|
.attach('file', png(32), 'x.png')
|
||||||
|
.expect(403);
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/ponds/${pondId}/branding/favicon`)
|
||||||
|
.set('Cookie', memberCookie)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('purging the pond removes its branding files', async () => {
|
||||||
|
// A pond of its own, so the purge does not take the shared fixture with it.
|
||||||
|
const ownerRow = await prisma.user.findFirstOrThrow({ where: { username: owner.username } });
|
||||||
|
const created = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
name: `Purge Branding ${suffix}`,
|
||||||
|
slug: `purge-branding-${suffix}`,
|
||||||
|
type: 'SHARED',
|
||||||
|
ownerId: ownerRow.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Raw grant row, before this pond's first permission query — the
|
||||||
|
// documented exception to "grants through the API".
|
||||||
|
await grantOwnerAdmin(prisma, created.id, ownerRow.id);
|
||||||
|
await api()
|
||||||
|
.post(`/api/v1/ponds/${created.id}/branding/logo?variant=light`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', png(48), 'logo.png')
|
||||||
|
.expect(201);
|
||||||
|
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).not.toBeNull();
|
||||||
|
|
||||||
|
await prisma.pond.update({ where: { id: created.id }, data: { deletedAt: new Date() } });
|
||||||
|
const trash = app.get(TrashService);
|
||||||
|
await trash.purgePondNow(ownerRow, created.id);
|
||||||
|
|
||||||
|
// The purge standard is absolute: after it nothing referencing the pond
|
||||||
|
// survives — rows OR files.
|
||||||
|
expect(await storage.read(BrandingService.pondLogoKey(created.id, 'light'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { createTestApp } from '../testing/test-app';
|
import { createTestApp } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
import { FileStorageService } from './file-storage.service';
|
import { FileStorageService } from './file-storage.service';
|
||||||
@ -74,7 +74,7 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
|
|||||||
await prisma.attachment.deleteMany({ where: { pondId } });
|
await prisma.attachment.deleteMany({ where: { pondId } });
|
||||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
await prisma.roleGrant.deleteMany({ where });
|
await prisma.roleGrant.deleteMany({ where });
|
||||||
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
55
apps/api/src/fonts/custom-font-storage.service.ts
Normal file
55
apps/api/src/fonts/custom-font-storage.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { FontUploadFormat } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem binding for operator-uploaded fonts (issue #303, ADR 0016 §#303).
|
||||||
|
*
|
||||||
|
* The layout mirrors the baked-in catalog — `<slug>/<slug>-<weight>.woff2` —
|
||||||
|
* so the PDF exporter's `@font-face` builder needs no special case beyond
|
||||||
|
* choosing the directory.
|
||||||
|
*
|
||||||
|
* That directory is `CUSTOM_FONTS_DIR`, NOT `FONTS_DIR`: the latter is baked
|
||||||
|
* into the image, so anything written there disappears on the next deploy and
|
||||||
|
* never reaches a backup. This one is a sibling of the uploads and plugins
|
||||||
|
* mounts and travels in the restore set (`apps/backup/src/data-dirs.ts`).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomFontStorageService {
|
||||||
|
constructor(private readonly config: AppConfig) {}
|
||||||
|
|
||||||
|
private dirFor(slug: string): string {
|
||||||
|
return join(this.config.env.CUSTOM_FONTS_DIR, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileNameFor(slug: string, weight: number, format: FontUploadFormat): string {
|
||||||
|
return `${slug}-${weight}.${format}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
pathFor(slug: string, weight: number, format: FontUploadFormat): string {
|
||||||
|
return join(this.dirFor(slug), this.fileNameFor(slug, weight, format));
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(slug: string, weight: number, format: FontUploadFormat, bytes: Buffer): Promise<void> {
|
||||||
|
await mkdir(this.dirFor(slug), { recursive: true });
|
||||||
|
await writeFile(this.pathFor(slug, weight, format), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
read(slug: string, weight: number, format: FontUploadFormat): Promise<Buffer> {
|
||||||
|
return readFile(this.pathFor(slug, weight, format));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the family's whole directory. Missing is fine — deletion must
|
||||||
|
* stay idempotent so a half-failed upload can still be cleaned up. */
|
||||||
|
async deleteFamily(slug: string): Promise<void> {
|
||||||
|
await rm(this.dirFor(slug), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWeight(slug: string, weight: number, format: FontUploadFormat): Promise<void> {
|
||||||
|
await rm(this.pathFor(slug, weight, format), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
164
apps/api/src/fonts/custom-fonts.controller.ts
Normal file
164
apps/api/src/fonts/custom-fonts.controller.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
CustomFontView,
|
||||||
|
FONT_WEIGHTS,
|
||||||
|
MAX_FONT_FILE_BYTES,
|
||||||
|
createCustomFontInputSchema,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||||
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||||
|
import { CustomFontStorageService } from './custom-font-storage.service';
|
||||||
|
import { CustomFontsService, WeightUpload } from './custom-fonts.service';
|
||||||
|
|
||||||
|
/** Multipart field names: `woff2-<weight>` and the optional `woff-<weight>`. */
|
||||||
|
const FILE_FIELD = /^(woff2|woff)-(\d{3})$/;
|
||||||
|
|
||||||
|
function parseUploads(files: Express.Multer.File[] | undefined): WeightUpload[] {
|
||||||
|
const byWeight = new Map<number, WeightUpload>();
|
||||||
|
for (const file of files ?? []) {
|
||||||
|
const match = FILE_FIELD.exec(file.fieldname);
|
||||||
|
if (!match) throw new BadRequestException({ code: 'font_unexpected_field' });
|
||||||
|
const weight = Number(match[2]);
|
||||||
|
if (!(FONT_WEIGHTS as readonly number[]).includes(weight)) {
|
||||||
|
throw new BadRequestException({ code: 'font_weight_invalid' });
|
||||||
|
}
|
||||||
|
const entry = byWeight.get(weight) ?? { weight, woff2: Buffer.alloc(0) };
|
||||||
|
if (match[1] === 'woff2') entry.woff2 = file.buffer;
|
||||||
|
else entry.woff = file.buffer;
|
||||||
|
byWeight.set(weight, entry);
|
||||||
|
}
|
||||||
|
// A WOFF without its WOFF2 would produce a weight the PDF path cannot
|
||||||
|
// embed — the exporter reads WOFF2 only.
|
||||||
|
for (const entry of byWeight.values()) {
|
||||||
|
if (entry.woff2.length === 0) throw new BadRequestException({ code: 'font_woff2_missing' });
|
||||||
|
}
|
||||||
|
return [...byWeight.values()].sort((a, b) => a.weight - b.weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Site-Admin management of operator-uploaded fonts (issue #303). */
|
||||||
|
@Controller('admin/fonts')
|
||||||
|
@UseGuards(SiteAdminGuard)
|
||||||
|
export class CustomFontsAdminController {
|
||||||
|
constructor(private readonly fonts: CustomFontsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(): Promise<CustomFontView[]> {
|
||||||
|
return this.fonts.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_FONT_FILE_BYTES } }))
|
||||||
|
async create(
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<CustomFontView> {
|
||||||
|
// The metadata rides as ordinary multipart fields next to the files.
|
||||||
|
const input = createCustomFontInputSchema.parse({
|
||||||
|
family: request.body?.family,
|
||||||
|
category: request.body?.category,
|
||||||
|
licence: request.body?.licence,
|
||||||
|
licenceUrl: request.body?.licenceUrl || null,
|
||||||
|
});
|
||||||
|
return this.fonts.create(request.user!, input, parseUploads(files));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/weights')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_FONT_FILE_BYTES } }))
|
||||||
|
async addWeight(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||||
|
): Promise<CustomFontView> {
|
||||||
|
const uploads = parseUploads(files);
|
||||||
|
if (uploads.length !== 1) throw new BadRequestException({ code: 'font_one_weight_expected' });
|
||||||
|
return this.fonts.addWeight(request.user!, id, uploads[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many live ponds still use the family — shown before deleting. */
|
||||||
|
@Get(':id/usage')
|
||||||
|
async usage(@Param('id') id: string): Promise<{ pondsAffected: number }> {
|
||||||
|
const font = (await this.fonts.list()).find((entry) => entry.id === id);
|
||||||
|
if (!font) throw new BadRequestException({ code: 'not_found' });
|
||||||
|
return { pondsAffected: await this.fonts.pondsUsing(font.family) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||||
|
await this.fonts.remove(request.user!, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading side of the uploaded fonts: the family list every signed-in user
|
||||||
|
* needs, and the bytes themselves.
|
||||||
|
*
|
||||||
|
* The listing is NOT site-admin-gated (issue #304): every signed-in user picks
|
||||||
|
* fonts in their pond's Appearance settings, reads the licence page, and needs
|
||||||
|
* the `@font-face` rules injected — the admin list at `/admin/fonts` carries
|
||||||
|
* the same data, so gating this one would only force a second, admin-only UI.
|
||||||
|
*
|
||||||
|
* The file route is unauthenticated on purpose: a font is referenced from CSS,
|
||||||
|
* and the login screen carries the pond-independent chrome — an authenticated
|
||||||
|
* font URL would simply not load. The bytes are branding, not content.
|
||||||
|
*/
|
||||||
|
@Controller('fonts/custom')
|
||||||
|
export class CustomFontsFileController {
|
||||||
|
constructor(
|
||||||
|
private readonly storage: CustomFontStorageService,
|
||||||
|
private readonly fonts: CustomFontsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Explicit access declaration, as every route needs (issue #52's fence
|
||||||
|
// `route-permissions.e2e.db.test.ts`): a session, no further permission —
|
||||||
|
// the list says which families exist, which is what the pickers offer.
|
||||||
|
@AuthenticatedOnly()
|
||||||
|
@Get()
|
||||||
|
list(): Promise<CustomFontView[]> {
|
||||||
|
return this.fonts.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get(':slug/:file')
|
||||||
|
async serve(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Param('file') file: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const match = /^([a-z0-9-]+)-(\d{3})\.(woff2|woff)$/.exec(file);
|
||||||
|
// The slug must match the file's own prefix, so the path cannot be used
|
||||||
|
// to reach a different family's directory.
|
||||||
|
if (!match || match[1] !== slug) throw new BadRequestException({ code: 'not_found' });
|
||||||
|
|
||||||
|
const known = (await this.fonts.list()).find((entry) => entry.slug === slug);
|
||||||
|
if (!known) throw new BadRequestException({ code: 'not_found' });
|
||||||
|
|
||||||
|
const format = match[3] as 'woff2' | 'woff';
|
||||||
|
const bytes = await this.storage
|
||||||
|
.read(slug, Number(match[2]), format)
|
||||||
|
.catch(() => Promise.reject(new BadRequestException({ code: 'not_found' })));
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', format === 'woff2' ? 'font/woff2' : 'font/woff');
|
||||||
|
// Slug + weight + format identify the bytes; a changed family is a new
|
||||||
|
// upload under a new id, so a long lifetime is safe.
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
res.send(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
apps/api/src/fonts/custom-fonts.e2e.db.test.ts
Normal file
244
apps/api/src/fonts/custom-fonts.e2e.db.test.ts
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/** Smallest bytes that pass the magic check — the api never parses further. */
|
||||||
|
const woff2 = (): Buffer => Buffer.concat([Buffer.from('wOF2'), Buffer.alloc(64)]);
|
||||||
|
const woff = (): Buffer => Buffer.concat([Buffer.from('wOFF'), Buffer.alloc(64)]);
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let fontsDir: string;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'schriftverwaltung mit stil 1';
|
||||||
|
const admin = { username: `fa-${suffix}`, displayName: `Font Admin ${suffix}` };
|
||||||
|
const plain = { username: `fp-${suffix}`, displayName: `Font Plain ${suffix}` };
|
||||||
|
let adminCookie: string;
|
||||||
|
let plainCookie: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
// A real directory so the storage layer is exercised, not mocked — the
|
||||||
|
// point of this suite is that bytes actually land somewhere retrievable.
|
||||||
|
fontsDir = await mkdtemp(join(tmpdir(), 'dorfteich-fonts-'));
|
||||||
|
process.env.CUSTOM_FONTS_DIR = fontsDir;
|
||||||
|
app = await createTestApp();
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
|
||||||
|
const adminUser = await users.createUser({
|
||||||
|
username: admin.username,
|
||||||
|
email: `${admin.username}@example.org`,
|
||||||
|
displayName: admin.displayName,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(adminUser.id);
|
||||||
|
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||||
|
// additional_ponds defaults to 0 (ADR 0011) and the instance default is
|
||||||
|
// never raised — the usage test needs a pond, so grant an override.
|
||||||
|
await prisma.quotaOverride.create({
|
||||||
|
data: {
|
||||||
|
subjectType: 'USER',
|
||||||
|
subjectId: adminUser.id,
|
||||||
|
quotaKey: 'additional_ponds',
|
||||||
|
value: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plainUser = await users.createUser({
|
||||||
|
username: plain.username,
|
||||||
|
email: `${plain.username}@example.org`,
|
||||||
|
displayName: plain.displayName,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(plainUser.id);
|
||||||
|
|
||||||
|
const login = async (username: string): Promise<string> =>
|
||||||
|
sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
adminCookie = await login(admin.username);
|
||||||
|
plainCookie = await login(plain.username);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.customFont.deleteMany({});
|
||||||
|
const ids = (
|
||||||
|
await prisma.user.findMany({
|
||||||
|
where: { username: { contains: suffix } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((row) => row.id);
|
||||||
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
||||||
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
await rm(fontsDir, { recursive: true, force: true });
|
||||||
|
delete process.env.CUSTOM_FONTS_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads a family, writes the bytes, and serves them back', async () => {
|
||||||
|
const created = await api()
|
||||||
|
.post('/api/v1/admin/fonts')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.field('family', `Hausschrift ${suffix}`)
|
||||||
|
.field('category', 'serif')
|
||||||
|
.field('licence', 'Commercial — Foundry XY')
|
||||||
|
.attach('woff2-400', woff2(), 'x.woff2')
|
||||||
|
.attach('woff-400', woff(), 'x.woff')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(created.body.weights).toEqual([400]);
|
||||||
|
expect(created.body.licence).toBe('Commercial — Foundry XY');
|
||||||
|
|
||||||
|
const slug = created.body.slug as string;
|
||||||
|
// The bytes are really on disk, in the catalog's layout.
|
||||||
|
const onDisk = await readFile(join(fontsDir, slug, `${slug}-400.woff2`));
|
||||||
|
expect(onDisk.subarray(0, 4).toString()).toBe('wOF2');
|
||||||
|
|
||||||
|
// …and reachable without a session: a font is fetched from CSS.
|
||||||
|
const served = await api().get(`/api/v1/fonts/custom/${slug}/${slug}-400.woff2`).expect(200);
|
||||||
|
expect(served.headers['content-type']).toContain('font/woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a file that is not a font, whatever it is called', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/fonts')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.field('family', `Fake ${suffix}`)
|
||||||
|
.field('category', 'sans-serif')
|
||||||
|
.field('licence', 'X')
|
||||||
|
.attach('woff2-400', Buffer.from('\x89PNG\r\n\x1a\n and more'), 'evil.woff2')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('font_file_not_a_font');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a family name that a catalog font already owns', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/fonts')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.field('family', 'Roboto')
|
||||||
|
.field('category', 'sans-serif')
|
||||||
|
.field('licence', 'X')
|
||||||
|
.attach('woff2-400', woff2(), 'x.woff2')
|
||||||
|
.expect(409);
|
||||||
|
expect(res.body.code).toBe('font_family_reserved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a weight whose WOFF2 is missing', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/fonts')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.field('family', `NurWoff ${suffix}`)
|
||||||
|
.field('category', 'sans-serif')
|
||||||
|
.field('licence', 'X')
|
||||||
|
.attach('woff-400', woff(), 'x.woff')
|
||||||
|
.expect(400);
|
||||||
|
expect(res.body.code).toBe('font_woff2_missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #304: an ordinary member picks fonts in their pond's Appearance
|
||||||
|
* settings and reads the licence page, so the family list cannot be
|
||||||
|
* Site-Admin-only — only the management routes are.
|
||||||
|
*/
|
||||||
|
it('lets any signed-in user read the family list, but nobody anonymous', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/fonts')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.field('family', `Leseschrift ${suffix}`)
|
||||||
|
.field('category', 'monospace')
|
||||||
|
.field('licence', 'Read me')
|
||||||
|
.attach('woff2-500', woff2(), 'x.woff2')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const listed = await api().get('/api/v1/fonts/custom').set('Cookie', plainCookie).expect(200);
|
||||||
|
const seen = (listed.body as { family: string; weights: number[] }[]).find(
|
||||||
|
(font) => font.family === `Leseschrift ${suffix}`,
|
||||||
|
);
|
||||||
|
expect(seen?.weights).toEqual([500]);
|
||||||
|
|
||||||
|
await api().get('/api/v1/fonts/custom').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every management route away from a non-admin', async () => {
|
||||||
|
await api().get('/api/v1/admin/fonts').set('Cookie', plainCookie).expect(403);
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/admin/fonts')
|
||||||
|
.set('Cookie', plainCookie)
|
||||||
|
.field('family', `Nope ${suffix}`)
|
||||||
|
.field('category', 'serif')
|
||||||
|
.field('licence', 'X')
|
||||||
|
.attach('woff2-400', woff2(), 'x.woff2')
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the ponds a family is used by, and deletion leaves them working', async () => {
|
||||||
|
const created = await api()
|
||||||
|
.post('/api/v1/admin/fonts')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.field('family', `Zählschrift ${suffix}`)
|
||||||
|
.field('category', 'sans-serif')
|
||||||
|
.field('licence', 'X')
|
||||||
|
.attach('woff2-400', woff2(), 'x.woff2')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const pond = await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ name: `Schriftteich ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/ponds/${pond.body.id}`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.send({ fonts: { body: { family: `Zählschrift ${suffix}`, weight: 400 } } })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const usage = await api()
|
||||||
|
.get(`/api/v1/admin/fonts/${created.body.id}/usage`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(usage.body.pondsAffected).toBe(1);
|
||||||
|
|
||||||
|
// Deletion is never blocked by usage.
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/admin/fonts/${created.body.id}`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
// The pond still resolves — it keeps the stored family name and falls
|
||||||
|
// back to the system stack, rather than breaking.
|
||||||
|
const after = await api()
|
||||||
|
.get(`/api/v1/ponds/${pond.body.slug}`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(after.body.settings.fonts.body.family).toBe(`Zählschrift ${suffix}`);
|
||||||
|
expect(
|
||||||
|
await api().get('/api/v1/admin/fonts').set('Cookie', adminCookie).expect(200),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'font.deleted', targetId: created.body.id },
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
expect(audit!.details).toMatchObject({ pondsAffected: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
249
apps/api/src/fonts/custom-fonts.service.ts
Normal file
249
apps/api/src/fonts/custom-fonts.service.ts
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CreateCustomFontInput,
|
||||||
|
CustomFontView,
|
||||||
|
FONT_CATALOG,
|
||||||
|
FontCategory,
|
||||||
|
FontUploadFormat,
|
||||||
|
MAX_FONT_FILE_BYTES,
|
||||||
|
MAX_FONT_WEIGHTS,
|
||||||
|
fontSlug,
|
||||||
|
hasFontMagic,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import { User } from '@prisma/client';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CustomFontStorageService } from './custom-font-storage.service';
|
||||||
|
|
||||||
|
/** One weight's bytes as they arrive from the controller. */
|
||||||
|
export interface WeightUpload {
|
||||||
|
weight: number;
|
||||||
|
woff2: Buffer;
|
||||||
|
woff?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator-uploaded font families (issue #303, ADR 0016 §#303).
|
||||||
|
*
|
||||||
|
* Site-Admin-only, additive to the compile-time catalog, and deliberately
|
||||||
|
* incurious about the files: the api validates the magic number and the size
|
||||||
|
* and then stores the bytes. Family, category and licence come from the form.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomFontsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: CustomFontStorageService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(CustomFontsService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects bytes that are not what they claim to be, before anything is
|
||||||
|
* written. Deliberately the ONLY inspection: parsing the font would gain
|
||||||
|
* metadata the form already carries, at the price of a known
|
||||||
|
* memory-safety surface (ADR 0016 §#303).
|
||||||
|
*/
|
||||||
|
private assertUsableFont(bytes: Buffer, format: FontUploadFormat): void {
|
||||||
|
if (bytes.length === 0) throw new BadRequestException({ code: 'font_file_empty' });
|
||||||
|
if (bytes.length > MAX_FONT_FILE_BYTES) {
|
||||||
|
throw new BadRequestException({ code: 'font_file_too_large' });
|
||||||
|
}
|
||||||
|
if (!hasFontMagic(bytes, format)) {
|
||||||
|
throw new BadRequestException({ code: 'font_file_not_a_font' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom family must not collide with a catalog one, by name or by slug:
|
||||||
|
* a pond stores `fonts.<slot>.family` as a plain string, so two families
|
||||||
|
* answering to the same name would make the PDF path embed whichever file
|
||||||
|
* it happened to find.
|
||||||
|
*/
|
||||||
|
private async assertNameIsFree(family: string, slug: string): Promise<void> {
|
||||||
|
const catalogHit = FONT_CATALOG.some(
|
||||||
|
(entry) => entry.family === family || fontSlug(entry.family) === slug,
|
||||||
|
);
|
||||||
|
if (catalogHit) throw new ConflictException({ code: 'font_family_reserved' });
|
||||||
|
const existing = await this.prisma.customFont.findFirst({
|
||||||
|
where: { OR: [{ family }, { slug }] },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) throw new ConflictException({ code: 'font_family_exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private viewOf(font: {
|
||||||
|
id: string;
|
||||||
|
family: string;
|
||||||
|
slug: string;
|
||||||
|
category: string;
|
||||||
|
licence: string;
|
||||||
|
licenceUrl: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
weights: { weight: number }[];
|
||||||
|
}): CustomFontView {
|
||||||
|
return {
|
||||||
|
id: font.id,
|
||||||
|
family: font.family,
|
||||||
|
slug: font.slug,
|
||||||
|
category: font.category as FontCategory,
|
||||||
|
licence: font.licence,
|
||||||
|
licenceUrl: font.licenceUrl,
|
||||||
|
weights: font.weights.map((row) => row.weight).sort((a, b) => a - b),
|
||||||
|
createdAt: font.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<CustomFontView[]> {
|
||||||
|
const fonts = await this.prisma.customFont.findMany({
|
||||||
|
orderBy: { family: 'asc' },
|
||||||
|
include: { weights: { select: { weight: true } } },
|
||||||
|
});
|
||||||
|
return fonts.map((font) => this.viewOf(font));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
admin: User,
|
||||||
|
input: CreateCustomFontInput,
|
||||||
|
uploads: WeightUpload[],
|
||||||
|
): Promise<CustomFontView> {
|
||||||
|
if (uploads.length === 0) throw new BadRequestException({ code: 'font_no_weights' });
|
||||||
|
if (uploads.length > MAX_FONT_WEIGHTS) {
|
||||||
|
throw new BadRequestException({ code: 'font_too_many_weights' });
|
||||||
|
}
|
||||||
|
for (const upload of uploads) {
|
||||||
|
this.assertUsableFont(upload.woff2, 'woff2');
|
||||||
|
if (upload.woff) this.assertUsableFont(upload.woff, 'woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = fontSlug(input.family);
|
||||||
|
if (!slug) throw new BadRequestException({ code: 'font_family_unusable' });
|
||||||
|
await this.assertNameIsFree(input.family, slug);
|
||||||
|
|
||||||
|
// Row first, then bytes: a row without files is repairable (re-upload the
|
||||||
|
// weight), while files without a row would be invisible litter.
|
||||||
|
const font = await this.prisma.customFont.create({
|
||||||
|
data: {
|
||||||
|
family: input.family,
|
||||||
|
slug,
|
||||||
|
category: input.category,
|
||||||
|
licence: input.licence,
|
||||||
|
licenceUrl: input.licenceUrl,
|
||||||
|
uploadedBy: admin.id,
|
||||||
|
weights: {
|
||||||
|
create: uploads.map((upload) => ({
|
||||||
|
weight: upload.weight,
|
||||||
|
hasWoff: Boolean(upload.woff),
|
||||||
|
byteSize: upload.woff2.length,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { weights: { select: { weight: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const upload of uploads) {
|
||||||
|
await this.storage.save(slug, upload.weight, 'woff2', upload.woff2);
|
||||||
|
if (upload.woff) await this.storage.save(slug, upload.weight, 'woff', upload.woff);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'font.uploaded',
|
||||||
|
actorId: admin.id,
|
||||||
|
targetType: 'font',
|
||||||
|
targetId: font.id,
|
||||||
|
details: { family: font.family },
|
||||||
|
});
|
||||||
|
return this.viewOf(font);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addWeight(admin: User, fontId: string, upload: WeightUpload): Promise<CustomFontView> {
|
||||||
|
this.assertUsableFont(upload.woff2, 'woff2');
|
||||||
|
if (upload.woff) this.assertUsableFont(upload.woff, 'woff');
|
||||||
|
|
||||||
|
const font = await this.prisma.customFont.findUnique({
|
||||||
|
where: { id: fontId },
|
||||||
|
include: { weights: { select: { weight: true } } },
|
||||||
|
});
|
||||||
|
if (!font) throw new NotFoundException();
|
||||||
|
if (font.weights.length >= MAX_FONT_WEIGHTS) {
|
||||||
|
throw new BadRequestException({ code: 'font_too_many_weights' });
|
||||||
|
}
|
||||||
|
if (font.weights.some((row) => row.weight === upload.weight)) {
|
||||||
|
throw new ConflictException({ code: 'font_weight_exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.customFontWeight.create({
|
||||||
|
data: {
|
||||||
|
fontId,
|
||||||
|
weight: upload.weight,
|
||||||
|
hasWoff: Boolean(upload.woff),
|
||||||
|
byteSize: upload.woff2.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.storage.save(font.slug, upload.weight, 'woff2', upload.woff2);
|
||||||
|
if (upload.woff) await this.storage.save(font.slug, upload.weight, 'woff', upload.woff);
|
||||||
|
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'font.uploaded',
|
||||||
|
actorId: admin.id,
|
||||||
|
targetType: 'font',
|
||||||
|
targetId: fontId,
|
||||||
|
details: { family: font.family, weight: upload.weight },
|
||||||
|
});
|
||||||
|
const updated = await this.prisma.customFont.findUniqueOrThrow({
|
||||||
|
where: { id: fontId },
|
||||||
|
include: { weights: { select: { weight: true } } },
|
||||||
|
});
|
||||||
|
return this.viewOf(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many live ponds still name this family in any of their three font
|
||||||
|
* slots. Shown before deletion — those ponds keep working (an unknown
|
||||||
|
* family falls back to the system stack) but they visibly change.
|
||||||
|
*/
|
||||||
|
async pondsUsing(family: string): Promise<number> {
|
||||||
|
const rows = await this.prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
|
SELECT count(*)::bigint AS count
|
||||||
|
FROM ponds
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (settings #>> '{fonts,heading,family}' = ${family}
|
||||||
|
OR settings #>> '{fonts,body,family}' = ${family}
|
||||||
|
OR settings #>> '{fonts,mono,family}' = ${family})
|
||||||
|
`;
|
||||||
|
return Number(rows[0]?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletion is never blocked by usage. `fontStack` already yields the system
|
||||||
|
* fallback for an unknown family, so affected ponds degrade rather than
|
||||||
|
* break, and re-uploading the family restores them — but the count travels
|
||||||
|
* into the audit entry so the change is not silent.
|
||||||
|
*/
|
||||||
|
async remove(admin: User, fontId: string): Promise<void> {
|
||||||
|
const font = await this.prisma.customFont.findUnique({ where: { id: fontId } });
|
||||||
|
if (!font) throw new NotFoundException();
|
||||||
|
const pondsAffected = await this.pondsUsing(font.family);
|
||||||
|
|
||||||
|
await this.prisma.customFont.delete({ where: { id: fontId } });
|
||||||
|
await this.storage.deleteFamily(font.slug);
|
||||||
|
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'font.deleted',
|
||||||
|
actorId: admin.id,
|
||||||
|
targetType: 'font',
|
||||||
|
targetId: fontId,
|
||||||
|
details: { family: font.family, pondsAffected },
|
||||||
|
});
|
||||||
|
this.logger.info({ fontId, family: font.family, pondsAffected }, 'custom font deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/fonts/fonts.module.ts
Normal file
14
apps/api/src/fonts/fonts.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CustomFontStorageService } from './custom-font-storage.service';
|
||||||
|
import { CustomFontsAdminController, CustomFontsFileController } from './custom-fonts.controller';
|
||||||
|
import { CustomFontsService } from './custom-fonts.service';
|
||||||
|
|
||||||
|
/** Operator-uploaded fonts (issue #303, ADR 0016 §#303). Exports the service
|
||||||
|
* so the PDF exporter can resolve a pond's font to a custom family. */
|
||||||
|
@Module({
|
||||||
|
controllers: [CustomFontsAdminController, CustomFontsFileController],
|
||||||
|
providers: [CustomFontsService, CustomFontStorageService],
|
||||||
|
exports: [CustomFontsService, CustomFontStorageService],
|
||||||
|
})
|
||||||
|
export class FontsModule {}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||||
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
||||||
import deMails from '@dorfteich/shared/i18n/de/mails.json';
|
import deMails from '@dorfteich/shared/i18n/de/mails.json';
|
||||||
|
import dePonds from '@dorfteich/shared/i18n/de/ponds.json';
|
||||||
import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
|
import deTasks from '@dorfteich/shared/i18n/de/tasks.json';
|
||||||
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||||
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
||||||
import enMails from '@dorfteich/shared/i18n/en/mails.json';
|
import enMails from '@dorfteich/shared/i18n/en/mails.json';
|
||||||
|
import enPonds from '@dorfteich/shared/i18n/en/ponds.json';
|
||||||
import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
|
import enTasks from '@dorfteich/shared/i18n/en/tasks.json';
|
||||||
import { createInstance, type i18n as I18n } from 'i18next';
|
import { createInstance, type i18n as I18n } from 'i18next';
|
||||||
|
|
||||||
@ -17,8 +19,8 @@ export const apiI18n: I18n = createInstance();
|
|||||||
|
|
||||||
void apiI18n.init({
|
void apiI18n.init({
|
||||||
resources: {
|
resources: {
|
||||||
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks },
|
en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks, ponds: enPonds },
|
||||||
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks },
|
de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks, ponds: dePonds },
|
||||||
},
|
},
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['de', 'en'],
|
supportedLngs: ['de', 'en'],
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
import { ConversionJobService } from './conversion-job.service';
|
import { ConversionJobService } from './conversion-job.service';
|
||||||
@ -108,7 +108,7 @@ describe.skipIf(!hasTestDb)('conversion job queue (e2e, issue #62)', () => {
|
|||||||
// grant); clear those before the users they reference.
|
// grant); clear those before the users they reference.
|
||||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
await prisma.roleGrant.deleteMany({ where });
|
await prisma.roleGrant.deleteMany({ where });
|
||||||
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||||
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
|
import {
|
||||||
|
ConversionJobView,
|
||||||
|
PageExportInput,
|
||||||
|
PondArchivePreview,
|
||||||
|
pageExportInputSchema,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
@ -7,7 +12,10 @@ 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
|
||||||
@ -16,7 +24,41 @@ import { ExportService } from './export.service';
|
|||||||
*/
|
*/
|
||||||
@Controller()
|
@Controller()
|
||||||
export class ExportController {
|
export class ExportController {
|
||||||
constructor(private readonly exports: ExportService) {}
|
constructor(
|
||||||
|
private readonly exports: ExportService,
|
||||||
|
private readonly archives: PondArchiveService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the pond this requester's archive would contain (issue #305).
|
||||||
|
* Asked before the download so the UI can name the number of omitted pages:
|
||||||
|
* an archive silently missing content is worse than no archive, because it
|
||||||
|
* ends the search.
|
||||||
|
*/
|
||||||
|
@Get('ponds/:pondId/archive/preview')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
archivePreview(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PondArchivePreview> {
|
||||||
|
return this.archives.preview(request.user!, pondId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full archive: every readable page, EVERY attachment, and a versioned
|
||||||
|
* manifest with settings, labels, comments and the hierarchy (issue #305).
|
||||||
|
* Pond-Admin, because it is the deletion flow's last resort — a reader who
|
||||||
|
* wants their own copy has the Markdown export.
|
||||||
|
*/
|
||||||
|
@Get('ponds/:pondId/archive')
|
||||||
|
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||||
|
async pondArchive(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.archives.stream(request.user!, pondId, response, readActorOf(request), false);
|
||||||
|
}
|
||||||
|
|
||||||
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
|
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
|
||||||
* `reader` role is "may see the pond"; the service filters to readable pages,
|
* `reader` role is "may see the pond"; the service filters to readable pages,
|
||||||
@ -48,3 +90,34 @@ export class ExportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Site Admin's archive from the purge dialog (issue #305, #193).
|
||||||
|
*
|
||||||
|
* Separate controller because it must NOT carry `@RequiresPondRole`: a Site
|
||||||
|
* Admin purging a trashed pond is usually not a member of it, and the last
|
||||||
|
* archive before an irreversible purge must not depend on that. It is
|
||||||
|
* therefore complete by construction — the read filter is skipped.
|
||||||
|
*/
|
||||||
|
@Controller('admin/trash')
|
||||||
|
@UseGuards(SiteAdminGuard)
|
||||||
|
export class PondArchiveAdminController {
|
||||||
|
constructor(private readonly archives: PondArchiveService) {}
|
||||||
|
|
||||||
|
@Get('ponds/:pondId/archive/preview')
|
||||||
|
archivePreview(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PondArchivePreview> {
|
||||||
|
return this.archives.preview(request.user!, pondId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ponds/:pondId/archive')
|
||||||
|
async archive(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.archives.stream(request.user!, pondId, response, readActorOf(request), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
ConversionJobView,
|
ConversionJobView,
|
||||||
ExportFormat,
|
ExportFormat,
|
||||||
PondFonts,
|
PondFonts,
|
||||||
|
customFontEntries,
|
||||||
fontSlug,
|
fontSlug,
|
||||||
PageClassification,
|
PageClassification,
|
||||||
classificationMarking,
|
classificationMarking,
|
||||||
@ -20,6 +21,7 @@ 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';
|
||||||
@ -53,6 +55,7 @@ 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,
|
||||||
) {
|
) {
|
||||||
@ -333,6 +336,9 @@ 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.
|
||||||
@ -371,12 +377,18 @@ export class ExportService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Base64 `@font-face` rules for the pond's three fonts, read from the
|
/** Base64 `@font-face` rules for the pond's three fonts. Catalog families
|
||||||
* catalog baked into the image (ADR 0016). A font file that is absent (a
|
* come from the directory baked into the image (ADR 0016); operator-uploaded
|
||||||
* native dev run without `FONTS_DIR` populated) is skipped — the render falls
|
* ones from `CUSTOM_FONTS_DIR` (issue #303) — same on-disk layout, so only
|
||||||
* back to the system stack rather than failing. */
|
* the base directory differs. A font file that is absent (a native dev run
|
||||||
|
* without `FONTS_DIR` populated, or a family deleted between the settings
|
||||||
|
* write and the export) is skipped: the render falls back to the system
|
||||||
|
* stack rather than failing. */
|
||||||
private async fontFaceCss(fonts: PondFonts): Promise<string> {
|
private async fontFaceCss(fonts: PondFonts): Promise<string> {
|
||||||
const slots = [fonts.heading, fonts.body, fonts.mono];
|
const slots = [fonts.heading, fonts.body, fonts.mono];
|
||||||
|
const customSlugs = new Map(
|
||||||
|
(await this.customFonts.list()).map((font) => [font.family, font.slug]),
|
||||||
|
);
|
||||||
// Dedup identical family+weight so a doc that repeats a font embeds it once.
|
// Dedup identical family+weight so a doc that repeats a font embeds it once.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const faces: string[] = [];
|
const faces: string[] = [];
|
||||||
@ -384,8 +396,10 @@ export class ExportService {
|
|||||||
const key = `${slot.family}:${slot.weight}`;
|
const key = `${slot.family}:${slot.weight}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
const slug = fontSlug(slot.family);
|
const customSlug = customSlugs.get(slot.family);
|
||||||
const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
|
const slug = customSlug ?? fontSlug(slot.family);
|
||||||
|
const baseDir = customSlug ? this.config.env.CUSTOM_FONTS_DIR : this.config.env.FONTS_DIR;
|
||||||
|
const file = join(baseDir, slug, `${slug}-${slot.weight}.woff2`);
|
||||||
try {
|
try {
|
||||||
const bytes = await readFile(file);
|
const bytes = await readFile(file);
|
||||||
faces.push(
|
faces.push(
|
||||||
@ -393,7 +407,7 @@ export class ExportService {
|
|||||||
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
|
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.warn({ font: key }, 'pdf export: catalog font file missing, using fallback');
|
this.logger.warn({ font: key }, 'pdf export: font file missing, using fallback');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return faces.join('\n');
|
return faces.join('\n');
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common';
|
|||||||
|
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
|
import { FontsModule } from '../fonts/fonts.module';
|
||||||
import { LabelsModule } from '../labels/labels.module';
|
import { LabelsModule } from '../labels/labels.module';
|
||||||
import { PagesModule } from '../pages/pages.module';
|
import { PagesModule } from '../pages/pages.module';
|
||||||
import { PluginsModule } from '../plugins/plugins.module';
|
import { PluginsModule } from '../plugins/plugins.module';
|
||||||
@ -14,7 +15,7 @@ import { ConversionWorker } from './conversion-worker.service';
|
|||||||
import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
|
import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
|
||||||
import { DataExportController } from './data-export.controller';
|
import { DataExportController } from './data-export.controller';
|
||||||
import { DataExportService } from './data-export.service';
|
import { DataExportService } from './data-export.service';
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController, PondArchiveAdminController } from './export.controller';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
||||||
import { IMPORT_PROCESSOR } from './import.constants';
|
import { IMPORT_PROCESSOR } from './import.constants';
|
||||||
@ -22,6 +23,7 @@ import { ImportController } from './import.controller';
|
|||||||
import { ImportService } from './import.service';
|
import { ImportService } from './import.service';
|
||||||
import { JobsController } from './jobs.controller';
|
import { JobsController } from './jobs.controller';
|
||||||
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
||||||
|
import { PondArchiveService } from './pond-archive.service';
|
||||||
|
|
||||||
/** How often expired data-export payloads are purged (#68). Hourly is ample:
|
/** How often expired data-export payloads are purged (#68). Hourly is ample:
|
||||||
* the link's own expiry check already stops downloads the moment it lapses. */
|
* the link's own expiry check already stops downloads the moment it lapses. */
|
||||||
@ -40,18 +42,26 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
FontsModule,
|
||||||
LabelsModule,
|
LabelsModule,
|
||||||
PagesModule,
|
PagesModule,
|
||||||
PluginsModule,
|
PluginsModule,
|
||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
],
|
],
|
||||||
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
controllers: [
|
||||||
|
JobsController,
|
||||||
|
ImportController,
|
||||||
|
ExportController,
|
||||||
|
PondArchiveAdminController,
|
||||||
|
DataExportController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ConversionJobService,
|
ConversionJobService,
|
||||||
ConversionWorker,
|
ConversionWorker,
|
||||||
ImportService,
|
ImportService,
|
||||||
ExportService,
|
ExportService,
|
||||||
|
PondArchiveService,
|
||||||
DataExportService,
|
DataExportService,
|
||||||
// The worker resolves the import pipeline through this token (never the
|
// The worker resolves the import pipeline through this token (never the
|
||||||
// class), so its file does not import the import service's (avoids a cycle).
|
// class), so its file does not import the import service's (avoids a cycle).
|
||||||
|
|||||||
51
apps/api/src/import-export/pdf-html.test.ts
Normal file
51
apps/api/src/import-export/pdf-html.test.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { DEFAULT_FONTS, customFontEntries } from '@dorfteich/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildPdfHtml } from './pdf-html';
|
||||||
|
|
||||||
|
const CUSTOM = customFontEntries([
|
||||||
|
{
|
||||||
|
id: 'f1',
|
||||||
|
family: 'Corporate Grotesk',
|
||||||
|
slug: 'corporate-grotesk',
|
||||||
|
category: 'sans-serif',
|
||||||
|
licence: 'Bought from Foundry X',
|
||||||
|
licenceUrl: null,
|
||||||
|
weights: [400, 700],
|
||||||
|
createdAt: '2026-08-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function base(family: string): Parameters<typeof buildPdfHtml>[0] {
|
||||||
|
return {
|
||||||
|
title: 'T',
|
||||||
|
pondName: 'P',
|
||||||
|
bodyHtml: '<p>x</p>',
|
||||||
|
fonts: { ...DEFAULT_FONTS, body: { family, weight: 400 } },
|
||||||
|
fontFaceCss: `@font-face { font-family: '${family}'; src: url('data:font/woff2;base64,AA'); }`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildPdfHtml font stacks (issues #303/#304)', () => {
|
||||||
|
it('names an operator-uploaded family in the CSS stack when it is known', () => {
|
||||||
|
const html = buildPdfHtml({ ...base('Corporate Grotesk'), customFonts: CUSTOM });
|
||||||
|
expect(html).toContain("--font-body: 'Corporate Grotesk',");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regression this pins: the `@font-face` rule for a custom family was
|
||||||
|
* embedded, but `fontStack` — not knowing the family — produced the bare
|
||||||
|
* system fallback, so the rule was never referenced and the PDF rendered in
|
||||||
|
* the system font while everything reported success.
|
||||||
|
*/
|
||||||
|
it('would fall back to the system stack without the uploaded families', () => {
|
||||||
|
const html = buildPdfHtml(base('Corporate Grotesk'));
|
||||||
|
expect(html).not.toContain("'Corporate Grotesk',");
|
||||||
|
expect(html).toContain('--font-body: system-ui');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves catalog families working without any uploaded ones', () => {
|
||||||
|
const html = buildPdfHtml(base('Lora'));
|
||||||
|
expect(html).toContain("--font-body: 'Lora', Georgia");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { PondFonts, fontStack } from '@dorfteich/shared';
|
import { FontCatalogEntry, PondFonts, fontStack } from '@dorfteich/shared';
|
||||||
|
|
||||||
export interface PdfHtmlParams {
|
export interface PdfHtmlParams {
|
||||||
title: string;
|
title: string;
|
||||||
@ -8,6 +8,12 @@ export interface PdfHtmlParams {
|
|||||||
fonts: PondFonts;
|
fonts: PondFonts;
|
||||||
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
|
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
|
||||||
fontFaceCss: string;
|
fontFaceCss: string;
|
||||||
|
/** The instance's operator-uploaded families (issue #303), so a pond set to
|
||||||
|
* one gets it NAMED in the `font-family` stack. Without them `fontStack`
|
||||||
|
* cannot tell a custom family from a typo and yields the bare system
|
||||||
|
* fallback — the `@font-face` rule would then be embedded but never
|
||||||
|
* referenced, and the PDF would silently render in the system font. */
|
||||||
|
customFonts?: readonly FontCatalogEntry[];
|
||||||
/** The pond's active section-style plugin CSS (issue #75), already validated
|
/** The pond's active section-style plugin CSS (issue #75), already validated
|
||||||
* at install time (scoped selectors, no external fetches, no `</style>`).
|
* at install time (scoped selectors, no external fetches, no `</style>`).
|
||||||
* Sections of a disabled plugin render neutrally — their class matches
|
* Sections of a disabled plugin render neutrally — their class matches
|
||||||
@ -35,6 +41,7 @@ function escapeHtml(value: string): string {
|
|||||||
*/
|
*/
|
||||||
export function buildPdfHtml(params: PdfHtmlParams): string {
|
export function buildPdfHtml(params: PdfHtmlParams): string {
|
||||||
const { fonts } = params;
|
const { fonts } = params;
|
||||||
|
const extra = params.customFonts ?? [];
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@ -44,9 +51,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
|
|||||||
${params.fontFaceCss}
|
${params.fontFaceCss}
|
||||||
@page { size: A4; }
|
@page { size: A4; }
|
||||||
:root {
|
:root {
|
||||||
--font-heading: ${fontStack(fonts.heading.family)};
|
--font-heading: ${fontStack(fonts.heading.family, extra)};
|
||||||
--font-body: ${fontStack(fonts.body.family)};
|
--font-body: ${fontStack(fonts.body.family, extra)};
|
||||||
--font-mono: ${fontStack(fonts.mono.family)};
|
--font-mono: ${fontStack(fonts.mono.family, extra)};
|
||||||
}
|
}
|
||||||
html { font-size: 11pt; }
|
html { font-size: 11pt; }
|
||||||
body {
|
body {
|
||||||
|
|||||||
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
250
apps/api/src/import-export/pond-archive.e2e.db.test.ts
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PondArchiveManifest } from '@dorfteich/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
const PNG_BASE64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
function entries(buffer: Buffer): Record<string, Uint8Array> {
|
||||||
|
return unzipSync(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** supertest parses text by default — a ZIP has to be collected as bytes. */
|
||||||
|
function asBinary(req: request.Test): request.Test {
|
||||||
|
return req.parse((res, cb) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestOf(buffer: Buffer): PondArchiveManifest {
|
||||||
|
const raw = entries(buffer)['manifest.json'];
|
||||||
|
return JSON.parse(Buffer.from(raw!).toString('utf8')) as PondArchiveManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full pond archive (issue #305). What separates it from the Markdown
|
||||||
|
* export is exactly what is asserted here: EVERY attachment travels, not only
|
||||||
|
* the embedded ones, and the manifest carries what Markdown cannot — settings,
|
||||||
|
* labels, comments and the hierarchy.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('pond archive (e2e, issue #305)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let files: FilesService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'archiviere den ganzen teich 1';
|
||||||
|
const owner = { username: `arch-${suffix}` };
|
||||||
|
const admin = { username: `archadm-${suffix}` };
|
||||||
|
let ownerId: string;
|
||||||
|
let ownerCookie: string;
|
||||||
|
let adminCookie: string;
|
||||||
|
let pondId: string;
|
||||||
|
let parentPageId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
files = app.get(FilesService);
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const tokens = app.get(AuthTokensService);
|
||||||
|
|
||||||
|
const ownerUser = await users.createUser({
|
||||||
|
username: owner.username,
|
||||||
|
email: `${owner.username}@example.org`,
|
||||||
|
displayName: `Archive Owner ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
ownerId = ownerUser.id;
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/verify-email')
|
||||||
|
.send({ token: await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600) })
|
||||||
|
.expect(204);
|
||||||
|
ownerCookie = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: owner.username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminUser = await users.createUser({
|
||||||
|
username: admin.username,
|
||||||
|
email: `${admin.username}@example.org`,
|
||||||
|
displayName: `Archive Admin ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(adminUser.id);
|
||||||
|
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||||
|
adminCookie = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: admin.username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
|
||||||
|
pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })).id;
|
||||||
|
|
||||||
|
// A parent and a child page, so the hierarchy has something to state.
|
||||||
|
const parent = await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId,
|
||||||
|
title: 'Archive Parent',
|
||||||
|
slug: 'archive-parent',
|
||||||
|
ydocState: new Uint8Array(),
|
||||||
|
sortKey: 'a',
|
||||||
|
createdBy: ownerId,
|
||||||
|
contentCache: {
|
||||||
|
create: { plainText: 'Parent body', markdown: 'Parent body', html: '', outline: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
parentPageId = parent.id;
|
||||||
|
await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId,
|
||||||
|
parentId: parent.id,
|
||||||
|
title: 'Archive Child',
|
||||||
|
slug: 'archive-child',
|
||||||
|
ydocState: new Uint8Array(),
|
||||||
|
sortKey: 'b',
|
||||||
|
createdBy: ownerId,
|
||||||
|
contentCache: {
|
||||||
|
create: { plainText: 'Child body', markdown: 'Child body', html: '', outline: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = await prisma.label.create({
|
||||||
|
data: { pondId, name: `Archive Label ${suffix}`, color: '#2f6f4f' },
|
||||||
|
});
|
||||||
|
await prisma.pageLabel.create({ data: { pageId: parent.id, labelId: label.id } });
|
||||||
|
await prisma.comment.create({
|
||||||
|
data: { pageId: parent.id, authorId: ownerId, body: 'A remark worth keeping.' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
|
await prisma.comment.deleteMany({ where: { page: where } });
|
||||||
|
await prisma.attachment.deleteMany({ where });
|
||||||
|
await prisma.pageLabel.deleteMany({ where: { page: where } });
|
||||||
|
await prisma.label.deleteMany({ where });
|
||||||
|
await prisma.page.deleteMany({ where });
|
||||||
|
await prisma.roleGrant.deleteMany({ where });
|
||||||
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains EVERY attachment, not only the embedded ones', async () => {
|
||||||
|
// The gap this whole issue exists for: an attachment nobody embedded
|
||||||
|
// would vanish unnoticed with the Markdown export.
|
||||||
|
const orphan = await files.upload({ id: ownerId } as never, pondId, {
|
||||||
|
buffer: Buffer.from(PNG_BASE64, 'base64'),
|
||||||
|
size: 70,
|
||||||
|
originalname: 'never-embedded.png',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(res.headers['content-type']).toContain('application/zip');
|
||||||
|
|
||||||
|
const names = Object.keys(entries(res.body as Buffer));
|
||||||
|
expect(names).toContain('manifest.json');
|
||||||
|
expect(names).toContain('README.txt');
|
||||||
|
expect(names).toContain('pages/archive-parent.md');
|
||||||
|
expect(names).toContain('pages/archive-child.md');
|
||||||
|
expect(names.some((name) => name.startsWith(`media/${orphan.id}.`))).toBe(true);
|
||||||
|
|
||||||
|
const manifest = manifestOf(res.body as Buffer);
|
||||||
|
expect(manifest.attachments.map((a) => a.id)).toContain(orphan.id);
|
||||||
|
// The Markdown export would have shipped no media at all here.
|
||||||
|
expect(manifest.attachments.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('states the hierarchy, labels, comments and settings in the manifest', async () => {
|
||||||
|
const res = await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
const manifest = manifestOf(res.body as Buffer);
|
||||||
|
|
||||||
|
expect(manifest.kind).toBe('dorfteich-pond-archive');
|
||||||
|
expect(manifest.formatVersion).toBe(1);
|
||||||
|
expect(manifest.complete).toBe(true);
|
||||||
|
expect(manifest.omittedPages).toBe(0);
|
||||||
|
|
||||||
|
const child = manifest.pages.find((page) => page.slug === 'archive-child');
|
||||||
|
// The hierarchy is exactly what a folder of Markdown cannot express.
|
||||||
|
expect(child?.parentId).toBe(parentPageId);
|
||||||
|
|
||||||
|
expect(manifest.labels.some((label) => label.name.includes(suffix))).toBe(true);
|
||||||
|
expect(manifest.comments.map((comment) => comment.body)).toContain('A remark worth keeping.');
|
||||||
|
// A display name, not an account id — the archive outlives the account.
|
||||||
|
expect(manifest.comments[0]?.author).toContain('Archive Owner');
|
||||||
|
// Pond settings ride along; fonts are always present through the schema
|
||||||
|
// defaults, so their presence proves the settings object is real.
|
||||||
|
expect(manifest.pond.settings).toHaveProperty('fonts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a requester before the download how much they would get', async () => {
|
||||||
|
const preview = await api()
|
||||||
|
.get(`/api/v1/ponds/${pondId}/archive/preview`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(preview.body.omittedPages).toBe(0);
|
||||||
|
expect(preview.body.includedPages).toBe(preview.body.totalPages);
|
||||||
|
expect(preview.body.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits the download with counts and completeness', async () => {
|
||||||
|
await asBinary(api().get(`/api/v1/ponds/${pondId}/archive`))
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
const entry = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'pond.archived', targetId: pondId },
|
||||||
|
orderBy: { at: 'desc' },
|
||||||
|
});
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.details).toMatchObject({ complete: true, omittedPages: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the Site Admin a complete archive without pond membership', async () => {
|
||||||
|
// The purge dialog's archive must not depend on which ponds the operator
|
||||||
|
// happens to be a member of — this admin is a member of none.
|
||||||
|
const preview = await api()
|
||||||
|
.get(`/api/v1/admin/trash/ponds/${pondId}/archive/preview`)
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(preview.body.complete).toBe(true);
|
||||||
|
expect(preview.body.omittedPages).toBe(0);
|
||||||
|
|
||||||
|
const res = await asBinary(api().get(`/api/v1/admin/trash/ponds/${pondId}/archive`))
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(manifestOf(res.body as Buffer).pages.length).toBe(preview.body.totalPages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the admin archive away from an ordinary pond admin', async () => {
|
||||||
|
await api()
|
||||||
|
.get(`/api/v1/admin/trash/ponds/${pondId}/archive`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
395
apps/api/src/import-export/pond-archive.service.ts
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PageClassification,
|
||||||
|
PondArchiveManifest,
|
||||||
|
PondArchivePreview,
|
||||||
|
POND_ARCHIVE_FORMAT_VERSION,
|
||||||
|
classificationMarking,
|
||||||
|
highestClassification,
|
||||||
|
pondSettingsSchema,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import { User } from '@prisma/client';
|
||||||
|
import archiver from 'archiver';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { FileStorageService } from '../files/file-storage.service';
|
||||||
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service';
|
||||||
|
|
||||||
|
import { markClassifiedMarkdown } from './classified-markdown';
|
||||||
|
import { imageExtension, markdownForZip } from './export-markdown';
|
||||||
|
|
||||||
|
/** The plain-text note that travels inside the ZIP. The manifest says the
|
||||||
|
* same thing machine-readably, but a person unpacking a folder of Markdown
|
||||||
|
* a year from now reads the file lying next to it — and must not believe
|
||||||
|
* they are holding a one-click restore. */
|
||||||
|
const README = `Dorfteich pond archive (format version ${POND_ARCHIVE_FORMAT_VERSION})
|
||||||
|
|
||||||
|
This is a PRESERVATION archive, not a backup you can re-import: Dorfteich has
|
||||||
|
no importer for it yet. Everything needed to write one later is here and
|
||||||
|
documented — see manifest.json and docs/architecture/pond-archive-format.md in
|
||||||
|
the Dorfteich repository.
|
||||||
|
|
||||||
|
manifest.json pond settings, labels, page hierarchy, comments, attachment
|
||||||
|
metadata, and the classification of every file
|
||||||
|
pages/ one Markdown file per page
|
||||||
|
media/ EVERY attachment of the pond, not only the embedded ones
|
||||||
|
|
||||||
|
If manifest.json states "complete": false, the archive was produced by someone
|
||||||
|
who could not read every page of the pond; "omittedPages" says how many are
|
||||||
|
missing.
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full pond archive offered before a pond is deleted (issue #305).
|
||||||
|
*
|
||||||
|
* Distinct from the Markdown export (`exportPond`, #65) on purpose: that one
|
||||||
|
* ships the pages plus the images they embed, which as a LAST resort is not
|
||||||
|
* enough — an attachment nobody embedded would vanish unnoticed. This one adds
|
||||||
|
* every attachment and a machine-readable sidecar of the things Markdown
|
||||||
|
* cannot carry: settings, labels, comments and the page hierarchy.
|
||||||
|
*
|
||||||
|
* Re-import is deliberately out of scope. The archive is a preservation
|
||||||
|
* format: complete, versioned and documented, so an importer can be written
|
||||||
|
* later without guesswork.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PondArchiveService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionService,
|
||||||
|
private readonly storage: FileStorageService,
|
||||||
|
private readonly readTrail: ReadTrailService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(PondArchiveService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages in the pond and how many of them this requester may read.
|
||||||
|
*
|
||||||
|
* The UI states the difference BEFORE the download: an archive silently
|
||||||
|
* missing content is worse than no archive, because it ends the search.
|
||||||
|
* A site admin archiving from the purge dialog reads everything, so their
|
||||||
|
* preview says nothing is omitted.
|
||||||
|
*/
|
||||||
|
async preview(user: User, pondId: string, unfiltered: boolean): Promise<PondArchivePreview> {
|
||||||
|
const pond = await this.loadPond(pondId);
|
||||||
|
const pages = await this.readablePages(user, pond.id, unfiltered);
|
||||||
|
const total = await this.prisma.page.count({ where: { pondId: pond.id, deletedAt: null } });
|
||||||
|
return {
|
||||||
|
totalPages: total,
|
||||||
|
includedPages: pages.length,
|
||||||
|
omittedPages: total - pages.length,
|
||||||
|
// "Complete" is a statement about the RESULT, not about the route: a
|
||||||
|
// pond admin who may read every page gets a complete archive too. Only
|
||||||
|
// an archive that actually leaves pages out is incomplete.
|
||||||
|
complete: total === pages.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pond, or 404 — the caller's permission is checked by the route. */
|
||||||
|
private async loadPond(pondId: string): Promise<{ id: string; slug: string; name: string }> {
|
||||||
|
// Deliberately including trashed ponds: the purge dialog archives a pond
|
||||||
|
// that is already in the trash, which is the last moment it exists.
|
||||||
|
const pond = await this.prisma.pond.findUnique({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { id: true, slug: true, name: true },
|
||||||
|
});
|
||||||
|
if (!pond) throw new NotFoundException();
|
||||||
|
return pond;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readablePages(
|
||||||
|
user: User,
|
||||||
|
pondId: string,
|
||||||
|
unfiltered: boolean,
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
parentId: string | null;
|
||||||
|
sortKey: string;
|
||||||
|
classification: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
labels: { labelId: string }[];
|
||||||
|
contentCache: { markdown: string } | null;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
const pages = await this.prisma.page.findMany({
|
||||||
|
where: { pondId, deletedAt: null },
|
||||||
|
orderBy: { title: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
parentId: true,
|
||||||
|
sortKey: true,
|
||||||
|
classification: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
labels: { select: { labelId: true } },
|
||||||
|
contentCache: { select: { markdown: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (unfiltered) return pages;
|
||||||
|
const readable = await this.permissions.filterPages(
|
||||||
|
user,
|
||||||
|
pondId,
|
||||||
|
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
|
||||||
|
'read',
|
||||||
|
);
|
||||||
|
return pages.filter((page) => readable.has(page.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the archive.
|
||||||
|
*
|
||||||
|
* `unfiltered` is the Site-Admin path from the purge dialog: it skips the
|
||||||
|
* read filter, because the last archive before an irreversible purge must
|
||||||
|
* not depend on which pages the operator happens to be a member of.
|
||||||
|
* Whether the RESULT is complete is a separate question, answered by
|
||||||
|
* comparing what went in with what exists.
|
||||||
|
*/
|
||||||
|
async stream(
|
||||||
|
user: User,
|
||||||
|
pondId: string,
|
||||||
|
res: Response,
|
||||||
|
read: ReadActor,
|
||||||
|
unfiltered: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const pond = await this.loadPond(pondId);
|
||||||
|
const pages = await this.readablePages(user, pond.id, unfiltered);
|
||||||
|
const totalPages = await this.prisma.page.count({
|
||||||
|
where: { pondId: pond.id, deletedAt: null },
|
||||||
|
});
|
||||||
|
const complete = totalPages === pages.length;
|
||||||
|
|
||||||
|
// Read trail (ADR 0023, issue #222's property): one `export` event per
|
||||||
|
// classified page BEFORE any classified byte enters the stream, so a
|
||||||
|
// failed write aborts the download with the evidence intact. The added
|
||||||
|
// attachments carry their page's classification and are covered by the
|
||||||
|
// same events — they never travel without their page.
|
||||||
|
for (const page of pages) {
|
||||||
|
if (page.classification !== 'VS_NFD') continue;
|
||||||
|
await this.readTrail.record({
|
||||||
|
...read,
|
||||||
|
pageId: page.id,
|
||||||
|
pondId: pond.id,
|
||||||
|
channel: 'export',
|
||||||
|
details: { format: 'pond_archive' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [settingsRow, labels, comments, attachmentRows] = await Promise.all([
|
||||||
|
this.prisma.pond.findUniqueOrThrow({
|
||||||
|
where: { id: pond.id },
|
||||||
|
select: { name: true, slug: true, type: true, settings: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.label.findMany({
|
||||||
|
where: { pondId: pond.id },
|
||||||
|
select: { id: true, name: true, color: true, parentId: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
}),
|
||||||
|
this.prisma.comment.findMany({
|
||||||
|
where: { page: { pondId: pond.id, deletedAt: null } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
pageId: true,
|
||||||
|
parentId: true,
|
||||||
|
body: true,
|
||||||
|
createdAt: true,
|
||||||
|
editedAt: true,
|
||||||
|
resolvedAt: true,
|
||||||
|
author: { select: { displayName: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// EVERY attachment of the pond (issue #305) — not only the embedded
|
||||||
|
// ones the Markdown export ships.
|
||||||
|
this.prisma.attachment.findMany({
|
||||||
|
where: { pondId: pond.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
pageId: true,
|
||||||
|
fileName: true,
|
||||||
|
mimeType: true,
|
||||||
|
sizeBytes: true,
|
||||||
|
sha256: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const includedPageIds = new Set(pages.map((page) => page.id));
|
||||||
|
// An attachment of a page the requester cannot read stays out — the same
|
||||||
|
// rule the pages follow. Pond-level attachments (no page) are included:
|
||||||
|
// nothing narrower than the pond governs them.
|
||||||
|
const visibleAttachments = attachmentRows.filter(
|
||||||
|
(row) => !row.pageId || includedPageIds.has(row.pageId),
|
||||||
|
);
|
||||||
|
const onDisk = await Promise.all(
|
||||||
|
visibleAttachments.map((row) => this.storage.exists(pond.id, row.id)),
|
||||||
|
);
|
||||||
|
const attachments = visibleAttachments.filter((_, index) => onDisk[index]);
|
||||||
|
|
||||||
|
const classificationByPage = new Map(
|
||||||
|
pages.map((page) => [page.id, page.classification.toLowerCase() as PageClassification]),
|
||||||
|
);
|
||||||
|
const mediaName = new Map(
|
||||||
|
attachments.map((row) => [row.id, `${row.id}.${imageExtension(row.mimeType)}`]),
|
||||||
|
);
|
||||||
|
const readableSlugs = new Set(pages.map((page) => page.slug));
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
res.set('Content-Type', 'application/zip');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${pond.slug}-archive.zip"`);
|
||||||
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
archive.on('error', (error) => {
|
||||||
|
this.logger.error({ pondId: pond.id, err: error.message }, 'pond archive failed');
|
||||||
|
res.destroy(error);
|
||||||
|
});
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
const files: { path: string; classification: PageClassification }[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const level = classificationByPage.get(page.id) ?? 'unclassified';
|
||||||
|
const markdown = markClassifiedMarkdown(
|
||||||
|
markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaName),
|
||||||
|
level,
|
||||||
|
);
|
||||||
|
const path = `pages/${page.slug}.md`;
|
||||||
|
archive.append(markdown, { name: path });
|
||||||
|
files.push({ path, classification: level });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of attachments) {
|
||||||
|
// An attachment inherits its page's level (fail-closed, ADR 0022); one
|
||||||
|
// that belongs to no page inherits the pond's highest, because nothing
|
||||||
|
// narrower governs it.
|
||||||
|
const level = row.pageId
|
||||||
|
? (classificationByPage.get(row.pageId) ?? 'unclassified')
|
||||||
|
: highestClassification([...classificationByPage.values()]);
|
||||||
|
const path = `media/${mediaName.get(row.id)!}`;
|
||||||
|
files.push({ path, classification: level });
|
||||||
|
// Companion marking (issue #212): binaries cannot carry it themselves,
|
||||||
|
// and the sibling file survives unpacking where a manifest may not.
|
||||||
|
const marking = classificationMarking(level);
|
||||||
|
if (marking) {
|
||||||
|
archive.append(`${marking}\n`, { name: `${path}.classification.txt` });
|
||||||
|
files.push({ path: `${path}.classification.txt`, classification: level });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: PondArchiveManifest = {
|
||||||
|
kind: 'dorfteich-pond-archive',
|
||||||
|
formatVersion: POND_ARCHIVE_FORMAT_VERSION,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
complete,
|
||||||
|
omittedPages: totalPages - pages.length,
|
||||||
|
classification: highestClassification(files.map((file) => file.classification)),
|
||||||
|
pond: {
|
||||||
|
name: settingsRow.name,
|
||||||
|
slug: settingsRow.slug,
|
||||||
|
type: settingsRow.type,
|
||||||
|
createdAt: settingsRow.createdAt.toISOString(),
|
||||||
|
// The EFFECTIVE settings, defaults filled in — a preservation format
|
||||||
|
// must not require its reader to know Dorfteich's defaults, and the
|
||||||
|
// stored row only holds what was explicitly set.
|
||||||
|
settings: pondSettingsSchema.parse(settingsRow.settings ?? {}) as unknown as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
labels: labels.map((label) => ({
|
||||||
|
id: label.id,
|
||||||
|
name: label.name,
|
||||||
|
color: label.color,
|
||||||
|
parentId: label.parentId,
|
||||||
|
})),
|
||||||
|
pages: pages.map((page) => ({
|
||||||
|
id: page.id,
|
||||||
|
slug: page.slug,
|
||||||
|
title: page.title,
|
||||||
|
parentId: page.parentId,
|
||||||
|
sortKey: page.sortKey,
|
||||||
|
classification: page.classification.toLowerCase() as PageClassification,
|
||||||
|
labelIds: page.labels.map((label) => label.labelId),
|
||||||
|
createdAt: page.createdAt.toISOString(),
|
||||||
|
updatedAt: page.updatedAt.toISOString(),
|
||||||
|
file: `pages/${page.slug}.md`,
|
||||||
|
})),
|
||||||
|
// Comments of included pages only — a comment is content of its page.
|
||||||
|
comments: comments
|
||||||
|
.filter((comment) => includedPageIds.has(comment.pageId))
|
||||||
|
.map((comment) => ({
|
||||||
|
id: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
parentId: comment.parentId,
|
||||||
|
body: comment.body,
|
||||||
|
// The display name, not the account: the archive is a document, and
|
||||||
|
// it should stay readable after the account is gone.
|
||||||
|
author: comment.author?.displayName ?? null,
|
||||||
|
createdAt: comment.createdAt.toISOString(),
|
||||||
|
editedAt: comment.editedAt?.toISOString() ?? null,
|
||||||
|
resolvedAt: comment.resolvedAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
attachments: attachments.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
pageId: row.pageId,
|
||||||
|
fileName: row.fileName,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
sizeBytes: row.sizeBytes,
|
||||||
|
sha256: row.sha256,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
file: `media/${mediaName.get(row.id)!}`,
|
||||||
|
})),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
|
||||||
|
archive.append(README, { name: 'README.txt' });
|
||||||
|
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
|
||||||
|
|
||||||
|
for (const row of attachments) {
|
||||||
|
const stream = this.storage.createReadStream(pond.id, row.id);
|
||||||
|
stream.on('error', (error) =>
|
||||||
|
this.logger.warn(
|
||||||
|
{ pondId: pond.id, fileId: row.id, err: error.message },
|
||||||
|
'pond archive: media read failed',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
archive.append(stream, { name: `media/${mediaName.get(row.id)!}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audited: a whole pond leaving the instance in one file, usually right
|
||||||
|
// before it is deleted, is exactly the event an operator wants to find
|
||||||
|
// later. Recorded before finalize so the trail exists even if the
|
||||||
|
// download is aborted mid-stream.
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'pond.archived',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'pond',
|
||||||
|
targetId: pond.id,
|
||||||
|
details: {
|
||||||
|
pages: pages.length,
|
||||||
|
attachments: attachments.length,
|
||||||
|
omittedPages: totalPages - pages.length,
|
||||||
|
complete,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await archive.finalize();
|
||||||
|
this.logger.info(
|
||||||
|
{ pondId: pond.id, pages: pages.length, attachments: attachments.length, complete },
|
||||||
|
'pond archive streamed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/api/src/invitations/invitations.controller.ts
Normal file
50
apps/api/src/invitations/invitations.controller.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CreateInvitationInput,
|
||||||
|
InvitationListView,
|
||||||
|
InvitationPreview,
|
||||||
|
InvitationView,
|
||||||
|
createInvitationSchema,
|
||||||
|
invitationPreviewSchema,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||||
|
import { InvitationsService } from './invitations.service';
|
||||||
|
|
||||||
|
/** Peer invitations (issue #332). */
|
||||||
|
@AuthenticatedOnly()
|
||||||
|
@Controller('invitations')
|
||||||
|
export class InvitationsController {
|
||||||
|
constructor(private readonly invitations: InvitationsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Body(new ZodValidationPipe(createInvitationSchema)) input: CreateInvitationInput,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<InvitationView> {
|
||||||
|
return this.invitations.create(request.user!, input.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Req() request: AuthedRequest): Promise<InvitationListView> {
|
||||||
|
return this.invitations.list(request.user!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
async revoke(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||||
|
await this.invitations.revoke(request.user!, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The signup screen's link check — POST keeps the token out of logs. */
|
||||||
|
@Public()
|
||||||
|
@Post('preview')
|
||||||
|
@HttpCode(200)
|
||||||
|
async preview(
|
||||||
|
@Body(new ZodValidationPipe(invitationPreviewSchema)) input: { token: string },
|
||||||
|
): Promise<InvitationPreview> {
|
||||||
|
return this.invitations.preview(input.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
246
apps/api/src/invitations/invitations.e2e.db.test.ts
Normal file
246
apps/api/src/invitations/invitations.e2e.db.test.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { InvitationListView, InvitationPreview, InvitationView } from '@dorfteich/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peer invitations end to end (issue #332): inviting mails a single-use
|
||||||
|
* link, open invitations are quota-bound per user, and a valid token lets
|
||||||
|
* exactly one signup through a closed registration. Settings written here
|
||||||
|
* are restored inside each test and the keys are deleted in afterAll
|
||||||
|
* (shared-DB rule).
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('invitations (e2e, issue #332)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'einladungen sind praktisch 1';
|
||||||
|
const ids: Record<string, string> = {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
const settings = () => app.get(InstanceSettingsService);
|
||||||
|
|
||||||
|
async function makeUser(handle: string): Promise<void> {
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const username = `inv-${handle}-${suffix}`;
|
||||||
|
const user = await users.createUser({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: `Inv ${handle}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(user.id);
|
||||||
|
ids[handle] = user.id;
|
||||||
|
cookies[handle] = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The raw token only travels in the mail — fish it out of the outbox. */
|
||||||
|
async function mailedTokenFor(email: string): Promise<string> {
|
||||||
|
const mail = await prisma.mailOutbox.findFirstOrThrow({
|
||||||
|
where: { toAddress: email },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
const match = /invitation=([A-Za-z0-9_-]+)/.exec(mail.textBody);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
return match![1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
await makeUser('alice');
|
||||||
|
await makeUser('quota');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.instanceSetting.deleteMany({
|
||||||
|
where: { key: { in: ['auth.registrationMode', 'invitations.maxOpenPerUser'] } },
|
||||||
|
});
|
||||||
|
const all = Object.values(ids);
|
||||||
|
await prisma.invitation.deleteMany({ where: { inviterId: { in: all } } });
|
||||||
|
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||||
|
await prisma.session.deleteMany({ where: { userId: { in: all } } });
|
||||||
|
await deletePondsWhere(prisma, { ownerId: { in: all } });
|
||||||
|
await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: all } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invites, lists, and mails a single-use signup link', async () => {
|
||||||
|
const invitee = `guest-${suffix}@example.org`;
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/invitations')
|
||||||
|
.set('Cookie', cookies.alice!)
|
||||||
|
.send({ email: invitee })
|
||||||
|
.expect(201);
|
||||||
|
const view = res.body as InvitationView;
|
||||||
|
expect(view.status).toBe('pending');
|
||||||
|
|
||||||
|
const list = (await api().get('/api/v1/invitations').set('Cookie', cookies.alice!).expect(200))
|
||||||
|
.body as InvitationListView;
|
||||||
|
expect(list.open).toBe(1);
|
||||||
|
expect(list.maxOpen).toBe(5);
|
||||||
|
expect(list.invitations.map((i) => i.id)).toContain(view.id);
|
||||||
|
|
||||||
|
// The mail exists and the public preview identifies the inviter.
|
||||||
|
const token = await mailedTokenFor(invitee);
|
||||||
|
const preview = (await api().post('/api/v1/invitations/preview').send({ token }).expect(200))
|
||||||
|
.body as InvitationPreview;
|
||||||
|
expect(preview.email).toBe(invitee);
|
||||||
|
expect(preview.inviterName).toBe('Inv alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a valid token passes a closed registration exactly once; a burned signup attempt does not consume it', async () => {
|
||||||
|
const invitee = `joiner-${suffix}@example.org`;
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/invitations')
|
||||||
|
.set('Cookie', cookies.alice!)
|
||||||
|
.send({ email: invitee })
|
||||||
|
.expect(201);
|
||||||
|
const token = await mailedTokenFor(invitee);
|
||||||
|
|
||||||
|
await settings().set('auth.registrationMode', 'closed', ids.alice!);
|
||||||
|
try {
|
||||||
|
// Closed without a token: refused.
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/signup')
|
||||||
|
.send({
|
||||||
|
username: `inv-blocked-${suffix}`,
|
||||||
|
email: `inv-blocked-${suffix}@example.org`,
|
||||||
|
displayName: 'Blocked',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
// A failing signup (taken username) must NOT burn the token.
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/signup')
|
||||||
|
.send({
|
||||||
|
username: `inv-alice-${suffix}`, // taken
|
||||||
|
email: invitee,
|
||||||
|
displayName: 'Joiner',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
invitationToken: token,
|
||||||
|
})
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
// Same link, fresh username: through, despite closed mode.
|
||||||
|
const username = `inv-joiner-${suffix}`;
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/signup')
|
||||||
|
.send({
|
||||||
|
username,
|
||||||
|
email: invitee,
|
||||||
|
displayName: 'Joiner',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
invitationToken: token,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const joiner = await prisma.user.findUniqueOrThrow({ where: { username } });
|
||||||
|
ids.joiner = joiner.id;
|
||||||
|
|
||||||
|
// The invitation is tied to the new account…
|
||||||
|
const accepted = await prisma.invitation.findFirstOrThrow({
|
||||||
|
where: { acceptedUserId: joiner.id },
|
||||||
|
});
|
||||||
|
expect(accepted.acceptedAt).not.toBeNull();
|
||||||
|
|
||||||
|
// …and the token is single-use.
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/signup')
|
||||||
|
.send({
|
||||||
|
username: `inv-replay-${suffix}`,
|
||||||
|
email: `inv-replay-${suffix}@example.org`,
|
||||||
|
displayName: 'Replay',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
invitationToken: token,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
} finally {
|
||||||
|
await settings().set('auth.registrationMode', 'open', ids.alice!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the open-invitations quota and frees it on revoke', async () => {
|
||||||
|
await settings().set('invitations.maxOpenPerUser', 2, ids.alice!);
|
||||||
|
try {
|
||||||
|
const first = (
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/invitations')
|
||||||
|
.set('Cookie', cookies.quota!)
|
||||||
|
.send({ email: `q1-${suffix}@example.org` })
|
||||||
|
.expect(201)
|
||||||
|
).body as InvitationView;
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/invitations')
|
||||||
|
.set('Cookie', cookies.quota!)
|
||||||
|
.send({ email: `q2-${suffix}@example.org` })
|
||||||
|
.expect(201);
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/invitations')
|
||||||
|
.set('Cookie', cookies.quota!)
|
||||||
|
.send({ email: `q3-${suffix}@example.org` })
|
||||||
|
.expect(400)
|
||||||
|
.expect((r) => expect((r.body as { code: string }).code).toBe('invitation_quota_reached'));
|
||||||
|
|
||||||
|
// Revoking an open invitation frees the slot…
|
||||||
|
await api()
|
||||||
|
.delete(`/api/v1/invitations/${first.id}`)
|
||||||
|
.set('Cookie', cookies.quota!)
|
||||||
|
.expect(204);
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/invitations')
|
||||||
|
.set('Cookie', cookies.quota!)
|
||||||
|
.send({ email: `q3-${suffix}@example.org` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// …and the revoked token is dead.
|
||||||
|
const revokedToken = await mailedTokenFor(`q1-${suffix}@example.org`);
|
||||||
|
await api().post('/api/v1/invitations/preview').send({ token: revokedToken }).expect(400);
|
||||||
|
} finally {
|
||||||
|
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quota 0 disables inviting entirely', async () => {
|
||||||
|
await settings().set('invitations.maxOpenPerUser', 0, ids.alice!);
|
||||||
|
try {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/invitations')
|
||||||
|
.set('Cookie', cookies.alice!)
|
||||||
|
.send({ email: `off-${suffix}@example.org` })
|
||||||
|
.expect(403)
|
||||||
|
.expect((r) => expect((r.body as { code: string }).code).toBe('invitations_disabled'));
|
||||||
|
} finally {
|
||||||
|
await settings().set('invitations.maxOpenPerUser', 5, ids.alice!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a session for create/list/revoke but not for preview', async () => {
|
||||||
|
await api().post('/api/v1/invitations').send({ email: 'nope@example.org' }).expect(401);
|
||||||
|
await api().get('/api/v1/invitations').expect(401);
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/invitations/preview')
|
||||||
|
.send({ token: 'x'.repeat(32) })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/api/src/invitations/invitations.module.ts
Normal file
13
apps/api/src/invitations/invitations.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MailModule } from '../mail/mail.module';
|
||||||
|
import { InvitationsController } from './invitations.controller';
|
||||||
|
import { InvitationsService } from './invitations.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MailModule],
|
||||||
|
controllers: [InvitationsController],
|
||||||
|
providers: [InvitationsService],
|
||||||
|
exports: [InvitationsService],
|
||||||
|
})
|
||||||
|
export class InvitationsModule {}
|
||||||
199
apps/api/src/invitations/invitations.service.ts
Normal file
199
apps/api/src/invitations/invitations.service.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
InvitationListView,
|
||||||
|
InvitationPreview,
|
||||||
|
InvitationStatus,
|
||||||
|
InvitationView,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import { Invitation, User } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { MailService } from '../mail/mail.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RateLimitService } from '../rate-limit/rate-limit.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
|
||||||
|
export const INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
// Anti-spam backstop besides the open-invitations quota: without it a
|
||||||
|
// revoke-and-recreate loop would allow unlimited mail volume while never
|
||||||
|
// exceeding the quota.
|
||||||
|
const CREATE_LIMIT = { limit: 20, windowSeconds: 24 * 60 * 60 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peer invitations (issue #332). A user invites an e-mail address; the
|
||||||
|
* mailed single-use token lets exactly one signup through even while
|
||||||
|
* registration is closed (auth.service). Open (pending, unexpired)
|
||||||
|
* invitations count against the per-user quota
|
||||||
|
* `invitations.maxOpenPerUser` — 0 turns the feature off. Only the
|
||||||
|
* SHA-256 hash of the token is stored (auth-tokens pattern); revoked and
|
||||||
|
* accepted rows are kept so the settings UI can show history.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class InvitationsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly mail: MailService,
|
||||||
|
private readonly rateLimits: RateLimitService,
|
||||||
|
private readonly settings: InstanceSettingsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(user: User, email: string): Promise<InvitationView> {
|
||||||
|
const maxOpen = (await this.settings.get('invitations.maxOpenPerUser')) as number;
|
||||||
|
if (maxOpen === 0) throw new ForbiddenException({ code: 'invitations_disabled' });
|
||||||
|
if ((await this.openCount(user.id)) >= maxOpen) {
|
||||||
|
throw new BadRequestException({ code: 'invitation_quota_reached' });
|
||||||
|
}
|
||||||
|
const limit = await this.rateLimits.hit(
|
||||||
|
'invitation-create',
|
||||||
|
user.id,
|
||||||
|
CREATE_LIMIT.limit,
|
||||||
|
CREATE_LIMIT.windowSeconds,
|
||||||
|
);
|
||||||
|
if (!limit.allowed) {
|
||||||
|
throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = randomBytes(32).toString('base64url');
|
||||||
|
const row = await this.prisma.invitation.create({
|
||||||
|
data: {
|
||||||
|
inviterId: user.id,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
tokenHash: hashToken(raw),
|
||||||
|
expiresAt: new Date(Date.now() + INVITATION_TTL_SECONDS * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The invitee has no account and no locale yet — the instance default
|
||||||
|
// decides the mail language. The greeting falls back to the address.
|
||||||
|
await this.mail.enqueue(
|
||||||
|
row.email,
|
||||||
|
'invitation',
|
||||||
|
{
|
||||||
|
displayName: row.email,
|
||||||
|
inviterName: user.displayName,
|
||||||
|
link: `${this.config.env.APP_BASE_URL}/signup?invitation=${raw}`,
|
||||||
|
},
|
||||||
|
(await this.settings.get('instance.defaultLocale')) as 'de' | 'en',
|
||||||
|
);
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'invitation.created',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'invitation',
|
||||||
|
targetId: row.id,
|
||||||
|
});
|
||||||
|
return this.viewOf(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(user: User): Promise<InvitationListView> {
|
||||||
|
const rows = await this.prisma.invitation.findMany({
|
||||||
|
where: { inviterId: user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
invitations: rows.map((row) => this.viewOf(row)),
|
||||||
|
open: await this.openCount(user.id),
|
||||||
|
maxOpen: (await this.settings.get('invitations.maxOpenPerUser')) as number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(user: User, id: string): Promise<void> {
|
||||||
|
const row = await this.prisma.invitation.findFirst({
|
||||||
|
where: { id, inviterId: user.id },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException();
|
||||||
|
if (row.acceptedAt) throw new BadRequestException({ code: 'invitation_already_accepted' });
|
||||||
|
if (row.revokedAt) return; // idempotent
|
||||||
|
await this.prisma.invitation.update({ where: { id }, data: { revokedAt: new Date() } });
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'invitation.revoked',
|
||||||
|
actorId: user.id,
|
||||||
|
targetType: 'invitation',
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the signup screen may show for a link before it is used. */
|
||||||
|
async preview(raw: string): Promise<InvitationPreview> {
|
||||||
|
const row = await this.prisma.invitation.findUnique({
|
||||||
|
where: { tokenHash: hashToken(raw) },
|
||||||
|
include: { inviter: true },
|
||||||
|
});
|
||||||
|
if (!row || row.revokedAt || row.acceptedAt || row.expiresAt <= new Date()) {
|
||||||
|
throw new BadRequestException({ code: 'token_invalid' });
|
||||||
|
}
|
||||||
|
return { email: row.email, inviterName: row.inviter.displayName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically claims the token (only one signup can flip acceptedAt from
|
||||||
|
* null). Returns the row, or null for unknown/revoked/expired/used
|
||||||
|
* tokens. The caller un-redeems if the signup fails afterwards.
|
||||||
|
*/
|
||||||
|
async redeem(raw: string): Promise<Invitation | null> {
|
||||||
|
const result = await this.prisma.invitation.updateMany({
|
||||||
|
where: {
|
||||||
|
tokenHash: hashToken(raw),
|
||||||
|
revokedAt: null,
|
||||||
|
acceptedAt: null,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
data: { acceptedAt: new Date() },
|
||||||
|
});
|
||||||
|
if (result.count === 0) return null;
|
||||||
|
return this.prisma.invitation.findUnique({ where: { tokenHash: hashToken(raw) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ties the redeemed invitation to the account it created. */
|
||||||
|
async markAccepted(id: string, userId: string): Promise<void> {
|
||||||
|
await this.prisma.invitation.update({ where: { id }, data: { acceptedUserId: userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rolls a redeem back when the signup it gated failed (e.g. duplicate
|
||||||
|
* username) — the invitee must be able to try again with the same link. */
|
||||||
|
async unredeem(id: string): Promise<void> {
|
||||||
|
await this.prisma.invitation.updateMany({
|
||||||
|
where: { id, acceptedUserId: null },
|
||||||
|
data: { acceptedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCount(inviterId: string): Promise<number> {
|
||||||
|
return this.prisma.invitation.count({
|
||||||
|
where: { inviterId, revokedAt: null, acceptedAt: null, expiresAt: { gt: new Date() } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private viewOf(row: Invitation): InvitationView {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
status: statusOf(row),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
expiresAt: row.expiresAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOf(row: Invitation): InvitationStatus {
|
||||||
|
if (row.revokedAt) return 'revoked';
|
||||||
|
if (row.acceptedAt) return 'accepted';
|
||||||
|
if (row.expiresAt <= new Date()) return 'expired';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(raw: string): string {
|
||||||
|
return createHash('sha256').update(raw).digest('hex');
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { apiI18n } from '../i18n/api-i18n';
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
|
||||||
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
|
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation';
|
||||||
|
|
||||||
export interface RenderedMail {
|
export interface RenderedMail {
|
||||||
subject: string;
|
subject: string;
|
||||||
@ -15,14 +15,15 @@ export interface RenderedMail {
|
|||||||
*/
|
*/
|
||||||
export function renderMail(
|
export function renderMail(
|
||||||
template: MailTemplate,
|
template: MailTemplate,
|
||||||
params: { displayName: string; link: string },
|
// Extra keys (e.g. inviterName, #332) interpolate into the body text.
|
||||||
|
params: { displayName: string; link: string } & Record<string, string>,
|
||||||
locale: 'de' | 'en',
|
locale: 'de' | 'en',
|
||||||
): RenderedMail {
|
): RenderedMail {
|
||||||
const t = (key: string, options: Record<string, string> = {}): string =>
|
const t = (key: string, options: Record<string, string> = {}): string =>
|
||||||
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
|
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
|
||||||
|
|
||||||
const greeting = t('common.greeting', { displayName: params.displayName });
|
const greeting = t('common.greeting', { displayName: params.displayName });
|
||||||
const body = t(`${template}.body`);
|
const body = t(`${template}.body`, params);
|
||||||
const action = t(`${template}.action`);
|
const action = t(`${template}.action`);
|
||||||
const expiry = t(`${template}.expiry`);
|
const expiry = t(`${template}.expiry`);
|
||||||
const ignore = t('common.ignoreHint');
|
const ignore = t('common.ignoreHint');
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export class MailService {
|
|||||||
async enqueue(
|
async enqueue(
|
||||||
to: string,
|
to: string,
|
||||||
template: MailTemplate,
|
template: MailTemplate,
|
||||||
params: { displayName: string; link: string },
|
params: { displayName: string; link: string } & Record<string, string>,
|
||||||
locale: 'de' | 'en',
|
locale: 'de' | 'en',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const rendered = renderMail(template, params, locale);
|
const rendered = renderMail(template, params, locale);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -97,7 +97,7 @@ describe.skipIf(!hasTestDb)('pond members (e2e, issue #54)', () => {
|
|||||||
const ids = Object.values(userIds);
|
const ids = Object.values(userIds);
|
||||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
||||||
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } });
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [...ids, pondId] } } });
|
||||||
await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
await deletePondsWhere(prisma, { ownerId: { in: ids } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@ -3,11 +3,7 @@ import { createHmac } from 'node:crypto';
|
|||||||
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import { signUnsubscribeToken, verifyUnsubscribeToken } from './unsubscribe-token';
|
||||||
LEGACY_VERIFY_UNTIL,
|
|
||||||
signUnsubscribeToken,
|
|
||||||
verifyUnsubscribeToken,
|
|
||||||
} from './unsubscribe-token';
|
|
||||||
|
|
||||||
const secret = 'test-secret-at-least-16-chars-long';
|
const secret = 'test-secret-at-least-16-chars-long';
|
||||||
const TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
const TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
||||||
@ -50,23 +46,15 @@ describe('unsubscribe token', () => {
|
|||||||
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
|
expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a pre-separation legacy token inside the dual-verify window', () => {
|
it('rejects pre-separation legacy tokens — the dual-verify window is gone (#296)', () => {
|
||||||
const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000;
|
const now = Date.now();
|
||||||
expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe(
|
// Unexpired on its own terms — rejected because only the subkey verifies.
|
||||||
'u1',
|
expect(verifyUnsubscribeToken(legacyToken('u1', now - 1000), secret, now)).toBeNull();
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a legacy token once the dual-verify window has closed', () => {
|
it('verifies freshly minted tokens via the subkey', () => {
|
||||||
const afterWindow = LEGACY_VERIFY_UNTIL + 1000;
|
const now = Date.now();
|
||||||
// Unexpired on its own terms — rejected purely because the window closed.
|
const token = signUnsubscribeToken('u1', secret, now);
|
||||||
const token = legacyToken('u1', afterWindow - 1000);
|
expect(verifyUnsubscribeToken(token, secret, now + 1000)).toBe('u1');
|
||||||
expect(verifyUnsubscribeToken(token, secret, afterWindow)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verifies freshly minted tokens via the subkey, independent of the window', () => {
|
|
||||||
const afterWindow = LEGACY_VERIFY_UNTIL + 24 * 60 * 60 * 1000;
|
|
||||||
const token = signUnsubscribeToken('u1', secret, afterWindow);
|
|
||||||
expect(verifyUnsubscribeToken(token, secret, afterWindow + 1000)).toBe('u1');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,26 +13,17 @@ import { deriveTokenKey } from '@dorfteich/shared/token-crypto';
|
|||||||
|
|
||||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||||
|
|
||||||
/**
|
// The pre-#188 dual-verify window (root secret + `digest-unsubscribe.`
|
||||||
* Dual-verify window (#188, ADR 0020): before the key separation, tokens
|
// prefix) was removed EARLY by operator decision at the ADR 0020
|
||||||
* were HMACed with the root secret over a `digest-unsubscribe.` prefix.
|
// acceptance (issue #296): links in mails sent before the key separation
|
||||||
* Those links live in digest mails that are already sent and stay valid
|
// no longer work — recipients use the in-app notification settings.
|
||||||
* for their full 90-day TTL, so verification accepts the legacy derivation
|
// Verification is subkey-only; the regression test pins that the legacy
|
||||||
* until every pre-separation token has expired. Tokens are only ever
|
// derivation can never verify again.
|
||||||
* SIGNED with the new subkey; the legacy path is verify-only and goes dead
|
|
||||||
* automatically on the date below (last possible legacy expiry, rounded up).
|
|
||||||
*/
|
|
||||||
export const LEGACY_VERIFY_UNTIL = Date.parse('2026-11-01T00:00:00Z');
|
|
||||||
const LEGACY_PURPOSE = 'digest-unsubscribe';
|
|
||||||
|
|
||||||
function signature(body: string, rootSecret: string): Buffer {
|
function signature(body: string, rootSecret: string): Buffer {
|
||||||
return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest();
|
return createHmac('sha256', deriveTokenKey(rootSecret, 'unsubscribe')).update(body).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacySignature(body: string, rootSecret: string): Buffer {
|
|
||||||
return createHmac('sha256', rootSecret).update(`${LEGACY_PURPOSE}.${body}`).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function signUnsubscribeToken(userId: string, rootSecret: string, now = Date.now()): string {
|
export function signUnsubscribeToken(userId: string, rootSecret: string, now = Date.now()): string {
|
||||||
const body = Buffer.from(
|
const body = Buffer.from(
|
||||||
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
|
JSON.stringify({ userId, exp: Math.floor(now / 1000) + TTL_SECONDS }),
|
||||||
@ -59,10 +50,7 @@ export function verifyUnsubscribeToken(
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const current = matches(provided, signature(body, rootSecret));
|
if (!matches(provided, signature(body, rootSecret))) return null;
|
||||||
const legacy =
|
|
||||||
!current && now < LEGACY_VERIFY_UNTIL && matches(provided, legacySignature(body, rootSecret));
|
|
||||||
if (!current && !legacy) return null;
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
|
||||||
import { WatchesModule } from '../watches/watches.module';
|
import { WatchesModule } from '../watches/watches.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
|
|
||||||
@ -10,7 +9,7 @@ import { PluginApiController } from './plugin-api.controller';
|
|||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PondsModule, SearchModule, WatchesModule],
|
imports: [SearchModule, WatchesModule],
|
||||||
controllers: [PagesController, PluginApiController],
|
controllers: [PagesController, PluginApiController],
|
||||||
providers: [PagesService, TasksService],
|
providers: [PagesService, TasksService],
|
||||||
exports: [PagesService, TasksService],
|
exports: [PagesService, TasksService],
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,6 +23,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
const userIds: Record<string, string> = {};
|
const userIds: Record<string, string> = {};
|
||||||
const cookies: Record<string, string> = {};
|
const cookies: Record<string, string> = {};
|
||||||
let pondId: string;
|
let pondId: string;
|
||||||
|
let startPageId: string;
|
||||||
let openPageId: string;
|
let openPageId: string;
|
||||||
let secretPageId: string;
|
let secretPageId: string;
|
||||||
|
|
||||||
@ -71,6 +72,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
.send({ name: `PlugApi Pond ${suffix}` })
|
.send({ name: `PlugApi Pond ${suffix}` })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
pondId = pond.body.id;
|
pondId = pond.body.id;
|
||||||
|
// Every pond created through the api starts with a page (issue #302);
|
||||||
|
// a "full reader sees everything" assertion has to include it rather
|
||||||
|
// than pretend the pond began empty.
|
||||||
|
startPageId = pond.body.settings.startPageId as string;
|
||||||
|
|
||||||
const open = await api()
|
const open = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
@ -128,10 +133,10 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
|
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.page.deleteMany({ where: { pondId } });
|
await prisma.page.deleteMany({ where: { pondId } });
|
||||||
await prisma.pond.deleteMany({ where: { id: pondId } });
|
await deletePondsWhere(prisma, { id: pondId });
|
||||||
const ids = Object.values(userIds);
|
const ids = Object.values(userIds);
|
||||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
||||||
await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
await deletePondsWhere(prisma, { ownerId: { in: ids } });
|
||||||
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
@ -144,7 +149,7 @@ describe.skipIf(!hasTestDb)('plugin API (e2e, issue #74)', () => {
|
|||||||
.set('Cookie', cookies.owner!)
|
.set('Cookie', cookies.owner!)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(
|
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(
|
||||||
[openPageId, secretPageId].sort(),
|
[startPageId, openPageId, secretPageId].sort(),
|
||||||
);
|
);
|
||||||
expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) });
|
expect(res.body[0]).toMatchObject({ title: expect.any(String), slug: expect.any(String) });
|
||||||
// Label *names* travel with each summary (issue #77, page-index filter).
|
// Label *names* travel with each summary (issue #77, page-index filter).
|
||||||
|
|||||||
@ -26,48 +26,56 @@ describe('sort-key helpers (issue #45)', () => {
|
|||||||
* pattern — repeatedly drop the last page between the first two — must never
|
* pattern — repeatedly drop the last page between the first two — must never
|
||||||
* collide and never overflow the key length, because the caller rebalances
|
* collide and never overflow the key length, because the caller rebalances
|
||||||
* when {@link nextKeyOrRebalance} returns null.
|
* when {@link nextKeyOrRebalance} returns null.
|
||||||
|
*
|
||||||
|
* Under parallel CI load the 10.000 iterations have repeatedly exceeded the
|
||||||
|
* default 5 s per-test timeout (runs 685, 699 — same code passed on rerun),
|
||||||
|
* so this test carries its own budget.
|
||||||
*/
|
*/
|
||||||
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
|
it(
|
||||||
// Start with five pages in a fixed order.
|
'10.000 adversarial reorders never collide or overflow (rebalance verified)',
|
||||||
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
|
{ timeout: 30_000 },
|
||||||
let rebalances = 0;
|
() => {
|
||||||
|
// Start with five pages in a fixed order.
|
||||||
|
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
|
||||||
|
let rebalances = 0;
|
||||||
|
|
||||||
const rebalance = (): void => {
|
const rebalance = (): void => {
|
||||||
const keys = evenlySpacedKeys(order.length);
|
const keys = evenlySpacedKeys(order.length);
|
||||||
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
|
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
|
||||||
rebalances += 1;
|
rebalances += 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < 10_000; i += 1) {
|
for (let i = 0; i < 10_000; i += 1) {
|
||||||
// Move the last page to sit between the first and second — the tightest
|
// Move the last page to sit between the first and second — the tightest
|
||||||
// possible gap, which is what grows key length fastest.
|
// possible gap, which is what grows key length fastest.
|
||||||
const moved = order[order.length - 1]!;
|
const moved = order[order.length - 1]!;
|
||||||
const rest = order.slice(0, -1);
|
const rest = order.slice(0, -1);
|
||||||
const afterKey = rest[0]!.key;
|
const afterKey = rest[0]!.key;
|
||||||
const beforeKey = rest[1]!.key;
|
const beforeKey = rest[1]!.key;
|
||||||
|
|
||||||
const key = nextKeyOrRebalance(afterKey, beforeKey);
|
const key = nextKeyOrRebalance(afterKey, beforeKey);
|
||||||
if (key === null) {
|
if (key === null) {
|
||||||
// Rebalance keeps the CURRENT order, then retry the move once.
|
// Rebalance keeps the CURRENT order, then retry the move once.
|
||||||
rebalance();
|
rebalance();
|
||||||
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
|
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
|
||||||
expect(k2).not.toBeNull();
|
expect(k2).not.toBeNull();
|
||||||
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
|
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
|
||||||
} else {
|
} else {
|
||||||
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
|
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariants after every move: keys unique, bounded, and consistent with
|
||||||
|
// the intended array order.
|
||||||
|
const keys = order.map((p) => p.key);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
|
||||||
|
for (let j = 1; j < keys.length; j += 1) {
|
||||||
|
expect(keys[j - 1]! < keys[j]!).toBe(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invariants after every move: keys unique, bounded, and consistent with
|
// The adversarial pattern must have forced at least one rebalance.
|
||||||
// the intended array order.
|
expect(rebalances).toBeGreaterThan(0);
|
||||||
const keys = order.map((p) => p.key);
|
},
|
||||||
expect(new Set(keys).size).toBe(keys.length);
|
);
|
||||||
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
|
|
||||||
for (let j = 1; j < keys.length; j += 1) {
|
|
||||||
expect(keys[j - 1]! < keys[j]!).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The adversarial pattern must have forced at least one rebalance.
|
|
||||||
expect(rebalances).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { PondsService } from '../ponds/ponds.service';
|
import { PondsService } from '../ponds/ponds.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -120,11 +120,11 @@ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => {
|
|||||||
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
|
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
|
||||||
await prisma.page.deleteMany({ where: { pondId } });
|
await prisma.page.deleteMany({ where: { pondId } });
|
||||||
await prisma.pond.deleteMany({ where: { id: pondId } });
|
await deletePondsWhere(prisma, { id: pondId });
|
||||||
// Personal ponds (and their grants) before their users.
|
// Personal ponds (and their grants) before their users.
|
||||||
const ids = Object.values(userIds);
|
const ids = Object.values(userIds);
|
||||||
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
|
||||||
await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
|
await deletePondsWhere(prisma, { ownerId: { in: ids } });
|
||||||
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
|
||||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
@ -40,6 +41,10 @@ function toHttpException(error: PluginPackageError): HttpException {
|
|||||||
case 'plugin_version_not_higher':
|
case 'plugin_version_not_higher':
|
||||||
case 'plugin_not_optional':
|
case 'plugin_not_optional':
|
||||||
return new ConflictException(body);
|
return new ConflictException(body);
|
||||||
|
// Hash pinning (#232): the upload is well-formed, the policy says no.
|
||||||
|
case 'plugin_not_pinned':
|
||||||
|
case 'plugin_hash_mismatch':
|
||||||
|
return new ForbiddenException(body);
|
||||||
case 'plugin_too_large':
|
case 'plugin_too_large':
|
||||||
return new PayloadTooLargeException(body);
|
return new PayloadTooLargeException(body);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -96,7 +96,9 @@ export class PluginAssetsController {
|
|||||||
@Param('version') version: string,
|
@Param('version') version: string,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const plugin = await this.plugins.get(id);
|
// getServable enforces the hash-pinning allowlist (#232): a blocked
|
||||||
|
// plugin 404s here exactly like a missing one, and is audited.
|
||||||
|
const plugin = await this.plugins.getServable(id);
|
||||||
if (!plugin || plugin.version !== version) throw new NotFoundException();
|
if (!plugin || plugin.version !== version) throw new NotFoundException();
|
||||||
|
|
||||||
// Asset base is built from the configured public origin, not the request
|
// Asset base is built from the configured public origin, not the request
|
||||||
@ -122,8 +124,9 @@ export class PluginAssetsController {
|
|||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
// Only serve assets for an installed, current version — a removed plugin or
|
// Only serve assets for an installed, current version — a removed plugin or
|
||||||
// a stale version pointer must not leak files.
|
// a stale version pointer must not leak files. getServable additionally
|
||||||
const plugin = await this.plugins.get(id);
|
// enforces the hash-pinning allowlist (#232).
|
||||||
|
const plugin = await this.plugins.getServable(id);
|
||||||
if (!plugin || plugin.version !== version) throw new NotFoundException();
|
if (!plugin || plugin.version !== version) throw new NotFoundException();
|
||||||
|
|
||||||
const rest = (request.params as Record<string, unknown>).rest;
|
const rest = (request.params as Record<string, unknown>).rest;
|
||||||
|
|||||||
172
apps/api/src/plugins/plugin-pinning.e2e.db.test.ts
Normal file
172
apps/api/src/plugins/plugin-pinning.e2e.db.test.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { zipSync } from 'fflate';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
const enc = (text: string) => new TextEncoder().encode(text);
|
||||||
|
|
||||||
|
function manifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: 'pin-me',
|
||||||
|
name: 'Pin Me',
|
||||||
|
version: '1.0.0',
|
||||||
|
apiVersion: '1',
|
||||||
|
kind: 'code',
|
||||||
|
extensionPoints: [{ type: 'pageTool', id: 'pin', title: { de: 'Pin', en: 'Pin' } }],
|
||||||
|
permissions: [],
|
||||||
|
license: 'MIT',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginZip(m: Record<string, unknown>, bundle = 'export default {}'): Buffer {
|
||||||
|
return Buffer.from(
|
||||||
|
zipSync({ 'manifest.json': enc(JSON.stringify(m)), 'plugin.js': enc(bundle) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256 = (buffer: Buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash-pinning allowlist (issue #232, ADR 0025): with a non-empty
|
||||||
|
* `plugins.allowlist`, only pinned ids with the exact bundle hash install
|
||||||
|
* and load; tampered or unpinned bundles fail closed with a stable code,
|
||||||
|
* every rejection is audited, and a version bump requires an explicit
|
||||||
|
* re-pin. Empty allowlist = unchanged behaviour (backward compatible).
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasTestDb)('plugin hash pinning (e2e, issue #232)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let settings: InstanceSettingsService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const password = 'gepinnt ist gepinnt 1';
|
||||||
|
let adminCookie: string;
|
||||||
|
let adminId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
const zipV1 = pluginZip(manifest());
|
||||||
|
const zipV1Tampered = pluginZip(manifest(), 'export default { evil: true }');
|
||||||
|
const zipV2 = pluginZip(manifest({ version: '1.1.0' }));
|
||||||
|
|
||||||
|
async function setAllowlist(entries: { id: string; sha256: string }[]): Promise<void> {
|
||||||
|
await settings.set('plugins.allowlist', entries, adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.pondPlugin.deleteMany({});
|
||||||
|
await prisma.plugin.deleteMany({});
|
||||||
|
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
|
||||||
|
app = await createTestApp();
|
||||||
|
settings = app.get(InstanceSettingsService);
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const adminUser = await users.createUser({
|
||||||
|
username: `pin-admin-${suffix}`,
|
||||||
|
email: `pin-admin-${suffix}@example.org`,
|
||||||
|
displayName: 'Pin Admin',
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(adminUser.id);
|
||||||
|
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||||||
|
adminId = adminUser.id;
|
||||||
|
adminCookie = sessionCookieOf(
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: `pin-admin-${suffix}`, password })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
|
||||||
|
await prisma.pondPlugin.deleteMany({});
|
||||||
|
await prisma.plugin.deleteMany({});
|
||||||
|
await prisma.auditEntry.deleteMany({ where: { targetId: { in: ['pin-me', 'stranger'] } } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty allowlist: installs record the hash, nothing is enforced', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/plugins')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', zipV1, 'pin-me.zip')
|
||||||
|
.expect(201);
|
||||||
|
expect(res.body.pinning).toBe('not_enforced');
|
||||||
|
expect(res.body.bundleSha256).toBe(sha256(zipV1));
|
||||||
|
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pinned hash: plugin stays loadable and reports pinned', async () => {
|
||||||
|
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
|
||||||
|
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
|
||||||
|
expect(list.body[0].pinning).toBe('pinned');
|
||||||
|
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unpinned plugin: install rejected with the stable code and audited', async () => {
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/plugins')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', pluginZip(manifest({ id: 'stranger', name: 'Stranger' })), 'stranger.zip')
|
||||||
|
.expect(403);
|
||||||
|
expect(res.body.code).toBe('plugin_not_pinned');
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'plugin.rejected', targetId: 'stranger' },
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tampered bundle: same id + pin, different bytes — install rejected', async () => {
|
||||||
|
// A tampered re-delivery of the pinned version arrives as an update
|
||||||
|
// attempt; the hash gate must fire before any version comparison.
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/plugins')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', zipV1Tampered, 'pin-me.zip')
|
||||||
|
.expect(403);
|
||||||
|
expect(res.body.code).toBe('plugin_hash_mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pin changed away from the installed hash: plugin no longer loads, audited', async () => {
|
||||||
|
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1Tampered) }]);
|
||||||
|
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
|
||||||
|
// The admin still SEES the plugin, with the deviation named…
|
||||||
|
expect(list.body[0].pinning).toBe('mismatch');
|
||||||
|
// …but nothing serves it: frame 404s and the rejection is audited.
|
||||||
|
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(404);
|
||||||
|
const audit = await prisma.auditEntry.findFirst({
|
||||||
|
where: { action: 'plugin.rejected', targetId: 'pin-me' },
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('version bump requires an explicit re-pin', async () => {
|
||||||
|
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
|
||||||
|
const rejected = await api()
|
||||||
|
.post('/api/v1/admin/plugins')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', zipV2, 'pin-me-1.1.zip')
|
||||||
|
.expect(403);
|
||||||
|
expect(rejected.body.code).toBe('plugin_hash_mismatch');
|
||||||
|
|
||||||
|
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV2) }]);
|
||||||
|
const res = await api()
|
||||||
|
.post('/api/v1/admin/plugins')
|
||||||
|
.set('Cookie', adminCookie)
|
||||||
|
.attach('file', zipV2, 'pin-me-1.1.zip')
|
||||||
|
.expect(201);
|
||||||
|
expect(res.body.pinning).toBe('pinned');
|
||||||
|
await api().get(`/api/v1/plugins/pin-me/1.1.0/frame`).expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
@ -456,7 +456,7 @@ describe.skipIf(!hasTestDb)('plugins kill switch (e2e, issue #200)', () => {
|
|||||||
await prisma.plugin.deleteMany({ where: { id: pluginId } });
|
await prisma.plugin.deleteMany({ where: { id: pluginId } });
|
||||||
const where = { pond: { owner: { username: { contains: suffix } } } };
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
||||||
await prisma.roleGrant.deleteMany({ where });
|
await prisma.roleGrant.deleteMany({ where });
|
||||||
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
|
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
@ -12,6 +14,7 @@ import type {
|
|||||||
import { ClockService } from '../common/clock.service';
|
import { ClockService } from '../common/clock.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
|
||||||
import { PluginPackageService } from './plugin-package.service';
|
import { PluginPackageService } from './plugin-package.service';
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
@ -44,6 +47,7 @@ export class PluginsService {
|
|||||||
private readonly storage: PluginStorageService,
|
private readonly storage: PluginStorageService,
|
||||||
private readonly clock: ClockService,
|
private readonly clock: ClockService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(PluginsService.name);
|
this.logger.setContext(PluginsService.name);
|
||||||
@ -58,6 +62,32 @@ export class PluginsService {
|
|||||||
/** `actor` is absent for dropzone installs (watcher, no session). */
|
/** `actor` is absent for dropzone installs (watcher, no session). */
|
||||||
async install(zip: Buffer, actor?: User): Promise<PluginView> {
|
async install(zip: Buffer, actor?: User): Promise<PluginView> {
|
||||||
const { manifest, files } = this.packages.parse(zip);
|
const { manifest, files } = this.packages.parse(zip);
|
||||||
|
const bundleHash = createHash('sha256').update(zip).digest('hex');
|
||||||
|
|
||||||
|
// Hash pinning (#232, ADR 0025): while the allowlist is non-empty,
|
||||||
|
// only listed ids with the exact pinned bundle hash may install.
|
||||||
|
// Fail closed with a distinct code per cause; every rejection is
|
||||||
|
// audited so a tampering attempt leaves a trace.
|
||||||
|
const allowlist = await this.settings.get('plugins.allowlist');
|
||||||
|
if (allowlist.length > 0) {
|
||||||
|
const pin = allowlist.find((entry) => entry.id === manifest.id);
|
||||||
|
if (!pin || pin.sha256 !== bundleHash) {
|
||||||
|
const reason = pin ? 'hash_mismatch' : 'not_pinned';
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'plugin.rejected',
|
||||||
|
actorId: actor?.id,
|
||||||
|
targetType: 'plugin',
|
||||||
|
targetId: manifest.id,
|
||||||
|
details: { surface: 'install', reason, version: manifest.version, bundleHash },
|
||||||
|
});
|
||||||
|
throw new PluginPackageError(
|
||||||
|
pin ? 'plugin_hash_mismatch' : 'plugin_not_pinned',
|
||||||
|
pin
|
||||||
|
? `Bundle hash ${bundleHash} does not match the pinned hash for ${manifest.id}`
|
||||||
|
: `Plugin ${manifest.id} is not on the allowlist`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
|
const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
|
||||||
const isActiveUpdate = existing !== null && existing.removedAt === null;
|
const isActiveUpdate = existing !== null && existing.removedAt === null;
|
||||||
@ -81,6 +111,7 @@ export class PluginsService {
|
|||||||
apiVersion: manifest.apiVersion,
|
apiVersion: manifest.apiVersion,
|
||||||
kind: manifest.kind,
|
kind: manifest.kind,
|
||||||
manifest: manifest as unknown as Prisma.InputJsonValue,
|
manifest: manifest as unknown as Prisma.InputJsonValue,
|
||||||
|
bundleHash,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
name: manifest.name,
|
name: manifest.name,
|
||||||
@ -88,6 +119,7 @@ export class PluginsService {
|
|||||||
apiVersion: manifest.apiVersion,
|
apiVersion: manifest.apiVersion,
|
||||||
kind: manifest.kind,
|
kind: manifest.kind,
|
||||||
manifest: manifest as unknown as Prisma.InputJsonValue,
|
manifest: manifest as unknown as Prisma.InputJsonValue,
|
||||||
|
bundleHash,
|
||||||
// Reinstalling a previously removed plugin clears the tombstone.
|
// Reinstalling a previously removed plugin clears the tombstone.
|
||||||
removedAt: null,
|
removedAt: null,
|
||||||
},
|
},
|
||||||
@ -105,7 +137,7 @@ export class PluginsService {
|
|||||||
targetId: manifest.id,
|
targetId: manifest.id,
|
||||||
details: { version: manifest.version, update: isActiveUpdate },
|
details: { version: manifest.version, update: isActiveUpdate },
|
||||||
});
|
});
|
||||||
return this.toView(record);
|
return this.toView(record, await this.settings.get('plugins.allowlist'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -131,7 +163,7 @@ export class PluginsService {
|
|||||||
targetId: id,
|
targetId: id,
|
||||||
details: { mode },
|
details: { mode },
|
||||||
});
|
});
|
||||||
return this.toView(updated);
|
return this.toView(updated, await this.settings.get('plugins.allowlist'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,9 +214,18 @@ export class PluginsService {
|
|||||||
this.prisma.pondPlugin.findMany({ where: { pondId } }),
|
this.prisma.pondPlugin.findMany({ where: { pondId } }),
|
||||||
]);
|
]);
|
||||||
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
|
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
|
||||||
return plugins
|
const allowlist = await this.settings.get('plugins.allowlist');
|
||||||
.filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true))
|
return (
|
||||||
.map((p) => this.toView(p));
|
plugins
|
||||||
|
.filter(
|
||||||
|
(p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true),
|
||||||
|
)
|
||||||
|
// Hash pinning (#232): a plugin outside the allowlist, or with a
|
||||||
|
// deviating bundle hash, does not load — it simply never appears in
|
||||||
|
// the pond's mount list. Existing blocks render their fallback.
|
||||||
|
.filter((p) => this.loadable(this.verdict(p, allowlist)))
|
||||||
|
.map((p) => this.toView(p, allowlist))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -219,7 +260,11 @@ export class PluginsService {
|
|||||||
this.prisma.pondPlugin.findMany({ where: { pondId } }),
|
this.prisma.pondPlugin.findMany({ where: { pondId } }),
|
||||||
]);
|
]);
|
||||||
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
|
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
|
||||||
return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true }));
|
const allowlist = await this.settings.get('plugins.allowlist');
|
||||||
|
return plugins.map((p) => ({
|
||||||
|
plugin: this.toView(p, allowlist),
|
||||||
|
enabled: enabled.get(p.id) === true,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -287,19 +332,66 @@ export class PluginsService {
|
|||||||
where: { removedAt: null },
|
where: { removedAt: null },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
return plugins.map((plugin) => this.toView(plugin));
|
const allowlist = await this.settings.get('plugins.allowlist');
|
||||||
|
return plugins.map((plugin) => this.toView(plugin, allowlist));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One installed plugin, or `null` if absent/removed. */
|
/** One installed plugin, or `null` if absent/removed. */
|
||||||
async get(id: string): Promise<PluginView | null> {
|
async get(id: string): Promise<PluginView | null> {
|
||||||
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
|
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
|
||||||
if (!plugin || plugin.removedAt !== null) return null;
|
if (!plugin || plugin.removedAt !== null) return null;
|
||||||
return this.toView(plugin);
|
return this.toView(plugin, await this.settings.get('plugins.allowlist'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private toView(plugin: Plugin): PluginView {
|
/**
|
||||||
|
* Pinning verdict against `plugins.allowlist` (#232). The observed hash
|
||||||
|
* is the one recorded at install: post-install tampering with unpacked
|
||||||
|
* files on disk is platform integrity (ADR 0019), not this check's
|
||||||
|
* scope — the pin answers "is this the reviewed bundle".
|
||||||
|
*/
|
||||||
|
private verdict(
|
||||||
|
plugin: Plugin,
|
||||||
|
allowlist: { id: string; sha256: string }[],
|
||||||
|
): PluginView['pinning'] {
|
||||||
|
if (allowlist.length === 0) return 'not_enforced';
|
||||||
|
const pin = allowlist.find((entry) => entry.id === plugin.id);
|
||||||
|
if (!pin) return 'unpinned';
|
||||||
|
return plugin.bundleHash === pin.sha256 ? 'pinned' : 'mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadable(verdict: PluginView['pinning']): boolean {
|
||||||
|
return verdict === 'not_enforced' || verdict === 'pinned';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The load gate for code-serving surfaces (frame + assets, #232): an
|
||||||
|
* installed plugin outside the allowlist, or with a deviating bundle
|
||||||
|
* hash, does not load — 404 like a missing plugin, and the rejection is
|
||||||
|
* audited (unlike the silent list filtering, an asset request proves
|
||||||
|
* something actively referenced the blocked plugin).
|
||||||
|
*/
|
||||||
|
async getServable(id: string): Promise<PluginView | null> {
|
||||||
|
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
|
||||||
|
if (!plugin || plugin.removedAt !== null) return null;
|
||||||
|
const allowlist = await this.settings.get('plugins.allowlist');
|
||||||
|
const verdict = this.verdict(plugin, allowlist);
|
||||||
|
if (!this.loadable(verdict)) {
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'plugin.rejected',
|
||||||
|
targetType: 'plugin',
|
||||||
|
targetId: id,
|
||||||
|
details: { surface: 'load', reason: verdict, version: plugin.version },
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toView(plugin, allowlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toView(plugin: Plugin, allowlist: { id: string; sha256: string }[]): PluginView {
|
||||||
const manifest = plugin.manifest as unknown as PluginManifest;
|
const manifest = plugin.manifest as unknown as PluginManifest;
|
||||||
return {
|
return {
|
||||||
|
bundleSha256: plugin.bundleHash,
|
||||||
|
pinning: this.verdict(plugin, allowlist),
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
version: plugin.version,
|
version: plugin.version,
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { PondAccessNotifier } from './pond-access-notifier.service';
|
import { PondAccessNotifier } from './pond-access-notifier.service';
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
|
|||||||
await prisma.quotaOverride.deleteMany({
|
await prisma.quotaOverride.deleteMany({
|
||||||
where: { subjectId: { in: users.map((u) => u.id) } },
|
where: { subjectId: { in: users.map((u) => u.id) } },
|
||||||
});
|
});
|
||||||
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
@ -106,6 +106,107 @@ describe.skipIf(!hasTestDb)('ponds (e2e, issue #21)', () => {
|
|||||||
expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1);
|
expect(res.body.filter((p: { type: string }) => p.type === 'personal')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gives a new shared pond a start page and points settings at it (issue #302)', async () => {
|
||||||
|
const created = await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ name: `Startseitenteich ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const pages = await api()
|
||||||
|
.get(`/api/v1/ponds/${created.body.id}/pages`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(pages.body).toHaveLength(1);
|
||||||
|
|
||||||
|
// The title follows the creator's stored locale — this owner is 'de'.
|
||||||
|
expect(pages.body[0].title).toBe('Startseite');
|
||||||
|
|
||||||
|
const pond = await api()
|
||||||
|
.get(`/api/v1/ponds/${created.body.slug}`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(pond.body.settings.startPageId).toBe(pages.body[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("titles the start page in the creator's locale (issue #302)", async () => {
|
||||||
|
// The owner is 'de' and got "Startseite" above; an 'en' account must get
|
||||||
|
// the English title. Without both halves the test would pass on a
|
||||||
|
// hardcoded string just as happily.
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const tokens = app.get(AuthTokensService);
|
||||||
|
const username = `ellie-${suffix}`;
|
||||||
|
const user = await users.createUser({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: `Ellie English ${suffix}`,
|
||||||
|
password,
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
const token = await tokens.issue(user.id, 'EMAIL_VERIFICATION', 600);
|
||||||
|
await api().post('/api/v1/auth/verify-email').send({ token }).expect(204);
|
||||||
|
|
||||||
|
const pond = await prisma.pond.findFirstOrThrow({
|
||||||
|
where: { ownerId: user.id, type: 'PERSONAL' },
|
||||||
|
});
|
||||||
|
const pages = await prisma.page.findMany({ where: { pondId: pond.id } });
|
||||||
|
expect(pages.map((page) => page.title)).toEqual(['Home']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the personal pond a start page too (issue #302)', async () => {
|
||||||
|
const res = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
|
||||||
|
const personal = res.body.find((p: { type: string }) => p.type === 'personal');
|
||||||
|
const pages = await api()
|
||||||
|
.get(`/api/v1/ponds/${personal.id}/pages`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(pages.body).toHaveLength(1);
|
||||||
|
expect(personal.settings.startPageId).toBe(pages.body[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the start page without losing other settings (issue #302)', async () => {
|
||||||
|
const created = await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ name: `Wechselteich ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await api()
|
||||||
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ commentPolicy: 'editors' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const second = await api()
|
||||||
|
.post(`/api/v1/ponds/${created.body.id}/pages`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ title: `Zweite ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const updated = await api()
|
||||||
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ startPageId: second.body.id })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(updated.body.settings.startPageId).toBe(second.body.id);
|
||||||
|
// The neighbouring key must survive the merge — settings hold only
|
||||||
|
// deviations, so an assigning write would silently reset it.
|
||||||
|
expect(updated.body.settings.commentPolicy).toBe('editors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the start page back to the sort-order default (issue #302)', async () => {
|
||||||
|
const created = await api()
|
||||||
|
.post('/api/v1/ponds')
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ name: `Leerteich ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const cleared = await api()
|
||||||
|
.patch(`/api/v1/ponds/${created.body.id}`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ startPageId: null })
|
||||||
|
.expect(200);
|
||||||
|
expect(cleared.body.settings.startPageId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates shared ponds with deterministic slug suffixes', async () => {
|
it('creates shared ponds with deterministic slug suffixes', async () => {
|
||||||
const name = `Gartenteich ${suffix}`;
|
const name = `Gartenteich ${suffix}`;
|
||||||
const first = await api()
|
const first = await api()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PagesModule } from '../pages/pages.module';
|
||||||
import { QuotasModule } from '../quotas/quotas.module';
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ import { PondsController } from './ponds.controller';
|
|||||||
import { PondsService } from './ponds.service';
|
import { PondsService } from './ponds.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QuotasModule, SearchModule],
|
imports: [PagesModule, QuotasModule, SearchModule],
|
||||||
controllers: [PondsController],
|
controllers: [PondsController],
|
||||||
providers: [PondsService, PondAccessNotifier],
|
providers: [PondsService, PondAccessNotifier],
|
||||||
exports: [PondsService, PondAccessNotifier],
|
exports: [PondsService, PondAccessNotifier],
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
import { Pond, Prisma, User } from '@prisma/client';
|
import { Pond, Prisma, User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
import { PagesService } from '../pages/pages.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { QuotaService } from '../quotas/quota.service';
|
import { QuotaService } from '../quotas/quota.service';
|
||||||
@ -23,11 +25,52 @@ export class PondsService {
|
|||||||
private readonly quotas: QuotaService,
|
private readonly quotas: QuotaService,
|
||||||
private readonly accessNotifier: PondAccessNotifier,
|
private readonly accessNotifier: PondAccessNotifier,
|
||||||
private readonly search: SearchProvider,
|
private readonly search: SearchProvider,
|
||||||
|
private readonly pages: PagesService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(PondsService.name);
|
this.logger.setContext(PondsService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every new pond opens on a start page instead of the empty-pond hint
|
||||||
|
* (issue #302). Deliberately AFTER the creating transaction commits: the
|
||||||
|
* owner's POND_ADMIN grant is written inside it and the permission layer
|
||||||
|
* caches per pond, so creating the page in the same transaction would ask
|
||||||
|
* about rights the grant has not yet published.
|
||||||
|
*
|
||||||
|
* Goes through PagesService so the page carries every invariant a page
|
||||||
|
* needs — unique slug, appended sort key, derived content cache, search
|
||||||
|
* indexing, and an `emptyPageState()` the collab server can bind to. A
|
||||||
|
* hand-rolled insert here would produce a page the editor cannot open.
|
||||||
|
*
|
||||||
|
* Failure is logged, not fatal: a pond without a start page simply falls
|
||||||
|
* back to the historical behaviour, which is a working state. Losing the
|
||||||
|
* whole pond over its first page would not be.
|
||||||
|
*/
|
||||||
|
private async createStartPage(owner: User, pondId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const title = apiI18n.t('ponds:startPage.title', {
|
||||||
|
lng: owner.locale === 'de' ? 'de' : 'en',
|
||||||
|
});
|
||||||
|
const page = await this.pages.create(owner, pondId, { title });
|
||||||
|
const pond = await this.prisma.pond.findUniqueOrThrow({
|
||||||
|
where: { id: pondId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
// Merge rather than assign: stored settings hold only deviations from
|
||||||
|
// the defaults, and overwriting the object would drop them.
|
||||||
|
await this.prisma.pond.update({
|
||||||
|
where: { id: pondId },
|
||||||
|
data: { settings: { ...(pond.settings as object), startPageId: page.id } },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
{ pondId, err: error instanceof Error ? error.message : String(error) },
|
||||||
|
'start page for new pond could not be created',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewOf(pond: Pond): PondView {
|
viewOf(pond: Pond): PondView {
|
||||||
return {
|
return {
|
||||||
id: pond.id,
|
id: pond.id,
|
||||||
@ -105,7 +148,12 @@ export class PondsService {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
|
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
|
||||||
return this.viewOf(pond);
|
await this.createStartPage(owner, pond.id);
|
||||||
|
// Re-read: the row captured in the transaction predates the start page,
|
||||||
|
// so returning it would hand the caller `startPageId: null` for a pond
|
||||||
|
// that has one.
|
||||||
|
const withStartPage = await this.prisma.pond.findUniqueOrThrow({ where: { id: pond.id } });
|
||||||
|
return this.viewOf(withStartPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -128,6 +176,7 @@ export class PondsService {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
|
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
|
||||||
|
await this.createStartPage(user, pond.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listVisible(user: User): Promise<PondView[]> {
|
async listVisible(user: User): Promise<PondView[]> {
|
||||||
@ -157,7 +206,8 @@ export class PondsService {
|
|||||||
input.commentPolicy !== undefined ||
|
input.commentPolicy !== undefined ||
|
||||||
input.apiEnabled !== undefined ||
|
input.apiEnabled !== undefined ||
|
||||||
input.mcpEnabled !== undefined ||
|
input.mcpEnabled !== undefined ||
|
||||||
input.theme !== undefined;
|
input.theme !== undefined ||
|
||||||
|
input.startPageId !== undefined;
|
||||||
const settings = !settingsChanged
|
const settings = !settingsChanged
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
@ -169,6 +219,7 @@ export class PondsService {
|
|||||||
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
|
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
|
||||||
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
|
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
|
||||||
...(input.theme !== undefined ? { theme: input.theme } : {}),
|
...(input.theme !== undefined ? { theme: input.theme } : {}),
|
||||||
|
...(input.startPageId !== undefined ? { startPageId: input.startPageId } : {}),
|
||||||
};
|
};
|
||||||
const updated = await this.prisma.pond.update({
|
const updated = await this.prisma.pond.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared';
|
import {
|
||||||
|
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';
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,8 +21,21 @@ 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),
|
||||||
@ -139,6 +158,26 @@ export const INSTANCE_SETTINGS = {
|
|||||||
// (an image fallback degrades to neutral text: its bytes live on the
|
// (an image fallback degrades to neutral text: its bytes live on the
|
||||||
// disabled asset surface).
|
// disabled asset surface).
|
||||||
'plugins.enabled': z.boolean().default(true),
|
'plugins.enabled': z.boolean().default(true),
|
||||||
|
// Plugin allowlist with SHA-256 hash pinning (issue #232, ADR 0025).
|
||||||
|
// Empty (the default) = pinning is NOT enforced — plugins load as
|
||||||
|
// before, which keeps existing instances working. Non-empty = only the
|
||||||
|
// listed plugin ids load, and only while the installed bundle's
|
||||||
|
// observed hash equals the pinned one; installs of unlisted or
|
||||||
|
// mismatching bundles are rejected, loads fail closed (assets/frame
|
||||||
|
// 404, audited `plugin.rejected`). A version bump changes the bundle
|
||||||
|
// hash, so it requires an explicit re-pin — the intended friction.
|
||||||
|
'plugins.allowlist': z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
sha256: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(/^[a-f0-9]{64}$/),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
// Atom feed master switch (issue #191). Default ON: feeds predate the
|
// Atom feed master switch (issue #191). Default ON: feeds predate the
|
||||||
// switch, so existing instances and their subscribed readers keep
|
// switch, so existing instances and their subscribed readers keep
|
||||||
// working; the VS-NfD reference configuration (#227) turns it off.
|
// working; the VS-NfD reference configuration (#227) turns it off.
|
||||||
@ -201,6 +240,7 @@ export class InstanceSettingsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
|
private readonly config: AppConfig,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(InstanceSettingsService.name);
|
this.logger.setContext(InstanceSettingsService.name);
|
||||||
}
|
}
|
||||||
@ -237,6 +277,22 @@ export class InstanceSettingsService {
|
|||||||
details: { [key]: parsed.error.issues.map((i) => i.message) },
|
details: { [key]: parsed.error.issues.map((i) => i.message) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Mode `enforced` (#246, ADR 0027): a write that would set a
|
||||||
|
// catalog-violating value is rejected at the ONE write path every
|
||||||
|
// caller uses — hiding alone (#245) is UI cosmetics a scripted client
|
||||||
|
// bypasses. Existing violating values are reported (startup log,
|
||||||
|
// admin card), never auto-changed: the operator resolves them
|
||||||
|
// consciously, and writes that DECREASE compliance are what this
|
||||||
|
// blocks. 403, not 400: the request is well-formed, the policy says no.
|
||||||
|
if (this.config.env.VS_NFD_MODE === 'enforced') {
|
||||||
|
const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key);
|
||||||
|
if (entry && !isVsNfdCompliant(entry, parsed.data)) {
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'vs_nfd_profile_violation',
|
||||||
|
details: { [key]: ['vs_nfd_profile_violation'] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
// Nullable settings (setup.completedAt) store JSON null explicitly —
|
// Nullable settings (setup.completedAt) store JSON null explicitly —
|
||||||
// Prisma requires the sentinel for that.
|
// Prisma requires the sentinel for that.
|
||||||
const stored = parsed.data === null ? Prisma.JsonNull : parsed.data;
|
const stored = parsed.data === null ? Prisma.JsonNull : parsed.data;
|
||||||
|
|||||||
143
apps/api/src/settings/vs-nfd-enforced.e2e.db.test.ts
Normal file
143
apps/api/src/settings/vs-nfd-enforced.e2e.db.test.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { type VsNfdProfileView } from '@dorfteich/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode `enforced` (issue #246, ADR 0027): the api rejects settings writes
|
||||||
|
* that would set a catalog-violating value — with a stable error code —
|
||||||
|
* while existing violations are reported and never auto-changed. The mode
|
||||||
|
* is env-fixed per boot, so each mode gets its own app (sequential
|
||||||
|
* describes; pattern vs-nfd-profile.e2e.db.test.ts).
|
||||||
|
*/
|
||||||
|
async function makeAdmin(
|
||||||
|
app: INestApplication,
|
||||||
|
prisma: PrismaClient,
|
||||||
|
suffix: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const users = app.get(UsersService);
|
||||||
|
const username = `enf-admin-${suffix}`;
|
||||||
|
const user = await users.createUser({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.org`,
|
||||||
|
displayName: 'Enf Admin',
|
||||||
|
password: 'erzwungen ist erzwungen 1',
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
await users.markEmailVerified(user.id);
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
|
||||||
|
return sessionCookieOf(
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ usernameOrEmail: username, password: 'erzwungen ist erzwungen 1' })
|
||||||
|
.expect(200),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('VS-NfD mode enforced (e2e, issue #246)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let cookie: string;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.VS_NFD_MODE = 'enforced';
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
// A violation that exists BEFORE the enforced boot: reported, never
|
||||||
|
// auto-changed (feeds.enabled default is true = violating anyway, but
|
||||||
|
// pin it as an explicit stored row).
|
||||||
|
await prisma.instanceSetting.upsert({
|
||||||
|
where: { key: 'feeds.enabled' },
|
||||||
|
create: { key: 'feeds.enabled', value: true },
|
||||||
|
update: { value: true },
|
||||||
|
});
|
||||||
|
app = await createTestApp();
|
||||||
|
cookie = await makeAdmin(app, prisma, suffix);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.VS_NFD_MODE;
|
||||||
|
await prisma.instanceSetting.deleteMany({
|
||||||
|
where: { key: { in: ['feeds.enabled', 'auth.registrationMode', 'upload.svgPolicy'] } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a violating write with the stable error code', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', cookie)
|
||||||
|
.send({ 'upload.svgPolicy': 'sanitize' })
|
||||||
|
.expect(403);
|
||||||
|
expect(res.body.code).toBe('vs_nfd_profile_violation');
|
||||||
|
// Nothing was stored — the read still returns the schema default.
|
||||||
|
const settings = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', cookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(settings.body['upload.svgPolicy']).toBe('sanitize');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts compliant writes', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', cookie)
|
||||||
|
.send({ 'auth.registrationMode': 'closed', 'upload.svgPolicy': 'reject' })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the pre-existing violation and never auto-changes it', async () => {
|
||||||
|
const profile = (
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/admin/system/vs-nfd-profile')
|
||||||
|
.set('Cookie', cookie)
|
||||||
|
.expect(200)
|
||||||
|
).body as VsNfdProfileView;
|
||||||
|
expect(profile.mode).toBe('enforced');
|
||||||
|
expect(profile.entries.find((e) => e.key === 'feeds.enabled')!.compliant).toBe(false);
|
||||||
|
const settings = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', cookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(settings.body['feeds.enabled']).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('the same write passes outside enforced (issue #246)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let cookie: string;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.VS_NFD_MODE = 'hidden';
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
app = await createTestApp();
|
||||||
|
cookie = await makeAdmin(app, prisma, suffix);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.VS_NFD_MODE;
|
||||||
|
await prisma.instanceSetting.deleteMany({ where: { key: 'upload.svgPolicy' } });
|
||||||
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mode hidden: the violating write is NOT rejected (UI-level only)', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', cookie)
|
||||||
|
.send({ 'upload.svgPolicy': 'sanitize' })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -93,6 +93,14 @@ describe.skipIf(!hasTestDb)('VS-NfD profile endpoint (e2e, issue #243)', () => {
|
|||||||
expect(view.violations).toBeGreaterThan(0);
|
expect(view.violations).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('mode marked: a violating write is NOT rejected (issue #246 contrast)', async () => {
|
||||||
|
await api()
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.set('Cookie', cookies.admin!)
|
||||||
|
.send({ 'upload.svgPolicy': 'sanitize' })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
it('verdict follows a settings change', async () => {
|
it('verdict follows a settings change', async () => {
|
||||||
const settings = app.get(InstanceSettingsService);
|
const settings = app.get(InstanceSettingsService);
|
||||||
const before = (
|
const before = (
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import {
|
import {
|
||||||
VS_NFD_PROFILE,
|
VS_NFD_PROFILE,
|
||||||
describeCompliance,
|
describeCompliance,
|
||||||
@ -20,11 +21,33 @@ import { InstanceSettingsService, type InstanceSettings } from './instance-setti
|
|||||||
* (#244–#246) build on this evaluation; here it is exposure only.
|
* (#244–#246) build on this evaluation; here it is exposure only.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VsNfdProfileService {
|
export class VsNfdProfileService implements OnModuleInit {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
) {}
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(VsNfdProfileService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startup report (#246): existing violations are stated once, never
|
||||||
|
* auto-changed — the operator resolves them consciously. Must not throw
|
||||||
|
* on a database-less boot (healthz suites boot without a db). */
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
if (this.config.env.VS_NFD_MODE === 'off') return;
|
||||||
|
try {
|
||||||
|
const view = await this.evaluate();
|
||||||
|
this.logger.info(
|
||||||
|
{
|
||||||
|
mode: view.mode,
|
||||||
|
violations: view.entries.filter((e) => !e.compliant).map((e) => e.key),
|
||||||
|
},
|
||||||
|
'VS-NfD profile verdict at startup',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Stated at the next successful evaluation instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private valueOf(entry: VsNfdProfileEntry, settings: InstanceSettings): unknown {
|
private valueOf(entry: VsNfdProfileEntry, settings: InstanceSettings): unknown {
|
||||||
return entry.scope === 'instance'
|
return entry.scope === 'instance'
|
||||||
|
|||||||
@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
|
|||||||
expect(locked.body.code).toBe('setup_locked');
|
expect(locked.body.code).toBe('setup_locked');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('env pre-seeding with invalid values (issue #325)', () => {
|
||||||
|
const dbName = `dorfteich_preseed_bad_${suffix}`;
|
||||||
|
let app: INestApplication;
|
||||||
|
const badEnv = {
|
||||||
|
SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`,
|
||||||
|
SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`,
|
||||||
|
SETUP_ADMIN_PASSWORD: 'short',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const url = await createFreshDatabase(dbName);
|
||||||
|
process.env.TEST_DATABASE_URL = url;
|
||||||
|
process.env.SECRETS_FILE = join(
|
||||||
|
mkdtempSync(join(tmpdir(), 'dorfteich-preseed-bad-')),
|
||||||
|
'secrets.env',
|
||||||
|
);
|
||||||
|
Object.assign(process.env, badEnv);
|
||||||
|
app = await createTestApp();
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const key of Object.keys(badEnv)) delete process.env[key];
|
||||||
|
await app.close();
|
||||||
|
await dropDatabase(dbName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => {
|
||||||
|
await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow(
|
||||||
|
/SETUP_ADMIN_PASSWORD must be at least 10 characters/,
|
||||||
|
);
|
||||||
|
// Fail-fast left nothing half-seeded: the wizard is still pending.
|
||||||
|
const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200);
|
||||||
|
expect(status.body.status).toBe('required');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
interface FakeSmtpServer {
|
interface FakeSmtpServer {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
import { SessionsService } from '../auth/sessions.service';
|
import { SessionsService } from '../auth/sessions.service';
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit {
|
|||||||
if (!(await this.state.isPending())) return;
|
if (!(await this.state.isPending())) return;
|
||||||
|
|
||||||
// Fails the boot loudly on invalid values — a half-seeded instance
|
// Fails the boot loudly on invalid values — a half-seeded instance
|
||||||
// would be much harder to diagnose than a startup error.
|
// would be much harder to diagnose than a startup error. Translated
|
||||||
const input = setupAdminInputSchema.parse({
|
// into operator terms first: the raw ZodError names schema fields and
|
||||||
|
// i18n keys, not the SETUP_* variable to fix (issue #325).
|
||||||
|
const parsed = setupAdminInputSchema.safeParse({
|
||||||
username: env.SETUP_ADMIN_USERNAME,
|
username: env.SETUP_ADMIN_USERNAME,
|
||||||
email: env.SETUP_ADMIN_EMAIL,
|
email: env.SETUP_ADMIN_EMAIL,
|
||||||
password: env.SETUP_ADMIN_PASSWORD,
|
password: env.SETUP_ADMIN_PASSWORD,
|
||||||
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
|
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
|
||||||
locale: env.SETUP_DEFAULT_LOCALE,
|
locale: env.SETUP_DEFAULT_LOCALE,
|
||||||
});
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Pre-seeding failed: ${describePreseedIssues(parsed.error)}. ` +
|
||||||
|
'Fix .env and recreate the api container.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const input = parsed.data;
|
||||||
const admin = await this.createAdmin(input);
|
const admin = await this.createAdmin(input);
|
||||||
if (env.SETUP_INSTANCE_NAME) {
|
if (env.SETUP_INSTANCE_NAME) {
|
||||||
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
|
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
|
||||||
@ -223,3 +233,27 @@ export class SetupService implements OnModuleInit {
|
|||||||
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
|
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The env variable behind each schema field of the pre-seeded admin. */
|
||||||
|
const PRESEED_FIELD_TO_ENV: Record<string, string> = {
|
||||||
|
username: 'SETUP_ADMIN_USERNAME',
|
||||||
|
email: 'SETUP_ADMIN_EMAIL',
|
||||||
|
password: 'SETUP_ADMIN_PASSWORD',
|
||||||
|
displayName: 'SETUP_ADMIN_DISPLAY_NAME',
|
||||||
|
locale: 'SETUP_DEFAULT_LOCALE',
|
||||||
|
};
|
||||||
|
|
||||||
|
function describePreseedIssues(error: ZodError): string {
|
||||||
|
return error.issues
|
||||||
|
.map((issue) => {
|
||||||
|
const variable = PRESEED_FIELD_TO_ENV[String(issue.path[0])] ?? String(issue.path[0]);
|
||||||
|
if (issue.code === 'too_small' && issue.type === 'string') {
|
||||||
|
return `${variable} must be at least ${issue.minimum} characters`;
|
||||||
|
}
|
||||||
|
if (issue.code === 'invalid_string' && issue.validation === 'email') {
|
||||||
|
return `${variable} is not a valid e-mail address`;
|
||||||
|
}
|
||||||
|
return `${variable} is invalid (${issue.message})`;
|
||||||
|
})
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
/** True when database-backed tests can run (see vitest.global-setup.ts). */
|
/** True when database-backed tests can run (see vitest.global-setup.ts). */
|
||||||
export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL);
|
export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||||
@ -40,3 +40,27 @@ export async function grantOwnerAdmin(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the ponds matching `where`, their pages first.
|
||||||
|
*
|
||||||
|
* `Page.pond` deliberately carries no `onDelete: Cascade` — a real purge
|
||||||
|
* (TrashService) removes a pond's contents explicitly and audits it, and a
|
||||||
|
* silent database cascade would hide that. Since issue #302 every pond
|
||||||
|
* created through the api starts with a page, so teardowns that went
|
||||||
|
* straight for `pond.deleteMany` now hit the foreign key.
|
||||||
|
*
|
||||||
|
* Page-owned rows (updates, comments, links, …) do cascade from the page.
|
||||||
|
*/
|
||||||
|
export async function deletePondsWhere(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
where: Prisma.PondWhereInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const pondIds = (await prisma.pond.findMany({ where, select: { id: true } })).map(
|
||||||
|
(pond) => pond.id,
|
||||||
|
);
|
||||||
|
if (pondIds.length === 0) return;
|
||||||
|
await prisma.attachment.deleteMany({ where: { pondId: { in: pondIds } } });
|
||||||
|
await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } });
|
||||||
|
await prisma.pond.deleteMany({ where: { id: { in: pondIds } } });
|
||||||
|
}
|
||||||
|
|||||||
@ -231,7 +231,10 @@ describe.skipIf(!hasTestDb)('pond purge (e2e, issue #193)', () => {
|
|||||||
where: { action: 'pond.purged', targetId: pondId },
|
where: { action: 'pond.purged', targetId: pondId },
|
||||||
});
|
});
|
||||||
expect(audit).not.toBeNull();
|
expect(audit).not.toBeNull();
|
||||||
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 2, attachments: 1 });
|
// Two pages created here plus the pond's own start page (issue #302) —
|
||||||
|
// the audit records what was actually removed, so the count moves with
|
||||||
|
// the pond's real contents rather than with what the test typed out.
|
||||||
|
expect(audit!.details).toMatchObject({ trigger: 'manual', pages: 3, attachments: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('purges due ponds on the retention path with an audit event', async () => {
|
it('purges due ponds on the retention path with an audit event', async () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Module, OnModuleInit } from '@nestjs/common';
|
import { Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BrandingModule } from '../branding/branding.module';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { PagesModule } from '../pages/pages.module';
|
import { PagesModule } from '../pages/pages.module';
|
||||||
@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
BrandingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
PondsModule,
|
PondsModule,
|
||||||
QuotasModule,
|
QuotasModule,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { User } from '@prisma/client';
|
|||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { BrandingService } from '../branding/branding.service';
|
||||||
import { ClockService } from '../common/clock.service';
|
import { ClockService } from '../common/clock.service';
|
||||||
import { SearchProvider } from '../search/search.provider';
|
import { SearchProvider } from '../search/search.provider';
|
||||||
import { PagesService } from '../pages/pages.service';
|
import { PagesService } from '../pages/pages.service';
|
||||||
@ -32,6 +33,7 @@ export class TrashService {
|
|||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly quotas: QuotaService,
|
private readonly quotas: QuotaService,
|
||||||
private readonly storage: FileStorageService,
|
private readonly storage: FileStorageService,
|
||||||
|
private readonly branding: BrandingService,
|
||||||
private readonly clock: ClockService,
|
private readonly clock: ClockService,
|
||||||
private readonly watches: WatchesService,
|
private readonly watches: WatchesService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
@ -183,6 +185,9 @@ export class TrashService {
|
|||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
await this.storage.delete(pondId, attachment.id);
|
await this.storage.delete(pondId, attachment.id);
|
||||||
}
|
}
|
||||||
|
// The pond's branding files (issue #307). The purge standard is absolute:
|
||||||
|
// after it, nothing referencing the pond survives — rows OR files.
|
||||||
|
await this.branding.removePondAssets(pondId);
|
||||||
const pageIds = (
|
const pageIds = (
|
||||||
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
|
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
|
||||||
).map((page) => page.id);
|
).map((page) => page.id);
|
||||||
|
|||||||
@ -20,6 +20,7 @@ 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
|
||||||
|
|||||||
30
apps/backup/src/data-dirs.test.ts
Normal file
30
apps/backup/src/data-dirs.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { backupEnvSchema } from '@dorfteich/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { dataDirs } from './data-dirs.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fence against the failure #303 hit and #306 could repeat: a new data
|
||||||
|
* directory gets its env entry but not its line here, and the nightly archive
|
||||||
|
* skips it WORDLESSLY (`createArchive` tolerates missing directories on
|
||||||
|
* purpose). Nobody notices until a restore comes up short.
|
||||||
|
*
|
||||||
|
* Every `*_DIR` the backup sidecar knows must therefore travel in the archive.
|
||||||
|
* `BACKUPS_DIR` is the exception by definition — it is where the archive is
|
||||||
|
* written, not something archived into it.
|
||||||
|
*/
|
||||||
|
const NOT_DATA = new Set(['BACKUPS_DIR']);
|
||||||
|
|
||||||
|
describe('data directories (issues #303/#306)', () => {
|
||||||
|
it('archives every *_DIR the backup env declares', () => {
|
||||||
|
const env = backupEnvSchema.parse({ DATABASE_URL: 'postgresql://x/y' });
|
||||||
|
const values = env as unknown as Record<string, unknown>;
|
||||||
|
const declared = Object.keys(env).filter((key) => key.endsWith('_DIR') && !NOT_DATA.has(key));
|
||||||
|
const archived = dataDirs(env);
|
||||||
|
|
||||||
|
expect(declared.length).toBeGreaterThan(0);
|
||||||
|
for (const key of declared) {
|
||||||
|
expect(archived, `${key} is missing from dataDirs()`).toContain(values[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
18
apps/backup/src/data-dirs.ts
Normal file
18
apps/backup/src/data-dirs.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { BackupEnv } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data directories that travel in a restore set's archive (ADR 0015).
|
||||||
|
*
|
||||||
|
* ONE list, used by the nightly archive AND by the restore — they must not
|
||||||
|
* drift, or a backup would carry something the restore never puts back.
|
||||||
|
* Adding a new persistent data directory is a one-line change here plus the
|
||||||
|
* env entry and the compose mount.
|
||||||
|
*
|
||||||
|
* All of them must share a parent directory: `archiveBase` derives the tar
|
||||||
|
* root from that and throws otherwise.
|
||||||
|
*/
|
||||||
|
export function dataDirs(
|
||||||
|
env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR' | 'BRANDING_DIR'>,
|
||||||
|
): string[] {
|
||||||
|
return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR, env.BRANDING_DIR];
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ 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';
|
||||||
@ -50,7 +51,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, [env.UPLOADS_DIR, env.PLUGINS_DIR]),
|
archive: (outFile) => createArchive(outFile, dataDirs(env)),
|
||||||
onFailure: async (run) => {
|
onFailure: async (run) => {
|
||||||
const sent = await sendFailureMail(env, run);
|
const sent = await sendFailureMail(env, run);
|
||||||
if (!sent)
|
if (!sent)
|
||||||
|
|||||||
@ -5,18 +5,27 @@ 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 uploads/plugins mounts. Shared by the operator CLI
|
* back over the data mounts (see data-dirs.ts). Shared by the operator CLI
|
||||||
* (restore.js via restore.sh) and the in-app restore orchestrator (#103) —
|
* (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<BackupEnv, 'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR'>,
|
env: Pick<
|
||||||
|
BackupEnv,
|
||||||
|
| 'BACKUPS_DIR'
|
||||||
|
| 'DATABASE_URL'
|
||||||
|
| 'UPLOADS_DIR'
|
||||||
|
| 'PLUGINS_DIR'
|
||||||
|
| 'CUSTOM_FONTS_DIR'
|
||||||
|
| 'BRANDING_DIR'
|
||||||
|
>,
|
||||||
backupId: string,
|
backupId: string,
|
||||||
log: RemoteLogger,
|
log: RemoteLogger,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -29,6 +38,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 uploads/plugins archive');
|
log.info({ backupId }, 'restoring the data-directory archive');
|
||||||
await extractArchive(archiveFile, [env.UPLOADS_DIR, env.PLUGINS_DIR]);
|
await extractArchive(archiveFile, dataDirs(env));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,7 +87,27 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -99,8 +119,111 @@ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
55
apps/web/e2e/admin-settings.spec.ts
Normal file
55
apps/web/e2e/admin-settings.spec.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The general admin settings card saves THROUGH THE FORM (issue #322).
|
||||||
|
*
|
||||||
|
* This must drive the UI, not the api: the bug it fences was invisible to
|
||||||
|
* every api-level test — react-hook-form nested the dotted field names on
|
||||||
|
* input, the strict PATCH schema rejected the body, and the form looked
|
||||||
|
* fine while never saving. Verified end to end: success message, the value
|
||||||
|
* survives a full reload, the api returns it, and the TopBar picks it up
|
||||||
|
* without a reload (branding query invalidation).
|
||||||
|
*/
|
||||||
|
test('instance name changed in the general settings form persists', async ({ browser }) => {
|
||||||
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
const before = (
|
||||||
|
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
|
||||||
|
)['instance.name'] as string;
|
||||||
|
const newName = `Renamed ${Date.now()}`;
|
||||||
|
|
||||||
|
const nameLabel = /^(Instance name|Name der Instanz)$/;
|
||||||
|
const page = await admin.newPage();
|
||||||
|
try {
|
||||||
|
await page.goto('/admin');
|
||||||
|
const generalCard = page
|
||||||
|
.locator('section.settings-section')
|
||||||
|
.filter({ has: page.getByLabel(nameLabel) });
|
||||||
|
await page.getByLabel(nameLabel).fill(newName);
|
||||||
|
await generalCard.getByRole('button', { name: /^(Save|Speichern)$/ }).click();
|
||||||
|
// Scoped to the card: the page has several forms with status regions.
|
||||||
|
await expect(generalCard.getByRole('status')).toHaveText(/^(Saved\.|Gespeichert\.)$/);
|
||||||
|
|
||||||
|
// The TopBar and the document title show the new name without a reload —
|
||||||
|
// the save invalidates the branding query both read from (issue #323).
|
||||||
|
await expect(page.locator('.topbar__brand')).toHaveText(newName);
|
||||||
|
await expect(page).toHaveTitle(new RegExp(`${newName}$`));
|
||||||
|
|
||||||
|
// The proof the form really persisted: the value survives a reload and
|
||||||
|
// the api returns it.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel(nameLabel)).toHaveValue(newName);
|
||||||
|
const stored = (
|
||||||
|
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
|
||||||
|
)['instance.name'];
|
||||||
|
expect(stored).toBe(newName);
|
||||||
|
} finally {
|
||||||
|
await admin.request.patch('/api/v1/admin/settings', {
|
||||||
|
data: { 'instance.name': before },
|
||||||
|
});
|
||||||
|
await admin.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -44,3 +44,43 @@ test('disabling a user in the admin UI blocks their login, enabling restores it'
|
|||||||
await admin.close();
|
await admin.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct account creation (issue #331): the dialog creates an active account
|
||||||
|
* — the new user logs in immediately, no verification hop. The account stays
|
||||||
|
* in the e2e database; the unique name keeps reruns independent.
|
||||||
|
*/
|
||||||
|
test('creating a user in the admin UI yields an account that can log in at once', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
const username = `created-${Date.now()}`;
|
||||||
|
const password = 'ein sicheres anfangspasswort';
|
||||||
|
|
||||||
|
const page = await admin.newPage();
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.locator('.user-manager__create').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByLabel(/username|benutzername/i).fill(username);
|
||||||
|
await dialog.getByLabel(/e-mail/i).fill(`${username}@example.org`);
|
||||||
|
await dialog.getByLabel(/display name|anzeigename/i).fill('Created via UI');
|
||||||
|
await dialog.getByLabel(/initial password|anfangspasswort/i).fill(password);
|
||||||
|
await dialog.getByRole('button', { name: /^create$|^anlegen$/i }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
|
// The list refetches; the fresh account is findable.
|
||||||
|
await page.locator('.user-manager__search').fill(username);
|
||||||
|
const row = page.locator(`.user-row[data-username="${username}"]`);
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator('.user-row__status')).toHaveText(/active|aktiv/i);
|
||||||
|
await admin.close();
|
||||||
|
|
||||||
|
// No verification mail hop: login works right away.
|
||||||
|
const ctx = await request.newContext({ baseURL: BASE_URL });
|
||||||
|
const res = await ctx.post('/api/v1/auth/login', {
|
||||||
|
data: { usernameOrEmail: username, password },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
await ctx.dispose();
|
||||||
|
});
|
||||||
|
|||||||
@ -60,6 +60,155 @@ test('typing persists across reload and undo/redo work', async ({ browser }) =>
|
|||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gap cursor reaches positions before and after a lone table (issue #335)', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E Gapcursor ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||||
|
const status = page.locator('.editor-connection');
|
||||||
|
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||||
|
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await content.click();
|
||||||
|
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||||
|
await expect(content.locator('table')).toBeVisible();
|
||||||
|
// Inserting into the empty page replaces the placeholder paragraph — the
|
||||||
|
// table really is the only block, which is the situation of issue #335.
|
||||||
|
await expect(content.locator(':scope > p')).toHaveCount(0);
|
||||||
|
// Right after the insert the collab sync can still swallow a click's
|
||||||
|
// selection update; interact only against a settled editor (established
|
||||||
|
// pattern, see a11y.spec.ts). The typed markers below verify each click
|
||||||
|
// really placed the cursor where the locator points.
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Keyboard only: ArrowUp from the first cell lands on the gap cursor
|
||||||
|
// before the table; typing there materializes a paragraph.
|
||||||
|
await content.locator('th').first().click();
|
||||||
|
await page.keyboard.type('in');
|
||||||
|
await expect(content.locator('th').first()).toHaveText('in');
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||||
|
await page.keyboard.type('above');
|
||||||
|
await expect(content.locator(':scope > :first-child')).toHaveText('above');
|
||||||
|
|
||||||
|
// Same for the position after the table.
|
||||||
|
await content.locator('td').last().click();
|
||||||
|
await page.keyboard.type('z');
|
||||||
|
await expect(content.locator('td').last()).toHaveText('z');
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||||
|
await page.keyboard.type('below');
|
||||||
|
await expect(content.locator(':scope > :last-child')).toHaveText('below');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cells can be merged and split from the toolbar (issue #337)', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E MergeSplit ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||||
|
const status = page.locator('.editor-connection');
|
||||||
|
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||||
|
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await content.click();
|
||||||
|
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||||
|
await expect(content.locator('table')).toBeVisible();
|
||||||
|
|
||||||
|
const mergeButton = page.getByRole('button', { name: /merge cells|zellen verbinden/i });
|
||||||
|
const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i });
|
||||||
|
await expect(mergeButton).toBeDisabled();
|
||||||
|
await expect(splitButton).toBeDisabled();
|
||||||
|
// Settle before clicking into cells — see the gap cursor test.
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Extending the selection across the cell border turns it into a cell
|
||||||
|
// selection (prosemirror-tables), which is what merge operates on.
|
||||||
|
// Shift+Click, not Shift+ArrowRight: a keypress fired in the same tick as
|
||||||
|
// the preceding click races the editor's post-click rendering and gets
|
||||||
|
// dropped — no human types that fast (works fine interactively).
|
||||||
|
await content.locator('td').first().click();
|
||||||
|
await content
|
||||||
|
.locator('td')
|
||||||
|
.nth(1)
|
||||||
|
.click({ modifiers: ['Shift'] });
|
||||||
|
await expect(content.locator('.selectedCell')).toHaveCount(2);
|
||||||
|
await expect(mergeButton).toBeEnabled();
|
||||||
|
await mergeButton.click();
|
||||||
|
await expect(content.locator('td[colspan="2"]')).toHaveCount(1);
|
||||||
|
|
||||||
|
// Splitting the merged cell restores the row's full cell count.
|
||||||
|
await content.locator('td[colspan="2"]').click();
|
||||||
|
await expect(splitButton).toBeEnabled();
|
||||||
|
await splitButton.click();
|
||||||
|
await expect(content.locator('td[colspan="2"]')).toHaveCount(0);
|
||||||
|
await expect(content.locator('tr').nth(1).locator('td')).toHaveCount(3);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tab navigates table cells, extends the table, and never traps focus (issue #338)', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E TableTab ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||||
|
const status = page.locator('.editor-connection');
|
||||||
|
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||||
|
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await content.click();
|
||||||
|
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||||
|
const rows = content.locator('tr');
|
||||||
|
await expect(rows).toHaveCount(3);
|
||||||
|
// Settle before clicking into cells — see the gap cursor test.
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Tab moves to the next cell, Shift+Tab back. Typed markers prove where
|
||||||
|
// the cursor really is (the typing assertions also settle the editor
|
||||||
|
// between keypresses — see the merge test on click/key races).
|
||||||
|
await content.locator('th').first().click();
|
||||||
|
await page.keyboard.type('one');
|
||||||
|
await expect(content.locator('th').first()).toHaveText('one');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.type('two');
|
||||||
|
await expect(content.locator('th').nth(1)).toHaveText('two');
|
||||||
|
await page.keyboard.press('Shift+Tab');
|
||||||
|
await page.keyboard.type('back');
|
||||||
|
await expect(content.locator('th').first()).toContainText('back');
|
||||||
|
|
||||||
|
// Tab in the last cell appends a row and moves into it (Word behavior).
|
||||||
|
const lastCell = rows.nth(2).locator('td').nth(2);
|
||||||
|
await lastCell.click();
|
||||||
|
await page.keyboard.type('z');
|
||||||
|
await expect(lastCell).toHaveText('z');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(rows).toHaveCount(4);
|
||||||
|
await page.keyboard.type('new');
|
||||||
|
await expect(rows.nth(3).locator('td').first()).toHaveText('new');
|
||||||
|
|
||||||
|
// No keyboard trap (WCAG 2.1.2): Escape works from EVERY cell (the gap
|
||||||
|
// cursor is only reachable per arrow key from edge cells) and places the
|
||||||
|
// cursor after the table; once outside, Tab leaves the editor entirely.
|
||||||
|
// The mechanism is announced via the editor's aria-describedby hint.
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(content).not.toBeFocused();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
|
test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
|
||||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);
|
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);
|
||||||
|
|||||||
@ -93,11 +93,19 @@ test('a pond admin imports an Obsidian vault through the settings dialog', async
|
|||||||
await expect(
|
await expect(
|
||||||
mountItem.locator('.sidebar__tree-children .sidebar__page', { hasText: 'Projekte' }),
|
mountItem.locator('.sidebar__tree-children .sidebar__page', { hasText: 'Projekte' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.locator('.sidebar__page:text-is("Startseite")')).toBeVisible();
|
// Scoped to the mount: the pond has its own "Startseite" since issue #302,
|
||||||
|
// so an unscoped title match now finds two entries.
|
||||||
|
const importedHome = mountItem
|
||||||
|
.locator('.sidebar__tree-children .sidebar__page')
|
||||||
|
.filter({ hasText: /^Startseite$/ })
|
||||||
|
.first();
|
||||||
|
await expect(importedHome).toBeVisible();
|
||||||
|
|
||||||
// A rewritten Obsidian link navigates to the right imported page, and the
|
// A rewritten Obsidian link navigates to the right imported page, and the
|
||||||
// display text still reads like the original note name.
|
// display text still reads like the original note name. Reached through the
|
||||||
await page.goto(`/p/${pond.slug}/startseite`);
|
// sidebar rather than by slug — `/startseite` belongs to the pond's own
|
||||||
|
// start page, so the imported note landed on a suffixed slug.
|
||||||
|
await importedHome.click();
|
||||||
await page.locator('.editor-content a.wikilink', { hasText: 'Projekt A' }).click();
|
await page.locator('.editor-content a.wikilink', { hasText: 'Projekt A' }).click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/projekt-a$`));
|
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/projekt-a$`));
|
||||||
|
|
||||||
|
|||||||
93
apps/web/e2e/invitations.spec.ts
Normal file
93
apps/web/e2e/invitations.spec.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser, latestMailFor, tokenFromMail } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peer invitations (issue #332), the full loop through the UI: a user
|
||||||
|
* invites an address, registration is closed, the invitee registers
|
||||||
|
* through the mailed link anyway, verifies, and the inviter sees the
|
||||||
|
* invitation accepted. Needs Mailpit like the auth pack.
|
||||||
|
*/
|
||||||
|
const MAILPIT_URL = process.env.E2E_MAILPIT_URL;
|
||||||
|
test.skip(!MAILPIT_URL, 'requires a Mailpit instance (E2E_MAILPIT_URL)');
|
||||||
|
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
|
test('invite -> closed registration -> signup through the link -> accepted', async ({
|
||||||
|
browser,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const invitee = `invited-${stamp}@dorfteich.test`;
|
||||||
|
|
||||||
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
const inviter = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
await admin.request.patch('/api/v1/admin/settings', {
|
||||||
|
data: { 'auth.registrationMode': 'closed' },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Invite through the settings UI.
|
||||||
|
const settingsPage = await inviter.newPage();
|
||||||
|
await settingsPage.goto('/settings');
|
||||||
|
const section = settingsPage.locator('.invitations');
|
||||||
|
await section.getByLabel(/e-mail/i).fill(invitee);
|
||||||
|
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
|
||||||
|
await expect(section.locator('.invitations__sent')).toHaveText(/sent|verschickt/i);
|
||||||
|
const row = section.locator(`.invitation-row[data-email="${invitee}"]`);
|
||||||
|
await expect(row.locator('.invitation-row__status')).toHaveText(/open|offen/i);
|
||||||
|
|
||||||
|
// Plain signup is closed…
|
||||||
|
await page.goto('/signup');
|
||||||
|
await expect(page.locator('.form-banner')).toHaveText(/closed|geschlossen/i);
|
||||||
|
|
||||||
|
// …but the mailed link opens the form, inviter banner and prefill included.
|
||||||
|
const mail = await latestMailFor(MAILPIT_URL!, invitee);
|
||||||
|
const invitationToken = /invitation=([A-Za-z0-9_-]+)/.exec(mail.text)?.[1];
|
||||||
|
expect(invitationToken).toBeTruthy();
|
||||||
|
await page.goto(`/signup?invitation=${invitationToken}`);
|
||||||
|
await expect(page.locator('.signup-invitation__banner')).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/e-mail/i)).toHaveValue(invitee);
|
||||||
|
const username = `invited-${stamp}`;
|
||||||
|
await page.getByLabel(/username|benutzername/i).fill(username);
|
||||||
|
await page.getByLabel(/display name|anzeigename/i).fill('Invited Guest');
|
||||||
|
await page.getByLabel(/^password|^passwort/i).fill('ein einladungs passwort 1');
|
||||||
|
await page.getByRole('button', { name: /register|registrieren/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /inbox|postfach/i })).toBeVisible();
|
||||||
|
|
||||||
|
// The usual verification still applies (the link proves nothing about
|
||||||
|
// the mailbox). Two mails went to this address — poll for the second.
|
||||||
|
let verifyToken = '';
|
||||||
|
await expect(async () => {
|
||||||
|
const verifyMail = await latestMailFor(MAILPIT_URL!, invitee);
|
||||||
|
expect(verifyMail.text).toContain('/verify-email');
|
||||||
|
verifyToken = tokenFromMail(verifyMail.text);
|
||||||
|
}).toPass();
|
||||||
|
await page.goto(`/verify-email?token=${verifyToken}`);
|
||||||
|
await expect(page.getByRole('heading', { name: /confirmed|bestätigt/i })).toBeVisible();
|
||||||
|
|
||||||
|
// The inviter sees the acceptance; the used link is dead.
|
||||||
|
await settingsPage.reload();
|
||||||
|
await expect(
|
||||||
|
settingsPage
|
||||||
|
.locator(`.invitation-row[data-email="${invitee}"]`)
|
||||||
|
.locator('.invitation-row__status'),
|
||||||
|
).toHaveText(/accepted|angenommen/i);
|
||||||
|
await page.goto(`/signup?invitation=${invitationToken}`);
|
||||||
|
await expect(page.locator('.signup-invitation__invalid')).toBeVisible();
|
||||||
|
|
||||||
|
// Revoke flow through the UI: a second invitation dies by revoke.
|
||||||
|
const second = `revoked-${stamp}@dorfteich.test`;
|
||||||
|
await section.getByLabel(/e-mail/i).fill(second);
|
||||||
|
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
|
||||||
|
const secondRow = section.locator(`.invitation-row[data-email="${second}"]`);
|
||||||
|
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/open|offen/i);
|
||||||
|
await secondRow.getByRole('button', { name: /revoke|widerrufen/i }).click();
|
||||||
|
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/revoked|widerrufen/i);
|
||||||
|
} finally {
|
||||||
|
await admin.request.patch('/api/v1/admin/settings', {
|
||||||
|
data: { 'auth.registrationMode': 'open' },
|
||||||
|
});
|
||||||
|
await admin.close();
|
||||||
|
await inviter.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -63,7 +63,11 @@ test('the admin form previews and publishes the privacy policy', async ({ browse
|
|||||||
await expect(editor.locator('.legal-editor__preview strong')).toHaveText('only what is needed');
|
await 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();
|
||||||
await expect(page.getByRole('status')).toBeVisible();
|
// Auf den Abschnitt gescopet: seit der Schriftverwaltung (#304) hat /admin
|
||||||
|
// weitere Live-Regionen (Upload-Fortschritt), und ein seitenweites
|
||||||
|
// getByRole('status') wäre mehrdeutig. Gemeint war immer die
|
||||||
|
// Erfolgsmeldung DIESES Formulars.
|
||||||
|
await expect(page.locator('.legal-settings').getByRole('status')).toBeVisible();
|
||||||
await admin.close();
|
await admin.close();
|
||||||
|
|
||||||
const anonymous = await browser.newContext({ baseURL: BASE_URL });
|
const anonymous = await browser.newContext({ baseURL: BASE_URL });
|
||||||
|
|||||||
@ -108,6 +108,102 @@ test('a plain-text paste is not mangled into rich structure', async ({ browser }
|
|||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a Markdown table pasted with code-editor styling HTML becomes a table (issue #339)', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TablePaste ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
await page.locator('.ProseMirror').click();
|
||||||
|
|
||||||
|
// VS Code (copyWithSyntaxHighlighting) ships the plain text a second time
|
||||||
|
// as styled div/span HTML — exactly the flavor that used to shadow the
|
||||||
|
// Markdown conversion.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.ProseMirror');
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
|
||||||
|
dataTransfer.setData(
|
||||||
|
'text/html',
|
||||||
|
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
|
||||||
|
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
|
||||||
|
'<div><span style="color:#d4d4d4;">| --- | --- |</span></div>' +
|
||||||
|
'<div><span style="color:#d4d4d4;">| 1 | 2 |</span></div></div>',
|
||||||
|
);
|
||||||
|
el!.dispatchEvent(
|
||||||
|
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await expect(content.locator('table')).toHaveCount(1);
|
||||||
|
await expect(content.locator('th').first()).toHaveText('A');
|
||||||
|
await expect(content.locator('td').first()).toHaveText('1');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Markdown table pasted into a code block stays verbatim text (issue #339)', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E MD CodePaste ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
await page.locator('.ProseMirror').click();
|
||||||
|
await page.getByRole('button', { name: /code block|codeblock/i }).click();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.ProseMirror');
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
|
||||||
|
el!.dispatchEvent(
|
||||||
|
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await expect(content.locator('table')).toHaveCount(0);
|
||||||
|
await expect(content.locator('pre')).toContainText('| A | B |');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typing a Markdown table header plus separator creates a table (issue #339)', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TableType ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await content.click();
|
||||||
|
|
||||||
|
await page.keyboard.type('| Name | Rolle |');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('| --- | --- |');
|
||||||
|
await expect(content).toContainText('| --- | --- |');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(content.locator('table')).toHaveCount(1);
|
||||||
|
await expect(content.locator('th').first()).toHaveText('Name');
|
||||||
|
await expect(content).not.toContainText('| --- | --- |');
|
||||||
|
|
||||||
|
// The cursor lands in the table; Tab from the last header cell appends the
|
||||||
|
// first body row (#338), so typing continues seamlessly.
|
||||||
|
await page.keyboard.type('x');
|
||||||
|
await expect(content.locator('th').first()).toContainText('x');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
|
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
|
||||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);
|
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);
|
||||||
|
|||||||
@ -173,10 +173,14 @@ test('page read & edit — the 404-vs-403 policy holds per subject', async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sidebar list is filtered to each subject’s visible pages', async () => {
|
test('sidebar list is filtered to each subject’s visible pages', async () => {
|
||||||
expect(await listCount(f.admin.request, f.pondId)).toBe(3);
|
// Three fixture pages plus the pond's own start page (issue #302), which
|
||||||
expect(await listCount(f.owner.request, f.pondId)).toBe(3);
|
// every pond created through the api now carries. It is an ordinary page
|
||||||
expect(await listCount(f.reader.request, f.pondId)).toBe(3);
|
// with no grant of its own, so it follows the pond-wide permissions: the
|
||||||
expect(await listCount(f.editor.request, f.pondId)).toBe(2); // secret hidden
|
// outsider, who reaches only the explicitly public page, still sees one.
|
||||||
|
expect(await listCount(f.admin.request, f.pondId)).toBe(4);
|
||||||
|
expect(await listCount(f.owner.request, f.pondId)).toBe(4);
|
||||||
|
expect(await listCount(f.reader.request, f.pondId)).toBe(4);
|
||||||
|
expect(await listCount(f.editor.request, f.pondId)).toBe(3); // secret hidden
|
||||||
expect(await listCount(f.outsider.request, f.pondId)).toBe(1); // only the public page
|
expect(await listCount(f.outsider.request, f.pondId)).toBe(1); // only the public page
|
||||||
// Anonymous cannot hit the authenticated list endpoint at all.
|
// Anonymous cannot hit the authenticated list endpoint at all.
|
||||||
expect(await listCount(f.anon, f.pondId)).toBe(401);
|
expect(await listCount(f.anon, f.pondId)).toBe(401);
|
||||||
|
|||||||
@ -28,8 +28,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
|||||||
await expect(nav).toBeVisible();
|
await expect(nav).toBeVisible();
|
||||||
const links = nav.locator('.settings-nav__link');
|
const links = nav.locator('.settings-nav__link');
|
||||||
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
||||||
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild).
|
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild),
|
||||||
await expect(links).toHaveCount(9);
|
// 10 seit #332 (Einladungen).
|
||||||
|
await expect(links).toHaveCount(10);
|
||||||
|
|
||||||
// Jump to the last section: it scrolls into view and becomes active.
|
// Jump to the last section: it scrolls into view and becomes active.
|
||||||
const last = links.last();
|
const last = links.last();
|
||||||
|
|||||||
@ -34,7 +34,7 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
|
|||||||
// select value — compliant choice clears it, violating choice brings it
|
// select value — compliant choice clears it, violating choice brings it
|
||||||
// back, no save in between.
|
// back, no save in between.
|
||||||
const regField = page.locator('label.field', {
|
const regField = page.locator('label.field', {
|
||||||
has: page.locator('select[name="auth.registrationMode"]'),
|
has: page.locator('select[name="registrationMode"]'),
|
||||||
});
|
});
|
||||||
const regSelect = regField.locator('select');
|
const regSelect = regField.locator('select');
|
||||||
await regSelect.selectOption('open');
|
await regSelect.selectOption('open');
|
||||||
@ -72,7 +72,7 @@ test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({
|
|||||||
|
|
||||||
// Value-listed control: the compliant registration mode keeps only its
|
// Value-listed control: the compliant registration mode keeps only its
|
||||||
// compliant choice (seed leaves it open = violating? then all options).
|
// compliant choice (seed leaves it open = violating? then all options).
|
||||||
const regSelect = page.locator('select[name="auth.registrationMode"]');
|
const regSelect = page.locator('select[name="registrationMode"]');
|
||||||
const regField = page.locator('label.field', { has: regSelect });
|
const regField = page.locator('label.field', { has: regSelect });
|
||||||
const optionCount = await regSelect.locator('option').count();
|
const optionCount = await regSelect.locator('option').count();
|
||||||
const marked = await regField.locator('.vs-nfd-mark').count();
|
const marked = await regField.locator('.vs-nfd-mark').count();
|
||||||
|
|||||||
@ -10,6 +10,13 @@
|
|||||||
<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). -->
|
||||||
|
|||||||
@ -1,3 +1,23 @@
|
|||||||
|
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
|
||||||
|
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
|
||||||
|
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
|
||||||
|
# SPA route — which nginx answers with index.html — would see `en` forever,
|
||||||
|
# even for German content.
|
||||||
|
#
|
||||||
|
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
|
||||||
|
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
|
||||||
|
# German; `en-US,de` does not, because that visitor asked for English first.
|
||||||
|
#
|
||||||
|
# Known limit, documented rather than worked around: nginx does not know
|
||||||
|
# `instance.defaultLocale` from the database, so a visitor with no (or an
|
||||||
|
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
|
||||||
|
# content that is not the authoritative rendering anyway — the api's server
|
||||||
|
# shell (`/api/v1/public/...`) renders those with the instance locale.
|
||||||
|
map $http_accept_language $spa_lang {
|
||||||
|
default en;
|
||||||
|
~*^de de;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA serving: static assets with long-lived caching, everything else
|
# 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 {
|
||||||
@ -34,6 +54,17 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { ApiError, apiGet } from '../lib/api';
|
|||||||
import { usePondLabels } from '../labels/use-pond-labels';
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
import { usePondMembers } from '../members/use-pond-members';
|
import { usePondMembers } from '../members/use-pond-members';
|
||||||
import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
|
import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
|
||||||
|
import { IconButton } from '../components/IconButton';
|
||||||
|
|
||||||
type ScopedType = 'label' | 'page';
|
type ScopedType = 'label' | 'page';
|
||||||
|
|
||||||
@ -238,15 +239,14 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<IconButton
|
||||||
className="icon-button rule-add__submit"
|
className="rule-add__submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
aria-label={t('add.submit')}
|
label={t('add.submit')}
|
||||||
title={t('add.submit')}
|
|
||||||
>
|
>
|
||||||
<Plus aria-hidden />
|
<Plus aria-hidden />
|
||||||
</button>
|
</IconButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{rules.length === 0 ? (
|
{rules.length === 0 ? (
|
||||||
@ -260,15 +260,13 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
|
|||||||
{group.rules.map((rule) => (
|
{group.rules.map((rule) => (
|
||||||
<li key={rule.id} className="rule-item">
|
<li key={rule.id} className="rule-item">
|
||||||
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
|
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
className="rule-remove"
|
||||||
className="icon-button rule-remove"
|
label={t('remove')}
|
||||||
aria-label={t('remove')}
|
|
||||||
title={t('remove')}
|
|
||||||
onClick={() => void mutations.remove(rule.id)}
|
onClick={() => void mutations.remove(rule.id)}
|
||||||
>
|
>
|
||||||
<Trash2 aria-hidden />
|
<Trash2 aria-hidden />
|
||||||
</button>
|
</IconButton>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -172,52 +172,54 @@ function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormError error={error} />
|
<FormError error={error} />
|
||||||
<table className="table api-tokens__table">
|
<div className="table-scroll" tabIndex={0} role="region" aria-label={t('section.title')}>
|
||||||
<thead>
|
<table className="table api-tokens__table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>{t('fields.name')}</th>
|
<tr>
|
||||||
<th>{t('fields.scope')}</th>
|
<th>{t('fields.name')}</th>
|
||||||
<th>{t('fields.ponds')}</th>
|
<th>{t('fields.scope')}</th>
|
||||||
<th>{t('list.created')}</th>
|
<th>{t('fields.ponds')}</th>
|
||||||
<th>{t('list.lastUsed')}</th>
|
<th>{t('list.created')}</th>
|
||||||
<th>{t('list.expires')}</th>
|
<th>{t('list.lastUsed')}</th>
|
||||||
<th>{t('list.status')}</th>
|
<th>{t('list.expires')}</th>
|
||||||
<th>
|
<th>{t('list.status')}</th>
|
||||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
<th>
|
||||||
</th>
|
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||||
</tr>
|
</th>
|
||||||
</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>
|
||||||
</table>
|
{tokens.map((token) => (
|
||||||
|
<tr key={token.id}>
|
||||||
|
<td>{token.name}</td>
|
||||||
|
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
|
||||||
|
<td>
|
||||||
|
{token.ponds.length === 0
|
||||||
|
? t('list.allPonds')
|
||||||
|
: token.ponds.map((pond) => pond.name).join(', ')}
|
||||||
|
</td>
|
||||||
|
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
|
||||||
|
<td>
|
||||||
|
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
|
||||||
|
</td>
|
||||||
|
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
|
||||||
|
<td>{t(`list.${statusOf(token)}`)}</td>
|
||||||
|
<td>
|
||||||
|
{!token.revokedAt && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button api-tokens__revoke"
|
||||||
|
onClick={() => void revoke(token.id)}
|
||||||
|
>
|
||||||
|
{t('list.revoke')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,32 +85,38 @@ export function FeedTokensSection(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
{tokens.data && tokens.data.length === 0 && <p>{t('feed.empty')}</p>}
|
{tokens.data && tokens.data.length === 0 && <p>{t('feed.empty')}</p>}
|
||||||
{tokens.data && tokens.data.length > 0 && (
|
{tokens.data && tokens.data.length > 0 && (
|
||||||
<table className="table">
|
<div className="table-scroll" tabIndex={0} role="region" aria-label={t('feed.title')}>
|
||||||
<thead>
|
<table className="table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>{t('fields.name')}</th>
|
<tr>
|
||||||
<th>{t('list.created')}</th>
|
<th>{t('fields.name')}</th>
|
||||||
<th>{t('list.lastUsed')}</th>
|
<th>{t('list.created')}</th>
|
||||||
<th>
|
<th>{t('list.lastUsed')}</th>
|
||||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
<th>
|
||||||
</th>
|
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||||
</tr>
|
</th>
|
||||||
</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>
|
||||||
</table>
|
{tokens.data.map((token) => (
|
||||||
|
<tr key={token.id}>
|
||||||
|
<td>{token.name}</td>
|
||||||
|
<td>{formatTime(token.createdAt)}</td>
|
||||||
|
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike"
|
||||||
|
onClick={() => void remove(token.id)}
|
||||||
|
>
|
||||||
|
{t('feed.delete')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
58
apps/web/src/branding/BrandLogo.tsx
Normal file
58
apps/web/src/branding/BrandLogo.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useCurrentPondRoute } from '../layout/use-pond-route';
|
||||||
|
import { logoUrl, usePondFavicon, useResolvedBranding } from './use-branding';
|
||||||
|
import { usePondId } from './use-pond-id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity at the top of the sidebar (issues #306/#307): the pond's own
|
||||||
|
* logo when it has one, else the instance's, else the instance name as text.
|
||||||
|
*
|
||||||
|
* Its accessible name follows the LEVEL the logo came from — a pond logo is
|
||||||
|
* named by the pond, an instance logo by the instance. For a screen reader
|
||||||
|
* this is the link home, and a link's name has to say where it goes; keeping
|
||||||
|
* the instance name on a pond logo would announce the wrong destination.
|
||||||
|
*
|
||||||
|
* Both variants are rendered and one is hidden by CSS (`:root[data-theme]`),
|
||||||
|
* not by JavaScript: `theme-init.js` resolves the theme before first paint, so
|
||||||
|
* the correct logo is the one painted. A logo set belongs to ONE level and is
|
||||||
|
* never mixed across levels — see `resolveBranding`.
|
||||||
|
*/
|
||||||
|
export function BrandLogo(): React.JSX.Element | null {
|
||||||
|
const { pondSlug } = useCurrentPondRoute();
|
||||||
|
const { pondId, pondName } = usePondId(pondSlug);
|
||||||
|
const { resolved, instanceName, pondId: logoPond } = useResolvedBranding(pondId);
|
||||||
|
usePondFavicon(pondId, resolved.faviconLevel === 'pond');
|
||||||
|
|
||||||
|
const name = resolved.logoLevel === 'pond' ? (pondName ?? instanceName) : instanceName;
|
||||||
|
if (!instanceName && resolved.logoLevel === 'none') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to="/" className="brand-logo" aria-label={name}>
|
||||||
|
{resolved.logo || resolved.logoDark ? (
|
||||||
|
<>
|
||||||
|
{resolved.logo && (
|
||||||
|
<img
|
||||||
|
className={`brand-logo__img brand-logo__img--light${resolved.logoDark ? '' : ' brand-logo__img--both'}`}
|
||||||
|
src={logoUrl('light', resolved.logo.hash, logoPond)}
|
||||||
|
width={resolved.logo.width}
|
||||||
|
height={resolved.logo.height}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{resolved.logoDark && (
|
||||||
|
<img
|
||||||
|
className={`brand-logo__img brand-logo__img--dark${resolved.logo ? '' : ' brand-logo__img--both'}`}
|
||||||
|
src={logoUrl('dark', resolved.logoDark.hash, logoPond)}
|
||||||
|
width={resolved.logoDark.width}
|
||||||
|
height={resolved.logoDark.height}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="brand-logo__name">{name}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
apps/web/src/branding/CropField.tsx
Normal file
173
apps/web/src/branding/CropField.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { BRANDING_SOURCE_TYPES } from '@dorfteich/shared';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Field } from '../components/forms';
|
||||||
|
import { CropRect, clampCrop, drawCrop, initialCrop, loadImage, outputSize } from './crop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick an image, crop it, see the result (issue #306).
|
||||||
|
*
|
||||||
|
* The crop is driven by NUMBER INPUTS, not by dragging. A drag-only cropper
|
||||||
|
* excludes keyboard and switch users outright, and a number input is
|
||||||
|
* arrow-key operable, screen-reader readable and announces its value without
|
||||||
|
* any custom aria plumbing — the accessible option is also the simpler one.
|
||||||
|
* The preview canvas is a picture of the result, never the control.
|
||||||
|
*
|
||||||
|
* The resulting pixel dimensions are stated in TEXT next to it, so the outcome
|
||||||
|
* does not depend on seeing the frame.
|
||||||
|
*/
|
||||||
|
export function CropField({
|
||||||
|
idPrefix,
|
||||||
|
square,
|
||||||
|
maxEdge,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
idPrefix: string;
|
||||||
|
/** Favicons are square by construction; a logo keeps its own proportions. */
|
||||||
|
square: boolean;
|
||||||
|
maxEdge: number;
|
||||||
|
/** Called with the rendering canvas whenever the crop changes, so the
|
||||||
|
* parent can encode PNGs from it on submit. Null = nothing selected. */
|
||||||
|
onChange: (canvas: HTMLCanvasElement | null) => void;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('branding');
|
||||||
|
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||||
|
const [crop, setCrop] = useState<CropRect | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const out = image && crop ? outputSize(crop, maxEdge) : null;
|
||||||
|
|
||||||
|
// Held in a ref so the redraw depends on the crop alone: callers pass an
|
||||||
|
// inline arrow, whose identity changes every render and would otherwise
|
||||||
|
// repaint the canvas on every keystroke in the surrounding form.
|
||||||
|
const notifyRef = useRef(onChange);
|
||||||
|
notifyRef.current = onChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !image || !crop) {
|
||||||
|
notifyRef.current(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Derived inside the effect: `outputSize` returns a fresh object every
|
||||||
|
// render, so as a dependency it would never compare equal.
|
||||||
|
drawCrop(image, crop, outputSize(crop, maxEdge), canvas);
|
||||||
|
notifyRef.current(canvas);
|
||||||
|
}, [image, crop, maxEdge]);
|
||||||
|
|
||||||
|
async function choose(file: File | undefined): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
if (!file) {
|
||||||
|
setImage(null);
|
||||||
|
setCrop(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(BRANDING_SOURCE_TYPES as readonly string[]).includes(file.type)) {
|
||||||
|
setImage(null);
|
||||||
|
setCrop(null);
|
||||||
|
// SVG is the one an operator is most likely to try, and it is refused
|
||||||
|
// on purpose (it can carry script) — say which types work instead.
|
||||||
|
setError(file.type === 'image/svg+xml' ? 'branding_svg_rejected' : 'branding_not_an_image');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const loaded = await loadImage(file);
|
||||||
|
setImage(loaded);
|
||||||
|
setCrop(initialCrop(loaded, square));
|
||||||
|
} catch {
|
||||||
|
setError('branding_not_an_image');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(patch: Partial<CropRect>): void {
|
||||||
|
if (!image || !crop) return;
|
||||||
|
const next = { ...crop, ...patch };
|
||||||
|
// A square crop has one size, so width and height move together.
|
||||||
|
if (square && patch.width !== undefined) next.height = patch.width;
|
||||||
|
setCrop(clampCrop(next, image));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crop-field">
|
||||||
|
<Field label={t('crop.file')} hint={t('crop.fileHint')} error={error ?? undefined}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={BRANDING_SOURCE_TYPES.join(',')}
|
||||||
|
onChange={(event) => void choose(event.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{image && crop && out && (
|
||||||
|
<>
|
||||||
|
<div className="crop-field__controls">
|
||||||
|
<Field label={t('crop.x')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`${idPrefix}-x`}
|
||||||
|
min={0}
|
||||||
|
max={image.width - crop.width}
|
||||||
|
value={crop.x}
|
||||||
|
onChange={(event) => update({ x: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('crop.y')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`${idPrefix}-y`}
|
||||||
|
min={0}
|
||||||
|
max={image.height - crop.height}
|
||||||
|
value={crop.y}
|
||||||
|
onChange={(event) => update({ y: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={square ? t('crop.size') : t('crop.width')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`${idPrefix}-w`}
|
||||||
|
min={1}
|
||||||
|
max={square ? Math.min(image.width, image.height) : image.width}
|
||||||
|
value={crop.width}
|
||||||
|
onChange={(event) => update({ width: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{!square && (
|
||||||
|
<Field label={t('crop.height')}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`${idPrefix}-h`}
|
||||||
|
min={1}
|
||||||
|
max={image.height}
|
||||||
|
value={crop.height}
|
||||||
|
onChange={(event) => update({ height: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike"
|
||||||
|
onClick={() => setCrop(initialCrop(image, square))}
|
||||||
|
>
|
||||||
|
{t('crop.reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="crop-field__preview">
|
||||||
|
<canvas ref={canvasRef} className="crop-field__canvas" />
|
||||||
|
{/* The outcome in words: the frame alone would leave a
|
||||||
|
keyboard-only or screen-reader user guessing. */}
|
||||||
|
<p className="crop-field__result" role="status">
|
||||||
|
{t('crop.result', {
|
||||||
|
width: out.width,
|
||||||
|
height: out.height,
|
||||||
|
sourceWidth: image.width,
|
||||||
|
sourceHeight: image.height,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/web/src/branding/crop.test.ts
Normal file
73
apps/web/src/branding/crop.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { clampCrop, initialCrop, outputSize } from './crop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The crop arithmetic (issue #306). Pure functions on purpose: the canvas
|
||||||
|
* work is a thin shell around these, and getting the bounds wrong is what
|
||||||
|
* would let a number input produce a rectangle outside the image.
|
||||||
|
*/
|
||||||
|
describe('initialCrop', () => {
|
||||||
|
it('takes the whole image when the aspect is free', () => {
|
||||||
|
expect(initialCrop({ width: 900, height: 300 }, false)).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 900,
|
||||||
|
height: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centres the largest square that fits', () => {
|
||||||
|
expect(initialCrop({ width: 900, height: 300 }, true)).toEqual({
|
||||||
|
x: 300,
|
||||||
|
y: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('outputSize', () => {
|
||||||
|
it('scales the long edge down to the bound and keeps the ratio', () => {
|
||||||
|
expect(outputSize({ x: 0, y: 0, width: 900, height: 300 }, 512)).toEqual({
|
||||||
|
width: 512,
|
||||||
|
height: 171,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never scales UP — enlarging would only invent pixels', () => {
|
||||||
|
expect(outputSize({ x: 0, y: 0, width: 120, height: 40 }, 512)).toEqual({
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampCrop', () => {
|
||||||
|
const source = { width: 200, height: 100 };
|
||||||
|
|
||||||
|
it('keeps the rectangle inside the image', () => {
|
||||||
|
expect(clampCrop({ x: 190, y: 90, width: 50, height: 50 }, source)).toEqual({
|
||||||
|
x: 150,
|
||||||
|
y: 50,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets a size fall below one pixel or exceed the source', () => {
|
||||||
|
expect(clampCrop({ x: 0, y: 0, width: 0, height: 999 }, source)).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a negative offset by pulling it back to the edge', () => {
|
||||||
|
expect(clampCrop({ x: -30, y: -5, width: 20, height: 20 }, source)).toMatchObject({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
106
apps/web/src/branding/crop.ts
Normal file
106
apps/web/src/branding/crop.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Client-side image preparation for branding uploads (issue #306).
|
||||||
|
*
|
||||||
|
* Cropping, scaling and the conversion to PNG happen here on a canvas; the
|
||||||
|
* api receives finished bytes and never decodes an image. That keeps a
|
||||||
|
* decoder away from attacker-supplied bytes and keeps `sharp` (and its
|
||||||
|
* platform binaries) out of the `--network none` offline build.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CropRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a file into an `HTMLImageElement`, rejecting what the browser cannot
|
||||||
|
* decode — the first line of defence, before anything reaches the api. */
|
||||||
|
export function loadImage(file: File): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('image_undecodable'));
|
||||||
|
};
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The crop the editor starts with: the largest centred rectangle of the
|
||||||
|
* wanted aspect that fits the source. */
|
||||||
|
export function initialCrop(source: { width: number; height: number }, square: boolean): CropRect {
|
||||||
|
if (!square) return { x: 0, y: 0, width: source.width, height: source.height };
|
||||||
|
const size = Math.min(source.width, source.height);
|
||||||
|
return {
|
||||||
|
x: Math.round((source.width - size) / 2),
|
||||||
|
y: Math.round((source.height - size) / 2),
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output size for a crop: scaled down so the longest edge fits `maxEdge`,
|
||||||
|
* never scaled UP — enlarging would only invent pixels. */
|
||||||
|
export function outputSize(crop: CropRect, maxEdge: number): { width: number; height: number } {
|
||||||
|
const longest = Math.max(crop.width, crop.height);
|
||||||
|
const factor = longest > maxEdge ? maxEdge / longest : 1;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(crop.width * factor)),
|
||||||
|
height: Math.max(1, Math.round(crop.height * factor)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps a crop inside the source and above 1px, so number inputs cannot
|
||||||
|
* produce a rectangle the canvas would refuse. */
|
||||||
|
export function clampCrop(crop: CropRect, source: { width: number; height: number }): CropRect {
|
||||||
|
const width = Math.min(Math.max(1, Math.round(crop.width)), source.width);
|
||||||
|
const height = Math.min(Math.max(1, Math.round(crop.height)), source.height);
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x: Math.min(Math.max(0, Math.round(crop.x)), source.width - width),
|
||||||
|
y: Math.min(Math.max(0, Math.round(crop.y)), source.height - height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders the crop into a canvas at the given output size. */
|
||||||
|
export function drawCrop(
|
||||||
|
image: CanvasImageSource,
|
||||||
|
crop: CropRect,
|
||||||
|
out: { width: number; height: number },
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
): void {
|
||||||
|
canvas.width = out.width;
|
||||||
|
canvas.height = out.height;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return;
|
||||||
|
context.clearRect(0, 0, out.width, out.height);
|
||||||
|
context.imageSmoothingQuality = 'high';
|
||||||
|
context.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, out.width, out.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canvas contents as PNG bytes.
|
||||||
|
*
|
||||||
|
* PNG regardless of the source format — which is why the form states that a
|
||||||
|
* JPEG source cannot gain transparency: the alpha channel exists in the
|
||||||
|
* output, but every pixel of a JPEG is opaque, so the background stays.
|
||||||
|
* Conversion cannot invent what was never in the file.
|
||||||
|
*/
|
||||||
|
export function canvasToPngFile(canvas: HTMLCanvasElement, name: string): Promise<File> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('canvas_encode_failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(new File([blob], name, { type: 'image/png' }));
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
91
apps/web/src/branding/use-branding.ts
Normal file
91
apps/web/src/branding/use-branding.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { BrandingView, PondBranding, ResolvedBranding, resolveBranding } from '@dorfteich/shared';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
export const BRANDING_KEY = ['branding'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance branding in force (issue #306).
|
||||||
|
*
|
||||||
|
* Public, so the login screen carries it too — an operator's logo IS visible
|
||||||
|
* to anonymous visitors, which the admin screen says out loud.
|
||||||
|
*/
|
||||||
|
export function useBranding(): BrandingView | undefined {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: BRANDING_KEY,
|
||||||
|
queryFn: () => apiGet<BrandingView>('/branding'),
|
||||||
|
// Branding changes are rare and the manager invalidates the key itself.
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
}).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL of a logo variant, with the content hash so a replaced logo is never
|
||||||
|
* served from cache. `pondId` scopes it to a pond's own asset (issue #307);
|
||||||
|
* the route never falls back on its own — the CALLER decided which level
|
||||||
|
* applies, and a silent fallback here would mix variants across levels. */
|
||||||
|
export function logoUrl(variant: 'light' | 'dark', hash: string, pondId?: string): string {
|
||||||
|
const pond = pondId ? `&pond=${pondId}` : '';
|
||||||
|
return `/api/v1/branding/logo?variant=${variant}&v=${hash}${pond}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branding in force here: the pond's own, else the instance's, else the
|
||||||
|
* default (issue #307). One helper, in shared, so api and web cannot drift.
|
||||||
|
*/
|
||||||
|
export function useResolvedBranding(pondId?: string): {
|
||||||
|
resolved: ResolvedBranding;
|
||||||
|
instanceName: string;
|
||||||
|
/** Which level the logo came from — the asset URLs need the pond scope
|
||||||
|
* exactly when the pond supplied it. */
|
||||||
|
pondId?: string;
|
||||||
|
} {
|
||||||
|
const instance = useBranding();
|
||||||
|
const pond = useQuery({
|
||||||
|
queryKey: ['pond', pondId, 'branding'],
|
||||||
|
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId!}/branding`),
|
||||||
|
enabled: Boolean(pondId),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const base = instance ?? { logo: null, logoDark: null, favicon: null, instanceName: '' };
|
||||||
|
const resolved = resolveBranding(base, pond.data ?? null);
|
||||||
|
return {
|
||||||
|
resolved,
|
||||||
|
instanceName: base.instanceName,
|
||||||
|
pondId: resolved.logoLevel === 'pond' ? pondId : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points the document's icon at the pond's favicon while a pond is open, and
|
||||||
|
* back at the instance's on leaving (issue #307).
|
||||||
|
*
|
||||||
|
* Accepted and worth stating: the swap necessarily happens AFTER first paint,
|
||||||
|
* so opening a pond link directly shows the instance favicon briefly before it
|
||||||
|
* changes. Avoiding that would mean server-rendering index.html, which is
|
||||||
|
* #179's territory and deliberately out of scope here. In a pinned tab — where
|
||||||
|
* telling ponds apart matters most — the tab is already open, so the swap is
|
||||||
|
* the normal case rather than the exception.
|
||||||
|
*
|
||||||
|
* Driven by the RESOLVED pond, never by the raw route parameter: an unreadable
|
||||||
|
* or non-existent pond slug must not leave a stale icon in the tab.
|
||||||
|
*/
|
||||||
|
export function usePondFavicon(pondId: string | undefined, hasPondFavicon: boolean): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||||
|
if (!link) return undefined;
|
||||||
|
const instanceHref = '/api/v1/branding/favicon';
|
||||||
|
link.href = pondId && hasPondFavicon ? `${instanceHref}?pond=${pondId}` : instanceHref;
|
||||||
|
return () => {
|
||||||
|
link.href = instanceHref;
|
||||||
|
};
|
||||||
|
}, [pondId, hasPondFavicon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvalidateBranding(): () => Promise<void> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
|
||||||
|
};
|
||||||
|
}
|
||||||
21
apps/web/src/branding/use-pond-id.ts
Normal file
21
apps/web/src/branding/use-pond-id.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { PondView } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current pond's id and name from its slug (issue #307).
|
||||||
|
*
|
||||||
|
* Shares the sidebar's query key, so the pond is fetched once. Returns
|
||||||
|
* nothing for an unreadable or unknown slug — which is exactly why the
|
||||||
|
* favicon swap is driven by this and not by the raw route parameter: a bad
|
||||||
|
* slug must not leave a stale icon in the tab.
|
||||||
|
*/
|
||||||
|
export function usePondId(pondSlug: string | null): { pondId?: string; pondName?: string } {
|
||||||
|
const pond = useQuery({
|
||||||
|
queryKey: ['pond', pondSlug],
|
||||||
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug!}`),
|
||||||
|
enabled: Boolean(pondSlug),
|
||||||
|
});
|
||||||
|
return { pondId: pond.data?.id, pondName: pond.data?.name };
|
||||||
|
}
|
||||||
@ -1,4 +1,14 @@
|
|||||||
import type { ButtonHTMLAttributes } from 'react';
|
import type { ButtonHTMLAttributes } from 'react';
|
||||||
|
import { Link, type LinkProps } from 'react-router-dom';
|
||||||
|
|
||||||
|
/** The shared class list behind both controls (issue #300): one place decides
|
||||||
|
* what an icon-only control looks like, so the box, the icon size, the hover
|
||||||
|
* and the focus ring cannot drift apart between a button and a link. */
|
||||||
|
function iconClasses(active: boolean | undefined, className: string | undefined): string {
|
||||||
|
return ['icon-button', active ? 'icon-button--active' : '', className ?? '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
/** Localized accessible name; also shown as the hover tooltip. */
|
/** Localized accessible name; also shown as the hover tooltip. */
|
||||||
@ -15,12 +25,42 @@ export function IconButton({
|
|||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: IconButtonProps): React.JSX.Element {
|
}: IconButtonProps): React.JSX.Element {
|
||||||
const classes = ['icon-button', active ? 'icon-button--active' : '', className ?? '']
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ');
|
|
||||||
return (
|
return (
|
||||||
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconClasses(active, className)}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IconLinkProps extends LinkProps {
|
||||||
|
/** Localized accessible name; also shown as the hover tooltip. */
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The navigating twin of {@link IconButton} (issue #300). An icon-only control
|
||||||
|
* that goes somewhere is a link, not a button — but it has to look and focus
|
||||||
|
* exactly like one, which is why both share {@link iconClasses}. Without it the
|
||||||
|
* three navigating icons (pond settings, graph, trash) stayed the one group
|
||||||
|
* that had to glue the class on by hand.
|
||||||
|
*/
|
||||||
|
export function IconLink({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: IconLinkProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Link className={iconClasses(active, className)} aria-label={label} title={label} {...rest}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user