MCP: удалён base64-вариант загрузки файлов — только REST upload + привязка по fileUrl

- upload_file удалён; upload_task_file/upload_message_file/upload_directory_file/upload_table_row_file принимают только fileUrl (+fileName)
- descriptions указывают на REST POST /api/upload и get_api_guide
- server/services/file-upload.service.ts удалён (без base64 не используется)
This commit is contained in:
2026-07-22 16:22:26 +03:00
parent b3818852df
commit af119d1cdc
2 changed files with 69 additions and 278 deletions

View File

@@ -1,135 +0,0 @@
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 };
}