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 = [ '', '', '', '', ].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;