Files
iistwin/server/utils/file-tracking.ts
Ильяс Султанов 4bac21ff71 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).
2026-09-29 10:36:41 +03:00

106 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}