import { createHash, randomUUID } from 'node:crypto'; import { Readable } from 'node:stream'; import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, PayloadTooLargeException, } from '@nestjs/common'; import { ATTACHMENT_EXTENSION_MIME_TYPES, AttachmentListItemView, AttachmentView, PondFilesView, PageClassification, SVG_MIME_TYPE, classificationFilenamePrefix, fileExtension, isImageMimeType, } from '@dorfteich/shared'; import { Attachment, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService } from '../quotas/quota.service'; import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { FileStorageService } from './file-storage.service'; import { looksLikeSvg, sniffImageMimeType } from './magic-bytes'; import { sanitizeSvg } from './svg-sanitize'; export interface FileDownload { attachment: Attachment; stream: Readable; /** Raster images render inline (they are embedded in pages); everything * else — office files, PDFs, and SVG — is always sent as a download so it * can never execute inline (ADR 0011, security.md §Uploads). */ inline: boolean; /** The filename for the Content-Disposition (issue #212, ADR 0022): the * original name, prefixed `VS-NfD_` when the attachment's effective * classification is vs_nfd — the one marker an arbitrary binary can * carry. The file's CONTENT stays unmarked (documented residual risk). */ downloadName: string; } /** What the upload bytes resolved to after allowlist + SVG handling. */ interface ResolvedUpload { mimeType: string; /** The bytes to store — identical to the input except for a sanitized SVG. */ buffer: Buffer; } /** File storage, upload, and attachment listing API (issue #27, #61, ADR 0011). */ @Injectable() export class FilesService { constructor( private readonly prisma: PrismaService, private readonly quotas: QuotaService, private readonly storage: FileStorageService, private readonly settings: InstanceSettingsService, private readonly audit: AuditService, private readonly readTrail: ReadTrailService, 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(), }; } /** * Decide the served MIME type and the bytes to persist. Raster images pass * the magic-byte sniff and are always allowed. An SVG is sanitized (its * scripts/handlers stripped) or rejected per the instance policy. Anything * else is admitted only if its extension is on the configurable allowlist; * its bytes are never trusted to be inert, but it is only ever downloaded, * never rendered. */ private async resolveUpload(fileName: string, buffer: Buffer): Promise { const rasterMime = sniffImageMimeType(buffer); if (rasterMime) return { mimeType: rasterMime, buffer }; if (looksLikeSvg(buffer)) { const policy = await this.settings.get('upload.svgPolicy'); if (policy === 'reject') { throw new BadRequestException({ code: 'upload_type_not_allowed' }); } let clean: string; try { clean = sanitizeSvg(buffer.toString('utf8')); } catch { throw new BadRequestException({ code: 'upload_type_not_allowed' }); } return { mimeType: SVG_MIME_TYPE, buffer: Buffer.from(clean, 'utf8') }; } const ext = fileExtension(fileName); const allowed = await this.settings.get('upload.allowedExtensions'); if (!ext || !allowed.includes(ext)) { throw new BadRequestException({ code: 'upload_type_not_allowed', details: { allowed }, }); } return { mimeType: ATTACHMENT_EXTENSION_MIME_TYPES[ext] ?? 'application/octet-stream', buffer }; } /** * Store an upload against a pond, optionally linked to a page. `pageId` is * set for a page attachment (uploaded from the page's attachments section) * so it is listed there and purged with the page; image pastes leave it null * and the collab persistence hook links them to whichever page embeds them. */ async upload( user: User, pondId: string, file: { buffer: Buffer; size: number; originalname: string }, pageId?: string, ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); 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 resolved = await this.resolveUpload(file.originalname, file.buffer); // Account for what actually lands on the volume (a sanitized SVG is // usually smaller than the upload) so pond_usage matches disk exactly. const sizeBytes = resolved.buffer.length; 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, sizeBytes); try { await this.storage.save(pond.id, id, resolved.buffer); const attachment = await this.prisma.attachment.create({ data: { id, pondId: pond.id, pageId: pageId ?? null, fileName: file.originalname, mimeType: resolved.mimeType, sizeBytes, storagePath: `${pond.id}/${id}`, uploadedBy: user.id, // Integrity hash (issue #199): computed from the exact in-memory // bytes that were just written — never by re-reading the disk. sha256: createHash('sha256').update(resolved.buffer).digest('hex'), }, }); this.logger.info( { attachmentId: id, pondId: pond.id, pageId: pageId ?? null, 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, sizeBytes); await this.storage.delete(pond.id, id); throw error; } } /** * Point a set of just-uploaded attachments at the page that now embeds them * (issue #63 import). The import worker stores a document's media before the * page exists (the page's Yjs state references their ids), then calls this so * they list under the page and are purged with it (#31), the same invariant * the collab persistence hook maintains for pasted images. */ async linkAttachmentsToPage(attachmentIds: string[], pageId: string): Promise { if (attachmentIds.length === 0) return; await this.prisma.attachment.updateMany({ where: { id: { in: attachmentIds } }, data: { pageId }, }); } /** Upload against the pond of `pageId`, linked to that page (#61). */ async uploadToPage( user: User, pageId: string, file: { buffer: Buffer; size: number; originalname: string }, ): Promise { const page = await this.prisma.page.findFirst({ where: { id: pageId } }); if (!page) throw new NotFoundException(); // Attaching to a classified page (issue #213, ADR 0022): the file will // inherit a classification its content cannot carry (#212). The UI warns; // the instance can harden the warning into a server-side block — enforced // HERE, not only client-side. if (page.classification === 'VS_NFD') { const policy = await this.settings.get('classification.uploadPolicy'); if (policy === 'block') { throw new ForbiddenException({ code: 'classified_upload_blocked' }); } } return this.upload(user, page.pondId, file, page.id); } /** * Serve an attachment, verifying its integrity first (issue #199): the * whole object is read and hashed BEFORE the first byte leaves — a stream * cannot be un-sent, so verification must precede serving. Memory is * bounded by the `max_file_bytes` quota that gated the upload. A mismatch * fails closed with its own error code and lands in the audit trail (a * security event, not content activity); the operator's move is a restore * from backup (runbook). Rows that predate #199 (sha256 still null until * the nightly backfill reaches them) are served unverified — that is the * pre-#199 status quo, not a downgrade. */ async download(_user: User | null, id: string, read: ReadActor): Promise { const attachment = await this.prisma.attachment.findFirst({ where: { id } }); if (!attachment) throw new NotFoundException(); const buffer = await this.storage.read(attachment.pondId, attachment.id).catch(() => null); if (!buffer) throw new NotFoundException(); if (attachment.sha256) { const actual = createHash('sha256').update(buffer).digest('hex'); if (actual !== attachment.sha256) { await this.audit.record({ action: 'file.integrity_failed', targetType: 'attachment', targetId: attachment.id, details: { pondId: attachment.pondId, expected: attachment.sha256, actual }, }); throw new InternalServerErrorException({ code: 'attachment_integrity_failure' }); } } const classification = await this.effectiveClassification(attachment); // Read trail (issue #222): a download whose effective classification is // vs_nfd (#212 semantics — page level, pond max when page-less) is a read // of classified content. `pageId` may be null for pond-level files; the // attachment id in `details` keeps the object identifiable. if (classification === 'vs_nfd') { await this.readTrail.record({ ...read, pageId: attachment.pageId, pondId: attachment.pondId, channel: 'attachment', details: { attachmentId: attachment.id }, }); } return { attachment, stream: Readable.from(buffer), inline: isImageMimeType(attachment.mimeType), downloadName: `${classificationFilenamePrefix(classification)}${attachment.fileName}`, }; } /** * The classification an attachment inherits (issue #212, ADR 0022): its * page's level. An attachment whose `pageId` is still unset * (paste-then-insert, pond-level files) FAILS CLOSED to the highest level * of any live page in its pond — it could belong to any of them, so it is * treated as classified as the most classified candidate. In an all-open * pond that is `unclassified`, so nothing gets marked noise. */ private async effectiveClassification(attachment: Attachment): Promise { if (attachment.pageId) { const page = await this.prisma.page.findUnique({ where: { id: attachment.pageId }, select: { classification: true }, }); if (page) return page.classification.toLowerCase() as PageClassification; // Page row gone but link set (race with purge): fall through to the // pond-wide fail-closed answer below. } const classified = await this.prisma.page.findFirst({ where: { pondId: attachment.pondId, deletedAt: null, classification: 'VS_NFD' }, select: { id: true }, }); return classified ? 'vs_nfd' : 'unclassified'; } /** * Hash attachments that predate #199 (sha256 null), a bounded batch per * nightly run until none remain — idempotent by construction (hashed rows * stop matching). An unreadable file is reported (log + count) and left * null so the next run retries it; the orphan sweep is the mechanism that * eventually explains truly missing bytes. */ async backfillHashes(limit = 1000): Promise<{ hashed: number; unreadable: number }> { const rows = await this.prisma.attachment.findMany({ where: { sha256: null }, select: { id: true, pondId: true }, take: limit, }); let hashed = 0; let unreadable = 0; for (const row of rows) { let buffer: Buffer; try { buffer = await this.storage.read(row.pondId, row.id); } catch (error) { unreadable += 1; this.logger.error( { attachmentId: row.id, pondId: row.pondId, err: error }, 'attachment unreadable during hash backfill; will retry next run', ); continue; } await this.prisma.attachment.update({ where: { id: row.id }, data: { sha256: createHash('sha256').update(buffer).digest('hex') }, }); hashed += 1; } if (rows.length > 0) { this.logger.info( { hashed, unreadable, batch: rows.length, batchLimit: limit }, 'audit: attachment hash backfill progress', ); } return { hashed, unreadable }; } /** Attachments linked to a page, for its attachments section (#61). */ async listForPage(pageId: string): Promise { const rows = await this.prisma.attachment.findMany({ where: { pageId }, orderBy: { createdAt: 'desc' }, include: { uploader: true, page: true }, }); return rows.map((row) => this.listItemOf(row)); } /** Every file in a pond, for the Pond Admin file manager (#61). */ async listForPond(pondId: string): Promise { const [rows, usage, storageBytesLimit] = await Promise.all([ this.prisma.attachment.findMany({ where: { pondId }, orderBy: { createdAt: 'desc' }, include: { uploader: true, page: true }, }), this.prisma.pondUsage.findUnique({ where: { pondId } }), this.pondStorageLimit(pondId), ]); return { files: rows.map((row) => this.listItemOf(row)), storageBytesUsed: Number(usage?.storageBytesUsed ?? 0n), storageBytesLimit, }; } private async pondStorageLimit(pondId: string): Promise { const pond = await this.prisma.pond.findUnique({ where: { id: pondId } }); if (!pond) throw new NotFoundException(); return this.quotas.getEffective('storage_bytes', { userId: pond.ownerId, pondId }); } private listItemOf( row: Attachment & { uploader: User; page: { title: string } | null }, ): AttachmentListItemView { return { ...this.viewOf(row), uploaderName: row.uploader.displayName, pageTitle: row.page?.title ?? null, }; } async remove(user: User, id: string): Promise { const attachment = await this.prisma.attachment.findFirst({ where: { id } }); if (!attachment) throw new NotFoundException(); 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', ); } }