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

@@ -49,7 +49,6 @@ 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, indexTaskAsync } 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";
@@ -162,7 +161,6 @@ 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_task_file',
'upload_message_file', 'upload_message_file',
'upload_directory_file', 'upload_directory_file',
@@ -289,58 +287,30 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco
botId: actor.bot?.id ?? null, botId: actor.bot?.id ?? null,
}); });
// Источник файла для upload-инструментов: base64 (загрузка в хранилище) // Валидация fileUrl для привязки уже загруженного файла.
// или fileUrl (уже загруженный файл — только привязка, без повторной загрузки). // Загрузка бинарных данных через MCP НЕ поддерживается: файл сначала
// Ровно один источник обязателен. // загружается через REST POST /api/upload (см. get_api_guide), сюда передаётся только URL.
const resolveUploadSource = async (args: { const resolveUploadSource = (args: {
fileName?: string; fileName: string;
contentBase64?: string; fileUrl: string;
fileUrl?: string;
fileSize?: number; fileSize?: number;
mimeType?: string; mimeType?: string;
taskId?: number | null; }):
fieldId?: number | null;
}): Promise<
| { error: string } | { error: string }
| { actor: McpActor; file: { key: string; url: string; name: string; size: number; mimeType: string } } | { file: { key: string; url: string; name: string; size: number; mimeType: string } } => {
> => { const url = args.fileUrl;
const hasBase64 = !!args.contentBase64; if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) {
const hasUrl = !!args.fileUrl; return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/ (файл сначала загружается через REST POST /api/upload)' };
if (hasBase64 === hasUrl) {
return { error: 'Укажите ровно один источник файла: contentBase64 или fileUrl' };
} }
const actor = await getActor(); return {
if (hasUrl) { file: {
const url = args.fileUrl!; key: url.replace(/^\/api\/files\/|^\/uploads\//, ''),
if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) { url,
return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/' }; name: args.fileName,
} size: args.fileSize ?? 0,
const name = args.fileName || url.split('/').pop() || 'file'; mimeType: args.mimeType ?? 'application/octet-stream',
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 };
}; };
// ── Объектный доступ по scopes.tableIds (справочники) ───────────────────── // ── Объектный доступ по scopes.tableIds (справочники) ─────────────────────
@@ -4832,47 +4802,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
} }
); );
// ── Загрузка файлов (base64) ─────────────────────────────────────────────── // ── Привязка файлов (fileUrl после REST-загрузки) ──────────────────────────
// 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}`);
}
}
);
// upload_task_file // upload_task_file
register( register(
@@ -4880,21 +4810,20 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
{ {
title: "Upload Task Field File", title: "Upload Task Field File",
description: description:
"Upload a file and APPEND it to a file-type form field of a task. " + "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. " +
"Source: contentBase64 (upload) OR fileUrl (already uploaded file, e.g. via POST /api/upload — binding only). " + "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}. " + "File fields are multiple: the value is an array of {url, name, size}. " +
"Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.", "Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.",
inputSchema: { inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"), 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"), 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)"), fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"),
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), fileName: z.string().min(1).describe("Original file name (as shown to users)"),
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 (default 0; pass the size from the /api/upload response)"),
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
mimeType: z.string().optional().describe("MIME type (optional)"), 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); const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId); 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 { 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); if ('error' in source) return mcpError(source.error);
const { actor, file } = source; const { file } = source;
const actor = await getActor();
// File-поля множественные: значение = массив {url, name, size} — добавляем файл // File-поля множественные: значение = массив {url, name, size} — добавляем файл
const existingValues = await storage.getTaskFieldValues(taskId, organizationId); 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) { } catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err); 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", title: "Upload Message Attachment",
description: description:
"Upload a file and post it as a task comment attachment. " + "Attach a file previously uploaded via REST POST /api/upload (with the same API key) as a task comment attachment. " +
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + "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 '📎 <file name>'. " + "If content is omitted, the message text is generated as '📎 <file name>'. " +
"Triggers the same side effects as send_task_message (notifications, SSE, webhooks).", "Triggers the same side effects as send_task_message (notifications, SSE, webhooks).",
inputSchema: { inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"), 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)"), fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"),
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), fileName: z.string().min(1).describe("Original file name (as shown to users)"),
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 (default 0)"),
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
mimeType: z.string().optional().describe("MIME type (optional)"), mimeType: z.string().optional().describe("MIME type (optional)"),
content: z.string().optional().describe("Comment text (optional; default '📎 <file name>')"), content: z.string().optional().describe("Comment text (optional; default '📎 <file name>')"),
}, },
}, },
async ({ taskId, fileName, contentBase64, fileUrl, fileSize, mimeType, content }) => { async ({ taskId, fileUrl, fileName, fileSize, mimeType, content }) => {
const task = await storage.getTask(taskId, organizationId); const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId); if (!isFormAllowed(task.formId)) return formDenied(task.formId);
try { 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); if ('error' in source) return mcpError(source.error);
const { actor, file } = source; const { file } = source;
const actor = await getActor();
const created = await sendTaskMessage({ const created = await sendTaskMessage({
task, task,
@@ -5024,10 +4953,9 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
}], }],
}; };
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
if (err instanceof SendTaskMessageError) return mcpError(err.message); if (err instanceof SendTaskMessageError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err); 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", title: "Upload Directory Cell File",
description: description:
"Upload a file and write a link into a directory row cell. " + "Attach a file previously uploaded via REST POST /api/upload (with the same API key) into a directory row cell. " +
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + "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).", "Directory columns have no file type, so the cell gets a markdown link: [file name](url).",
inputSchema: { inputSchema: {
tableId: z.number().int().describe("The numeric ID of the directory (data table)"), tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
rowId: z.number().int().describe("The numeric ID of the row"), rowId: z.number().int().describe("The numeric ID of the row"),
columnIndex: z.number().int().min(0).describe("Column index (0-based)"), 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)"), fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"),
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), fileName: z.string().min(1).describe("Original file name (as shown to users)"),
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 (default 0)"),
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
mimeType: z.string().optional().describe("MIME type (optional)"), 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); if (!isTableAllowed(tableId)) return tableDenied(tableId);
const table = await storage.getDataTable(tableId, organizationId); const table = await storage.getDataTable(tableId, organizationId);
if (!table) return mcpError(`Справочник ${tableId} не найден`); 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}`); if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
try { 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); if ('error' in source) return mcpError(source.error);
const { file } = source; const { file } = source;
@@ -5085,9 +5012,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
}], }],
}; };
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err); 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", title: "Upload Table Tab Cell File",
description: description:
"Upload a file and write a link into a cell of a task's 'table' tab (regular_table_rows). " + "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). " +
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + "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). " + "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.", "If rowId is omitted, a new row is created (like append_table_row). Use get_form_tabs to discover tab and column IDs.",
inputSchema: { 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"), 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')"), 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."), 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)"), fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"),
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), fileName: z.string().min(1).describe("Original file name (as shown to users)"),
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 (default 0)"),
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
mimeType: z.string().optional().describe("MIME type (optional)"), 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); const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId); 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 { 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); if ('error' in source) return mcpError(source.error);
const { actor, file } = source; const { file } = source;
const actor = await getActor();
// Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст): // Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст):
// пишем markdown-ссылку [имя](url), как и в справочниках // пишем markdown-ссылку [имя](url), как и в справочниках
@@ -5163,9 +5089,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
}], }],
}; };
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err); 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\`). Авторизация везде: заголовок \`X-Api-Key: $KEY\` (или \`Authorization: Bearer $KEY\`).
Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST. Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST.
## 1. Загрузка файла (multipart, НЕ base64) ## 1. Загрузка файла (ТОЛЬКО через REST, multipart)
Через MCP бинарные данные НЕ передаются. Сначала загрузи файл через REST тем же ключом:
\`\`\`bash \`\`\`bash
curl -F "file=@/path/report.pdf" \\ 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\` МБ. документы \`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. - Расширения — whitelist: jpg/jpeg/png/gif/webp/svg, pdf, doc/docx, xls/xlsx, ppt/pptx, txt/csv, zip/rar.
Для jpg/png/pdf проверяются magic bytes. Для jpg/png/pdf проверяются magic bytes.
- Для base64-загрузки больших файлов НЕ используй MCP upload_* с contentBase64 — - Из ответа возьми \`url\`, \`name\`, \`size\` — они нужны для привязки (п.2).
грузи через REST /api/upload, а привязывай через fileUrl (п.2).
## 2. Привязка файла к задаче/справочнику ## 2. Привязка файла к задаче/справочнику (MCP, по fileUrl)
Проще всего — MCP-инструменты с fileUrl (без повторной загрузки): После загрузки вызови нужный MCP-инструмент с \`fileUrl\` (и \`fileName\`, желательно \`fileSize\` из ответа upload):
- \`upload_task_file({ taskId, fieldId, fileUrl, fileName?, fileSize? })\` — добавит файл в file-поле задачи. - \`upload_task_file({ taskId, fieldId, fileUrl, fileName, fileSize? })\` — добавит файл в file-поле задачи.
- \`upload_message_file({ taskId, fileUrl, content? })\` — комментарий с вложением. - \`upload_message_file({ taskId, fileUrl, fileName, content? })\` — комментарий с вложением.
- \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl })\` — ссылка в ячейку справочника. - \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl, fileName })\` — ссылка в ячейку справочника.
- \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl })\` — ссылка в ячейку таб-таблицы. - \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl, fileName })\` — ссылка в ячейку таб-таблицы.
Либо напрямую REST (file-поле — массив объектов {url, name, size}): Либо напрямую REST (file-поле — массив объектов {url, name, size}):
\`\`\`bash \`\`\`bash

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 };
}