fix and update
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "Session" ADD COLUMN "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
UPDATE "Session" SET "lastActivityAt" = "updatedAt";
|
||||
@@ -132,6 +132,7 @@ model Session {
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
expiresAt DateTime
|
||||
lastActivityAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -800,13 +800,13 @@ export class AuthGrpcController {
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'CreateDocumentPhotoUploadUrl')
|
||||
createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string }) {
|
||||
return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType);
|
||||
createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string; fileName?: string }) {
|
||||
return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType, command.fileName);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'GetDocumentPhotoAccessUrl')
|
||||
getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string }) {
|
||||
return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey);
|
||||
getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string; fileName?: string }) {
|
||||
return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey, command.fileName);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'ResolveStreamToken')
|
||||
|
||||
@@ -115,9 +115,10 @@ export class MediaService {
|
||||
};
|
||||
}
|
||||
|
||||
async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string) {
|
||||
async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string, fileName?: string) {
|
||||
let normalizedContentType: string;
|
||||
try {
|
||||
this.minio.assertImageContentType(contentType);
|
||||
normalizedContentType = this.minio.assertDocumentAttachmentContentType(contentType, fileName);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
||||
}
|
||||
@@ -128,9 +129,9 @@ export class MediaService {
|
||||
throw new NotFoundException('Документ не найден');
|
||||
}
|
||||
|
||||
const extension = this.minio.extensionForContentType(contentType);
|
||||
const extension = this.minio.extensionForDocumentContentType(normalizedContentType, fileName);
|
||||
const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`;
|
||||
return this.createUploadTarget(userId, storageKey, contentType);
|
||||
return this.createUploadTarget(userId, storageKey, normalizedContentType);
|
||||
}
|
||||
|
||||
async resolveStreamToken(token: string, requesterId?: string) {
|
||||
@@ -177,18 +178,19 @@ export class MediaService {
|
||||
);
|
||||
}
|
||||
|
||||
async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string) {
|
||||
async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string, fileName?: string) {
|
||||
await this.access.assertCanViewUserDocuments(requesterId, targetUserId);
|
||||
|
||||
if (!storageKey.startsWith(`documents/${targetUserId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ фото документа');
|
||||
throw new BadRequestException('Некорректный ключ файла документа');
|
||||
}
|
||||
|
||||
const token = await this.jwt.signAsync(
|
||||
{
|
||||
purpose: 'media-stream',
|
||||
objectKey: storageKey,
|
||||
ownerId: targetUserId
|
||||
ownerId: targetUserId,
|
||||
fileName: fileName?.trim() || undefined
|
||||
} satisfies MediaStreamPayload,
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
|
||||
@@ -169,6 +169,7 @@ export class PinService {
|
||||
data: {
|
||||
pinVerified: true,
|
||||
status: SessionStatus.ACTIVE,
|
||||
lastActivityAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,7 +47,8 @@ export class SessionService {
|
||||
if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) {
|
||||
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
|
||||
const threshold = new Date(Date.now() - timeoutMinutes * 60_000);
|
||||
if (session.updatedAt < threshold) {
|
||||
const lastActivityAt = session.lastActivityAt ?? session.updatedAt;
|
||||
if (lastActivityAt < threshold) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { pinVerified: false, status: SessionStatus.LOCKED }
|
||||
@@ -76,7 +77,7 @@ export class SessionService {
|
||||
|
||||
await this.prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { updatedAt: new Date() }
|
||||
data: { lastActivityAt: new Date(), updatedAt: new Date() }
|
||||
});
|
||||
|
||||
return state;
|
||||
@@ -160,7 +161,7 @@ export class SessionService {
|
||||
return this.prisma.session.updateMany({
|
||||
where: {
|
||||
pinVerified: true,
|
||||
updatedAt: { lt: threshold },
|
||||
lastActivityAt: { lt: threshold },
|
||||
status: SessionStatus.ACTIVE,
|
||||
user: { pinCode: { isEnabled: true } }
|
||||
},
|
||||
|
||||
141
apps/sso-core/src/infra/document-media.util.ts
Normal file
141
apps/sso-core/src/infra/document-media.util.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
const BLOCKED_DOCUMENT_TYPES = new Set([
|
||||
'application/javascript',
|
||||
'application/x-javascript',
|
||||
'text/javascript',
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
'application/x-httpd-php',
|
||||
'application/x-sh'
|
||||
]);
|
||||
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
txt: 'text/plain',
|
||||
rtf: 'application/rtf',
|
||||
odt: 'application/vnd.oasis.opendocument.text'
|
||||
};
|
||||
|
||||
const ALLOWED_DOCUMENT_MIME = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/plain',
|
||||
'application/rtf',
|
||||
'application/vnd.oasis.opendocument.text'
|
||||
]);
|
||||
|
||||
const MIME_ALIASES: Record<string, string> = {
|
||||
'image/jpg': 'image/jpeg',
|
||||
'image/pjpeg': 'image/jpeg',
|
||||
'application/x-pdf': 'application/pdf'
|
||||
};
|
||||
|
||||
function extractFileExtension(fileName?: string | null) {
|
||||
const trimmed = (fileName ?? '').trim().toLowerCase();
|
||||
const dotIndex = trimmed.lastIndexOf('.');
|
||||
if (dotIndex <= 0) return '';
|
||||
return trimmed.slice(dotIndex + 1);
|
||||
}
|
||||
|
||||
export function normalizeDocumentContentType(contentType?: string | null, fileName?: string | null) {
|
||||
const raw = (contentType ?? '').trim().toLowerCase();
|
||||
const base = raw.split(';')[0]?.trim() ?? '';
|
||||
const aliased = MIME_ALIASES[base] ?? base;
|
||||
|
||||
if (aliased && aliased !== 'application/octet-stream' && ALLOWED_DOCUMENT_MIME.has(aliased)) {
|
||||
return aliased;
|
||||
}
|
||||
|
||||
const extension = extractFileExtension(fileName);
|
||||
if (extension && EXTENSION_TO_MIME[extension]) {
|
||||
return EXTENSION_TO_MIME[extension];
|
||||
}
|
||||
|
||||
return aliased || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function assertDocumentAttachmentContentType(contentType: string, fileName?: string) {
|
||||
const normalized = normalizeDocumentContentType(contentType, fileName);
|
||||
if (BLOCKED_DOCUMENT_TYPES.has(normalized)) {
|
||||
throw new Error('Тип файла не поддерживается');
|
||||
}
|
||||
if (!ALLOWED_DOCUMENT_MIME.has(normalized)) {
|
||||
throw new Error('Допустимы изображения, PDF, Word, Excel, PowerPoint, TXT и ODT');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function extensionForDocumentContentType(contentType: string, fileName?: string) {
|
||||
const normalized = normalizeDocumentContentType(contentType, fileName);
|
||||
switch (normalized) {
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
case 'application/pdf':
|
||||
return 'pdf';
|
||||
case 'application/msword':
|
||||
return 'doc';
|
||||
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||
return 'docx';
|
||||
case 'application/vnd.ms-excel':
|
||||
return 'xls';
|
||||
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
|
||||
return 'xlsx';
|
||||
case 'application/vnd.ms-powerpoint':
|
||||
return 'ppt';
|
||||
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
||||
return 'pptx';
|
||||
case 'text/plain':
|
||||
return 'txt';
|
||||
case 'application/rtf':
|
||||
return 'rtf';
|
||||
case 'application/vnd.oasis.opendocument.text':
|
||||
return 'odt';
|
||||
default: {
|
||||
const extension = extractFileExtension(fileName);
|
||||
return extension || 'bin';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function contentTypeForDocumentKey(storageKey: string) {
|
||||
const lower = storageKey.toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
if (lower.endsWith('.doc')) return 'application/msword';
|
||||
if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
|
||||
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
if (lower.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
|
||||
if (lower.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
||||
if (lower.endsWith('.txt')) return 'text/plain';
|
||||
if (lower.endsWith('.rtf')) return 'application/rtf';
|
||||
if (lower.endsWith('.odt')) return 'application/vnd.oasis.opendocument.text';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
extensionForChatContentType,
|
||||
normalizeChatContentType
|
||||
} from './chat-media.util';
|
||||
import {
|
||||
assertDocumentAttachmentContentType as assertDocumentAttachment,
|
||||
contentTypeForDocumentKey as resolveDocumentContentType,
|
||||
extensionForDocumentContentType as mapDocumentExtension,
|
||||
normalizeDocumentContentType
|
||||
} from './document-media.util';
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
@@ -89,6 +95,18 @@ export class MinioService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
assertDocumentAttachmentContentType(contentType: string, fileName?: string) {
|
||||
return assertDocumentAttachment(contentType, fileName);
|
||||
}
|
||||
|
||||
extensionForDocumentContentType(contentType: string, fileName?: string) {
|
||||
return mapDocumentExtension(normalizeDocumentContentType(contentType, fileName), fileName);
|
||||
}
|
||||
|
||||
contentTypeForDocumentKey(storageKey: string) {
|
||||
return resolveDocumentContentType(storageKey);
|
||||
}
|
||||
|
||||
assertImageContentType(contentType: string) {
|
||||
if (!ALLOWED_IMAGE_TYPES.has(contentType)) {
|
||||
throw new Error('Допустимы только изображения JPEG, PNG, WEBP или GIF');
|
||||
@@ -133,6 +151,9 @@ export class MinioService implements OnModuleInit {
|
||||
if (storageKey.startsWith('chat/')) {
|
||||
return contentTypeForStorageKey(storageKey);
|
||||
}
|
||||
if (storageKey.startsWith('documents/')) {
|
||||
return this.contentTypeForDocumentKey(storageKey);
|
||||
}
|
||||
if (storageKey.endsWith('.png')) return 'image/png';
|
||||
if (storageKey.endsWith('.webp')) return 'image/webp';
|
||||
if (storageKey.endsWith('.gif')) return 'image/gif';
|
||||
|
||||
Reference in New Issue
Block a user