wa-dev-tools/routes/favicon.js

174 lines
6.9 KiB
JavaScript

const express = require('express');
const multer = require('multer');
const sharp = require('sharp');
const archiver = require('archiver');
const path = require('path');
const fs = require('fs');
const rateLimit = require('express-rate-limit');
const { RESULTS_DIR } = require('../lib/storage');
const log = require('../lib/logger');
const router = express.Router();
// ── Rate limiter ──────────────────────────────────────────────────────────────
const limiter = rateLimit({
keyGenerator: (req) =>
req.session && req.session.user && req.session.user.id
? 'user_' + req.session.user.id
: req.ip,
windowMs: 60000,
max: 30,
message: { error: 'Слишком много запросов' },
});
// ── Multer (memory storage, 5 MB, images only) ────────────────────────────────
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Допустимы только изображения'));
}
},
});
// ── Favicon sizes spec ────────────────────────────────────────────────────────
const FAVICON_SIZES = [
{ size: 16, name: 'favicon-16x16.png', purpose: 'ico' },
{ size: 32, name: 'favicon-32x32.png', purpose: 'ico' },
{ size: 48, name: 'favicon-48x48.png', purpose: 'ico' },
{ size: 180, name: 'apple-touch-icon.png', purpose: 'apple' },
{ size: 192, name: 'android-chrome-192x192.png', purpose: 'android' },
{ size: 512, name: 'android-chrome-512x512.png', purpose: 'android' },
];
// ── Cleanup helper ────────────────────────────────────────────────────────────
function scheduleCleanup(dirPath, delayMs = 30 * 60 * 1000) {
setTimeout(() => {
fs.rm(dirPath, { recursive: true, force: true }, (err) => {
if (err) log.warn('Favicon cleanup failed', { dir: dirPath, error: err.message });
else log.debug('Favicon temp dir removed', { dir: dirPath });
});
}, delayMs);
}
// ── GET / — serve page ────────────────────────────────────────────────────────
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'favicon.html'));
});
// ── POST /generate ────────────────────────────────────────────────────────────
router.post('/generate', limiter, upload.single('image'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Изображение не загружено' });
}
const timestamp = Date.now();
const tempDirName = `favicon_${timestamp}`;
const tempDir = path.join(RESULTS_DIR, tempDirName);
try {
fs.mkdirSync(tempDir, { recursive: true });
// Generate all PNG sizes
const generatedSizes = [];
for (const spec of FAVICON_SIZES) {
await sharp(req.file.buffer)
.resize(spec.size, spec.size, { fit: 'cover', position: 'centre' })
.png({ compressionLevel: 9 })
.toFile(path.join(tempDir, spec.name));
generatedSizes.push({ size: spec.size, filename: spec.name });
}
// Build site.webmanifest
const manifestJson = {
name: '',
short_name: '',
icons: [
{ src: '/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
],
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
};
const manifestStr = JSON.stringify(manifestJson, null, 2);
fs.writeFileSync(path.join(tempDir, 'site.webmanifest'), manifestStr);
// Build HTML tags snippet
const htmlTags = [
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">',
'<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">',
'<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">',
'<link rel="manifest" href="/site.webmanifest">',
].join('\n');
fs.writeFileSync(path.join(tempDir, 'favicon-tags.html'), htmlTags);
// Create ZIP archive
const zipName = `favicon_${timestamp}.zip`;
const zipPath = path.join(RESULTS_DIR, zipName);
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
archive.directory(tempDir, false);
archive.finalize();
});
// Schedule cleanup of temp dir and zip
scheduleCleanup(tempDir);
scheduleCleanup(zipPath);
log.info('Favicon generated', { timestamp, sizes: generatedSizes.length });
res.json({
downloadUrl: `/favicon/download/${zipName}`,
sizes: generatedSizes,
htmlTags,
manifestJson: manifestStr,
});
} catch (err) {
log.error('Favicon generation failed', { error: err.message });
fs.rm(tempDir, { recursive: true, force: true }, () => {});
res.status(500).json({ error: 'Ошибка генерации favicon: ' + err.message });
}
});
// ── GET /download/:filename ───────────────────────────────────────────────────
router.get('/download/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
// Allow only favicon zip files to prevent path traversal
if (!/^favicon_\d+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Недопустимое имя файла' });
}
const filePath = path.join(RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' });
}
res.download(filePath, 'favicon-pack.zip');
});
// ── Multer error handler ──────────────────────────────────────────────────────
router.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'Файл слишком большой. Максимум 5 МБ.' });
}
if (err.message === 'Допустимы только изображения') {
return res.status(415).json({ error: err.message });
}
next(err);
});
module.exports = router;