import { BadRequestException, Body, Controller, ForbiddenException, Get, Headers, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { JwtService } from '@nestjs/jwt'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { getAuthorizedUserId } from '../document-access'; import { AvatarUploadDto, ChatMediaUploadDto, ConfirmAvatarDto, DocumentPhotoUploadDto } from '../dto/media.dto'; import { buildContentDisposition } from '../media-content-disposition'; type StreamResponse = { setHeader: (key: string, value: string) => void; status: (code: number) => { json: (body: unknown) => void }; } & NodeJS.WritableStream; @ApiTags('Медиа') @Controller('media') export class MediaController { private s3Client: S3Client | null = null; constructor( private readonly core: CoreGrpcService, private readonly jwt: JwtService ) {} private getS3Client() { if (!this.s3Client) { const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000'; const useSsl = process.env.MINIO_USE_SSL === 'true'; this.s3Client = new S3Client({ endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`, region: process.env.MINIO_REGION ?? 'us-east-1', credentials: { accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin', secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin' }, forcePathStyle: true }); } return this.s3Client; } private async authUserId(authorization?: string) { return getAuthorizedUserId(this.jwt, this.core, authorization); } @Post('upload') @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Загрузить файл через API', description: 'Принимает файл по uploadToken из upload-url ответа. Обходит прямой доступ браузера к MinIO.' }) @ApiResponse({ status: 201, description: 'Файл загружен' }) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 } })) async uploadObject( @Headers('x-upload-token') uploadToken: string | undefined, @UploadedFile() file: { buffer: Buffer; mimetype: string } | undefined ) { if (!uploadToken) { throw new BadRequestException('Не передан uploadToken'); } if (!file) { throw new BadRequestException('Файл не передан'); } let payload: { purpose?: string; storageKey?: string; contentType?: string }; try { payload = await this.jwt.verifyAsync(uploadToken, { secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret', issuer: 'id.lendry.ru' }); } catch { throw new ForbiddenException('Ссылка загрузки недействительна или истекла'); } if (payload.purpose !== 'media-upload' || !payload.storageKey) { throw new ForbiddenException('Ссылка загрузки недействительна'); } const bucket = process.env.MINIO_BUCKET ?? 'lendry-id'; await this.getS3Client().send( new PutObjectCommand({ Bucket: bucket, Key: payload.storageKey, Body: file.buffer, ContentType: file.mimetype || payload.contentType || 'application/octet-stream' }) ); return { ok: true, storageKey: payload.storageKey }; } @Post('avatars/upload-url') @ApiBearerAuth() @ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' }) @ApiBody({ type: AvatarUploadDto }) async createAvatarUploadUrl(@Headers('authorization') authorization: string | undefined, @Body() dto: AvatarUploadDto) { const userId = await this.authUserId(authorization); return firstValueFrom(this.core.media.CreateAvatarUploadUrl({ userId, contentType: dto.contentType })); } @Post('avatars/confirm') @ApiBearerAuth() @ApiOperation({ summary: 'Подтвердить загрузку аватара', description: 'Сохраняет ключ объекта MinIO как аватар пользователя.' }) @ApiBody({ type: ConfirmAvatarDto }) async confirmAvatar(@Headers('authorization') authorization: string | undefined, @Body() dto: ConfirmAvatarDto) { const userId = await this.authUserId(authorization); return firstValueFrom(this.core.media.ConfirmAvatar({ userId, storageKey: dto.storageKey })); } @Get('avatars/:userId/url') @ApiBearerAuth() @ApiOperation({ summary: 'Получить временную ссылку на аватар', description: 'Возвращает ссылку, действующую 15 минут. Без авторизации ссылка не выдаётся.' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) async getAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { const requesterId = await this.authUserId(authorization); return firstValueFrom(this.core.media.GetAvatarAccessUrl({ requesterId, targetUserId: userId })); } @Post('documents/:documentId/photo/upload-url') @ApiBearerAuth() @ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' }) @ApiBody({ type: DocumentPhotoUploadDto }) async createDocumentPhotoUploadUrl( @Headers('authorization') authorization: string | undefined, @Param('documentId') documentId: string, @Body() dto: DocumentPhotoUploadDto ) { const userId = await this.authUserId(authorization); return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType })); } @Get('users/:userId/documents/photo-url') @ApiBearerAuth() @ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' }) @ApiParam({ name: 'userId', description: 'ID владельца документа' }) @ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' }) async getDocumentPhotoUrl( @Headers('authorization') authorization: string | undefined, @Param('userId') userId: string, @Query('storageKey') storageKey: string ) { const requesterId = await this.authUserId(authorization); return firstValueFrom( this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey }) ); } @Get('stream/:token') @ApiOperation({ summary: 'Потоковая выдача медиа', description: 'Отдаёт файл по временному токену. Для файлов чата требуется Authorization. Токен действует 15 минут.' }) @ApiParam({ name: 'token', description: 'Временный JWT-токен доступа' }) @ApiResponse({ status: 403, description: 'Ссылка недействительна или истекла' }) async stream( @Param('token') token: string, @Headers('authorization') authorization: string | undefined, @Query('download') download: string | undefined, @Res({ passthrough: false }) response: StreamResponse ) { let requesterId: string | undefined; if (authorization) { try { requesterId = await getAuthorizedUserId(this.jwt, this.core, authorization); } catch { requesterId = undefined; } } const resolved = await firstValueFrom(this.core.media.ResolveStreamToken({ token, requesterId })); const payload = resolved as { storageKey: string; contentType: string; fileName?: string }; const bucket = process.env.MINIO_BUCKET ?? 'lendry-id'; const object = await this.getS3Client().send( new GetObjectCommand({ Bucket: bucket, Key: payload.storageKey }) ); response.setHeader('Content-Type', payload.contentType); response.setHeader('Cache-Control', 'private, no-store'); if (payload.fileName) { const asAttachment = download === '1' || download === 'true'; response.setHeader( 'Content-Disposition', buildContentDisposition(asAttachment ? 'attachment' : 'inline', payload.fileName) ); } const body = object.Body; if (!body) { response.status(404).json({ message: 'Файл не найден' }); return; } const stream = body as NodeJS.ReadableStream; stream.pipe(response); } @Post('families/:groupId/avatar/upload-url') @ApiBearerAuth() async createFamilyAvatarUploadUrl( @Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string, @Body() dto: AvatarUploadDto ) { const requesterId = await this.authUserId(authorization); return firstValueFrom( this.core.media.CreateFamilyAvatarUploadUrl({ requesterId, groupId, contentType: dto.contentType }) ); } @Post('families/:groupId/avatar/confirm') @ApiBearerAuth() async confirmFamilyAvatar( @Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string, @Body() dto: ConfirmAvatarDto ) { const requesterId = await this.authUserId(authorization); return firstValueFrom( this.core.media.ConfirmFamilyAvatar({ requesterId, groupId, storageKey: dto.storageKey }) ); } @Get('families/:groupId/avatar/url') @ApiBearerAuth() async getFamilyAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { const requesterId = await this.authUserId(authorization); return firstValueFrom(this.core.media.GetFamilyAvatarAccessUrl({ requesterId, groupId })); } @Post('chat/:roomId/media/upload-url') @ApiBearerAuth() async createChatMediaUploadUrl( @Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string, @Body() dto: ChatMediaUploadDto ) { const requesterId = await this.authUserId(authorization); return firstValueFrom( this.core.media.CreateChatMediaUploadUrl({ requesterId, roomId, contentType: dto.contentType, fileName: dto.fileName }) ); } @Get('chat/:roomId/media/url') @ApiBearerAuth() async getChatMediaUrl( @Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string, @Query('storageKey') storageKey: string, @Query('fileName') fileName?: string ) { const requesterId = await this.authUserId(authorization); return firstValueFrom( this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName }) ); } @Post('chat/:roomId/avatar/upload-url') @ApiBearerAuth() async createChatRoomAvatarUploadUrl( @Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string, @Body() dto: AvatarUploadDto ) { const requesterId = await this.authUserId(authorization); return firstValueFrom( this.core.media.CreateChatRoomAvatarUploadUrl({ requesterId, roomId, contentType: dto.contentType }) ); } @Post('chat/:roomId/avatar/confirm') @ApiBearerAuth() async confirmChatRoomAvatar( @Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string, @Body() dto: ConfirmAvatarDto ) { const requesterId = await this.authUserId(authorization); return firstValueFrom( this.core.media.ConfirmChatRoomAvatar({ requesterId, roomId, storageKey: dto.storageKey }) ); } @Get('chat/:roomId/avatar/url') @ApiBearerAuth() async getChatRoomAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string) { const requesterId = await this.authUserId(authorization); return firstValueFrom(this.core.media.GetChatRoomAvatarAccessUrl({ requesterId, roomId })); } }