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):

View File

@@ -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<void>((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<void>((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<ReturnType<typeof storage.getFormFieldById>>;
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<ReturnType<typeof storage.getFormFieldById>>;
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;

View File

@@ -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<string, UploadTicket>();
// Ленивая очистка просроченных тикетов (вызывается при 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;
}