diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 07f05fa..26c5865 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -228,6 +228,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ 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 run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/package.json b/apps/api/package.json index 0a3a520..cb8d2d7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,8 +23,10 @@ "@prisma/client": "^6.3.0", "argon2": "^0.44.0", "cookie-parser": "^1.4.7", + "dompurify": "^3.4.11", "fractional-indexing": "^4.0.0", "i18next": "^26.3.4", + "jsdom": "^26.1.0", "multer": "^2.1.1", "nestjs-pino": "^4.3.0", "nodemailer": "^9.0.3", @@ -47,6 +49,7 @@ "@swc/core": "^1.10.0", "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", + "@types/jsdom": "^28.0.3", "@types/multer": "^2.0.0", "@types/nodemailer": "^8.0.1", "@types/supertest": "^6.0.0", diff --git a/apps/api/src/files/files.controller.ts b/apps/api/src/files/files.controller.ts index ae33c68..1df84b3 100644 --- a/apps/api/src/files/files.controller.ts +++ b/apps/api/src/files/files.controller.ts @@ -13,18 +13,24 @@ import { UseInterceptors, } from '@nestjs/common'; 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 { AuthedRequest, Public } from '../auth/auth.guard'; import { RequiresAttachmentPermission, + RequiresPagePermission, RequiresPondRole, } from '../permissions/permission.decorators'; import { FilesService } from './files.service'; -/** File storage and image-upload API (issue #27). */ +/** File storage, upload, and attachment listing API (issue #27, #61). */ @Controller() export class FilesController { constructor(private readonly files: FilesService) {} @@ -41,11 +47,40 @@ export class FilesController { 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 { + 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 { + 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 { + return this.files.listForPage(pageId); + } + /** * Permission-checked file streaming (ADR 0011) — never served same-origin as * executable content. `@Public()` so embedded images on a public page load * 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') @Public() @@ -55,13 +90,14 @@ export class FilesController { @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { - 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'); // Attachments are immutable — a new upload always gets a new id. response.set('Cache-Control', 'private, max-age=31536000, immutable'); + const kind = inline ? 'inline' : 'attachment'; return new StreamableFile(stream, { type: attachment.mimeType, - disposition: `inline; filename="${encodeURIComponent(attachment.fileName)}"`, + disposition: `${kind}; filename="${encodeURIComponent(attachment.fileName)}"`, }); } diff --git a/apps/api/src/files/files.e2e.db.test.ts b/apps/api/src/files/files.e2e.db.test.ts index 53d47b0..8501d61 100644 --- a/apps/api/src/files/files.e2e.db.test.ts +++ b/apps/api/src/files/files.e2e.db.test.ts @@ -8,6 +8,7 @@ import type { Test } from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; @@ -134,22 +135,89 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => { 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() .post(`/api/v1/ponds/${pondId}/files`) .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(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() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', Buffer.from('evil'), 'sneaky.png') .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( + '' + + '', + ); + 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(' { + 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(''), '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 () => { @@ -236,6 +304,65 @@ describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => { 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 () => { const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts index 75746cd..742a751 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -7,28 +7,50 @@ import { NotFoundException, PayloadTooLargeException, } 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 { PinoLogger } from 'nestjs-pino'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService } from '../quotas/quota.service'; +import { InstanceSettingsService } from '../settings/instance-settings.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 { attachment: Attachment; 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() export class FilesService { constructor( private readonly prisma: PrismaService, private readonly quotas: QuotaService, private readonly storage: FileStorageService, + private readonly settings: InstanceSettingsService, private readonly logger: PinoLogger, ) { 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 { + 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( user: User, pondId: string, file: { buffer: Buffer; size: number; originalname: string }, + pageId?: string, ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); 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', { userId: pond.ownerId, 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(); // Consume the storage budget before touching the disk so a race never // 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 { - 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({ data: { id, pondId: pond.id, + pageId: pageId ?? null, fileName: file.originalname, - mimeType, - sizeBytes: file.size, + mimeType: resolved.mimeType, + sizeBytes, storagePath: `${pond.id}/${id}`, uploadedBy: user.id, }, }); 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', ); return this.viewOf(attachment); } catch (error) { // Roll back the reservation and any bytes already written so usage // 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); 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 { + 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 { const attachment = await this.prisma.attachment.findFirst({ where: { id } }); 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 { + 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 { + 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 { + 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 { diff --git a/apps/api/src/files/magic-bytes.ts b/apps/api/src/files/magic-bytes.ts index 1e729dc..4c1420b 100644 --- a/apps/api/src/files/magic-bytes.ts +++ b/apps/api/src/files/magic-bytes.ts @@ -1,11 +1,10 @@ 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 — * only the actual bytes decide, which is what catches a renamed - * `.html`-as-`.png` upload. SVG has no reliable magic-byte signature (it's - * XML) and is rejected in M2 regardless, per ADR 0011. + * `.html`-as-`.png` upload. */ const SIGNATURES: ReadonlyArray<{ 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 { return SIGNATURES.find((signature) => signature.matches(buffer))?.mimeType ?? null; } + +/** + * Heuristic SVG detection: an XML document whose leading bytes contain an + * ``, 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 ` root'); + } + return clean; +} diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 3752013..a51376f 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared'; import { PinoLogger } from 'nestjs-pino'; import { z } from 'zod'; @@ -32,6 +33,20 @@ export const INSTANCE_SETTINGS = { // Trash retention (ADR 0013, issue #31): days a soft-deleted page stays // restorable before the daily purge job removes it for good. '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; export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS; diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 5d7bdb9..1fe549a 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -70,6 +70,16 @@ _write_ on something readable is 403. 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. +## 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 `db:seed` also creates a **shared** pond `content-fixtures` (owned by diff --git a/apps/web/e2e/attachments.spec.ts b/apps/web/e2e/attachments.spec.ts new file mode 100644 index 0000000..d237726 --- /dev/null +++ b/apps/web/e2e/attachments.spec.ts @@ -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>, + 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 { + 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(); +}); diff --git a/apps/web/src/files/AttachmentsPanel.tsx b/apps/web/src/files/AttachmentsPanel.tsx new file mode 100644 index 0000000..8ba21d6 --- /dev/null +++ b/apps/web/src/files/AttachmentsPanel.tsx @@ -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(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const list = useQuery({ + queryKey: ['page-files', pageId], + queryFn: () => apiGet(`/pages/${pageId}/files`), + }); + + async function refresh(): Promise { + await queryClient.invalidateQueries({ queryKey: ['page-files', pageId] }); + } + + async function onPick(event: React.ChangeEvent): Promise { + 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 { + 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 ( + + ); +} diff --git a/apps/web/src/files/PondFileManager.tsx b/apps/web/src/files/PondFileManager.tsx new file mode 100644 index 0000000..7564375 --- /dev/null +++ b/apps/web/src/files/PondFileManager.tsx @@ -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(null); + + const files = useQuery({ + queryKey: ['pond-files', pondId], + queryFn: () => apiGet(`/ponds/${pondId}/files`), + }); + + async function remove(id: string): Promise { + 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 ( +
+

+ {t('usage', { + used: formatBytes(storageBytesUsed), + limit: formatBytes(storageBytesLimit), + })} +

+ + {items.length === 0 ? ( +

{t('empty')}

+ ) : ( +
    + {items.map((item) => ( +
  • + + + {item.fileName} + + + {formatBytes(item.sizeBytes)} · {item.uploaderName} ·{' '} + {item.pageTitle ?? {t('orphan')}} + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/files/file-format.ts b/apps/web/src/files/file-format.ts new file mode 100644 index 0000000..1e3df74 --- /dev/null +++ b/apps/web/src/files/file-format.ts @@ -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}`; diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 0f82131..0ba8216 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -3,6 +3,7 @@ import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.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 deLinks from '@dorfteich/shared/i18n/de/links.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 enEditor from '@dorfteich/shared/i18n/en/editor.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 enLinks from '@dorfteich/shared/i18n/en/links.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json'; @@ -46,6 +48,7 @@ void i18n auth: enAuth, settings: enSettings, editor: enEditor, + files: enFiles, labels: enLabels, links: enLinks, members: enMembers, @@ -61,6 +64,7 @@ void i18n auth: deAuth, settings: deSettings, editor: deEditor, + files: deFiles, labels: deLabels, links: deLinks, members: deMembers, diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index dbb179f..77929a8 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -17,6 +17,8 @@ interface InstanceSettings { 'quota.additionalPonds': number; 'quota.storageBytes': number; 'quota.maxFileBytes': number; + 'upload.allowedExtensions': string[]; + 'upload.svgPolicy': 'reject' | 'sanitize'; } export function AdminSettingsPage(): React.JSX.Element { @@ -97,12 +99,80 @@ export function AdminSettingsPage(): React.JSX.Element { + + ); } +/** + * 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(null); + const [saved, setSaved] = useState(false); + const [busy, setBusy] = useState(false); + + async function onSubmit(event: React.FormEvent): Promise { + 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 ( +
+

{t('settings.title')}

+
void onSubmit(event)} noValidate> + + + + setExtensions(event.target.value)} + /> + + + + + + +
+ ); +} + /** Map the instance-setting key to the shared quota key its label lives under. */ const SETTING_TO_QUOTA_KEY = { 'quota.editorsPerPond': 'editors_per_pond', diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 1e1df7f..c75f318 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -10,6 +10,7 @@ import * as Y from 'yjs'; import { useAuth } from '../auth/auth-context'; import { FormError } from '../components/forms'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; +import { AttachmentsPanel } from '../files/AttachmentsPanel'; import { HistoryPanel } from '../editor/HistoryPanel'; import { LabelPicker } from '../labels/LabelPicker'; import { BacklinksPanel } from '../links/BacklinksPanel'; @@ -48,6 +49,7 @@ function PageEditor({ const { t } = useTranslation('editor'); const { user } = useAuth(); const navigate = useNavigate(); + const [showAttachments, setShowAttachments] = useState(false); // Created and destroyed within the same effect (not `useMemo` + a separate // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would @@ -120,6 +122,24 @@ function PageEditor({
{canEdit && } +
+ +
+ {showAttachments && ( + setShowAttachments(false)} + /> + )}
{t(`connection.${collab.status}`)}
diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 5ee8deb..48017d5 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -9,6 +9,7 @@ import { LabelManager } from '../labels/LabelManager'; import { PhantomPagesView } from '../links/PhantomPagesView'; import { AccessRulesManager } from '../access/AccessRulesManager'; import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector'; +import { PondFileManager } from '../files/PondFileManager'; import { apiGet } from '../lib/api'; import { MemberManager } from '../members/MemberManager'; @@ -25,6 +26,7 @@ export function PondSettingsPage(): React.JSX.Element { const { t: tLinks } = useTranslation('links'); const { t: tMembers } = useTranslation('members'); const { t: tErrors } = useTranslation('errors'); + const { t: tFiles } = useTranslation('files'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -63,6 +65,12 @@ export function PondSettingsPage(): React.JSX.Element { )} + {canModify && ( +
+

{tFiles('manager.title')}

+ +
+ )}
); } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 9f07c66..7b78f38 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1754,3 +1754,94 @@ button { gap: 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); +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 5e240d4..7dafd1b 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -29,6 +29,7 @@ "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.", "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).", "network": "Der Server war nicht erreichbar.", "grant_exists": "Diese Berechtigung existiert bereits.", diff --git a/packages/shared/i18n/de/files.json b/packages/shared/i18n/de/files.json new file mode 100644 index 0000000..e39f374 --- /dev/null +++ b/packages/shared/i18n/de/files.json @@ -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" + } +} diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index adc4563..b2ce64c 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -29,6 +29,7 @@ "label_has_pages": "This label still has pages assigned; confirm to detach them.", "label_wrong_pond": "This label belongs to a different pond.", "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).", "network": "The server could not be reached.", "grant_exists": "This grant already exists.", diff --git a/packages/shared/i18n/en/files.json b/packages/shared/i18n/en/files.json new file mode 100644 index 0000000..2d33f97 --- /dev/null +++ b/packages/shared/i18n/en/files.json @@ -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" + } +} diff --git a/packages/shared/src/files.ts b/packages/shared/src/files.ts index 3119dbd..4cd0680 100644 --- a/packages/shared/src/files.ts +++ b/packages/shared/src/files.ts @@ -1,6 +1,7 @@ /** * 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 = [ 'image/png', @@ -11,6 +12,75 @@ export const ATTACHMENT_IMAGE_MIME_TYPES = [ 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> = { + 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, * independent of the per-pond/user `max_file_bytes` quota (QuotaService) @@ -28,3 +98,20 @@ export interface AttachmentView { sizeBytes: number; 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4008986..70583af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,12 +50,18 @@ importers: cookie-parser: specifier: ^1.4.7 version: 1.4.7 + dompurify: + specifier: ^3.4.11 + version: 3.4.11 fractional-indexing: specifier: ^4.0.0 version: 4.0.0 i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 multer: specifier: ^2.1.1 version: 2.1.1 @@ -117,6 +123,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/jsdom': + specifier: ^28.0.3 + version: 28.0.3 '@types/multer': specifier: ^2.0.0 version: 2.2.0 @@ -2176,6 +2185,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsdom@28.0.3': + resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2232,6 +2244,9 @@ packages: '@types/supertest@6.0.3': 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': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2870,6 +2885,9 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -2918,6 +2936,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} 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: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3896,6 +3918,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4793,6 +4818,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.28.0: + resolution: {integrity: sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -6972,6 +7000,13 @@ snapshots: '@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/linkify-it@5.0.0': {} @@ -7038,6 +7073,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.10 + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': {} '@types/use-sync-external-store@0.0.6': {} @@ -7729,6 +7766,10 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -7769,6 +7810,8 @@ snapshots: entities@6.0.1: {} + entities@8.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -8899,6 +8942,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -9884,6 +9931,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@7.28.0: {} + undici-types@8.3.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {}