import { Router, type Response } from "express"; import fs from 'fs/promises'; import multer from 'multer'; import { storage } from "../storage"; import { authenticateTokenOrApiKey, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { isS3Enabled, uploadToS3FromFile } from "../utils/s3"; import { isFormAllowedByScopes } from "../utils/api-key"; import { consumeUploadTicket } from "../utils/upload-tickets"; import { openTenantCtx, _tenantCtx } from "../db"; import { upload, EXT_TO_MIME, getFileExt, checkMagicBytes, getSizeLimit, DOC_MAX_SIZE, getPendingKey, getActivePendingUploads, addPendingUpload, } from "./shared"; import { db } from "../db"; import { fileUploads } from "@shared/schema"; const router = Router(); // Контекст загрузки, не зависящий от способа авторизации // (JWT пользователя / API-ключ / upload-тикет) interface UploadRequestContext { organizationId: number; /** Автор загрузки для file_uploads.uploadedBy (владелец ключа/тикета). * Для JWT-пути не передаётся — берётся req.user.id. */ uploadedBy?: number; /** Атрибуция бота для file_uploads.botId */ botId?: number | null; } // Общий пайплайн загрузки файла ПОСЛЕ авторизации: // multer → magic bytes → лимиты по типу/полю → S3 или локальный диск → file_uploads. // Используется и POST /api/upload (JWT/API-ключ), и POST /api/upload/ticket/:token. async function processUploadRequest(req: AuthenticatedRequest, res: Response, ctx: UploadRequestContext) { try { await new Promise((resolve, reject) => { upload.single('file')(req, res, (err: unknown) => { if (err) reject(err); else resolve(); }); }); } catch (err: unknown) { if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: `Файл слишком большой (максимум ${DOC_MAX_SIZE / 1024 / 1024} МБ)` }); } const message = err instanceof Error ? err.message : 'Ошибка загрузки файла'; return res.status(400).json({ error: message }); } if (!req.file) { return res.status(400).json({ error: 'Файл не передан' }); } // Проверки для авторизации по API-ключу (JWT-путь не меняется) if (req.apiKey) { if (req.apiKey.scopes.mode === 'read') { if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } } return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' }); } const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN; if (!isNaN(taskIdForScope)) { const scopedTask = await storage.getTask(taskIdForScope, req.organizationId!); if (!scopedTask || !isFormAllowedByScopes(req.apiKey.scopes, scopedTask.formId)) { if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } } return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); } } } // deleteNewFile — удаляем временный файл с диска если валидация не прошла // Теперь всегда disk-режим (S3 тоже пишет во tmpUploadDir до загрузки) const deleteNewFile = async () => { if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } } }; // ── Дополнительные проверки безопасности после приёма файла ────────────── // Все проверки основаны на расширении файла (доверенный источник), // а не на file.mimetype (клиентская переменная, легко подделывается) const fileExt = getFileExt(req.file.originalname); // 1. Проверка magic bytes по РАСШИРЕНИЮ (не по MIME) // Файл, переименованный из .exe в .jpg, не пройдёт эту проверку const magicOk = await checkMagicBytes(req.file.path, fileExt); if (!magicOk) { await deleteNewFile(); return res.status(400).json({ error: 'Содержимое файла не соответствует расширению — загрузка отклонена' }); } // 2. Раздельный лимит по РАСШИРЕНИЮ: изображения 10 МБ, документы 50 МБ const typeLimit = getSizeLimit(fileExt); if (req.file.size > typeLimit) { await deleteNewFile(); const limitMB = typeLimit / (1024 * 1024); return res.status(400).json({ error: `Файл превышает лимит ${limitMB} МБ для данного типа` }); } // ───────────────────────────────────────────────────────────────────────── const rawFieldId = req.query.fieldId; const rawTaskId = req.query.taskId; if (rawFieldId) { const fieldId = parseInt(String(rawFieldId)); if (isNaN(fieldId) || !ctx.organizationId) { await deleteNewFile(); return res.status(400).json({ error: 'Неверный контекст загрузки файла' }); } let field: Awaited>; try { field = await storage.getFormFieldById(fieldId, ctx.organizationId); } catch (lookupErr) { console.error('Upload field limit check error:', lookupErr); await deleteNewFile(); return res.status(500).json({ error: 'Ошибка проверки ограничений поля' }); } if (!field || field.type !== 'file') { await deleteNewFile(); return res.status(404).json({ error: 'Поле не найдено' }); } const hasCountLimit = field.maxFileCount != null; const hasSizeLimit = field.maxFileSizeMB != null; if (hasCountLimit || hasSizeLimit) { const taskId = rawTaskId ? parseInt(String(rawTaskId)) : NaN; let existingBytes = 0; let existingCount = 0; if (!isNaN(taskId)) { const existingValues = await storage.getTaskFieldValues(taskId, ctx.organizationId); const fieldValue = existingValues.find(v => v.fieldId === fieldId); if (fieldValue && Array.isArray(fieldValue.value)) { const existingFiles = fieldValue.value as Array<{ size?: number }>; existingCount = existingFiles.length; existingBytes = existingFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0); } } else { if (req.user?.id) { const key = getPendingKey(req.user.id, fieldId); const pending = getActivePendingUploads(key); existingCount = pending.length; existingBytes = pending.reduce((sum, e) => sum + e.size, 0); } } if (hasCountLimit && existingCount >= field.maxFileCount!) { await deleteNewFile(); return res.status(400).json({ error: `Превышено максимальное количество файлов (${field.maxFileCount})`, }); } if (hasSizeLimit) { const limitBytes = field.maxFileSizeMB! * 1024 * 1024; if (existingBytes + req.file.size > limitBytes) { await deleteNewFile(); return res.status(400).json({ error: `Суммарный размер файлов превысит лимит (${field.maxFileSizeMB} МБ)`, }); } } } } const name = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); let url: string; if (isS3Enabled) { // S3/MinIO режим: файл уже на диске в tmpUploadDir, загружаем потоком и удаляем // Используем нормализованный MIME по расширению, не клиентский mimetype const normalizedMime = EXT_TO_MIME[fileExt]?.[0] ?? 'application/octet-stream'; try { const result = await uploadToS3FromFile(req.file.path, name, normalizedMime); url = result.url; } catch (s3Err) { console.error('S3 upload error:', s3Err); return res.status(500).json({ error: 'Ошибка загрузки файла в хранилище' }); } finally { // Всегда удаляем временный файл после загрузки в S3 try { await fs.unlink(req.file.path); } catch { /* ignore */ } } } else { // Локальный режим — файл уже сохранён на диске в uploadsDir url = `/uploads/${req.file.filename}`; } // Трекинг для новых задач (без taskId) для контроля лимитов if (rawFieldId && !rawTaskId && req.user?.id) { const fieldId = parseInt(String(rawFieldId)); if (!isNaN(fieldId)) { const key = getPendingKey(req.user.id, fieldId); addPendingUpload(key, url, req.file.size); } } // Записываем файл в таблицу трекинга для последующей проверки доступа // Автор: пользователь (JWT) или владелец API-ключа/тикета; botId — атрибуция бота const uploaderId = req.user?.id ?? ctx.uploadedBy; if (uploaderId && ctx.organizationId) { const fileKey = isS3Enabled ? url.replace('/api/files/', '') : (req.file.filename ?? ''); if (fileKey) { const taskIdNum = rawTaskId ? parseInt(String(rawTaskId)) : undefined; const fieldIdNum = rawFieldId ? parseInt(String(rawFieldId)) : undefined; try { await db.insert(fileUploads).values({ organizationId: ctx.organizationId, uploadedBy: uploaderId, fileKey, originalName: name, sizeBytes: req.file.size, taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null, fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null, botId: req.apiKey?.botId ?? ctx.botId ?? null, }).onConflictDoNothing(); } catch (trackErr) { console.warn('[Upload] Failed to track file upload:', trackErr); } } } return res.json({ url, name, size: req.file.size }); } // Загрузка по upload-тикету (БЕЗ API-ключа/JWT): авторизация = валидный тикет. // Тикет выдаёт MCP-инструмент create_upload_ticket — см. server/mcp.ts. // ВАЖНО: роут зарегистрирован ДО '/api/upload', пути не пересекаются // ('/api/upload' — exact match, '/api/upload/ticket/:token' — отдельная ветка). router.post('/api/upload/ticket/:token', async (req: AuthenticatedRequest, res) => { const ticket = consumeUploadTicket(String(req.params.token || '')); if (!ticket) { return res.status(401).json({ error: 'Тикет загрузки недействителен или истёк' }); } if (ticket.scopes.mode === 'read') { return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' }); } // Основная логика: tenant-контекст (RLS) открывается так же, // как в authenticateTokenOrApiKey (openTenantCtx + release по finish/close) const proceed = async () => { // Проверка доступа к форме задачи по скоупам ключа (как в POST /api/upload) const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN; if (!isNaN(taskIdForScope)) { const scopedTask = await storage.getTask(taskIdForScope, ticket.organizationId); if (!scopedTask || !isFormAllowedByScopes(ticket.scopes, scopedTask.formId)) { return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); } } req.organizationId = ticket.organizationId; return processUploadRequest(req, res, { organizationId: ticket.organizationId, uploadedBy: ticket.createdBy, botId: ticket.botId, }); }; if (_tenantCtx.getStore()) return proceed(); try { const handle = await openTenantCtx(ticket.organizationId); 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(); }); proceed().catch((err: unknown) => { console.error('[Upload ticket] error:', err); if (!res.headersSent) res.status(500).json({ error: 'Ошибка загрузки файла' }); }); }); } catch (err) { console.error('[Upload ticket] tenant context error:', err); return res.status(500).json({ error: 'Ошибка загрузки файла' }); } }); // File upload endpoint (JWT пользователя или API-ключ организации) router.post('/api/upload', authenticateTokenOrApiKey, async (req: AuthenticatedRequest, res) => { return processUploadRequest(req, res, { organizationId: req.organizationId!, uploadedBy: req.apiKey?.createdBy, botId: req.apiKey?.botId ?? null, }); }); export default router;