293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
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 }));
|
||
}
|
||
}
|