first commit
This commit is contained in:
215
apps/api-gateway/src/controllers/media.controller.ts
Normal file
215
apps/api-gateway/src/controllers/media.controller.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { GetObjectCommand, 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('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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user