From 4bac21ff719130f102d74a78dd5b892260630ecc 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 10:36:41 +0300 Subject: [PATCH] =?UTF-8?q?fix(files):=20=D0=B1=D0=BE=D1=82=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D1=82=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=E2=80=94=20API?= =?UTF-8?q?-=D0=BA=D0=BB=D1=8E=D1=87=20=D0=BD=D0=B0=20/api/files=20=D0=B8?= =?UTF-8?q?=20/uploads=20+=20=D1=82=D1=80=D0=B5=D0=BA=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D0=B0=D0=B1=D1=81=D0=BE=D0=BB=D1=8E=D1=82=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: прямое скачивание /api/files/ с X-Api-Key давало 403 (файловые эндпоинты принимали только JWT), а get_task_file отвечал «не отслеживается» — 77 файлов со старыми АБСОЛЮТНЫМИ URL (https://iistwin.ru/api/files/...) не попадали в file_uploads: startup-backfill понимал только относительные ссылки. - tryPresignedOrAuth: ветка X-Api-Key — resolve ключа, req.apiKey/organizationId, tenant-контекст; владение проверяет canAccessFile, скоупы форм — новый checkApiKeyFileScope (файл привязан к задаче → форма должна быть разрешена ключом); - /api/files/:key/presigned теперь тоже через tryPresignedOrAuth (боты могут выпускать presigned-ссылки); - server/utils/file-tracking.ts: trackFileOnDemand — догрузка по требованию из task_field_values/task_messages по ссылке любого вида; используется в canAccessFile и MCP get_task_file; - startup-backfill: паттерны покрывают абсолютные URL (substring FROM regex). --- server/index.ts | 114 +++++++++++++++++++++++++++++----- server/mcp.ts | 17 ++++- server/utils/file-tracking.ts | 105 +++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 server/utils/file-tracking.ts diff --git a/server/index.ts b/server/index.ts index 5daa34c..0d4188b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -15,7 +15,7 @@ import { startSessionCleanup } from "./workers/session-cleanup"; import { startGpsWorker } from "./gps/worker"; import { storage } from "./storage"; import type { ReminderRecipient } from "@shared/schema"; -import { db, withSuperAdmin } from "./db"; // lazy proxy — safe at module load; throws on first use if DATABASE_URL missing +import { db, withSuperAdmin, openTenantCtx, _tenantCtx } from "./db"; // lazy proxy — safe at module load; throws on first use if DATABASE_URL missing import { sql, eq } from "drizzle-orm"; import fs from "fs"; import path from "path"; @@ -26,6 +26,8 @@ import { decrypt as decryptSecret } from "./crypto"; import { fileUploads, errorLogs } from "@shared/schema"; import { authenticateToken, authenticateFileToken, type AuthenticatedRequest } from "./middleware/auth.middleware"; import { EXT_TO_MIME, getFileExt } from "./utils/upload"; +import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key"; +import { trackFileOnDemand } from "./utils/file-tracking"; import crypto from "crypto"; import { logger } from "./utils/logger"; @@ -91,17 +93,23 @@ setInterval(sweepPresignedTokens, PRESIGNED_TTL_MS); // ────────────────────────────────────────────────────────────────────────────── // Helper: check file ownership — fail-closed (deny if untracked or DB error). -// Legacy files are backfilled from task_field_values / task_messages at startup -// so untracked after startup == foreign or unknown file. +// Legacy files are backfilled from task_field_values / task_messages at startup; +// неохваченные (старые абсолютные URL и т.п.) догружаются по требованию через +// trackFileOnDemand, дальше untracked == foreign or unknown file. async function canAccessFile(fileKey: string, organizationId: number): Promise { try { - const rows = await db.select({ orgId: fileUploads.organizationId }) + let rows = await db.select({ orgId: fileUploads.organizationId }) .from(fileUploads) .where(eq(fileUploads.fileKey, fileKey)) .limit(1); if (rows.length === 0) { - // Not tracked even after startup backfill — deny (fail-closed) - return false; + const tracked = await trackFileOnDemand(fileKey); + if (!tracked) return false; + rows = await db.select({ orgId: fileUploads.organizationId }) + .from(fileUploads) + .where(eq(fileUploads.fileKey, fileKey)) + .limit(1); + if (rows.length === 0) return false; } return rows[0].orgId === organizationId; } catch { @@ -110,6 +118,25 @@ async function canAccessFile(fileKey: string, organizationId: number): Promise { + if (!req.apiKey) return true; + try { + const rows = await db.select({ taskId: fileUploads.taskId }) + .from(fileUploads) + .where(eq(fileUploads.fileKey, fileKey)) + .limit(1); + const taskId = rows[0]?.taskId; + if (!taskId) return true; + const task = await storage.getTask(taskId, req.organizationId!); + if (!task) return true; + return isFormAllowedByScopes(req.apiKey.scopes, task.formId); + } catch { + return false; + } +} + // Middleware: try presigned token first, fall back to authenticateFileToken. function tryPresignedOrAuth() { return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { @@ -123,18 +150,64 @@ function tryPresignedOrAuth() { } return res.status(403).json({ error: 'Недействительная или истёкшая presigned-ссылка' }); } + + // API-ключ организации (боты): доступ к файлам своей организации. + // Владение файлом проверяет canAccessFile в обработчике, скоупы форм — + // checkApiKeyFileScope. Раньше боты получали 403 даже на файлах своей org. + const apiKeyHeader = (req.headers['x-api-key'] as string | undefined)?.trim(); + if (apiKeyHeader) { + let key; + try { + key = await storage.getApiKeyByHash(apiKeyHeader); + } catch { + return res.status(401).json({ error: 'Недействительный API-ключ' }); + } + if (!key || !key.isActive) { + return res.status(401).json({ error: 'Недействительный API-ключ' }); + } + storage.touchApiKey(key.id).catch(() => {}); + req.apiKey = { + id: key.id, + organizationId: key.organizationId, + botId: key.botId ?? null, + createdBy: key.createdBy, + label: key.label, + scopes: normalizeApiKeyScopes(key.scopes), + }; + req.user = null; + req.organizationId = key.organizationId; + if (_tenantCtx.getStore()) return next(); + openTenantCtx(key.organizationId) + .then((handle) => { + 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(); }); + next(); + }); + }) + .catch((err) => next(err as Error)); + return; + } + return authenticateFileToken(req, res, next); }; } // Endpoint to generate a presigned URL for file preview. -app.get('/api/files/:key/presigned', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { +app.get('/api/files/:key/presigned', tryPresignedOrAuth(), async (req: AuthenticatedRequest, res: Response) => { const key = req.params.key; if (!key || key.includes('..') || key.includes('/')) { return res.status(400).json({ error: 'Неверный ключ файла' }); } const allowed = await canAccessFile(key, req.organizationId!); if (!allowed) return res.status(403).json({ error: 'Доступ запрещён' }); + if (!(await checkApiKeyFileScope(req, key))) { + return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); + } const token = generatePresignedToken(key, req.organizationId!); const presignedUrl = isS3Enabled @@ -188,6 +261,9 @@ if (isS3Enabled) { const allowed = await canAccessFile(key, req.organizationId!); if (!allowed) return res.status(403).json({ error: 'Доступ запрещён' }); + if (!(await checkApiKeyFileScope(req, key))) { + return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); + } const obj = await streamFromS3(key); if (!obj) return res.status(404).end(); @@ -210,6 +286,9 @@ if (isS3Enabled) { const allowed = await canAccessFile(filename, req.organizationId!); if (!allowed) return res.status(403).json({ error: 'Доступ запрещён' }); + if (!(await checkApiKeyFileScope(req, filename))) { + return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); + } const filePath = path.join(uploadsDir, filename); if (!fs.existsSync(filePath)) return res.status(404).end(); @@ -841,6 +920,9 @@ async function runStartupDataPatches() { // Backfill file_uploads from historical task field values and chat attachments. // This runs once at startup so canAccessFile can use fail-closed logic for all files. + // Паттерны '%/api/files/%' и '%/uploads/%' покрывают и относительные, и старые + // АБСОЛЮТНЫЕ URL (https://iistwin.ru/api/files/...) — раньше они пропускались, + // и такие файлы были недоступны ботам («не отслеживается»). // Single-file field values: { url, name, size } await db.execute(sql` INSERT INTO file_uploads (organization_id, uploaded_by, file_key, original_name, size_bytes, task_id, field_id) @@ -848,8 +930,8 @@ async function runStartupDataPatches() { t.organization_id, t.created_by, CASE - WHEN tfv.value->>'url' LIKE '/uploads/%' THEN substring(tfv.value->>'url' FROM 10) - WHEN tfv.value->>'url' LIKE '/api/files/%' THEN substring(tfv.value->>'url' FROM 12) + WHEN tfv.value->>'url' LIKE '%/api/files/%' THEN substring(tfv.value->>'url' FROM '^.*/api/files/') + WHEN tfv.value->>'url' LIKE '%/uploads/%' THEN substring(tfv.value->>'url' FROM '^.*/uploads/') END AS file_key, tfv.value->>'name', (tfv.value->>'size')::integer, @@ -860,7 +942,7 @@ async function runStartupDataPatches() { JOIN form_fields ff ON tfv.field_id = ff.id WHERE ff.type = 'file' AND jsonb_typeof(tfv.value) = 'object' - AND (tfv.value->>'url' LIKE '/uploads/%' OR tfv.value->>'url' LIKE '/api/files/%') + AND (tfv.value->>'url' LIKE '%/api/files/%' OR tfv.value->>'url' LIKE '%/uploads/%') ON CONFLICT (file_key) DO NOTHING `).catch(() => {}); @@ -871,8 +953,8 @@ async function runStartupDataPatches() { t.organization_id, t.created_by, CASE - WHEN elem->>'url' LIKE '/uploads/%' THEN substring(elem->>'url' FROM 10) - WHEN elem->>'url' LIKE '/api/files/%' THEN substring(elem->>'url' FROM 12) + WHEN elem->>'url' LIKE '%/api/files/%' THEN substring(elem->>'url' FROM '^.*/api/files/') + WHEN elem->>'url' LIKE '%/uploads/%' THEN substring(elem->>'url' FROM '^.*/uploads/') END AS file_key, elem->>'name', (elem->>'size')::integer, @@ -884,7 +966,7 @@ async function runStartupDataPatches() { CROSS JOIN LATERAL jsonb_array_elements(tfv.value) AS elem WHERE ff.type = 'file' AND jsonb_typeof(tfv.value) = 'array' - AND (elem->>'url' LIKE '/uploads/%' OR elem->>'url' LIKE '/api/files/%') + AND (elem->>'url' LIKE '%/api/files/%' OR elem->>'url' LIKE '%/uploads/%') ON CONFLICT (file_key) DO NOTHING `).catch(() => {}); @@ -895,8 +977,8 @@ async function runStartupDataPatches() { f.organization_id, COALESCE(tm.author_id, t.created_by), CASE - WHEN att->>'url' LIKE '/uploads/%' THEN substring(att->>'url' FROM 10) - WHEN att->>'url' LIKE '/api/files/%' THEN substring(att->>'url' FROM 12) + WHEN att->>'url' LIKE '%/api/files/%' THEN substring(att->>'url' FROM '^.*/api/files/') + WHEN att->>'url' LIKE '%/uploads/%' THEN substring(att->>'url' FROM '^.*/uploads/') END AS file_key, att->>'name', (att->>'size')::integer, @@ -907,7 +989,7 @@ async function runStartupDataPatches() { CROSS JOIN LATERAL jsonb_array_elements(tm.attachments) AS att WHERE tm.attachments IS NOT NULL AND jsonb_typeof(tm.attachments) = 'array' - AND (att->>'url' LIKE '/uploads/%' OR att->>'url' LIKE '/api/files/%') + AND (att->>'url' LIKE '%/api/files/%' OR att->>'url' LIKE '%/uploads/%') ON CONFLICT (file_key) DO NOTHING `).catch(() => {}); diff --git a/server/mcp.ts b/server/mcp.ts index 1e56ab7..bb5d18c 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -39,6 +39,7 @@ import type { Request, Response } from "express"; import type { Task, ApiKeyScopes, OrganizationApiKey, SafeUser, Bot } from "@shared/schema"; import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key"; import { createUploadTicket } from "./utils/upload-tickets"; +import { trackFileOnDemand } from "./utils/file-tracking"; import beautify from "js-beautify"; import { semanticSearch, @@ -4252,12 +4253,24 @@ To block task creation from task.before_create, set: ctx.result = { allow: false if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!isFormAllowed(task.formId)) return formDenied(task.formId); - // Файл должен отслеживаться в file_uploads и принадлежать организации (как canAccessFile в index.ts) - const [upload] = await db + // Файл должен отслеживаться в file_uploads и принадлежать организации (как canAccessFile в index.ts). + // Если не отслеживается — догружаем по требованию (старые абсолютные URL и прочие + // неохваченные startup-backfill случаи, см. utils/file-tracking.ts). + let [upload] = await db .select() .from(fileUploads) .where(eq(fileUploads.fileKey, fileKey)) .limit(1); + if (!upload) { + const tracked = await trackFileOnDemand(fileKey); + if (tracked) { + [upload] = await db + .select() + .from(fileUploads) + .where(eq(fileUploads.fileKey, fileKey)) + .limit(1); + } + } if (!upload) return mcpError("Файл не найден или не отслеживается"); if (upload.organizationId !== organizationId) { return mcpError("Файл принадлежит другой организации"); diff --git a/server/utils/file-tracking.ts b/server/utils/file-tracking.ts new file mode 100644 index 0000000..fb19d47 --- /dev/null +++ b/server/utils/file-tracking.ts @@ -0,0 +1,105 @@ +import { db } from "../db"; +import { sql, eq } from "drizzle-orm"; +import { fileUploads } from "@shared/schema"; + +// ── Отслеживание файлов (file_uploads) ─────────────────────────────────────── +// canAccessFile (index.ts) и get_task_file (mcp.ts) работают fail-closed по этой +// таблице. Файлы, загруженные до появления трекинга или со старыми АБСОЛЮТНЫМИ +// URL (https://iistwin.ru/api/files/...), в таблице отсутствовали — startup +// backfill понимал только относительные ссылки. Этот модуль — догрузка по +// требованию: ищем файл в значениях file-полей и вложениях сообщений по ссылке +// (любого вида) и записываем в file_uploads. + +interface FileReference { + organizationId: number; + uploadedBy: number | null; + taskId: number | null; + fieldId: number | null; + name: string | null; + size: number | null; +} + +async function findFileReference(fileKey: string): Promise { + // Значения file-полей задач (одиночные и массивные значения) + const fieldRows = await db.execute(sql` + SELECT t.organization_id AS org, t.created_by AS uploader, + tfv.task_id AS task, tfv.field_id AS field, + elem->>'name' AS name, (elem->>'size')::integer AS size + FROM task_field_values tfv + JOIN tasks t ON tfv.task_id = t.id + JOIN form_fields ff ON tfv.field_id = ff.id + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(tfv.value) = 'array' THEN tfv.value ELSE jsonb_build_array(tfv.value) END + ) elem + WHERE ff.type = 'file' + AND position(${fileKey} in coalesce(elem->>'url', '')) > 0 + LIMIT 1 + `); + const fr = (fieldRows as unknown as { rows?: FileReferenceRow[] }).rows?.[0]; + if (fr) { + return { organizationId: fr.org, uploadedBy: fr.uploader ?? null, taskId: fr.task ?? null, fieldId: fr.field ?? null, name: fr.name ?? null, size: fr.size ?? null }; + } + + // Вложения сообщений чата задач + const msgRows = await db.execute(sql` + SELECT f.organization_id AS org, COALESCE(tm.author_id, t.created_by) AS uploader, + tm.task_id AS task, + att->>'name' AS name, (att->>'size')::integer AS size + FROM task_messages tm + JOIN tasks t ON tm.task_id = t.id + JOIN forms f ON t.form_id = f.id + CROSS JOIN LATERAL jsonb_array_elements(tm.attachments) att + WHERE tm.attachments IS NOT NULL + AND jsonb_typeof(tm.attachments) = 'array' + AND position(${fileKey} in coalesce(att->>'url', '')) > 0 + LIMIT 1 + `); + const mr = (msgRows as unknown as { rows?: FileReferenceRow[] }).rows?.[0]; + if (mr) { + return { organizationId: mr.org, uploadedBy: mr.uploader ?? null, taskId: mr.task ?? null, fieldId: null, name: mr.name ?? null, size: mr.size ?? null }; + } + + return null; +} + +interface FileReferenceRow { + org: number; + uploader: number | null; + task: number | null; + field?: number | null; + name: string | null; + size: number | null; +} + +/** + * Догрузка по требованию: если файл не отслеживается, ищем его в значениях полей + * и вложениях сообщений и записываем в file_uploads. + * Возвращает true, если файл после вызова отслеживается. + */ +export async function trackFileOnDemand(fileKey: string): Promise { + const existing = await db + .select({ id: fileUploads.id }) + .from(fileUploads) + .where(eq(fileUploads.fileKey, fileKey)) + .limit(1); + if (existing.length > 0) return true; + + try { + const ref = await findFileReference(fileKey); + // uploadedBy NOT NULL в схеме — без владельца записать не сможем + if (!ref || ref.uploadedBy == null) return false; + await db.insert(fileUploads).values({ + organizationId: ref.organizationId, + uploadedBy: ref.uploadedBy, + fileKey, + originalName: ref.name, + sizeBytes: ref.size, + taskId: ref.taskId, + fieldId: ref.fieldId, + }).onConflictDoNothing(); + return true; + } catch { + // Ошибка догрузки — fail-closed (доступ не открываем) + return false; + } +}