Add non-image attachments with allowlist, SVG policy, and file managers (#61)
All checks were successful
CD / Build and push images (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 2m46s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s

Extend uploads (#27, ADR 0011) beyond images to a configurable general
attachment allowlist, plus the page attachments section and the Pond Admin
file manager.

Backend:
- Two instance settings: `upload.allowedExtensions` (lowercase, dot-stripped,
  images always allowed regardless) and `upload.svgPolicy` (reject | sanitize).
- FilesService.resolveUpload: raster images still decided by magic bytes; SVG
  is sanitized with DOMPurify (scripts, event handlers, foreignObject stripped)
  or rejected per policy; everything else is admitted only if its extension is
  on the allowlist. A sanitized SVG's stored bytes are re-accounted so
  pond_usage matches disk.
- Downloads set `Content-Disposition: attachment` for every non-raster type
  (office files, PDFs, SVG) with `nosniff`, so they can never execute inline;
  raster images stay inline for page embeds.
- New endpoints: `GET /ponds/:id/files` (pond_admin: all files + usage + orphan
  flag), `POST /pages/:id/files` and `GET /pages/:id/files` (page-write/read:
  the attachments section). New error code `upload_type_not_allowed` (de+en).

Frontend:
- Page attachments section (AttachmentsPanel): upload, list with type glyph,
  size, and uploader, insert-as-link into the document (an internal media link
  that downloads, never renders inline), and delete. Toggled in the editor.
- Pond file manager (PondFileManager) in pond settings for Pond Admins: every
  file with its referencing page (or an orphan flag) and storage usage.
- Admin uploads settings form (allowlist + SVG policy). New `files` i18n
  namespace (de+en).

Tests:
- files.e2e.db.test.ts: allowlisted non-image accepted and served as a
  download; disallowed extension rejected; renamed-.html-as-.png still fails;
  SVG sanitized (scripts/handlers stripped) and reject-mode rejects; page
  attachment listing; pond file manager usage/orphan; non-admin denied.
- New e2e pack apps/web/e2e/attachments.spec.ts (+ CI step): upload → list →
  insert link (verified attachment disposition + nosniff), disallowed-type
  error, pond file manager usage/orphan.

Local: typecheck, lint, i18n:check, build all green; api-db 184, shared 121,
web 50; attachments pack 3/3, members 3/3, content 5/5. Adds dompurify + jsdom
to the api for server-side SVG sanitization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-10 02:52:40 +02:00
parent ae8cdd0e1e
commit 30891f99cf
24 changed files with 1166 additions and 32 deletions

View File

@ -228,6 +228,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/permission-matrix.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/permission-matrix.spec.ts
- name: Reset login rate limit before attachments 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 attachments pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/attachments.spec.ts
- name: Reset login rate limit before offline pack - name: Reset login rate limit before offline pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -23,8 +23,10 @@
"@prisma/client": "^6.3.0", "@prisma/client": "^6.3.0",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dompurify": "^3.4.11",
"fractional-indexing": "^4.0.0", "fractional-indexing": "^4.0.0",
"i18next": "^26.3.4", "i18next": "^26.3.4",
"jsdom": "^26.1.0",
"multer": "^2.1.1", "multer": "^2.1.1",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
@ -47,6 +49,7 @@
"@swc/core": "^1.10.0", "@swc/core": "^1.10.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jsdom": "^28.0.3",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",

View File

@ -13,18 +13,24 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { AttachmentView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared'; import {
AttachmentListItemView,
AttachmentView,
MAX_UPLOAD_PARSE_BYTES,
PondFilesView,
} from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard'; import { AuthedRequest, Public } from '../auth/auth.guard';
import { import {
RequiresAttachmentPermission, RequiresAttachmentPermission,
RequiresPagePermission,
RequiresPondRole, RequiresPondRole,
} from '../permissions/permission.decorators'; } from '../permissions/permission.decorators';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
/** File storage and image-upload API (issue #27). */ /** File storage, upload, and attachment listing API (issue #27, #61). */
@Controller() @Controller()
export class FilesController { export class FilesController {
constructor(private readonly files: FilesService) {} constructor(private readonly files: FilesService) {}
@ -41,11 +47,40 @@ export class FilesController {
return this.files.upload(request.user!, pondId, file); return this.files.upload(request.user!, pondId, file);
} }
/** All files in a pond, for the Pond Admin file manager (#61). */
@Get('ponds/:pondId/files')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
listPondFiles(@Param('pondId') pondId: string): Promise<PondFilesView> {
return this.files.listForPond(pondId);
}
/** Upload an attachment linked to a page — its attachments section (#61). */
@Post('pages/:pageId/files')
@RequiresPagePermission('write', { idParam: 'pageId' })
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } }))
async uploadToPage(
@Param('pageId') pageId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Req() request: AuthedRequest,
): Promise<AttachmentView> {
if (!file) throw new BadRequestException({ code: 'bad_request' });
return this.files.uploadToPage(request.user!, pageId, file);
}
/** Attachments linked to a page, for its attachments section (#61). */
@Get('pages/:pageId/files')
@RequiresPagePermission('read', { idParam: 'pageId' })
listPageFiles(@Param('pageId') pageId: string): Promise<AttachmentListItemView[]> {
return this.files.listForPage(pageId);
}
/** /**
* Permission-checked file streaming (ADR 0011) never served same-origin as * Permission-checked file streaming (ADR 0011) never served same-origin as
* executable content. `@Public()` so embedded images on a public page load * executable content. `@Public()` so embedded images on a public page load
* for anonymous visitors (issue #56); the guard still resolves the `public` * for anonymous visitors (issue #56); the guard still resolves the `public`
* grant via the attachment's page and 404s otherwise. * grant via the attachment's page and 404s otherwise. Raster images render
* inline; every other type office files, PDFs, SVG is sent as a download
* with its original filename and can never execute inline (#61).
*/ */
@Get('media/:fileId') @Get('media/:fileId')
@Public() @Public()
@ -55,13 +90,14 @@ export class FilesController {
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const { attachment, stream } = await this.files.download(request.user ?? null, fileId); const { attachment, stream, inline } = await this.files.download(request.user ?? null, fileId);
response.set('X-Content-Type-Options', 'nosniff'); response.set('X-Content-Type-Options', 'nosniff');
// Attachments are immutable — a new upload always gets a new id. // Attachments are immutable — a new upload always gets a new id.
response.set('Cache-Control', 'private, max-age=31536000, immutable'); response.set('Cache-Control', 'private, max-age=31536000, immutable');
const kind = inline ? 'inline' : 'attachment';
return new StreamableFile(stream, { return new StreamableFile(stream, {
type: attachment.mimeType, type: attachment.mimeType,
disposition: `inline; filename="${encodeURIComponent(attachment.fileName)}"`, disposition: `${kind}; filename="${encodeURIComponent(attachment.fileName)}"`,
}); });
} }

View File

