From 936bce2ba99e95433b9996aa15ac33eac7e75a74 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 11:28:45 +0300 Subject: [PATCH] =?UTF-8?q?MCP:=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20(base64)?= =?UTF-8?q?=20=D0=B2=20file-=D0=BF=D0=BE=D0=BB=D1=8F,=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8,=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B0=D0=B1-=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/mcp.ts | 371 ++++++++++++++++++++++++- server/services/file-upload.service.ts | 133 +++++++++ 2 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 server/services/file-upload.service.ts diff --git a/server/mcp.ts b/server/mcp.ts index 10665a6..cda971c 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -48,7 +48,8 @@ import { tasksMinimalCache, formsCache } from "./utils/cache"; import { evaluateAutoTransitions } from "./utils/auto-transitions"; import { notifyTaskAssigned } from "./utils/notifyAssignee"; import { eventBus, publishNotificationSSE } from "./routes/shared"; -import { indexFormAsync } from "./routes/task-helpers"; +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"; @@ -160,6 +161,11 @@ 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', + 'upload_table_row_file', ]; // Все остальные инструменты (изменение/удаление форм, задач, пользователей, @@ -4716,6 +4722,369 @@ 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 orgUsers = await storage.getUsersByOrganization(organizationId); + const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + if (!adminUser) return mcpError("В организации нет пользователей"); + try { + const file = await uploadFileFromBase64({ + organizationId, + userId: adminUser.id, + fileName, + contentBase64, + mimeType, + }); + 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}`); + } + } + ); + + // upload_task_file + register( + "upload_task_file", + { + title: "Upload Task Field File", + description: + "Upload a file (base64) and APPEND it to a file-type form field of a task. " + + "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).describe("Original file name with extension"), + contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"), + mimeType: z.string().optional().describe("MIME type (optional)"), + }, + }, + async ({ taskId, fieldId, fileName, contentBase64, mimeType }) => { + const task = await storage.getTask(taskId, organizationId); + if (!task) return mcpError(`Задача ${taskId} не найдена`); + if (!isFormAllowed(task.formId)) return formDenied(task.formId); + + const formFields = await storage.getFormFields(task.formId, organizationId); + const field = formFields.find((f) => f.id === fieldId); + if (!field) return mcpError(`Поле ${fieldId} не найдено в форме ${task.formId}`); + if (field.type !== 'file') { + return mcpError(`Поле ${fieldId} имеет тип «${field.type}», а не file`); + } + + const orgUsers = await storage.getUsersByOrganization(organizationId); + const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + if (!adminUser) return mcpError("В организации нет пользователей"); + + try { + const file = await uploadFileFromBase64({ + organizationId, + userId: adminUser.id, + fileName, + contentBase64, + mimeType, + taskId, + fieldId, + }); + + // File-поля множественные: значение = массив {url, name, size} — добавляем файл + const existingValues = await storage.getTaskFieldValues(taskId, organizationId); + const existingValue = existingValues.find((v) => v.fieldId === fieldId); + const currentFiles = Array.isArray(existingValue?.value) ? existingValue.value as Array> : []; + const newFiles = [...currentFiles, { url: file.url, name: file.name, size: file.size }]; + + // Лимиты поля (как в PATCH /api/tasks/:id/field-values/:fieldId) + if (field.maxFileCount != null && newFiles.length > field.maxFileCount) { + return mcpError(`Превышено максимальное количество файлов (${field.maxFileCount})`); + } + if (field.maxFileSizeMB != null) { + const totalBytes = newFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0); + if (totalBytes > field.maxFileSizeMB * 1024 * 1024) { + return mcpError(`Суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)`); + } + } + + if (existingValue) { + await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value: newFiles }); + } else { + await storage.createTaskFieldValue({ taskId, fieldId, formId: task.formId, value: newFiles }); + } + + const editorName = `${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP'; + storage.addTaskAuditLog({ + taskId, + organizationId, + action: 'field.changed', + fieldId: field.id, + fieldName: field.name, + oldValue: existingValue?.value ?? null, + newValue: newFiles, + changedBy: adminUser.id, + changedByName: editorName, + }).catch((e: unknown) => { console.error('Audit log error (MCP upload_task_file):', e); }); + + tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`); + const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser.id }); + indexTaskAsync(taskId, organizationId).catch(() => {}); + const freshTask = await storage.getTask(taskId, organizationId); + eventBus.publishEvent({ + type: 'task_updated', + organizationId, + data: { taskId, formId: task.formId, task: freshTask, autoTransition: autoResult.changed }, + }); + + 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 }, + fieldValue: newFiles, + }, 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}`); + } + } + ); + + // upload_message_file + register( + "upload_message_file", + { + title: "Upload Message Attachment", + description: + "Upload a file (base64) and post it as a task comment attachment. " + + "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).describe("Original file name with extension"), + contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"), + mimeType: z.string().optional().describe("MIME type (optional)"), + content: z.string().optional().describe("Comment text (optional; default '📎 ')"), + }, + }, + async ({ taskId, fileName, contentBase64, mimeType, content }) => { + const task = await storage.getTask(taskId, organizationId); + if (!task) return mcpError(`Задача ${taskId} не найдена`); + if (!isFormAllowed(task.formId)) return formDenied(task.formId); + + const orgUsers = await storage.getUsersByOrganization(organizationId); + const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + if (!adminUser) return mcpError("В организации нет пользователей"); + + try { + const file = await uploadFileFromBase64({ + organizationId, + userId: adminUser.id, + fileName, + contentBase64, + mimeType, + taskId, + }); + + const created = await sendTaskMessage({ + task, + user: adminUser, + organizationId, + message: content?.trim() || `📎 ${file.name}`, + attachments: [{ url: file.url, name: file.name, size: file.size, mimeType: file.mimeType }], + }); + + 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 }, + message: { id: created.id, taskId: created.taskId, message: created.message, createdAt: created.createdAt }, + }, null, 2), + }], + }; + } 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}`); + } + } + ); + + // upload_directory_file + register( + "upload_directory_file", + { + title: "Upload Directory Cell File", + description: + "Upload a file (base64) and write a link into a directory row cell. " + + "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).describe("Original file name with extension"), + contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"), + mimeType: z.string().optional().describe("MIME type (optional)"), + }, + }, + async ({ tableId, rowId, columnIndex, fileName, contentBase64, mimeType }) => { + if (!isTableAllowed(tableId)) return tableDenied(tableId); + const table = await storage.getDataTable(tableId, organizationId); + if (!table) return mcpError(`Справочник ${tableId} не найден`); + const columnCount = table.columns?.length ?? 0; + if (columnIndex < 0 || columnIndex >= columnCount) { + return mcpError(`Колонка ${columnIndex} вне диапазона (в справочнике ${columnCount} колонок)`); + } + const row = await storage.getDataTableRow(rowId, tableId, organizationId); + if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`); + + const orgUsers = await storage.getUsersByOrganization(organizationId); + const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + if (!adminUser) return mcpError("В организации нет пользователей"); + + try { + const file = await uploadFileFromBase64({ + organizationId, + userId: adminUser.id, + fileName, + contentBase64, + mimeType, + }); + + // У колонок справочника нет file-типа: в ячейку пишем markdown-ссылку [имя](url) + const values = Array.isArray(row.values) ? [...row.values] : []; + while (values.length < columnCount) values.push(''); + values[columnIndex] = `[${file.name}](${file.url})`; + const updated = await storage.updateDataTableRow(rowId, tableId, organizationId, { values }); + + 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 }, + row: { id: updated.id, values: updated.values }, + }, 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}`); + } + } + ); + + // upload_table_row_file + register( + "upload_table_row_file", + { + title: "Upload Table Tab Cell File", + description: + "Upload a file (base64) and write a link into a cell of a task's 'table' tab (regular_table_rows). " + + "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: { + taskId: z.number().int().describe("The numeric ID of the task"), + 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).describe("Original file name with extension"), + contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"), + mimeType: z.string().optional().describe("MIME type (optional)"), + }, + }, + async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, mimeType }) => { + const task = await storage.getTask(taskId, organizationId); + if (!task) return mcpError(`Задача ${taskId} не найдена`); + if (!isFormAllowed(task.formId)) return formDenied(task.formId); + + const tab = await storage.getFormTab(tabId, task.formId, organizationId); + if (!tab) return mcpError(`Таб ${tabId} не найден в форме ${task.formId}`); + if (tab.type !== 'table') return mcpError(`Таб ${tabId} имеет тип '${tab.type}', а не 'table'`); + type ColDef = { id: string; name: string; type?: string }; + const columns: ColDef[] = Array.isArray(tab.tableColumns) ? (tab.tableColumns as ColDef[]) : []; + if (!columns.some((c) => c.id === columnId)) { + return mcpError(`Колонка "${columnId}" не найдена в табе ${tabId}. Доступные: ${columns.map((c) => c.id).join(', ') || '(нет)'}`); + } + + const orgUsers = await storage.getUsersByOrganization(organizationId); + const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + if (!adminUser) return mcpError("В организации нет пользователей"); + + try { + const file = await uploadFileFromBase64({ + organizationId, + userId: adminUser.id, + fileName, + contentBase64, + mimeType, + taskId, + }); + + // Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст): + // пишем markdown-ссылку [имя](url), как и в справочниках + const cellValue = `[${file.name}](${file.url})`; + + let row; + if (rowId !== undefined) { + const existing = await storage.getRegularTableRow(rowId, taskId, tabId); + if (!existing) return mcpError(`Строка ${rowId} не найдена в табе ${tabId}`); + const data = { ...((existing.data ?? {}) as Record), [columnId]: cellValue }; + row = await storage.updateRegularTableRow(rowId, taskId, tabId, { data }); + } else { + row = await storage.createRegularTableRow({ + taskId, + tabId, + data: { [columnId]: cellValue }, + createdBy: adminUser.id, + }); + } + + 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 }, + row: { id: row.id, data: row.data }, + }, 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}`); + } + } + ); + return server; } diff --git a/server/services/file-upload.service.ts b/server/services/file-upload.service.ts new file mode 100644 index 0000000..63ba31a --- /dev/null +++ b/server/services/file-upload.service.ts @@ -0,0 +1,133 @@ +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; // опциональная привязка к полю формы +} + +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 } = 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 }; +}