Files
IdP/apps/api-gateway/src/controllers/media.controller.ts
2026-06-24 23:17:24 +03:00

293 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }));
}
}