@ -8,6 +8,7 @@ import type { Test } from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthTokensService } from '../auth/auth-tokens.service'; import { AuthTokensService } from '../auth/auth-tokens.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
@ -134,22 +135,89 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
expect(Buffer.compare(served.body, bytes)).toBe(0); expect(Buffer.compare(served.body, bytes)).toBe(0);
}); });
it('rejects non-image uploads', async () => { it('accepts an allowlisted non-image file and serves it as a download (#61)', async () => {
const pdf = Buffer.from('%PDF-1.4 minimal but allowlisted');
const uploaded = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', pdf, 'document.pdf')
.expect(201);
expect(uploaded.body.mimeType).toBe('application/pdf');
const served = await api()
.get(`/api/v1/media/${uploaded.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
// Non-images are always downloads, never inline (ADR 0011, security.md).
expect(served.headers['content-disposition']).toContain('attachment');
expect(served.headers['content-disposition']).toContain('document.pdf');
expect(served.headers['content-type']).toContain('application/pdf');
});
it('rejects a file whose extension is not on the allowlist (#61)', async () => {
const res = await api() const res = await api()
.post(`/api/v1/ponds/${pondId}/files`) .post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie) .set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 not really'), 'document.pdf') .attach('file', Buffer.from('binary junk'), 'malware.exe')
.expect(400); .expect(400);
expect(res.body.code).toBe('unsupported_file_type'); expect(res.body.code).toBe('upload_type_not_allowed');
expect(res.body.details.allowed).toContain('pdf');
}); });
it('rejects a renamed .html-as-.png via the magic-byte check', async () => { it('rejects a renamed .html-as-.png via the magic-byte check (#61)', async () => {
const res = await api() const res = await api()
.post(`/api/v1/ponds/${pondId}/files`) .post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie) .set('Cookie', ownerCookie)
.attach('file', Buffer.from('<html><body>evil</body></html>'), 'sneaky.png') .attach('file', Buffer.from('<html><body>evil</body></html>'), 'sneaky.png')
.expect(400); .expect(400);
expect(res.body.code).toBe('unsupported_file_type'); // png is validated by bytes, not extension, so the disguise fails the
// allowlist (png is not a configured non-image extension either).
expect(res.body.code).toBe('upload_type_not_allowed');
});
it('sanitizes an uploaded SVG, stripping scripts and event handlers (#61)', async () => {
const settings = app.get(InstanceSettingsService);
await settings.set('upload.svgPolicy', 'sanitize', 'test');
const dirty = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">' +
'<script>alert(2)</script><rect width="10" height="10"/></svg>',
);
const uploaded = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', dirty, 'diagram.svg')
.expect(201);
expect(uploaded.body.mimeType).toBe('image/svg+xml');
const served = await api()
.get(`/api/v1/media/${uploaded.body.id}`)
.set('Cookie', ownerCookie)
.buffer(true)
.parse(binaryParser as unknown as ParseCallback)
.expect(200);
const cleaned = served.body.toString('utf8').toLowerCase();
expect(cleaned).not.toContain('<script');
expect(cleaned).not.toContain('onload');
expect(cleaned).toContain('<rect');
// SVG is always a download, never inline.
expect(served.headers['content-disposition']).toContain('attachment');
});
it('rejects an SVG upload when the policy is reject (#61)', async () => {
const settings = app.get(InstanceSettingsService);
await settings.set('upload.svgPolicy', 'reject', 'test');
try {
const res = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'), 'x.svg')
.expect(400);
expect(res.body.code).toBe('upload_type_not_allowed');
} finally {
await settings.set('upload.svgPolicy', 'sanitize', 'test');
}
}); });
it('rejects an oversize file with a distinct error from quota_exceeded', async () => { it('rejects an oversize file with a distinct error from quota_exceeded', async () => {
@ -236,6 +304,65 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
expect(stillThere?.deletedAt).toBeNull(); expect(stillThere?.deletedAt).toBeNull();
}); });
it('lists a page attachment for the page and links it (#61)', async () => {
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Page Files ${suffix}` })
.expect(201);
const uploaded = await api()
.post(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 attached to a page'), 'report.pdf')
.expect(201);
expect(uploaded.body.pageId).toBe(page.body.id);
const listed = await api()
.get(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.expect(200);
const item = listed.body.find((f: { id: string }) => f.id === uploaded.body.id);
expect(item).toBeDefined();
expect(item.uploaderName).toBe(owner.displayName);
expect(item.pageTitle).toBe(`Page Files ${suffix}`);
});
it('pond file manager reports usage, orphans, and page links (#61)', async () => {
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Manager Page ${suffix}` })
.expect(201);
const linked = await api()
.post(`/api/v1/pages/${page.body.id}/files`)
.set('Cookie', ownerCookie)
.attach('file', Buffer.from('%PDF-1.4 linked'), 'linked.pdf')
.expect(201);
const orphan = await api()
.post(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.attach('file', pngBuffer('orphan image'), 'orphan.png')
.expect(201);
const manager = await api()
.get(`/api/v1/ponds/${pondId}/files`)
.set('Cookie', ownerCookie)
.expect(200);
expect(manager.body.storageBytesLimit).toBeGreaterThan(0);
const usage = await prisma.pondUsage.findUnique({ where: { pondId } });
expect(manager.body.storageBytesUsed).toBe(Number(usage?.storageBytesUsed ?? 0));
const linkedItem = manager.body.files.find((f: { id: string }) => f.id === linked.body.id);
const orphanItem = manager.body.files.find((f: { id: string }) => f.id === orphan.body.id);
expect(linkedItem.pageTitle).toBe(`Manager Page ${suffix}`);
expect(orphanItem.pageTitle).toBeNull();
});
it('denies the pond file manager to a non-admin (#61)', async () => {
await api().get(`/api/v1/ponds/${pondId}/files`).set('Cookie', outsiderCookie).expect(404);
});
it('hides foreign-pond files from download and delete (404, not 403)', async () => { it('hides foreign-pond files from download and delete (404, not 403)', async () => {
const uploaded = await api() const uploaded = await api()
.post(`/api/v1/ponds/${pondId}/files`) .post(`/api/v1/ponds/${pondId}/files`)

View File

@ -7,28 +7,50 @@ import {
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AttachmentView } from '@dorfteich/shared'; import {
ATTACHMENT_EXTENSION_MIME_TYPES,
AttachmentListItemView,
AttachmentView,
PondFilesView,
SVG_MIME_TYPE,
fileExtension,
isImageMimeType,
} from '@dorfteich/shared';
import { Attachment, User } from '@prisma/client'; import { Attachment, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service'; import { QuotaService } from '../quotas/quota.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { FileStorageService } from './file-storage.service'; import { FileStorageService } from './file-storage.service';
import { sniffImageMimeType } from './magic-bytes'; import { looksLikeSvg, sniffImageMimeType } from './magic-bytes';
import { sanitizeSvg } from './svg-sanitize';
export interface FileDownload { export interface FileDownload {
attachment: Attachment; attachment: Attachment;
stream: Readable; stream: Readable;
/** Raster images render inline (they are embedded in pages); everything
* else office files, PDFs, and SVG is always sent as a download so it
* can never execute inline (ADR 0011, security.md §Uploads). */
inline: boolean;
} }
/** File storage and image-upload API (issue #27, ADR 0011). */ /** What the upload bytes resolved to after allowlist + SVG handling. */
interface ResolvedUpload {
mimeType: string;
/** The bytes to store — identical to the input except for a sanitized SVG. */
buffer: Buffer;
}
/** File storage, upload, and attachment listing API (issue #27, #61, ADR 0011). */
@Injectable() @Injectable()
export class FilesService { export class FilesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly storage: FileStorageService, private readonly storage: FileStorageService,
private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(FilesService.name); this.logger.setContext(FilesService.name);
@ -46,21 +68,58 @@ export class FilesService {
}; };
} }
/**
* Decide the served MIME type and the bytes to persist. Raster images pass
* the magic-byte sniff and are always allowed. An SVG is sanitized (its
* scripts/handlers stripped) or rejected per the instance policy. Anything
* else is admitted only if its extension is on the configurable allowlist;
* its bytes are never trusted to be inert, but it is only ever downloaded,
* never rendered.
*/
private async resolveUpload(fileName: string, buffer: Buffer): Promise<ResolvedUpload> {
const rasterMime = sniffImageMimeType(buffer);
if (rasterMime) return { mimeType: rasterMime, buffer };
if (looksLikeSvg(buffer)) {
const policy = await this.settings.get('upload.svgPolicy');
if (policy === 'reject') {
throw new BadRequestException({ code: 'upload_type_not_allowed' });
}
let clean: string;
try {
clean = sanitizeSvg(buffer.toString('utf8'));
} catch {
throw new BadRequestException({ code: 'upload_type_not_allowed' });
}
return { mimeType: SVG_MIME_TYPE, buffer: Buffer.from(clean, 'utf8') };
}
const ext = fileExtension(fileName);
const allowed = await this.settings.get('upload.allowedExtensions');
if (!ext || !allowed.includes(ext)) {
throw new BadRequestException({
code: 'upload_type_not_allowed',
details: { allowed },
});
}
return { mimeType: ATTACHMENT_EXTENSION_MIME_TYPES[ext] ?? 'application/octet-stream', buffer };
}
/**
* Store an upload against a pond, optionally linked to a page. `pageId` is
* set for a page attachment (uploaded from the page's attachments section)
* so it is listed there and purged with the page; image pastes leave it null
* and the collab persistence hook links them to whichever page embeds them.
*/
async upload( async upload(
user: User, user: User,
pondId: string, pondId: string,
file: { buffer: Buffer; size: number; originalname: string }, file: { buffer: Buffer; size: number; originalname: string },
pageId?: string,
): Promise<AttachmentView> { ): Promise<AttachmentView> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException(); if (!pond) throw new NotFoundException();
// Bytes decide, not the client-declared Content-Type or extension —
// catches a renamed .html-as-.png (ADR 0011 acceptance criterion).
const mimeType = sniffImageMimeType(file.buffer);
if (!mimeType) {
throw new BadRequestException({ code: 'unsupported_file_type' });
}
const maxFileBytes = await this.quotas.getEffective('max_file_bytes', { const maxFileBytes = await this.quotas.getEffective('max_file_bytes', {
userId: pond.ownerId, userId: pond.ownerId,
pondId: pond.id, pondId: pond.id,
@ -72,42 +131,107 @@ export class FilesService {
}); });
} }
const resolved = await this.resolveUpload(file.originalname, file.buffer);
// Account for what actually lands on the volume (a sanitized SVG is
// usually smaller than the upload) so pond_usage matches disk exactly.
const sizeBytes = resolved.buffer.length;
const id = randomUUID(); const id = randomUUID();
// Consume the storage budget before touching the disk so a race never // Consume the storage budget before touching the disk so a race never
// leaves bytes written without a matching reservation. // leaves bytes written without a matching reservation.
await this.quotas.checkAndConsume(pond.id, pond.ownerId, file.size); await this.quotas.checkAndConsume(pond.id, pond.ownerId, sizeBytes);
try { try {
await this.storage.save(pond.id, id, file.buffer); await this.storage.save(pond.id, id, resolved.buffer);
const attachment = await this.prisma.attachment.create({ const attachment = await this.prisma.attachment.create({
data: { data: {
id, id,
pondId: pond.id, pondId: pond.id,
pageId: pageId ?? null,
fileName: file.originalname, fileName: file.originalname,
mimeType, mimeType: resolved.mimeType,
sizeBytes: file.size, sizeBytes,
storagePath: `${pond.id}/${id}`, storagePath: `${pond.id}/${id}`,
uploadedBy: user.id, uploadedBy: user.id,
}, },
}); });
this.logger.info( this.logger.info(
{ attachmentId: id, pondId: pond.id, userId: user.id }, { attachmentId: id, pondId: pond.id, pageId: pageId ?? null, userId: user.id },
'audit: file uploaded', 'audit: file uploaded',
); );
return this.viewOf(attachment); return this.viewOf(attachment);
} catch (error) { } catch (error) {
// Roll back the reservation and any bytes already written so usage // Roll back the reservation and any bytes already written so usage
// never drifts from what is actually on the volume/in the database. // never drifts from what is actually on the volume/in the database.
await this.quotas.release(pond.id, file.size); await this.quotas.release(pond.id, sizeBytes);
await this.storage.delete(pond.id, id); await this.storage.delete(pond.id, id);
throw error; throw error;
} }
} }
/** Upload against the pond of `pageId`, linked to that page (#61). */
async uploadToPage(
user: User,
pageId: string,
file: { buffer: Buffer; size: number; originalname: string },
): Promise<AttachmentView> {
const page = await this.prisma.page.findFirst({ where: { id: pageId } });
if (!page) throw new NotFoundException();
return this.upload(user, page.pondId, file, page.id);
}
async download(_user: User | null, id: string): Promise<FileDownload> { async download(_user: User | null, id: string): Promise<FileDownload> {
const attachment = await this.prisma.attachment.findFirst({ where: { id } }); const attachment = await this.prisma.attachment.findFirst({ where: { id } });
if (!attachment) throw new NotFoundException(); if (!attachment) throw new NotFoundException();
return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) }; return {
attachment,
stream: this.storage.createReadStream(attachment.pondId, attachment.id),
inline: isImageMimeType(attachment.mimeType),
};
}
/** Attachments linked to a page, for its attachments section (#61). */
async listForPage(pageId: string): Promise<AttachmentListItemView[]> {
const rows = await this.prisma.attachment.findMany({
where: { pageId, deletedAt: null },
orderBy: { createdAt: 'desc' },
include: { uploader: true, page: true },
});
return rows.map((row) => this.listItemOf(row));
}
/** Every file in a pond, for the Pond Admin file manager (#61). */
async listForPond(pondId: string): Promise<PondFilesView> {
const [rows, usage, storageBytesLimit] = await Promise.all([
this.prisma.attachment.findMany({
where: { pondId, deletedAt: null },
orderBy: { createdAt: 'desc' },
include: { uploader: true, page: true },
}),
this.prisma.pondUsage.findUnique({ where: { pondId } }),
this.pondStorageLimit(pondId),
]);
return {
files: rows.map((row) => this.listItemOf(row)),
storageBytesUsed: Number(usage?.storageBytesUsed ?? 0n),
storageBytesLimit,
};
}
private async pondStorageLimit(pondId: string): Promise<number> {
const pond = await this.prisma.pond.findUnique({ where: { id: pondId } });
if (!pond) throw new NotFoundException();
return this.quotas.getEffective('storage_bytes', { userId: pond.ownerId, pondId });
}
private listItemOf(
row: Attachment & { uploader: User; page: { title: string } | null },
): AttachmentListItemView {
return {
...this.viewOf(row),
uploaderName: row.uploader.displayName,
pageTitle: row.page?.title ?? null,
};
} }
async remove(user: User, id: string): Promise<void> { async remove(user: User, id: string): Promise<void> {

View File

@ -1,11 +1,10 @@
import type { AttachmentMimeType } from '@dorfteich/shared'; import type { AttachmentMimeType } from '@dorfteich/shared';
/** /**
* Magic-byte signatures for the M2 image allowlist (ADR 0011). The * Magic-byte signatures for the raster-image allowlist (ADR 0011). The
* client-declared MIME type and filename extension are never trusted * client-declared MIME type and filename extension are never trusted
* only the actual bytes decide, which is what catches a renamed * only the actual bytes decide, which is what catches a renamed
* `.html`-as-`.png` upload. SVG has no reliable magic-byte signature (it's * `.html`-as-`.png` upload.
* XML) and is rejected in M2 regardless, per ADR 0011.
*/ */
const SIGNATURES: ReadonlyArray<{ const SIGNATURES: ReadonlyArray<{
mimeType: AttachmentMimeType; mimeType: AttachmentMimeType;
@ -36,7 +35,18 @@ const SIGNATURES: ReadonlyArray<{
}, },
]; ];
/** Returns the sniffed image MIME type, or null when the bytes match none of the allowed signatures. */ /** Returns the sniffed raster-image MIME type, or null when the bytes match none of the allowed signatures. */
export function sniffImageMimeType(buffer: Buffer): AttachmentMimeType | null { export function sniffImageMimeType(buffer: Buffer): AttachmentMimeType | null {
return SIGNATURES.find((signature) => signature.matches(buffer))?.mimeType ?? null; return SIGNATURES.find((signature) => signature.matches(buffer))?.mimeType ?? null;
} }
/**
* Heuristic SVG detection: an XML document whose leading bytes contain an
* `<svg` tag. SVG has no binary magic number (it is XML), so we scan the
* head, tolerating a BOM, an XML prolog, and a leading DOCTYPE/comment. This
* only gates *candidacy*; the bytes are still sanitized or rejected per the
* SVG policy before storage, so a false positive is harmless.
*/
export function looksLikeSvg(buffer: Buffer): boolean {
return buffer.subarray(0, 1024).toString('utf8').toLowerCase().includes('<svg');
}

View File

@ -0,0 +1,29 @@
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
/**
* Server-side SVG sanitizer (security.md §Uploads, issue #61). SVG is XML that
* can carry `<script>`, event handlers (`onload`, ), and external references,
* so an uploaded SVG is scrubbed with DOMPurify before it is ever stored or
* served. One jsdom window is created once and reused across calls (DOMPurify
* is stateless per `sanitize`).
*/
const window = new JSDOM('').window;
const purify = createDOMPurify(window as unknown as Window & typeof globalThis);
/**
* Returns a sanitized copy of the SVG markup with scripts, event handlers, and
* other active content removed. Throws when the input contains no SVG element
* at all (a non-SVG file that only tripped the `<svg` heuristic).
*/
export function sanitizeSvg(source: string): string {
const clean = purify.sanitize(source, {
USE_PROFILES: { svg: true, svgFilters: true },
// Foreign objects can embed arbitrary HTML/scripts into an SVG — drop them.
FORBID_TAGS: ['script', 'foreignObject'],
});
if (!clean.toLowerCase().includes('<svg')) {
throw new Error('sanitized SVG has no <svg> root');
}
return clean;
}

View File

@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod'; import { z } from 'zod';
@ -32,6 +33,20 @@ export const INSTANCE_SETTINGS = {
// Trash retention (ADR 0013, issue #31): days a soft-deleted page stays // Trash retention (ADR 0013, issue #31): days a soft-deleted page stays
// restorable before the daily purge job removes it for good. // restorable before the daily purge job removes it for good.
'trash.retentionDays': z.number().int().min(1).default(30), 'trash.retentionDays': z.number().int().min(1).default(30),
// Non-image upload allowlist (ADR 0011, issue #61): lowercase extensions
// without the dot. Images are always allowed regardless; SVG is governed
// by `upload.svgPolicy`. Normalized (lowercased, dot-stripped, deduped) so
// an admin can paste `.PDF` or `pdf` interchangeably.
'upload.allowedExtensions': z
.array(z.string())
.transform((exts) => [
...new Set(exts.map((e) => e.trim().replace(/^\./, '').toLowerCase()).filter(Boolean)),
])
.pipe(z.array(z.string().regex(/^[a-z0-9]+$/)))
.default([...DEFAULT_ATTACHMENT_EXTENSIONS]),
// SVG upload handling (security.md §Uploads): sanitize strips scripts and
// event handlers with a maintained library; reject refuses SVG outright.
'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'),
} as const; } as const;
export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS; export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS;

View File

@ -70,6 +70,16 @@ _write_ on something readable is 403.
one place the policy is pinned. A weakened guard is caught here — verified by one place the policy is pinned. A weakened guard is caught here — verified by
temporarily loosening a route decorator and watching the pack go red. temporarily loosening a route decorator and watching the pack go red.
## Attachments (`attachments.spec.ts`, issue #61)
Non-image attachments: a page's attachments section uploads an allowlisted
file, lists it, and inserts it into the document as a download link (verified
to serve with `Content-Disposition: attachment` + `nosniff`, never inline); a
disallowed extension is rejected with the localized allowlist error; the Pond
Admin file manager reports storage usage and flags an orphan (a pond-level
upload with no embedding page). The SVG sanitize/reject policy is covered at
the api level in `files.e2e.db.test.ts`.
## Content fixtures ## Content fixtures
`db:seed` also creates a **shared** pond `content-fixtures` (owned by `db:seed` also creates a **shared** pond `content-fixtures` (owned by

View File

@ -0,0 +1,111 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Non-image attachments pack (issue #61): a page's attachments section uploads
* an allowlisted file, lists it, and inserts it into the document as a
* download link; a disallowed extension is rejected with the localized error;
* the Pond Admin file manager reports usage and flags an orphan. Selectors are
* language-neutral (the UI follows the user's locale) CSS classes, not the
* button text.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const PDF = Buffer.from('%PDF-1.4 e2e attachment body');
async function createPage(
context: Awaited<ReturnType<typeof contextForUser>>,
title: string,
): Promise<{ pondSlug: string; pageSlug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title } });
const page = await created.json();
return { pondSlug: pond.slug, pageSlug: page.slug };
}
async function openAttachments(page: Page): Promise<void> {
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
await page.locator('.editor-shell__attachments-toggle').click();
await expect(page.locator('.attachments-panel')).toBeVisible();
}
test('uploads a page attachment, lists it, and inserts a download link', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Attach ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await openAttachments(page);
await page.locator('.attachments-panel__input').setInputFiles({
name: 'report.pdf',
mimeType: 'application/pdf',
buffer: PDF,
});
const item = page.locator('.attachments-item__name', { hasText: 'report.pdf' });
await expect(item).toBeVisible({ timeout: 10000 });
// Insert into the document as a link, then confirm it landed as an anchor.
await page.locator('.attachments-item__insert').first().click();
const link = page.locator('.ProseMirror a[href^="/api/v1/media/"]');
await expect(link).toBeVisible();
// The linked media downloads (attachment disposition) with its filename and
// is never rendered inline as HTML (ADR 0011, security.md §Uploads).
const href = await link.getAttribute('href');
const served = await context.request.get(href!);
expect(served.status()).toBe(200);
expect(served.headers()['content-disposition']).toContain('attachment');
expect(served.headers()['content-disposition']).toContain('report.pdf');
expect(served.headers()['x-content-type-options']).toBe('nosniff');
await context.close();
});
test('rejects a disallowed extension with the localized error', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Reject ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await openAttachments(page);
await page.locator('.attachments-panel__input').setInputFiles({
name: 'malware.exe',
mimeType: 'application/octet-stream',
buffer: Buffer.from('MZ not allowed'),
});
await expect(page.locator('.attachments-panel .form-banner--error')).toBeVisible();
await expect(page.locator('.attachments-item__name')).toHaveCount(0);
await context.close();
});
test('pond file manager shows usage and flags an orphan (Pond Admin)', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
// A pond-level upload with no embedding page is an orphan candidate.
const upload = await context.request.post(`/api/v1/ponds/${pond.id}/files`, {
multipart: { file: { name: 'loose.pdf', mimeType: 'application/pdf', buffer: PDF } },
});
expect(upload.ok()).toBeTruthy();
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/settings`);
const manager = page.locator('.pond-file-manager');
await expect(manager).toBeVisible();
await expect(manager.locator('.pond-file-manager__usage')).toBeVisible();
const row = manager.locator('.attachments-item', { hasText: 'loose.pdf' });
await expect(row).toBeVisible();
await expect(row.locator('.attachments-item__orphan')).toBeVisible();
await context.close();
});

View File

@ -0,0 +1,165 @@
import type { AttachmentListItemView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { Editor } from '@tiptap/react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiUploadFile } from '../lib/api';
import { fileGlyph, formatBytes, mediaUrl } from './file-format';
/**
* A page's attachments section (issue #61): upload a file, see it listed with
* its type icon, size, and uploader, insert it into the document as a download
* link, or delete it. Uploads and deletes are page-write-gated in the api; in
* read mode the panel is a download list (no upload/insert/delete controls).
*/
export function AttachmentsPanel({
pageId,
editor,
canEdit,
onClose,
}: {
pageId: string;
editor: Editor | null;
canEdit: boolean;
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation('files');
const queryClient = useQueryClient();
const fileInput = useRef<HTMLInputElement>(null);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const list = useQuery({
queryKey: ['page-files', pageId],
queryFn: () => apiGet<AttachmentListItemView[]>(`/pages/${pageId}/files`),
});
async function refresh(): Promise<void> {
await queryClient.invalidateQueries({ queryKey: ['page-files', pageId] });
}
async function onPick(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setError(null);
setBusy(true);
try {
await apiUploadFile(`/pages/${pageId}/files`, file);
await refresh();
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
}
async function remove(id: string): Promise<void> {
setError(null);
try {
await apiDelete(`/files/${id}`);
await refresh();
} catch (err) {
setError(err);
}
}
function insertLink(item: AttachmentListItemView): void {
if (!editor) return;
editor
.chain()
.focus()
.insertContent([
{
type: 'text',
text: item.fileName,
marks: [{ type: 'link', attrs: { href: mediaUrl(item.id) } }],
},
{ type: 'text', text: ' ' },
])
.run();
}
const items = list.data ?? [];
return (
<aside className="attachments-panel" aria-label={t('title')}>
<div className="attachments-panel__header">
<h3>{t('title')}</h3>
<button type="button" className="button" onClick={onClose}>
{t('close')}
</button>
</div>
<FormError error={error} />
{canEdit && (
<div className="attachments-panel__upload">
<input
ref={fileInput}
type="file"
className="attachments-panel__input"
onChange={(event) => void onPick(event)}
/>
<button
type="button"
className="button"
disabled={busy}
onClick={() => fileInput.current?.click()}
>
{busy ? t('uploading') : t('upload')}
</button>
</div>
)}
{items.length === 0 ? (
<p className="attachments-panel__empty">{t('empty')}</p>
) : (
<ul className="attachments-panel__list">
{items.map((item) => (
<li key={item.id} className="attachments-item">
<span className="attachments-item__glyph" aria-hidden="true">
{fileGlyph(item.fileName, item.mimeType)}
</span>
<a
className="attachments-item__name"
href={mediaUrl(item.id)}
target="_blank"
rel="noreferrer"
download={item.fileName}
>
{item.fileName}
</a>
<span className="attachments-item__meta">
{formatBytes(item.sizeBytes)} · {item.uploaderName}
</span>
{canEdit && (
<span className="attachments-item__actions">
{editor && (
<button
type="button"
className="button attachments-item__insert"
onClick={() => insertLink(item)}
>
{t('insert')}
</button>
)}
<button
type="button"
className="button attachments-item__delete"
onClick={() => void remove(item.id)}
>
{t('delete')}
</button>
</span>
)}
</li>
))}
</ul>
)}
</aside>
);
}

View File

@ -0,0 +1,84 @@
import type { PondFilesView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormError } from '../components/forms';
import { apiDelete, apiGet } from '../lib/api';
import { fileGlyph, formatBytes, mediaUrl } from './file-format';
/**
* Pond-wide file manager (issue #61), Pond-Admin-gated in the api. Lists every
* attachment in the pond with its type, size, uploader, and the page that
* references it a file with no referencing page is flagged as an orphan
* candidate plus the pond's storage usage against its quota.
*/
export function PondFileManager({ pondId }: { pondId: string }): React.JSX.Element {
const { t } = useTranslation('files');
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null);
const files = useQuery({
queryKey: ['pond-files', pondId],
queryFn: () => apiGet<PondFilesView>(`/ponds/${pondId}/files`),
});
async function remove(id: string): Promise<void> {
setError(null);
try {
await apiDelete(`/files/${id}`);
await queryClient.invalidateQueries({ queryKey: ['pond-files', pondId] });
} catch (err) {
setError(err);
}
}
if (!files.data) return <></>;
const { files: items, storageBytesUsed, storageBytesLimit } = files.data;
return (
<div className="pond-file-manager">
<p className="pond-file-manager__usage">
{t('usage', {
used: formatBytes(storageBytesUsed),
limit: formatBytes(storageBytesLimit),
})}
</p>
<FormError error={error} />
{items.length === 0 ? (
<p className="pond-file-manager__empty">{t('empty')}</p>
) : (
<ul className="pond-file-manager__list">
{items.map((item) => (
<li key={item.id} className="attachments-item">
<span className="attachments-item__glyph" aria-hidden="true">
{fileGlyph(item.fileName, item.mimeType)}
</span>
<a
className="attachments-item__name"
href={mediaUrl(item.id)}
target="_blank"
rel="noreferrer"
download={item.fileName}
>
{item.fileName}
</a>
<span className="attachments-item__meta">
{formatBytes(item.sizeBytes)} · {item.uploaderName} ·{' '}
{item.pageTitle ?? <span className="attachments-item__orphan">{t('orphan')}</span>}
</span>
<button
type="button"
className="button attachments-item__delete"
onClick={() => void remove(item.id)}
>
{t('delete')}
</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
import { fileExtension, isImageMimeType } from '@dorfteich/shared';
/** Human-readable byte size (binary units) for the file listings (#61). */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
}
/**
* A tiny emoji glyph standing in for a file-type icon (no icon asset pipeline
* yet) enough to tell an image, a document, a spreadsheet, and an archive
* apart at a glance in the attachment lists.
*/
export function fileGlyph(fileName: string, mimeType: string): string {
if (isImageMimeType(mimeType) || mimeType === 'image/svg+xml') return '🖼️';
const ext = fileExtension(fileName);
if (ext === 'pdf') return '📕';
if (['doc', 'docx', 'odt', 'rtf', 'txt', 'md'].includes(ext)) return '📄';
if (['xls', 'xlsx', 'ods', 'csv'].includes(ext)) return '📊';
if (['ppt', 'pptx', 'odp'].includes(ext)) return '📽️';
if (['zip'].includes(ext)) return '🗜️';
return '📎';
}
/** The permission-checked download URL for an attachment (ADR 0011). */
export const mediaUrl = (fileId: string): string => `/api/v1/media/${fileId}`;

View File

@ -3,6 +3,7 @@ import deAuth from '@dorfteich/shared/i18n/de/auth.json';
import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json';
import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.json';
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deFiles from '@dorfteich/shared/i18n/de/files.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json';
@ -16,6 +17,7 @@ import enAuth from '@dorfteich/shared/i18n/en/auth.json';
import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json';
import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enFiles from '@dorfteich/shared/i18n/en/files.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json';
import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json';
@ -46,6 +48,7 @@ void i18n
auth: enAuth, auth: enAuth,
settings: enSettings, settings: enSettings,
editor: enEditor, editor: enEditor,
files: enFiles,
labels: enLabels, labels: enLabels,
links: enLinks, links: enLinks,
members: enMembers, members: enMembers,
@ -61,6 +64,7 @@ void i18n
auth: deAuth, auth: deAuth,
settings: deSettings, settings: deSettings,
editor: deEditor, editor: deEditor,
files: deFiles,
labels: deLabels, labels: deLabels,
links: deLinks, links: deLinks,
members: deMembers, members: deMembers,

View File

@ -17,6 +17,8 @@ interface InstanceSettings {
'quota.additionalPonds': number; 'quota.additionalPonds': number;
'quota.storageBytes': number; 'quota.storageBytes': number;
'quota.maxFileBytes': number; 'quota.maxFileBytes': number;
'upload.allowedExtensions': string[];
'upload.svgPolicy': 'reject' | 'sanitize';
} }
export function AdminSettingsPage(): React.JSX.Element { export function AdminSettingsPage(): React.JSX.Element {
@ -97,12 +99,80 @@ export function AdminSettingsPage(): React.JSX.Element {
</form> </form>
</section> </section>
<UploadSettingsForm settings={settings.data} />
<QuotaManager /> <QuotaManager />
<UserManager /> <UserManager />
</> </>
); );
} }
/**
* Upload allowlist + SVG policy (issue #61). The allowlist is an array in the
* api but edited here as a comma-separated field; images are always allowed
* and are not part of this list.
*/
function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
const { t } = useTranslation('files');
const queryClient = useQueryClient();
const [extensions, setExtensions] = useState(settings['upload.allowedExtensions'].join(', '));
const [svgPolicy, setSvgPolicy] = useState(settings['upload.svgPolicy']);
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
async function onSubmit(event: React.FormEvent): Promise<void> {
event.preventDefault();
setError(null);
setSaved(false);
setBusy(true);
try {
await apiPatch('/admin/settings', {
'upload.allowedExtensions': extensions
.split(',')
.map((e) => e.trim())
.filter(Boolean),
'upload.svgPolicy': svgPolicy,
});
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
setSaved(true);
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h2>{t('settings.title')}</h2>
<form onSubmit={(event) => void onSubmit(event)} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('settings.save') : null} />
<Field label={t('settings.allowedExtensions')} hint={t('settings.allowedExtensionsHelp')}>
<input
type="text"
value={extensions}
onChange={(event) => setExtensions(event.target.value)}
/>
</Field>
<Field label={t('settings.svgPolicy')}>
<select
value={svgPolicy}
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
>
<option value="sanitize">{t('settings.svgSanitize')}</option>
<option value="reject">{t('settings.svgReject')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={busy}>
{t('settings.save')}
</button>
</form>
</section>
);
}
/** Map the instance-setting key to the shared quota key its label lives under. */ /** Map the instance-setting key to the shared quota key its label lives under. */
const SETTING_TO_QUOTA_KEY = { const SETTING_TO_QUOTA_KEY = {
'quota.editorsPerPond': 'editors_per_pond', 'quota.editorsPerPond': 'editors_per_pond',

View File

@ -10,6 +10,7 @@ import * as Y from 'yjs';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
import { AttachmentsPanel } from '../files/AttachmentsPanel';
import { HistoryPanel } from '../editor/HistoryPanel'; import { HistoryPanel } from '../editor/HistoryPanel';
import { LabelPicker } from '../labels/LabelPicker'; import { LabelPicker } from '../labels/LabelPicker';
import { BacklinksPanel } from '../links/BacklinksPanel'; import { BacklinksPanel } from '../links/BacklinksPanel';
@ -48,6 +49,7 @@ function PageEditor({
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [showAttachments, setShowAttachments] = useState(false);
// Created and destroyed within the same effect (not `useMemo` + a separate // Created and destroyed within the same effect (not `useMemo` + a separate
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
@ -120,6 +122,24 @@ function PageEditor({
<WikilinkContext.Provider value={wikilinks}> <WikilinkContext.Provider value={wikilinks}>
<div className="editor-shell"> <div className="editor-shell">
{canEdit && <Toolbar editor={editor} />} {canEdit && <Toolbar editor={editor} />}
<div className="editor-shell__tools">
<button
type="button"
className="button editor-shell__attachments-toggle"
aria-expanded={showAttachments}
onClick={() => setShowAttachments((open) => !open)}
>
{t('files:title')}
</button>
</div>
{showAttachments && (
<AttachmentsPanel
pageId={page.id}
editor={editor}
canEdit={canEdit}
onClose={() => setShowAttachments(false)}
/>
)}
<div className="editor-connection" role="status" data-status={collab.status}> <div className="editor-connection" role="status" data-status={collab.status}>
{t(`connection.${collab.status}`)} {t(`connection.${collab.status}`)}
</div> </div>

View File

@ -9,6 +9,7 @@ import { LabelManager } from '../labels/LabelManager';
import { PhantomPagesView } from '../links/PhantomPagesView'; import { PhantomPagesView } from '../links/PhantomPagesView';
import { AccessRulesManager } from '../access/AccessRulesManager'; import { AccessRulesManager } from '../access/AccessRulesManager';
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector'; import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
import { PondFileManager } from '../files/PondFileManager';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { MemberManager } from '../members/MemberManager'; import { MemberManager } from '../members/MemberManager';
@ -25,6 +26,7 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tLinks } = useTranslation('links'); const { t: tLinks } = useTranslation('links');
const { t: tMembers } = useTranslation('members'); const { t: tMembers } = useTranslation('members');
const { t: tErrors } = useTranslation('errors'); const { t: tErrors } = useTranslation('errors');
const { t: tFiles } = useTranslation('files');
const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const { user } = useAuth(); const { user } = useAuth();
@ -63,6 +65,12 @@ export function PondSettingsPage(): React.JSX.Element {
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} /> <PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
</section> </section>
)} )}
{canModify && (
<section>
<h2>{tFiles('manager.title')}</h2>
<PondFileManager pondId={pond.data.id} />
</section>
)}
</div> </div>
); );
} }

View File

@ -1754,3 +1754,94 @@ button {
gap: var(--space-3); gap: var(--space-3);
margin-top: var(--space-3); margin-top: var(--space-3);
} }
/* Attachments: page section + pond file manager (issue #61) */
.editor-shell__tools {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.attachments-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.375rem);
padding: var(--space-3);
margin-bottom: var(--space-3);
background: var(--color-surface-muted, rgba(127, 127, 127, 0.06));
}
.attachments-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.attachments-panel__header h3 {
margin: 0;
}
.attachments-panel__upload {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.attachments-panel__input {
display: none;
}
.attachments-panel__empty,
.pond-file-manager__empty {
color: var(--color-text-muted);
}
.attachments-panel__list,
.pond-file-manager__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.attachments-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
flex-wrap: wrap;
}
.attachments-item__glyph {
font-size: 1.1rem;
}
.attachments-item__name {
font-weight: 600;
}
.attachments-item__meta {
color: var(--color-text-muted);
font-size: 0.875rem;
}
.attachments-item__orphan {
color: var(--color-danger, #b91c1c);
font-style: italic;
}
.attachments-item__actions {
margin-left: auto;
display: flex;
gap: var(--space-2);
}
.pond-file-manager__usage {
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}

View File

@ -29,6 +29,7 @@
"label_has_pages": "Diesem Label sind noch Seiten zugeordnet; bitte bestätigen, um sie zu lösen.", "label_has_pages": "Diesem Label sind noch Seiten zugeordnet; bitte bestätigen, um sie zu lösen.",
"label_wrong_pond": "Dieses Label gehört zu einem anderen Teich.", "label_wrong_pond": "Dieses Label gehört zu einem anderen Teich.",
"unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.", "unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
"upload_type_not_allowed": "Dieser Dateityp ist auf dieser Instanz nicht erlaubt.",
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).", "file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
"network": "Der Server war nicht erreichbar.", "network": "Der Server war nicht erreichbar.",
"grant_exists": "Diese Berechtigung existiert bereits.", "grant_exists": "Diese Berechtigung existiert bereits.",

View File

@ -0,0 +1,23 @@
{
"title": "Anhänge",
"upload": "Datei hochladen",
"uploading": "Wird hochgeladen…",
"insert": "Als Link einfügen",
"delete": "Löschen",
"close": "Schließen",
"empty": "Noch keine Anhänge.",
"orphan": "nicht referenziert",
"usage": "{{used}} von {{limit}} belegt",
"manager": {
"title": "Dateien"
},
"settings": {
"title": "Uploads",
"allowedExtensions": "Erlaubte Dateiendungen",
"allowedExtensionsHelp": "Kommagetrennt, ohne Punkt. Bilder sind immer erlaubt.",
"svgPolicy": "SVG-Uploads",
"svgSanitize": "Bereinigen (Skripte entfernen)",
"svgReject": "Ablehnen",
"save": "Upload-Einstellungen speichern"
}
}

View File

@ -29,6 +29,7 @@
"label_has_pages": "This label still has pages assigned; confirm to detach them.", "label_has_pages": "This label still has pages assigned; confirm to detach them.",
"label_wrong_pond": "This label belongs to a different pond.", "label_wrong_pond": "This label belongs to a different pond.",
"unsupported_file_type": "This file type is not supported.", "unsupported_file_type": "This file type is not supported.",
"upload_type_not_allowed": "This file type is not allowed on this instance.",
"file_too_large": "The file is too large (limit: {{limitBytes}} bytes).", "file_too_large": "The file is too large (limit: {{limitBytes}} bytes).",
"network": "The server could not be reached.", "network": "The server could not be reached.",
"grant_exists": "This grant already exists.", "grant_exists": "This grant already exists.",

View File

@ -0,0 +1,23 @@
{
"title": "Attachments",
"upload": "Upload file",
"uploading": "Uploading…",
"insert": "Insert as link",
"delete": "Delete",
"close": "Close",
"empty": "No attachments yet.",
"orphan": "not referenced",
"usage": "{{used}} of {{limit}} used",
"manager": {
"title": "Files"
},
"settings": {
"title": "Uploads",
"allowedExtensions": "Allowed file extensions",
"allowedExtensionsHelp": "Comma-separated, without the dot. Images are always allowed.",
"svgPolicy": "SVG uploads",
"svgSanitize": "Sanitize (strip scripts)",
"svgReject": "Reject",
"save": "Save upload settings"
}
}

View File

@ -1,6 +1,7 @@
/** /**
* Attachment types shared between api and web (issue #27, ADR 0011). M2 * Attachment types shared between api and web (issue #27, ADR 0011). M2
* accepts images only; the general type allowlist arrives in M6 (#61). * accepted images only; M6 (#61) adds a configurable allowlist for general
* attachments (PDF, office files, ) plus an SVG policy.
*/ */
export const ATTACHMENT_IMAGE_MIME_TYPES = [ export const ATTACHMENT_IMAGE_MIME_TYPES = [
'image/png', 'image/png',
@ -11,6 +12,75 @@ export const ATTACHMENT_IMAGE_MIME_TYPES = [
export type AttachmentMimeType = (typeof ATTACHMENT_IMAGE_MIME_TYPES)[number]; export type AttachmentMimeType = (typeof ATTACHMENT_IMAGE_MIME_TYPES)[number];
export function isImageMimeType(mimeType: string): boolean {
return (ATTACHMENT_IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
}
/**
* SVG is an image but also an XML document that can carry scripts and event
* handlers, so it is never treated like a raster image: it is sanitized or
* rejected on upload (instance setting) and always served as a download,
* never inline (security.md §Uploads).
*/
export const SVG_MIME_TYPE = 'image/svg+xml';
/** How the instance handles SVG uploads (ADR 0011, security.md §Uploads). */
export type SvgPolicy = 'reject' | 'sanitize';
/**
* Extension served MIME type for the non-image allowlist. The allowlist is
* keyed on the lowercase extension (what an admin configures and what names
* the download); the MIME here only sets the response `Content-Type`, and
* non-images are always sent with `Content-Disposition: attachment` +
* `nosniff`, so a wrong guess can never cause inline execution.
*/
export const ATTACHMENT_EXTENSION_MIME_TYPES: Readonly<Record<string, string>> = {
pdf: 'application/pdf',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
rtf: 'application/rtf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
odt: 'application/vnd.oasis.opendocument.text',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ods: 'application/vnd.oasis.opendocument.spreadsheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
odp: 'application/vnd.oasis.opendocument.presentation',
zip: 'application/zip',
};
/**
* Default non-image allowlist (extensions, lowercase, no dot). Images from
* {@link ATTACHMENT_IMAGE_MIME_TYPES} are always allowed regardless of this
* list; SVG is governed separately by the SVG policy.
*/
export const DEFAULT_ATTACHMENT_EXTENSIONS: readonly string[] = [
'pdf',
'txt',
'md',
'csv',
'doc',
'docx',
'odt',
'xls',
'xlsx',
'ods',
'ppt',
'pptx',
'odp',
'zip',
];
/** Lowercase extension without the leading dot, or '' when the name has none. */
export function fileExtension(fileName: string): string {
const dot = fileName.lastIndexOf('.');
if (dot <= 0 || dot === fileName.length - 1) return '';
return fileName.slice(dot + 1).toLowerCase();
}
/** /**
* Hard ceiling on the raw multipart body the api will buffer in memory, * Hard ceiling on the raw multipart body the api will buffer in memory,
* independent of the per-pond/user `max_file_bytes` quota (QuotaService) * independent of the per-pond/user `max_file_bytes` quota (QuotaService)
@ -28,3 +98,20 @@ export interface AttachmentView {
sizeBytes: number; sizeBytes: number;
createdAt: string; createdAt: string;
} }
/**
* A row in the page-attachments section and the pond file manager (#61):
* carries the uploader's display name and, for the pond manager, the title
* of the page currently referencing the file (null = orphan candidate).
*/
export interface AttachmentListItemView extends AttachmentView {
uploaderName: string;
pageTitle: string | null;
}
/** Pond-wide file manager payload (Pond Admin, #61). */
export interface PondFilesView {
files: AttachmentListItemView[];
storageBytesUsed: number;
storageBytesLimit: number;
}

49
pnpm-lock.yaml generated
View File

@ -50,12 +50,18 @@ importers:
cookie-parser: cookie-parser:
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.7 version: 1.4.7
dompurify:
specifier: ^3.4.11
version: 3.4.11
fractional-indexing: fractional-indexing:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
i18next: i18next:
specifier: ^26.3.4 specifier: ^26.3.4
version: 26.3.4(typescript@5.9.3) version: 26.3.4(typescript@5.9.3)
jsdom:
specifier: ^26.1.0
version: 26.1.0
multer: multer:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@ -117,6 +123,9 @@ importers:
'@types/express': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.6 version: 5.0.6
'@types/jsdom':
specifier: ^28.0.3
version: 28.0.3
'@types/multer': '@types/multer':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.2.0 version: 2.2.0
@ -2176,6 +2185,9 @@ packages:
'@types/http-errors@2.0.5': '@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/jsdom@28.0.3':
resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -2232,6 +2244,9 @@ packages:
'@types/supertest@6.0.3': '@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@ -2870,6 +2885,9 @@ packages:
dezalgo@1.0.4: dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
dompurify@3.4.11:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
dotenv@16.6.1: dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -2918,6 +2936,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
error-ex@1.3.4: error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@ -3896,6 +3918,9 @@ packages:
parse5@7.3.0: parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -4793,6 +4818,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
undici-types@7.28.0:
resolution: {integrity: sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==}
undici-types@8.3.0: undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
@ -6972,6 +7000,13 @@ snapshots:
'@types/http-errors@2.0.5': {} '@types/http-errors@2.0.5': {}
'@types/jsdom@28.0.3':
dependencies:
'@types/node': 26.1.0
'@types/tough-cookie': 4.0.5
parse5: 8.0.1
undici-types: 7.28.0
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
@ -7038,6 +7073,8 @@ snapshots:
'@types/methods': 1.1.4 '@types/methods': 1.1.4
'@types/superagent': 8.1.10 '@types/superagent': 8.1.10
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@types/use-sync-external-store@0.0.6': {} '@types/use-sync-external-store@0.0.6': {}
@ -7729,6 +7766,10 @@ snapshots:
asap: 2.0.6 asap: 2.0.6
wrappy: 1.0.2 wrappy: 1.0.2
dompurify@3.4.11:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@16.6.1: {} dotenv@16.6.1: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
@ -7769,6 +7810,8 @@ snapshots:
entities@6.0.1: {} entities@6.0.1: {}
entities@8.0.0: {}
error-ex@1.3.4: error-ex@1.3.4:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
@ -8899,6 +8942,10 @@ snapshots:
dependencies: dependencies:
entities: 6.0.1 entities: 6.0.1
parse5@8.0.1:
dependencies:
entities: 8.0.0
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
@ -9884,6 +9931,8 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
undici-types@7.28.0: {}
undici-types@8.3.0: {} undici-types@8.3.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {}