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.

Endpoints concernés. Voir API REST pour le détail : 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.

FamilleExtensions acceptéesCas d'usage
Images raster.jpg, .jpeg, .png, .gif, .webp, .avifPhotos, captures, vignettes d'articles, illustrations de pages
Images vectorielles.svgLogos, icônes, pictogrammes
Vidéos.mp4, .webm, .ogv, .movArrière-plans hero, vidéos illustratives, démos produit
Attention SVG. Les SVG sont acceptés mais peuvent contenir du JavaScript inline. La sérialisation HTML statique générée par buildSite() ne ré-exécute pas ces scripts côté serveur, mais ils s'exécuteront chez le visiteur final si le navigateur charge le SVG via <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'uploadTaille maximaleMiddleware
Images5 MouploadImage
Vidéos & médias mixtes50 MouploadMedia
Templates HTML (pages, articles, cocons, layouts)1 MouploadHtml

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.

Capability requise. L'upload nécessite la capability 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 :

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 :

Suppression et liens cassés. Aucun garde-fou ne vérifie qu'un média est référencé par une page publiée avant suppression. Si un fichier est supprimé alors qu'il est utilisé dans une page, le rendu HTML statique affichera une image cassée. Penser à rebuilder le site après ménage (voir Build & Publication) pour repérer les liens morts.

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.

EndpointVerbeRôle
/api/cocon/mediaGETLister les médias du cocon (filtré par groupe)
/api/cocon/mediaPOSTAjouter un média à un cocon (upload simple)
/api/cocon/media/:idPUTModifier les métadonnées d'un média
/api/cocon/media/:idDELETESupprimer 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 :

Ne jamais désactiver le SSRF guard. Même en développement local, l'absence de garde rend l'instance vulnérable à toute future extension qui exposerait un endpoint de "fetch d'image par URL". Voir Sécurité pour la liste complète des protections.

Bonnes pratiques opérationnelles

Récapitulatif sécurité

VecteurProtection 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 fichierSlugification + suppression des séparateurs
Collision de nomSuffixe horodaté ajouté au slug
SSRF sur téléchargement banque d'imageWhitelist hostnames + blocage IP privées + timeout
Suppression non autoriséerequireRole + requireCapability media
XSS via SVG inlineServi 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é.