Upload-тикеты: загрузка файлов через REST без знания API-ключа

- MCP create_upload_ticket → короткоживущий uploadUrl (10 мин, in-memory)
- POST /api/upload/ticket/:token — тот же пайплайн (processUploadRequest вынесен из /api/upload), авторизация тикетом, проверки скоупов
- get_api_guide: флоу без ключа — ticket → curl → привязка по fileUrl
This commit is contained in:
2026-07-23 15:53:17 +03:00
parent af119d1cdc
commit affbdeee1e
3 changed files with 389 additions and 177 deletions

View File

@@ -35,6 +35,7 @@ async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: stri
import type { Request, Response } from "express";
import type { Task, ApiKeyScopes, OrganizationApiKey, User, Bot } from "@shared/schema";
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
import { createUploadTicket } from "./utils/upload-tickets";
import beautify from "js-beautify";
import {
semanticSearch,
@@ -165,6 +166,7 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
'upload_message_file',
'upload_directory_file',
'upload_table_row_file',
'create_upload_ticket',
];
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
@@ -5095,39 +5097,109 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
}
);
// create_upload_ticket — короткоживущий URL загрузки БЕЗ API-ключа.
// Сценарий: агент видит MCP-инструменты, но значение ключа зашито в конфиге
// его MCP-клиента — выполнить curl с X-Api-Key он не может. Тикет решает это.
register(
"create_upload_ticket",
{
title: "Create Upload Ticket",
description:
"Get a short-lived (10 min) upload URL so files can be uploaded via plain curl WITHOUT the API key " +
"(the key value is hidden in your MCP client config and cannot be used in shell commands). " +
"Upload the binary with `curl -F \"file=@...\" \"<uploadUrl>?taskId=...&fieldId=...\"` (no auth headers), " +
"then attach the returned url via upload_task_file / upload_message_file / upload_directory_file / upload_table_row_file (fileUrl). " +
"The ticket is reusable within its TTL and bound to your organization and key scopes.",
inputSchema: {
taskId: z.number().int().optional().describe("Optional task ID the file will be attached to — checked against key scopes and embedded into curlExample"),
fieldId: z.number().int().optional().describe("Optional file field ID — embedded into curlExample"),
},
},
async ({ taskId, fieldId }) => {
if (scopes.mode === 'read') {
return mcpError('Загрузка файлов недоступна в режиме только чтение (scopes.mode = read)');
}
if (taskId !== undefined) {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
}
try {
// Автор загрузки по тикету = владелец ключа (fallback: актор организации)
let createdBy = apiKeyRecord?.createdBy;
if (!createdBy) createdBy = (await getActor()).user.id;
const ticket = createUploadTicket({
organizationId,
apiKeyId: apiKeyRecord?.id ?? null,
botId: apiKeyRecord?.botId ?? null,
createdBy,
scopes,
});
const baseUrl = (process.env.PUBLIC_APP_URL || process.env.APP_URL || process.env.BASE_URL || 'https://iistwin.ru').replace(/\/+$/, '');
const uploadUrl = `${baseUrl}/api/upload/ticket/${ticket.token}`;
const query = [
taskId !== undefined ? `taskId=${taskId}` : null,
fieldId !== undefined ? `fieldId=${fieldId}` : null,
].filter(Boolean).join('&');
const curlExample = `curl -F "file=@<путь_к_файлу>" "${uploadUrl}${query ? `?${query}` : ''}"`;
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
uploadUrl,
expiresAt: ticket.expiresAt.toISOString(),
curlExample,
}, null, 2),
}],
};
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return mcpError(`Ошибка создания тикета загрузки: ${msg}`);
}
}
);
// get_api_guide
register(
"get_api_guide",
{
title: "Get API Guide",
description: "Returns a compact Russian-language guide for AI agents: how to upload files and create tasks/comments via REST with the same API key, limits, and file field value formats. Call this FIRST when you need to attach files to tasks.",
description: "Returns a compact Russian-language guide for AI agents: how to upload files via create_upload_ticket (curl WITHOUT the API key) and create tasks/comments via REST, limits, and file field value formats. Call this FIRST when you need to attach files to tasks.",
inputSchema: {},
},
async () => {
const guide = `
# Работа с API iistwin по ключу (гайд для ИИ-агента)
Авторизация везде: заголовок \`X-Api-Key: $KEY\` (или \`Authorization: Bearer $KEY\`).
Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST.
## 1. Загрузка файла (ТОЛЬКО через REST, multipart)
## 1. Загрузка файла через upload-тикет (БЕЗ API-ключа — основной способ)
Через MCP бинарные данные НЕ передаются. Сначала загрузи файл через REST тем же ключом:
Значение ключа зашито в конфиге твоего MCP-клиента и тебе недоступно, поэтому
для curl используй короткоживущий тикет загрузки:
**Шаг 1.** Вызови MCP-инструмент \`create_upload_ticket({ taskId?, fieldId? })\` →
получишь \`uploadUrl\`, \`expiresAt\` (TTL 10 минут) и готовый \`curlExample\`.
**Шаг 2.** Загрузи файл БЕЗ заголовков авторизации:
\`\`\`bash
curl -F "file=@/path/report.pdf" \\
-H "X-Api-Key: $KEY" \\
"https://iistwin.ru/api/upload?taskId=<taskId>&fieldId=<fieldId>"
curl -F "file=@/path/report.pdf" "<uploadUrl>?taskId=<taskId>&fieldId=<fieldId>"
# → { "url": "/api/files/<key>", "name": "report.pdf", "size": 123456 }
\`\`\`
- taskId/fieldId в query — необязательны, но при taskId проверяется доступ ключа к форме задачи.
- Тикет многоразовый в пределах TTL (можно загрузить несколько файлов), привязан к твоей организации.
- Лимиты (дефолты, переопределяются env): изображения \`UPLOAD_IMAGE_MAX_MB=25\` МБ,
документы \`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.
- Из ответа возьми \`url\`, \`name\`, \`size\` — они нужны для привязки (п.2).
Если значение ключа тебе ИЗВЕСТНО, можно загружать напрямую:
\`curl -F "file=@..." -H "X-Api-Key: $KEY" "https://iistwin.ru/api/upload?taskId=...&fieldId=..."\`.
## 2. Привязка файла к задаче/справочнику (MCP, по fileUrl)
После загрузки вызови нужный MCP-инструмент с \`fileUrl\` (и \`fileName\`, желательно \`fileSize\` из ответа upload):