305 lines
14 KiB
JavaScript
305 lines
14 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 { 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;
|