import path from 'path'; import fs from 'fs/promises'; import fssync from 'fs'; import multer from 'multer'; import crypto from 'crypto'; import { isS3Enabled, deleteFromS3 } from './s3'; // ── File upload security configuration ──────────────────────────────────────── // Uploads directory for local disk mode export const uploadsDir = path.resolve(process.cwd(), 'uploads'); if (!fssync.existsSync(uploadsDir)) { fssync.mkdirSync(uploadsDir, { recursive: true }); } // Temp directory for S3 mode — files deleted after successful S3 upload export const tmpUploadDir = path.resolve(process.cwd(), 'uploads/tmp'); if (!fssync.existsSync(tmpUploadDir)) { fssync.mkdirSync(tmpUploadDir, { recursive: true }); } // ── Extension-based file classification (trusted source of truth) ───────────── // All policy decisions (magic bytes, size limits, MIME consistency) use the // file EXTENSION, not file.mimetype, because MIME is client-controlled. // Extension → acceptable MIME types (browser may send any of these for that ext) // application/octet-stream is always accepted as a fallback from some browsers/OSes export const EXT_TO_MIME: Record = { jpg: ['image/jpeg'], jpeg: ['image/jpeg'], png: ['image/png'], gif: ['image/gif'], webp: ['image/webp'], svg: ['image/svg+xml'], pdf: ['application/pdf'], doc: ['application/msword'], docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], xls: ['application/vnd.ms-excel'], xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], ppt: ['application/vnd.ms-powerpoint'], pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'], txt: ['text/plain'], csv: ['text/csv', 'text/plain', 'application/csv'], zip: ['application/zip', 'application/x-zip-compressed', 'application/x-zip'], rar: ['application/x-rar-compressed', 'application/vnd.rar', 'application/x-rar'], }; // Magic byte signatures keyed by EXTENSION (not MIME) // A file renamed from .exe → .jpg will fail this check export const EXT_MAGIC: Record = { jpg: Buffer.from([0xFF, 0xD8, 0xFF]), jpeg: Buffer.from([0xFF, 0xD8, 0xFF]), png: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), pdf: Buffer.from([0x25, 0x50, 0x44, 0x46]), // %PDF }; // Image extensions → лимит изображений; all others → документный лимит // Лимиты настраиваются через env (в МБ), дефолты: 25 МБ изображения, 100 МБ документы export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']); export const IMAGE_MAX_SIZE = Number(process.env.UPLOAD_IMAGE_MAX_MB || 25) * 1024 * 1024; export const DOC_MAX_SIZE = Number(process.env.UPLOAD_DOC_MAX_MB || 100) * 1024 * 1024; // Жёсткий потолок multer (fileSize) — отдельный env, дефолт 100 МБ export const UPLOAD_MAX_SIZE = Number(process.env.UPLOAD_MAX_MB || 100) * 1024 * 1024; // Derives the lowercase extension from the original filename (trusted) export function getFileExt(originalname: string): string { return path.extname(path.basename(originalname)).slice(1).toLowerCase(); } // Validates original filename: // - extension must be in EXT_TO_MIME (whitelist) // - name part blocks path traversal, shell metacharacters and control characters // - Unicode letters (including Cyrillic), digits, spaces, dots, underscores and hyphens are allowed export function validateFilename(originalname: string): { valid: boolean; reason?: string } { const basename = path.basename(originalname); const ext = path.extname(basename).slice(1).toLowerCase(); if (!EXT_TO_MIME[ext]) { return { valid: false, reason: `Недопустимое расширение файла: .${ext}` }; } const namePart = path.basename(basename, path.extname(basename)); if (!namePart) { return { valid: false, reason: 'Пустое имя файла' }; } // Block path traversal, shell metacharacters, and control characters // Allow Unicode letters (including Cyrillic), digits, spaces, dots, underscores, hyphens if (/[\/\\&|;$()<>\`\"'\x00-\x1f]/.test(namePart) || namePart.includes('..')) { return { valid: false, reason: 'Имя файла содержит недопустимые символы' }; } return { valid: true }; } // Checks declared MIME against the expected list for the given extension. // Returns false if MIME is clearly wrong for the extension (e.g. .jpg + application/msword). // application/octet-stream is always accepted as a browser fallback. export function isMimeConsistentWithExt(ext: string, mime: string): boolean { if (mime === 'application/octet-stream') return true; // browser fallback — always ok const allowed = EXT_TO_MIME[ext]; if (!allowed) return false; return allowed.includes(mime); } // Reads magic bytes from file on disk and compares against known signature for the extension. // Returns true if no signature is defined for this extension (no check needed). export async function checkMagicBytes(filePath: string, ext: string): Promise { const signature = EXT_MAGIC[ext]; if (!signature) return true; const fd = await fs.open(filePath, 'r'); try { const buf = Buffer.alloc(signature.length); await fd.read(buf, 0, signature.length, 0); return buf.equals(signature); } finally { await fd.close(); } } // Returns the size limit in bytes based on extension (trusted), not MIME (client-controlled) export function getSizeLimit(ext: string): number { return IMAGE_EXTENSIONS.has(ext) ? IMAGE_MAX_SIZE : DOC_MAX_SIZE; } // Always use disk storage (tmpUploadDir for S3 mode so we can stream to S3, then delete) const multerStorage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, isS3Enabled ? tmpUploadDir : uploadsDir), filename: (_req, file, cb) => { const ext = getFileExt(file.originalname); const uniqueName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${ext}`; cb(null, uniqueName); }, }); export const upload = multer({ storage: multerStorage, limits: { fileSize: UPLOAD_MAX_SIZE }, // жёсткий потолок (UPLOAD_MAX_MB); раздельная проверка по типам — в хендлере fileFilter: (_req, file, cb) => { // 1. Validate filename: strict regex + extension whitelist (extension is trusted) const { valid, reason } = validateFilename(file.originalname); if (!valid) { return cb(new Error(reason || 'Недопустимое имя файла')); } // 2. Reject if declared MIME is clearly incompatible with the extension // (e.g. .jpg uploaded with Content-Type: application/msword is suspicious) const ext = getFileExt(file.originalname); if (!isMimeConsistentWithExt(ext, file.mimetype)) { return cb(new Error(`Тип файла (${file.mimetype}) не соответствует расширению .${ext}`)); } cb(null, true); }, }); // ────────────────────────────────────────────────────────────────────────────── // Удаляет физический файл из uploads/ по сохранённому значению поля (объект или JSON-строка) // Извлекает все URL из значения файлового поля (одиночный объект, массив или JSON-строка) export function extractFileUrls(fileValue: unknown): string[] { if (!fileValue) return []; // Массив объектов [{url, name, size}] if (Array.isArray(fileValue)) { return fileValue .filter((f): f is Record => f && typeof f === 'object') .map(f => (typeof f.url === 'string' ? f.url : null)) .filter((u): u is string => !!u); } // Одиночный объект {url, name, size} if (typeof fileValue === 'object') { const obj = fileValue as Record; return typeof obj.url === 'string' ? [obj.url] : []; } // JSON-строка if (typeof fileValue === 'string' && fileValue) { try { return extractFileUrls(JSON.parse(fileValue)); } catch { return fileValue ? [fileValue] : []; } } return []; } export async function deleteFileByUrl(url: string): Promise { if (isS3Enabled) { // S3-режим: извлекаем ключ из URL вида /api/files/:key const key = url.startsWith('/api/files/') ? url.slice('/api/files/'.length) : path.basename(url); if (!key || key.includes('..') || key.includes('/')) return; await deleteFromS3(key); } else { // Локальный режим: удаляем с диска const filename = path.basename(url); if (!filename || filename.includes('..') || !filename.includes('-')) return; try { await fs.unlink(path.join(uploadsDir, filename)); } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Ошибка удаления файла с диска:', e); } } } } // Удаляет все файлы из значения поля (используется при полном удалении поля) export async function deleteUploadedFile(fileValue: unknown): Promise { const urls = extractFileUrls(fileValue); for (const url of urls) { await deleteFileByUrl(url); } } // Удаляет только файлы, которые были в oldValue но исчезли в newValue export async function deleteRemovedFiles(oldValue: unknown, newValue: unknown): Promise { const oldUrls = new Set(extractFileUrls(oldValue)); const newUrls = new Set(extractFileUrls(newValue)); for (const url of oldUrls) { if (!newUrls.has(url)) { await deleteFileByUrl(url); } } } // ── Pending-upload registry for new-task flows ──────────────────────────────── // Tracks uploads that have been accepted but not yet committed to a saved task. // Key: `${userId}_${fieldId}`; entries expire after 60 minutes. // This allows the server to enforce per-field limits without trusting the client. export interface PendingUploadEntry { url: string; size: number; expiresAt: number; } export const pendingUploads = new Map(); export const PENDING_UPLOAD_TTL_MS = 60 * 60 * 1000; // 1 hour export function getPendingKey(userId: number, fieldId: number): string { return `${userId}_${fieldId}`; } export function getActivePendingUploads(key: string): PendingUploadEntry[] { const now = Date.now(); const entries = pendingUploads.get(key) || []; const expired = entries.filter(e => e.expiresAt <= now); const active = entries.filter(e => e.expiresAt > now); if (active.length !== entries.length) { pendingUploads.set(key, active); // Удаляем осиротевшие файлы (disk или S3) асинхронно expired.forEach(e => deleteFileByUrl(e.url).catch(() => {/* ignore */})); } return active; } export function addPendingUpload(key: string, url: string, size: number): void { const active = getActivePendingUploads(key); active.push({ url, size, expiresAt: Date.now() + PENDING_UPLOAD_TTL_MS }); pendingUploads.set(key, active); } export function commitPendingUploads(userId: number, fieldId: number, committedUrls: string[]): void { const key = getPendingKey(userId, fieldId); const active = getActivePendingUploads(key); const urlSet = new Set(committedUrls); pendingUploads.set(key, active.filter(e => !urlSet.has(e.url))); } export function sweepPendingUploads(): void { const now = Date.now(); for (const [key, entries] of pendingUploads.entries()) { const expired = entries.filter(e => e.expiresAt <= now); const active = entries.filter(e => e.expiresAt > now); if (expired.length > 0) { if (active.length === 0) { pendingUploads.delete(key); } else { pendingUploads.set(key, active); } expired.forEach(e => deleteFileByUrl(e.url).catch(() => {/* ignore */})); } } } // Run orphan sweep every hour setInterval(sweepPendingUploads, 60 * 60 * 1000); // ──────────────────────────────────────────────────────────────────────────────