- MCP: getActor (владелец ключа + бот), аудит changedByName=бот/label ключа, botId, metadata.source='mcp'; аудит для update_task/update_task_status - sendTaskMessage: botId (messageType 'bot'), починен путь isBotToken без req.user - authenticateTokenOrApiKey: JWT или X-Api-Key с белым списком endpoint'ов (/api/upload, messages, field-values, create task), проверки скоупов, аудит source='api' - Лимиты файлов через env: UPLOAD_IMAGE_MAX_MB=25, UPLOAD_DOC_MAX_MB=100, UPLOAD_MAX_MB=100 - MCP upload-инструменты: параметр fileUrl для привязки уже загруженного файла; новый инструмент get_api_guide
136 lines
5.8 KiB
TypeScript
136 lines
5.8 KiB
TypeScript
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; // опциональная привязка к полю формы
|
||
botId?: number | null; // атрибуция загрузки ботом (file_uploads.bot_id)
|
||
}
|
||
|
||
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, botId = 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,
|
||
botId,
|
||
}).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 };
|
||
}
|