461 lines
16 KiB
TypeScript
461 lines
16 KiB
TypeScript
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<string | undefined> {
|
|
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<string>('JWT_ACCESS_SECRET'),
|
|
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
|
issuer: 'id.lendry.ru'
|
|
}
|
|
);
|
|
|
|
const publicApiUrl = this.config.get<string>('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<MediaStreamPayload>(token, {
|
|
secret: this.config.getOrThrow<string>('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<string>('JWT_ACCESS_SECRET'),
|
|
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
|
issuer: 'id.lendry.ru'
|
|
}
|
|
);
|
|
|
|
const publicApiUrl = this.config.get<string>('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<string>('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<string>('JWT_ACCESS_SECRET'),
|
|
expiresIn: UPLOAD_TTL_SECONDS,
|
|
issuer: 'id.lendry.ru'
|
|
}
|
|
);
|
|
const publicApiUrl = this.config.get<string>('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<string>('JWT_ACCESS_SECRET'),
|
|
expiresIn: AVATAR_ACCESS_TTL_SECONDS,
|
|
issuer: 'id.lendry.ru'
|
|
}
|
|
);
|
|
const publicApiUrl = this.config.get<string>('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('Нет доступа к аватару пользователя');
|
|
}
|
|
}
|
|
}
|