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 { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage'); const router = express.Router(); // 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(RESULTS_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({ keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, 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(RESULTS_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 {} }); } } }); // Single file compress endpoint router.post('/single', limiter, upload.single('image'), 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.file) { return res.status(400).json({ error: 'Файл не загружен' }); } const country = getCountry(req.ip); const inputPath = req.file.path; const originalSize = req.file.size; try { 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 + " single resized: " + req.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 + " single compress: " + req.file.originalname + " " + metadata.width + "x" + metadata.height + " format=" + format + " q=" + quality + " (" + (originalSize / 1024).toFixed(0) + "KB)"; log.info(msg); log.logToFile(msg); } let buffer; let outputMimetype; if (format === 'webp') { buffer = await image.webp({ quality }).toBuffer(); outputMimetype = 'image/webp'; } else if (format === 'jpeg') { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; } else if (format === 'png') { buffer = await image.png({ compressionLevel: 9 }).toBuffer(); outputMimetype = 'image/png'; } else if (format === 'avif') { buffer = await image.avif({ quality }).toBuffer(); outputMimetype = 'image/avif'; } else if (format === 'tiff') { buffer = await image.tiff({ quality }).toBuffer(); outputMimetype = 'image/tiff'; } else if (format === 'gif') { buffer = await image.gif().toBuffer(); outputMimetype = 'image/gif'; } else { if (req.file.mimetype === 'image/jpeg') { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; } else if (req.file.mimetype === 'image/png') { buffer = await image.png({ compressionLevel: 9 }).toBuffer(); outputMimetype = 'image/png'; } else if (req.file.mimetype === 'image/webp') { buffer = await image.webp({ quality }).toBuffer(); outputMimetype = 'image/webp'; } else if (req.file.mimetype === 'image/avif') { buffer = await image.avif({ quality }).toBuffer(); outputMimetype = 'image/avif'; } else if (req.file.mimetype === 'image/tiff') { buffer = await image.tiff({ quality }).toBuffer(); outputMimetype = 'image/tiff'; } else if (req.file.mimetype === 'image/gif') { buffer = await image.gif().toBuffer(); outputMimetype = 'image/gif'; } else { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; } } const readableName = fixMulterFilename(req.file.originalname); const newExt = getOutputExtension(format, readableName); const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt); const outputFilename = 'single_' + Date.now() + '_' + transliterate(rawName); const outputPath = require('path').join(RESULTS_DIR, outputFilename); require('fs').writeFileSync(outputPath, buffer); const compressedSize = buffer.length; const savings = originalSize > 0 ? Math.round((1 - compressedSize / originalSize) * 100) : 0; setTimeout(() => { try { require('fs').existsSync(outputPath) && require('fs').unlinkSync(outputPath); } catch(e) {} }, 30 * 60 * 1000); res.json({ filename: readableName, outputFilename, originalSize, compressedSize, savings, downloadUrl: '/compress/download/' + outputFilename, mimetype: outputMimetype, }); } catch (err) { log.error('Single compression error', { error: err.message }); res.status(500).json({ error: 'Ошибка при сжатии' }); } finally { try { require('fs').existsSync(inputPath) && require('fs').unlinkSync(inputPath); } catch(e) {} } }); // Individual file download router.get('/download/:filename', (req, res) => { const filename = require('path').basename(req.params.filename); const filePath = require('path').join(RESULTS_DIR, filename); if (!require('fs').existsSync(filePath)) { return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' }); } res.download(filePath, filename); }); // 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.RESULTS_DIR = RESULTS_DIR;