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', ); } }