import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import type { AuthPrincipal, RequestWithContext, } from '../common/http/request-context'; import { AssetMediaKind } from '../database/entities'; import { MAX_ASSET_MEDIA_BYTES, type UploadedAssetFile, } from './asset-media-file'; import { AssetMediaService } from './asset-media.service'; import { CreateAssetMediaDto } from './dto/create-asset-media.dto'; import { UpdateAssetMediaDto } from './dto/update-asset-media.dto'; @Controller() export class AssetMediaController { constructor(private readonly media: AssetMediaService) {} @Get('assets/:assetId/media') @RequirePermissions('assets.read_media') list( @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, ) { return this.media.list(assetId); } @Post('assets/:assetId/media') @RequirePermissions('assets.manage_media') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ASSET_MEDIA_BYTES, files: 1 }, })) upload( @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, @Body() dto: CreateAssetMediaDto, @UploadedFile() file: UploadedAssetFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { return this.media.upload(assetId, dto, file, principal, request); } @Patch('asset-media/:mediaId') @RequirePermissions('assets.manage_media') update( @Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string, @Body() dto: UpdateAssetMediaDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { return this.media.update(mediaId, dto, principal, request); } @Delete('asset-media/:mediaId') @RequirePermissions('assets.manage_media') remove( @Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { return this.media.remove(mediaId, principal, request); } @Get('asset-media/:mediaId/content') @RequirePermissions('assets.read_media') async content( @Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string, @Query('download') download: string | undefined, @Res() response: Response, ): Promise { const { filePath, media } = await this.media.content(mediaId); const forceDownload = download === '1' || media.kind === AssetMediaKind.DOCUMENT; const disposition = forceDownload ? 'attachment' : 'inline'; const fallbackName = media.originalName .replace(/[^\x20-\x7e]/g, '_') .replace(/["\\]/g, '_'); response.setHeader('Content-Type', media.mimeType); response.setHeader('Content-Length', String(media.sizeBytes)); response.setHeader('Content-Disposition', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(media.originalName)}`); response.setHeader('Cache-Control', 'private, no-store'); response.setHeader('X-Content-Type-Options', 'nosniff'); await new Promise((resolveSend, rejectSend) => { response.sendFile(filePath, (error) => { if (error) rejectSend(error); else resolveSend(); }); }); } }