From af119d1cdcc575ba76122e780f868b286bcc70fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 22 Jul 2026 16:22:26 +0300 Subject: [PATCH] =?UTF-8?q?MCP:=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=20b?= =?UTF-8?q?ase64-=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20REST=20upload=20+=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20fileUrl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 не используется) --- server/mcp.ts | 212 ++++++++----------------- server/services/file-upload.service.ts | 135 ---------------- 2 files changed, 69 insertions(+), 278 deletions(-) delete mode 100644 server/services/file-upload.service.ts diff --git a/server/mcp.ts b/server/mcp.ts index 19bb867..76b0e15 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -49,7 +49,6 @@ import { evaluateAutoTransitions } from "./utils/auto-transitions"; import { notifyTaskAssigned } from "./utils/notifyAssignee"; import { eventBus, publishNotificationSSE } from "./routes/shared"; import { indexFormAsync, indexTaskAsync } from "./routes/task-helpers"; -import { uploadFileFromBase64, FileUploadError } from "./services/file-upload.service"; import { DocumentTemplateService } from "./documents/template.service"; import { DocumentGenerationService } from "./documents/generation.service"; import { DataResolutionService } from "./documents/data-resolution.service"; @@ -162,7 +161,6 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [ 'set_task_reminder', 'send_notification', 'mark_notifications_read', - 'upload_file', 'upload_task_file', 'upload_message_file', 'upload_directory_file', @@ -289,58 +287,30 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco botId: actor.bot?.id ?? null, }); - // Источник файла для upload-инструментов: base64 (загрузка в хранилище) - // или fileUrl (уже загруженный файл — только привязка, без повторной загрузки). - // Ровно один источник обязателен. - const resolveUploadSource = async (args: { - fileName?: string; - contentBase64?: string; - fileUrl?: string; + // Валидация fileUrl для привязки уже загруженного файла. + // Загрузка бинарных данных через MCP НЕ поддерживается: файл сначала + // загружается через REST POST /api/upload (см. get_api_guide), сюда передаётся только URL. + const resolveUploadSource = (args: { + fileName: string; + fileUrl: string; fileSize?: number; mimeType?: string; - taskId?: number | null; - fieldId?: number | null; - }): Promise< + }): | { error: string } - | { actor: McpActor; file: { key: string; url: string; name: string; size: number; mimeType: string } } - > => { - const hasBase64 = !!args.contentBase64; - const hasUrl = !!args.fileUrl; - if (hasBase64 === hasUrl) { - return { error: 'Укажите ровно один источник файла: contentBase64 или fileUrl' }; + | { file: { key: string; url: string; name: string; size: number; mimeType: string } } => { + const url = args.fileUrl; + if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) { + return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/ (файл сначала загружается через REST POST /api/upload)' }; } - const actor = await getActor(); - if (hasUrl) { - const url = args.fileUrl!; - if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) { - return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/' }; - } - const name = args.fileName || url.split('/').pop() || 'file'; - return { - actor, - file: { - key: url.replace(/^\/api\/files\/|^\/uploads\//, ''), - url, - name, - size: args.fileSize ?? 0, - mimeType: args.mimeType ?? 'application/octet-stream', - }, - }; - } - if (!args.fileName) { - return { error: 'fileName обязателен при загрузке через contentBase64' }; - } - const file = await uploadFileFromBase64({ - organizationId, - userId: actor.user.id, - fileName: args.fileName, - contentBase64: args.contentBase64!, - mimeType: args.mimeType, - taskId: args.taskId, - fieldId: args.fieldId, - botId: actor.bot?.id ?? null, - }); - return { actor, file }; + return { + file: { + key: url.replace(/^\/api\/files\/|^\/uploads\//, ''), + url, + name: args.fileName, + size: args.fileSize ?? 0, + mimeType: args.mimeType ?? 'application/octet-stream', + }, + }; }; // ── Объектный доступ по scopes.tableIds (справочники) ───────────────────── @@ -4832,47 +4802,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false } ); - // ── Загрузка файлов (base64) ─────────────────────────────────────────────── - - // upload_file - register( - "upload_file", - { - title: "Upload File", - description: - "Upload a file (base64) to the organization storage without attaching it anywhere. " + - "Returns {key, url, name, size, mimeType} — the url can then be placed into fields, cells or attachments manually. " + - "Limits: images 10 MB, other types 50 MB; extension whitelist and magic-bytes checks apply (same as the web UI upload).", - inputSchema: { - fileName: z.string().min(1).describe("Original file name with extension (extension must be whitelisted: images, pdf, office docs, txt/csv, zip/rar)"), - contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix 'data:...;base64,' is allowed)"), - mimeType: z.string().optional().describe("MIME type (optional; normalized from the extension if missing or inconsistent)"), - }, - }, - async ({ fileName, contentBase64, mimeType }) => { - const actor = await getActor(); - try { - const file = await uploadFileFromBase64({ - organizationId, - userId: actor.user.id, - fileName, - contentBase64, - mimeType, - botId: actor.bot?.id ?? null, - }); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ success: true, file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType } }, null, 2), - }], - }; - } catch (err: unknown) { - if (err instanceof FileUploadError) return mcpError(err.message); - const msg = err instanceof Error ? err.message : String(err); - return mcpError(`Ошибка загрузки файла: ${msg}`); - } - } - ); + // ── Привязка файлов (fileUrl после REST-загрузки) ────────────────────────── // upload_task_file register( @@ -4880,21 +4810,20 @@ To block task creation from task.before_create, set: ctx.result = { allow: false { title: "Upload Task Field File", description: - "Upload a file and APPEND it to a file-type form field of a task. " + - "Source: contentBase64 (upload) OR fileUrl (already uploaded file, e.g. via POST /api/upload — binding only). " + + "Attach a file previously uploaded via REST POST /api/upload (with the same API key) to a file-type form field of a task — APPENDs to the field value. " + + "Do NOT try to pass file content through MCP — upload the binary via REST first (see get_api_guide). " + "File fields are multiple: the value is an array of {url, name, size}. " + "Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.", inputSchema: { taskId: z.number().int().describe("The numeric ID of the task"), fieldId: z.number().int().describe("The numeric ID of the file-type form field"), - fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64; for fileUrl defaults to the URL basename)"), - contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), - fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."), - fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"), + fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"), + fileName: z.string().min(1).describe("Original file name (as shown to users)"), + fileSize: z.number().int().min(0).optional().describe("File size in bytes (default 0; pass the size from the /api/upload response)"), mimeType: z.string().optional().describe("MIME type (optional)"), }, }, - async ({ taskId, fieldId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => { + async ({ taskId, fieldId, fileUrl, fileName, fileSize, mimeType }) => { const task = await storage.getTask(taskId, organizationId); if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!isFormAllowed(task.formId)) return formDenied(task.formId); @@ -4907,9 +4836,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false } try { - const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId, fieldId }); + const source = resolveUploadSource({ fileName, fileUrl, fileSize, mimeType }); if ('error' in source) return mcpError(source.error); - const { actor, file } = source; + const { file } = source; + const actor = await getActor(); // File-поля множественные: значение = массив {url, name, size} — добавляем файл const existingValues = await storage.getTaskFieldValues(taskId, organizationId); @@ -4967,9 +4897,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }], }; } catch (err: unknown) { - if (err instanceof FileUploadError) return mcpError(err.message); const msg = err instanceof Error ? err.message : String(err); - return mcpError(`Ошибка загрузки файла: ${msg}`); + return mcpError(`Ошибка привязки файла: ${msg}`); } } ); @@ -4980,29 +4909,29 @@ To block task creation from task.before_create, set: ctx.result = { allow: false { title: "Upload Message Attachment", description: - "Upload a file and post it as a task comment attachment. " + - "Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + + "Attach a file previously uploaded via REST POST /api/upload (with the same API key) as a task comment attachment. " + + "Do NOT try to pass file content through MCP — upload the binary via REST first (see get_api_guide). " + "If content is omitted, the message text is generated as '📎 '. " + "Triggers the same side effects as send_task_message (notifications, SSE, webhooks).", inputSchema: { taskId: z.number().int().describe("The numeric ID of the task"), - fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"), - contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), - fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."), - fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"), + fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"), + fileName: z.string().min(1).describe("Original file name (as shown to users)"), + fileSize: z.number().int().min(0).optional().describe("File size in bytes (default 0)"), mimeType: z.string().optional().describe("MIME type (optional)"), content: z.string().optional().describe("Comment text (optional; default '📎 ')"), }, }, - async ({ taskId, fileName, contentBase64, fileUrl, fileSize, mimeType, content }) => { + async ({ taskId, fileUrl, fileName, fileSize, mimeType, content }) => { const task = await storage.getTask(taskId, organizationId); if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!isFormAllowed(task.formId)) return formDenied(task.formId); try { - const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId }); + const source = resolveUploadSource({ fileName, fileUrl, fileSize, mimeType }); if ('error' in source) return mcpError(source.error); - const { actor, file } = source; + const { file } = source; + const actor = await getActor(); const created = await sendTaskMessage({ task, @@ -5024,10 +4953,9 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }], }; } catch (err: unknown) { - if (err instanceof FileUploadError) return mcpError(err.message); if (err instanceof SendTaskMessageError) return mcpError(err.message); const msg = err instanceof Error ? err.message : String(err); - return mcpError(`Ошибка загрузки файла: ${msg}`); + return mcpError(`Ошибка привязки файла: ${msg}`); } } ); @@ -5038,21 +4966,20 @@ To block task creation from task.before_create, set: ctx.result = { allow: false { title: "Upload Directory Cell File", description: - "Upload a file and write a link into a directory row cell. " + - "Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + + "Attach a file previously uploaded via REST POST /api/upload (with the same API key) into a directory row cell. " + + "Do NOT try to pass file content through MCP — upload the binary via REST first (see get_api_guide). " + "Directory columns have no file type, so the cell gets a markdown link: [file name](url).", inputSchema: { tableId: z.number().int().describe("The numeric ID of the directory (data table)"), rowId: z.number().int().describe("The numeric ID of the row"), columnIndex: z.number().int().min(0).describe("Column index (0-based)"), - fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"), - contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), - fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."), - fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"), + fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"), + fileName: z.string().min(1).describe("Original file name (as shown to users)"), + fileSize: z.number().int().min(0).optional().describe("File size in bytes (default 0)"), mimeType: z.string().optional().describe("MIME type (optional)"), }, }, - async ({ tableId, rowId, columnIndex, fileName, contentBase64, fileUrl, fileSize, mimeType }) => { + async ({ tableId, rowId, columnIndex, fileUrl, fileName, fileSize, mimeType }) => { if (!isTableAllowed(tableId)) return tableDenied(tableId); const table = await storage.getDataTable(tableId, organizationId); if (!table) return mcpError(`Справочник ${tableId} не найден`); @@ -5064,7 +4991,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`); try { - const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType }); + const source = resolveUploadSource({ fileName, fileUrl, fileSize, mimeType }); if ('error' in source) return mcpError(source.error); const { file } = source; @@ -5085,9 +5012,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }], }; } catch (err: unknown) { - if (err instanceof FileUploadError) return mcpError(err.message); const msg = err instanceof Error ? err.message : String(err); - return mcpError(`Ошибка загрузки файла: ${msg}`); + return mcpError(`Ошибка привязки файла: ${msg}`); } } ); @@ -5098,8 +5024,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false { title: "Upload Table Tab Cell File", description: - "Upload a file and write a link into a cell of a task's 'table' tab (regular_table_rows). " + - "Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + + "Attach a file previously uploaded via REST POST /api/upload (with the same API key) into a cell of a task's 'table' tab (regular_table_rows). " + + "Do NOT try to pass file content through MCP — upload the binary via REST first (see get_api_guide). " + "The cell gets a markdown link: [file name](url). " + "If rowId is omitted, a new row is created (like append_table_row). Use get_form_tabs to discover tab and column IDs.", inputSchema: { @@ -5107,14 +5033,13 @@ To block task creation from task.before_create, set: ctx.result = { allow: false tabId: z.number().int().describe("The numeric ID of the table tab"), columnId: z.string().min(1).describe("Column ID (e.g. 'gf_12', 'ft_34')"), rowId: z.number().int().optional().describe("Row ID to update. Omit to create a new row."), - fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"), - contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), - fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."), - fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"), + fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"), + fileName: z.string().min(1).describe("Original file name (as shown to users)"), + fileSize: z.number().int().min(0).optional().describe("File size in bytes (default 0)"), mimeType: z.string().optional().describe("MIME type (optional)"), }, }, - async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => { + async ({ taskId, tabId, columnId, rowId, fileUrl, fileName, fileSize, mimeType }) => { const task = await storage.getTask(taskId, organizationId); if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!isFormAllowed(task.formId)) return formDenied(task.formId); @@ -5129,9 +5054,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false } try { - const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId }); + const source = resolveUploadSource({ fileName, fileUrl, fileSize, mimeType }); if ('error' in source) return mcpError(source.error); - const { actor, file } = source; + const { file } = source; + const actor = await getActor(); // Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст): // пишем markdown-ссылку [имя](url), как и в справочниках @@ -5163,9 +5089,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }], }; } catch (err: unknown) { - if (err instanceof FileUploadError) return mcpError(err.message); const msg = err instanceof Error ? err.message : String(err); - return mcpError(`Ошибка загрузки файла: ${msg}`); + return mcpError(`Ошибка привязки файла: ${msg}`); } } ); @@ -5185,7 +5110,9 @@ To block task creation from task.before_create, set: ctx.result = { allow: false Авторизация везде: заголовок \`X-Api-Key: $KEY\` (или \`Authorization: Bearer $KEY\`). Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST. -## 1. Загрузка файла (multipart, НЕ base64) +## 1. Загрузка файла (ТОЛЬКО через REST, multipart) + +Через MCP бинарные данные НЕ передаются. Сначала загрузи файл через REST тем же ключом: \`\`\`bash curl -F "file=@/path/report.pdf" \\ @@ -5199,16 +5126,15 @@ curl -F "file=@/path/report.pdf" \\ документы \`UPLOAD_DOC_MAX_MB=100\` МБ, жёсткий потолок \`UPLOAD_MAX_MB=100\` МБ. - Расширения — whitelist: jpg/jpeg/png/gif/webp/svg, pdf, doc/docx, xls/xlsx, ppt/pptx, txt/csv, zip/rar. Для jpg/png/pdf проверяются magic bytes. -- Для base64-загрузки больших файлов НЕ используй MCP upload_* с contentBase64 — - грузи через REST /api/upload, а привязывай через fileUrl (п.2). +- Из ответа возьми \`url\`, \`name\`, \`size\` — они нужны для привязки (п.2). -## 2. Привязка файла к задаче/справочнику +## 2. Привязка файла к задаче/справочнику (MCP, по fileUrl) -Проще всего — MCP-инструменты с fileUrl (без повторной загрузки): -- \`upload_task_file({ taskId, fieldId, fileUrl, fileName?, fileSize? })\` — добавит файл в file-поле задачи. -- \`upload_message_file({ taskId, fileUrl, content? })\` — комментарий с вложением. -- \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl })\` — ссылка в ячейку справочника. -- \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl })\` — ссылка в ячейку таб-таблицы. +После загрузки вызови нужный MCP-инструмент с \`fileUrl\` (и \`fileName\`, желательно \`fileSize\` из ответа upload): +- \`upload_task_file({ taskId, fieldId, fileUrl, fileName, fileSize? })\` — добавит файл в file-поле задачи. +- \`upload_message_file({ taskId, fileUrl, fileName, content? })\` — комментарий с вложением. +- \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl, fileName })\` — ссылка в ячейку справочника. +- \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl, fileName })\` — ссылка в ячейку таб-таблицы. Либо напрямую REST (file-поле — массив объектов {url, name, size}): \`\`\`bash diff --git a/server/services/file-upload.service.ts b/server/services/file-upload.service.ts deleted file mode 100644 index 1294d75..0000000 --- a/server/services/file-upload.service.ts +++ /dev/null @@ -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:;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 { - 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 }; -}