import { Router } from "express"; import fs from 'fs/promises'; import multer from 'multer'; import { storage } from "../storage"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { isS3Enabled, uploadToS3FromFile } from "../utils/s3"; 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(); // File upload endpoint router.post('/api/upload', authenticateToken, async (req: AuthenticatedRequest, res) => { 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: 'Файл не передан' }); } // 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) || !req.organizationId) { await deleteNewFile(); return res.status(400).json({ error: 'Неверный контекст загрузки файла' }); } let field: Awaited>; try { field = await storage.getFormFieldById(fieldId, req.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, req.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); } } // Записываем файл в таблицу трекинга для последующей проверки доступа if (req.user?.id && req.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: req.organizationId, uploadedBy: req.user.id, fileKey, originalName: name, sizeBytes: req.file.size, taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null, fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null, }).onConflictDoNothing(); } catch (trackErr) { console.warn('[Upload] Failed to track file upload:', trackErr); } } } return res.json({ url, name, size: req.file.size }); }); export default router;