From 5fe0e30ea0ae41caf940dc98bbbe8a4b42ec900f 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: Tue, 29 Sep 2026 11:04:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(files):=20get=5Ftask=5Ffile=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B0=D1=91=D1=82=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20presigned-URL=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20minio:9000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живой тест показал: presigned-ссылка формировалась против внутреннего хоста (http://minio:9000/...) — снаружи docker-сети недоступна, для ботов бесполезна. - presigned-токены вынесены в server/utils/presigned-tokens.ts (общие для index.ts и mcp.ts); - MCP get_task_file возвращает /api/files/?presigned= — работает через https://iistwin.ru без авторизации ~5 минут, в S3 и локальном режимах. --- server/index.ts | 39 +++-------------------------- server/mcp.ts | 36 +++++++++----------------- server/utils/presigned-tokens.ts | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 59 deletions(-) create mode 100644 server/utils/presigned-tokens.ts diff --git a/server/index.ts b/server/index.ts index 30407e2..4d64e89 100644 --- a/server/index.ts +++ b/server/index.ts @@ -28,6 +28,7 @@ import { authenticateToken, authenticateFileToken, type AuthenticatedRequest } f import { EXT_TO_MIME, getFileExt } from "./utils/upload"; import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key"; import { trackFileOnDemand } from "./utils/file-tracking"; +import { generatePresignedToken, validatePresignedToken, sweepPresignedTokens, PRESIGNED_TTL_MS } from "./utils/presigned-tokens"; import crypto from "crypto"; import { logger } from "./utils/logger"; @@ -55,41 +56,9 @@ app.use('/api', (req, res, next) => { app.use(cookieParser()); // ── Presigned URLs for file preview (temporary unauthenticated access) ────── -interface PresignedToken { - fileKey: string; - organizationId: number; - expiresAt: number; -} - -const presignedTokens = new Map(); -const PRESIGNED_TTL_MS = 5 * 60 * 1000; // 5 minutes - -function generatePresignedToken(fileKey: string, organizationId: number): string { - const token = crypto.randomBytes(32).toString('hex'); - presignedTokens.set(token, { fileKey, organizationId, expiresAt: Date.now() + PRESIGNED_TTL_MS }); - return token; -} - -function validatePresignedToken(token: string, fileKey: string): number | null { - const entry = presignedTokens.get(token); - if (!entry) return null; - if (entry.fileKey !== fileKey) return null; - if (entry.expiresAt <= Date.now()) { - presignedTokens.delete(token); - return null; - } - return entry.organizationId; -} - -function sweepPresignedTokens(): void { - const now = Date.now(); - for (const [token, entry] of presignedTokens.entries()) { - if (entry.expiresAt <= now) { - presignedTokens.delete(token); - } - } -} -setInterval(sweepPresignedTokens, PRESIGNED_TTL_MS); +// Реализация — server/utils/presigned-tokens.ts (общая с MCP get_task_file). +const presignedSweep = setInterval(sweepPresignedTokens, PRESIGNED_TTL_MS); +presignedSweep.unref?.(); // ────────────────────────────────────────────────────────────────────────────── // Helper: check file ownership — fail-closed (deny if untracked or DB error). diff --git a/server/mcp.ts b/server/mcp.ts index 8bad017..70f5105 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -58,7 +58,8 @@ import { DocumentTemplateService } from "./documents/template.service"; import { DocumentGenerationService } from "./documents/generation.service"; import { DataResolutionService } from "./documents/data-resolution.service"; import { AssetService } from "./documents/asset.service"; -import { isS3Enabled, getPresignedUrl } from "./utils/s3"; +import { isS3Enabled } from "./utils/s3"; +import { generatePresignedToken } from "./utils/presigned-tokens"; import { db, pool } from "./db"; import { fileUploads, taskReminders } from "@shared/schema"; import { eq, and } from "drizzle-orm"; @@ -4239,7 +4240,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false title: "Get Task File", description: "Get a download URL for a file attached to a task (by fileKey from message attachments or file field values). " + - "In S3/MinIO mode returns a presigned URL (valid ~5 minutes). In local mode returns a direct path that requires user authorization.", + "Returns a presigned app-proxy URL (/api/files/?presigned=, valid ~5 minutes, no authorization required — prepend the CRM base URL, e.g. https://iistwin.ru).", inputSchema: { taskId: z.number().int().describe("The numeric ID of the task"), fileKey: z.string().min(1).describe("File key (the part after /api/files/ or /uploads/ in the attachment URL)"), @@ -4299,34 +4300,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false return mcpError(`Файл не относится к задаче ${taskId}`); } - if (isS3Enabled) { - const presignedUrl = await getPresignedUrl(fileKey, 300); - if (presignedUrl) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - fileKey, - originalName: upload.originalName, - downloadUrl: presignedUrl, - type: "presigned", - expiresInSeconds: 300, - }, null, 2), - }], - }; - } - } - - // Локальный режим (или ошибка presigned): отдаём прямой путь с пояснением + // Внешняя presigned-ссылка через прокси приложения (/api/files/?presigned=). + // Сырой presigned MinIO (http://minio:9000/...) снаружи docker-сети недоступен — + // для ботов/агентов он бесполезен, поэтому отдаём прокси-URL: работает и в + // S3-, и в локальном режиме, авторизация не требуется (~5 минут). + const token = generatePresignedToken(fileKey, organizationId); return { content: [{ type: "text" as const, text: JSON.stringify({ fileKey, originalName: upload.originalName, - downloadUrl: isS3Enabled ? `/api/files/${fileKey}` : `/uploads/${fileKey}`, - type: "direct", - note: "Presigned URL недоступен (локальный режим хранения). Ссылка требует авторизации пользователя (JWT/сессия); временную ссылку выдаёт GET /api/files/:key/presigned.", + downloadUrl: `/api/files/${fileKey}?presigned=${token}`, + type: "presigned", + expiresInSeconds: 300, + note: "Относительная ссылка — подставьте базовый URL CRM (например, https://iistwin.ru). Действует ~5 минут, авторизация не требуется.", }, null, 2), }], }; diff --git a/server/utils/presigned-tokens.ts b/server/utils/presigned-tokens.ts new file mode 100644 index 0000000..03bcbc8 --- /dev/null +++ b/server/utils/presigned-tokens.ts @@ -0,0 +1,43 @@ +import crypto from 'crypto'; + +// ── Presigned-токены для временного доступа к файлам без авторизации ───────── +// Вынесено из index.ts: используется и файловыми маршрутами (/api/files/:key/presigned), +// и MCP get_task_file — последний отдаёт внешний прокси-URL приложения +// (/api/files/?presigned=) вместо внутреннего хоста MinIO +// (http://minio:9000/...), который снаружи docker-сети недоступен. +// Токены in-memory: при multi-instance понадобится Redis (как upload-тикеты). + +interface PresignedToken { + fileKey: string; + organizationId: number; + expiresAt: number; +} + +const presignedTokens = new Map(); +export const PRESIGNED_TTL_MS = 5 * 60 * 1000; // 5 minutes + +export function generatePresignedToken(fileKey: string, organizationId: number): string { + const token = crypto.randomBytes(32).toString('hex'); + presignedTokens.set(token, { fileKey, organizationId, expiresAt: Date.now() + PRESIGNED_TTL_MS }); + return token; +} + +export function validatePresignedToken(token: string, fileKey: string): number | null { + const entry = presignedTokens.get(token); + if (!entry) return null; + if (entry.fileKey !== fileKey) return null; + if (entry.expiresAt <= Date.now()) { + presignedTokens.delete(token); + return null; + } + return entry.organizationId; +} + +export function sweepPresignedTokens(): void { + const now = Date.now(); + for (const [token, entry] of presignedTokens.entries()) { + if (entry.expiresAt <= now) { + presignedTokens.delete(token); + } + } +}