Initial commit
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user