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 { ChatService } from './chat.service'; import { NotificationsService } from './notifications.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, private readonly chat: ChatService, private readonly notifications: NotificationsService ) {} 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 } }); await this.publishUserAvatarUpdated(userId); return { userId, hasAvatar: true }; } async createFedcmAvatarUrl(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { return undefined; } const legacyUrl = user.avatarUrl?.trim(); if (legacyUrl) { return legacyUrl; } if (!user.avatarStorageKey) { return undefined; } const { accessUrl } = await this.createStreamAccessUrl(user.avatarStorageKey, userId); return accessUrl; } async getAvatarAccessUrl(requesterId: string, targetUserId: string) { const user = await this.prisma.user.findUnique({ where: { id: targetUserId } }); if (!user?.avatarStorageKey) { throw new NotFoundException('Аватар не найден'); } await this.assertCanViewUserAvatar(requesterId, targetUserId); 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, fileName?: string) { let normalizedContentType: string; try { normalizedContentType = this.minio.assertDocumentAttachmentContentType(contentType, fileName); } 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.extensionForDocumentContentType(normalizedContentType, fileName); const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`; return this.createUploadTarget(userId, storageKey, normalizedContentType); } 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, fileName?: 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, fileName: fileName?.trim() || undefined } 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.assertChatMemberOrModerator(roomId, requesterId); if (!storageKey.startsWith(`chat/${roomId}/`)) { throw new BadRequestException('Некорректный ключ медиафайла'); } return this.createStreamAccessUrl(storageKey, requesterId, { roomId, fileName }); } async createChatRoomAvatarUploadUrl(requesterId: string, roomId: string, contentType: string) { await this.assertChatMember(roomId, requesterId); try { this.minio.assertImageContentType(contentType); } catch (error) { throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); } const extension = this.minio.extensionForContentType(contentType); const storageKey = `chat-rooms/${roomId}/${crypto.randomUUID()}.${extension}`; return this.createUploadTarget(requesterId, storageKey, contentType); } async confirmChatRoomAvatar(requesterId: string, roomId: string, storageKey: string) { await this.assertChatMember(roomId, requesterId); if (!storageKey.startsWith(`chat-rooms/${roomId}/`)) { throw new BadRequestException('Некорректный ключ аватара чата'); } await this.prisma.chatRoom.update({ where: { id: roomId }, data: { avatarStorageKey: storageKey, hasAvatar: true } }); await this.chat.publishRoomAvatarChangedMessage(roomId, requesterId); return { roomId, hasAvatar: true }; } async getChatRoomAvatarAccessUrl(requesterId: string, roomId: string) { await this.assertChatMember(roomId, requesterId); const room = await this.prisma.chatRoom.findUnique({ where: { id: roomId } }); if (!room?.avatarStorageKey) { throw new NotFoundException('Аватар чата не найден'); } return this.createStreamAccessUrl(room.avatarStorageKey, requesterId, { roomId }); } private extractChatRoomId(storageKey: string) { const [prefix, roomId] = storageKey.split('/'); if (prefix !== 'chat' || !roomId) { return null; } return roomId; } private extractChatRoomAvatarRoomId(storageKey: string) { const [prefix, roomId] = storageKey.split('/'); if (prefix !== 'chat-rooms' || !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('Нет доступа к чату'); } } private async assertChatMemberOrModerator(roomId: string, userId: string) { const member = await this.prisma.chatRoomMember.findFirst({ where: { roomId, userId } }); if (member) { return; } const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new ForbiddenException('Нет доступа к чату'); } const access = await this.access.getUserAccess(userId, user.isSuperAdmin); if (!access.canModerateChats) { throw new ForbiddenException('Нет доступа к чату'); } const room = await this.prisma.chatRoom.findUnique({ where: { id: roomId } }); if (!room || room.isE2E || room.type === 'E2E' || room.type === 'BOT') { throw new ForbiddenException('Этот чат недоступен для модерации'); } } private async publishUserAvatarUpdated(userId: string) { const memberships = await this.prisma.familyMember.findMany({ where: { userId }, select: { groupId: true } }); const groupIds = memberships.map((member) => member.groupId); if (!groupIds.length) { return; } const coMembers = await this.prisma.familyMember.findMany({ where: { groupId: { in: groupIds }, userId: { not: userId } }, select: { userId: true } }); const recipientIds = [...new Set(coMembers.map((member) => member.userId))]; await Promise.all( recipientIds.map((memberId) => this.notifications.publishRealtime(memberId, 'user_avatar_updated', '', '', { userId }) ) ); } private async assertCanViewUserAvatar(requesterId: string, targetUserId: string) { if (requesterId === targetUserId) { return; } const requester = await this.prisma.user.findUnique({ where: { id: requesterId } }); if (requester?.isSuperAdmin) { return; } const [requesterFamilies, targetFamilies] = await Promise.all([ this.prisma.familyMember.findMany({ where: { userId: requesterId }, select: { groupId: true } }), this.prisma.familyMember.findMany({ where: { userId: targetUserId }, select: { groupId: true } }) ]); const targetGroupIds = new Set(targetFamilies.map((member) => member.groupId)); if (requesterFamilies.some((member) => targetGroupIds.has(member.groupId))) { return; } const sharedChat = await this.prisma.chatRoomMember.findFirst({ where: { userId: requesterId, room: { members: { some: { userId: targetUserId } } } } }); if (!sharedChat) { throw new ForbiddenException('Нет доступа к аватару пользователя'); } } }