wa-dev-tools/routes/compress.js
treamz c65b3f5006 HEIC в /compress, конструктор «Картинки → PDF», белый фон зон загрузки и табов
HEIC:
- lib/image.js — openImage(): HEIC (HEVC) через heic-decode (libheif WASM), т.к. sharp
  декодирует только AV1-HEIF; непрозрачная альфа отбрасывается
- compress: общий encodeImage(); формат «оригинала» по содержимому (metadata), а не по MIME;
  HEIC/BMP в режиме «Оригинал» → JPEG с расширением .jpg

Картинки → PDF (/pdf):
- /pdf/fromImages переписан: любые форматы вкл. HEIC, порядок/повторы/поворот страниц,
  размер листа (по картинке/A4/A3/A5/Letter), ориентация, поля, качество, имя файла;
  повторы встраиваются один раз; нетронутые JPEG/PNG — байт-в-байт
- /pdf/thumb — серверные миниатюры для HEIC/TIFF (свой мягкий лимитер)
- UI-конструктор: сетка с превью, drag&drop, сортировка, массовый поворот, прогресс загрузки
- старая вкладка была нерабочей (поле files вместо images) и требовала загрузки PDF
- SVG не принимается (librsvg + внешние ссылки)
- фикс: потерянная обёртка panel-watermark ломала вложенность stepConfig
- фикс: pdfIcon недоступен из inline onerror

UI: белый фон (--surface-800) у зон загрузки, неактивных табов, опций и карточек
(compress, pdf, video, converter, formatter, sanitizer, parser)
2026-09-22 20:37:48 +03:00

324 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 { createLimiter } = require('../lib/limits');
const geoip = require('geoip-lite');
const crypto = require('crypto');
const log = require('../lib/logger');
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
const owner = require('../lib/owner');
const { openImage } = require('../lib/image');
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 imageFileFilter = (req, file, cb) => {
// SVG intentionally excluded: sharp/librsvg would render it and honour external
// <image href>/file:// references → SSRF / local-file read / SVG-bomb DoS.
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/tiff', 'image/gif', 'image/bmp'];
if (validTypes.includes(file.mimetype) || file.originalname.match(/\.(jpe?g|png|webp|avif|tiff?|gif|bmp|heic|heif)$/i)) cb(null, true);
else cb(new Error('Неподдерживаемый формат изображения'));
};
// Админ — без лимитов (размер, кол-во файлов, rate-limit)
function isAdmin(req) {
return !!(req.session && req.session.user && req.session.user.role === 'admin');
}
// User — лимит размера из .env; Admin — без лимита размера
const uploadUser = multer({ dest: UPLOADS_DIR, limits: { fileSize: maxFileSize }, fileFilter: imageFileFilter });
const uploadAdmin = multer({ dest: UPLOADS_DIR, fileFilter: imageFileFilter });
// Динамический выбор multer: админу — без лимита количества и размера
function uploadArray(req, res, next) {
return (isAdmin(req) ? uploadAdmin.array('images') : uploadUser.array('images', maxFiles))(req, res, next);
}
function uploadSingle(req, res, next) {
return (isAdmin(req) ? uploadAdmin : uploadUser).single('image')(req, res, next);
}
// Rate limiter (админ пропускается — безлимитная загрузка по одному файлу на запрос)
const limiter = createLimiter({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
max: parseInt(process.env.RATE_LIMIT_MAX) || 30,
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, '');
}
// Формат источника по содержимому (metadata sharp), а не по MIME от браузера
function sourceFormatOf(meta) {
if (['jpeg', 'png', 'webp', 'tiff', 'gif'].includes(meta.format)) return meta.format;
if (meta.format === 'heif' && meta.compression === 'av1') return 'avif';
return null; // HEIC (raw после декодера), BMP и пр.
}
const FORMAT_INFO = {
webp: { ext: '.webp', mimetype: 'image/webp' },
jpeg: { ext: '.jpg', mimetype: 'image/jpeg' },
png: { ext: '.png', mimetype: 'image/png' },
avif: { ext: '.avif', mimetype: 'image/avif' },
tiff: { ext: '.tiff', mimetype: 'image/tiff' },
gif: { ext: '.gif', mimetype: 'image/gif' },
};
// 'original' сохраняет формат источника; то, что не пересжать в себя же (HEIC, BMP), уходит в JPEG
async function encodeImage(image, format, meta, quality) {
const sourceFormat = sourceFormatOf(meta);
const target = format !== 'original' ? format : (sourceFormat || 'jpeg');
let buffer;
if (target === 'webp') buffer = await image.webp({ quality }).toBuffer();
else if (target === 'jpeg') buffer = await image.jpeg({ quality }).toBuffer();
else if (target === 'png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
else if (target === 'avif') buffer = await image.avif({ quality }).toBuffer();
else if (target === 'tiff') buffer = await image.tiff({ quality }).toBuffer();
else buffer = await image.gif().toBuffer();
const keepName = format === 'original' && !!sourceFormat;
return { buffer, mimetype: FORMAT_INFO[target].mimetype, ext: keepName ? null : FORMAT_INFO[target].ext };
}
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, uploadArray, 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()}_${crypto.randomBytes(6).toString('hex')}.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);
// Bind error/close listeners BEFORE piping — an unhandled 'error' on archive/output
// would otherwise crash the whole process via uncaughtException.
const archiveDone = new Promise((resolve, reject) => {
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
});
try {
archive.pipe(output);
for (const file of req.files) {
const inputPath = file.path;
const originalSize = file.size;
const image = await openImage(file);
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);
}
const { buffer, ext } = await encodeImage(image, format, metadata, quality);
const readableName = fixMulterFilename(file.originalname);
const rawName = ext ? changeExtension(readableName, ext) : readableName;
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 archiveDone;
owner.claim(archiveName, req.session.user && req.session.user.id, 'compressed.zip');
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, uploadSingle, 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 = await openImage(req.file);
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);
}
const { buffer, mimetype: outputMimetype, ext } = await encodeImage(image, format, metadata, quality);
const readableName = fixMulterFilename(req.file.originalname);
const rawName = ext ? changeExtension(readableName, ext) : readableName;
const outputFilename = 'single_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex') + '_' + transliterate(rawName);
const outputPath = require('path').join(RESULTS_DIR, outputFilename);
require('fs').writeFileSync(outputPath, buffer);
owner.claim(outputFilename, req.session.user && req.session.user.id, transliterate(rawName));
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);
if (!owner.isOwner(filename, req.session.user && req.session.user.id)) {
return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' });
}
const filePath = require('path').join(RESULTS_DIR, filename);
if (!require('fs').existsSync(filePath)) {
return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' });
}
res.download(filePath, owner.displayName(filename) || 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;