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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user