fix(files): бот может скачивать файлы — API-ключ на /api/files и /uploads + трекинг абсолютных URL

Проблема: прямое скачивание /api/files/<key> с 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).
This commit is contained in:
2026-09-29 10:36:41 +03:00
parent 2ac9da7220
commit 4bac21ff71
3 changed files with 218 additions and 18 deletions

View File

@@ -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<boolean> {
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<b
}
}
// Скоупы API-ключа для файла: если ключ ограничен формами и файл привязан
// к задаче — форма задачи должна быть разрешена ключом.
async function checkApiKeyFileScope(req: AuthenticatedRequest, fileKey: string): Promise<boolean> {
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(() => {});