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