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:
114
server/index.ts
114
server/index.ts
@@ -15,7 +15,7 @@ import { startSessionCleanup } from "./workers/session-cleanup";
|
|||||||
import { startGpsWorker } from "./gps/worker";
|
import { startGpsWorker } from "./gps/worker";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import type { ReminderRecipient } from "@shared/schema";
|
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 { sql, eq } from "drizzle-orm";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -26,6 +26,8 @@ import { decrypt as decryptSecret } from "./crypto";
|
|||||||
import { fileUploads, errorLogs } from "@shared/schema";
|
import { fileUploads, errorLogs } from "@shared/schema";
|
||||||
import { authenticateToken, authenticateFileToken, type AuthenticatedRequest } from "./middleware/auth.middleware";
|
import { authenticateToken, authenticateFileToken, type AuthenticatedRequest } from "./middleware/auth.middleware";
|
||||||
import { EXT_TO_MIME, getFileExt } from "./utils/upload";
|
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 crypto from "crypto";
|
||||||
import { logger } from "./utils/logger";
|
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).
|
// Helper: check file ownership — fail-closed (deny if untracked or DB error).
|
||||||
// Legacy files are backfilled from task_field_values / task_messages at startup
|
// Legacy files are backfilled from task_field_values / task_messages at startup;
|
||||||
// so untracked after startup == foreign or unknown file.
|
// неохваченные (старые абсолютные URL и т.п.) догружаются по требованию через
|
||||||
|
// trackFileOnDemand, дальше untracked == foreign or unknown file.
|
||||||
async function canAccessFile(fileKey: string, organizationId: number): Promise<boolean> {
|
async function canAccessFile(fileKey: string, organizationId: number): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const rows = await db.select({ orgId: fileUploads.organizationId })
|
let rows = await db.select({ orgId: fileUploads.organizationId })
|
||||||
.from(fileUploads)
|
.from(fileUploads)
|
||||||
.where(eq(fileUploads.fileKey, fileKey))
|
.where(eq(fileUploads.fileKey, fileKey))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
// Not tracked even after startup backfill — deny (fail-closed)
|
const tracked = await trackFileOnDemand(fileKey);
|
||||||
return false;
|
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;
|
return rows[0].orgId === organizationId;
|
||||||
} catch {
|
} 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.
|
// Middleware: try presigned token first, fall back to authenticateFileToken.
|
||||||
function tryPresignedOrAuth() {
|
function tryPresignedOrAuth() {
|
||||||
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
@@ -123,18 +150,64 @@ function tryPresignedOrAuth() {
|
|||||||
}
|
}
|
||||||
return res.status(403).json({ error: 'Недействительная или истёкшая presigned-ссылка' });
|
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);
|
return authenticateFileToken(req, res, next);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Endpoint to generate a presigned URL for file preview.
|
// 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;
|
const key = req.params.key;
|
||||||
if (!key || key.includes('..') || key.includes('/')) {
|
if (!key || key.includes('..') || key.includes('/')) {
|
||||||
return res.status(400).json({ error: 'Неверный ключ файла' });
|
return res.status(400).json({ error: 'Неверный ключ файла' });
|
||||||
}
|
}
|
||||||
const allowed = await canAccessFile(key, req.organizationId!);
|
const allowed = await canAccessFile(key, req.organizationId!);
|
||||||
if (!allowed) return res.status(403).json({ error: 'Доступ запрещён' });
|
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 token = generatePresignedToken(key, req.organizationId!);
|
||||||
const presignedUrl = isS3Enabled
|
const presignedUrl = isS3Enabled
|
||||||
@@ -188,6 +261,9 @@ if (isS3Enabled) {
|
|||||||
|
|
||||||
const allowed = await canAccessFile(key, req.organizationId!);
|
const allowed = await canAccessFile(key, req.organizationId!);
|
||||||
if (!allowed) return res.status(403).json({ error: 'Доступ запрещён' });
|
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);
|
const obj = await streamFromS3(key);
|
||||||
if (!obj) return res.status(404).end();
|
if (!obj) return res.status(404).end();
|
||||||
@@ -210,6 +286,9 @@ if (isS3Enabled) {
|
|||||||
|
|
||||||
const allowed = await canAccessFile(filename, req.organizationId!);
|
const allowed = await canAccessFile(filename, req.organizationId!);
|
||||||
if (!allowed) return res.status(403).json({ error: 'Доступ запрещён' });
|
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);
|
const filePath = path.join(uploadsDir, filename);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).end();
|
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.
|
// 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.
|
// 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 }
|
// Single-file field values: { url, name, size }
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
INSERT INTO file_uploads (organization_id, uploaded_by, file_key, original_name, size_bytes, task_id, field_id)
|
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.organization_id,
|
||||||
t.created_by,
|
t.created_by,
|
||||||
CASE
|
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 '^.*/api/files/')
|
||||||
WHEN tfv.value->>'url' LIKE '/api/files/%' THEN substring(tfv.value->>'url' FROM 12)
|
WHEN tfv.value->>'url' LIKE '%/uploads/%' THEN substring(tfv.value->>'url' FROM '^.*/uploads/')
|
||||||
END AS file_key,
|
END AS file_key,
|
||||||
tfv.value->>'name',
|
tfv.value->>'name',
|
||||||
(tfv.value->>'size')::integer,
|
(tfv.value->>'size')::integer,
|
||||||
@@ -860,7 +942,7 @@ async function runStartupDataPatches() {
|
|||||||
JOIN form_fields ff ON tfv.field_id = ff.id
|
JOIN form_fields ff ON tfv.field_id = ff.id
|
||||||
WHERE ff.type = 'file'
|
WHERE ff.type = 'file'
|
||||||
AND jsonb_typeof(tfv.value) = 'object'
|
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
|
ON CONFLICT (file_key) DO NOTHING
|
||||||
`).catch(() => {});
|
`).catch(() => {});
|
||||||
|
|
||||||
@@ -871,8 +953,8 @@ async function runStartupDataPatches() {
|
|||||||
t.organization_id,
|
t.organization_id,
|
||||||
t.created_by,
|
t.created_by,
|
||||||
CASE
|
CASE
|
||||||
WHEN elem->>'url' LIKE '/uploads/%' THEN substring(elem->>'url' FROM 10)
|
WHEN elem->>'url' LIKE '%/api/files/%' THEN substring(elem->>'url' FROM '^.*/api/files/')
|
||||||
WHEN elem->>'url' LIKE '/api/files/%' THEN substring(elem->>'url' FROM 12)
|
WHEN elem->>'url' LIKE '%/uploads/%' THEN substring(elem->>'url' FROM '^.*/uploads/')
|
||||||
END AS file_key,
|
END AS file_key,
|
||||||
elem->>'name',
|
elem->>'name',
|
||||||
(elem->>'size')::integer,
|
(elem->>'size')::integer,
|
||||||
@@ -884,7 +966,7 @@ async function runStartupDataPatches() {
|
|||||||
CROSS JOIN LATERAL jsonb_array_elements(tfv.value) AS elem
|
CROSS JOIN LATERAL jsonb_array_elements(tfv.value) AS elem
|
||||||
WHERE ff.type = 'file'
|
WHERE ff.type = 'file'
|
||||||
AND jsonb_typeof(tfv.value) = 'array'
|
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
|
ON CONFLICT (file_key) DO NOTHING
|
||||||
`).catch(() => {});
|
`).catch(() => {});
|
||||||
|
|
||||||
@@ -895,8 +977,8 @@ async function runStartupDataPatches() {
|
|||||||
f.organization_id,
|
f.organization_id,
|
||||||
COALESCE(tm.author_id, t.created_by),
|
COALESCE(tm.author_id, t.created_by),
|
||||||
CASE
|
CASE
|
||||||
WHEN att->>'url' LIKE '/uploads/%' THEN substring(att->>'url' FROM 10)
|
WHEN att->>'url' LIKE '%/api/files/%' THEN substring(att->>'url' FROM '^.*/api/files/')
|
||||||
WHEN att->>'url' LIKE '/api/files/%' THEN substring(att->>'url' FROM 12)
|
WHEN att->>'url' LIKE '%/uploads/%' THEN substring(att->>'url' FROM '^.*/uploads/')
|
||||||
END AS file_key,
|
END AS file_key,
|
||||||
att->>'name',
|
att->>'name',
|
||||||
(att->>'size')::integer,
|
(att->>'size')::integer,
|
||||||
@@ -907,7 +989,7 @@ async function runStartupDataPatches() {
|
|||||||
CROSS JOIN LATERAL jsonb_array_elements(tm.attachments) AS att
|
CROSS JOIN LATERAL jsonb_array_elements(tm.attachments) AS att
|
||||||
WHERE tm.attachments IS NOT NULL
|
WHERE tm.attachments IS NOT NULL
|
||||||
AND jsonb_typeof(tm.attachments) = 'array'
|
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
|
ON CONFLICT (file_key) DO NOTHING
|
||||||
`).catch(() => {});
|
`).catch(() => {});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type { Request, Response } from "express";
|
|||||||
import type { Task, ApiKeyScopes, OrganizationApiKey, SafeUser, Bot } from "@shared/schema";
|
import type { Task, ApiKeyScopes, OrganizationApiKey, SafeUser, Bot } from "@shared/schema";
|
||||||
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
|
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
|
||||||
import { createUploadTicket } from "./utils/upload-tickets";
|
import { createUploadTicket } from "./utils/upload-tickets";
|
||||||
|
import { trackFileOnDemand } from "./utils/file-tracking";
|
||||||
import beautify from "js-beautify";
|
import beautify from "js-beautify";
|
||||||
import {
|
import {
|
||||||
semanticSearch,
|
semanticSearch,
|
||||||
@@ -4252,12 +4253,24 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
|||||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||||
|
|
||||||
// Файл должен отслеживаться в file_uploads и принадлежать организации (как canAccessFile в index.ts)
|
// Файл должен отслеживаться в file_uploads и принадлежать организации (как canAccessFile в index.ts).
|
||||||
const [upload] = await db
|
// Если не отслеживается — догружаем по требованию (старые абсолютные URL и прочие
|
||||||
|
// неохваченные startup-backfill случаи, см. utils/file-tracking.ts).
|
||||||
|
let [upload] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(fileUploads)
|
.from(fileUploads)
|
||||||
.where(eq(fileUploads.fileKey, fileKey))
|
.where(eq(fileUploads.fileKey, fileKey))
|
||||||
.limit(1);
|
.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) return mcpError("Файл не найден или не отслеживается");
|
||||||
if (upload.organizationId !== organizationId) {
|
if (upload.organizationId !== organizationId) {
|
||||||
return mcpError("Файл принадлежит другой организации");
|
return mcpError("Файл принадлежит другой организации");
|
||||||
|
|||||||
105
server/utils/file-tracking.ts
Normal file
105
server/utils/file-tracking.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user