MCP: загрузка файлов (base64) в file-поля, комментарии, справочники и таб-таблицы задач

- 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
This commit is contained in:
2026-07-22 11:28:45 +03:00
parent 25f84ee532
commit 936bce2ba9
2 changed files with 503 additions and 1 deletions

View File

@@ -48,7 +48,8 @@ import { tasksMinimalCache, formsCache } from "./utils/cache";
import { evaluateAutoTransitions } from "./utils/auto-transitions"; import { evaluateAutoTransitions } from "./utils/auto-transitions";
import { notifyTaskAssigned } from "./utils/notifyAssignee"; import { notifyTaskAssigned } from "./utils/notifyAssignee";
import { eventBus, publishNotificationSSE } from "./routes/shared"; 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 { DocumentTemplateService } from "./documents/template.service";
import { DocumentGenerationService } from "./documents/generation.service"; import { DocumentGenerationService } from "./documents/generation.service";
import { DataResolutionService } from "./documents/data-resolution.service"; import { DataResolutionService } from "./documents/data-resolution.service";
@@ -160,6 +161,11 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
'set_task_reminder', 'set_task_reminder',
'send_notification', 'send_notification',
'mark_notifications_read', '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<Record<string, unknown>> : [];
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 '📎 <file name>'. " +
"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 '📎 <file name>')"),
},
},
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<string, unknown>), [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; return server;
} }

View File

@@ -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:<mime>;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<UploadedFileInfo> {
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 };
}