const express = require('express'); const multer = require('multer'); const sharp = require('sharp'); const fs = require('fs'); const path = require('path'); const archiver = require('archiver'); const rateLimit = require('express-rate-limit'); const geoip = require('geoip-lite'); const log = require('../lib/logger'); const router = express.Router(); const UPLOADS_DIR = path.join(__dirname, '..', 'uploads'); const DOWNLOADS_DIR = path.join(__dirname, '..', 'downloads'); for (const dir of [UPLOADS_DIR, DOWNLOADS_DIR]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } // Cleanup old files function cleanupDir(dir, maxAgeMs) { try { const files = fs.readdirSync(dir); const now = Date.now(); for (const file of files) { const filePath = path.join(dir, file); try { const stat = fs.statSync(filePath); if (now - stat.mtimeMs > maxAgeMs) fs.unlinkSync(filePath); } catch {} } } catch {} } setInterval(() => cleanupDir(UPLOADS_DIR, 10 * 60 * 1000), 2 * 60 * 1000); setInterval(() => cleanupDir(DOWNLOADS_DIR, 30 * 60 * 1000), 5 * 60 * 1000); // Multer config const maxFileSize = (parseInt(process.env.MAX_FILE_SIZE_MB) || 20) * 1024 * 1024; const maxFiles = parseInt(process.env.MAX_FILES) || 50; const upload = multer({ dest: UPLOADS_DIR, limits: { fileSize: maxFileSize }, fileFilter: (req, file, cb) => { const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/tiff', 'image/gif', 'image/bmp', 'image/svg+xml']; if (validTypes.includes(file.mimetype) || file.originalname.match(/\.(jpe?g|png|webp|avif|tiff?|gif|bmp|svg|heic|heif)$/i)) cb(null, true); else cb(new Error('Неподдерживаемый формат изображения')); }, }); // Rate limiter const limiter = rateLimit({ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000, max: parseInt(process.env.RATE_LIMIT_MAX) || 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Слишком много запросов. Попробуйте через минуту.' }, }); // Transliteration const TRANSLIT_MAP = { 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'yo','ж':'zh','з':'z','и':'i', 'й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t', 'у':'u','ф':'f','х':'kh','ц':'ts','ч':'ch','ш':'sh','щ':'shch','ъ':'','ы':'y', 'ь':'','э':'e','ю':'yu','я':'ya', 'А':'A','Б':'B','В':'V','Г':'G','Д':'D','Е':'E','Ё':'Yo','Ж':'Zh','З':'Z','И':'I', 'Й':'Y','К':'K','Л':'L','М':'M','Н':'N','О':'O','П':'P','Р':'R','С':'S','Т':'T', 'У':'U','Ф':'F','Х':'Kh','Ц':'Ts','Ч':'Ch','Ш':'Sh','Щ':'Shch','Ъ':'','Ы':'Y', 'Ь':'','Э':'E','Ю':'Yu','Я':'Ya', }; function fixMulterFilename(str) { try { const buf = Buffer.from(str, 'latin1'); const decoded = buf.toString('utf8'); if (/[а-яёА-ЯЁ]/.test(decoded)) return decoded; } catch {} return str; } function transliterate(str) { const fixed = fixMulterFilename(str); return fixed.replace(/[а-яёА-ЯЁ]/g, ch => TRANSLIT_MAP[ch] !== undefined ? TRANSLIT_MAP[ch] : ch) .replace(/[^\w.\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, ''); } function getOutputExtension(format, originalName) { const extMap = { webp: '.webp', jpeg: '.jpg', png: '.png', avif: '.avif', tiff: '.tiff', gif: '.gif' }; return extMap[format] || path.extname(originalName); } function changeExtension(filename, newExt) { return path.basename(filename, path.extname(filename)) + newExt; } function getCountry(ip) { const clean = ip.replace(/^::ffff:/, ''); if (clean === '127.0.0.1' || clean === '::1') return 'local'; const geo = geoip.lookup(clean); return geo ? geo.country : '??'; } // Compress page router.get('/', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'compress.html')); }); // Compress endpoint router.post('/', limiter, upload.array('images', maxFiles), async (req, res) => { const resize = parseInt(req.body.resize || '0', 10); const format = (req.body.format || 'original').toLowerCase(); const quality = Math.min(100, Math.max(1, parseInt(req.body.quality || process.env.COMPRESS_QUALITY || '80', 10))); const validFormats = ['original', 'webp', 'jpeg', 'png', 'avif', 'tiff', 'gif']; if (!validFormats.includes(format)) { return res.status(400).json({ error: 'Неверный формат' }); } if (!req.files || !req.files.length) { return res.status(400).json({ error: 'Файлы не загружены' }); } const archiveName = `archive_${Date.now()}.zip`; const archivePath = path.join(DOWNLOADS_DIR, archiveName); const output = fs.createWriteStream(archivePath); const archive = archiver('zip', { zlib: { level: 9 } }); const stats = []; const country = getCountry(req.ip); try { archive.pipe(output); for (const file of req.files) { const inputPath = file.path; const originalSize = file.size; const image = sharp(inputPath); const metadata = await image.metadata(); if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) { const scale = resize / Math.max(metadata.width, metadata.height); const newWidth = Math.round(metadata.width * scale); const newHeight = Math.round(metadata.height * scale); image.resize({ width: newWidth, height: newHeight }); const msg = `[${country}] ${req.ip} resized: ${file.originalname} ${metadata.width}x${metadata.height} → ${newWidth}x${newHeight} (${(originalSize / 1024).toFixed(0)}KB)`; log.info(msg); log.logToFile(msg); } else { const msg = `[${country}] ${req.ip} compress: ${file.originalname} ${metadata.width}x${metadata.height} format=${format} q=${quality} (${(originalSize / 1024).toFixed(0)}KB)`; log.info(msg); log.logToFile(msg); } let buffer; if (format === 'webp') buffer = await image.webp({ quality }).toBuffer(); else if (format === 'jpeg') buffer = await image.jpeg({ quality }).toBuffer(); else if (format === 'png') buffer = await image.png({ compressionLevel: 9 }).toBuffer(); else if (format === 'avif') buffer = await image.avif({ quality }).toBuffer(); else if (format === 'tiff') buffer = await image.tiff({ quality }).toBuffer(); else if (format === 'gif') buffer = await image.gif().toBuffer(); else { // original — keep source format with compression if (file.mimetype === 'image/jpeg') buffer = await image.jpeg({ quality }).toBuffer(); else if (file.mimetype === 'image/png') buffer = await image.png({ compressionLevel: 9 }).toBuffer(); else if (file.mimetype === 'image/webp') buffer = await image.webp({ quality }).toBuffer(); else if (file.mimetype === 'image/avif') buffer = await image.avif({ quality }).toBuffer(); else if (file.mimetype === 'image/tiff') buffer = await image.tiff({ quality }).toBuffer(); else if (file.mimetype === 'image/gif') buffer = await image.gif().toBuffer(); else buffer = await image.jpeg({ quality }).toBuffer(); // fallback } const readableName = fixMulterFilename(file.originalname); const newExt = getOutputExtension(format, readableName); const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt); const outputName = transliterate(rawName); archive.append(buffer, { name: outputName }); const compressedSize = buffer.length; const savings = originalSize > 0 ? Math.round((1 - compressedSize / originalSize) * 100) : 0; stats.push({ filename: readableName, outputFilename: outputName, originalSize, compressedSize, savings }); } await archive.finalize(); await new Promise((resolve, reject) => { output.on('close', resolve); output.on('error', reject); }); res.json({ success: true, downloadUrl: `/download/${archiveName}`, stats }); } catch (err) { log.error('Compression error', { error: err.message }); try { fs.existsSync(archivePath) && fs.unlinkSync(archivePath); } catch {} res.status(500).json({ error: 'Ошибка при сжатии' }); } finally { if (req.files) { req.files.forEach(file => { try { fs.existsSync(file.path) && fs.unlinkSync(file.path); } catch {} }); } } }); // Multer error handler router.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: `Файл слишком большой. Максимум ${process.env.MAX_FILE_SIZE_MB || 20}MB.` }); if (err.code === 'LIMIT_FILE_COUNT') return res.status(400).json({ error: `Слишком много файлов. Максимум ${maxFiles}.` }); return res.status(400).json({ error: err.message }); } if (err) return res.status(500).json({ error: 'Внутренняя ошибка сервера' }); next(); }); module.exports = router; module.exports.DOWNLOADS_DIR = DOWNLOADS_DIR;