174 lines
6.9 KiB
JavaScript
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;
|