Files
IdP/apps/sso-core/src/domain/media.service.ts
2026-06-24 20:15:19 +03:00

320 lines
11 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 { 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<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) {
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<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) {
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<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.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<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('Нет доступа к чату');
}
}
}