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
385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import request from 'supertest';
|
|
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';
|
|
|
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const pngBuffer = (payload = 'fake-but-signed png bytes'): Buffer =>
|
|
Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]);
|
|
|
|
/** superagent has no default parser for image/*; buffer the raw bytes ourselves. */
|
|
function binaryParser(
|
|
res: NodeJS.ReadableStream,
|
|
callback: (err: Error | null, body: Buffer) => void,
|
|
): void {
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
res.on('end', () => callback(null, Buffer.concat(chunks)));
|
|
}
|
|
type ParseCallback = Parameters<Test['parse']>[0];
|
|
|
|
describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
const suffix = uniqueSuffix();
|
|
const password = 'bilder hochladen ist toll 1';
|
|
|
|
const owner = { username: `fiona-files-${suffix}`, displayName: `Fiona Files ${suffix}` };
|
|
const outsider = { username: `otto-files-${suffix}`, displayName: `Otto Outside ${suffix}` };
|
|
let ownerCookie: string;
|
|
let outsiderCookie: string;
|
|
let pondId: string;
|
|
|
|
const api = () => request(app.getHttpServer());
|
|
|
|
async function loginOf(username: string): Promise<string> {
|
|
const res = await api()
|
|
.post('/api/v1/auth/login')
|
|
.send({ usernameOrEmail: username, password })
|
|
.expect(200);
|
|
return sessionCookieOf(res);
|
|
}
|
|
|
|
async function setPondOverride(quotaKey: string, value: number): Promise<void> {
|
|
await prisma.quotaOverride.upsert({
|
|
where: {
|
|
subjectType_subjectId_quotaKey: { subjectType: 'POND', subjectId: pondId, quotaKey },
|
|
},
|
|
create: { subjectType: 'POND', subjectId: pondId, quotaKey, value },
|
|
update: { value },
|
|
});
|
|
}
|
|
|
|
async function clearPondOverrides(): Promise<void> {
|
|
await prisma.quotaOverride.deleteMany({ where: { subjectType: 'POND', subjectId: pondId } });
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
await prisma.rateLimit.deleteMany({});
|
|
app = await createTestApp();
|
|
const users = app.get(UsersService);
|
|
const tokens = app.get(AuthTokensService);
|
|
|
|
const ownerUser = await users.createUser({
|
|
username: owner.username,
|
|
email: `${owner.username}@example.org`,
|
|
displayName: owner.displayName,
|
|
password,
|
|
locale: 'en',
|
|
});
|
|
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
|
|
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
|
|
ownerCookie = await loginOf(owner.username);
|
|
|
|
const outsiderUser = await users.createUser({
|
|
username: outsider.username,
|
|
email: `${outsider.username}@example.org`,
|
|
displayName: outsider.displayName,
|
|
password,
|
|
locale: 'en',
|
|
});
|
|
await users.markEmailVerified(outsiderUser.id);
|
|
outsiderCookie = await loginOf(outsider.username);
|
|
|
|
const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
|
|
pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.attachment.deleteMany({ where: { pondId } });
|
|
await prisma.page.deleteMany({
|
|
where: { pond: { owner: { username: { contains: suffix } } } },
|
|
});
|
|
const users = await prisma.user.findMany({
|
|
where: { username: { contains: suffix } },
|
|
select: { id: true },
|
|
});
|
|
await prisma.quotaOverride.deleteMany({
|
|
where: { subjectId: { in: [pondId, ...users.map((u) => u.id)] } },
|
|
});
|
|
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('roundtrips an uploaded image: same bytes, sniffed content type', async () => {
|
|
const bytes = pngBuffer('roundtrip');
|
|
const uploaded = await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', ownerCookie)
|
|
.attach('file', bytes, 'photo.png')
|
|
.expect(201);
|
|
expect(uploaded.body.mimeType).toBe('image/png');
|
|
expect(uploaded.body.sizeBytes).toBe(bytes.length);
|
|
|
|
const served = await api()
|
|
.get(`/api/v1/media/${uploaded.body.id}`)
|
|
.set('Cookie', ownerCookie)
|
|
.buffer(true)
|
|
.parse(binaryParser as unknown as ParseCallback)
|
|
.expect(200);
|
|
expect(served.headers['content-type']).toBe('image/png');
|
|
expect(served.headers['x-content-type-options']).toBe('nosniff');
|
|
expect(Buffer.compare(served.body, bytes)).toBe(0);
|
|
});
|
|
|
|
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('binary junk'), 'malware.exe')
|
|
.expect(400);
|
|
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 (#61)', async () => {
|
|
const res = await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', ownerCookie)
|
|
.attach('file', Buffer.from('<html><body>evil</body></html>'), 'sneaky.png')
|
|
.expect(400);
|
|
// 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 () => {
|
|
await setPondOverride('max_file_bytes', 10);
|
|
try {
|
|
const res = await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', ownerCookie)
|
|
.attach('file', pngBuffer('this payload is well beyond ten bytes'), 'big.png')
|
|
.expect(413);
|
|
expect(res.body.code).toBe('file_too_large');
|
|
expect(res.body.details.limitBytes).toBe(10);
|
|
} finally {
|
|
await clearPondOverrides();
|
|
}
|
|
});
|
|
|
|
it('rejects uploads that exceed the storage quota', async () => {
|
|
await setPondOverride('max_file_bytes', 10_000);
|
|
await setPondOverride('storage_bytes', 5);
|
|
try {
|
|
const res = await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', ownerCookie)
|
|
.attach('file', pngBuffer('needs more than five bytes of budget'), 'photo.png')
|
|
.expect(403);
|
|
expect(res.body.code).toBe('quota_exceeded');
|
|
expect(res.body.details.quotaKey).toBe('storage_bytes');
|
|
} finally {
|
|
await clearPondOverrides();
|
|
}
|
|
});
|
|
|
|
it('releases quota and removes the on-disk bytes on delete', async () => {
|
|
const before = await prisma.pondUsage.findUnique({ where: { pondId } });
|
|
const startUsage = Number(before?.storageBytesUsed ?? 0);
|
|
|
|
const bytes = pngBuffer('to be deleted');
|
|
const uploaded = await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', ownerCookie)
|
|
.attach('file', bytes, 'delete-me.png')
|
|
.expect(201);
|
|
|
|
const afterUpload = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
|
|
expect(Number(afterUpload.storageBytesUsed)).toBe(startUsage + bytes.length);
|
|
|
|
const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id);
|
|
expect(existsSync(filePath)).toBe(true);
|
|
|
|
await api().delete(`/api/v1/files/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(204);
|
|
|
|
expect(existsSync(filePath)).toBe(false);
|
|
const afterDelete = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } });
|
|
expect(Number(afterDelete.storageBytesUsed)).toBe(startUsage);
|
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(404);
|
|
});
|
|
|
|
it('keeps a file reachable after its page is soft-deleted (no eager purge)', async () => {
|
|
const page = await api()
|
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
|
.set('Cookie', ownerCookie)
|
|
.send({ title: `With Attachment ${suffix}` })
|
|
.expect(201);
|
|
|
|
const uploaded = await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', ownerCookie)
|
|
.attach('file', pngBuffer('page-attached'), 'inline.png')
|
|
.expect(201);
|
|
// #27 does not expose pageId on the upload endpoint yet (it is set once
|
|
// a future story tracks a page's embedded attachments) — associate it
|
|
// directly to exercise the "stays until purge" guarantee now.
|
|
await prisma.attachment.update({
|
|
where: { id: uploaded.body.id },
|
|
data: { pageId: page.body.id },
|
|
});
|
|
|
|
await api().delete(`/api/v1/pages/${page.body.id}`).set('Cookie', ownerCookie).expect(204);
|
|
|
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200);
|
|
const stillThere = await prisma.attachment.findUnique({ where: { id: uploaded.body.id } });
|
|
expect(stillThere).not.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 () => {
|
|
const uploaded = await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', ownerCookie)
|
|
.attach('file', pngBuffer('private'), 'private.png')
|
|
.expect(201);
|
|
|
|
await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', outsiderCookie).expect(404);
|
|
await api()
|
|
.delete(`/api/v1/files/${uploaded.body.id}`)
|
|
.set('Cookie', outsiderCookie)
|
|
.expect(404);
|
|
await api()
|
|
.post(`/api/v1/ponds/${pondId}/files`)
|
|
.set('Cookie', outsiderCookie)
|
|
.attach('file', pngBuffer('sneaky'), 'sneaky.png')
|
|
.expect(404);
|
|
});
|
|
});
|