All checks were successful
CD / Build and push images (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 2m46s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s
Extend uploads (#27, ADR 0011) beyond images to a configurable general attachment allowlist, plus the page attachments section and the Pond Admin file manager. Backend: - Two instance settings: `upload.allowedExtensions` (lowercase, dot-stripped, images always allowed regardless) and `upload.svgPolicy` (reject | sanitize). - FilesService.resolveUpload: raster images still decided by magic bytes; SVG is sanitized with DOMPurify (scripts, event handlers, foreignObject stripped) or rejected per policy; everything else is admitted only if its extension is on the allowlist. A sanitized SVG's stored bytes are re-accounted so pond_usage matches disk. - Downloads set `Content-Disposition: attachment` for every non-raster type (office files, PDFs, SVG) with `nosniff`, so they can never execute inline; raster images stay inline for page embeds. - New endpoints: `GET /ponds/:id/files` (pond_admin: all files + usage + orphan flag), `POST /pages/:id/files` and `GET /pages/:id/files` (page-write/read: the attachments section). New error code `upload_type_not_allowed` (de+en). Frontend: - Page attachments section (AttachmentsPanel): upload, list with type glyph, size, and uploader, insert-as-link into the document (an internal media link that downloads, never renders inline), and delete. Toggled in the editor. - Pond file manager (PondFileManager) in pond settings for Pond Admins: every file with its referencing page (or an orphan flag) and storage usage. - Admin uploads settings form (allowlist + SVG policy). New `files` i18n namespace (de+en). Tests: - files.e2e.db.test.ts: allowlisted non-image accepted and served as a download; disallowed extension rejected; renamed-.html-as-.png still fails; SVG sanitized (scripts/handlers stripped) and reject-mode rejects; page attachment listing; pond file manager usage/orphan; non-admin denied. - New e2e pack apps/web/e2e/attachments.spec.ts (+ CI step): upload → list → insert link (verified attachment disposition + nosniff), disallowed-type error, pond file manager usage/orphan. Local: typecheck, lint, i18n:check, build all green; api-db 184, shared 121, web 50; attachments pack 3/3, members 3/3, content 5/5. Adds dompurify + jsdom to the api for server-side SVG sanitization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
111 lines
3.9 KiB
TypeScript
111 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 } = 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(attachment.fileName)}"`,
|
|
});
|
|
}
|
|
|
|
@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);
|
|
}
|
|
}
|