dorfteich/apps/api/src/files/files.controller.ts
Claude Fable 5 e505fc74dc
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m34s
CI / Build container images (pull_request) Successful in 14s
CI / Auth e2e pack (pull_request) Successful in 9m36s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#212: mark attachment downloads by filename prefix and companion file
Downloads whose effective classification is vs_nfd carry the documented
VS-NfD_ filename prefix (single source classificationFilenamePrefix() in
shared; ADR 0022 records the short form for file names). Effective
classification: the linked page's level; an attachment with unset pageId
(paste-then-insert, pond-level) FAILS CLOSED to the highest level of any
live page in its pond. The pond export ZIP adds a sibling
<file>.classification.txt companion with the full marking for classified
media, next to the manifest entry (#210). Documented in operations.md,
incl. the deliberate residual risk: the file's own content carries no
marking (recorded on #231, not hidden). Tests: prefixed classified
download, unchanged open download, fail-closed orphan both ways, ZIP
companion + manifest level.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:29:16 +02:00

114 lines
3.9 KiB
TypeScript

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