import { Router } from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import db from '../db/index.js'; import { requireAdmin } from '../middleware/auth.js'; import sharp from 'sharp'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const iconsDir = path.resolve(__dirname, '../../../data/icons'); const historyDir = path.resolve(iconsDir, 'history'); fs.mkdirSync(iconsDir, { recursive: true }); fs.mkdirSync(historyDir, { recursive: true }); // ── Suppression fond blanc SVG ──────────────────────────────────────────────── function isNearWhite(color) { if (!color) return false; const c = color.trim().toLowerCase(); if (c === 'white' || c === 'snow') return true; const s3 = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/); if (s3) { return parseInt(s3[1] + s3[1], 16) >= 240 && parseInt(s3[2] + s3[2], 16) >= 240 && parseInt(s3[3] + s3[3], 16) >= 240; } const s6 = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); if (s6) { return parseInt(s6[1], 16) >= 240 && parseInt(s6[2], 16) >= 240 && parseInt(s6[3], 16) >= 240; } const rgb = c.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); if (rgb) { return parseInt(rgb[1]) >= 240 && parseInt(rgb[2]) >= 240 && parseInt(rgb[3]) >= 240; } return false; } function getFillFromElement(str) { const fa = str.match(/\bfill\s*=\s*["']([^"']*)["']/i); if (fa) return fa[1]; const sa = str.match(/\bstyle\s*=\s*["']([^"']*)["']/i); if (sa) { const fm = sa[1].match(/(?:^|;)\s*fill\s*:\s*([^;]+)/i); if (fm) return fm[1].trim(); } return null; } function pathCoversCanvas(d, vbW, vbH) { if (!vbW || !vbH) return true; const nums = (d.match(/[\d.]+/g) || []).map(parseFloat); const hasNearZero = nums.some(v => v <= 5); const hasMaxX = nums.some(v => Math.abs(v - vbW) <= 5); const hasMaxY = nums.some(v => Math.abs(v - vbH) <= 5); return hasNearZero && hasMaxX && hasMaxY; } function stripSvgBackground(svgContent) { let out = svgContent; // 1. Remove background-color from style="" on out = out.replace( /(]*)\sstyle="([^"]*)"/i, (_, tag, style) => { const cleaned = style.split(';') .filter(s => !/^\s*background(-color)?\s*:/i.test(s)) .join(';').replace(/^;+|;+$/g, ''); return cleaned ? `${tag} style="${cleaned}"` : tag; } ); // 2. Remove enable-background (Adobe Illustrator artifact) out = out.replace(/\s+enable-background="[^"]*"/gi, ''); // 3. Parse viewBox for canvas coverage check const vbMatch = out.match(/\bviewBox\s*=\s*["']\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i); const vbW = vbMatch ? parseFloat(vbMatch[1]) : 0; const vbH = vbMatch ? parseFloat(vbMatch[2]) : 0; // 4. Remove near-white elements at origin covering the canvas out = out.replace(/]*)?\/?>/gis, (match) => { const fill = getFillFromElement(match); if (!fill || !isNearWhite(fill)) return match; const x = match.match(/\bx\s*=\s*["']?([^"'\s>]+)/i); const y = match.match(/\by\s*=\s*["']?([^"'\s>]+)/i); if ((x && parseFloat(x[1]) > 5) || (y && parseFloat(y[1]) > 5)) return match; const w = match.match(/\bwidth\s*=\s*["']?([^"'\s>]+)/i); const h = match.match(/\bheight\s*=\s*["']?([^"'\s>]+)/i); if (!w || !h) return match; return ''; }); out = out.replace(/<\/rect>/gi, ''); // 5. Remove near-white elements covering the canvas // (raster-trace backgrounds from design tools like GIMP/Inkscape export) out = out.replace(/]*\/?>/gis, (match) => { const fill = getFillFromElement(match); if (!fill || !isNearWhite(fill)) return match; const dAttr = match.match(/\bd\s*=\s*["']([^"']*?)["']/is); if (!dAttr) return match; if (pathCoversCanvas(dAttr[1], vbW, vbH)) return ''; return match; }); out = out.replace(/<\/path>/gi, ''); out = out.replace(/\n{3,}/g, '\n\n'); return out; } // ── Retraitement post-upload ─────────────────────────────────────────────────── // Retourne le chemin final du fichier (peut changer si JPG/WebP converti en PNG) async function processUploadedFile(filePath) { const ext = path.extname(filePath).toLowerCase(); // ── SVG : suppression fond blanc en pur texte ────────────────── if (ext === '.svg') { try { const original = fs.readFileSync(filePath, 'utf8'); const cleaned = stripSvgBackground(original); if (cleaned !== original) fs.writeFileSync(filePath, cleaned, 'utf8'); } catch (e) { console.warn('[icons] stripSvgBackground failed:', e.message); } return filePath; } // ── Raster (PNG / JPG / WebP) : fond blanc → transparent via sharp ── if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { // La transparence nécessite PNG — on convertit si besoin const pngPath = filePath.replace(/\.(jpg|jpeg|webp|png)$/i, '.png'); try { const { data, info } = await sharp(filePath) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); // Rendre transparents les pixels blanc ou quasi-blanc (seuil > 240/255) for (let i = 0; i < data.length; i += 4) { if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) { data[i + 3] = 0; } } await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 }, }).png({ compressionLevel: 8 }).toFile(pngPath); // Supprimer l'original s'il a changé d'extension if (pngPath !== filePath) fs.unlinkSync(filePath); return pngPath; } catch (e) { console.warn('[icons] sharp processing failed:', e.message); return filePath; // garder le fichier original en cas d'erreur } } return filePath; } const router = Router(); // ── Multer ──────────────────────────────────────────────────────────────────── const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, iconsDir), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.svg'; const name = req.params.name || req.body?.name || 'icon'; cb(null, `icon_${name}_${Date.now()}${ext}`); }, }); const upload = multer({ storage, limits: { fileSize: 2 * 1024 * 1024 }, // 2 Mo max fileFilter: (_req, file, cb) => { const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp']; if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true); else cb(new Error('Format non supporté — SVG, PNG, JPG ou WebP uniquement')); }, }); // ── Validation slug ─────────────────────────────────────────────────────────── function isValidSlug(s) { return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(s) && s.length <= 64; } // ── GET /api/icons — liste toutes les icônes (authentifié) ─────────────────── router.get('/', (req, res) => { const rows = db.prepare(` SELECT id, name, filename, description, created_at, updated_at FROM app_icons ORDER BY name `).all(); res.json(rows); }); // ── GET /api/icons/:name — détail d'une icône ──────────────────────────────── router.get('/:name', (req, res) => { const icon = db.prepare('SELECT * FROM app_icons WHERE name = ?').get(req.params.name); if (!icon) return res.status(404).json({ error: 'Icône introuvable' }); res.json(icon); }); // ── GET /api/icons/:name/history — historique des versions ────────────────── router.get('/:name/history', requireAdmin, (req, res) => { const icon = db.prepare('SELECT id FROM app_icons WHERE name = ?').get(req.params.name); if (!icon) return res.status(404).json({ error: 'Icône introuvable' }); const rows = db.prepare(` SELECT id, filename, replaced_at FROM app_icons_history WHERE icon_id = ? ORDER BY replaced_at DESC `).all(icon.id); res.json(rows); }); // ── POST /api/icons — créer une nouvelle association nom/image ─────────────── router.post('/', requireAdmin, upload.single('file'), async (req, res, next) => { try { const name = (req.body.name || '').trim().toLowerCase(); const description = (req.body.description || '').trim() || null; if (!name) return res.status(400).json({ error: 'Le nom est requis' }); if (!isValidSlug(name)) return res.status(400).json({ error: 'Nom invalide — lettres minuscules, chiffres et tirets uniquement' }); if (!req.file) return res.status(400).json({ error: 'Fichier requis' }); // Renommer le fichier avec le bon nom maintenant qu'on a le slug const ext = path.extname(req.file.originalname).toLowerCase() || '.svg'; const newFilename = `icon_${name}_${Date.now()}${ext}`; fs.renameSync(req.file.path, path.join(iconsDir, newFilename)); const finalPath = await processUploadedFile(path.join(iconsDir, newFilename)); const finalFilename = path.basename(finalPath); const row = db.prepare(` INSERT INTO app_icons (name, filename, description) VALUES (?, ?, ?) RETURNING * `).get(name, finalFilename, description); res.status(201).json(row); } catch (err) { if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { if (req.file) fs.unlinkSync(req.file.path).catch?.(() => {}); return res.status(409).json({ error: `Le nom "${req.body?.name}" existe déjà` }); } next(err); } }); // ── PUT /api/icons/:name — remplacer l'image (archive l'ancienne) ──────────── router.put('/:name', requireAdmin, upload.single('file'), async (req, res, next) => { try { const icon = db.prepare('SELECT * FROM app_icons WHERE name = ?').get(req.params.name); if (!icon) return res.status(404).json({ error: 'Icône introuvable' }); if (!req.file) return res.status(400).json({ error: 'Fichier requis' }); const ext = path.extname(req.file.originalname).toLowerCase() || '.svg'; const newFilename = `icon_${icon.name}_${Date.now()}${ext}`; fs.renameSync(req.file.path, path.join(iconsDir, newFilename)); const finalPath = await processUploadedFile(path.join(iconsDir, newFilename)); const finalFilename = path.basename(finalPath); const doReplace = db.transaction(() => { // Archiver l'ancienne version db.prepare(` INSERT INTO app_icons_history (icon_id, filename, replaced_at) VALUES (?, ?, datetime('now')) `).run(icon.id, icon.filename); // Mettre à jour l'entrée principale return db.prepare(` UPDATE app_icons SET filename = ?, updated_at = datetime('now') WHERE id = ? RETURNING * `).get(finalFilename, icon.id); }); const updated = doReplace(); // Supprimer les fichiers d'historique au-delà de 10 versions const old = db.prepare(` SELECT id, filename FROM app_icons_history WHERE icon_id = ? ORDER BY replaced_at DESC LIMIT -1 OFFSET 10 `).all(icon.id); for (const o of old) { fs.unlink(path.join(iconsDir, o.filename), () => {}); db.prepare('DELETE FROM app_icons_history WHERE id = ?').run(o.id); } res.json(updated); } catch (err) { next(err); } }); // ── PATCH /api/icons/:name — modifier description uniquement ───────────────── router.patch('/:name', requireAdmin, (req, res) => { const { description } = req.body; const updated = db.prepare(` UPDATE app_icons SET description = ?, updated_at = datetime('now') WHERE name = ? RETURNING * `).get(description ?? null, req.params.name); if (!updated) return res.status(404).json({ error: 'Icône introuvable' }); res.json(updated); }); export default router;