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:
212
server/mcp.ts
212
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 '📎 <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).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 '📎 <file name>')"),
|
||||
},
|
||||
},
|
||||
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
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../db';
|
||||
import { fileUploads } from '@shared/schema';
|
||||
import { isS3Enabled, uploadToS3 } from '../utils/s3';
|
||||
import {
|
||||
uploadsDir,
|
||||
getFileExt,
|
||||
validateFilename,
|
||||
getSizeLimit,
|
||||
isMimeConsistentWithExt,
|
||||
EXT_TO_MIME,
|
||||
EXT_MAGIC,
|
||||
DOC_MAX_SIZE,
|
||||
} from '../utils/upload';
|
||||
|
||||
// Ошибка загрузки файла — маппится в понятное сообщение пользователю (на русском).
|
||||
export class FileUploadError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FileUploadError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface UploadFileFromBase64Params {
|
||||
organizationId: number;
|
||||
userId: number; // автор загрузки (file_uploads.uploadedBy)
|
||||
fileName: string;
|
||||
contentBase64: string; // допускается data-URL префикс "data:<mime>;base64,"
|
||||
mimeType?: string;
|
||||
taskId?: number | null; // опциональная привязка к задаче
|
||||
fieldId?: number | null; // опциональная привязка к полю формы
|
||||
botId?: number | null; // атрибуция загрузки ботом (file_uploads.bot_id)
|
||||
}
|
||||
|
||||
export interface UploadedFileInfo {
|
||||
key: string;
|
||||
url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
fileUploadId: number | null;
|
||||
}
|
||||
|
||||
// Максимальная длина base64-строки: 50 МБ бинарных данных * 4/3 + запас на префикс.
|
||||
const MAX_BASE64_LENGTH = Math.ceil(DOC_MAX_SIZE * 4 / 3) + 1024;
|
||||
|
||||
// Загружает файл, переданный в base64, в хранилище (S3/MinIO или локальный диск)
|
||||
// в том же режиме и с теми же проверками, что POST /api/upload:
|
||||
// whitelist расширений, magic bytes, раздельные лимиты (10 МБ изображения / 50 МБ прочее).
|
||||
// Создаёт запись трекинга в file_uploads.
|
||||
export async function uploadFileFromBase64(params: UploadFileFromBase64Params): Promise<UploadedFileInfo> {
|
||||
const { organizationId, userId, taskId = null, fieldId = null, botId = null } = params;
|
||||
|
||||
// 1. Имя файла: whitelist расширений + запрет недопустимых символов (как в multer fileFilter)
|
||||
const name = path.basename(params.fileName || '');
|
||||
const nameCheck = validateFilename(name);
|
||||
if (!nameCheck.valid) {
|
||||
throw new FileUploadError(nameCheck.reason || 'Недопустимое имя файла');
|
||||
}
|
||||
|
||||
// 2. Base64: снимаем data-URL префикс, проверяем размер пейлоада до декодирования
|
||||
let base64 = params.contentBase64 || '';
|
||||
const commaIdx = base64.indexOf(',');
|
||||
if (base64.startsWith('data:') && commaIdx !== -1) {
|
||||
base64 = base64.slice(commaIdx + 1);
|
||||
}
|
||||
if (base64.length > MAX_BASE64_LENGTH) {
|
||||
throw new FileUploadError(`Файл слишком большой (максимум ${DOC_MAX_SIZE / 1024 / 1024} МБ)`);
|
||||
}
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
if (buffer.length === 0) {
|
||||
throw new FileUploadError('Пустое содержимое файла');
|
||||
}
|
||||
|
||||
// 3. Раздельный лимит по расширению (доверенный источник — расширение, не MIME)
|
||||
const ext = getFileExt(name);
|
||||
const typeLimit = getSizeLimit(ext);
|
||||
if (buffer.length > typeLimit) {
|
||||
throw new FileUploadError(`Файл превышает лимит ${typeLimit / (1024 * 1024)} МБ для данного типа`);
|
||||
}
|
||||
|
||||
// 4. Magic bytes по расширению (для типов с известной сигнатурой)
|
||||
const signature = EXT_MAGIC[ext];
|
||||
if (signature && !buffer.subarray(0, signature.length).equals(signature)) {
|
||||
throw new FileUploadError('Содержимое файла не соответствует расширению — загрузка отклонена');
|
||||
}
|
||||
|
||||
// 5. MIME: принимаем переданный, если согласован с расширением, иначе нормализуем по расширению
|
||||
const normalizedMime = EXT_TO_MIME[ext]?.[0] ?? 'application/octet-stream';
|
||||
const mimeType = params.mimeType && isMimeConsistentWithExt(ext, params.mimeType)
|
||||
? params.mimeType
|
||||
: normalizedMime;
|
||||
|
||||
// 6. Загрузка в хранилище в том же режиме, что POST /api/upload
|
||||
let url: string;
|
||||
let key: string;
|
||||
if (isS3Enabled) {
|
||||
try {
|
||||
const result = await uploadToS3(buffer, name, mimeType);
|
||||
url = result.url;
|
||||
key = result.key;
|
||||
} catch (s3Err) {
|
||||
console.error('S3 upload error (base64):', s3Err);
|
||||
throw new FileUploadError('Ошибка загрузки файла в хранилище');
|
||||
}
|
||||
} else {
|
||||
// Локальный режим: то же имя файла, что генерирует multer (timestamp-randomhex.ext)
|
||||
const uniqueName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${ext}`;
|
||||
await fs.writeFile(path.join(uploadsDir, uniqueName), buffer);
|
||||
url = `/uploads/${uniqueName}`;
|
||||
key = uniqueName;
|
||||
}
|
||||
|
||||
// 7. Запись трекинга (best-effort, как в POST /api/upload)
|
||||
let fileUploadId: number | null = null;
|
||||
try {
|
||||
const [row] = await db.insert(fileUploads).values({
|
||||
organizationId,
|
||||
uploadedBy: userId,
|
||||
fileKey: key,
|
||||
originalName: name,
|
||||
sizeBytes: buffer.length,
|
||||
taskId,
|
||||
fieldId,
|
||||
botId,
|
||||
}).returning({ id: fileUploads.id });
|
||||
fileUploadId = row?.id ?? null;
|
||||
} catch (trackErr) {
|
||||
console.warn('[Upload] Failed to track base64 file upload:', trackErr);
|
||||
}
|
||||
|
||||
return { key, url, name, size: buffer.length, mimeType, fileUploadId };
|
||||
}
|
||||
Reference in New Issue
Block a user