316 lines
12 KiB
JavaScript
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;
|