import { BadRequestException, Controller, Delete, Get, HttpCode, Param, Post, Req, Res, StreamableFile, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { AttachmentListItemView, AttachmentView, MAX_UPLOAD_PARSE_BYTES, PondFilesView, } from '@dorfteich/shared'; import type { Response } from 'express'; import { AuthedRequest, Public } from '../auth/auth.guard'; import { RequiresAttachmentPermission, RequiresPagePermission, RequiresPondRole, } from '../permissions/permission.decorators'; import { FilesService } from './files.service'; /** File storage, upload, and attachment listing API (issue #27, #61). */ @Controller() export class FilesController { constructor(private readonly files: FilesService) {} @Post('ponds/:pondId/files') @RequiresPondRole('editor', { idParam: 'pondId' }) @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); } /** All files in a pond, for the Pond Admin file manager (#61). */ @Get('ponds/:pondId/files') @RequiresPondRole('pond_admin', { idParam: 'pondId' }) listPondFiles(@Param('pondId') pondId: string): Promise { return this.files.listForPond(pondId); } /** Upload an attachment linked to a page — its attachments section (#61). */ @Post('pages/:pageId/files') @RequiresPagePermission('write', { idParam: 'pageId' }) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } })) async uploadToPage( @Param('pageId') pageId: string, @UploadedFile() file: Express.Multer.File | undefined, @Req() request: AuthedRequest, ): Promise { if (!file) throw new BadRequestException({ code: 'bad_request' }); return this.files.uploadToPage(request.user!, pageId, file); } /** Attachments linked to a page, for its attachments section (#61). */ @Get('pages/:pageId/files') @RequiresPagePermission('read', { idParam: 'pageId' }) listPageFiles(@Param('pageId') pageId: string): Promise { return this.files.listForPage(pageId); } /** * Permission-checked file streaming (ADR 0011) — never served same-origin as * executable content. `@Public()` so embedded images on a public page load * for anonymous visitors (issue #56); the guard still resolves the `public` * grant via the attachment's page and 404s otherwise. Raster images render * inline; every other type — office files, PDFs, SVG — is sent as a download * with its original filename and can never execute inline (#61). */ @Get('media/:fileId') @Public() @RequiresAttachmentPermission('read', { idParam: 'fileId' }) async download( @Param('fileId') fileId: string, @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { const { attachment, stream, inline, downloadName } = await this.files.download( request.user ?? null, 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'); const kind = inline ? 'inline' : 'attachment'; return new StreamableFile(stream, { type: attachment.mimeType, disposition: `${kind}; filename="${encodeURIComponent(downloadName)}"`, }); } @Delete('files/:id') @HttpCode(204) @RequiresAttachmentPermission('write', { idParam: 'id' }) async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { await this.files.remove(request.user!, id); } }