wa-dev-tools/routes/compress.js
treamz a7d6287d65 Image converter: add AVIF/TIFF/GIF/BMP/HEIC, quality slider, custom resize
- Renamed: Сжать картинки → Конвертер изображений
- New output formats: AVIF, TIFF, GIF (via sharp)
- New input formats: AVIF, TIFF, GIF, BMP, SVG, HEIC
- Quality slider (10-100%) in UI
- Custom resize option (prompt for px)
- 3-column control layout (format, size, quality)
- Updated descriptions in dashboard, DB, page title
2026-03-22 01:15:03 +03:00

213 lines
8.9 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 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;