first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View 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 })
);
}
}