From affbdeee1eb286953d812c2d4e44117851ca976f 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: Thu, 23 Jul 2026 15:53:17 +0300 Subject: [PATCH] =?UTF-8?q?Upload-=D1=82=D0=B8=D0=BA=D0=B5=D1=82=D1=8B:=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20RES?= =?UTF-8?q?T=20=D0=B1=D0=B5=D0=B7=20=D0=B7=D0=BD=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20API-=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP create_upload_ticket → короткоживущий uploadUrl (10 мин, in-memory) - POST /api/upload/ticket/:token — тот же пайплайн (processUploadRequest вынесен из /api/upload), авторизация тикетом, проверки скоупов - get_api_guide: флоу без ключа — ticket → curl → привязка по fileUrl --- server/mcp.ts | 86 ++++++- server/routes/files.routes.ts | 419 ++++++++++++++++++++------------- server/utils/upload-tickets.ts | 61 +++++ 3 files changed, 389 insertions(+), 177 deletions(-) create mode 100644 server/utils/upload-tickets.ts diff --git a/server/mcp.ts b/server/mcp.ts index 76b0e15..d974153 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -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=@...\" \"?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=&fieldId=" +curl -F "file=@/path/report.pdf" "?taskId=&fieldId=" # → { "url": "/api/files/", "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): diff --git a/server/routes/files.routes.ts b/server/routes/files.routes.ts index a690068..85baa34 100644 --- a/server/routes/files.routes.ts +++ b/server/routes/files.routes.ts @@ -1,10 +1,12 @@ -import { Router } from "express"; +import { Router, type Response } from "express"; import fs from 'fs/promises'; import multer from 'multer'; import { storage } from "../storage"; import { authenticateTokenOrApiKey, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { isS3Enabled, uploadToS3FromFile } from "../utils/s3"; import { isFormAllowedByScopes } from "../utils/api-key"; +import { consumeUploadTicket } from "../utils/upload-tickets"; +import { openTenantCtx, _tenantCtx } from "../db"; import { upload, EXT_TO_MIME, getFileExt, checkMagicBytes, getSizeLimit, DOC_MAX_SIZE, getPendingKey, getActivePendingUploads, addPendingUpload, } from "./shared"; @@ -12,202 +14,279 @@ import { db } from "../db"; import { fileUploads } from "@shared/schema"; const router = Router(); - // File upload endpoint (JWT пользователя или API-ключ организации) - router.post('/api/upload', authenticateTokenOrApiKey, async (req: AuthenticatedRequest, res) => { - try { - await new Promise((resolve, reject) => { - upload.single('file')(req, res, (err: unknown) => { - if (err) reject(err); - else resolve(); - }); + +// Контекст загрузки, не зависящий от способа авторизации +// (JWT пользователя / API-ключ / upload-тикет) +interface UploadRequestContext { + organizationId: number; + /** Автор загрузки для file_uploads.uploadedBy (владелец ключа/тикета). + * Для JWT-пути не передаётся — берётся req.user.id. */ + uploadedBy?: number; + /** Атрибуция бота для file_uploads.botId */ + botId?: number | null; +} + +// Общий пайплайн загрузки файла ПОСЛЕ авторизации: +// multer → magic bytes → лимиты по типу/полю → S3 или локальный диск → file_uploads. +// Используется и POST /api/upload (JWT/API-ключ), и POST /api/upload/ticket/:token. +async function processUploadRequest(req: AuthenticatedRequest, res: Response, ctx: UploadRequestContext) { + try { + await new Promise((resolve, reject) => { + upload.single('file')(req, res, (err: unknown) => { + if (err) reject(err); + else resolve(); }); - } catch (err: unknown) { - if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ error: `Файл слишком большой (максимум ${DOC_MAX_SIZE / 1024 / 1024} МБ)` }); - } - const message = err instanceof Error ? err.message : 'Ошибка загрузки файла'; - return res.status(400).json({ error: message }); + }); + } catch (err: unknown) { + if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ error: `Файл слишком большой (максимум ${DOC_MAX_SIZE / 1024 / 1024} МБ)` }); } + const message = err instanceof Error ? err.message : 'Ошибка загрузки файла'; + return res.status(400).json({ error: message }); + } - if (!req.file) { - return res.status(400).json({ error: 'Файл не передан' }); + if (!req.file) { + return res.status(400).json({ error: 'Файл не передан' }); + } + + // Проверки для авторизации по API-ключу (JWT-путь не меняется) + if (req.apiKey) { + if (req.apiKey.scopes.mode === 'read') { + if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } } + return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' }); } - - // Проверки для авторизации по API-ключу (JWT-путь не меняется) - if (req.apiKey) { - if (req.apiKey.scopes.mode === 'read') { + const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN; + if (!isNaN(taskIdForScope)) { + const scopedTask = await storage.getTask(taskIdForScope, req.organizationId!); + if (!scopedTask || !isFormAllowedByScopes(req.apiKey.scopes, scopedTask.formId)) { if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } } - return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' }); + return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); } - const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN; - if (!isNaN(taskIdForScope)) { - const scopedTask = await storage.getTask(taskIdForScope, req.organizationId!); - if (!scopedTask || !isFormAllowedByScopes(req.apiKey.scopes, scopedTask.formId)) { - if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } } - return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); + } + } + + // deleteNewFile — удаляем временный файл с диска если валидация не прошла + // Теперь всегда disk-режим (S3 тоже пишет во tmpUploadDir до загрузки) + const deleteNewFile = async () => { + if (req.file?.path) { + try { await fs.unlink(req.file.path); } catch { /* ignore */ } + } + }; + + // ── Дополнительные проверки безопасности после приёма файла ────────────── + // Все проверки основаны на расширении файла (доверенный источник), + // а не на file.mimetype (клиентская переменная, легко подделывается) + + const fileExt = getFileExt(req.file.originalname); + + // 1. Проверка magic bytes по РАСШИРЕНИЮ (не по MIME) + // Файл, переименованный из .exe в .jpg, не пройдёт эту проверку + const magicOk = await checkMagicBytes(req.file.path, fileExt); + if (!magicOk) { + await deleteNewFile(); + return res.status(400).json({ error: 'Содержимое файла не соответствует расширению — загрузка отклонена' }); + } + + // 2. Раздельный лимит по РАСШИРЕНИЮ: изображения 10 МБ, документы 50 МБ + const typeLimit = getSizeLimit(fileExt); + if (req.file.size > typeLimit) { + await deleteNewFile(); + const limitMB = typeLimit / (1024 * 1024); + return res.status(400).json({ error: `Файл превышает лимит ${limitMB} МБ для данного типа` }); + } + + // ───────────────────────────────────────────────────────────────────────── + + const rawFieldId = req.query.fieldId; + const rawTaskId = req.query.taskId; + + if (rawFieldId) { + const fieldId = parseInt(String(rawFieldId)); + if (isNaN(fieldId) || !ctx.organizationId) { + await deleteNewFile(); + return res.status(400).json({ error: 'Неверный контекст загрузки файла' }); + } + + let field: Awaited>; + try { + field = await storage.getFormFieldById(fieldId, ctx.organizationId); + } catch (lookupErr) { + console.error('Upload field limit check error:', lookupErr); + await deleteNewFile(); + return res.status(500).json({ error: 'Ошибка проверки ограничений поля' }); + } + + if (!field || field.type !== 'file') { + await deleteNewFile(); + return res.status(404).json({ error: 'Поле не найдено' }); + } + + const hasCountLimit = field.maxFileCount != null; + const hasSizeLimit = field.maxFileSizeMB != null; + + if (hasCountLimit || hasSizeLimit) { + const taskId = rawTaskId ? parseInt(String(rawTaskId)) : NaN; + let existingBytes = 0; + let existingCount = 0; + + if (!isNaN(taskId)) { + const existingValues = await storage.getTaskFieldValues(taskId, ctx.organizationId); + const fieldValue = existingValues.find(v => v.fieldId === fieldId); + if (fieldValue && Array.isArray(fieldValue.value)) { + const existingFiles = fieldValue.value as Array<{ size?: number }>; + existingCount = existingFiles.length; + existingBytes = existingFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0); + } + } else { + if (req.user?.id) { + const key = getPendingKey(req.user.id, fieldId); + const pending = getActivePendingUploads(key); + existingCount = pending.length; + existingBytes = pending.reduce((sum, e) => sum + e.size, 0); } } - } - // deleteNewFile — удаляем временный файл с диска если валидация не прошла - // Теперь всегда disk-режим (S3 тоже пишет во tmpUploadDir до загрузки) - const deleteNewFile = async () => { - if (req.file?.path) { - try { await fs.unlink(req.file.path); } catch { /* ignore */ } - } - }; - - // ── Дополнительные проверки безопасности после приёма файла ────────────── - // Все проверки основаны на расширении файла (доверенный источник), - // а не на file.mimetype (клиентская переменная, легко подделывается) - - const fileExt = getFileExt(req.file.originalname); - - // 1. Проверка magic bytes по РАСШИРЕНИЮ (не по MIME) - // Файл, переименованный из .exe в .jpg, не пройдёт эту проверку - const magicOk = await checkMagicBytes(req.file.path, fileExt); - if (!magicOk) { - await deleteNewFile(); - return res.status(400).json({ error: 'Содержимое файла не соответствует расширению — загрузка отклонена' }); - } - - // 2. Раздельный лимит по РАСШИРЕНИЮ: изображения 10 МБ, документы 50 МБ - const typeLimit = getSizeLimit(fileExt); - if (req.file.size > typeLimit) { - await deleteNewFile(); - const limitMB = typeLimit / (1024 * 1024); - return res.status(400).json({ error: `Файл превышает лимит ${limitMB} МБ для данного типа` }); - } - - // ───────────────────────────────────────────────────────────────────────── - - const rawFieldId = req.query.fieldId; - const rawTaskId = req.query.taskId; - - if (rawFieldId) { - const fieldId = parseInt(String(rawFieldId)); - if (isNaN(fieldId) || !req.organizationId) { + if (hasCountLimit && existingCount >= field.maxFileCount!) { await deleteNewFile(); - return res.status(400).json({ error: 'Неверный контекст загрузки файла' }); + return res.status(400).json({ + error: `Превышено максимальное количество файлов (${field.maxFileCount})`, + }); } - let field: Awaited>; - try { - field = await storage.getFormFieldById(fieldId, req.organizationId); - } catch (lookupErr) { - console.error('Upload field limit check error:', lookupErr); - await deleteNewFile(); - return res.status(500).json({ error: 'Ошибка проверки ограничений поля' }); - } - - if (!field || field.type !== 'file') { - await deleteNewFile(); - return res.status(404).json({ error: 'Поле не найдено' }); - } - - const hasCountLimit = field.maxFileCount != null; - const hasSizeLimit = field.maxFileSizeMB != null; - - if (hasCountLimit || hasSizeLimit) { - const taskId = rawTaskId ? parseInt(String(rawTaskId)) : NaN; - let existingBytes = 0; - let existingCount = 0; - - if (!isNaN(taskId)) { - const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId); - const fieldValue = existingValues.find(v => v.fieldId === fieldId); - if (fieldValue && Array.isArray(fieldValue.value)) { - const existingFiles = fieldValue.value as Array<{ size?: number }>; - existingCount = existingFiles.length; - existingBytes = existingFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0); - } - } else { - if (req.user?.id) { - const key = getPendingKey(req.user.id, fieldId); - const pending = getActivePendingUploads(key); - existingCount = pending.length; - existingBytes = pending.reduce((sum, e) => sum + e.size, 0); - } - } - - if (hasCountLimit && existingCount >= field.maxFileCount!) { + if (hasSizeLimit) { + const limitBytes = field.maxFileSizeMB! * 1024 * 1024; + if (existingBytes + req.file.size > limitBytes) { await deleteNewFile(); return res.status(400).json({ - error: `Превышено максимальное количество файлов (${field.maxFileCount})`, + error: `Суммарный размер файлов превысит лимит (${field.maxFileSizeMB} МБ)`, }); } - - if (hasSizeLimit) { - const limitBytes = field.maxFileSizeMB! * 1024 * 1024; - if (existingBytes + req.file.size > limitBytes) { - await deleteNewFile(); - return res.status(400).json({ - error: `Суммарный размер файлов превысит лимит (${field.maxFileSizeMB} МБ)`, - }); - } - } } } + } - const name = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); - let url: string; + const name = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); + let url: string; - if (isS3Enabled) { - // S3/MinIO режим: файл уже на диске в tmpUploadDir, загружаем потоком и удаляем - // Используем нормализованный MIME по расширению, не клиентский mimetype - const normalizedMime = EXT_TO_MIME[fileExt]?.[0] ?? 'application/octet-stream'; + if (isS3Enabled) { + // S3/MinIO режим: файл уже на диске в tmpUploadDir, загружаем потоком и удаляем + // Используем нормализованный MIME по расширению, не клиентский mimetype + const normalizedMime = EXT_TO_MIME[fileExt]?.[0] ?? 'application/octet-stream'; + try { + const result = await uploadToS3FromFile(req.file.path, name, normalizedMime); + url = result.url; + } catch (s3Err) { + console.error('S3 upload error:', s3Err); + return res.status(500).json({ error: 'Ошибка загрузки файла в хранилище' }); + } finally { + // Всегда удаляем временный файл после загрузки в S3 + try { await fs.unlink(req.file.path); } catch { /* ignore */ } + } + } else { + // Локальный режим — файл уже сохранён на диске в uploadsDir + url = `/uploads/${req.file.filename}`; + } + + // Трекинг для новых задач (без taskId) для контроля лимитов + if (rawFieldId && !rawTaskId && req.user?.id) { + const fieldId = parseInt(String(rawFieldId)); + if (!isNaN(fieldId)) { + const key = getPendingKey(req.user.id, fieldId); + addPendingUpload(key, url, req.file.size); + } + } + + // Записываем файл в таблицу трекинга для последующей проверки доступа + // Автор: пользователь (JWT) или владелец API-ключа/тикета; botId — атрибуция бота + const uploaderId = req.user?.id ?? ctx.uploadedBy; + if (uploaderId && ctx.organizationId) { + const fileKey = isS3Enabled + ? url.replace('/api/files/', '') + : (req.file.filename ?? ''); + if (fileKey) { + const taskIdNum = rawTaskId ? parseInt(String(rawTaskId)) : undefined; + const fieldIdNum = rawFieldId ? parseInt(String(rawFieldId)) : undefined; try { - const result = await uploadToS3FromFile(req.file.path, name, normalizedMime); - url = result.url; - } catch (s3Err) { - console.error('S3 upload error:', s3Err); - return res.status(500).json({ error: 'Ошибка загрузки файла в хранилище' }); - } finally { - // Всегда удаляем временный файл после загрузки в S3 - try { await fs.unlink(req.file.path); } catch { /* ignore */ } - } - } else { - // Локальный режим — файл уже сохранён на диске в uploadsDir - url = `/uploads/${req.file.filename}`; - } - - // Трекинг для новых задач (без taskId) для контроля лимитов - if (rawFieldId && !rawTaskId && req.user?.id) { - const fieldId = parseInt(String(rawFieldId)); - if (!isNaN(fieldId)) { - const key = getPendingKey(req.user.id, fieldId); - addPendingUpload(key, url, req.file.size); + await db.insert(fileUploads).values({ + organizationId: ctx.organizationId, + uploadedBy: uploaderId, + fileKey, + originalName: name, + sizeBytes: req.file.size, + taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null, + fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null, + botId: req.apiKey?.botId ?? ctx.botId ?? null, + }).onConflictDoNothing(); + } catch (trackErr) { + console.warn('[Upload] Failed to track file upload:', trackErr); } } + } - // Записываем файл в таблицу трекинга для последующей проверки доступа - // Автор: пользователь (JWT) или владелец API-ключа; botId — атрибуция бота - const uploaderId = req.user?.id ?? req.apiKey?.createdBy; - if (uploaderId && req.organizationId) { - const fileKey = isS3Enabled - ? url.replace('/api/files/', '') - : (req.file.filename ?? ''); - if (fileKey) { - const taskIdNum = rawTaskId ? parseInt(String(rawTaskId)) : undefined; - const fieldIdNum = rawFieldId ? parseInt(String(rawFieldId)) : undefined; - try { - await db.insert(fileUploads).values({ - organizationId: req.organizationId, - uploadedBy: uploaderId, - fileKey, - originalName: name, - sizeBytes: req.file.size, - taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null, - fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null, - botId: req.apiKey?.botId ?? null, - }).onConflictDoNothing(); - } catch (trackErr) { - console.warn('[Upload] Failed to track file upload:', trackErr); - } + return res.json({ url, name, size: req.file.size }); +} + +// Загрузка по upload-тикету (БЕЗ API-ключа/JWT): авторизация = валидный тикет. +// Тикет выдаёт MCP-инструмент create_upload_ticket — см. server/mcp.ts. +// ВАЖНО: роут зарегистрирован ДО '/api/upload', пути не пересекаются +// ('/api/upload' — exact match, '/api/upload/ticket/:token' — отдельная ветка). +router.post('/api/upload/ticket/:token', async (req: AuthenticatedRequest, res) => { + const ticket = consumeUploadTicket(String(req.params.token || '')); + if (!ticket) { + return res.status(401).json({ error: 'Тикет загрузки недействителен или истёк' }); + } + if (ticket.scopes.mode === 'read') { + return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' }); + } + + // Основная логика: tenant-контекст (RLS) открывается так же, + // как в authenticateTokenOrApiKey (openTenantCtx + release по finish/close) + const proceed = async () => { + // Проверка доступа к форме задачи по скоупам ключа (как в POST /api/upload) + const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN; + if (!isNaN(taskIdForScope)) { + const scopedTask = await storage.getTask(taskIdForScope, ticket.organizationId); + if (!scopedTask || !isFormAllowedByScopes(ticket.scopes, scopedTask.formId)) { + return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); } } + req.organizationId = ticket.organizationId; + return processUploadRequest(req, res, { + organizationId: ticket.organizationId, + uploadedBy: ticket.createdBy, + botId: ticket.botId, + }); + }; - return res.json({ url, name, size: req.file.size }); + if (_tenantCtx.getStore()) return proceed(); + + try { + const handle = await openTenantCtx(ticket.organizationId); + handle.run(() => { + const guard = setTimeout(() => { + console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`); + handle.release(); + }, 30_000); + res.once('finish', () => { clearTimeout(guard); handle.release(); }); + res.once('close', () => { clearTimeout(guard); handle.release(); }); + proceed().catch((err: unknown) => { + console.error('[Upload ticket] error:', err); + if (!res.headersSent) res.status(500).json({ error: 'Ошибка загрузки файла' }); + }); + }); + } catch (err) { + console.error('[Upload ticket] tenant context error:', err); + return res.status(500).json({ error: 'Ошибка загрузки файла' }); + } +}); + +// File upload endpoint (JWT пользователя или API-ключ организации) +router.post('/api/upload', authenticateTokenOrApiKey, async (req: AuthenticatedRequest, res) => { + return processUploadRequest(req, res, { + organizationId: req.organizationId!, + uploadedBy: req.apiKey?.createdBy, + botId: req.apiKey?.botId ?? null, }); +}); export default router; diff --git a/server/utils/upload-tickets.ts b/server/utils/upload-tickets.ts new file mode 100644 index 0000000..ec6306d --- /dev/null +++ b/server/utils/upload-tickets.ts @@ -0,0 +1,61 @@ +import crypto from 'crypto'; +import type { ApiKeyScopes } from '@shared/schema'; + +// Короткоживущие тикеты загрузки файлов. +// Сценарий: ИИ-агент подключён по MCP, но значение API-ключа зашито в конфиге +// его MCP-клиента и агенту недоступно — выполнить curl на POST /api/upload +// с заголовком X-Api-Key он не может. MCP-инструмент create_upload_ticket +// выдаёт тикет, а REST POST /api/upload/ticket/:token принимает файл БЕЗ ключа. + +// TTL тикета: 10 минут +const TICKET_TTL_MS = 10 * 60 * 1000; + +export interface UploadTicket { + token: string; + organizationId: number; + apiKeyId: number | null; + botId: number | null; + createdBy: number; + scopes: ApiKeyScopes; + expiresAt: Date; +} + +// Хранилище в памяти процесса: тикеты короткоживущие, персистентность не нужна +const tickets = new Map(); + +// Ленивая очистка просроченных тикетов (вызывается при create/consume) +function cleanupExpired(): void { + const now = Date.now(); + for (const [token, ticket] of tickets) { + if (ticket.expiresAt.getTime() <= now) tickets.delete(token); + } +} + +// Создаёт тикет загрузки. Тикет многоразовый в пределах TTL (несколько файлов) +// и жёстко привязан к организации ключа. +export function createUploadTicket(data: { + organizationId: number; + apiKeyId: number | null; + botId: number | null; + createdBy: number; + scopes: ApiKeyScopes; +}): { token: string; expiresAt: Date } { + cleanupExpired(); + const token = crypto.randomBytes(24).toString('base64url'); + const expiresAt = new Date(Date.now() + TICKET_TTL_MS); + tickets.set(token, { token, expiresAt, ...data }); + return { token, expiresAt }; +} + +// Возвращает данные тикета или null (неизвестный/просроченный). +// Тикет НЕ удаляется при использовании — он валиден до конца TTL. +export function consumeUploadTicket(token: string): UploadTicket | null { + cleanupExpired(); + const ticket = tickets.get(token); + if (!ticket) return null; + if (ticket.expiresAt.getTime() <= Date.now()) { + tickets.delete(token); + return null; + } + return ticket; +}