import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { PrismaService } from '../infra/prisma.service'; import { AccessService } from './access.service'; import { MinioService } from '../infra/minio.service'; const AVATAR_ACCESS_TTL_SECONDS = 900; interface MediaStreamPayload { purpose: 'media-stream'; objectKey: string; ownerId: string; roomId?: string; fileName?: string; } interface MediaUploadPayload { purpose: 'media-upload'; storageKey: string; contentType: string; userId: string; } const UPLOAD_TTL_SECONDS = 300; @Injectable() export class MediaService { constructor( private readonly prisma: PrismaService, private readonly minio: MinioService, private readonly jwt: JwtService, private readonly config: ConfigService, private readonly access: AccessService ) {} async createAvatarUploadUrl(userId: string, contentType: string) { try { this.minio.assertImageContentType(contentType); } catch (error) { throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); } const extension = this.minio.extensionForContentType(contentType); const storageKey = `avatars/${userId}/${crypto.randomUUID()}.${extension}`; return this.createUploadTarget(userId, storageKey, contentType); } async confirmAvatar(userId: string, storageKey: string) { if (!storageKey.startsWith(`avatars/${userId}/`)) { throw new BadRequestException('Некорректный ключ аватара'); } await this.prisma.user.update({ where: { id: userId }, data: { avatarStorageKey: storageKey, avatarUrl: null } }); return { userId, hasAvatar: true }; } async getAvatarAccessUrl(requesterId: string, targetUserId: string) { const user = await this.prisma.user.findUnique({ where: { id: targetUserId } }); if (!user?.avatarStorageKey) { throw new NotFoundException('Аватар не найден'); } if (requesterId !== targetUserId) { const requester = await this.prisma.user.findUnique({ where: { id: requesterId } }); if (!requester?.isSuperAdmin) { throw new ForbiddenException('Нет доступа к аватару пользователя'); } } const token = await this.jwt.signAsync( { purpose: 'media-stream', objectKey: user.avatarStorageKey, ownerId: targetUserId } satisfies MediaStreamPayload, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: AVATAR_ACCESS_TTL_SECONDS, issuer: 'id.lendry.ru' } ); const publicApiUrl = this.config.get('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, ''); return { accessUrl: `${publicApiUrl}/media/stream/${token}`, expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString() }; } async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string) { try { this.minio.assertImageContentType(contentType); } catch (error) { throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); } const document = await this.prisma.userDocument.findFirst({ where: { id: documentId, userId } }); if (!document) { throw new NotFoundException('Документ не найден'); } const extension = this.minio.extensionForContentType(contentType); const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`; return this.createUploadTarget(userId, storageKey, contentType); } async resolveStreamToken(token: string, requesterId?: string) { let payload: MediaStreamPayload; try { payload = await this.jwt.verifyAsync(token, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), issuer: 'id.lendry.ru' }); } catch { throw new ForbiddenException('Ссылка недействительна или истекла'); } if (payload.purpose !== 'media-stream' || !payload.objectKey) { throw new ForbiddenException('Ссылка недействительна'); } if (payload.objectKey.startsWith('chat/')) { if (!requesterId) { throw new ForbiddenException('Для доступа к файлу чата требуется авторизация'); } const roomId = payload.roomId ?? this.extractChatRoomId(payload.objectKey); if (!roomId) { throw new ForbiddenException('Некорректный ключ файла чата'); } await this.assertChatMember(roomId, requesterId); } const contentType = this.minio.contentTypeForKey(payload.objectKey); return { storageKey: payload.objectKey, contentType, fileName: payload.fileName }; } async getObjectStream(storageKey: string) { return this.minio.getClient().send( new GetObjectCommand({ Bucket: this.minio.getBucket(), Key: storageKey }) ); } async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string) { await this.access.assertCanViewUserDocuments(requesterId, targetUserId); if (!storageKey.startsWith(`documents/${targetUserId}/`)) { throw new BadRequestException('Некорректный ключ фото документа'); } const token = await this.jwt.signAsync( { purpose: 'media-stream', objectKey: storageKey, ownerId: targetUserId } satisfies MediaStreamPayload, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: AVATAR_ACCESS_TTL_SECONDS, issuer: 'id.lendry.ru' } ); const publicApiUrl = this.config.get('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, ''); return { accessUrl: `${publicApiUrl}/media/stream/${token}`, expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString() }; } async createFamilyAvatarUploadUrl(requesterId: string, groupId: string, contentType: string) { await this.assertFamilyMember(groupId, requesterId); try { this.minio.assertImageContentType(contentType); } catch (error) { throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); } const extension = this.minio.extensionForContentType(contentType); const storageKey = `families/${groupId}/${crypto.randomUUID()}.${extension}`; return this.createUploadTarget(requesterId, storageKey, contentType); } async confirmFamilyAvatar(requesterId: string, groupId: string, storageKey: string) { await this.assertFamilyMember(groupId, requesterId); if (!storageKey.startsWith(`families/${groupId}/`)) { throw new BadRequestException('Некорректный ключ аватара семьи'); } await this.prisma.familyGroup.update({ where: { id: groupId }, data: { avatarStorageKey: storageKey, hasAvatar: true } }); return { groupId, hasAvatar: true }; } async getFamilyAvatarAccessUrl(requesterId: string, groupId: string) { await this.assertFamilyMember(groupId, requesterId); const group = await this.prisma.familyGroup.findUnique({ where: { id: groupId } }); if (!group?.avatarStorageKey) { throw new NotFoundException('Аватар семьи не найден'); } return this.createStreamAccessUrl(group.avatarStorageKey, groupId); } async createChatMediaUploadUrl(requesterId: string, roomId: string, contentType: string, fileName?: string) { await this.assertChatMember(roomId, requesterId); let normalized: string; try { normalized = this.minio.assertChatMediaContentType(contentType, fileName); } catch (error) { throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); } const extension = this.minio.extensionForChatMedia(normalized, fileName); const storageKey = `chat/${roomId}/${crypto.randomUUID()}.${extension}`; return this.createUploadTarget(requesterId, storageKey, normalized); } async getChatMediaAccessUrl(requesterId: string, roomId: string, storageKey: string, fileName?: string) { await this.assertChatMember(roomId, requesterId); if (!storageKey.startsWith(`chat/${roomId}/`)) { throw new BadRequestException('Некорректный ключ медиафайла'); } return this.createStreamAccessUrl(storageKey, requesterId, { roomId, fileName }); } private extractChatRoomId(storageKey: string) { const [prefix, roomId] = storageKey.split('/'); if (prefix !== 'chat' || !roomId) { return null; } return roomId; } private async createUploadTarget(userId: string, storageKey: string, contentType: string) { const viaApi = this.config.get('MINIO_UPLOAD_VIA_API', 'true') !== 'false'; const expiresAt = new Date(Date.now() + UPLOAD_TTL_SECONDS * 1000).toISOString(); if (viaApi) { const uploadToken = await this.jwt.signAsync( { purpose: 'media-upload', storageKey, contentType, userId } satisfies MediaUploadPayload, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: UPLOAD_TTL_SECONDS, issuer: 'id.lendry.ru' } ); const publicApiUrl = this.config.get('PUBLIC_API_URL', 'http://localhost:3001').replace(/\/$/, ''); return { uploadUrl: `${publicApiUrl}/media/upload`, uploadToken, storageKey, expiresAt }; } const presigned = await this.minio.createPresignedUploadUrl(storageKey, contentType, UPLOAD_TTL_SECONDS); return { ...presigned, storageKey }; } private async createStreamAccessUrl( storageKey: string, ownerId: string, options?: { roomId?: string; fileName?: string } ) { const token = await this.jwt.signAsync( { purpose: 'media-stream', objectKey: storageKey, ownerId, roomId: options?.roomId, fileName: options?.fileName } satisfies MediaStreamPayload, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: AVATAR_ACCESS_TTL_SECONDS, issuer: 'id.lendry.ru' } ); const publicApiUrl = this.config.get('PUBLIC_API_URL', 'http://localhost:3000').replace(/\/$/, ''); return { accessUrl: `${publicApiUrl}/media/stream/${token}`, expiresAt: new Date(Date.now() + AVATAR_ACCESS_TTL_SECONDS * 1000).toISOString() }; } private async assertFamilyMember(groupId: string, userId: string) { const member = await this.prisma.familyMember.findFirst({ where: { groupId, userId } }); if (!member) { throw new ForbiddenException('Нет доступа к семье'); } } private async assertChatMember(roomId: string, userId: string) { const member = await this.prisma.chatRoomMember.findFirst({ where: { roomId, userId } }); if (!member) { throw new ForbiddenException('Нет доступа к чату'); } } }