diff --git a/client/src/lib/fileUtils.ts b/client/src/lib/fileUtils.ts index cc4f103..36830f3 100644 --- a/client/src/lib/fileUtils.ts +++ b/client/src/lib/fileUtils.ts @@ -19,13 +19,22 @@ export function stripFileTokensFromHtml(html: string): string { } // Extracts the file key from a local file URL. -// Supports /api/files/:key and /uploads/:key patterns. +// Supports /api/files/:key and /uploads/:key patterns, including absolute +// self-URLs (https://iistwin.ru/api/files/) written by external agents via REST/MCP. export function extractFileKey(url: string): string | null { - if (url.startsWith('/api/files/')) { - return url.slice('/api/files/'.length).split('?')[0]; + let path = url; + if (url.startsWith('http://') || url.startsWith('https://')) { + try { + path = new URL(url).pathname; + } catch { + return null; + } } - if (url.startsWith('/uploads/')) { - return url.slice('/uploads/'.length).split('?')[0]; + if (path.startsWith('/api/files/')) { + return path.slice('/api/files/'.length).split('?')[0]; + } + if (path.startsWith('/uploads/')) { + return path.slice('/uploads/'.length).split('?')[0]; } return null; } diff --git a/server/mcp.ts b/server/mcp.ts index 97da53f..7781092 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -7,7 +7,8 @@ import crypto from "crypto"; import { storage } from "./storage"; import { authService } from "./services/auth.service"; import { FORM_FIELD_TYPES, GLOBAL_FIELD_TYPES, TABLE_FIELD_TYPES } from "../shared/field-types"; -import { normalizeFieldValueForStorage } from "./utils/normalize-field-value"; +import { normalizeFieldValueForStorage } from "./utils/normalize-field-value"; +import { normalizeFileUrl, normalizeFileFieldUrls } from "./utils/upload"; // In-process dedup for legacy-key admin notifications (reset on process restart) @@ -292,6 +293,8 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco // Валидация fileUrl для привязки уже загруженного файла. // Загрузка бинарных данных через MCP НЕ поддерживается: файл сначала // загружается через REST POST /api/upload (см. get_api_guide), сюда передаётся только URL. + // Абсолютные self-URL (https://iistwin.ru/api/files/) принимаются + // и нормализуются к относительному виду перед сохранением. const resolveUploadSource = (args: { fileName: string; fileUrl: string; @@ -300,9 +303,9 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco }): | { error: string } | { file: { key: string; url: string; name: string; size: number; mimeType: string } } => { - const url = args.fileUrl; + const url = normalizeFileUrl(args.fileUrl); if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) { - return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/ (файл сначала загружается через REST POST /api/upload)' }; + return { error: 'fileUrl должен указывать на файл этого сервера (/api/files/... или /uploads/..., в т.ч. абсолютный self-URL) — файл сначала загружается через REST POST /api/upload или upload-тикет' }; } return { file: { @@ -858,7 +861,9 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco const updated: Array<{ fieldId: number; name: string; value: unknown }> = []; for (const fv of fieldValues) { const field = fieldMap.get(fv.fieldId)!; - const normalizedValue = normalizeFieldValueForStorage(fv.value, field.type); + // Нормализация self-URL в file-полях: абсолютные https://.../api/files/ → относительные + const normalizedValueRaw = normalizeFieldValueForStorage(fv.value, field.type); + const normalizedValue = field.type === 'file' ? normalizeFileFieldUrls(normalizedValueRaw) : normalizedValueRaw; const hasExisting = oldValueMap.has(fv.fieldId); // Поле типа task: чистим старую связь перед записью новой @@ -899,7 +904,8 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyReco .map((fv) => { const field = fieldMap.get(fv.fieldId)!; const oldVal = oldValueMap.has(fv.fieldId) ? oldValueMap.get(fv.fieldId) : undefined; - const newVal = normalizeFieldValueForStorage(fv.value, field.type); + const newValRaw = normalizeFieldValueForStorage(fv.value, field.type); + const newVal = field.type === 'file' ? normalizeFileFieldUrls(newValRaw) : newValRaw; const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal); const newStr = newVal === null || newVal === undefined ? '' : String(newVal); return oldStr === newStr ? null : { field, oldVal, newVal }; diff --git a/server/routes/bot-api.routes.ts b/server/routes/bot-api.routes.ts index f109d5e..0f1887d 100644 --- a/server/routes/bot-api.routes.ts +++ b/server/routes/bot-api.routes.ts @@ -15,6 +15,7 @@ import { withSuperAdmin, openTenantCtx, _tenantCtx, db } from "../db"; import { eq, and, desc, sql, lt, inArray } from "drizzle-orm"; import { enrichMessage, getMembersForSSE } from "./messenger.helpers"; import { notificationService } from "../services/notification.service"; +import { normalizeFileFieldUrls } from "../utils/upload"; export function registerBotApiRoutes(app: import("express").Express): void { // ===================================================== @@ -341,9 +342,11 @@ export function registerBotApiRoutes(app: import("express").Express): void { const field = fieldMap.get(code); if (!field) continue; + // Нормализация self-URL в file-полях: абсолютные https://.../api/files/ → относительные + const storedValue = field.type === 'file' ? normalizeFileFieldUrls(value) : value; try { await storage.updateTaskFieldValue(taskId, field.id, req.organizationId!, { - value: value as string | number | boolean | null | Record + value: storedValue as string | number | boolean | null | Record }); } catch { // Если запись не существует, создаём @@ -351,7 +354,7 @@ export function registerBotApiRoutes(app: import("express").Express): void { taskId, fieldId: field.id, formId: task.formId, - value: value as string | number | boolean | null | Record + value: storedValue as string | number | boolean | null | Record }); } } diff --git a/server/routes/task-crud-write.routes.ts b/server/routes/task-crud-write.routes.ts index 90360d0..d6c7f0f 100644 --- a/server/routes/task-crud-write.routes.ts +++ b/server/routes/task-crud-write.routes.ts @@ -16,6 +16,7 @@ import { validateRequiredFields } from "../utils/validate-required-fields"; import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service"; import { runAutomationsByTrigger, type AutomationRunResult } from "./automation.routes"; import { normalizeFieldValueForStorage } from "../utils/normalize-field-value"; +import { normalizeFileFieldUrls } from "../utils/upload"; import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor"; import { db, withTenant } from "../db"; import { eq, and, sql } from "drizzle-orm"; @@ -261,6 +262,10 @@ export function registerTaskCrudWriteRoutes(router: ReturnType → относительные + if (field.type === 'file') { + finalValue = normalizeFileFieldUrls(finalValue) as typeof finalValue; + } if (field.type === 'contract-number') { const parsed = typeof value === 'string' ? JSON.parse(value) : value; if (parsed && parsed.mode === 'mine' && (!parsed.value || parsed.value === '')) { diff --git a/server/routes/task-fields.routes.ts b/server/routes/task-fields.routes.ts index 33eb3ba..0674195 100644 --- a/server/routes/task-fields.routes.ts +++ b/server/routes/task-fields.routes.ts @@ -12,6 +12,7 @@ import { validateRequiredFields } from "../utils/validate-required-fields"; import { validateImmutableFields } from "../utils/validate-immutable-fields"; import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service"; import { normalizeFieldValueForStorage } from "../utils/normalize-field-value"; +import { normalizeFileFieldUrls } from "../utils/upload"; function extractUserId(value: unknown): number | null { if (value === null || value === undefined || value === '') return null; @@ -103,6 +104,12 @@ export function registerTaskFieldRoutes(router: ReturnType [f.id, f])); + // Нормализация self-URL в file-полях: абсолютные https://.../api/files/ + // от внешних агентов сводятся к относительным /api/files/ + const normalizedFieldValues = (fieldValues as Array<{ fieldId: number; value: unknown }>).map((fv) => + fieldMap.get(fv.fieldId)?.type === 'file' ? { ...fv, value: normalizeFileFieldUrls(fv.value) } : fv + ); + // Pre-load data for audit log display-text resolution (and reuse later for title template) const [auditUsers, auditRoles] = await Promise.all([ storage.getUsersByOrganization(req.organizationId!), @@ -166,7 +173,7 @@ export function registerTaskFieldRoutes(router: ReturnType → относительные + const normalizedValue = field.type === 'file' ? normalizeFileFieldUrls(normalizedValueRaw) : normalizedValueRaw; // Validate conditional required fields before applying the change const existingValuesSingle = await storage.getTaskFieldValues(taskId, req.organizationId!); @@ -630,7 +639,9 @@ export function registerTaskFieldRoutes(router: ReturnType { const fid = parseInt(k, 10); const fld = fieldMap.get(fid); - return [k, normalizeFieldValueForStorage(value, fld?.type)]; + const normalized = normalizeFieldValueForStorage(value, fld?.type); + // Нормализация self-URL в file-полях: абсолютные https://.../api/files/ → относительные + return [k, fld?.type === 'file' ? normalizeFileFieldUrls(normalized) : normalized]; }); // Validate conditional required fields before applying bulk changes diff --git a/server/utils/upload.ts b/server/utils/upload.ts index 59160eb..8fe3275 100644 --- a/server/utils/upload.ts +++ b/server/utils/upload.ts @@ -175,6 +175,37 @@ export function extractFileUrls(fileValue: unknown): string[] { return []; } +// Нормализация self-URL файла к относительному виду. +// Абсолютный http(s) URL, pathname которого начинается с /api/files/ или /uploads/ +// (т.е. указывает на этот же сервер), сводится к pathname — в БД храним относительные URL. +// Внешние URL и невалидные строки возвращаются без изменений. +export function normalizeFileUrl(url: string): string { + if (!url.startsWith('http://') && !url.startsWith('https://')) return url; + try { + const pathname = new URL(url).pathname; + if (pathname.startsWith('/api/files/') || pathname.startsWith('/uploads/')) { + return pathname; + } + } catch { + // невалидный URL — оставляем как есть + } + return url; +} + +// Нормализует url внутри значения file-поля: одиночный объект {url,name,size} +// или массив таких объектов. Прочие значения возвращаются без изменений. +export function normalizeFileFieldUrls(value: unknown): unknown { + const normalizeItem = (item: unknown): unknown => { + if (item && typeof item === 'object' && typeof (item as Record).url === 'string') { + const obj = item as Record; + return { ...obj, url: normalizeFileUrl(obj.url as string) }; + } + return item; + }; + if (Array.isArray(value)) return value.map(normalizeItem); + return normalizeItem(value); +} + export async function deleteFileByUrl(url: string): Promise { if (isS3Enabled) { // S3-режим: извлекаем ключ из URL вида /api/files/:key