Add file storage service and image upload API (#27)
All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m43s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s

Implements the FileStorage abstraction (uploads/<pondId>/<fileId> 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
This commit is contained in:
Claude Sonnet 5 2026-07-08 10:35:03 +02:00
parent 49beb45b3e
commit 0fae699018
20 changed files with 693 additions and 8 deletions

2
.gitignore vendored
View File

@ -8,3 +8,5 @@ coverage/
.DS_Store .DS_Store
.pnpm-store/ .pnpm-store/
test-results/ test-results/
# Local upload storage for native (non-Docker) dev runs (UPLOADS_DIR default).
apps/api/data/

View File

@ -22,6 +22,10 @@ WORKDIR /app
COPY --from=build --chown=node:node /out /app COPY --from=build --chown=node:node /out /app
# Generate the Prisma client for this image's platform. # Generate the Prisma client for this image's platform.
RUN node node_modules/prisma/build/index.js generate 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 USER node
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --retries=3 \

View File

@ -23,6 +23,7 @@
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"fractional-indexing": "^4.0.0", "fractional-indexing": "^4.0.0",
"i18next": "^26.3.4", "i18next": "^26.3.4",
"multer": "^2.1.1",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pino": "^9.6.0", "pino": "^9.6.0",
@ -44,6 +45,7 @@
"@swc/core": "^1.10.0", "@swc/core": "^1.10.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^2.0.0",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",

View File

@ -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;

View File

@ -47,6 +47,7 @@ model User {
authTokens AuthToken[] authTokens AuthToken[]
ponds Pond[] ponds Pond[]
pages Page[] pages Page[]
attachments Attachment[]
@@map("users") @@map("users")
} }
@ -76,6 +77,7 @@ model Pond {
owner User @relation(fields: [ownerId], references: [id]) owner User @relation(fields: [ownerId], references: [id])
usage PondUsage? usage PondUsage?
pages Page[] pages Page[]
attachments Attachment[]
@@index([ownerId]) @@index([ownerId])
@@map("ponds") @@map("ponds")
@ -103,6 +105,7 @@ model Page {
creator User @relation(fields: [createdBy], references: [id]) creator User @relation(fields: [createdBy], references: [id])
updates PageUpdate[] updates PageUpdate[]
contentCache PageContentCache? contentCache PageContentCache?
attachments Attachment[]
@@unique([pondId, slug]) @@unique([pondId, slug])
@@index([pondId]) @@index([pondId])
@ -175,6 +178,37 @@ model PondUsage {
@@map("pond_usage") @@map("pond_usage")
} }
/// Uploaded file (ADR 0011, issue #27). Bytes live on the uploads volume at
/// `<uploadsDir>/<pondId>/<id>` (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 /// One row per login method. `provider` is "password" today and
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for /// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
/// password identities. /// password identities.

View File

@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module';
import { ApiExceptionFilter } from './common/api-exception.filter'; import { ApiExceptionFilter } from './common/api-exception.filter';
import { AppConfig } from './config/app-config.service'; import { AppConfig } from './config/app-config.service';
import { ConfigModule } from './config/config.module'; import { ConfigModule } from './config/config.module';
import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { PagesModule } from './pages/pages.module'; import { PagesModule } from './pages/pages.module';
@ -26,6 +27,7 @@ import { UsersModule } from './users/users.module';
UsersModule, UsersModule,
PondsModule, PondsModule,
PagesModule, PagesModule,
FilesModule,
AuthModule, AuthModule,
AdminModule, AdminModule,
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({

View File

@ -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 `<uploadsDir>/<pondId>/<fileId>`, 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<void> {
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<void> {
await rm(this.pathFor(pondId, fileId), { force: true });
}
}

View File

@ -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<AttachmentView> {
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<StreamableFile> {
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<void> {
await this.files.remove(request.user!, id);
}
}

View File

@ -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<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('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('<html><body>evil</body></html>'), '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);
});
});

View File

@ -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 {}

View File

@ -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<AttachmentView> {
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<FileDownload> {
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<void> {
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',
);
}
}

View File

@ -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;
}

View File

@ -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 { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
@ -16,6 +20,9 @@ export async function createTestApp(): Promise<INestApplication> {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
} }
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent'; 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 moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleRef.createNestApplication<NestExpressApplication>(); const app = moduleRef.createNestApplication<NestExpressApplication>();

View File

@ -55,6 +55,8 @@ services:
SMTP_USER: ${SMTP_USER:-} SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-} SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>} SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
# Matches the `uploads` volume mount below (ADR 0011).
UPLOADS_DIR: /data/uploads
ports: ports:
- '127.0.0.1:${API_PORT:-8101}:3000' - '127.0.0.1:${API_PORT:-8101}:3000'
networks: [frontend, internal] networks: [frontend, internal]

View File

@ -22,6 +22,8 @@
"slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.", "slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.",
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).", "page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.", "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.", "network": "Der Server war nicht erreichbar.",
"validation": { "validation": {
"required": "Dieses Feld ist erforderlich.", "required": "Dieses Feld ist erforderlich.",

View File

@ -22,6 +22,8 @@
"slug_taken": "This slug is already taken in this pond.", "slug_taken": "This slug is already taken in this pond.",
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).", "page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
"invalid_page_state": "The submitted page content is invalid.", "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.", "network": "The server could not be reached.",
"validation": { "validation": {
"required": "This field is required.", "required": "This field is required.",

View File

@ -40,6 +40,13 @@ export const apiEnvSchema = z.object({
SMTP_USER: z.string().optional(), SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(), SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('Dorfteich <no-reply@localhost>'), SMTP_FROM: z.string().default('Dorfteich <no-reply@localhost>'),
/**
* 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<typeof apiEnvSchema>; export type ApiEnv = z.infer<typeof apiEnvSchema>;

View File

@ -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;
}

View File

@ -2,6 +2,7 @@ export * from './api-error';
export * from './auth'; export * from './auth';
export * from './editor-schema'; export * from './editor-schema';
export * from './env'; export * from './env';
export * from './files';
export * from './health'; export * from './health';
export * from './i18n-tools'; export * from './i18n-tools';
export * from './pages'; export * from './pages';

13
pnpm-lock.yaml generated
View File

@ -56,6 +56,9 @@ importers:
i18next: i18next:
specifier: ^26.3.4 specifier: ^26.3.4
version: 26.3.4(typescript@5.9.3) version: 26.3.4(typescript@5.9.3)
multer:
specifier: ^2.1.1
version: 2.1.1
nestjs-pino: nestjs-pino:
specifier: ^4.3.0 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) 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': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.6 version: 5.0.6
'@types/multer':
specifier: ^2.0.0
version: 2.2.0
'@types/nodemailer': '@types/nodemailer':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1 version: 8.0.1
@ -1627,6 +1633,9 @@ packages:
'@types/methods@1.1.4': '@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
'@types/multer@2.2.0':
resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==}
'@types/node@26.1.0': '@types/node@26.1.0':
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
@ -5129,6 +5138,10 @@ snapshots:
'@types/methods@1.1.4': {} '@types/methods@1.1.4': {}
'@types/multer@2.2.0':
dependencies:
'@types/express': 5.0.6
'@types/node@26.1.0': '@types/node@26.1.0':
dependencies: dependencies:
undici-types: 8.3.0 undici-types: 8.3.0