fix(files): get_task_file отдаёт внешний presigned-URL через прокси приложения вместо внутреннего minio:9000
Живой тест показал: presigned-ссылка формировалась против внутреннего хоста (http://minio:9000/...) — снаружи docker-сети недоступна, для ботов бесполезна. - presigned-токены вынесены в server/utils/presigned-tokens.ts (общие для index.ts и mcp.ts); - MCP get_task_file возвращает /api/files/<key>?presigned=<token> — работает через https://iistwin.ru без авторизации ~5 минут, в S3 и локальном режимах.
This commit is contained in:
@@ -28,6 +28,7 @@ import { authenticateToken, authenticateFileToken, type AuthenticatedRequest } f
|
|||||||
import { EXT_TO_MIME, getFileExt } from "./utils/upload";
|
import { EXT_TO_MIME, getFileExt } from "./utils/upload";
|
||||||
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
|
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
|
||||||
import { trackFileOnDemand } from "./utils/file-tracking";
|
import { trackFileOnDemand } from "./utils/file-tracking";
|
||||||
|
import { generatePresignedToken, validatePresignedToken, sweepPresignedTokens, PRESIGNED_TTL_MS } from "./utils/presigned-tokens";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
|
|
||||||
@@ -55,41 +56,9 @@ app.use('/api', (req, res, next) => {
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
// ── Presigned URLs for file preview (temporary unauthenticated access) ──────
|
// ── Presigned URLs for file preview (temporary unauthenticated access) ──────
|
||||||
interface PresignedToken {
|
// Реализация — server/utils/presigned-tokens.ts (общая с MCP get_task_file).
|
||||||
fileKey: string;
|
const presignedSweep = setInterval(sweepPresignedTokens, PRESIGNED_TTL_MS);
|
||||||
organizationId: number;
|
presignedSweep.unref?.();
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const presignedTokens = new Map<string, PresignedToken>();
|
|
||||||
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);
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Helper: check file ownership — fail-closed (deny if untracked or DB error).
|
// Helper: check file ownership — fail-closed (deny if untracked or DB error).
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ import { DocumentTemplateService } from "./documents/template.service";
|
|||||||
import { DocumentGenerationService } from "./documents/generation.service";
|
import { DocumentGenerationService } from "./documents/generation.service";
|
||||||
import { DataResolutionService } from "./documents/data-resolution.service";
|
import { DataResolutionService } from "./documents/data-resolution.service";
|
||||||
import { AssetService } from "./documents/asset.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 { db, pool } from "./db";
|
||||||
import { fileUploads, taskReminders } from "@shared/schema";
|
import { fileUploads, taskReminders } from "@shared/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
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",
|
title: "Get Task File",
|
||||||
description:
|
description:
|
||||||
"Get a download URL for a file attached to a task (by fileKey from message attachments or file field values). " +
|
"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/<key>?presigned=<token>, valid ~5 minutes, no authorization required — prepend the CRM base URL, e.g. https://iistwin.ru).",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
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)"),
|
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}`);
|
return mcpError(`Файл не относится к задаче ${taskId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isS3Enabled) {
|
// Внешняя presigned-ссылка через прокси приложения (/api/files/<key>?presigned=<token>).
|
||||||
const presignedUrl = await getPresignedUrl(fileKey, 300);
|
// Сырой presigned MinIO (http://minio:9000/...) снаружи docker-сети недоступен —
|
||||||
if (presignedUrl) {
|
// для ботов/агентов он бесполезен, поэтому отдаём прокси-URL: работает и в
|
||||||
|
// S3-, и в локальном режиме, авторизация не требуется (~5 минут).
|
||||||
|
const token = generatePresignedToken(fileKey, organizationId);
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
fileKey,
|
fileKey,
|
||||||
originalName: upload.originalName,
|
originalName: upload.originalName,
|
||||||
downloadUrl: presignedUrl,
|
downloadUrl: `/api/files/${fileKey}?presigned=${token}`,
|
||||||
type: "presigned",
|
type: "presigned",
|
||||||
expiresInSeconds: 300,
|
expiresInSeconds: 300,
|
||||||
}, null, 2),
|
note: "Относительная ссылка — подставьте базовый URL CRM (например, https://iistwin.ru). Действует ~5 минут, авторизация не требуется.",
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Локальный режим (или ошибка presigned): отдаём прямой путь с пояснением
|
|
||||||
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.",
|
|
||||||
}, null, 2),
|
}, null, 2),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|||||||
43
server/utils/presigned-tokens.ts
Normal file
43
server/utils/presigned-tokens.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// ── Presigned-токены для временного доступа к файлам без авторизации ─────────
|
||||||
|
// Вынесено из index.ts: используется и файловыми маршрутами (/api/files/:key/presigned),
|
||||||
|
// и MCP get_task_file — последний отдаёт внешний прокси-URL приложения
|
||||||
|
// (/api/files/<key>?presigned=<token>) вместо внутреннего хоста MinIO
|
||||||
|
// (http://minio:9000/...), который снаружи docker-сети недоступен.
|
||||||
|
// Токены in-memory: при multi-instance понадобится Redis (как upload-тикеты).
|
||||||
|
|
||||||
|
interface PresignedToken {
|
||||||
|
fileKey: string;
|
||||||
|
organizationId: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presignedTokens = new Map<string, PresignedToken>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user