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
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:
parent
49beb45b3e
commit
0fae699018
2
.gitignore
vendored
2
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
/// `<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
|
||||
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
|
||||
/// password identities.
|
||||
|
||||
@ -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({
|
||||
|
||||
37
apps/api/src/files/file-storage.service.ts
Normal file
37
apps/api/src/files/file-storage.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
61
apps/api/src/files/files.controller.ts
Normal file
61
apps/api/src/files/files.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
257
apps/api/src/files/files.e2e.db.test.ts
Normal file
257
apps/api/src/files/files.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
apps/api/src/files/files.module.ts
Normal file
15
apps/api/src/files/files.module.ts
Normal 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 {}
|
||||
135
apps/api/src/files/files.service.ts
Normal file
135
apps/api/src/files/files.service.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
42
apps/api/src/files/magic-bytes.ts
Normal file
42
apps/api/src/files/magic-bytes.ts
Normal 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;
|
||||
}
|
||||
@ -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<INestApplication> {
|
||||
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<NestExpressApplication>();
|
||||
|
||||
@ -55,6 +55,8 @@ services:
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-Dorfteich <no-reply@localhost>}
|
||||
# Matches the `uploads` volume mount below (ADR 0011).
|
||||
UPLOADS_DIR: /data/uploads
|
||||
ports:
|
||||
- '127.0.0.1:${API_PORT:-8101}:3000'
|
||||
networks: [frontend, internal]
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 <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>;
|
||||
|
||||
30
packages/shared/src/files.ts
Normal file
30
packages/shared/src/files.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user