wa-dev-tools/routes/compress.js

305 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;