Compare commits
24 Commits
issue-303-
...
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 |
@ -107,6 +107,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A fresh named volume inherits the ownership of the image directory it
|
||||
# is mounted over. Every /data/… path the api image defaults to must
|
||||
# therefore be pre-created AND chowned to `node`, or the non-root user
|
||||
# cannot write to it — found on a real deploy in #303, where the env
|
||||
# entry was added but the mkdir/chown line was not.
|
||||
- name: api image pre-creates its data directories node-owned
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dirs=$(grep -oE '[A-Z_]+_DIR=/data/[a-z]+' apps/api/Dockerfile | cut -d= -f2 | sort -u)
|
||||
bad=0
|
||||
for d in $dirs; do
|
||||
grep -q "mkdir -p .*$d" apps/api/Dockerfile || {
|
||||
echo "$d is not pre-created in apps/api/Dockerfile"; bad=1; }
|
||||
grep -q "chown -R node:node .*$d" apps/api/Dockerfile || {
|
||||
echo "$d is not chowned to node in apps/api/Dockerfile"; bad=1; }
|
||||
done
|
||||
exit "$bad"
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
@ -327,6 +345,16 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
|
||||
|
||||
- name: Reset login rate limit before admin-settings pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
- name: Run admin-settings pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
|
||||
|
||||
- name: Reset login rate limit before admin-quotas pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
@ -347,6 +375,18 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
|
||||
|
||||
- name: Reset login rate limit before invitations pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
# Invitations (issue #332) need the mail catcher like the auth pack:
|
||||
# the invite link and the follow-up verification both travel by mail.
|
||||
- name: Run invitations pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/invitations.spec.ts
|
||||
|
||||
- name: Reset login rate limit before permission-matrix pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
|
||||
@ -29,18 +29,19 @@ ARG APP_VERSION=0.0.0-dev
|
||||
# Default the data dirs to the writable, node-owned locations created below, so
|
||||
# the image works out of the box even where compose does not set them; compose
|
||||
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
|
||||
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
|
||||
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
|
||||
WORKDIR /app
|
||||
COPY --from=build --chown=node:node /out /app
|
||||
# Generate the Prisma client for this image's platform.
|
||||
RUN node node_modules/prisma/build/index.js generate
|
||||
# A fresh named volume mounted at /data/uploads or /data/plugins is created
|
||||
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
|
||||
# or /data/branding is created
|
||||
# root-owned; pre-creating them here (Docker copies an image directory's
|
||||
# ownership into a new volume on first mount) lets the non-root `node` user
|
||||
# write to them. /data/backups is mounted read-only here, but pre-creating it
|
||||
# node-owned keeps the shared `backups` volume writable for the backup
|
||||
# sidecar even when the api container is the one that initializes it.
|
||||
RUN mkdir -p /data/uploads /data/plugins /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/secrets /data/backups
|
||||
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
|
||||
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,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;
|
||||
@ -68,10 +68,34 @@ model User {
|
||||
notifications Notification[]
|
||||
favorites PageFavorite[]
|
||||
customFonts CustomFont[]
|
||||
invitations Invitation[] @relation("InvitationsSent")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// Peer invitations (issue #332): a user invites an e-mail address; the
|
||||
/// token allows exactly one registration even while registration is
|
||||
/// closed. Only the SHA-256 hash of the token is stored (auth-tokens
|
||||
/// pattern); revoked/accepted rows are kept so the settings UI can show
|
||||
/// history. "Open" (pending, unexpired) rows count against the per-user
|
||||
/// quota `invitations.maxOpenPerUser`.
|
||||
model Invitation {
|
||||
id String @id @default(uuid())
|
||||
inviterId String @map("inviter_id")
|
||||
email String
|
||||
tokenHash String @unique @map("token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
acceptedAt DateTime? @map("accepted_at")
|
||||
acceptedUserId String? @map("accepted_user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([inviterId])
|
||||
@@map("invitations")
|
||||
}
|
||||
|
||||
/// Persistent audit trail (issue #86, security.md §Logging): auth events and
|
||||
/// admin actions — grants, member roles, plugin installs, quota and settings
|
||||
/// changes, setup steps, manual job triggers. Written by AuditService, which
|
||||
|
||||
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';
|
||||
import { SiteAdminGuard } from './site-admin.guard';
|
||||
|
||||
// Lifecycle markers, not configuration: never editable through this
|
||||
// endpoint (the setup lock must be irreversible, issue #80).
|
||||
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
|
||||
// Lifecycle markers and file-backed metadata, not configuration: never
|
||||
// editable through this endpoint. The setup lock must be irreversible
|
||||
// (issue #80), and the branding entries only describe bytes on disk
|
||||
// (issue #306) — writing one by hand would claim an asset that is not
|
||||
// there. Both have their own write paths.
|
||||
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set([
|
||||
'setup.completedAt',
|
||||
'instance.logo',
|
||||
'instance.logoDark',
|
||||
'instance.favicon',
|
||||
]);
|
||||
|
||||
// Partial update: any subset of the known settings, each validated by
|
||||
// its own schema inside the service (double validation is fine — this
|
||||
|
||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BackupModule } from '../backup/backup.module';
|
||||
import { PondsModule } from '../ponds/ponds.module';
|
||||
import { QuotasModule } from '../quotas/quotas.module';
|
||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
@ -19,7 +20,15 @@ import { UserAdminController } from './user-admin.controller';
|
||||
import { UserAdminService } from './user-admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [QuotasModule, UsersModule, AuthModule, SchedulerModule, BackupModule, SearchModule],
|
||||
imports: [
|
||||
QuotasModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
SchedulerModule,
|
||||
BackupModule,
|
||||
SearchModule,
|
||||
PondsModule,
|
||||
],
|
||||
controllers: [
|
||||
AdminSettingsController,
|
||||
BackupAdminController,
|
||||
|
||||
@ -12,9 +12,11 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AdminCreateUserInput,
|
||||
AdminUserListQuery,
|
||||
AdminUserListView,
|
||||
AdminUserView,
|
||||
adminCreateUserSchema,
|
||||
adminUserListQuerySchema,
|
||||
setSiteAdminSchema,
|
||||
setUserDisabledSchema,
|
||||
@ -31,6 +33,14 @@ import { UserAdminService } from './user-admin.service';
|
||||
export class UserAdminController {
|
||||
constructor(private readonly users: UserAdminService) {}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(adminCreateUserSchema)) input: AdminCreateUserInput,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<AdminUserView> {
|
||||
return this.users.createUser(request.user!, input);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@Query(new ZodValidationPipe(adminUserListQuerySchema)) query: AdminUserListQuery,
|
||||
|
||||
@ -69,6 +69,64 @@ describe.skipIf(!hasTestDb)('user admin (e2e, issue #59)', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('creates an account that can log in right away, with a personal pond (issue #331)', async () => {
|
||||
const username = `ua-created-${suffix}`;
|
||||
const res = await api()
|
||||
.post('/api/v1/admin/users')
|
||||
.set('Cookie', cookies.admin1!)
|
||||
.send({
|
||||
username,
|
||||
email: `${username}@example.org`,
|
||||
displayName: 'UA Created',
|
||||
password,
|
||||
locale: 'de',
|
||||
})
|
||||
.expect(201);
|
||||
const created = res.body as { id: string; status: string };
|
||||
ids.created = created.id;
|
||||
// No verification hop: the admin vouched for the address.
|
||||
expect(created.status).toBe('ACTIVE');
|
||||
await api()
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ usernameOrEmail: username, password })
|
||||
.expect(200);
|
||||
// The personal pond exists exactly like after self-registration.
|
||||
expect(await prisma.pond.count({ where: { ownerId: created.id, type: 'PERSONAL' } })).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects duplicate usernames with a field-level conflict', async () => {
|
||||
await api()
|
||||
.post('/api/v1/admin/users')
|
||||
.set('Cookie', cookies.admin1!)
|
||||
.send({
|
||||
username: `ua-created-${suffix}`,
|
||||
email: `ua-created-other-${suffix}@example.org`,
|
||||
displayName: 'UA Dup',
|
||||
password,
|
||||
locale: 'en',
|
||||
})
|
||||
.expect(409)
|
||||
.expect((r) =>
|
||||
expect((r.body as { details: Record<string, string[]> }).details.username).toEqual([
|
||||
'validation.taken',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses creation for non-admins', async () => {
|
||||
await api()
|
||||
.post('/api/v1/admin/users')
|
||||
.set('Cookie', cookies.bob!)
|
||||
.send({
|
||||
username: `ua-sneak-${suffix}`,
|
||||
email: `ua-sneak-${suffix}@example.org`,
|
||||
displayName: 'UA Sneak',
|
||||
password,
|
||||
locale: 'en',
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('lists and searches users (Site-Admin only)', async () => {
|
||||
const res = await api()
|
||||
.get(`/api/v1/admin/users?q=ua-bob-${suffix}`)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
AdminCreateUserInput,
|
||||
AdminUserListQuery,
|
||||
AdminUserListView,
|
||||
AdminUserStatus,
|
||||
@ -10,7 +11,9 @@ import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { PondsService } from '../ponds/ponds.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PseudonymizationService } from './pseudonymization.service';
|
||||
|
||||
/**
|
||||
@ -27,12 +30,33 @@ export class UserAdminService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pseudonymizer: PseudonymizationService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly users: UsersService,
|
||||
private readonly ponds: PondsService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(UserAdminService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an account on behalf of a user (issue #331). The e-mail is
|
||||
* marked verified immediately — the admin vouches for the address — and
|
||||
* the personal pond is provisioned exactly like the verify-email path
|
||||
* does, so the account is indistinguishable from a self-registered one.
|
||||
*/
|
||||
async createUser(actor: User, input: AdminCreateUserInput): Promise<AdminUserView> {
|
||||
const user = await this.users.createUser(input);
|
||||
const verified = await this.users.markEmailVerified(user.id);
|
||||
await this.ponds.ensurePersonalPond(verified);
|
||||
await this.audit.record({
|
||||
action: 'user.created_by_admin',
|
||||
actorId: actor.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
});
|
||||
return this.viewOf(verified, await this.pondCountOf(user.id));
|
||||
}
|
||||
|
||||
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
|
||||
const q = query.q?.trim();
|
||||
const where: Prisma.UserWhereInput = q
|
||||
|
||||
@ -6,6 +6,7 @@ import { AdminModule } from './admin/admin.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { BrandingModule } from './branding/branding.module';
|
||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||
import { maskTokenParam } from './common/mask-token-param';
|
||||
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
|
||||
@ -82,6 +83,7 @@ import { VersionsModule } from './versions/versions.module';
|
||||
PublicModule,
|
||||
PublicApiModule,
|
||||
McpModule,
|
||||
BrandingModule,
|
||||
FontsModule,
|
||||
ImportExportModule,
|
||||
PluginsModule,
|
||||
|
||||
@ -29,6 +29,9 @@ export const AUDIT_EVENTS = {
|
||||
'file.integrity_failed': { severity: 'critical' },
|
||||
'grant.created': { severity: 'notice' },
|
||||
'grant.deleted': { severity: 'notice' },
|
||||
'invitation.accepted': { severity: 'notice' },
|
||||
'invitation.created': { severity: 'info' },
|
||||
'invitation.revoked': { severity: 'info' },
|
||||
'job.triggered': { severity: 'info' },
|
||||
'member.added': { severity: 'notice' },
|
||||
'member.removed': { severity: 'notice' },
|
||||
@ -40,17 +43,20 @@ export const AUDIT_EVENTS = {
|
||||
'plugin.mode_set': { severity: 'notice' },
|
||||
'plugin.pond_toggled': { severity: 'info' },
|
||||
'plugin.uninstalled': { severity: 'notice' },
|
||||
'pond.archived': { severity: 'notice' },
|
||||
'pond.purged': { severity: 'notice' },
|
||||
'quota.override_cleared': { severity: 'notice' },
|
||||
'quota.override_set': { severity: 'notice' },
|
||||
'read_trail.pruned': { severity: 'info' },
|
||||
'settings.changed': { severity: 'notice' },
|
||||
'branding.changed': { severity: 'notice' },
|
||||
'font.uploaded': { severity: 'notice' },
|
||||
'font.deleted': { severity: 'notice' },
|
||||
'setup.admin_created': { severity: 'notice' },
|
||||
'setup.completed': { severity: 'info' },
|
||||
'setup.preseeded': { severity: 'info' },
|
||||
'setup.smtp_stored': { severity: 'info' },
|
||||
'user.created_by_admin': { severity: 'notice' },
|
||||
'user.deleted': { severity: 'notice' },
|
||||
'user.disabled_set': { severity: 'notice' },
|
||||
'user.pseudonymized': { severity: 'notice' },
|
||||
|
||||
@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { GrantsModule } from '../grants/grants.module';
|
||||
import { InvitationsModule } from '../invitations/invitations.module';
|
||||
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { PondsModule } from '../ponds/ponds.module';
|
||||
@ -18,7 +19,7 @@ import { ProxyIdentityService } from './proxy-identity.service';
|
||||
import { SessionsModule } from './sessions.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule],
|
||||
imports: [UsersModule, MailModule, SessionsModule, PondsModule, GrantsModule, InvitationsModule],
|
||||
controllers: [AuthController, OidcController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@ -9,6 +9,7 @@ import { User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { InvitationsService } from '../invitations/invitations.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PondsService } from '../ponds/ponds.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
@ -33,6 +34,7 @@ export class AuthService {
|
||||
private readonly sessions: SessionsService,
|
||||
private readonly mail: MailService,
|
||||
private readonly ponds: PondsService,
|
||||
private readonly invitations: InvitationsService,
|
||||
private readonly rateLimits: RateLimitService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: AppConfig,
|
||||
@ -43,10 +45,37 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async signup(input: SignupInput): Promise<void> {
|
||||
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
|
||||
// An invitation token (issue #332) lets exactly one signup through a
|
||||
// closed registration. Claimed atomically BEFORE the account exists;
|
||||
// rolled back if the signup fails (duplicate username), so the invitee
|
||||
// can retry with the same link.
|
||||
const invitation = input.invitationToken
|
||||
? await this.invitations.redeem(input.invitationToken)
|
||||
: null;
|
||||
if (input.invitationToken && !invitation) {
|
||||
throw new BadRequestException({ code: 'token_invalid' });
|
||||
}
|
||||
if (!invitation && (await this.settings.get('auth.registrationMode')) === 'closed') {
|
||||
throw new ForbiddenException({ code: 'registration_closed' });
|
||||
}
|
||||
const user = await this.users.createUser(input);
|
||||
let user: User;
|
||||
try {
|
||||
user = await this.users.createUser(input);
|
||||
} catch (error) {
|
||||
if (invitation) await this.invitations.unredeem(invitation.id);
|
||||
throw error;
|
||||
}
|
||||
if (invitation) {
|
||||
await this.invitations.markAccepted(invitation.id, user.id);
|
||||
await this.audit.record({
|
||||
action: 'invitation.accepted',
|
||||
actorId: user.id,
|
||||
targetType: 'invitation',
|
||||
targetId: invitation.id,
|
||||
});
|
||||
}
|
||||
// The invite link proves nothing about the mailbox (it can be
|
||||
// forwarded), so the usual verification mail still applies.
|
||||
await this.sendVerificationMail(user);
|
||||
await this.audit.record({ action: 'auth.signup', actorId: user.id });
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -23,6 +23,7 @@ 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';
|
||||
|
||||
@ -106,7 +107,15 @@ export class CustomFontsAdminController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serving route. Unauthenticated on purpose: a font is referenced from CSS,
|
||||
* 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.
|
||||
*/
|
||||
@ -117,6 +126,15 @@ export class CustomFontsFileController {
|
||||
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(
|
||||
|
||||
@ -155,6 +155,30 @@ describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => {
|
||||
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()
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
||||
import { ConversionJobView, PageExportInput, pageExportInputSchema } from '@dorfteich/shared';
|
||||
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ConversionJobView,
|
||||
PageExportInput,
|
||||
PondArchivePreview,
|
||||
pageExportInputSchema,
|
||||
} from '@dorfteich/shared';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
@ -7,7 +12,10 @@ import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||
import { readActorOf } from '../read-trail/read-actor';
|
||||
|
||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||
|
||||
import { ExportService } from './export.service';
|
||||
import { PondArchiveService } from './pond-archive.service';
|
||||
|
||||
/**
|
||||
* Export endpoints (ADR 0009, issue #65): a whole pond as a ZIP of Markdown and
|
||||
@ -16,7 +24,41 @@ import { ExportService } from './export.service';
|
||||
*/
|
||||
@Controller()
|
||||
export class ExportController {
|
||||
constructor(private readonly exports: ExportService) {}
|
||||
constructor(
|
||||
private readonly exports: ExportService,
|
||||
private readonly archives: PondArchiveService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* How much of the pond this requester's archive would contain (issue #305).
|
||||
* Asked before the download so the UI can name the number of omitted pages:
|
||||
* an archive silently missing content is worse than no archive, because it
|
||||
* ends the search.
|
||||
*/
|
||||
@Get('ponds/:pondId/archive/preview')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
archivePreview(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondArchivePreview> {
|
||||
return this.archives.preview(request.user!, pondId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full archive: every readable page, EVERY attachment, and a versioned
|
||||
* manifest with settings, labels, comments and the hierarchy (issue #305).
|
||||
* Pond-Admin, because it is the deletion flow's last resort — a reader who
|
||||
* wants their own copy has the Markdown export.
|
||||
*/
|
||||
@Get('ponds/:pondId/archive')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
|
||||
async pondArchive(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.archives.stream(request.user!, pondId, response, readActorOf(request), false);
|
||||
}
|
||||
|
||||
/** Streamed ZIP of the pond's readable pages as Markdown (+ `media/`). The
|
||||
* `reader` role is "may see the pond"; the service filters to readable pages,
|
||||
@ -48,3 +90,34 @@ export class ExportController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Site Admin's archive from the purge dialog (issue #305, #193).
|
||||
*
|
||||
* Separate controller because it must NOT carry `@RequiresPondRole`: a Site
|
||||
* Admin purging a trashed pond is usually not a member of it, and the last
|
||||
* archive before an irreversible purge must not depend on that. It is
|
||||
* therefore complete by construction — the read filter is skipped.
|
||||
*/
|
||||
@Controller('admin/trash')
|
||||
@UseGuards(SiteAdminGuard)
|
||||
export class PondArchiveAdminController {
|
||||
constructor(private readonly archives: PondArchiveService) {}
|
||||
|
||||
@Get('ponds/:pondId/archive/preview')
|
||||
archivePreview(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PondArchivePreview> {
|
||||
return this.archives.preview(request.user!, pondId, true);
|
||||
}
|
||||
|
||||
@Get('ponds/:pondId/archive')
|
||||
async archive(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
await this.archives.stream(request.user!, pondId, response, readActorOf(request), true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
ConversionJobView,
|
||||
ExportFormat,
|
||||
PondFonts,
|
||||
customFontEntries,
|
||||
fontSlug,
|
||||
PageClassification,
|
||||
classificationMarking,
|
||||
@ -335,6 +336,9 @@ export class ExportService {
|
||||
pondName: page.pond.name,
|
||||
bodyHtml,
|
||||
fonts,
|
||||
// Both the rules and the stack need the uploaded families: embedding a
|
||||
// face the stack never names would render the system font (issue #304).
|
||||
customFonts: customFontEntries(await this.customFonts.list()),
|
||||
fontFaceCss: await this.fontFaceCss(fonts),
|
||||
// Styled sections keep their look in the PDF (#75); a pond without
|
||||
// active style plugins contributes an empty string.
|
||||
|
||||
@ -15,7 +15,7 @@ import { ConversionWorker } from './conversion-worker.service';
|
||||
import { DATA_EXPORT_PROCESSOR } from './data-export.constants';
|
||||
import { DataExportController } from './data-export.controller';
|
||||
import { DataExportService } from './data-export.service';
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportController, PondArchiveAdminController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
import { GotenbergHttpRenderer, GotenbergRenderer } from './gotenberg.renderer';
|
||||
import { IMPORT_PROCESSOR } from './import.constants';
|
||||
@ -23,6 +23,7 @@ import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
import { JobsController } from './jobs.controller';
|
||||
import { PandocConverter, PandocServerConverter } from './pandoc.converter';
|
||||
import { PondArchiveService } from './pond-archive.service';
|
||||
|
||||
/** How often expired data-export payloads are purged (#68). Hourly is ample:
|
||||
* the link's own expiry check already stops downloads the moment it lapses. */
|
||||
@ -48,12 +49,19 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
|
||||
SchedulerModule,
|
||||
SettingsModule,
|
||||
],
|
||||
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
||||
controllers: [
|
||||
JobsController,
|
||||
ImportController,
|
||||
ExportController,
|
||||
PondArchiveAdminController,
|
||||
DataExportController,
|
||||
],
|
||||
providers: [
|
||||
ConversionJobService,
|
||||
ConversionWorker,
|
||||
ImportService,
|
||||
ExportService,
|
||||
PondArchiveService,
|
||||
DataExportService,
|
||||
// The worker resolves the import pipeline through this token (never the
|
||||
// class), so its file does not import the import service's (avoids a cycle).
|
||||
|
||||
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 {
|
||||
title: string;
|
||||
@ -8,6 +8,12 @@ export interface PdfHtmlParams {
|
||||
fonts: PondFonts;
|
||||
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
|
||||
fontFaceCss: string;
|
||||
/** The instance's operator-uploaded families (issue #303), so a pond set to
|
||||
* one gets it NAMED in the `font-family` stack. Without them `fontStack`
|
||||
* cannot tell a custom family from a typo and yields the bare system
|
||||
* fallback — the `@font-face` rule would then be embedded but never
|
||||
* referenced, and the PDF would silently render in the system font. */
|
||||
customFonts?: readonly FontCatalogEntry[];
|
||||
/** The pond's active section-style plugin CSS (issue #75), already validated
|
||||
* at install time (scoped selectors, no external fetches, no `</style>`).
|
||||
* Sections of a disabled plugin render neutrally — their class matches
|
||||
@ -35,6 +41,7 @@ function escapeHtml(value: string): string {
|
||||
*/
|
||||
export function buildPdfHtml(params: PdfHtmlParams): string {
|
||||
const { fonts } = params;
|
||||
const extra = params.customFonts ?? [];
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -44,9 +51,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
|
||||
${params.fontFaceCss}
|
||||
@page { size: A4; }
|
||||
:root {
|
||||
--font-heading: ${fontStack(fonts.heading.family)};
|
||||
--font-body: ${fontStack(fonts.body.family)};
|
||||
--font-mono: ${fontStack(fonts.mono.family)};
|
||||
--font-heading: ${fontStack(fonts.heading.family, extra)};
|
||||
--font-body: ${fontStack(fonts.body.family, extra)};
|
||||
--font-mono: ${fontStack(fonts.mono.family, extra)};
|
||||
}
|
||||
html { font-size: 11pt; }
|
||||
body {
|
||||
|
||||
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';
|
||||
|
||||
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest';
|
||||
export type MailTemplate = 'verifyEmail' | 'resetPassword' | 'smtpTest' | 'invitation';
|
||||
|
||||
export interface RenderedMail {
|
||||
subject: string;
|
||||
@ -15,14 +15,15 @@ export interface RenderedMail {
|
||||
*/
|
||||
export function renderMail(
|
||||
template: MailTemplate,
|
||||
params: { displayName: string; link: string },
|
||||
// Extra keys (e.g. inviterName, #332) interpolate into the body text.
|
||||
params: { displayName: string; link: string } & Record<string, string>,
|
||||
locale: 'de' | 'en',
|
||||
): RenderedMail {
|
||||
const t = (key: string, options: Record<string, string> = {}): string =>
|
||||
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
|
||||
|
||||
const greeting = t('common.greeting', { displayName: params.displayName });
|
||||
const body = t(`${template}.body`);
|
||||
const body = t(`${template}.body`, params);
|
||||
const action = t(`${template}.action`);
|
||||
const expiry = t(`${template}.expiry`);
|
||||
const ignore = t('common.ignoreHint');
|
||||
|
||||
@ -14,7 +14,7 @@ export class MailService {
|
||||
async enqueue(
|
||||
to: string,
|
||||
template: MailTemplate,
|
||||
params: { displayName: string; link: string },
|
||||
params: { displayName: string; link: string } & Record<string, string>,
|
||||
locale: 'de' | 'en',
|
||||
): Promise<void> {
|
||||
const rendered = renderMail(template, params, locale);
|
||||
|
||||
@ -26,48 +26,56 @@ describe('sort-key helpers (issue #45)', () => {
|
||||
* pattern — repeatedly drop the last page between the first two — must never
|
||||
* collide and never overflow the key length, because the caller rebalances
|
||||
* when {@link nextKeyOrRebalance} returns null.
|
||||
*
|
||||
* Under parallel CI load the 10.000 iterations have repeatedly exceeded the
|
||||
* default 5 s per-test timeout (runs 685, 699 — same code passed on rerun),
|
||||
* so this test carries its own budget.
|
||||
*/
|
||||
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
|
||||
// Start with five pages in a fixed order.
|
||||
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
|
||||
let rebalances = 0;
|
||||
it(
|
||||
'10.000 adversarial reorders never collide or overflow (rebalance verified)',
|
||||
{ timeout: 30_000 },
|
||||
() => {
|
||||
// Start with five pages in a fixed order.
|
||||
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
|
||||
let rebalances = 0;
|
||||
|
||||
const rebalance = (): void => {
|
||||
const keys = evenlySpacedKeys(order.length);
|
||||
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
|
||||
rebalances += 1;
|
||||
};
|
||||
const rebalance = (): void => {
|
||||
const keys = evenlySpacedKeys(order.length);
|
||||
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
|
||||
rebalances += 1;
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10_000; i += 1) {
|
||||
// Move the last page to sit between the first and second — the tightest
|
||||
// possible gap, which is what grows key length fastest.
|
||||
const moved = order[order.length - 1]!;
|
||||
const rest = order.slice(0, -1);
|
||||
const afterKey = rest[0]!.key;
|
||||
const beforeKey = rest[1]!.key;
|
||||
for (let i = 0; i < 10_000; i += 1) {
|
||||
// Move the last page to sit between the first and second — the tightest
|
||||
// possible gap, which is what grows key length fastest.
|
||||
const moved = order[order.length - 1]!;
|
||||
const rest = order.slice(0, -1);
|
||||
const afterKey = rest[0]!.key;
|
||||
const beforeKey = rest[1]!.key;
|
||||
|
||||
const key = nextKeyOrRebalance(afterKey, beforeKey);
|
||||
if (key === null) {
|
||||
// Rebalance keeps the CURRENT order, then retry the move once.
|
||||
rebalance();
|
||||
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
|
||||
expect(k2).not.toBeNull();
|
||||
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
|
||||
} else {
|
||||
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
|
||||
const key = nextKeyOrRebalance(afterKey, beforeKey);
|
||||
if (key === null) {
|
||||
// Rebalance keeps the CURRENT order, then retry the move once.
|
||||
rebalance();
|
||||
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
|
||||
expect(k2).not.toBeNull();
|
||||
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
|
||||
} else {
|
||||
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
|
||||
}
|
||||
|
||||
// Invariants after every move: keys unique, bounded, and consistent with
|
||||
// the intended array order.
|
||||
const keys = order.map((p) => p.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
|
||||
for (let j = 1; j < keys.length; j += 1) {
|
||||
expect(keys[j - 1]! < keys[j]!).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Invariants after every move: keys unique, bounded, and consistent with
|
||||
// the intended array order.
|
||||
const keys = order.map((p) => p.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
|
||||
for (let j = 1; j < keys.length; j += 1) {
|
||||
expect(keys[j - 1]! < keys[j]!).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// The adversarial pattern must have forced at least one rebalance.
|
||||
expect(rebalances).toBeGreaterThan(0);
|
||||
});
|
||||
// The adversarial pattern must have forced at least one rebalance.
|
||||
expect(rebalances).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared';
|
||||
import {
|
||||
DEFAULT_ATTACHMENT_EXTENSIONS,
|
||||
VS_NFD_PROFILE,
|
||||
brandingAssetSchema,
|
||||
isVsNfdCompliant,
|
||||
} from '@dorfteich/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { z } from 'zod';
|
||||
@ -16,8 +21,21 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
*/
|
||||
export const INSTANCE_SETTINGS = {
|
||||
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
|
||||
// Peer invitations (issue #332): max OPEN (pending, unexpired)
|
||||
// invitations per user; 0 turns inviting off entirely.
|
||||
'invitations.maxOpenPerUser': z.number().int().min(0).default(5),
|
||||
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
|
||||
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
|
||||
// Branding assets (issue #306). Metadata only — the PNG bytes live under
|
||||
// BRANDING_DIR and travel in the restore set; `hash` goes into the serving
|
||||
// URL so a replaced asset is picked up without cache trouble. Null = not
|
||||
// uploaded: the instance name renders as text, the favicon falls back to
|
||||
// the shipped default. `logoDark` is optional by design — without it the
|
||||
// LIGHT logo is used in both themes, because showing the operator's own
|
||||
// asset unchanged beats substituting one they did not choose (#307).
|
||||
'instance.logo': brandingAssetSchema.nullable().default(null),
|
||||
'instance.logoDark': brandingAssetSchema.nullable().default(null),
|
||||
'instance.favicon': brandingAssetSchema.nullable().default(null),
|
||||
// Instance-default quotas (ADR 0011); per-user/per-pond overrides live
|
||||
// in quota_overrides and win over these (QuotaService, issue #22).
|
||||
'quota.editorsPerPond': z.number().int().min(0).default(5),
|
||||
|
||||
@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
|
||||
expect(locked.body.code).toBe('setup_locked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('env pre-seeding with invalid values (issue #325)', () => {
|
||||
const dbName = `dorfteich_preseed_bad_${suffix}`;
|
||||
let app: INestApplication;
|
||||
const badEnv = {
|
||||
SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`,
|
||||
SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`,
|
||||
SETUP_ADMIN_PASSWORD: 'short',
|
||||
} as const;
|
||||
|
||||
beforeAll(async () => {
|
||||
const url = await createFreshDatabase(dbName);
|
||||
process.env.TEST_DATABASE_URL = url;
|
||||
process.env.SECRETS_FILE = join(
|
||||
mkdtempSync(join(tmpdir(), 'dorfteich-preseed-bad-')),
|
||||
'secrets.env',
|
||||
);
|
||||
Object.assign(process.env, badEnv);
|
||||
app = await createTestApp();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
for (const key of Object.keys(badEnv)) delete process.env[key];
|
||||
await app.close();
|
||||
await dropDatabase(dbName);
|
||||
});
|
||||
|
||||
it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => {
|
||||
await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow(
|
||||
/SETUP_ADMIN_PASSWORD must be at least 10 characters/,
|
||||
);
|
||||
// Fail-fast left nothing half-seeded: the wizard is still pending.
|
||||
const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200);
|
||||
expect(status.body.status).toBe('required');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface FakeSmtpServer {
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from '@dorfteich/shared';
|
||||
import { User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { SessionsService } from '../auth/sessions.service';
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit {
|
||||
if (!(await this.state.isPending())) return;
|
||||
|
||||
// Fails the boot loudly on invalid values — a half-seeded instance
|
||||
// would be much harder to diagnose than a startup error.
|
||||
const input = setupAdminInputSchema.parse({
|
||||
// would be much harder to diagnose than a startup error. Translated
|
||||
// into operator terms first: the raw ZodError names schema fields and
|
||||
// i18n keys, not the SETUP_* variable to fix (issue #325).
|
||||
const parsed = setupAdminInputSchema.safeParse({
|
||||
username: env.SETUP_ADMIN_USERNAME,
|
||||
email: env.SETUP_ADMIN_EMAIL,
|
||||
password: env.SETUP_ADMIN_PASSWORD,
|
||||
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
|
||||
locale: env.SETUP_DEFAULT_LOCALE,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Pre-seeding failed: ${describePreseedIssues(parsed.error)}. ` +
|
||||
'Fix .env and recreate the api container.',
|
||||
);
|
||||
}
|
||||
const input = parsed.data;
|
||||
const admin = await this.createAdmin(input);
|
||||
if (env.SETUP_INSTANCE_NAME) {
|
||||
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
|
||||
@ -223,3 +233,27 @@ export class SetupService implements OnModuleInit {
|
||||
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** The env variable behind each schema field of the pre-seeded admin. */
|
||||
const PRESEED_FIELD_TO_ENV: Record<string, string> = {
|
||||
username: 'SETUP_ADMIN_USERNAME',
|
||||
email: 'SETUP_ADMIN_EMAIL',
|
||||
password: 'SETUP_ADMIN_PASSWORD',
|
||||
displayName: 'SETUP_ADMIN_DISPLAY_NAME',
|
||||
locale: 'SETUP_DEFAULT_LOCALE',
|
||||
};
|
||||
|
||||
function describePreseedIssues(error: ZodError): string {
|
||||
return error.issues
|
||||
.map((issue) => {
|
||||
const variable = PRESEED_FIELD_TO_ENV[String(issue.path[0])] ?? String(issue.path[0]);
|
||||
if (issue.code === 'too_small' && issue.type === 'string') {
|
||||
return `${variable} must be at least ${issue.minimum} characters`;
|
||||
}
|
||||
if (issue.code === 'invalid_string' && issue.validation === 'email') {
|
||||
return `${variable} is not a valid e-mail address`;
|
||||
}
|
||||
return `${variable} is invalid (${issue.message})`;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { BrandingModule } from '../branding/branding.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { PagesModule } from '../pages/pages.module';
|
||||
@ -18,6 +19,7 @@ const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BrandingModule,
|
||||
CommonModule,
|
||||
PondsModule,
|
||||
QuotasModule,
|
||||
|
||||
@ -4,6 +4,7 @@ import { User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { BrandingService } from '../branding/branding.service';
|
||||
import { ClockService } from '../common/clock.service';
|
||||
import { SearchProvider } from '../search/search.provider';
|
||||
import { PagesService } from '../pages/pages.service';
|
||||
@ -32,6 +33,7 @@ export class TrashService {
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly quotas: QuotaService,
|
||||
private readonly storage: FileStorageService,
|
||||
private readonly branding: BrandingService,
|
||||
private readonly clock: ClockService,
|
||||
private readonly watches: WatchesService,
|
||||
private readonly audit: AuditService,
|
||||
@ -183,6 +185,9 @@ export class TrashService {
|
||||
for (const attachment of attachments) {
|
||||
await this.storage.delete(pondId, attachment.id);
|
||||
}
|
||||
// The pond's branding files (issue #307). The purge standard is absolute:
|
||||
// after it, nothing referencing the pond survives — rows OR files.
|
||||
await this.branding.removePondAssets(pondId);
|
||||
const pageIds = (
|
||||
await this.prisma.page.findMany({ where: { pondId }, select: { id: true } })
|
||||
).map((page) => page.id);
|
||||
|
||||
@ -20,7 +20,7 @@ ENV NODE_ENV=production APP_VERSION=${APP_VERSION} \
|
||||
# Baked-in volume paths (self-sufficient without compose env, like the
|
||||
# api image's PLUGINS_DIR — issue #71's lesson).
|
||||
BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \
|
||||
CUSTOM_FONTS_DIR=/data/fonts \
|
||||
CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding \
|
||||
SECRETS_FILE=/data/secrets/secrets.env
|
||||
# pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the
|
||||
# volume archives, tzdata so BACKUP_TIME honors a configured TZ, and
|
||||
|
||||
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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -12,7 +12,7 @@ import type { BackupEnv } from '@dorfteich/shared';
|
||||
* root from that and throws otherwise.
|
||||
*/
|
||||
export function dataDirs(
|
||||
env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'>,
|
||||
env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR' | 'BRANDING_DIR'>,
|
||||
): string[] {
|
||||
return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR];
|
||||
return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR, env.BRANDING_DIR];
|
||||
}
|
||||
|
||||
@ -19,7 +19,12 @@ import type { RemoteLogger } from './remote.js';
|
||||
export async function performRestore(
|
||||
env: Pick<
|
||||
BackupEnv,
|
||||
'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'
|
||||
| 'BACKUPS_DIR'
|
||||
| 'DATABASE_URL'
|
||||
| 'UPLOADS_DIR'
|
||||
| 'PLUGINS_DIR'
|
||||
| 'CUSTOM_FONTS_DIR'
|
||||
| 'BRANDING_DIR'
|
||||
>,
|
||||
backupId: string,
|
||||
log: RemoteLogger,
|
||||
|
||||
@ -87,7 +87,27 @@ for (const scheme of SCHEMES) {
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Einladungs-Abschnitt (issue #332) gerendert — sonst liefe der Scan
|
||||
// auch grün, wenn die Sektion gar nicht erscheint.
|
||||
await page.locator('.invitations').waitFor();
|
||||
await expectClean(page, `/settings (${scheme})`);
|
||||
|
||||
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
|
||||
// eigenen Schriften zwei Tabellen samt Scroll-Regionen). Bewusst
|
||||
// KEIN eigener Test — jeder zusätzliche Login im Pack bringt die
|
||||
// CI zwei Packs später ans Rate-Limit (Lehre aus #301).
|
||||
await page.goto('/fonts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, `/fonts (${scheme})`);
|
||||
|
||||
// Teich-Einstellungen im selben Kontext (fixture-user besitzt den
|
||||
// Fixture-Teich): dort sitzt seit issue #305 das Archiv-Angebot in der
|
||||
// Löschzone. Wieder KEIN eigener Test — zusätzliche Logins kippen die
|
||||
// CI zwei Packs später am Rate-Limit (Lehre aus #301).
|
||||
await page.goto('/p/content-fixtures/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('.pond-archive__download').waitFor();
|
||||
await expectClean(page, `Teich-Einstellungen (${scheme})`);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
@ -99,7 +119,18 @@ for (const scheme of SCHEMES) {
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
|
||||
await page.locator('.user-manager__table .user-row').first().waitFor();
|
||||
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
|
||||
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
|
||||
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
|
||||
// Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist
|
||||
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
|
||||
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
|
||||
await expectClean(page, `/admin (${scheme})`);
|
||||
// Anlage-Dialog (issue #331) im selben Kontext öffnen und mitscannen —
|
||||
// wieder KEIN eigener Test (Rate-Limit-Lehre aus #301).
|
||||
await page.locator('.user-manager__create').click();
|
||||
await page.locator('.create-user-dialog').waitFor();
|
||||
await expectClean(page, `/admin Anlage-Dialog (${scheme})`);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
|
||||
test('gap cursor reaches positions before and after a lone table (issue #335)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E Gapcursor ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||
const status = page.locator('.editor-connection');
|
||||
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await content.click();
|
||||
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||
await expect(content.locator('table')).toBeVisible();
|
||||
// Inserting into the empty page replaces the placeholder paragraph — the
|
||||
// table really is the only block, which is the situation of issue #335.
|
||||
await expect(content.locator(':scope > p')).toHaveCount(0);
|
||||
// Right after the insert the collab sync can still swallow a click's
|
||||
// selection update; interact only against a settled editor (established
|
||||
// pattern, see a11y.spec.ts). The typed markers below verify each click
|
||||
// really placed the cursor where the locator points.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Keyboard only: ArrowUp from the first cell lands on the gap cursor
|
||||
// before the table; typing there materializes a paragraph.
|
||||
await content.locator('th').first().click();
|
||||
await page.keyboard.type('in');
|
||||
await expect(content.locator('th').first()).toHaveText('in');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||
await page.keyboard.type('above');
|
||||
await expect(content.locator(':scope > :first-child')).toHaveText('above');
|
||||
|
||||
// Same for the position after the table.
|
||||
await content.locator('td').last().click();
|
||||
await page.keyboard.type('z');
|
||||
await expect(content.locator('td').last()).toHaveText('z');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||
await page.keyboard.type('below');
|
||||
await expect(content.locator(':scope > :last-child')).toHaveText('below');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('cells can be merged and split from the toolbar (issue #337)', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E MergeSplit ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||
const status = page.locator('.editor-connection');
|
||||
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await content.click();
|
||||
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||
await expect(content.locator('table')).toBeVisible();
|
||||
|
||||
const mergeButton = page.getByRole('button', { name: /merge cells|zellen verbinden/i });
|
||||
const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i });
|
||||
await expect(mergeButton).toBeDisabled();
|
||||
await expect(splitButton).toBeDisabled();
|
||||
// Settle before clicking into cells — see the gap cursor test.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Extending the selection across the cell border turns it into a cell
|
||||
// selection (prosemirror-tables), which is what merge operates on.
|
||||
// Shift+Click, not Shift+ArrowRight: a keypress fired in the same tick as
|
||||
// the preceding click races the editor's post-click rendering and gets
|
||||
// dropped — no human types that fast (works fine interactively).
|
||||
await content.locator('td').first().click();
|
||||
await content
|
||||
.locator('td')
|
||||
.nth(1)
|
||||
.click({ modifiers: ['Shift'] });
|
||||
await expect(content.locator('.selectedCell')).toHaveCount(2);
|
||||
await expect(mergeButton).toBeEnabled();
|
||||
await mergeButton.click();
|
||||
await expect(content.locator('td[colspan="2"]')).toHaveCount(1);
|
||||
|
||||
// Splitting the merged cell restores the row's full cell count.
|
||||
await content.locator('td[colspan="2"]').click();
|
||||
await expect(splitButton).toBeEnabled();
|
||||
await splitButton.click();
|
||||
await expect(content.locator('td[colspan="2"]')).toHaveCount(0);
|
||||
await expect(content.locator('tr').nth(1).locator('td')).toHaveCount(3);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Tab navigates table cells, extends the table, and never traps focus (issue #338)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E TableTab ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||
const status = page.locator('.editor-connection');
|
||||
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await content.click();
|
||||
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
|
||||
const rows = content.locator('tr');
|
||||
await expect(rows).toHaveCount(3);
|
||||
// Settle before clicking into cells — see the gap cursor test.
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tab moves to the next cell, Shift+Tab back. Typed markers prove where
|
||||
// the cursor really is (the typing assertions also settle the editor
|
||||
// between keypresses — see the merge test on click/key races).
|
||||
await content.locator('th').first().click();
|
||||
await page.keyboard.type('one');
|
||||
await expect(content.locator('th').first()).toHaveText('one');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.type('two');
|
||||
await expect(content.locator('th').nth(1)).toHaveText('two');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await page.keyboard.type('back');
|
||||
await expect(content.locator('th').first()).toContainText('back');
|
||||
|
||||
// Tab in the last cell appends a row and moves into it (Word behavior).
|
||||
const lastCell = rows.nth(2).locator('td').nth(2);
|
||||
await lastCell.click();
|
||||
await page.keyboard.type('z');
|
||||
await expect(lastCell).toHaveText('z');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(rows).toHaveCount(4);
|
||||
await page.keyboard.type('new');
|
||||
await expect(rows.nth(3).locator('td').first()).toHaveText('new');
|
||||
|
||||
// No keyboard trap (WCAG 2.1.2): Escape works from EVERY cell (the gap
|
||||
// cursor is only reachable per arrow key from edge cells) and places the
|
||||
// cursor after the table; once outside, Tab leaves the editor entirely.
|
||||
// The mechanism is announced via the editor's aria-describedby hint.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(content).not.toBeFocused();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);
|
||||
|
||||
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 page.getByRole('button', { name: /save legal pages|rechtsseiten speichern/i }).click();
|
||||
await expect(page.getByRole('status')).toBeVisible();
|
||||
// Auf den Abschnitt gescopet: seit der Schriftverwaltung (#304) hat /admin
|
||||
// weitere Live-Regionen (Upload-Fortschritt), und ein seitenweites
|
||||
// getByRole('status') wäre mehrdeutig. Gemeint war immer die
|
||||
// Erfolgsmeldung DIESES Formulars.
|
||||
await expect(page.locator('.legal-settings').getByRole('status')).toBeVisible();
|
||||
await admin.close();
|
||||
|
||||
const anonymous = await browser.newContext({ baseURL: BASE_URL });
|
||||
|
||||
@ -108,6 +108,102 @@ test('a plain-text paste is not mangled into rich structure', async ({ browser }
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a Markdown table pasted with code-editor styling HTML becomes a table (issue #339)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TablePaste ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await enterEditMode(page);
|
||||
await page.locator('.ProseMirror').click();
|
||||
|
||||
// VS Code (copyWithSyntaxHighlighting) ships the plain text a second time
|
||||
// as styled div/span HTML — exactly the flavor that used to shadow the
|
||||
// Markdown conversion.
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.ProseMirror');
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
|
||||
dataTransfer.setData(
|
||||
'text/html',
|
||||
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
|
||||
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
|
||||
'<div><span style="color:#d4d4d4;">| --- | --- |</span></div>' +
|
||||
'<div><span style="color:#d4d4d4;">| 1 | 2 |</span></div></div>',
|
||||
);
|
||||
el!.dispatchEvent(
|
||||
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await expect(content.locator('table')).toHaveCount(1);
|
||||
await expect(content.locator('th').first()).toHaveText('A');
|
||||
await expect(content.locator('td').first()).toHaveText('1');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a Markdown table pasted into a code block stays verbatim text (issue #339)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E MD CodePaste ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await enterEditMode(page);
|
||||
await page.locator('.ProseMirror').click();
|
||||
await page.getByRole('button', { name: /code block|codeblock/i }).click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.ProseMirror');
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
|
||||
el!.dispatchEvent(
|
||||
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const content = page.locator('.ProseMirror');
|
||||
await expect(content.locator('table')).toHaveCount(0);
|
||||
await expect(content.locator('pre')).toContainText('| A | B |');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('typing a Markdown table header plus separator creates a table (issue #339)', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TableType ${Date.now()}`);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||
await enterEditMode(page);
|
||||
const content = page.locator('.ProseMirror');
|
||||
await content.click();
|
||||
|
||||
await page.keyboard.type('| Name | Rolle |');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('| --- | --- |');
|
||||
await expect(content).toContainText('| --- | --- |');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(content.locator('table')).toHaveCount(1);
|
||||
await expect(content.locator('th').first()).toHaveText('Name');
|
||||
await expect(content).not.toContainText('| --- | --- |');
|
||||
|
||||
// The cursor lands in the table; Tab from the last header cell appends the
|
||||
// first body row (#338), so typing continues seamlessly.
|
||||
await page.keyboard.type('x');
|
||||
await expect(content.locator('th').first()).toContainText('x');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);
|
||||
|
||||
@ -28,8 +28,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
||||
await expect(nav).toBeVisible();
|
||||
const links = nav.locator('.settings-nav__link');
|
||||
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
||||
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild).
|
||||
await expect(links).toHaveCount(9);
|
||||
// 8 seit #170 (Bedienung), 9 seit #180 (Erscheinungsbild),
|
||||
// 10 seit #332 (Einladungen).
|
||||
await expect(links).toHaveCount(10);
|
||||
|
||||
// Jump to the last section: it scrolls into view and becomes active.
|
||||
const last = links.last();
|
||||
|
||||
@ -34,7 +34,7 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
|
||||
// select value — compliant choice clears it, violating choice brings it
|
||||
// back, no save in between.
|
||||
const regField = page.locator('label.field', {
|
||||
has: page.locator('select[name="auth.registrationMode"]'),
|
||||
has: page.locator('select[name="registrationMode"]'),
|
||||
});
|
||||
const regSelect = regField.locator('select');
|
||||
await regSelect.selectOption('open');
|
||||
@ -72,7 +72,7 @@ test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({
|
||||
|
||||
// Value-listed control: the compliant registration mode keeps only its
|
||||
// compliant choice (seed leaves it open = violating? then all options).
|
||||
const regSelect = page.locator('select[name="auth.registrationMode"]');
|
||||
const regSelect = page.locator('select[name="registrationMode"]');
|
||||
const regField = page.locator('label.field', { has: regSelect });
|
||||
const optionCount = await regSelect.locator('option').count();
|
||||
const marked = await regField.locator('.vs-nfd-mark').count();
|
||||
|
||||
@ -10,6 +10,13 @@
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#2f6f4f" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#10161d" />
|
||||
<title>Dorfteich</title>
|
||||
<!-- Static link, dynamic resource (issue #306): the api answers with the
|
||||
operator's favicon or the shipped default, so this href never has to
|
||||
change and index.html stays a static file. An attribute like `lang`
|
||||
cannot be indirected this way — that is #179's problem, not this
|
||||
one's. -->
|
||||
<link rel="icon" type="image/png" href="/api/v1/branding/favicon" />
|
||||
<link rel="apple-touch-icon" href="/api/v1/branding/favicon?size=180" />
|
||||
<!-- Classic (non-module) script: executes during head parsing, before
|
||||
first paint and before the deferred module bundle. External file
|
||||
because the prod CSP forbids inline scripts (issue #180). -->
|
||||
|
||||
@ -1,3 +1,23 @@
|
||||
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
|
||||
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
|
||||
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
|
||||
# SPA route — which nginx answers with index.html — would see `en` forever,
|
||||
# even for German content.
|
||||
#
|
||||
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
|
||||
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
|
||||
# German; `en-US,de` does not, because that visitor asked for English first.
|
||||
#
|
||||
# Known limit, documented rather than worked around: nginx does not know
|
||||
# `instance.defaultLocale` from the database, so a visitor with no (or an
|
||||
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
|
||||
# content that is not the authoritative rendering anyway — the api's server
|
||||
# shell (`/api/v1/public/...`) renders those with the instance locale.
|
||||
map $http_accept_language $spa_lang {
|
||||
default en;
|
||||
~*^de de;
|
||||
}
|
||||
|
||||
# SPA serving: static assets with long-lived caching, everything else
|
||||
# falls back to index.html (client-side routing).
|
||||
server {
|
||||
@ -34,6 +54,17 @@ server {
|
||||
# zero-third-party-request guarantee (security.md) is unaffected.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
# The shell's language (issue #179). Only the html document is
|
||||
# rewritten, and only its first match — `<html lang="en">` is the
|
||||
# first and only occurrence in index.html. Everything else this
|
||||
# location serves passes through untouched.
|
||||
sub_filter_types text/html;
|
||||
sub_filter_once on;
|
||||
sub_filter 'lang="en"' 'lang="$spa_lang"';
|
||||
# The response now depends on a request header, so shared caches must
|
||||
# not serve one language's copy to the other. This location is
|
||||
# `no-cache` anyway; the header states the dependency correctly.
|
||||
add_header Vary "Accept-Language" always;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@ -127,6 +127,8 @@ export function Toolbar({
|
||||
canAddColumn: e.can().addColumnAfter(),
|
||||
canDeleteColumn: e.can().deleteColumn(),
|
||||
canDeleteTable: e.can().deleteTable(),
|
||||
canMergeCells: e.can().mergeCells(),
|
||||
canSplitCell: e.can().splitCell(),
|
||||
canToggleHeaderRow: e.can().toggleHeaderRow(),
|
||||
canUndo: e.can().undo(),
|
||||
canRedo: e.can().redo(),
|
||||
@ -270,7 +272,10 @@ export function Toolbar({
|
||||
disabled={!state.canDeleteColumn}
|
||||
onClick={() => editor.chain().focus().deleteColumn().run()}
|
||||
>
|
||||
⊟↕
|
||||
{/* Axis stripes + the × delete marker (already established by
|
||||
deleteTable's ⊠): the earlier ⊟↕/⊟↔ double arrows read as
|
||||
"resize/expand", not "delete" (issue #336). */}
|
||||
▥×
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.addRowBefore')}
|
||||
@ -291,7 +296,23 @@ export function Toolbar({
|
||||
disabled={!state.canDeleteRow}
|
||||
onClick={() => editor.chain().focus().deleteRow().run()}
|
||||
>
|
||||
⊟↔
|
||||
▤×
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.mergeCells')}
|
||||
disabled={!state.canMergeCells}
|
||||
onClick={() => editor.chain().focus().mergeCells().run()}
|
||||
>
|
||||
{/* Arrows collapsing onto / leaving a cell border: merge removes
|
||||
the border between selected cells, split restores it. */}
|
||||
→|←
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.splitCell')}
|
||||
disabled={!state.canSplitCell}
|
||||
onClick={() => editor.chain().focus().splitCell().run()}
|
||||
>
|
||||
←|→
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.toggleHeaderRow')}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { AnyExtension } from '@tiptap/core';
|
||||
|
||||
import { GapCursor } from './gap-cursor';
|
||||
import { MarkdownClipboard } from './markdown-clipboard';
|
||||
import { MarkdownTableInput } from './markdown-table-input';
|
||||
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
|
||||
import { Image } from './nodes/image';
|
||||
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
||||
@ -63,4 +65,6 @@ export const documentExtensions: AnyExtension[] = [
|
||||
Strikethrough,
|
||||
LinkMark,
|
||||
MarkdownClipboard,
|
||||
MarkdownTableInput,
|
||||
GapCursor,
|
||||
];
|
||||
|
||||
20
apps/web/src/editor/gap-cursor.ts
Normal file
20
apps/web/src/editor/gap-cursor.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { gapCursor } from '@tiptap/pm/gapcursor';
|
||||
|
||||
/**
|
||||
* Cursor position adjacent to block nodes that offer no text position of
|
||||
* their own — without it a table (or code block, image, …) as the page's
|
||||
* first, last, or only block is unreachable from before/after, and no
|
||||
* paragraph can be created there (issue #335). Wraps prosemirror-gapcursor,
|
||||
* which also handles the arrow-key navigation into the gap positions; the
|
||||
* bar itself is styled in `styles/base.css` (`.ProseMirror-gapcursor`)
|
||||
* because the upstream package does not ship its stylesheet through this
|
||||
* entry point.
|
||||
*/
|
||||
export const GapCursor = Extension.create({
|
||||
name: 'gapCursor',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [gapCursor()];
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { looksLikeMarkdown } from './markdown-clipboard';
|
||||
import { htmlIsStyledPlainText, looksLikeMarkdown } from './markdown-clipboard';
|
||||
|
||||
describe('looksLikeMarkdown (issue #30)', () => {
|
||||
it('recognizes a heading + list document', () => {
|
||||
@ -31,3 +32,21 @@ describe('looksLikeMarkdown (issue #30)', () => {
|
||||
expect(looksLikeMarkdown(' \n ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlIsStyledPlainText (issue #339)', () => {
|
||||
it('recognizes VS-Code-style syntax-highlighting HTML as styled plain text', () => {
|
||||
const vsCode =
|
||||
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
|
||||
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
|
||||
'<div><span>| --- | --- |</span></div></div>';
|
||||
expect(htmlIsStyledPlainText(vsCode)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps rich-text clipboard HTML on the HTML paste path', () => {
|
||||
expect(htmlIsStyledPlainText('<table><tr><td>a</td></tr></table>')).toBe(false);
|
||||
expect(htmlIsStyledPlainText('<p><strong>bold</strong> prose</p>')).toBe(false);
|
||||
expect(htmlIsStyledPlainText('<ul><li>one</li></ul>')).toBe(false);
|
||||
expect(htmlIsStyledPlainText('<p><a href="https://example.org">link</a></p>')).toBe(false);
|
||||
expect(htmlIsStyledPlainText('<pre><code>x</code></pre>')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -26,6 +26,25 @@ export function looksLikeMarkdown(text: string): boolean {
|
||||
return matches.length >= 2;
|
||||
}
|
||||
|
||||
/** Elements whose presence means the clipboard HTML carries real structure
|
||||
* or semantics that ProseMirror's HTML paste should interpret. */
|
||||
const STRUCTURAL_HTML =
|
||||
'table, ul, ol, li, h1, h2, h3, h4, h5, h6, blockquote, pre, code, a, img, b, strong, i, em, u, s';
|
||||
|
||||
/**
|
||||
* Code editors (VS Code with copyWithSyntaxHighlighting, similar tools) put
|
||||
* an HTML flavor on the clipboard that is nothing but the plain text wrapped
|
||||
* in styled div/span containers. Treating that as "real HTML" made the paste
|
||||
* ignore the Markdown heuristic below, so a Markdown table copied out of
|
||||
* VS Code arrived as verbatim text while the same text from a plain editor
|
||||
* converted fine (issue #339). Only HTML without any structural element is
|
||||
* declared equivalent to the plain text — anything from a rich-text source
|
||||
* keeps going through ProseMirror's own HTML paste.
|
||||
*/
|
||||
export function htmlIsStyledPlainText(html: string): boolean {
|
||||
return new DOMParser().parseFromString(html, 'text/html').querySelector(STRUCTURAL_HTML) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying
|
||||
* puts Markdown on `text/plain` alongside the browser's own HTML (so
|
||||
@ -65,8 +84,11 @@ export const MarkdownClipboard = Extension.create({
|
||||
}
|
||||
},
|
||||
handlePaste(view, event) {
|
||||
// Inside a code block pasted text is code, never a document —
|
||||
// converting there would split the block around rich nodes.
|
||||
if (view.state.selection.$from.parent.type.spec.code) return false;
|
||||
const html = event.clipboardData?.getData('text/html');
|
||||
if (html && html.trim() !== '') return false;
|
||||
if (html && html.trim() !== '' && !htmlIsStyledPlainText(html)) return false;
|
||||
const text = event.clipboardData?.getData('text/plain');
|
||||
if (!text || !looksLikeMarkdown(text)) return false;
|
||||
|
||||
|
||||
68
apps/web/src/editor/markdown-table-input.ts
Normal file
68
apps/web/src/editor/markdown-table-input.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { markdownToDoc } from '@dorfteich/shared';
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { Plugin, Selection } from '@tiptap/pm/state';
|
||||
|
||||
/** A `| … |` pipe row — the same signal `looksLikeMarkdown` uses. */
|
||||
const PIPE_ROW = /^\|.+\|\s*$/;
|
||||
|
||||
/** The GFM header separator (`| --- | :--- |`…). Three dashes minimum keeps
|
||||
* accidental short rows like `|-|` from ever triggering a conversion. */
|
||||
const SEPARATOR_ROW = /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/;
|
||||
|
||||
/**
|
||||
* Hand-typed Markdown tables (issue #339): pressing Enter at the end of a
|
||||
* separator row whose previous sibling is a pipe row replaces the two
|
||||
* paragraphs with a real table. TipTap input rules cannot express this —
|
||||
* they only see text inside a single textblock, and a table needs two.
|
||||
* Conversion is refused inside existing tables (the schema would allow the
|
||||
* nested table, the reader could not make sense of it). Body rows are then
|
||||
* typed cell-wise — Tab in the last cell appends a row (#338).
|
||||
*/
|
||||
export const MarkdownTableInput = Extension.create({
|
||||
name: 'markdownTableInput',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown(view, event) {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey)
|
||||
return false;
|
||||
const { $from, empty } = view.state.selection;
|
||||
if (!empty || $from.parent.type.name !== 'paragraph') return false;
|
||||
if ($from.parentOffset !== $from.parent.content.size) return false;
|
||||
if (!SEPARATOR_ROW.test($from.parent.textContent)) return false;
|
||||
for (let depth = $from.depth - 1; depth > 0; depth -= 1) {
|
||||
if ($from.node(depth).type.spec.tableRole) return false;
|
||||
}
|
||||
const container = $from.node($from.depth - 1);
|
||||
const index = $from.index($from.depth - 1);
|
||||
if (index === 0) return false;
|
||||
const headerRow = container.child(index - 1);
|
||||
if (headerRow.type.name !== 'paragraph' || !PIPE_ROW.test(headerRow.textContent))
|
||||
return false;
|
||||
|
||||
let table: ProseMirrorNode;
|
||||
try {
|
||||
const parsed = markdownToDoc(`${headerRow.textContent}\n${$from.parent.textContent}`);
|
||||
if (parsed.childCount !== 1 || parsed.firstChild?.type.name !== 'table') return false;
|
||||
// Re-hydrated against the live schema — same identity dance as
|
||||
// in markdown-clipboard.ts.
|
||||
table = ProseMirrorNode.fromJSON(view.state.schema, parsed.firstChild.toJSON());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const start = $from.before($from.depth) - headerRow.nodeSize;
|
||||
const end = $from.after($from.depth);
|
||||
const tr = view.state.tr.replaceWith(start, end, table);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(start), 1));
|
||||
view.dispatch(tr.scrollIntoView());
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@ -1,4 +1,6 @@
|
||||
import { Node } from '@tiptap/core';
|
||||
import { GapCursor } from '@tiptap/pm/gapcursor';
|
||||
import { Selection } from '@tiptap/pm/state';
|
||||
import type { Node as PMNode, Schema } from 'prosemirror-model';
|
||||
import {
|
||||
addColumnAfter,
|
||||
@ -8,6 +10,9 @@ import {
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
deleteTable,
|
||||
goToNextCell,
|
||||
mergeCells,
|
||||
splitCell,
|
||||
tableEditing,
|
||||
toggleHeaderRow,
|
||||
} from 'prosemirror-tables';
|
||||
@ -34,6 +39,10 @@ declare module '@tiptap/core' {
|
||||
addRowAfter: () => ReturnType;
|
||||
deleteRow: () => ReturnType;
|
||||
deleteTable: () => ReturnType;
|
||||
mergeCells: () => ReturnType;
|
||||
splitCell: () => ReturnType;
|
||||
goToNextCell: () => ReturnType;
|
||||
goToPreviousCell: () => ReturnType;
|
||||
toggleHeaderRow: () => ReturnType;
|
||||
};
|
||||
}
|
||||
@ -65,6 +74,37 @@ export const Table = Node.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [tableEditing()];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Word-style navigation (issue #338): Tab moves cell-wise and appends
|
||||
// a new row from the last cell. Outside a table every branch returns
|
||||
// false, so Tab keeps its browser default (focus moves on) and the
|
||||
// editor is no keyboard trap — from inside a table the arrow keys
|
||||
// lead out via the gap cursor (#335), then Tab leaves the editor.
|
||||
Tab: () => {
|
||||
if (this.editor.commands.goToNextCell()) return true;
|
||||
if (!this.editor.can().addRowAfter()) return false;
|
||||
return this.editor.chain().addRowAfter().goToNextCell().run();
|
||||
},
|
||||
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
|
||||
// The documented exit (aria-describedby hint, #338): the gap cursor is
|
||||
// only reachable per arrow key from the table's edge cells, so Escape
|
||||
// is the exit that works from EVERY cell. Falls back to a gap cursor
|
||||
// when no textblock follows the table (#335 guarantees the position).
|
||||
Escape: () =>
|
||||
this.editor.commands.command(({ state, dispatch }) => {
|
||||
const { $head } = state.selection;
|
||||
for (let depth = $head.depth; depth > 0; depth -= 1) {
|
||||
if ($head.node(depth).type.spec.tableRole !== 'table') continue;
|
||||
const $after = state.doc.resolve($head.after(depth));
|
||||
const selection = Selection.findFrom($after, 1, true) ?? new GapCursor($after);
|
||||
if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
};
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
@ -101,6 +141,22 @@ export const Table = Node.create({
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
deleteTable(state, dispatch),
|
||||
mergeCells:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
mergeCells(state, dispatch),
|
||||
splitCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
splitCell(state, dispatch),
|
||||
goToNextCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
goToNextCell(1)(state, dispatch),
|
||||
goToPreviousCell:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
goToNextCell(-1)(state, dispatch),
|
||||
toggleHeaderRow:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
|
||||
@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiPatch } from '../lib/api';
|
||||
import { useCustomFontEntries } from './use-custom-fonts';
|
||||
|
||||
const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono'];
|
||||
const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace'];
|
||||
@ -29,9 +30,12 @@ export function AppearanceManager({
|
||||
const [draft, setDraft] = useState<PondFonts>(fonts);
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
// The operator's own families (issue #304) — offered next to the catalog,
|
||||
// in their own labelled group, and resolvable by `fontEntry`/`fontStack`.
|
||||
const custom = useCustomFontEntries();
|
||||
|
||||
function chooseFamily(slot: keyof PondFonts, family: string): void {
|
||||
const weights = fontEntry(family)?.weights ?? [];
|
||||
const weights = fontEntry(family, custom)?.weights ?? [];
|
||||
// Keep the current weight if the new family offers it, else its first.
|
||||
const weight = weights.includes(draft[slot].weight) ? draft[slot].weight : (weights[0] ?? 400);
|
||||
setDraft((prev) => ({ ...prev, [slot]: { family, weight } }));
|
||||
@ -62,7 +66,7 @@ export function AppearanceManager({
|
||||
<FormError error={error} />
|
||||
{SLOTS.map((slot) => {
|
||||
const value = draft[slot];
|
||||
const weights = fontEntry(value.family)?.weights ?? [value.weight];
|
||||
const weights = fontEntry(value.family, custom)?.weights ?? [value.weight];
|
||||
return (
|
||||
<div className={`appearance__slot appearance__slot--${slot}`} key={slot}>
|
||||
<span className="appearance__slot-label">{t(`slots.${slot}`)}</span>
|
||||
@ -72,8 +76,18 @@ export function AppearanceManager({
|
||||
value={value.family}
|
||||
onChange={(event) => chooseFamily(slot, event.target.value)}
|
||||
>
|
||||
{/* Bundled and uploaded families are told apart by the group
|
||||
they sit in, not by a badge (issue #304): the grouping is
|
||||
then part of the control's semantics — a screen reader
|
||||
announces it on entering, and the native mobile select
|
||||
keeps it. Within each source the category grouping of the
|
||||
catalog is preserved, so a custom family appears under its
|
||||
own category exactly like a bundled one. */}
|
||||
{CATEGORIES.map((category) => (
|
||||
<optgroup key={category} label={t(`category.${category}`)}>
|
||||
<optgroup
|
||||
key={category}
|
||||
label={t('group.bundled', { category: t(`category.${category}`) })}
|
||||
>
|
||||
{FONT_CATALOG.filter((font) => font.category === category).map((font) => (
|
||||
<option key={font.family} value={font.family}>
|
||||
{font.family}
|
||||
@ -81,6 +95,22 @@ export function AppearanceManager({
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
{CATEGORIES.filter((category) =>
|
||||
custom.some((font) => font.category === category),
|
||||
).map((category) => (
|
||||
<optgroup
|
||||
key={`custom-${category}`}
|
||||
label={t('group.custom', { category: t(`category.${category}`) })}
|
||||
>
|
||||
{custom
|
||||
.filter((font) => font.category === category)
|
||||
.map((font) => (
|
||||
<option key={font.family} value={font.family}>
|
||||
{font.family}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="appearance__field">
|
||||
@ -98,7 +128,7 @@ export function AppearanceManager({
|
||||
</label>
|
||||
<p
|
||||
className="appearance__preview"
|
||||
style={{ fontFamily: fontStack(value.family), fontWeight: value.weight }}
|
||||
style={{ fontFamily: fontStack(value.family, custom), fontWeight: value.weight }}
|
||||
>
|
||||
{t('preview')}
|
||||
</p>
|
||||
|
||||
40
apps/web/src/fonts/CustomFontFaces.tsx
Normal file
40
apps/web/src/fonts/CustomFontFaces.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useCustomFonts } from './use-custom-fonts';
|
||||
|
||||
/**
|
||||
* A family name is free text the operator typed. It ends up inside a CSS
|
||||
* string, so quote and backslash are escaped and everything that could end
|
||||
* the declaration, the rule or the `<style>` element is dropped. Site Admins
|
||||
* are trusted with far more than this, but a rule that silently breaks the
|
||||
* whole stylesheet on an apostrophe would be a bug either way.
|
||||
*/
|
||||
function cssFamily(family: string): string {
|
||||
return family.replace(/[\\'<>{};\r\n]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* `@font-face` rules for the operator-uploaded families (issue #304).
|
||||
*
|
||||
* Catalog families are declared in the generated `public/fonts/catalog.css`,
|
||||
* which the build writes and `index.html` links. Uploaded ones only exist at
|
||||
* runtime, so their rules are injected here — same shape, same `swap`
|
||||
* behaviour, bytes from the api's public font route.
|
||||
*
|
||||
* Without this the pickers would offer families the browser cannot resolve:
|
||||
* `fontStack` names them, nothing declares them, and the text renders in the
|
||||
* system fallback.
|
||||
*/
|
||||
export function CustomFontFaces(): React.JSX.Element | null {
|
||||
const fonts = useCustomFonts();
|
||||
if (fonts.length === 0) return null;
|
||||
const css = fonts
|
||||
.flatMap((font) =>
|
||||
font.weights.map(
|
||||
(weight) =>
|
||||
`@font-face { font-family: '${cssFamily(font.family)}'; font-style: normal;` +
|
||||
` font-weight: ${weight}; font-display: swap;` +
|
||||
` src: url('/api/v1/fonts/custom/${font.slug}/${font.slug}-${weight}.woff2') format('woff2'); }`,
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
return <style data-custom-fonts="">{css}</style>;
|
||||
}
|
||||
@ -1,19 +1,28 @@
|
||||
import { PondSettings, fontStack } from '@dorfteich/shared';
|
||||
import { FontCatalogEntry, PondSettings, fontStack } from '@dorfteich/shared';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import { useCustomFontEntries } from './use-custom-fonts';
|
||||
|
||||
/** The CSS custom properties a pond's font choice sets on its content root
|
||||
* (ADR 0016). The content CSS reads these; a family that fails to load falls
|
||||
* back to the category's system stack (`fontStack`). */
|
||||
export function pondFontVariables(fonts: PondSettings['fonts']): CSSProperties {
|
||||
* back to the category's system stack (`fontStack`).
|
||||
*
|
||||
* `custom` carries the operator-uploaded families (issue #304): `fontStack`
|
||||
* cannot tell an uploaded family from a deleted one, so without them a pond
|
||||
* set to its operator's own font would render in the system fallback. */
|
||||
export function pondFontVariables(
|
||||
fonts: PondSettings['fonts'],
|
||||
custom: readonly FontCatalogEntry[] = [],
|
||||
): CSSProperties {
|
||||
// Overrides the same custom properties the app-wide CSS already reads
|
||||
// (tokens.css), so headings, body, and code inside the scope re-resolve to
|
||||
// the pond's fonts without any per-element rules.
|
||||
return {
|
||||
'--font-heading': fontStack(fonts.heading.family),
|
||||
'--font-heading': fontStack(fonts.heading.family, custom),
|
||||
'--font-weight-heading': String(fonts.heading.weight),
|
||||
'--font-body': fontStack(fonts.body.family),
|
||||
'--font-body': fontStack(fonts.body.family, custom),
|
||||
'--font-weight-body': String(fonts.body.weight),
|
||||
'--font-mono': fontStack(fonts.mono.family),
|
||||
'--font-mono': fontStack(fonts.mono.family, custom),
|
||||
'--font-weight-mono': String(fonts.mono.weight),
|
||||
} as CSSProperties;
|
||||
}
|
||||
@ -32,8 +41,9 @@ export function PondFontScope({
|
||||
fonts: PondSettings['fonts'];
|
||||
children: ReactNode;
|
||||
}): React.JSX.Element {
|
||||
const custom = useCustomFontEntries();
|
||||
return (
|
||||
<div className="pond-font-scope" style={pondFontVariables(fonts)}>
|
||||
<div className="pond-font-scope" style={pondFontVariables(fonts, custom)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
39
apps/web/src/fonts/use-custom-fonts.ts
Normal file
39
apps/web/src/fonts/use-custom-fonts.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { CustomFontView, FontCatalogEntry, customFontEntries } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { apiGet } from '../lib/api';
|
||||
|
||||
export const CUSTOM_FONTS_KEY = ['fonts', 'custom'];
|
||||
|
||||
/**
|
||||
* The instance's operator-uploaded font families (issues #303/#304).
|
||||
*
|
||||
* Every font-aware surface needs them: the pickers offer them, the licence
|
||||
* page attributes them, `fontStack` needs them to NAME the family instead of
|
||||
* falling through to the system stack, and `CustomFontFaces` turns them into
|
||||
* `@font-face` rules. One query key, so they are fetched once per session and
|
||||
* shared.
|
||||
*
|
||||
* Only fetched while signed in — the endpoint requires a session, and asking
|
||||
* on the login screen would produce a 401 for nothing.
|
||||
*/
|
||||
export function useCustomFonts(): CustomFontView[] {
|
||||
const { user } = useAuth();
|
||||
const query = useQuery({
|
||||
queryKey: CUSTOM_FONTS_KEY,
|
||||
queryFn: () => apiGet<CustomFontView[]>('/fonts/custom'),
|
||||
enabled: Boolean(user),
|
||||
// Uploading a font is a rare Site-Admin act; the manager invalidates the
|
||||
// key itself, so a long life here costs nothing.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
/** The same families in the shape `fontEntry`/`fontStack` accept. */
|
||||
export function useCustomFontEntries(): FontCatalogEntry[] {
|
||||
const fonts = useCustomFonts();
|
||||
return useMemo(() => customFontEntries(fonts), [fonts]);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import deAccess from '@dorfteich/shared/i18n/de/access.json';
|
||||
import deAuth from '@dorfteich/shared/i18n/de/auth.json';
|
||||
import deBranding from '@dorfteich/shared/i18n/de/branding.json';
|
||||
import deComments from '@dorfteich/shared/i18n/de/comments.json';
|
||||
import deCommon from '@dorfteich/shared/i18n/de/common.json';
|
||||
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
||||
@ -9,6 +10,7 @@ import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
||||
import deFont from '@dorfteich/shared/i18n/de/font.json';
|
||||
import deGraph from '@dorfteich/shared/i18n/de/graph.json';
|
||||
import deImport from '@dorfteich/shared/i18n/de/import.json';
|
||||
import deInvitations from '@dorfteich/shared/i18n/de/invitations.json';
|
||||
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
||||
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
||||
import deLinks from '@dorfteich/shared/i18n/de/links.json';
|
||||
@ -28,6 +30,7 @@ import deWatches from '@dorfteich/shared/i18n/de/watches.json';
|
||||
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
||||
import enAccess from '@dorfteich/shared/i18n/en/access.json';
|
||||
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
|
||||
import enBranding from '@dorfteich/shared/i18n/en/branding.json';
|
||||
import enComments from '@dorfteich/shared/i18n/en/comments.json';
|
||||
import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
||||
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
||||
@ -37,6 +40,7 @@ import enFiles from '@dorfteich/shared/i18n/en/files.json';
|
||||
import enFont from '@dorfteich/shared/i18n/en/font.json';
|
||||
import enGraph from '@dorfteich/shared/i18n/en/graph.json';
|
||||
import enImport from '@dorfteich/shared/i18n/en/import.json';
|
||||
import enInvitations from '@dorfteich/shared/i18n/en/invitations.json';
|
||||
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
||||
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
||||
import enLinks from '@dorfteich/shared/i18n/en/links.json';
|
||||
@ -79,9 +83,11 @@ void i18n
|
||||
editor: enEditor,
|
||||
export: enExport,
|
||||
files: enFiles,
|
||||
branding: enBranding,
|
||||
font: enFont,
|
||||
graph: enGraph,
|
||||
import: enImport,
|
||||
invitations: enInvitations,
|
||||
labels: enLabels,
|
||||
legal: enLegal,
|
||||
links: enLinks,
|
||||
@ -109,9 +115,11 @@ void i18n
|
||||
editor: deEditor,
|
||||
export: deExport,
|
||||
files: deFiles,
|
||||
branding: deBranding,
|
||||
font: deFont,
|
||||
graph: deGraph,
|
||||
import: deImport,
|
||||
invitations: deInvitations,
|
||||
labels: deLabels,
|
||||
legal: deLegal,
|
||||
links: deLinks,
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { CustomFontFaces } from '../fonts/CustomFontFaces';
|
||||
import { usePersistentState } from '../lib/use-persistent-state';
|
||||
import { Footer } from './Footer';
|
||||
import { PageActionsSlotContext } from './page-actions';
|
||||
@ -55,6 +56,9 @@ export function AppLayout(): React.JSX.Element {
|
||||
<SidebarChromeContext.Provider value={setForcedHidden}>
|
||||
<PageActionsSlotContext.Provider value={actionsSlot}>
|
||||
<div className="app">
|
||||
{/* Declares the operator-uploaded families (#304) for every screen
|
||||
below — pickers, previews, editor and read view alike. */}
|
||||
<CustomFontFaces />
|
||||
{/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */}
|
||||
<a className="skip-link" href="#main">
|
||||
{t('layout.skipToContent')}
|
||||
|
||||
@ -29,6 +29,7 @@ import { usePageFavorites } from '../favorites/use-favorites';
|
||||
import { ImportControl } from '../import/ImportControl';
|
||||
import { LabelChips } from '../labels/LabelChips';
|
||||
import { usePondLabels } from '../labels/use-pond-labels';
|
||||
import { BrandLogo } from '../branding/BrandLogo';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
import { usePersistentState } from '../lib/use-persistent-state';
|
||||
import { NewPageForm } from './NewPageForm';
|
||||
@ -71,6 +72,11 @@ export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element
|
||||
aria-label={t('layout.sidebar.label')}
|
||||
>
|
||||
{resizer}
|
||||
{/* The instance identity sits ABOVE the pond section, not inside it:
|
||||
the sidebar has no pond header outside a pond, and the logo is the
|
||||
link home — it must not disappear on /admin or the start page
|
||||
(issue #306). The pond name below stays the heading. */}
|
||||
<BrandLogo />
|
||||
{!pond.data ? (
|
||||
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
||||
) : (
|
||||
|
||||
@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { useBranding } from '../branding/use-branding';
|
||||
import { IconButton, IconLink } from '../components/IconButton';
|
||||
import { apiGet } from '../lib/api';
|
||||
import { isTypingTarget } from '../lib/keyboard';
|
||||
@ -67,6 +68,9 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
enabled: Boolean(user && pondSlug),
|
||||
});
|
||||
const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
|
||||
// Instance identity (issue #306) — shared query, also read by the sidebar
|
||||
// logo and reachable without a session (the login screen carries it).
|
||||
const branding = useBranding();
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
setMenuOpen(false);
|
||||
@ -101,8 +105,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
>
|
||||
<Menu aria-hidden />
|
||||
</IconButton>
|
||||
{/* The operator's instance name, not the product name (issue #306):
|
||||
an operator who uploaded their own logo does not expect "Dorfteich"
|
||||
to stay in the chrome. `instance.name` defaults to "Dorfteich", so
|
||||
an untouched instance looks exactly as before. */}
|
||||
<Link to="/" className="topbar__brand">
|
||||
Dorfteich
|
||||
{branding?.instanceName ?? 'Dorfteich'}
|
||||
</Link>
|
||||
{user && <PondSwitcher />}
|
||||
{isPondOwner && pondSlug && (
|
||||
|
||||
@ -90,6 +90,27 @@ export async function apiUploadFile<T>(
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Multipart upload of a whole form (issue #304's font upload: several files
|
||||
* plus metadata in one request). `apiUploadFile` above covers the single-file
|
||||
* case; this one takes the `FormData` the caller assembled. */
|
||||
export async function apiPostForm<T>(path: string, form: FormData): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api/v1${path}`, { method: 'POST', body: form });
|
||||
} catch {
|
||||
throw new ApiError(0, { code: 'network', message: 'network error' });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
parsed ?? { code: `http_${response.status}`, message: response.statusText },
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
export function fetchHealth(): Promise<HealthResponse> {
|
||||
return apiGet<HealthResponse>('/healthz');
|
||||
}
|
||||
|
||||
@ -1,22 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useBranding } from '../branding/use-branding';
|
||||
|
||||
const APP_NAME = 'Dorfteich';
|
||||
|
||||
/**
|
||||
* Route-specific document title (issue #163, WCAG 2.4.2): joins the given
|
||||
* parts with the app name ("Page — Pond — Dorfteich"). Empty/undefined
|
||||
* parts with the instance name ("Page — Pond — My Wiki"). Empty/undefined
|
||||
* parts are skipped, so callers can pass still-loading data directly.
|
||||
* Falls back to the bare app name on unmount.
|
||||
* Falls back to the bare instance name on unmount.
|
||||
*
|
||||
* The trailing name is the OPERATOR'S instance name, not the product name
|
||||
* (issue #323) — same reasoning as the TopBar brand (issue #306). Until
|
||||
* the branding query resolves (or when it cannot, e.g. maintenance mode)
|
||||
* the shipped default keeps the title stable, so an untouched instance
|
||||
* reads exactly as before.
|
||||
*/
|
||||
export function useDocumentTitle(...parts: (string | null | undefined)[]): void {
|
||||
const joined = [...parts.filter(Boolean), APP_NAME].join(' — ');
|
||||
const appName = useBranding()?.instanceName.trim() || APP_NAME;
|
||||
const joined = [...parts.filter(Boolean), appName].join(' — ');
|
||||
useEffect(() => {
|
||||
document.title = joined;
|
||||
}, [joined]);
|
||||
useEffect(
|
||||
// On unmount only in effect: `joined` always changes with `appName`,
|
||||
// so the title effect above re-runs right after this cleanup.
|
||||
() => () => {
|
||||
document.title = APP_NAME;
|
||||
document.title = appName;
|
||||
},
|
||||
[],
|
||||
[appName],
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,10 +5,19 @@ import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { BRANDING_KEY } from '../branding/use-branding';
|
||||
import { Field, FormError, FormSuccess } from '../components/forms';
|
||||
import { SettingsLayout } from '../components/SettingsLayout';
|
||||
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
import {
|
||||
GENERAL_FORM_FIELDS,
|
||||
GeneralSettingsForm,
|
||||
toFormValues,
|
||||
toSettingsPatch,
|
||||
} from './admin-settings-form';
|
||||
import { BrandingManager } from './BrandingManager';
|
||||
import { CustomFontManager } from './CustomFontManager';
|
||||
import { PluginManager } from './PluginManager';
|
||||
import { QuotaManager } from './QuotaManager';
|
||||
import { UserManager } from './UserManager';
|
||||
@ -16,6 +25,7 @@ import { UserManager } from './UserManager';
|
||||
import { useDocumentTitle } from '../lib/use-document-title';
|
||||
interface InstanceSettings {
|
||||
'auth.registrationMode': 'open' | 'closed';
|
||||
'invitations.maxOpenPerUser': number;
|
||||
'instance.name': string;
|
||||
'instance.defaultLocale': 'de' | 'en';
|
||||
'quota.editorsPerPond': number;
|
||||
@ -49,15 +59,23 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
|
||||
});
|
||||
|
||||
const form = useForm<InstanceSettings>({ values: settings.data });
|
||||
// Dot-free field names with an explicit mapping to the dotted settings
|
||||
// keys — see admin-settings-form.ts for why the names must not contain
|
||||
// dots (issue #322).
|
||||
const form = useForm<GeneralSettingsForm>({
|
||||
values: settings.data ? toFormValues(settings.data) : undefined,
|
||||
});
|
||||
const vsNfd = useVsNfdMarking();
|
||||
|
||||
const onSubmit = form.handleSubmit(async (input) => {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
await apiPatch('/admin/settings', input);
|
||||
await apiPatch('/admin/settings', toSettingsPatch(input));
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
|
||||
// The TopBar takes the instance name from the public branding query;
|
||||
// without this it keeps the old name until its staleTime runs out.
|
||||
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
@ -77,6 +95,10 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<section className="settings-section">
|
||||
<h2>{t('settings:admin.general')}</h2>
|
||||
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
|
||||
vsNfd.hides(
|
||||
'invitations.maxOpenPerUser',
|
||||
settings.data['invitations.maxOpenPerUser'],
|
||||
) ||
|
||||
vsNfd.hides(
|
||||
'classification.newPageDefault',
|
||||
settings.data['classification.newPageDefault'],
|
||||
@ -89,37 +111,53 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<FormError error={error} />
|
||||
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
|
||||
<Field label={t('settings:admin.instanceName')}>
|
||||
<input type="text" {...form.register('instance.name')} />
|
||||
<input type="text" {...form.register('instanceName')} />
|
||||
</Field>
|
||||
<Field label={t('settings:admin.defaultLocale')}>
|
||||
<select {...form.register('instance.defaultLocale')}>
|
||||
<select {...form.register('defaultLocale')}>
|
||||
<option value="de">{t('settings:profile.locales.de')}</option>
|
||||
<option value="en">{t('settings:profile.locales.en')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label={t('settings:admin.registrationMode')}
|
||||
marking={vsNfd.markingFor(
|
||||
'auth.registrationMode',
|
||||
form.watch('auth.registrationMode'),
|
||||
)}
|
||||
marking={vsNfd.markingFor('auth.registrationMode', form.watch('registrationMode'))}
|
||||
>
|
||||
<select {...form.register('auth.registrationMode')}>
|
||||
<select {...form.register('registrationMode')}>
|
||||
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
|
||||
<option value="open">{t('settings:admin.registrationOpen')}</option>
|
||||
)}
|
||||
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
{!vsNfd.hides(
|
||||
'invitations.maxOpenPerUser',
|
||||
settings.data['invitations.maxOpenPerUser'],
|
||||
) && (
|
||||
<Field
|
||||
label={t('settings:admin.invitationsMaxOpen')}
|
||||
hint={t('settings:admin.invitationsMaxOpenHelp')}
|
||||
marking={vsNfd.markingFor(
|
||||
'invitations.maxOpenPerUser',
|
||||
form.watch('invitationsMaxOpenPerUser'),
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
{...form.register('invitationsMaxOpenPerUser', { valueAsNumber: true })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label={t('settings:admin.newPageClassification')}
|
||||
hint={t('settings:admin.newPageClassificationHelp')}
|
||||
marking={vsNfd.markingFor(
|
||||
'classification.newPageDefault',
|
||||
form.watch('classification.newPageDefault'),
|
||||
form.watch('newPageClassification'),
|
||||
)}
|
||||
>
|
||||
<select {...form.register('classification.newPageDefault')}>
|
||||
<select {...form.register('newPageClassification')}>
|
||||
{!vsNfd.hides(
|
||||
'classification.newPageDefault',
|
||||
settings.data['classification.newPageDefault'],
|
||||
@ -134,12 +172,9 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<Field
|
||||
label={t('settings:admin.uploadPolicy')}
|
||||
hint={t('settings:admin.uploadPolicyHelp')}
|
||||
marking={vsNfd.markingFor(
|
||||
'classification.uploadPolicy',
|
||||
form.watch('classification.uploadPolicy'),
|
||||
)}
|
||||
marking={vsNfd.markingFor('classification.uploadPolicy', form.watch('uploadPolicy'))}
|
||||
>
|
||||
<select {...form.register('classification.uploadPolicy')}>
|
||||
<select {...form.register('uploadPolicy')}>
|
||||
{!vsNfd.hides(
|
||||
'classification.uploadPolicy',
|
||||
settings.data['classification.uploadPolicy'],
|
||||
@ -158,15 +193,18 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
{(
|
||||
[
|
||||
'quota.editorsPerPond',
|
||||
'quota.readersPerPond',
|
||||
'quota.additionalPonds',
|
||||
'quota.storageBytes',
|
||||
'quota.maxFileBytes',
|
||||
'quotaEditorsPerPond',
|
||||
'quotaReadersPerPond',
|
||||
'quotaAdditionalPonds',
|
||||
'quotaStorageBytes',
|
||||
'quotaMaxFileBytes',
|
||||
] as const
|
||||
).map((key) => (
|
||||
<Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
|
||||
<input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
|
||||
).map((field) => (
|
||||
<Field
|
||||
key={field}
|
||||
label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[GENERAL_FORM_FIELDS[field]]}`)}
|
||||
>
|
||||
<input type="number" min={0} {...form.register(field, { valueAsNumber: true })} />
|
||||
</Field>
|
||||
))}
|
||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||
@ -180,6 +218,8 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
<LandingSettingsForm settings={settings.data} />
|
||||
<LegalSettingsForm settings={settings.data} />
|
||||
|
||||
<BrandingManager />
|
||||
<CustomFontManager />
|
||||
<PluginManager />
|
||||
<QuotaManager />
|
||||
<UserManager />
|
||||
@ -457,7 +497,10 @@ function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
// Named class so the e2e can scope its success-message assertion to this
|
||||
// form: /admin has more than one live region since #304 (upload progress),
|
||||
// and a page-wide getByRole('status') became ambiguous.
|
||||
<section className="settings-section legal-settings">
|
||||
<h2>{t('admin.title')}</h2>
|
||||
<p className="field__hint">{t('admin.hint')}</p>
|
||||
<form onSubmit={(event) => void onSubmit(event)} noValidate>
|
||||
|
||||
228
apps/web/src/pages/BrandingManager.tsx
Normal file
228
apps/web/src/pages/BrandingManager.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { BrandingView, LogoVariant, MAX_LOGO_EDGE } from '@dorfteich/shared';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CropField } from '../branding/CropField';
|
||||
import { canvasToPngFile, drawCrop } from '../branding/crop';
|
||||
import { logoUrl, useInvalidateBranding } from '../branding/use-branding';
|
||||
import { FormError, FormSuccess } from '../components/forms';
|
||||
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
|
||||
|
||||
/** The favicon is uploaded as the pair the browser rendered — see the api's
|
||||
* reasoning: it cannot resize, and one source must not become two icons. */
|
||||
const FAVICON_SIZES = [32, 180] as const;
|
||||
|
||||
/**
|
||||
* Site-Admin branding management (issue #306): the instance logo (light and
|
||||
* an optional dark variant) and the favicon.
|
||||
*
|
||||
* The images are prepared in the browser — see `branding/crop.ts` for why the
|
||||
* api never decodes one.
|
||||
*/
|
||||
export function BrandingManager(): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const invalidate = useInvalidateBranding();
|
||||
|
||||
const branding = useQuery({
|
||||
queryKey: ['admin', 'branding'],
|
||||
queryFn: () => apiGet<BrandingView>('/branding'),
|
||||
});
|
||||
const view = branding.data;
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
await branding.refetch();
|
||||
await invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section branding">
|
||||
<h2>{t('admin.title')}</h2>
|
||||
<p>{t('admin.intro')}</p>
|
||||
{/* An operator may not expect their logo to be readable by anyone who
|
||||
opens the login page — so say it, rather than let them find out. */}
|
||||
<p>{t('admin.publicNote')}</p>
|
||||
|
||||
<LogoSection variant="light" asset={view?.logo ?? null} onChanged={refresh} />
|
||||
<LogoSection variant="dark" asset={view?.logoDark ?? null} onChanged={refresh} />
|
||||
{view?.logo && !view.logoDark && (
|
||||
// Advisory, never blocking (issue #306): it names the consequence and
|
||||
// the operator may decide their logo works on both surfaces.
|
||||
<p className="branding__warning" role="note">
|
||||
<span aria-hidden="true">⚠ </span>
|
||||
{t('admin.darkMissing')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FaviconSection present={Boolean(view?.favicon)} onChanged={refresh} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoSection({
|
||||
variant,
|
||||
asset,
|
||||
onChanged,
|
||||
}: {
|
||||
variant: LogoVariant;
|
||||
asset: { hash: string; width: number; height: number } | null;
|
||||
onChanged: () => Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) throw new Error('no image');
|
||||
const form = new FormData();
|
||||
form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`));
|
||||
return apiPostForm<BrandingView>(`/admin/branding/logo?variant=${variant}`, form);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setDone(true);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => apiDelete(`/admin/branding/logo?variant=${variant}`),
|
||||
onSuccess: async () => {
|
||||
setDone(false);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="branding__slot" data-logo-variant={variant}>
|
||||
<h3>{t(`admin.logo.${variant}`)}</h3>
|
||||
<p>{t(`admin.logo.${variant}Hint`)}</p>
|
||||
<FormError error={upload.error ?? remove.error} />
|
||||
<FormSuccess message={done ? t('admin.logo.saved') : null} />
|
||||
|
||||
{asset ? (
|
||||
<div className="branding__current">
|
||||
<img
|
||||
src={logoUrl(variant, asset.hash)}
|
||||
alt={t('admin.logo.currentAlt')}
|
||||
className={`branding__preview branding__preview--${variant}`}
|
||||
/>
|
||||
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() => remove.mutate()}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
{t('admin.logo.remove')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p>{t('admin.logo.none')}</p>
|
||||
)}
|
||||
|
||||
<CropField
|
||||
idPrefix={`logo-${variant}`}
|
||||
square={false}
|
||||
maxEdge={MAX_LOGO_EDGE}
|
||||
onChange={(canvas) => {
|
||||
canvasRef.current = canvas;
|
||||
setDone(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => upload.mutate()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
{t('admin.logo.submit')}
|
||||
</button>
|
||||
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaviconSection({
|
||||
present,
|
||||
onChanged,
|
||||
}: {
|
||||
present: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
const source = canvasRef.current;
|
||||
if (!source) throw new Error('no image');
|
||||
const form = new FormData();
|
||||
// One source, both sizes, rendered from the same crop — the tab icon
|
||||
// and the home-screen icon can then never disagree.
|
||||
for (const size of FAVICON_SIZES) {
|
||||
const scratch = document.createElement('canvas');
|
||||
drawCrop(
|
||||
source,
|
||||
{ x: 0, y: 0, width: source.width, height: source.height },
|
||||
{ width: size, height: size },
|
||||
scratch,
|
||||
);
|
||||
form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`));
|
||||
}
|
||||
return apiPostForm<BrandingView>('/admin/branding/favicon', form);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setDone(true);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => apiDelete('/admin/branding/favicon'),
|
||||
onSuccess: async () => {
|
||||
setDone(false);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="branding__slot" data-branding-slot="favicon">
|
||||
<h3>{t('admin.favicon.title')}</h3>
|
||||
<p>{t('admin.favicon.hint')}</p>
|
||||
<FormError error={upload.error ?? remove.error} />
|
||||
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
|
||||
<p>{present ? t('admin.favicon.present') : t('admin.favicon.default')}</p>
|
||||
{present && (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() => remove.mutate()}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
{t('admin.favicon.remove')}
|
||||
</button>
|
||||
)}
|
||||
<CropField
|
||||
idPrefix="favicon"
|
||||
square
|
||||
maxEdge={180}
|
||||
onChange={(canvas) => {
|
||||
canvasRef.current = canvas;
|
||||
setDone(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => upload.mutate()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
{t('admin.favicon.submit')}
|
||||
</button>
|
||||
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
425
apps/web/src/pages/CustomFontManager.tsx
Normal file
425
apps/web/src/pages/CustomFontManager.tsx
Normal file
@ -0,0 +1,425 @@
|
||||
import {
|
||||
CustomFontView,
|
||||
FONT_CATEGORIES,
|
||||
FONT_WEIGHTS,
|
||||
FontCategory,
|
||||
MAX_FONT_FILE_BYTES,
|
||||
fontStack,
|
||||
customFontEntries,
|
||||
} from '@dorfteich/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Field, FormError, FormSuccess } from '../components/forms';
|
||||
import { CUSTOM_FONTS_KEY } from '../fonts/use-custom-fonts';
|
||||
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
|
||||
|
||||
/** One weight the operator is about to upload. `woff` is optional — the api
|
||||
* rejects a WOFF without its WOFF2 because the PDF path reads WOFF2 only. */
|
||||
interface WeightDraft {
|
||||
weight: number;
|
||||
woff2: File | null;
|
||||
woff: File | null;
|
||||
}
|
||||
|
||||
const MAX_MIB = Math.round(MAX_FONT_FILE_BYTES / (1024 * 1024));
|
||||
|
||||
/** Preselects the weight the operator most likely wants next: Regular for the
|
||||
* first row, then upwards from the heaviest one already chosen (Regular →
|
||||
* Bold is the usual second file), and only after that whatever is left. The
|
||||
* selection stays free — this is a starting point, not a rule. */
|
||||
function emptyDraft(used: number[]): WeightDraft {
|
||||
const free = FONT_WEIGHTS.filter((weight) => !used.includes(weight));
|
||||
const heaviest = Math.max(0, ...used);
|
||||
const next = free.includes(400) ? 400 : (free.find((w) => w > heaviest) ?? free[0] ?? 400);
|
||||
return { weight: next, woff2: null, woff: null };
|
||||
}
|
||||
|
||||
function appendWeight(form: FormData, draft: WeightDraft): void {
|
||||
if (draft.woff2) form.append(`woff2-${draft.weight}`, draft.woff2);
|
||||
if (draft.woff) form.append(`woff-${draft.weight}`, draft.woff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-Admin management of operator-uploaded font families (issue #304,
|
||||
* backend #303, ADR 0016 §#303).
|
||||
*
|
||||
* The whole cycle lives here: upload a family with its licence and one file
|
||||
* per weight, see what is installed, add a weight later, and delete a family
|
||||
* after being told how many ponds still use it.
|
||||
*
|
||||
* Deleting is never blocked (the api's decision): an unknown family falls back
|
||||
* to the system stack, so the affected ponds change appearance rather than
|
||||
* break. The confirmation therefore names the consequence in text instead of
|
||||
* refusing.
|
||||
*/
|
||||
export function CustomFontManager(): React.JSX.Element {
|
||||
const { t } = useTranslation('font');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const fonts = useQuery({
|
||||
queryKey: ['admin', 'fonts'],
|
||||
queryFn: () => apiGet<CustomFontView[]>('/admin/fonts'),
|
||||
});
|
||||
const installed = fonts.data ?? [];
|
||||
const entries = customFontEntries(installed);
|
||||
|
||||
const invalidate = async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'fonts'] });
|
||||
// The pickers, the licence page and the injected `@font-face` rules read
|
||||
// the non-admin list — without this they keep the pre-upload state.
|
||||
await queryClient.invalidateQueries({ queryKey: CUSTOM_FONTS_KEY });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section custom-fonts">
|
||||
<h2>{t('admin.title')}</h2>
|
||||
<p>{t('admin.intro')}</p>
|
||||
<UploadForm onUploaded={invalidate} />
|
||||
<h3>{t('admin.installed')}</h3>
|
||||
{installed.length === 0 ? (
|
||||
<p>{t('admin.empty')}</p>
|
||||
) : (
|
||||
<ul className="custom-fonts__list">
|
||||
{installed.map((font) => (
|
||||
<FontRow key={font.id} font={font} entries={entries} onChanged={invalidate} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadForm({ onUploaded }: { onUploaded: () => Promise<void> }): React.JSX.Element {
|
||||
const { t } = useTranslation('font');
|
||||
const [family, setFamily] = useState('');
|
||||
const [category, setCategory] = useState<FontCategory>('sans-serif');
|
||||
const [licence, setLicence] = useState('');
|
||||
const [licenceUrl, setLicenceUrl] = useState('');
|
||||
const [weights, setWeights] = useState<WeightDraft[]>([emptyDraft([])]);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
const form = new FormData();
|
||||
form.append('family', family.trim());
|
||||
form.append('category', category);
|
||||
form.append('licence', licence.trim());
|
||||
if (licenceUrl.trim()) form.append('licenceUrl', licenceUrl.trim());
|
||||
for (const draft of weights) appendWeight(form, draft);
|
||||
return apiPostForm<CustomFontView>('/admin/fonts', form);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setFamily('');
|
||||
setLicence('');
|
||||
setLicenceUrl('');
|
||||
setWeights([emptyDraft([])]);
|
||||
setDone(true);
|
||||
await onUploaded();
|
||||
},
|
||||
});
|
||||
|
||||
const ready = family.trim() !== '' && licence.trim() !== '' && weights.some((w) => w.woff2);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="custom-fonts__upload"
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setDone(false);
|
||||
upload.mutate();
|
||||
}}
|
||||
>
|
||||
<h3>{t('admin.upload.title')}</h3>
|
||||
<FormError error={upload.error} />
|
||||
{/* role="status", so completion reaches assistive technology instead of
|
||||
being a colour change in the corner. */}
|
||||
<FormSuccess message={done ? t('admin.upload.done') : null} />
|
||||
<Field label={t('admin.upload.family')} hint={t('admin.upload.familyHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={family}
|
||||
maxLength={80}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setFamily(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('admin.upload.category')}>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value as FontCategory)}
|
||||
>
|
||||
{FONT_CATEGORIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`category.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('admin.upload.licence')} hint={t('admin.upload.licenceHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={licence}
|
||||
maxLength={200}
|
||||
onChange={(event) => setLicence(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('admin.upload.licenceUrl')}>
|
||||
<input
|
||||
type="url"
|
||||
value={licenceUrl}
|
||||
maxLength={500}
|
||||
placeholder="https://"
|
||||
onChange={(event) => setLicenceUrl(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<fieldset className="custom-fonts__weights">
|
||||
<legend>{t('admin.upload.weights')}</legend>
|
||||
{/* The accepted formats are stated up front, not only when a rejected
|
||||
upload comes back — an operator should not have to fail to learn
|
||||
the requirement. */}
|
||||
<p>{t('admin.upload.formatHint', { max: MAX_MIB })}</p>
|
||||
{weights.map((draft, index) => (
|
||||
<div className="custom-fonts__weight" key={index}>
|
||||
<Field label={t('admin.upload.weight')}>
|
||||
<select
|
||||
value={draft.weight}
|
||||
onChange={(event) =>
|
||||
setWeights((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, weight: Number(event.target.value) } : entry,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{FONT_WEIGHTS.map((weight) => (
|
||||
<option key={weight} value={weight}>
|
||||
{weight}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('admin.upload.woff2', { weight: draft.weight })}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".woff2,font/woff2"
|
||||
onChange={(event) =>
|
||||
setWeights((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, woff2: event.target.files?.[0] ?? null } : entry,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('admin.upload.woff', { weight: draft.weight })}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".woff,font/woff"
|
||||
onChange={(event) =>
|
||||
setWeights((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, woff: event.target.files?.[0] ?? null } : entry,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{weights.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="linklike"
|
||||
onClick={() => setWeights((prev) => prev.filter((_, i) => i !== index))}
|
||||
>
|
||||
{t('admin.upload.removeWeight', { weight: draft.weight })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() =>
|
||||
setWeights((prev) => [...prev, emptyDraft(prev.map((entry) => entry.weight))])
|
||||
}
|
||||
>
|
||||
{t('admin.upload.addWeight')}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" className="button" disabled={!ready || upload.isPending}>
|
||||
{t('admin.upload.submit')}
|
||||
</button>
|
||||
{/* Announced, not just spun: an 8 MiB face over a slow link takes long
|
||||
enough that silence reads as failure. */}
|
||||
<p role="status" className="custom-fonts__status">
|
||||
{upload.isPending ? t('admin.upload.uploading') : ''}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function FontRow({
|
||||
font,
|
||||
entries,
|
||||
onChanged,
|
||||
}: {
|
||||
font: CustomFontView;
|
||||
entries: ReturnType<typeof customFontEntries>;
|
||||
onChanged: () => Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('font');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [adding, setAdding] = useState<WeightDraft | null>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement | null>(null);
|
||||
const deleteRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const usage = useQuery({
|
||||
queryKey: ['admin', 'fonts', font.id, 'usage'],
|
||||
queryFn: () => apiGet<{ pondsAffected: number }>(`/admin/fonts/${font.id}/usage`),
|
||||
enabled: confirming,
|
||||
});
|
||||
|
||||
// The confirmation appears below the button that opened it; without moving
|
||||
// focus a keyboard user would have to hunt for it, and a screen reader would
|
||||
// never learn it exists.
|
||||
useEffect(() => {
|
||||
if (confirming) confirmRef.current?.focus();
|
||||
}, [confirming, usage.data]);
|
||||
|
||||
const addWeight = useMutation({
|
||||
mutationFn: async (draft: WeightDraft) => {
|
||||
const form = new FormData();
|
||||
appendWeight(form, draft);
|
||||
return apiPostForm<CustomFontView>(`/admin/fonts/${font.id}/weights`, form);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setAdding(null);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => apiDelete(`/admin/fonts/${font.id}`),
|
||||
onSuccess: async () => {
|
||||
setConfirming(false);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="custom-fonts__item" data-font-slug={font.slug}>
|
||||
<p className="custom-fonts__name" style={{ fontFamily: fontStack(font.family, entries) }}>
|
||||
{font.family}
|
||||
</p>
|
||||
<p className="custom-fonts__meta">
|
||||
{t(`category.${font.category}`)} · {t('catalog.weights')}: {font.weights.join(', ')} ·{' '}
|
||||
{font.licenceUrl ? (
|
||||
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
|
||||
{font.licence}
|
||||
</a>
|
||||
) : (
|
||||
font.licence
|
||||
)}
|
||||
</p>
|
||||
<FormError error={remove.error ?? addWeight.error} />
|
||||
|
||||
<div className="custom-fonts__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="linklike"
|
||||
onClick={() => setAdding((prev) => (prev ? null : emptyDraft(font.weights)))}
|
||||
aria-expanded={adding !== null}
|
||||
>
|
||||
{t('admin.addWeight.toggle')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="linklike"
|
||||
ref={deleteRef}
|
||||
onClick={() => setConfirming(true)}
|
||||
aria-expanded={confirming}
|
||||
>
|
||||
{t('admin.delete.start', { family: font.family })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<form
|
||||
className="custom-fonts__add-weight"
|
||||
noValidate
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
addWeight.mutate(adding);
|
||||
}}
|
||||
>
|
||||
<Field label={t('admin.upload.weight')}>
|
||||
<select
|
||||
value={adding.weight}
|
||||
onChange={(event) => setAdding({ ...adding, weight: Number(event.target.value) })}
|
||||
>
|
||||
{FONT_WEIGHTS.filter((weight) => !font.weights.includes(weight)).map((weight) => (
|
||||
<option key={weight} value={weight}>
|
||||
{weight}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('admin.upload.woff2', { weight: adding.weight })}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".woff2,font/woff2"
|
||||
onChange={(event) => setAdding({ ...adding, woff2: event.target.files?.[0] ?? null })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('admin.upload.woff', { weight: adding.weight })}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".woff,font/woff"
|
||||
onChange={(event) => setAdding({ ...adding, woff: event.target.files?.[0] ?? null })}
|
||||
/>
|
||||
</Field>
|
||||
<button type="submit" className="button" disabled={!adding.woff2 || addWeight.isPending}>
|
||||
{t('admin.addWeight.submit')}
|
||||
</button>
|
||||
<p role="status">{addWeight.isPending ? t('admin.upload.uploading') : ''}</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{confirming && (
|
||||
<div className="custom-fonts__confirm">
|
||||
{/* The count comes from the api; until it arrives the consequence is
|
||||
still stated, so the text never reads as "nothing will happen". */}
|
||||
<p>
|
||||
{usage.data
|
||||
? t('admin.delete.usage', { count: usage.data.pondsAffected })
|
||||
: t('admin.delete.usageLoading')}
|
||||
</p>
|
||||
<p>{t('admin.delete.consequence')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--danger"
|
||||
ref={confirmRef}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate()}
|
||||
>
|
||||
{t('admin.delete.confirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
deleteRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{t('admin.delete.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@ -1,19 +1,29 @@
|
||||
import { FONT_CATALOG, fontStack } from '@dorfteich/shared';
|
||||
import { CustomFontView, FONT_CATALOG, FontCatalogEntry, fontStack } from '@dorfteich/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCustomFontEntries, useCustomFonts } from '../fonts/use-custom-fonts';
|
||||
import { useDocumentTitle } from '../lib/use-document-title';
|
||||
/**
|
||||
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
|
||||
* family with its license, rendered in the font itself. The self-hosting is the
|
||||
* GDPR guarantee — this page is the human-readable attribution surface.
|
||||
*/
|
||||
export function FontCatalogPage(): React.JSX.Element {
|
||||
|
||||
/** One licence table. Both sources carry the same four columns; only where the
|
||||
* licence text comes from differs — a catalog entry names a licence id we
|
||||
* ship, an uploaded family whatever the operator typed. */
|
||||
function LicenceTable({
|
||||
label,
|
||||
rows,
|
||||
}: {
|
||||
label: string;
|
||||
rows: {
|
||||
key: string;
|
||||
family: string;
|
||||
category: string;
|
||||
weights: number[];
|
||||
licence: React.ReactNode;
|
||||
stack: string;
|
||||
}[];
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('font');
|
||||
useDocumentTitle(t('catalog.title'));
|
||||
return (
|
||||
<div className="font-catalog">
|
||||
<h1>{t('catalog.title')}</h1>
|
||||
<p>{t('catalog.intro')}</p>
|
||||
<div className="table-scroll" tabIndex={0} role="region" aria-label={label}>
|
||||
<table className="font-catalog__table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -24,16 +34,12 @@ export function FontCatalogPage(): React.JSX.Element {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FONT_CATALOG.map((font) => (
|
||||
<tr key={font.family}>
|
||||
<td style={{ fontFamily: fontStack(font.family) }}>{font.family}</td>
|
||||
<td>{t(`category.${font.category}`)}</td>
|
||||
<td>{font.weights.join(', ')}</td>
|
||||
<td>
|
||||
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
|
||||
{font.license}
|
||||
</a>
|
||||
</td>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td style={{ fontFamily: row.stack }}>{row.family}</td>
|
||||
<td>{row.category}</td>
|
||||
<td>{row.weights.join(', ')}</td>
|
||||
<td>{row.licence}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -41,3 +47,72 @@ export function FontCatalogPage(): React.JSX.Element {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
|
||||
* family with its license, rendered in the font itself. The self-hosting is the
|
||||
* GDPR guarantee — this page is the human-readable attribution surface.
|
||||
*
|
||||
* Since issue #304 the operator's own uploaded families are listed too, with
|
||||
* the licence label and link recorded at upload. That is what makes an
|
||||
* attribution obligation satisfiable: many commercial font licences require
|
||||
* naming the foundry or the licence, and an operator who cannot point at such
|
||||
* a page cannot comply.
|
||||
*/
|
||||
export function FontCatalogPage(): React.JSX.Element {
|
||||
const { t } = useTranslation('font');
|
||||
useDocumentTitle(t('catalog.title'));
|
||||
const customFonts: CustomFontView[] = useCustomFonts();
|
||||
const customEntries: FontCatalogEntry[] = useCustomFontEntries();
|
||||
|
||||
return (
|
||||
<div className="font-catalog">
|
||||
<h1>{t('catalog.title')}</h1>
|
||||
<p>{t('catalog.intro')}</p>
|
||||
|
||||
<h2>{t('catalog.bundledHeading')}</h2>
|
||||
<p>{t('catalog.bundledIntro')}</p>
|
||||
<LicenceTable
|
||||
label={t('catalog.bundledHeading')}
|
||||
rows={FONT_CATALOG.map((font) => ({
|
||||
key: font.family,
|
||||
family: font.family,
|
||||
category: t(`category.${font.category}`),
|
||||
weights: font.weights,
|
||||
stack: fontStack(font.family),
|
||||
licence: (
|
||||
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
|
||||
{font.license}
|
||||
</a>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<h2>{t('catalog.customHeading')}</h2>
|
||||
<p>{t('catalog.customIntro')}</p>
|
||||
{customFonts.length === 0 ? (
|
||||
<p>{t('catalog.customEmpty')}</p>
|
||||
) : (
|
||||
<LicenceTable
|
||||
label={t('catalog.customHeading')}
|
||||
rows={customFonts.map((font) => ({
|
||||
key: font.id,
|
||||
family: font.family,
|
||||
category: t(`category.${font.category}`),
|
||||
weights: font.weights,
|
||||
stack: fontStack(font.family, customEntries),
|
||||
// A licence URL is optional — without one the label stands alone
|
||||
// rather than becoming a link to nowhere.
|
||||
licence: font.licenceUrl ? (
|
||||
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
|
||||
{font.licence}
|
||||
</a>
|
||||
) : (
|
||||
font.licence
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
140
apps/web/src/pages/InvitationsSection.tsx
Normal file
140
apps/web/src/pages/InvitationsSection.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import type { InvitationListView } from '@dorfteich/shared';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Field, FormError } from '../components/forms';
|
||||
import { apiDelete, apiGet, apiPost } from '../lib/api';
|
||||
|
||||
const INVITATIONS_QUERY_KEY = ['users', 'me', 'invitations'] as const;
|
||||
|
||||
/**
|
||||
* Peer invitations in the user settings (issue #332): invite an e-mail
|
||||
* address, see your invitations with their status, revoke open ones. The
|
||||
* quota line shows how many of the instance-wide per-user allowance are
|
||||
* in use; with a quota of 0 the section explains that inviting is off.
|
||||
*/
|
||||
export function InvitationsSection(): React.JSX.Element {
|
||||
const { t } = useTranslation('invitations');
|
||||
const queryClient = useQueryClient();
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: INVITATIONS_QUERY_KEY,
|
||||
queryFn: () => apiGet<InvitationListView>('/invitations'),
|
||||
});
|
||||
|
||||
const submit = async (event: React.FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSent(false);
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost('/invitations', { email });
|
||||
setEmail('');
|
||||
setSent(true);
|
||||
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: string): Promise<void> => {
|
||||
setError(null);
|
||||
await apiDelete(`/invitations/${id}`);
|
||||
await queryClient.invalidateQueries({ queryKey: INVITATIONS_QUERY_KEY });
|
||||
};
|
||||
|
||||
const data = list.data;
|
||||
const disabled = data?.maxOpen === 0;
|
||||
|
||||
return (
|
||||
<section className="settings-section invitations">
|
||||
<h2>{t('section.title')}</h2>
|
||||
{disabled ? (
|
||||
<p>{t('section.disabled')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="invitations__intro">{t('section.intro')}</p>
|
||||
{data && (
|
||||
<p className="invitations__quota">
|
||||
{t('section.quota', { open: data.open, max: data.maxOpen })}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={(e) => void submit(e)} noValidate className="invitations__form">
|
||||
<FormError error={error} />
|
||||
{/* Scoped status region: a bare getByRole('status') must stay
|
||||
unambiguous for other specs (lesson from #304/legal). */}
|
||||
<p className="invitations__sent" role="status">
|
||||
{sent ? t('form.sent') : ''}
|
||||
</p>
|
||||
<Field label={t('form.email')}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<button type="submit" className="button" disabled={busy || email.length === 0}>
|
||||
{t('form.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{data && data.invitations.length === 0 && <p>{t('section.empty')}</p>}
|
||||
{data && data.invitations.length > 0 && (
|
||||
<div
|
||||
className="table-scroll"
|
||||
// A scroll container is only operable by keyboard once it is
|
||||
// focusable; role+name keep it from being an unlabelled stop.
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={t('section.title')}
|
||||
>
|
||||
<table className="table invitations__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('columns.email')}</th>
|
||||
<th>{t('columns.status')}</th>
|
||||
<th>{t('columns.created')}</th>
|
||||
<th>{t('columns.expires')}</th>
|
||||
<th>{t('columns.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.invitations.map((invitation) => (
|
||||
<tr
|
||||
key={invitation.id}
|
||||
className="invitation-row"
|
||||
data-email={invitation.email}
|
||||
>
|
||||
<td>{invitation.email}</td>
|
||||
<td className="invitation-row__status">{t(`status.${invitation.status}`)}</td>
|
||||
<td>{new Date(invitation.createdAt).toLocaleDateString()}</td>
|
||||
<td>{new Date(invitation.expiresAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{invitation.status === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
className="linklike invitation-row__revoke"
|
||||
onClick={() => void revoke(invitation.id)}
|
||||
>
|
||||
{t('actions.revoke')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -227,7 +227,9 @@ function PageEditor({
|
||||
attributes: {
|
||||
role: canEdit ? 'textbox' : 'document',
|
||||
'aria-label': t('contentLabel'),
|
||||
...(canEdit ? { 'aria-multiline': 'true' } : {}),
|
||||
...(canEdit
|
||||
? { 'aria-multiline': 'true', 'aria-describedby': 'editor-keyboard-hint' }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -361,6 +363,13 @@ function PageEditor({
|
||||
/>
|
||||
)}
|
||||
<EditorContent editor={editor} className="editor-content" />
|
||||
{/* Referenced via aria-describedby in edit mode: Tab is captured
|
||||
inside tables (#338), so the way out must be discoverable. */}
|
||||
{canEdit && (
|
||||
<p id="editor-keyboard-hint" className="visually-hidden">
|
||||
{t('keyboardHint')}
|
||||
</p>
|
||||
)}
|
||||
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
||||
{canEdit && <MentionAutocomplete editor={editor} />}
|
||||
</div>
|
||||
|
||||
@ -21,6 +21,7 @@ import { StartPageSetting } from '../ponds/StartPageSetting';
|
||||
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
||||
import { MemberManager } from '../members/MemberManager';
|
||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||
import { PondBrandingSection } from '../ponds/PondBrandingSection';
|
||||
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||
import { PondThemeSection } from '../theme/PondThemeSection';
|
||||
|
||||
@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
const { t: tMembers } = useTranslation('members');
|
||||
const { t: tErrors } = useTranslation('errors');
|
||||
const { t: tFiles } = useTranslation('files');
|
||||
const { t: tBranding } = useTranslation('branding');
|
||||
const { t: tExport } = useTranslation('export');
|
||||
const { t: tComments } = useTranslation('comments');
|
||||
const { t: tApiTokens } = useTranslation('apiTokens');
|
||||
@ -114,6 +116,8 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
pondSlug={pondSlug}
|
||||
theme={pond.data.settings.theme}
|
||||
/>
|
||||
<h3>{tBranding('admin.title')}</h3>
|
||||
<PondBrandingSection pondId={pond.data.id} />
|
||||
</section>
|
||||
)}
|
||||
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
||||
|
||||
@ -138,21 +138,26 @@ function QuotaRow({
|
||||
<td>{t(`keys.${line.key}`)}</td>
|
||||
<td>{line.instanceDefault}</td>
|
||||
<td className="quota-row__override">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="quota-row__input"
|
||||
value={draft}
|
||||
onChange={(e) => onDraft(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="linklike" onClick={onSet}>
|
||||
{t('overrides.set')}
|
||||
</button>
|
||||
{line.override !== null && (
|
||||
<button type="button" className="linklike" onClick={onClear}>
|
||||
{t('overrides.clear')}
|
||||
{/* Flex lives on the inner div: a td with display:flex stops behaving
|
||||
like a table cell and its bottom border no longer meets the row's
|
||||
(same fix as the user list's actions cell, #177). */}
|
||||
<div className="quota-row__override-inner">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="quota-row__input"
|
||||
value={draft}
|
||||
onChange={(e) => onDraft(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="linklike" onClick={onSet}>
|
||||
{t('overrides.set')}
|
||||
</button>
|
||||
)}
|
||||
{line.override !== null && (
|
||||
<button type="button" className="linklike" onClick={onClear}>
|
||||
{t('overrides.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="quota-row__effective">{line.effective}</td>
|
||||
<td className="quota-row__usage">
|
||||
|
||||
@ -17,6 +17,7 @@ import { SettingsLayout } from '../components/SettingsLayout';
|
||||
import { useDataExport } from '../export/use-data-export';
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
|
||||
import { InvitationsSection } from './InvitationsSection';
|
||||
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
||||
import { WatchesSection } from '../watches/WatchesSection';
|
||||
|
||||
@ -45,6 +46,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
<PasswordSection />
|
||||
<SessionsSection />
|
||||
<WatchesSection />
|
||||
<InvitationsSection />
|
||||
<ApiTokensSection />
|
||||
<FeedTokensSection />
|
||||
<AppearanceSection />
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { AdminCreateUserFormInput, AdminUserListView, AdminUserView } from '@dorfteich/shared';
|
||||
import { adminCreateUserSchema } from '@dorfteich/shared';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { IconButton } from '../components/IconButton';
|
||||
import { Field, FormError, applyFieldErrors } from '../components/forms';
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||
import { useDismissable } from '../lib/use-dismissable';
|
||||
import { useModalFocus } from '../lib/use-modal-focus';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@ -21,6 +27,8 @@ export function UserManager(): React.JSX.Element {
|
||||
const { user: me } = useAuth();
|
||||
const [q, setQ] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const createButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin', 'users', q, page],
|
||||
@ -42,6 +50,24 @@ export function UserManager(): React.JSX.Element {
|
||||
return (
|
||||
<section className="settings-section user-manager">
|
||||
<h2>{t('title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
ref={createButtonRef}
|
||||
className="button user-manager__create"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
{t('create.button')}
|
||||
</button>
|
||||
{creating && (
|
||||
<CreateUserDialog
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={() => {
|
||||
setCreating(false);
|
||||
void query.refetch();
|
||||
}}
|
||||
returnFocusRef={createButtonRef}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
className="user-manager__search"
|
||||
type="search"
|
||||
@ -92,6 +118,98 @@ export function UserManager(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct account creation by a Site Admin (issue #331): same field rules as
|
||||
* self-registration, but the account is active immediately — no verification
|
||||
* mail hop. Field names stay flat (no dots) — react-hook-form treats dots as
|
||||
* path separators (#322).
|
||||
*/
|
||||
function CreateUserDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
returnFocusRef,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
returnFocusRef: React.RefObject<HTMLElement | null>;
|
||||
}): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('users');
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDismissable(dialogRef, true, onClose);
|
||||
useModalFocus(dialogRef, returnFocusRef);
|
||||
|
||||
const form = useForm<AdminCreateUserFormInput>({
|
||||
resolver: zodResolver(adminCreateUserSchema) as Resolver<AdminCreateUserFormInput>,
|
||||
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (input) => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost('/admin/users', input);
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
applyFieldErrors(err, (name, fieldError) =>
|
||||
form.setError(name as keyof AdminCreateUserFormInput, fieldError),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div
|
||||
className="modal create-user-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<h2 className="modal__title" id={titleId}>
|
||||
{t('create.title')}
|
||||
</h2>
|
||||
<p>{t('create.intro')}</p>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<FormError error={error} />
|
||||
<Field label={t('create.username')} error={form.formState.errors.username?.message}>
|
||||
<input type="text" autoComplete="off" {...form.register('username')} />
|
||||
</Field>
|
||||
<Field label={t('create.email')} error={form.formState.errors.email?.message}>
|
||||
<input type="email" autoComplete="off" {...form.register('email')} />
|
||||
</Field>
|
||||
<Field label={t('create.displayName')} error={form.formState.errors.displayName?.message}>
|
||||
<input type="text" autoComplete="off" {...form.register('displayName')} />
|
||||
</Field>
|
||||
<Field
|
||||
label={t('create.password')}
|
||||
hint={t('create.passwordHint')}
|
||||
error={form.formState.errors.password?.message}
|
||||
>
|
||||
<input type="password" autoComplete="new-password" {...form.register('password')} />
|
||||
</Field>
|
||||
<Field label={t('create.locale')}>
|
||||
<select {...form.register('locale')}>
|
||||
<option value="de">{t('create.localeDe')}</option>
|
||||
<option value="en">{t('create.localeEn')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<div className="modal__actions">
|
||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||
{t('create.submit')}
|
||||
</button>
|
||||
<button type="button" className="linklike" onClick={onClose}>
|
||||
{t('create.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
|
||||
55
apps/web/src/pages/admin-settings-form.test.ts
Normal file
55
apps/web/src/pages/admin-settings-form.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GENERAL_FORM_FIELDS,
|
||||
GeneralSettingsForm,
|
||||
toFormValues,
|
||||
toSettingsPatch,
|
||||
} from './admin-settings-form';
|
||||
|
||||
describe('admin general settings form model (issue #322)', () => {
|
||||
// The regression this file exists for: a dotted field name makes
|
||||
// react-hook-form nest the typed value and the strict PATCH schema
|
||||
// reject the body — the form then looks fine but never saves.
|
||||
it('uses no dots in any form field name', () => {
|
||||
for (const field of Object.keys(GENERAL_FORM_FIELDS)) {
|
||||
expect(field).not.toContain('.');
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips settings through form values back to a flat patch', () => {
|
||||
const settings = {
|
||||
'instance.name': 'My Wiki',
|
||||
'instance.defaultLocale': 'de',
|
||||
'auth.registrationMode': 'closed',
|
||||
'invitations.maxOpenPerUser': 5,
|
||||
'classification.newPageDefault': 'unclassified',
|
||||
'classification.uploadPolicy': 'warn',
|
||||
'quota.editorsPerPond': 5,
|
||||
'quota.readersPerPond': 50,
|
||||
'quota.additionalPonds': 0,
|
||||
'quota.storageBytes': 1024,
|
||||
'quota.maxFileBytes': 25,
|
||||
};
|
||||
expect(toSettingsPatch(toFormValues(settings))).toEqual(settings);
|
||||
});
|
||||
|
||||
it('patches only the settings this form edits, under their dotted keys', () => {
|
||||
const input: GeneralSettingsForm = {
|
||||
instanceName: 'Renamed',
|
||||
defaultLocale: 'en',
|
||||
registrationMode: 'open',
|
||||
invitationsMaxOpenPerUser: 5,
|
||||
newPageClassification: 'vs_nfd',
|
||||
uploadPolicy: 'block',
|
||||
quotaEditorsPerPond: 1,
|
||||
quotaReadersPerPond: 2,
|
||||
quotaAdditionalPonds: 3,
|
||||
quotaStorageBytes: 4,
|
||||
quotaMaxFileBytes: 5,
|
||||
};
|
||||
const patch = toSettingsPatch(input);
|
||||
expect(patch['instance.name']).toBe('Renamed');
|
||||
expect(Object.keys(patch).sort()).toEqual(Object.values(GENERAL_FORM_FIELDS).slice().sort());
|
||||
});
|
||||
});
|
||||
62
apps/web/src/pages/admin-settings-form.ts
Normal file
62
apps/web/src/pages/admin-settings-form.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Form model of the general + quota cards on the admin settings page.
|
||||
*
|
||||
* Field names MUST NOT contain dots: react-hook-form treats a dot in a
|
||||
* field name as a nested-path separator. A field registered under its
|
||||
* settings key ('instance.name') DISPLAYS fine — RHF's getter falls back
|
||||
* to the literal flat key — but typing writes the value into a nested
|
||||
* object ({ instance: { name } }), which the api's strict PATCH schema
|
||||
* rejects, so nothing ever saved (issue #322). This mapping is the single
|
||||
* place tying a dot-free field name to its dotted settings key; the
|
||||
* converters below translate in both directions.
|
||||
*/
|
||||
|
||||
export const GENERAL_FORM_FIELDS = {
|
||||
instanceName: 'instance.name',
|
||||
defaultLocale: 'instance.defaultLocale',
|
||||
registrationMode: 'auth.registrationMode',
|
||||
invitationsMaxOpenPerUser: 'invitations.maxOpenPerUser',
|
||||
newPageClassification: 'classification.newPageDefault',
|
||||
uploadPolicy: 'classification.uploadPolicy',
|
||||
quotaEditorsPerPond: 'quota.editorsPerPond',
|
||||
quotaReadersPerPond: 'quota.readersPerPond',
|
||||
quotaAdditionalPonds: 'quota.additionalPonds',
|
||||
quotaStorageBytes: 'quota.storageBytes',
|
||||
quotaMaxFileBytes: 'quota.maxFileBytes',
|
||||
} as const;
|
||||
|
||||
export type GeneralFormField = keyof typeof GENERAL_FORM_FIELDS;
|
||||
export type GeneralFormSettingKey = (typeof GENERAL_FORM_FIELDS)[GeneralFormField];
|
||||
|
||||
export interface GeneralSettingsForm {
|
||||
instanceName: string;
|
||||
defaultLocale: 'de' | 'en';
|
||||
registrationMode: 'open' | 'closed';
|
||||
invitationsMaxOpenPerUser: number;
|
||||
newPageClassification: 'unclassified' | 'vs_nfd';
|
||||
uploadPolicy: 'warn' | 'block';
|
||||
quotaEditorsPerPond: number;
|
||||
quotaReadersPerPond: number;
|
||||
quotaAdditionalPonds: number;
|
||||
quotaStorageBytes: number;
|
||||
quotaMaxFileBytes: number;
|
||||
}
|
||||
|
||||
/** The settings this form reads and writes, keyed by their dotted names. */
|
||||
export type GeneralFormSettings = Record<GeneralFormSettingKey, unknown>;
|
||||
|
||||
export function toFormValues(settings: GeneralFormSettings): GeneralSettingsForm {
|
||||
return Object.fromEntries(
|
||||
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [field, settings[key]]),
|
||||
) as unknown as GeneralSettingsForm;
|
||||
}
|
||||
|
||||
/** Flat dotted keys, exactly what PATCH /admin/settings expects. */
|
||||
export function toSettingsPatch(input: GeneralSettingsForm): GeneralFormSettings {
|
||||
return Object.fromEntries(
|
||||
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [
|
||||
key,
|
||||
input[field as GeneralFormField],
|
||||
]),
|
||||
) as GeneralFormSettings;
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { SignupFormInput, signupInputSchema } from '@dorfteich/shared';
|
||||
import { InvitationPreview, SignupFormInput, signupInputSchema } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { Field, FormError, applyFieldErrors } from '../../components/forms';
|
||||
import { apiGet, apiPost } from '../../lib/api';
|
||||
@ -21,6 +21,19 @@ export function SignupPage(): React.JSX.Element {
|
||||
queryFn: () => apiGet<{ mode: 'open' | 'closed' }>('/auth/registration'),
|
||||
});
|
||||
|
||||
// An invitation link (issue #332) carries ?invitation=<token>: a valid
|
||||
// one lets this signup through even while registration is closed.
|
||||
const [searchParams] = useSearchParams();
|
||||
const invitationToken = searchParams.get('invitation');
|
||||
const invitation = useQuery({
|
||||
queryKey: ['invitation-preview', invitationToken],
|
||||
queryFn: () => apiPost<InvitationPreview>('/invitations/preview', { token: invitationToken }),
|
||||
enabled: Boolean(invitationToken),
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const invited = Boolean(invitationToken) && invitation.isSuccess;
|
||||
|
||||
type SignupFormValues = SignupFormInput;
|
||||
// One confined cast: RHF cannot express Zod's input/output split
|
||||
// (locale is optional on input, defaulted on output) without it.
|
||||
@ -29,10 +42,22 @@ export function SignupPage(): React.JSX.Element {
|
||||
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
|
||||
});
|
||||
|
||||
// Prefill the invited address; it stays editable (the mailbox is
|
||||
// verified separately either way).
|
||||
const setValue = form.setValue;
|
||||
useEffect(() => {
|
||||
if (invitation.data) setValue('email', invitation.data.email);
|
||||
}, [invitation.data, setValue]);
|
||||
|
||||
const onSubmit = form.handleSubmit(async (input) => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost('/auth/signup', input);
|
||||
// Only a previewed-valid token rides along; a broken link falls
|
||||
// back to a plain signup instead of failing the whole form.
|
||||
await apiPost('/auth/signup', {
|
||||
...input,
|
||||
invitationToken: invited ? invitationToken : undefined,
|
||||
});
|
||||
setRegistered(input.email);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
@ -42,10 +67,25 @@ export function SignupPage(): React.JSX.Element {
|
||||
}
|
||||
});
|
||||
|
||||
if (registration.data?.mode === 'closed') {
|
||||
// With a pending invitation check, neither the closed screen nor the
|
||||
// form should flash — hold the decision until the preview settles.
|
||||
if (registration.data?.mode === 'closed' && invitationToken && invitation.isPending) {
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (registration.data?.mode === 'closed' && !invited) {
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
{invitationToken && invitation.isError && (
|
||||
<p className="form-banner form-banner--error signup-invitation__invalid">
|
||||
{t('invitations:signup.invalid')}
|
||||
</p>
|
||||
)}
|
||||
<p className="form-banner">{t('auth:signup.closed')}</p>
|
||||
<p className="auth-card__links">
|
||||
<Link to="/login">{t('auth:signup.loginLink')}</Link>
|
||||
@ -74,6 +114,19 @@ export function SignupPage(): React.JSX.Element {
|
||||
<div className="auth-card">
|
||||
<h1>{t('auth:signup.title')}</h1>
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
{invited && invitation.data && (
|
||||
<p className="form-banner signup-invitation__banner">
|
||||
{t('invitations:signup.banner', {
|
||||
inviterName: invitation.data.inviterName,
|
||||
email: invitation.data.email,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{invitationToken && invitation.isError && (
|
||||
<p className="form-banner form-banner--error signup-invitation__invalid">
|
||||
{t('invitations:signup.invalid')}
|
||||
</p>
|
||||
)}
|
||||
<FormError error={error} />
|
||||
<Field
|
||||
label={t('auth:signup.username')}
|
||||
|
||||
@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiDelete } from '../lib/api';
|
||||
import { PondArchiveOffer } from './PondArchiveOffer';
|
||||
|
||||
/**
|
||||
* The pond's danger zone: move a shared pond to the site-level trash
|
||||
@ -45,6 +46,7 @@ export function DeletePondSection({
|
||||
<section className="pond-delete">
|
||||
<h2>{t('pond.delete.title')}</h2>
|
||||
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
|
||||
<PondArchiveOffer pondId={pondId} />
|
||||
<form onSubmit={(e) => void remove(e)}>
|
||||
<FormError error={error} />
|
||||
<label>
|
||||
|
||||
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
63
apps/web/src/ponds/PondArchiveOffer.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { PondArchivePreview } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { apiGet } from '../lib/api';
|
||||
|
||||
/**
|
||||
* The last full archive, offered INSIDE the deletion flow (issue #305).
|
||||
*
|
||||
* The gap this closes is not a missing prompt — the type-to-confirm field
|
||||
* next to it is stricter than a dialog. It is that the person who deletes the
|
||||
* pond loses access the moment they do: the pond disappears from their view,
|
||||
* only a Site Admin can bring it back, and the export is no longer reachable
|
||||
* for them. So the offer has to be here, before the button, not afterwards.
|
||||
*
|
||||
* Not downloading is allowed. A pond full of test pages should not require a
|
||||
* download, and the server cannot tell whether a file actually arrived — so
|
||||
* the finality is stated in text instead of enforced.
|
||||
*/
|
||||
export function PondArchiveOffer({ pondId }: { pondId: string }): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [started, setStarted] = useState(false);
|
||||
|
||||
const preview = useQuery({
|
||||
queryKey: ['pond', pondId, 'archive-preview'],
|
||||
queryFn: () => apiGet<PondArchivePreview>(`/ponds/${pondId}/archive/preview`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pond-archive">
|
||||
<p>{t('pond.archive.intro')}</p>
|
||||
{/* An archive silently missing content is worse than no archive: it ends
|
||||
the search. So the omission is named BEFORE the download, with its
|
||||
number, not implied afterwards. */}
|
||||
{preview.data && !preview.data.complete && (
|
||||
<p className="pond-archive__warning">
|
||||
<span aria-hidden="true">⚠ </span>
|
||||
{t('pond.archive.omitted', { count: preview.data.omittedPages })}
|
||||
</p>
|
||||
)}
|
||||
<p>{t('pond.archive.noImport')}</p>
|
||||
{/*
|
||||
A plain link, not fetch-into-a-blob: the api streams the ZIP, and
|
||||
buffering a whole pond in the tab to show a progress bar would trade
|
||||
memory for cosmetics. The browser's own download UI reports progress
|
||||
and completion; what it cannot say is that the archive is being BUILT,
|
||||
so the live region below says that.
|
||||
*/}
|
||||
<a
|
||||
className="button pond-archive__download"
|
||||
href={`/api/v1/ponds/${pondId}/archive`}
|
||||
onClick={() => setStarted(true)}
|
||||
>
|
||||
{t('pond.archive.download')}
|
||||
</a>
|
||||
<p role="status" className="pond-archive__status">
|
||||
{started ? t('pond.archive.started') : ''}
|
||||
</p>
|
||||
<p className="pond-archive__finality">{t('pond.archive.finality')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
apps/web/src/ponds/PondBrandingSection.tsx
Normal file
234
apps/web/src/ponds/PondBrandingSection.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
import { LogoVariant, MAX_LOGO_EDGE, PondBranding } from '@dorfteich/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CropField } from '../branding/CropField';
|
||||
import { canvasToPngFile, drawCrop } from '../branding/crop';
|
||||
import { logoUrl } from '../branding/use-branding';
|
||||
import { FormError, FormSuccess } from '../components/forms';
|
||||
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
|
||||
|
||||
const FAVICON_SIZES = [32, 180] as const;
|
||||
|
||||
/**
|
||||
* A pond's own logo and favicon (issue #307).
|
||||
*
|
||||
* Reuses the instance screen's crop control rather than growing a second,
|
||||
* drag-only one: the uploader here is an ordinary Pond Admin, and the
|
||||
* keyboard operability is not theirs to lose.
|
||||
*/
|
||||
export function PondBrandingSection({ pondId }: { pondId: string }): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const branding = useQuery({
|
||||
queryKey: ['pond', pondId, 'branding'],
|
||||
queryFn: () => apiGet<PondBranding>(`/ponds/${pondId}/branding`),
|
||||
});
|
||||
const view = branding.data;
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'branding'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="branding pond-branding">
|
||||
<p>{t('pond.intro')}</p>
|
||||
<p>{t('pond.quotaNote')}</p>
|
||||
|
||||
<PondLogoSlot
|
||||
variant="light"
|
||||
pondId={pondId}
|
||||
asset={view?.logo ?? null}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
<PondLogoSlot
|
||||
variant="dark"
|
||||
pondId={pondId}
|
||||
asset={view?.logoDark ?? null}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
{view?.logo && !view.logoDark && (
|
||||
// Advisory, exactly as on the instance screen: a pond that uploads
|
||||
// only a light logo shows THAT logo in dark mode — it does not borrow
|
||||
// the instance's dark one. Nothing is blocked.
|
||||
<p className="branding__warning" role="note">
|
||||
<span aria-hidden="true">⚠ </span>
|
||||
{t('pond.darkMissing')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PondFaviconSlot pondId={pondId} present={Boolean(view?.favicon)} onChanged={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PondLogoSlot({
|
||||
pondId,
|
||||
variant,
|
||||
asset,
|
||||
onChanged,
|
||||
}: {
|
||||
pondId: string;
|
||||
variant: LogoVariant;
|
||||
asset: { hash: string; width: number; height: number } | null;
|
||||
onChanged: () => Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) throw new Error('no image');
|
||||
const form = new FormData();
|
||||
form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`));
|
||||
return apiPostForm<PondBranding>(`/ponds/${pondId}/branding/logo?variant=${variant}`, form);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setDone(true);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const reset = useMutation({
|
||||
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/logo?variant=${variant}`),
|
||||
onSuccess: async () => {
|
||||
setDone(false);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="branding__slot" data-pond-logo-variant={variant}>
|
||||
<h3>{t(`admin.logo.${variant}`)}</h3>
|
||||
<FormError error={upload.error ?? reset.error} />
|
||||
<FormSuccess message={done ? t('admin.logo.saved') : null} />
|
||||
{asset ? (
|
||||
<div className="branding__current">
|
||||
<img
|
||||
src={logoUrl(variant, asset.hash, pondId)}
|
||||
alt={t('admin.logo.currentAlt')}
|
||||
className={`branding__preview branding__preview--${variant}`}
|
||||
/>
|
||||
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
|
||||
{/* A real, labelled control — not an empty file field standing in
|
||||
for "remove". */}
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() => reset.mutate()}
|
||||
disabled={reset.isPending}
|
||||
>
|
||||
{t('pond.reset')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p>{t('pond.inherited')}</p>
|
||||
)}
|
||||
<CropField
|
||||
idPrefix={`pond-logo-${variant}`}
|
||||
square={false}
|
||||
maxEdge={MAX_LOGO_EDGE}
|
||||
onChange={(canvas) => {
|
||||
canvasRef.current = canvas;
|
||||
setDone(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => upload.mutate()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
{t('admin.logo.submit')}
|
||||
</button>
|
||||
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PondFaviconSlot({
|
||||
pondId,
|
||||
present,
|
||||
onChanged,
|
||||
}: {
|
||||
pondId: string;
|
||||
present: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('branding');
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async () => {
|
||||
const source = canvasRef.current;
|
||||
if (!source) throw new Error('no image');
|
||||
const form = new FormData();
|
||||
for (const size of FAVICON_SIZES) {
|
||||
const scratch = document.createElement('canvas');
|
||||
drawCrop(
|
||||
source,
|
||||
{ x: 0, y: 0, width: source.width, height: source.height },
|
||||
{ width: size, height: size },
|
||||
scratch,
|
||||
);
|
||||
form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`));
|
||||
}
|
||||
return apiPostForm<PondBranding>(`/ponds/${pondId}/branding/favicon`, form);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setDone(true);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const reset = useMutation({
|
||||
mutationFn: () => apiDelete(`/ponds/${pondId}/branding/favicon`),
|
||||
onSuccess: async () => {
|
||||
setDone(false);
|
||||
await onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="branding__slot" data-branding-slot="pond-favicon">
|
||||
<h3>{t('admin.favicon.title')}</h3>
|
||||
<p>{t('pond.faviconHint')}</p>
|
||||
<FormError error={upload.error ?? reset.error} />
|
||||
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
|
||||
<p>{present ? t('admin.favicon.present') : t('pond.inherited')}</p>
|
||||
{present && (
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() => reset.mutate()}
|
||||
disabled={reset.isPending}
|
||||
>
|
||||
{t('pond.reset')}
|
||||
</button>
|
||||
)}
|
||||
<CropField
|
||||
idPrefix="pond-favicon"
|
||||
square
|
||||
maxEdge={180}
|
||||
onChange={(canvas) => {
|
||||
canvasRef.current = canvas;
|
||||
setDone(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => upload.mutate()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
{t('admin.favicon.submit')}
|
||||
</button>
|
||||
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1732,6 +1732,42 @@ button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Gap cursor (issue #335): the blinking bar prosemirror-gapcursor renders at
|
||||
positions adjacent to block nodes without a text position of their own
|
||||
(e.g. a table as the page's only block). The upstream package does not
|
||||
ship its stylesheet through our import path, so these rules replace it. */
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid var(--color-text);
|
||||
animation: dt-gapcursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes dt-gapcursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ProseMirror-gapcursor::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content h1,
|
||||
.editor-content h2,
|
||||
.editor-content h3,
|
||||
@ -3048,7 +3084,7 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.quota-row__override {
|
||||
.quota-row__override-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
@ -4109,3 +4145,201 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Site-Admin font management (issue #304). The layout stays a plain column so
|
||||
the section reflows at 320px without its own rules (#301). */
|
||||
.custom-fonts__list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.custom-fonts__item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.custom-fonts__name {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-fonts__meta {
|
||||
color: var(--color-text-muted);
|
||||
margin: var(--space-1) 0 var(--space-2);
|
||||
}
|
||||
|
||||
.custom-fonts__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.custom-fonts__weights {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-3);
|
||||
margin: var(--space-3) 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.custom-fonts__weight {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.custom-fonts__weight:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.custom-fonts__confirm,
|
||||
.custom-fonts__add-weight {
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* A file input is as wide as its filename; without this it pushes the page
|
||||
at 320px (issue #301's lesson, applied ahead of the fact). */
|
||||
.custom-fonts input[type='file'] {
|
||||
max-width: 100%;
|
||||
}
|
||||
/* Instance branding (issue #306). The logo sits above the pond name in the
|
||||
sidebar; the two variants are both rendered and one is hidden here rather
|
||||
than in JavaScript, so the right one is the one PAINTED — theme-init.js has
|
||||
already resolved data-theme when this applies. */
|
||||
.brand-logo {
|
||||
display: block;
|
||||
padding: var(--space-2) 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo__img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
/* A tall logo must not push the pond name out of view. */
|
||||
max-height: 3rem;
|
||||
}
|
||||
|
||||
.brand-logo__name {
|
||||
font-weight: var(--font-weight-heading);
|
||||
}
|
||||
|
||||
/* Without a dark variant the light logo carries both themes (#306/#307:
|
||||
variants are never mixed across levels). */
|
||||
.brand-logo__img--dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .brand-logo__img--light:not(.brand-logo__img--both) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .brand-logo__img--dark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.branding__slot {
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branding__preview {
|
||||
max-width: 100%;
|
||||
max-height: 6rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* The dark logo is meant for a dark surface — previewing it on the light
|
||||
admin background would misrepresent it. */
|
||||
.branding__preview--dark {
|
||||
background: #10161d;
|
||||
padding: var(--space-2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.branding__warning {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.crop-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crop-field input[type='file'] {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.crop-field__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.crop-field__controls .field {
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.crop-field__canvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
/* A transparent PNG on a transparent page shows nothing — the checkerboard
|
||||
is how an operator sees that the background really is transparent. */
|
||||
background-color: var(--color-surface);
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--color-surface-muted) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--color-surface-muted) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--color-surface-muted) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--color-surface-muted) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
}
|
||||
|
||||
/* Full-archive offer inside the pond deletion flow (issue #305). Plain
|
||||
column: the section must reflow at 320px without rules of its own (#301). */
|
||||
.pond-archive {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pond-archive__warning {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.pond-archive__download {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pond-archive__finality {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@ -2,14 +2,17 @@
|
||||
# next to docker-compose.yml and adjust the values.
|
||||
|
||||
# --- required ---------------------------------------------------------------
|
||||
# PostgreSQL password for the `dorfteich` database user.
|
||||
# PostgreSQL password for the `dorfteich` database user. URL-SAFE
|
||||
# characters only (generate with `openssl rand -hex 24`): the compose file
|
||||
# interpolates it into DATABASE_URL unescaped, so base64's `/`, `+`, `=`
|
||||
# break the URL — db stays healthy while api/collab/backup restart-loop.
|
||||
POSTGRES_PASSWORD=change-me
|
||||
|
||||
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
|
||||
# purpose (collaboration tokens, digest unsubscribe links) derives its own
|
||||
# HKDF subkey from this value — nothing signs with it directly. The api and
|
||||
# collab services share this one value; use a long random string
|
||||
# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all
|
||||
# (e.g. `openssl rand -hex 24`). Min length 16. Rotating it rotates all
|
||||
# derived keys at once and invalidates outstanding tokens.
|
||||
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
|
||||
|
||||
@ -52,7 +55,7 @@ COMPOSE_PROJECT_NAME=dorfteich
|
||||
# --- public URL + mail --------------------------------------------------------
|
||||
# Public base URL of the stage (scheme + host). E-mail links and the CSRF
|
||||
# origin check are derived from it — it must match what browsers use.
|
||||
APP_BASE_URL=https://test.dorfteich.cloud
|
||||
APP_BASE_URL=https://wiki.example.com
|
||||
|
||||
# SMTP relay for outgoing mail (verification, password reset). Optional:
|
||||
# leave everything unset and configure the relay in the browser during the
|
||||
@ -76,10 +79,36 @@ SMTP_FROM=Dorfteich <wiki@example.com>
|
||||
#CADDY_HTTP_PORT=80
|
||||
#CADDY_HTTPS_PORT=443
|
||||
|
||||
# --- external authentication (issues #214–#216, ADR 0021) ---------------------
|
||||
# Deploy-level on purpose — a Site Admin cannot change these. All unset =
|
||||
# local username/password login only. Full reference:
|
||||
# docs/architecture/security.md §External authentication.
|
||||
# OIDC (Authorization Code + PKCE) is enabled iff ISSUER + CLIENT_ID are set;
|
||||
# CLIENT_SECRET stays empty for a public client. Redirect URI to register at
|
||||
# the IdP: $APP_BASE_URL/api/v1/auth/oidc/callback
|
||||
#OIDC_ISSUER=https://idp.example.com/realms/example
|
||||
#OIDC_CLIENT_ID=dorfteich-web
|
||||
#OIDC_CLIENT_SECRET=
|
||||
#OIDC_SCOPES=openid profile email
|
||||
#OIDC_PROVIDER_LABEL=Single Sign-On
|
||||
# Hard switch (#216): false turns EVERY local credential flow off (404) —
|
||||
# sign-in only via OIDC or the trusted proxy. Complete the first-run setup
|
||||
# BEFORE flipping it.
|
||||
#AUTH_LOCAL_ENABLED=false
|
||||
# Perimeter authentication (#215): identity from a proxy header, honoured
|
||||
# only when the TCP peer is on the allowlist; the proxy MUST strip the
|
||||
# header from incoming traffic. Unset = feature off, the header is inert.
|
||||
#AUTH_PROXY_HEADER=X-Auth-User
|
||||
#AUTH_PROXY_TRUSTED_PEERS=10.0.0.5
|
||||
#AUTH_PROXY_MAP=username
|
||||
#AUTH_PROXY_MODE=plain
|
||||
#AUTH_PROXY_DN_ATTRIBUTE=CN
|
||||
|
||||
# --- backups (ADR 0015, issue #83) --------------------------------------------
|
||||
# The backup sidecar dumps the database and archives the uploads/plugins
|
||||
# volumes nightly onto the `backups` volume; restore via
|
||||
# deploy/backup/restore.sh <backup-id>. All values optional.
|
||||
# The backup sidecar dumps the database and archives the data volumes
|
||||
# (uploads, plugins, custom fonts, branding) nightly onto the `backups`
|
||||
# volume; restore via deploy/backup/restore.sh <backup-id>. All values
|
||||
# optional.
|
||||
# Daily run time HH:MM in TZ (default 03:00; set TZ for stage-local time,
|
||||
# e.g. TZ=Europe/Berlin — unset means UTC).
|
||||
#TZ=Europe/Berlin
|
||||
@ -129,6 +158,9 @@ SMTP_FROM=Dorfteich <wiki@example.com>
|
||||
# wizard. Automated deploys can skip it entirely by pre-seeding the Site
|
||||
# Admin here; the wizard then completes and locks itself at first boot.
|
||||
# All three SETUP_ADMIN_* values are required for pre-seeding to trigger.
|
||||
# The wizard's validation applies: the password needs at least 10
|
||||
# characters — a violation fails the boot with a message naming the
|
||||
# variable (deliberate: no half-seeded instance).
|
||||
#SETUP_ADMIN_USERNAME=admin
|
||||
#SETUP_ADMIN_EMAIL=admin@example.com
|
||||
#SETUP_ADMIN_PASSWORD=change-me-please
|
||||
|
||||
@ -61,6 +61,21 @@ services:
|
||||
# VS-NfD hardening-profile mode (issue #243, ADR 0027):
|
||||
# off | marked | hidden | enforced. Empty = off — no marking anywhere.
|
||||
VS_NFD_MODE: ${VS_NFD_MODE:-}
|
||||
# External authentication (issues #214–#216, ADR 0021) — deploy-level
|
||||
# on purpose, out of a Site Admin's reach. All empty = local
|
||||
# username/password login only. Reference: security.md §External
|
||||
# authentication.
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES:-}
|
||||
OIDC_PROVIDER_LABEL: ${OIDC_PROVIDER_LABEL:-}
|
||||
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-}
|
||||
AUTH_PROXY_HEADER: ${AUTH_PROXY_HEADER:-}
|
||||
AUTH_PROXY_TRUSTED_PEERS: ${AUTH_PROXY_TRUSTED_PEERS:-}
|
||||
AUTH_PROXY_MAP: ${AUTH_PROXY_MAP:-}
|
||||
AUTH_PROXY_MODE: ${AUTH_PROXY_MODE:-}
|
||||
AUTH_PROXY_DN_ATTRIBUTE: ${AUTH_PROXY_DN_ATTRIBUTE:-}
|
||||
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
|
||||
# the relay to the secret store on the `secrets` volume (issue #80);
|
||||
# values set here in the stage .env always win over the store.
|
||||
@ -91,6 +106,9 @@ services:
|
||||
# uploads/plugins so all three travel in one restore set — NOT inside
|
||||
# the image-baked font catalog, which a deploy would overwrite.
|
||||
CUSTOM_FONTS_DIR: /data/fonts
|
||||
# Instance and pond branding assets (issues #306/#307) — same reasoning
|
||||
# as the fonts above: operator data, so its own volume in the restore set.
|
||||
BRANDING_DIR: /data/branding
|
||||
# Read-only view of the backup sidecar's volume — the api only consumes
|
||||
# its status.json (readyz freshness #85, admin backup card #86).
|
||||
BACKUPS_DIR: /data/backups
|
||||
@ -105,6 +123,7 @@ services:
|
||||
- uploads:/data/uploads
|
||||
- plugins:/data/plugins
|
||||
- customfonts:/data/fonts
|
||||
- branding:/data/branding
|
||||
- secrets:/data/secrets
|
||||
- backups:/data/backups:ro
|
||||
depends_on:
|
||||
@ -194,6 +213,7 @@ services:
|
||||
- uploads:/data/uploads
|
||||
- plugins:/data/plugins
|
||||
- customfonts:/data/fonts
|
||||
- branding:/data/branding
|
||||
- secrets:/data/secrets:ro
|
||||
- backups:/backups
|
||||
depends_on:
|
||||
@ -280,6 +300,7 @@ volumes:
|
||||
uploads:
|
||||
plugins:
|
||||
customfonts:
|
||||
branding:
|
||||
secrets:
|
||||
backups:
|
||||
# Only used by the optional `caddy` profile (certificates + state).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user