Files
crowdlending-app/backend/src/routes/icons.js
T
Olivier CROGUENNEC 48ed7fe65e Initial commit
2026-06-13 14:57:15 +02:00

316 lines
12 KiB
JavaScript

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 <svg>
out = out.replace(
/(<svg\b[^>]*)\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 <rect> elements at origin covering the canvas
out = out.replace(/<rect(\s[^>]*)?\/?>/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 <path> elements covering the canvas
// (raster-trace backgrounds from design tools like GIMP/Inkscape export)
out = out.replace(/<path\b[^>]*\/?>/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;