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

@@ -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<FileReference | null> {
// Значения 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<boolean> {
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;
}
}