Médiathèque
La médiathèque du Labo du Yeti centralise l'ensemble des fichiers binaires utilisés dans le CMS : images de pages, vignettes d'articles, médias illustratifs des cocons SEO, vidéos d'arrière-plan, icônes et illustrations. Elle se découpe en deux couches : un service serveur (backend/src/services/media-service.js) qui gère le stockage disque et les opérations CRUD, et une interface React WordPress-like côté frontend qui propose grille, drawer de détails, drag-and-drop et filtres temporels. Un second service (backend/src/services/image-service.js) complète l'ensemble en interrogeant les banques d'images externes (Unsplash, Pexels, Pixabay) et en téléchargeant localement les fichiers retournés pour éviter toute dépendance à des URL signées expirables.
GET/POST/DELETE /api/media pour la médiathèque globale, et GET/POST/PUT/DELETE /api/cocon/media pour la médiathèque dédiée d'un cocon SEO.Architecture du stockage
Tous les fichiers uploadés sont écrits sur disque dans le dossier uploads/ à la racine du projet. Ce dossier est exclu du dépôt Git (cf. Déploiement) et doit être sauvegardé séparément. Aucune base de données n'enregistre la liste des médias : c'est le filesystem qui fait foi. À chaque requête de listing, le service relit le contenu du dossier, calcule les métadonnées (poids, mtime, dimensions le cas échéant) et renvoie la liste triée.
Ce choix est volontairement simple : la médiathèque reste cohérente avec le contenu réel servi par le serveur web Plesk, qui sert directement uploads/ en statique. Pas de risque d'orphelin DB : si un fichier est supprimé manuellement, il disparaît automatiquement de la médiathèque sans intervention.
saveImage(buffer, filename)
Le cœur du service est la fonction saveImage(buffer, filename). Elle prend un buffer binaire (issu d'un upload Multer ou d'un téléchargement HTTP), normalise le nom de fichier d'origine, génère un slug unique et écrit le fichier dans uploads/. Le slug est dérivé du nom d'origine (slugifié, accents supprimés, espaces remplacés par des tirets) auquel est ajouté un suffixe horodaté pour éviter toute collision en cas d'upload simultané de deux fichiers portant le même nom.
L'extension est conservée telle quelle pour préserver le type MIME servi par le serveur web. Si un utilisateur upload Photo de profil.JPG, le fichier final sera par exemple photo-de-profil-1717234567890.jpg. La fonction retourne le nom de fichier final, qui devient la clé canonique exploitée par toutes les URL publiques (/uploads/photo-de-profil-1717234567890.jpg).
// Pseudo-signature exposée par media-service.js
const filename = await saveImage(buffer, 'Photo de profil.JPG');
// → 'photo-de-profil-1717234567890.jpg'
const publicUrl = `/uploads/${filename}`;
Types de fichiers acceptés
La médiathèque accepte deux familles principales : les images (statiques et vectorielles) et les vidéos. Le filtrage est appliqué côté Multer (whitelisting d'extensions) avant même qu'un buffer ne soit instancié, ce qui évite tout traitement inutile sur un fichier invalide.
| Famille | Extensions acceptées | Cas d'usage |
|---|---|---|
| Images raster | .jpg, .jpeg, .png, .gif, .webp, .avif | Photos, captures, vignettes d'articles, illustrations de pages |
| Images vectorielles | .svg | Logos, icônes, pictogrammes |
| Vidéos | .mp4, .webm, .ogv, .mov | Arrière-plans hero, vidéos illustratives, démos produit |
<object>. Limiter l'upload aux SVG produits par les designers de confiance.Limites de taille
Trois plafonds distincts sont appliqués selon le type de fichier, configurés au niveau des middlewares Multer pour bloquer les uploads avant tout traitement disque :
| Type d'upload | Taille maximale | Middleware |
|---|---|---|
| Images | 5 Mo | uploadImage |
| Vidéos & médias mixtes | 50 Mo | uploadMedia |
| Templates HTML (pages, articles, cocons, layouts) | 1 Mo | uploadHtml |
Ces plafonds sont volontairement stricts pour deux raisons : limiter la pression sur le quota disque Plesk (les uploads sont la première source de saturation) et garantir des performances acceptables côté front (un visiteur sur connexion mobile n'a aucune raison de télécharger un PNG de 30 Mo). Si une vidéo de fond dépasse 50 Mo, la bonne pratique est de la ré-encoder avec un préréglage web (CRF 28, audio AAC 96 kbps) avant upload.
Interface utilisateur de la médiathèque
L'écran /media du backoffice React reproduit l'ergonomie de la médiathèque WordPress moderne. Trois zones structurent l'écran : la barre d'outils (recherche, filtres mois/année, bouton d'upload), la grille de vignettes centrale, et le drawer latéral qui s'ouvre au clic sur un média.
Grille responsive
Les vignettes sont disposées en grille CSS responsive, en carré 1:1, avec un nombre de colonnes qui s'adapte à la largeur du conteneur (auto-fill + minmax(160px, 1fr)). Chaque vignette affiche un aperçu (image décodée, ou icône générique pour vidéos et SVG), un overlay au survol avec le nom du fichier, et un état de sélection lorsqu'on clique dessus.
La grille est virtualisée pour rester fluide même avec plusieurs centaines de médias. Pour les vidéos, c'est la première frame qui sert de poster (extraite côté client via <video preload="metadata">), évitant un coûteux traitement serveur.
Drag-and-drop sur la grille
La grille entière fait office de drop zone. L'utilisateur peut faire glisser un ou plusieurs fichiers depuis son explorateur directement sur la grille pour les uploader. Un overlay visuel apparaît dès qu'un dragover est détecté sur la fenêtre (dragenter bubble jusqu'au document) et indique "Déposez vos fichiers pour les ajouter à la médiathèque".
L'upload est séquentiel (un fichier à la fois) pour conserver une barre de progression cohérente et éviter de saturer la connexion. Chaque fichier est validé client-side (extension + taille) avant l'envoi, puis posté en multipart/form-data sur POST /api/media. La grille se rafraîchit à la volée à chaque succès.
media et l'un des rôles owner, admin, webmaster ou editor. Les viewer n'ont pas accès à la médiathèque. Voir Authentification & RBAC.Filtres et recherche
Au-dessus de la grille, trois contrôles permettent de filtrer rapidement :
- Filtre Année : liste déroulante construite dynamiquement à partir des dates de modification des fichiers présents. Seules les années qui contiennent au moins un média apparaissent dans le menu.
- Filtre Mois : même logique, déduit du contenu réel. Les mois sans contenu ne sont jamais proposés, ce qui évite à l'utilisateur de cliquer dans le vide.
- Recherche par nom : champ texte à filtrage instantané (debounce 200 ms), insensible à la casse et aux accents, qui filtre la grille sur le nom de fichier.
Les trois filtres se combinent (ET logique) et sont appliqués côté client sur la liste retournée par GET /api/media, ce qui rend l'interaction instantanée sans aller-retour serveur.
Drawer de détails
Un clic sur une vignette ouvre un drawer latéral à droite, dans l'esprit du panneau "Détails du média" de WordPress. Il affiche :
- Un aperçu agrandi (image native ou lecteur vidéo HTML5 pour les
.mp4/.webm/.ogv/.mov). - Nom du fichier (renommage non supporté pour préserver les liens des pages publiées).
- Dimensions en pixels pour les images raster, calculées au chargement.
- Poids en Ko ou Mo selon la taille.
- Date d'upload (mtime du fichier, formatée en français via
lib/datetime.js). - URL publique copiable : bouton "Copier" qui place l'URL absolue (
https://domaine.tld/uploads/...) dans le presse-papier. - Bouton Ouvrir : nouvel onglet avec le fichier en taille réelle.
- Bouton Supprimer : appelle
DELETE /api/media/:nameavec confirmation modale. La suppression est immédiate et définitive ; aucune corbeille n'est implémentée.
Médiathèque dédiée du cocon SEO
Les cocons sémantiques (voir Cocon SEO) disposent d'une médiathèque propre, isolée de la médiathèque globale. Les médias y sont attachés à un cocon_group_id spécifique et listés via listCoconMedia(groupId) dans cocon-service.js.
L'objectif est triple : éviter la pollution de la médiathèque principale par les illustrations massivement générées en batch (un cocon de 50 pages peut produire 50 vignettes), garder un contexte visuel propre à chaque thématique, et permettre une suppression en cascade lorsqu'on détruit un groupe sans toucher au reste du contenu du site.
| Endpoint | Verbe | Rôle |
|---|---|---|
/api/cocon/media | GET | Lister les médias du cocon (filtré par groupe) |
/api/cocon/media | POST | Ajouter un média à un cocon (upload simple) |
/api/cocon/media/:id | PUT | Modifier les métadonnées d'un média |
/api/cocon/media/:id | DELETE | Supprimer un média du cocon |
image-service : recherche externe
Le service backend/src/services/image-service.js sert d'abstraction unifiée au-dessus des trois banques d'images publiques supportées : Unsplash, Pexels et Pixabay. Il est exploité par le builder IA (suggestion d'illustrations pour les pages générées), par le module blog (featured image automatique) et par le cocon SEO (vignettes par variable).
searchImage()
La fonction searchImage(query, opts) interroge le provider actif (configuré dans /settings/images), parse la réponse, normalise les résultats au format commun { url, thumb, author, source, width, height } et renvoie une liste paginée. Le provider est sélectionné selon les API keys configurées et le préréglage utilisateur (provider par défaut + fallback automatique en cas d'erreur 4xx/5xx).
searchImageProgressive()
Lorsqu'une page ou un cocon nécessite plusieurs visuels en parallèle (par exemple les 12 enfants d'un cocon, chacun avec sa propre image), searchImageProgressive() émet une recherche par item puis télécharge localement le fichier sélectionné via downloadFeaturedLocally(). Ce téléchargement est essentiel : les URL retournées par Unsplash et Pexels sont des URL signées qui expirent au bout de quelques heures ou jours selon le provider. Sans téléchargement local, le HTML publié afficherait des images cassées dès l'expiration de la signature.
Une fois téléchargé, chaque fichier est enregistré dans uploads/ via saveImage() (même pipeline que les uploads manuels) et le HTML généré référence l'URL locale stable /uploads/.... L'attribution de l'auteur (Unsplash exige un crédit) est conservée en méta et peut être affichée dans le footer du site.
SSRF Guard sur downloadFeaturedLocally
Le téléchargement HTTP d'une URL distante depuis le serveur est une surface d'attaque SSRF (Server-Side Request Forgery) : un attaquant qui pourrait injecter une URL arbitraire pourrait forcer le serveur à interroger des ressources internes (http://localhost:9200 sur un Elasticsearch, http://169.254.169.254 sur un metadata service cloud, etc.).
Pour parer cette menace, downloadFeaturedLocally() applique un SSRF guard avant toute requête sortante :
- Validation du schéma : seuls
https://ethttp://sont autorisés. - Whitelist de hostnames : la résolution DNS doit pointer vers un domaine connu (
images.unsplash.com,images.pexels.com,cdn.pixabay.com, etc.). - Blocage des IP privées et de loopback :
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1,fc00::/7. - Timeout court (par défaut 10 s) et taille maximale en streaming pour respecter le plafond image (5 Mo).
Bonnes pratiques opérationnelles
- Nommer les fichiers en amont : le slug repris est dérivé du nom d'origine, donc
illustration-hero-accueil.jpgdonnera une URL bien plus lisible qu'IMG_4827.JPG. - Convertir en WebP via l'extension
webp-auto-converterpour économiser 30 à 50 % de bande passante sans perte perceptible (voir Extensions). - Sauvegarder
uploads/régulièrement : le dossier n'est pas versionné. Une routine de backup Plesk vers un stockage distant (S3, B2) est fortement recommandée. - Préférer les URL locales dans les pages publiées plutôt que les URL externes :
searchImageProgressive()le fait automatiquement, mais un éditeur qui colle une URL Unsplash brute dans le builder verra l'image disparaître à la première expiration. - Surveiller le poids global de
uploads/: la commandedu -sh uploads/en SSH Plesk donne un aperçu rapide. Au-delà de quelques Go, prévoir un externalisation vers un CDN ou un bucket objet.
Récapitulatif sécurité
| Vecteur | Protection en place |
|---|---|
| Upload de fichier exécutable (PHP, JS, EXE) | Whitelist d'extensions stricte côté Multer |
| Taille démesurée (DoS disque) | Plafonds 5 Mo / 50 Mo / 1 Mo selon type |
| Path traversal via nom de fichier | Slugification + suppression des séparateurs |
| Collision de nom | Suffixe horodaté ajouté au slug |
| SSRF sur téléchargement banque d'image | Whitelist hostnames + blocage IP privées + timeout |
| Suppression non autorisée | requireRole + requireCapability media |
| XSS via SVG inline | Servi en statique par Plesk, à utiliser avec parcimonie |
L'ensemble de la médiathèque s'inscrit dans la même logique que le reste du CMS : simplicité opérationnelle, filesystem comme source de vérité, garde-fous serrés aux portes d'entrée. Aucune complexité inutile, mais aucun raccourci sur la sécurité.