From 0fae699018955fc5ab76d6f972be08255030723e Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Wed, 8 Jul 2026 10:35:03 +0200 Subject: [PATCH] Add file storage service and image upload API (#27) Implements the FileStorage abstraction (uploads// on the mounted volume), the attachments model, and POST /ponds/:id/files, GET /media/:fileId, DELETE /files/:id. Uploads are validated by sniffing magic bytes rather than trusting the client's Content-Type/filename (catches a renamed .html-as-.png), checked against the max_file_bytes and storage_bytes quotas, and served with nosniff + immutable caching. Closes #27 --- .gitignore | 2 + apps/api/Dockerfile | 4 + apps/api/package.json | 2 + .../20260708082627_attachments/migration.sql | 30 ++ apps/api/prisma/schema.prisma | 50 +++- apps/api/src/app.module.ts | 2 + apps/api/src/files/file-storage.service.ts | 37 +++ apps/api/src/files/files.controller.ts | 61 +++++ apps/api/src/files/files.e2e.db.test.ts | 257 ++++++++++++++++++ apps/api/src/files/files.module.ts | 15 + apps/api/src/files/files.service.ts | 135 +++++++++ apps/api/src/files/magic-bytes.ts | 42 +++ apps/api/src/testing/test-app.ts | 7 + deploy/compose/docker-compose.yml | 2 + packages/shared/i18n/de/errors.json | 2 + packages/shared/i18n/en/errors.json | 2 + packages/shared/src/env.ts | 7 + packages/shared/src/files.ts | 30 ++ packages/shared/src/index.ts | 1 + pnpm-lock.yaml | 13 + 20 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 apps/api/prisma/migrations/20260708082627_attachments/migration.sql create mode 100644 apps/api/src/files/file-storage.service.ts create mode 100644 apps/api/src/files/files.controller.ts create mode 100644 apps/api/src/files/files.e2e.db.test.ts create mode 100644 apps/api/src/files/files.module.ts create mode 100644 apps/api/src/files/files.service.ts create mode 100644 apps/api/src/files/magic-bytes.ts create mode 100644 packages/shared/src/files.ts diff --git a/.gitignore b/.gitignore index 95c0623..dfd5a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ coverage/ .DS_Store .pnpm-store/ test-results/ +# Local upload storage for native (non-Docker) dev runs (UPLOADS_DIR default). +apps/api/data/ diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index cf5409c..0d6439f 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -22,6 +22,10 @@ WORKDIR /app COPY --from=build --chown=node:node /out /app # Generate the Prisma client for this image's platform. RUN node node_modules/prisma/build/index.js generate +# A fresh named volume mounted at /data/uploads is created root-owned; +# pre-creating it here (Docker copies an image directory's ownership into +# a new volume on first mount) lets the non-root `node` user write to it. +RUN mkdir -p /data/uploads && chown -R node:node /data/uploads USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ diff --git a/apps/api/package.json b/apps/api/package.json index f9b14e2..3cc57b8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,7 @@ "cookie-parser": "^1.4.7", "fractional-indexing": "^4.0.0", "i18next": "^26.3.4", + "multer": "^2.1.1", "nestjs-pino": "^4.3.0", "nodemailer": "^9.0.3", "pino": "^9.6.0", @@ -44,6 +45,7 @@ "@swc/core": "^1.10.0", "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", + "@types/multer": "^2.0.0", "@types/nodemailer": "^8.0.1", "@types/supertest": "^6.0.0", "pino-pretty": "^13.0.0", diff --git a/apps/api/prisma/migrations/20260708082627_attachments/migration.sql b/apps/api/prisma/migrations/20260708082627_attachments/migration.sql new file mode 100644 index 0000000..67f6b99 --- /dev/null +++ b/apps/api/prisma/migrations/20260708082627_attachments/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "attachments" ( + "id" TEXT NOT NULL, + "pond_id" TEXT NOT NULL, + "page_id" TEXT, + "file_name" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size_bytes" INTEGER NOT NULL, + "storage_path" TEXT NOT NULL, + "uploaded_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "attachments_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "attachments_pond_id_idx" ON "attachments"("pond_id"); + +-- CreateIndex +CREATE INDEX "attachments_page_id_idx" ON "attachments"("page_id"); + +-- AddForeignKey +ALTER TABLE "attachments" ADD CONSTRAINT "attachments_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "attachments" ADD CONSTRAINT "attachments_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "attachments" ADD CONSTRAINT "attachments_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d0bfbf1..477b693 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -42,11 +42,12 @@ model User { createdAt DateTime @default(now()) @map("created_at") lastLoginAt DateTime? @map("last_login_at") - identities UserIdentity[] - sessions Session[] - authTokens AuthToken[] - ponds Pond[] - pages Page[] + identities UserIdentity[] + sessions Session[] + authTokens AuthToken[] + ponds Pond[] + pages Page[] + attachments Attachment[] @@map("users") } @@ -73,9 +74,10 @@ model Pond { deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") - owner User @relation(fields: [ownerId], references: [id]) - usage PondUsage? - pages Page[] + owner User @relation(fields: [ownerId], references: [id]) + usage PondUsage? + pages Page[] + attachments Attachment[] @@index([ownerId]) @@map("ponds") @@ -103,6 +105,7 @@ model Page { creator User @relation(fields: [createdBy], references: [id]) updates PageUpdate[] contentCache PageContentCache? + attachments Attachment[] @@unique([pondId, slug]) @@index([pondId]) @@ -175,6 +178,37 @@ model PondUsage { @@map("pond_usage") } +/// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at +/// `//` (FileStorageService); this row carries the +/// metadata needed to serve and account for it. `pageId` is nullable and +/// left unset by #27's endpoints — images are uploaded before the page +/// referencing them is known (paste-then-insert, issue #28); a later story +/// wires pages to set it once they track their embedded attachments. +/// `deletedAt` is unused by #27 (its `DELETE /files/:id` hard-deletes +/// immediately, bytes and all) — reserved for the page-trash-purge flow +/// (#31, ADR 0011 "orphan cleanup"), which soft-deletes an attachment when +/// its page is purged before the nightly job physically removes it. +model Attachment { + id String @id @default(uuid()) + pondId String @map("pond_id") + pageId String? @map("page_id") + fileName String @map("file_name") + mimeType String @map("mime_type") + sizeBytes Int @map("size_bytes") + storagePath String @map("storage_path") + uploadedBy String @map("uploaded_by") + createdAt DateTime @default(now()) @map("created_at") + deletedAt DateTime? @map("deleted_at") + + pond Pond @relation(fields: [pondId], references: [id]) + page Page? @relation(fields: [pageId], references: [id]) + uploader User @relation(fields: [uploadedBy], references: [id]) + + @@index([pondId]) + @@index([pageId]) + @@map("attachments") +} + /// One row per login method. `provider` is "password" today and /// "oidc:" later; `credential` holds the Argon2id hash for /// password identities. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f8f3393..bbabaca 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module'; import { ApiExceptionFilter } from './common/api-exception.filter'; import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; +import { FilesModule } from './files/files.module'; import { HealthModule } from './health/health.module'; import { MailModule } from './mail/mail.module'; import { PagesModule } from './pages/pages.module'; @@ -26,6 +27,7 @@ import { UsersModule } from './users/users.module'; UsersModule, PondsModule, PagesModule, + FilesModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/files/file-storage.service.ts b/apps/api/src/files/file-storage.service.ts new file mode 100644 index 0000000..7b6dedb --- /dev/null +++ b/apps/api/src/files/file-storage.service.ts @@ -0,0 +1,37 @@ +import { createReadStream } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { Readable } from 'node:stream'; + +import { Injectable } from '@nestjs/common'; + +import { AppConfig } from '../config/app-config.service'; + +/** + * Filesystem binding for uploaded files (ADR 0011, issue #27): opaque + * layout `//`, original filenames and metadata + * live in the database, not on disk. Kept behind this interface so an S3 + * binding stays possible later without touching callers. + */ +@Injectable() +export class FileStorageService { + constructor(private readonly config: AppConfig) {} + + private pathFor(pondId: string, fileId: string): string { + return join(this.config.env.UPLOADS_DIR, pondId, fileId); + } + + async save(pondId: string, fileId: string, data: Buffer): Promise { + await mkdir(join(this.config.env.UPLOADS_DIR, pondId), { recursive: true }); + await writeFile(this.pathFor(pondId, fileId), data); + } + + createReadStream(pondId: string, fileId: string): Readable { + return createReadStream(this.pathFor(pondId, fileId)); + } + + /** Idempotent — removing an already-absent file is not an error. */ + async delete(pondId: string, fileId: string): Promise { + await rm(this.pathFor(pondId, fileId), { force: true }); + } +} diff --git a/apps/api/src/files/files.controller.ts b/apps/api/src/files/files.controller.ts new file mode 100644 index 0000000..39c2e95 --- /dev/null +++ b/apps/api/src/files/files.controller.ts @@ -0,0 +1,61 @@ +import { + BadRequestException, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, + Req, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AttachmentView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared'; +import type { Response } from 'express'; + +import { AuthedRequest } from '../auth/auth.guard'; + +import { FilesService } from './files.service'; + +/** File storage and image-upload API (issue #27). */ +@Controller() +export class FilesController { + constructor(private readonly files: FilesService) {} + + @Post('ponds/:pondId/files') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } })) + async upload( + @Param('pondId') pondId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Req() request: AuthedRequest, + ): Promise { + if (!file) throw new BadRequestException({ code: 'bad_request' }); + return this.files.upload(request.user!, pondId, file); + } + + /** Permission-checked file streaming (ADR 0011) — never served same-origin as executable content. */ + @Get('media/:fileId') + async download( + @Param('fileId') fileId: string, + @Req() request: AuthedRequest, + @Res({ passthrough: true }) response: Response, + ): Promise { + const { attachment, stream } = await this.files.download(request.user!, 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'); + return new StreamableFile(stream, { + type: attachment.mimeType, + disposition: `inline; filename="${encodeURIComponent(attachment.fileName)}"`, + }); + } + + @Delete('files/:id') + @HttpCode(204) + async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.files.remove(request.user!, id); + } +} diff --git a/apps/api/src/files/files.e2e.db.test.ts b/apps/api/src/files/files.e2e.db.test.ts new file mode 100644 index 0000000..53d47b0 --- /dev/null +++ b/apps/api/src/files/files.e2e.db.test.ts @@ -0,0 +1,257 @@ +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 { 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[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 { + 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 { + 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 { + 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('rejects non-image uploads', 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') + .expect(400); + expect(res.body.code).toBe('unsupported_file_type'); + }); + + it('rejects a renamed .html-as-.png via the magic-byte check', 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'); + }); + + 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('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); + }); +}); diff --git a/apps/api/src/files/files.module.ts b/apps/api/src/files/files.module.ts new file mode 100644 index 0000000..51499cf --- /dev/null +++ b/apps/api/src/files/files.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { PondsModule } from '../ponds/ponds.module'; +import { QuotasModule } from '../quotas/quotas.module'; + +import { FileStorageService } from './file-storage.service'; +import { FilesController } from './files.controller'; +import { FilesService } from './files.service'; + +@Module({ + imports: [PondsModule, QuotasModule], + controllers: [FilesController], + providers: [FilesService, FileStorageService], +}) +export class FilesModule {} diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts new file mode 100644 index 0000000..378d80a --- /dev/null +++ b/apps/api/src/files/files.service.ts @@ -0,0 +1,135 @@ +import { randomUUID } from 'node:crypto'; +import type { Readable } from 'node:stream'; + +import { + BadRequestException, + Injectable, + NotFoundException, + PayloadTooLargeException, +} from '@nestjs/common'; +import { AttachmentView } from '@dorfteich/shared'; +import { Attachment, User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { InterimAccessService } from '../ponds/interim-access.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { QuotaService } from '../quotas/quota.service'; + +import { FileStorageService } from './file-storage.service'; +import { sniffImageMimeType } from './magic-bytes'; + +export interface FileDownload { + attachment: Attachment; + stream: Readable; +} + +/** File storage and image-upload API (issue #27, ADR 0011). */ +@Injectable() +export class FilesService { + constructor( + private readonly prisma: PrismaService, + private readonly access: InterimAccessService, + private readonly quotas: QuotaService, + private readonly storage: FileStorageService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(FilesService.name); + } + + viewOf(attachment: Attachment): AttachmentView { + return { + id: attachment.id, + pondId: attachment.pondId, + pageId: attachment.pageId, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + createdAt: attachment.createdAt.toISOString(), + }; + } + + async upload( + user: User, + pondId: string, + file: { buffer: Buffer; size: number; originalname: string }, + ): Promise { + const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); + this.access.assertCanModify(user, pond); + + // 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, + }); + if (file.size > maxFileBytes) { + throw new PayloadTooLargeException({ + code: 'file_too_large', + details: { limitBytes: maxFileBytes }, + }); + } + + 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); + + try { + await this.storage.save(pond.id, id, file.buffer); + const attachment = await this.prisma.attachment.create({ + data: { + id, + pondId: pond.id, + fileName: file.originalname, + mimeType, + sizeBytes: file.size, + storagePath: `${pond.id}/${id}`, + uploadedBy: user.id, + }, + }); + this.logger.info( + { attachmentId: id, pondId: pond.id, 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.storage.delete(pond.id, id); + throw error; + } + } + + async download(user: User, id: string): Promise { + const attachment = await this.prisma.attachment.findFirst({ + where: { id }, + include: { pond: true }, + }); + if (!attachment) throw new NotFoundException(); + this.access.assertCanSee(user, attachment.pond); + return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) }; + } + + async remove(user: User, id: string): Promise { + const attachment = await this.prisma.attachment.findFirst({ + where: { id }, + include: { pond: true }, + }); + if (!attachment) throw new NotFoundException(); + this.access.assertCanModify(user, attachment.pond); + + await this.prisma.attachment.delete({ where: { id: attachment.id } }); + await this.storage.delete(attachment.pondId, attachment.id); + await this.quotas.release(attachment.pondId, attachment.sizeBytes); + this.logger.info( + { attachmentId: id, pondId: attachment.pondId, userId: user.id }, + 'audit: file deleted', + ); + } +} diff --git a/apps/api/src/files/magic-bytes.ts b/apps/api/src/files/magic-bytes.ts new file mode 100644 index 0000000..1e729dc --- /dev/null +++ b/apps/api/src/files/magic-bytes.ts @@ -0,0 +1,42 @@ +import type { AttachmentMimeType } from '@dorfteich/shared'; + +/** + * Magic-byte signatures for the M2 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. + */ +const SIGNATURES: ReadonlyArray<{ + mimeType: AttachmentMimeType; + matches: (buf: Buffer) => boolean; +}> = [ + { + mimeType: 'image/png', + matches: (buf) => + buf.length >= 8 && + buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), + }, + { + mimeType: 'image/jpeg', + matches: (buf) => buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff, + }, + { + mimeType: 'image/gif', + matches: (buf) => + buf.length >= 6 && + (buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a'), + }, + { + mimeType: 'image/webp', + matches: (buf) => + buf.length >= 12 && + buf.toString('ascii', 0, 4) === 'RIFF' && + buf.toString('ascii', 8, 12) === 'WEBP', + }, +]; + +/** Returns the sniffed 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; +} diff --git a/apps/api/src/testing/test-app.ts b/apps/api/src/testing/test-app.ts index a666150..a9854e1 100644 --- a/apps/api/src/testing/test-app.ts +++ b/apps/api/src/testing/test-app.ts @@ -1,3 +1,7 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import type { NestExpressApplication } from '@nestjs/platform-express'; @@ -16,6 +20,9 @@ export async function createTestApp(): Promise { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; } process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent'; + // Fresh scratch directory per test file so upload tests never touch the + // repository or collide with each other. + process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-')); const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); const app = moduleRef.createNestApplication(); diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 07d9196..e980ced 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -55,6 +55,8 @@ services: SMTP_USER: ${SMTP_USER:-} SMTP_PASS: ${SMTP_PASS:-} SMTP_FROM: ${SMTP_FROM:-Dorfteich } + # Matches the `uploads` volume mount below (ADR 0011). + UPLOADS_DIR: /data/uploads ports: - '127.0.0.1:${API_PORT:-8101}:3000' networks: [frontend, internal] diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index ee64ce4..70a89b1 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -22,6 +22,8 @@ "slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.", "page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).", "invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.", + "unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.", + "file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).", "network": "Der Server war nicht erreichbar.", "validation": { "required": "Dieses Feld ist erforderlich.", diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 55aba23..0efa574 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -22,6 +22,8 @@ "slug_taken": "This slug is already taken in this pond.", "page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).", "invalid_page_state": "The submitted page content is invalid.", + "unsupported_file_type": "This file type is not supported.", + "file_too_large": "The file is too large (limit: {{limitBytes}} bytes).", "network": "The server could not be reached.", "validation": { "required": "This field is required.", diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index dbfe598..1001d7e 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -40,6 +40,13 @@ export const apiEnvSchema = z.object({ SMTP_USER: z.string().optional(), SMTP_PASS: z.string().optional(), SMTP_FROM: z.string().default('Dorfteich '), + /** + * Filesystem root for uploaded files (ADR 0011). The compose stack + * mounts the `uploads` volume at `/data/uploads` and sets this + * explicitly; the relative default only serves native (non-Docker) + * dev/test runs. + */ + UPLOADS_DIR: z.string().min(1).default('./data/uploads'), }); export type ApiEnv = z.infer; diff --git a/packages/shared/src/files.ts b/packages/shared/src/files.ts new file mode 100644 index 0000000..3119dbd --- /dev/null +++ b/packages/shared/src/files.ts @@ -0,0 +1,30 @@ +/** + * Attachment types shared between api and web (issue #27, ADR 0011). M2 + * accepts images only; the general type allowlist arrives in M6 (#61). + */ +export const ATTACHMENT_IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', +] as const; + +export type AttachmentMimeType = (typeof ATTACHMENT_IMAGE_MIME_TYPES)[number]; + +/** + * Hard ceiling on the raw multipart body the api will buffer in memory, + * independent of the per-pond/user `max_file_bytes` quota (QuotaService) + * that governs the actually accepted size — mirrors how + * `MAX_PAGE_DOCUMENT_BYTES` relates to the JSON body-parser limit (pages.ts). + */ +export const MAX_UPLOAD_PARSE_BYTES = 64 * 1024 * 1024; + +export interface AttachmentView { + id: string; + pondId: string; + pageId: string | null; + fileName: string; + mimeType: string; + sizeBytes: number; + createdAt: string; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e6c8797..de17541 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,7 @@ export * from './api-error'; export * from './auth'; export * from './editor-schema'; export * from './env'; +export * from './files'; export * from './health'; export * from './i18n-tools'; export * from './pages'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ef0976..3121293 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) + multer: + specifier: ^2.1.1 + version: 2.1.1 nestjs-pino: specifier: ^4.3.0 version: 4.6.1(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.2) @@ -114,6 +117,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/multer': + specifier: ^2.0.0 + version: 2.2.0 '@types/nodemailer': specifier: ^8.0.1 version: 8.0.1 @@ -1627,6 +1633,9 @@ packages: '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} @@ -5129,6 +5138,10 @@ snapshots: '@types/methods@1.1.4': {} + '@types/multer@2.2.0': + dependencies: + '@types/express': 5.0.6 + '@types/node@26.1.0': dependencies: undici-types: 8.3.0