Фикс предпросмотра файлов с абсолютными self-URL
- extractFileKey понимает абсолютные URL (по pathname) — превью работает для уже записанных файлов - normalizeFileUrl: абсолютные self-URL нормализуются в относительные при записи file-полей (REST field-values, MCP update_task_fields/upload-инструменты, bot-api, create task)
This commit is contained in:
@@ -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/<key>) 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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { 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/<key>) принимаются
|
||||
// и нормализуются к относительному виду перед сохранением.
|
||||
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/<key> → относительные
|
||||
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 };
|
||||
|
||||
@@ -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/<key> → относительные
|
||||
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<string, unknown>
|
||||
value: storedValue as string | number | boolean | null | Record<string, unknown>
|
||||
});
|
||||
} 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<string, unknown>
|
||||
value: storedValue as string | number | boolean | null | Record<string, unknown>
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof import("ex
|
||||
|
||||
// Auto-generate contract number for 'mine' mode
|
||||
let finalValue = normalizeFieldValueForStorage(value, field.type);
|
||||
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key> → относительные
|
||||
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 === '')) {
|
||||
|
||||
@@ -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<typeof import("expres
|
||||
const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!);
|
||||
const fieldMap = new Map(formFields.map(f => [f.id, f]));
|
||||
|
||||
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key>
|
||||
// от внешних агентов сводятся к относительным /api/files/<key>
|
||||
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<typeof import("expres
|
||||
}
|
||||
|
||||
const createdValues = [];
|
||||
for (const fieldValue of fieldValues) {
|
||||
for (const fieldValue of normalizedFieldValues) {
|
||||
const field = fieldMap.get(fieldValue.fieldId);
|
||||
const normalizedValue = normalizeFieldValueForStorage(fieldValue.value, field?.type);
|
||||
|
||||
@@ -234,7 +241,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
|
||||
const fieldActor = await resolveRestActor(req, req.organizationId!);
|
||||
const offlineEnqueuedAtPost = parseOfflineTimestamp(req);
|
||||
for (const fieldValue of fieldValues) {
|
||||
for (const fieldValue of normalizedFieldValues) {
|
||||
const field = fieldMap.get(fieldValue.fieldId);
|
||||
if (!field) continue;
|
||||
const oldVal = oldValueMap.has(fieldValue.fieldId) ? oldValueMap.get(fieldValue.fieldId) : undefined;
|
||||
@@ -411,7 +418,9 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
return res.status(404).json({ success: false, error: 'Поле не найдено' });
|
||||
}
|
||||
|
||||
const normalizedValue = normalizeFieldValueForStorage(value, field.type);
|
||||
const normalizedValueRaw = normalizeFieldValueForStorage(value, field.type);
|
||||
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key> → относительные
|
||||
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<typeof import("expres
|
||||
const normalizedEntries = entries.map(([k, value]): [string, unknown] => {
|
||||
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/<key> → относительные
|
||||
return [k, fld?.type === 'file' ? normalizeFileFieldUrls(normalized) : normalized];
|
||||
});
|
||||
|
||||
// Validate conditional required fields before applying bulk changes
|
||||
|
||||
@@ -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<string, unknown>).url === 'string') {
|
||||
const obj = item as Record<string, unknown>;
|
||||
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<void> {
|
||||
if (isS3Enabled) {
|
||||
// S3-режим: извлекаем ключ из URL вида /api/files/:key
|
||||
|
||||
Reference in New Issue
Block a user