Files
iistwin/server/services/file-upload.service.ts
Ильяс Султанов 936bce2ba9 MCP: загрузка файлов (base64) в file-поля, комментарии, справочники и таб-таблицы задач
- server/services/file-upload.service.ts: uploadFileFromBase64 (whitelist расширений, magic bytes, лимиты 10/50 МБ, S3/MinIO или локальный диск, запись file_uploads)
- Инструменты (write/full): upload_file, upload_task_file, upload_message_file, upload_directory_file, upload_table_row_file
- Проверки доступа isFormAllowed/isTableAllowed
2026-07-22 11:28:45 +03:00

134 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { db } from '../db';
import { fileUploads } from '@shared/schema';
import { isS3Enabled, uploadToS3 } from '../utils/s3';
import {
uploadsDir,
getFileExt,
validateFilename,
getSizeLimit,
isMimeConsistentWithExt,
EXT_TO_MIME,
EXT_MAGIC,
DOC_MAX_SIZE,
} from '../utils/upload';
// Ошибка загрузки файла — маппится в понятное сообщение пользователю (на русском).
export class FileUploadError extends Error {
constructor(message: string) {
super(message);
this.name = 'FileUploadError';
}
}
export interface UploadFileFromBase64Params {
organizationId: number;
userId: number; // автор загрузки (file_uploads.uploadedBy)
fileName: string;
contentBase64: string; // допускается data-URL префикс "data:<mime>;base64,"
mimeType?: string;
taskId?: number | null; // опциональная привязка к задаче
fieldId?: number | null; // опциональная привязка к полю формы
}
export interface UploadedFileInfo {
key: string;
url: string;
name: string;
size: number;
mimeType: string;
fileUploadId: number | null;
}
// Максимальная длина base64-строки: 50 МБ бинарных данных * 4/3 + запас на префикс.
const MAX_BASE64_LENGTH = Math.ceil(DOC_MAX_SIZE * 4 / 3) + 1024;
// Загружает файл, переданный в base64, в хранилище (S3/MinIO или локальный диск)
// в том же режиме и с теми же проверками, что POST /api/upload:
// whitelist расширений, magic bytes, раздельные лимиты (10 МБ изображения / 50 МБ прочее).
// Создаёт запись трекинга в file_uploads.
export async function uploadFileFromBase64(params: UploadFileFromBase64Params): Promise<UploadedFileInfo> {
const { organizationId, userId, taskId = null, fieldId = null } = params;
// 1. Имя файла: whitelist расширений + запрет недопустимых символов (как в multer fileFilter)
const name = path.basename(params.fileName || '');
const nameCheck = validateFilename(name);
if (!nameCheck.valid) {
throw new FileUploadError(nameCheck.reason || 'Недопустимое имя файла');
}
// 2. Base64: снимаем data-URL префикс, проверяем размер пейлоада до декодирования
let base64 = params.contentBase64 || '';
const commaIdx = base64.indexOf(',');
if (base64.startsWith('data:') && commaIdx !== -1) {
base64 = base64.slice(commaIdx + 1);
}
if (base64.length > MAX_BASE64_LENGTH) {
throw new FileUploadError(`Файл слишком большой (максимум ${DOC_MAX_SIZE / 1024 / 1024} МБ)`);
}
const buffer = Buffer.from(base64, 'base64');
if (buffer.length === 0) {
throw new FileUploadError('Пустое содержимое файла');
}
// 3. Раздельный лимит по расширению (доверенный источник — расширение, не MIME)
const ext = getFileExt(name);
const typeLimit = getSizeLimit(ext);
if (buffer.length > typeLimit) {
throw new FileUploadError(`Файл превышает лимит ${typeLimit / (1024 * 1024)} МБ для данного типа`);
}
// 4. Magic bytes по расширению (для типов с известной сигнатурой)
const signature = EXT_MAGIC[ext];
if (signature && !buffer.subarray(0, signature.length).equals(signature)) {
throw new FileUploadError('Содержимое файла не соответствует расширению — загрузка отклонена');
}
// 5. MIME: принимаем переданный, если согласован с расширением, иначе нормализуем по расширению
const normalizedMime = EXT_TO_MIME[ext]?.[0] ?? 'application/octet-stream';
const mimeType = params.mimeType && isMimeConsistentWithExt(ext, params.mimeType)
? params.mimeType
: normalizedMime;
// 6. Загрузка в хранилище в том же режиме, что POST /api/upload
let url: string;
let key: string;
if (isS3Enabled) {
try {
const result = await uploadToS3(buffer, name, mimeType);
url = result.url;
key = result.key;
} catch (s3Err) {
console.error('S3 upload error (base64):', s3Err);
throw new FileUploadError('Ошибка загрузки файла в хранилище');
}
} else {
// Локальный режим: то же имя файла, что генерирует multer (timestamp-randomhex.ext)
const uniqueName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${ext}`;
await fs.writeFile(path.join(uploadsDir, uniqueName), buffer);
url = `/uploads/${uniqueName}`;
key = uniqueName;
}
// 7. Запись трекинга (best-effort, как в POST /api/upload)
let fileUploadId: number | null = null;
try {
const [row] = await db.insert(fileUploads).values({
organizationId,
uploadedBy: userId,
fileKey: key,
originalName: name,
sizeBytes: buffer.length,
taskId,
fieldId,
}).returning({ id: fileUploads.id });
fileUploadId = row?.id ?? null;
} catch (trackErr) {
console.warn('[Upload] Failed to track base64 file upload:', trackErr);
}
return { key, url, name, size: buffer.length, mimeType, fileUploadId };
}