Audit du code source · CMS ClusTraly
Toutes les fonctionnalités, avec leur fonctionnement exact
Catalogue complet et corrigé, reconstruit à partir du code réel (contrôleurs, services, vues). Chaque ligne décrit précisément le mécanisme sous-jacent étapes, options, stockage, sécurité et cas limites.
Clustraly est un CMS éditorial auto-hébergeable en PHP : ce catalogue recense l'intégralité de ses fonctionnalités, module par module, avec leur fonctionnement réel. Retour à l'accueil · voir les 22 modules.
22
modules
398
fonctionnalités documentées
11
modules majeurs ajoutés
🆕 Nouvelles fonctionalités
Contenu
📝 Articles
23 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Liste, filtres et file de relecture | ListerFiltrerPaginerTrierScoper |
Index paginé à 20 articles/page, triés du plus récent au
plus ancien. • Filtre de statut (tous, brouillon, publié,
planifié, archivé) et filtre de workflow éditorial via
?workflow=in_review affichant un badge de
comptage en direct de la file de relecture. • Chaque ligne
montre des cercles de score SEO et de citabilité GEO, le
nombre de vues et un avertissement « relecture nécessaire
» (review_needed). • Les auteurs sans la
permission articles.edit_others ne voient que
leurs propres articles (scoping au niveau objet). •
L'indentation hiérarchique s'appuie sur
parent_id pour refléter l'arborescence.
|
| Création d'article et choix de mode | CréerChoisir modePré-remplir (voix) |
Le formulaire de création propose au choix un mode Manuel
ou un mode Assisté par IA (assistant/wizard). • Le
paramètre ?title= permet un pré-remplissage
du titre depuis une saisie vocale, avec une liste blanche
de valeurs autorisées. • Un interrupteur « Full-Auto »
enchaîne toutes les étapes sans validation intermédiaire.
• Le mode manuel ouvre directement l'éditeur riche et les
panneaux latéraux (SEO, publication, cocon).
|
| Persistance (enregistrement et mise à jour) | EnregistrerMettre à jourAssainirGénérer slug |
À l'enregistrement, un slug unique est dérivé du slug
fourni, à défaut du focus_keyword, à défaut
du titre (SlugService::generateUnique). • Le
HTML est nettoyé par HtmlSanitizer, l'extrait
auto-généré si vide, word_count et
reading_time calculés (≈ mots ÷ 200, min. 1)
et la langue résolue via LanguageResolver. •
published_at est posé au premier passage en «
published ». • En mise à jour, un changement de slug crée
automatiquement une redirection 301
(handleSlugChange) et une version « Before
edit » est prise avant écriture. • Les hooks
content.saving (mutation d'attributs),
content.saved puis, sur transition vers
publié, content.published sont déclenchés.
|
| Assistant IA en 7 étapes (wizard) | Suivre étapesValiderStreamerRégénérer | Assistant guidé: (1) mot-clé cible + langue de rédaction, (2) stratégie de mots-clés (H2 secondaires + mots-clés lexicaux H3/corps, générés par IA et éditables), (3) rédaction du contenu avec aperçu en streaming et statistiques mots/titres, (4) titre et slug avec régénération IA du titre selon instructions, puis étapes image à la une et génération des métas, (7) analyse SEO/GEO (cercles de score + liste de critères). • Chaque étape se valide manuellement, ou l'interrupteur Full-Auto enchaîne le tout sans validation. • Le choix Manuel ou Assisté par IA s'effectue à la création d'un nouvel article. |
| Barre d'outils IA de l'éditeur | RédigerAméliorerRégénérerSuggérer | Barre d'outils IA au-dessus de l'éditeur riche: rédiger ou améliorer le contenu intégral (en streaming), régénérer les métas (meta title + description), générer une image à la une par IA, régénérer le titre (menu de 3 suggestions), régénérer les mots-clés secondaires et lexicaux, régénérer/résumer l'extrait, et suggérer des tags par IA appliqués via des « chips ». • Chaque bouton par champ appelle un point de terminaison IA dédié et réinjecte le résultat dans le champ correspondant. • Ces actions sont ponctuelles (à la demande), distinctes de l'assistant 7 étapes. |
| Dictée vocale | DicterTranscrireInsérer |
La dictée vocale permet de saisir du texte à la voix dans
les champs titre et contenu de l'éditeur. • À la création,
une transcription vocale peut aussi pré-remplir le titre
via le paramètre ?title= (valeurs sur liste
blanche). • Le texte dicté est inséré dans le champ ciblé
pour être ensuite édité normalement.
|
| Générateur d'article IA autonome (SSE) | GénérerStreamerVérifier budget |
Flux dédié /admin/articles/generate qui
produit un article complet par IA
(ArticleGeneratorService). • La génération
est diffusée en Server-Sent-Events (streaming temps réel)
après une vérification du plafond de budget IA. • Les
paramètres incluent le sujet, la langue et le
cocon_type. • Un contrôle
anti-cannibalisation évite de dupliquer l'intention de
recherche d'articles existants.
|
| Autosave (sauvegarde automatique) | Sauvegarder autoHorodater |
Un POST AJAX
/admin/articles/{id}/autosave enregistre en
continu le titre, le contenu et l'extrait, puis renvoie un
horodatage saved_at (HH:MM:SS). • Il applique
les valeurs sur l'article existant et le sauvegarde sans
rechargement de page. • L'autosave sert aussi de battement
de cœur (heartbeat) qui rafraîchit le verrou d'édition
concurrent. • Un contrôle de propriété
(denyIfNotOwner) protège l'accès.
|
| Verrou d'édition concurrent (edit-lock) | AcquérirRafraîchirAvertirLibérer |
À l'ouverture de l'éditeur, un verrou souple est posé sur
l'article (articles.locked_by /
locked_at) via
EditLockService::acquire. • C'est un verrou
de niveau AVERTISSEMENT: un second éditeur voit une
bannière « X édite actuellement cet article » plutôt qu'un
blocage dur. • Le verrou expire automatiquement après un
TTL de 300 secondes s'il n'est pas rafraîchi, si bien
qu'un onglet abandonné ne bloque jamais durablement;
l'autosave et l'update servent de heartbeat pour le
maintenir. • Il est libéré à la publication
(EditLockService::release).
|
| Workflow éditorial (machine à états) | SoumettreAssignerApprouverRejeterPublierRouvrir |
Machine à états distincte du statut de publication:
none/draft →
in_review → approved →
published, avec branche
rejected et réouverture. • Transitions:
soumettre pour relecture (choix d'un relecteur ou «
n'importe lequel »), assigner/réassigner un relecteur,
approuver (commentaire optionnel), rejeter/demander des
changements (commentaire obligatoire), publier un article
approuvé (déclenche content.published, libère
le verrou, pose published_at),
rouvrir/re-soumettre après changements. • Chaque
transition valide l'état source, journalise une ligne
ArticleReview (fil de commentaires +
traçabilité), notifie l'utilisateur concerné et écrit une
entrée d'audit. • Un « publish gate » force les auteurs
sans articles.publish à rester en brouillon;
seuls les rôles disposant de
workflow.review apparaissent comme
relecteurs.
|
Planification de publication (scheduled_at)
|
PlanifierDaterFiltrer |
Un champ scheduled_at (datetime) programme
une date/heure de publication future, stockée telle quelle
(valeur vide → null). • Le statut « scheduled
» dispose de son propre filtre dans la liste des articles.
• Le champ est enregistré à la création comme à la mise à
jour depuis la barre latérale de publication.
|
| Archivage et republication | ArchiverRepublier |
Un article peut être archivé (statut « archived »),
individuellement ou via l'action groupée « archive »
(UPDATE status='archived'), ce qui le retire
de la circulation publiée tout en le conservant; il reste
accessible sous le filtre « archivé ». • La republication
se fait en repassant le statut à « published » (par mise à
jour ou action groupée « publish »), ce qui repose
published_at via
COALESCE(published_at, NOW()) sans écraser
une date existante. • Le cache de l'article est invalidé à
chaque transition.
|
| Actions groupées | PublierBrouillonArchiverSupprimer (lot) |
Depuis la liste, une multi-sélection permet: publier
(status=published,
published_at via COALESCE, déclenche
content.published pour chaque transition
réelle), passer en brouillon, archiver, ou supprimer
(soft-delete de chacun vers la corbeille). • Pour les
auteurs sans articles.edit_others, la
sélection est silencieusement restreinte à leurs propres
articles (ownedArticleIds). • La publication
groupée obéit au même « publish gate » que la publication
unitaire. • Le cache est invalidé pour tous les articles
concernés et chaque opération est journalisée à l'audit.
|
| Suppression et corbeille (soft-delete) | SupprimerEnvoyer en corbeilleRestaurer |
La suppression (DELETE) est un soft-delete restaurable:
TrashService::trash déplace l'article ET tous
ses enregistrements enfants (tags, catégories,
traductions, commentaires, versions, méta SEO) vers la
corbeille sans les effacer, de sorte qu'une restauration
le remet intact. • L'invalidation de cache, le retrait de
l'index de recherche, le hook
content.trashed et l'entrée d'audit sont
gérés par TrashService. • La suppression
définitive n'intervient que depuis la Corbeille ou via le
cron de purge par rétention. • La suppression en masse
passe par la même mécanique pour chaque id sélectionné.
|
| Prévisualisation brouillon en direct | PrévisualiserRendreBloquer indexation |
Un POST /admin/articles/preview rend
l'article dans le vrai gabarit public (vue
blog/show du thème) à partir du titre,
contenu, catégories, tags et image à la une du formulaire,
sans rien persister. • Cela permet de voir un brouillon
non enregistré exactement comme il apparaîtrait en ligne.
• Le rendu force les directives robots
noindex,nofollow pour empêcher toute
indexation.
|
| Liens de prévisualisation tokenisés (partage) | GénérerRégénérerRévoquerPartager |
Génère un lien de partage sans compte pour qu'un relecteur
externe ouvre un brouillon via
/preview/{token}. • Le token brut est une
valeur aléatoire de 256 bits (64 hex) affichée UNE seule
fois; seule son empreinte SHA-256 est stockée, donc une
fuite de base ne reconstitue pas de lien fonctionnel. •
Les liens sont à durée de vie limitée (7 jours par
défaut), révocables et limités à une seule entité: générer
un nouveau lien révoque les précédents (un seul lien actif
par entité). • Résolution en lecture seule (réutilisable
jusqu'à expiration ou révocation), garde de propriété
auteur, et purge opportuniste des liens périmés à la
création.
|
| Moteur de score SEO + GEO | AnalyserScorerMarquer à relire |
L'analyseur on-page (ContentAnalyzer) calcule
un score SEO sur 15 critères: mot-clé dans le
titre/H1/intro/gras, densité, secondaire en H2, lexical en
H3, longueur des paragraphes et phrases, mots de
transition, images + attribut alt, longueur du texte, meta
description. • Il produit aussi un score de lisibilité et
un score de citabilité GEO (structure faisant autorité,
réponse directe, données structurées, qualité des sources,
couverture, fraîcheur) avec bonus (FAQ, liens externes,
tableau de données, média riche). • Les résultats sont
persistés (seo_score,
readability_score,
seo_score_details) et
review_needed est positionné quand le score
SEO est inférieur à 50. • Les scores sont restitués sous
forme de cercles et d'une liste de critères dans
l'éditeur.
|
| Métadonnées SEO par contenu | Éditer métasChoisir robotsDéfinir canonical |
La boîte SEO enregistre dans SeoMeta le meta
title, la meta description, les jeux de mots-clés
focus/secondaires/lexicaux (listes séparées par virgules
stockées en JSON), une directive robots (index/noindex,
follow/nofollow), l'URL canonique, ainsi que
og_title et og_description (Open
Graph, articles). • Les métas ne sont persistées que si au
moins un champ SEO est renseigné. • Ces champs alimentent
l'analyseur SEO/GEO et le rendu public.
|
| Taxonomie et auto-tagging | AssignerSynchroniserAuto-taguer | Synchronise les catégories (avec une catégorie primaire désignée) et les tags de l'article. • Les nouveaux tags suggérés par l'IA sont créés à l'enregistrement. • En l'absence de tags choisis, des tags sont auto-générés à partir des mots-clés focus + secondaires. • En l'absence de catégorie choisie, une catégorie est auto-sélectionnée ou auto-créée depuis le mot-clé focus. |
| Champs cocon (topic cluster) | TyperRelierOrdonner |
Métadonnées de silo sémantique reliant l'article à une
structure pilier/cluster: cocon_type (pillar,
cluster ou support), pillar_id (lien vers un
article pilier), parent_id (hiérarchie) et
cocon_order (ordre). • Ces champs structurent
le maillage interne et sont saisis depuis le formulaire
d'édition.
|
| Options de publication | Régler visibilitéÉpinglerAutoriser commentairesChoisir langueImage à la une |
Barre latérale de publication: visibilité public/privé,
bascule is_featured (mise en avant) et
allow_comments (commentaires autorisés,
activé par défaut), choix de la langue du contenu. •
L'image à la une se choisit depuis une modale de la
médiathèque, se génère par IA, ou se retire. • La
planification (scheduled_at) et le statut
sont pilotés depuis la même barre.
|
| Versioning / révisions | SnapshotterRestaurerVerrouillerComparer (diff)Élaguer |
Une version est créée automatiquement à la création («
Initial version ») et avant chaque édition (« Before edit
»). • On peut lister les versions d'une entité (HTML ou
JSON AJAX), restaurer une version l'état courant étant
lui-même snapshotté d'abord (« Before restore to vN »),
donc annulable, avec écriture restreinte aux vraies
colonnes de la table (whitelist anti-injection de nom de
colonne) verrouiller/déverrouiller une version, et
comparer deux versions (diff champ par champ old/new, API
JSON). • L'élagage automatique conserve au plus
max_versions révisions (défaut 20, réglable).
• Des gardes de permission par entité (articles/pages
.view/.edit) et de propriété
auteur s'appliquent.
|
| Hooks d'extension de contenu (plugins) | FiltrerRéagir |
Des points d'extension pour plugins sont déclenchés tout
au long du cycle de vie: le filtre
content.saving permet de muter les attributs
de l'article avant insertion/mise à jour (un retour
non-tableau est ignoré et ne peut casser l'opération),
l'action content.saved après persistance
complète (ligne + taxonomies + méta SEO),
content.published sur la seule transition
vers « publié » (alimente webhooks et cache), et
content.trashed /
content.restored /
content.deleting sur les événements de
corbeille. • Ces hooks permettent aux plugins de réagir
aux événements de contenu sans modifier le cœur.
|
Contenu
📄 Pages, Catégories, Étiquettes, Corbeille
22 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Pages Liste | ListerTrier | GET /admin/pages hydrate toutes les pages via Page::hydrateMany et les trie par sort_order ASC puis title ASC (double clé de tri). La vue admin/pages/index affiche l'ensemble des pages statiques. Contrairement à la liste des articles, la liste des pages n'est pas paginée. Le titre d'écran est traduit via __('Pages'). |
| Pages Création & mise à jour (CRUD) | CréerEnregistrerÉditerMettre à jour | Le formulaire de création (GET /admin/pages/create) accepte un pré-remplissage vocal ?title= whitelisté (trim + coupe à 200 caractères, auto-échappé par la vue). À l'enregistrement le titre est obligatoire (sinon flash d'erreur et retour au formulaire), le slug est rendu unique par SlugService::generateUnique à partir du slug soumis, sinon du focus_keyword, sinon du titre, le contenu HTML est nettoyé par HtmlSanitizer::clean et la langue résolue par LanguageResolver::forContent. Un UUID v4 est généré, puis les attributs traversent le filtre plugin content.saving (un retour non-tableau est ignoré) avant insertion, suivi des actions content.saved et content.published (si publiée d'emblée), d'un audit page.create ou page.update et d'une invalidation cache via CacheService::invalidatePage. Les métadonnées SEO ne sont écrites que si focus_keyword, meta_title ou meta_description sont renseignés, avec review_needed=1 lorsque le score SEO passe sous 50. |
| Pages Redirection 301 au changement de slug | DétecterRediriger | À la mise à jour, si le slug soumis (ou dérivé du focus_keyword) diffère de l'ancien, il est re-unicisé par SlugService::generateUnique en excluant l'id courant, puis SlugService::handleSlugChange crée une redirection 301 automatique de l'ancienne URL vers la nouvelle. Le cache de l'ancienne et de la nouvelle URL est invalidé. published_at n'est posé qu'à la première publication (transition draft vers published) et l'action content.published n'est déclenchée que sur cette transition non-publié vers publié. |
| Pages Templates | ChoisirAppliquer | Chaque page porte un champ template (valeur 'default' par défaut) sélectionnable au formulaire parmi les gabarits disponibles (default ou personnalisé). Le rendu public passe par ThemeView::renderPage avec ce template. L'aperçu brouillon respecte lui aussi le template choisi. Le template détermine la mise en page appliquée à la page statique dans le thème. |
| Pages Hiérarchie, page d'accueil & ordre | RattacherOrdonnerDéfinir l'accueil | Une page se rattache à une page parente via parent_id, et le sélecteur de parent en édition exclut la page elle-même pour éviter l'auto-référence. Le drapeau is_homepage (0 ou 1) désigne la page d'accueil du site. sort_order (entier) fixe l'ordre d'affichage et sert de clé de tri primaire de la liste. La visibilité est public ou private et le statut draft ou published. |
| Pages Actions groupées | PublierRepasser en brouillonSupprimer | POST /admin/pages/bulk applique une action à une sélection d'ids (sinon message « aucun élément sélectionné »). 'publish' passe les pages en published avec published_at = COALESCE(published_at, NOW()) et déclenche content.published pour chaque page qui transitionne réellement (ids capturés au préalable par un SELECT sur status différent de published). 'draft' les repasse en brouillon. 'delete' route chaque page vers la corbeille (soft-delete restaurable) via TrashService::trash. Le cache de toutes les pages affectées est invalidé par slug et un message compte les pages traitées. |
| Pages Mise à la corbeille (soft-delete) | SupprimerRestaurer | DELETE /admin/pages/{id} appelle TrashService::trash('page', id) et renvoie 404 si la page est introuvable. La page et ses données liées (traductions, meta SEO) sont conservées telles quelles pour qu'une restauration la remette intacte, seule la ligne étant retirée immédiatement de l'index de recherche. La suppression définitive et le nettoyage en cascade n'ont lieu que depuis la Corbeille ou le cron de purge. Cache, hook content.trashed et audit sont pris en charge par TrashService. |
| Pages Aperçu brouillon dans le thème public | Prévisualiser | POST /admin/pages/preview rend le contenu non enregistré (titre, contenu nettoyé par HtmlSanitizer, slug, template) dans la vraie mise en page du thème public via ThemeView::renderPage, sans rien persister. Le SeoService force robots=noindex,nofollow et un meta_title suffixé « Preview ». Un fil d'Ariane Accueil puis Titre est injecté. C'est un rendu volatil destiné à la relecture avant sauvegarde. |
| Pages Liens de partage de brouillon (tokens) | GénérerRévoquerPartager | Depuis l'éditeur, PreviewController crée ou révoque un lien public tokenisé, ses routes étant protégées par la permission pages.edit (que le rôle auteur ne possède pas, d'où une garde de propriété nécessaire seulement pour les articles). PreviewTokenService::create génère un token aléatoire de 256 bits (64 caractères hex) dont seul le hash SHA-256 est stocké, avec une durée de vie par défaut de 7 jours (604800 s) et un seul lien actif par entité (les liens antérieurs sont révoqués avant d'en émettre un nouveau, et purgeStale nettoie les jetons révoqués ou expirés de plus d'un jour). L'URL brute /preview/{token} n'est affichée qu'une seule fois via un flash, jamais récupérable ensuite. Le lien reste réutilisable jusqu'à expiration ou révocation (resolve en lecture seule vérifie revoked_at IS NULL et expires_at supérieur à maintenant), la révocation posant revoked_at. |
| Catégories CRUD, métadonnées, hiérarchie & 301 | ListerCréerÉditerMettre à jour | GET /admin/categories liste toutes les catégories (Category::all) et le formulaire propose les racines (Category::roots) comme parents. Le nom est obligatoire et le slug rendu unique par SlugService::generateUnique. Chaque catégorie porte des métadonnées riches: description, image, icon, color, focus_keyword, sort_order et parent_id pour une hiérarchie à N niveaux, le sélecteur de parent en édition ne listant que les racines et excluant la catégorie courante. Si le slug change à la mise à jour, il est re-unicisé (id exclu) et SlugService::handleSlugChange crée une redirection 301 des chemins /category/{ancien} vers /category/{nouveau}. Le cache catégorie (ancien et nouveau slug, plus accueil) est invalidé via CacheService::invalidateCategory à la création et à la mise à jour. |
| Catégories Réorganisation glisser-déposer & re-parentage | Glisser-déposerRéordonnerRe-parenter | PUT /admin/api/sort/categories reçoit un corps JSON de la forme items contenant, pour chaque catégorie, id, sort_order et parent_id. Le contrôleur boucle et exécute, pour chaque item valide (id supérieur à 0), un UPDATE categories SET sort_order=?, parent_id=? WHERE id=?, ce qui permet à la fois de réordonner et de rattacher une catégorie à un nouveau parent (parent_id vide ou null équivaut au niveau racine). La réponse est un JSON success=true. L'opération pilote directement depuis l'interface l'ordre d'affichage et l'imbrication de l'arbre de catégories. |
| Catégories Mise à la corbeille | SupprimerRestaurer | DELETE /admin/categories/{id} appelle TrashService::trash('category', id) et renvoie 404 si absente. Les liens pivot (article_categories), traductions, meta SEO et le rattachement des sous-catégories sont conservés pour qu'une restauration remette la catégorie intacte. La suppression définitive n'intervient que depuis la Corbeille ou le cron de purge, avec alors nettoyage complet des enfants et détachement des sous-catégories au niveau racine (parent_id=NULL). |
| Étiquettes Liste & création inline | ListerCréer en ligneCompter les usages | GET /admin/tags sert d'écran unique: il liste les étiquettes (Tag::all) avec leur nombre d'utilisations et fait aussi office de formulaire de création inline. POST /admin/tags exige un nom (sinon flash d'erreur), génère un slug unique via SlugService::generateUnique et crée l'étiquette avec name, slug, description, focus_keyword, icon et image. Tag::ensureColumns est appelé à la volée pour garantir la présence des colonnes attendues (auto-migration légère du schéma). La création reste sur la page /admin/tags. |
| Étiquettes Édition | ModifierRenommerRe-slugger | PUT /admin/tags/{id} met à jour name, slug, description, focus_keyword, icon et image (404 si introuvable, nom obligatoire). Si le slug change, il est re-unicisé par SlugService::generateUnique en excluant l'id courant. Tag::ensureColumns est de nouveau invoqué avant sauvegarde pour sécuriser le schéma. La modification renvoie vers /admin/tags avec un message de succès. |
| Étiquettes Fusion (anti-doublon / anti-cannibalisation) | FusionnerConsoliderSupprimer la source | POST /admin/tags/merge consolide une étiquette source dans une cible (rejet si source ou cible manquante, ou si elles sont identiques). Les liaisons article_tags sont déplacées de la source vers la cible via UPDATE IGNORE (les doublons, articles déjà taggés avec la cible, sont ignorés), puis les liaisons résiduelles de la source sont supprimées (DELETE FROM article_tags). Enfin l'étiquette source est retirée définitivement via forceDelete() et non le soft-delete destroy, car une fusion consolide l'étiquette et n'est pas une suppression restaurable. Cet outil élimine les doublons de taxonomie et évite la cannibalisation entre étiquettes redondantes. |
| Étiquettes Mise à la corbeille | SupprimerRestaurer | DELETE /admin/tags/{id} appelle TrashService::trash('tag', id). Les liaisons article_tags sont conservées, de sorte qu'une restauration ré-attache l'étiquette à ses articles d'origine. La suppression définitive (avec nettoyage des pivots) n'a lieu que depuis la Corbeille ou le cron de purge. À distinguer de la fusion, qui, elle, supprime définitivement la source consolidée. |
| Corbeille Vue d'ensemble, onglets & compteurs | ConsulterFiltrer par typePaginer | GET /admin/trash est un centre unique de soft-delete avec un onglet par type d'entité (article, page, category, tag, media, comment) et un compteur d'éléments par type (TrashService::counts) plus un total. Si aucun type valide n'est passé en paramètre, l'écran ouvre le premier onglet non vide, sinon le premier type déclaré. Chaque section liste ses lignes supprimées triées par deleted_at décroissant (les plus récemment supprimées d'abord), paginées à 20 par page. La fenêtre de rétention (trash_retention_days, défaut 30) est affichée, et l'accès est protégé par les permissions trash.view (lecture), trash.restore (restauration) et trash.purge (suppression et vidage). |
| Corbeille Mécanique du soft-delete | Mettre à la corbeilleDésindexer | TrashService::trash(type, id) ne récupère qu'une ligne vivante (scopée), appelle model->trash() (qui pose deleted_at) et retire aussitôt l'entité de l'index de recherche via SearchIndex::deleteForEntity, l'index n'étant sinon reconstruit qu'au reindex complet. Aucune donnée enfant n'est touchée lors de la mise à la corbeille, ce qui est précisément la condition d'une restauration à l'identique. L'action déclenche le hook content.trashed, invalide le cache public concerné et journalise un audit type.trash. Elle retourne false si la ligne est absente ou déjà en corbeille. |
| Corbeille Restauration | RestaurerRé-attacherRéindexer | POST /admin/trash/{type}/{id}/restore appelle TrashService::restore, qui recharge la ligne via findTrashed puis model->restore() (efface deleted_at). Comme les enfants (pivots, traductions, meta) n'avaient pas été touchés à la mise en corbeille, l'entité revient intacte avec ses rattachements et réapparaît dans l'index de recherche au prochain reindex. Le hook content.restored est déclenché, le cache invalidé et un audit type.restore enregistré. Un message d'erreur s'affiche si l'élément n'est pas trouvé dans la corbeille. |
| Corbeille Suppression définitive (cascade) | Supprimer définitivementNettoyer en cascade |
DELETE /admin/trash/{type}/{id} appelle
TrashService::forceDelete, qui déclenche d'abord
content.deleting (les plugins peuvent encore lire le
graphe complet), effectue un nettoyage en cascade des
lignes dépendantes (aucune contrainte FK ON DELETE CASCADE
en base) puis model->forceDelete(). Nettoyage en
cascade selon le type: • Page: cocon_nodes détaché, page_translations, content_versions, seo_meta, seo_schemas et index de recherche • Catégorie: article_categories, category_translations, seo_meta, et sous-catégories rebasculées au niveau racine (parent_id=NULL) • Étiquette: article_tags. Le cache est invalidé et un audit type.force_delete consigné; l'action renvoie 404 ou une erreur si la ligne n'est pas présente en corbeille. |
| Corbeille Vider une section | ViderPurger en masse | POST /admin/trash/{type}/empty appelle TrashService::emptyTrash(type), qui récupère tous les ids en corbeille du type (onlyTrashed puis pluck) et applique forceDelete à chacun, avec le même nettoyage en cascade que la suppression individuelle. Le nombre réellement purgé est retourné et affiché dans le message de succès, et un audit trash.empty est consigné. Cette action de vidage est protégée par la permission trash.purge. |
| Corbeille Rétention & purge automatique (cron) | Purger automatiquementAppliquer la rétention | TrashService::purgeExpired(days) est le cron quotidien: il calcule une date de coupe (maintenant moins days multiplié par 86400) et, pour chaque type, force-delete toutes les lignes dont deleted_at est antérieur à cette coupe, avec le nettoyage en cascade complet. La durée de rétention provient du réglage advanced.trash_retention_days (défaut 30 jours, minimum 1). Un audit trash.purge_expired est enregistré dès qu'au moins une ligne est purgée. Ce mécanisme garantit qu'aucun élément supprimé ne subsiste indéfiniment au-delà de la fenêtre de rétention. |
Contenu
🖼️ Médiathèque
15 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Grille de la médiathèque (navigation, filtre par type, recherche, pagination) | ParcourirFiltrerRechercherPaginer |
GET /admin/media affiche une grille paginée de 36 éléments
par page, triés par created_at décroissant (les plus
récents d'abord). • Le filtre par type applique un
whereLike sur mime_type avec le
préfixe choisi suivi de « % » (ex. « image% », « video% »,
« audio% », « application% »), ce qui regroupe images,
vidéos, audio et documents. • La recherche (paramètre q)
fait un whereLike sur
original_name (%terme%). • Le total est
compté sur une copie de la requête AVANT pagination pour
calculer totalPages = ceil(total / 36) ; l'état des
filtres (currentType, search) est renvoyé à la vue pour
réafficher les contrôles.
|
| Détail d'un média (JSON) | ConsulterInspecter | GET /admin/media/{id} renvoie un JSON complet du média enrichi de champs calculés: url publique (getUrl), taille lisible (getHumanSize), icône et couleur d'icône, catégorie de type, et drapeaux is_image, is_video, is_audio, is_pdf. • Les champs title, description, caption, alt_text sont normalisés à chaîne vide si absents. • Le scan d'utilisation (findUsage) est inclus dans data['usage']. • Renvoie 404 si le média est introuvable et 500 (avec journalisation via error_log) en cas d'erreur serveur. |
| Navigateur AJAX pour l'éditeur riche (TinyMCE) | ParcourirSélectionnerPaginer |
GET /admin/media/browse sert l'insertion d'images dans
l'éditeur riche: 20 éléments par page, filtrés par type
(défaut « image ») via whereLike sur
mime_type. • Le total complet est compté AVANT
page() (double clone de la requête) pour que
l'appelant puisse afficher « Page X / Y » et désactiver «
Suivant » sur la dernière page sans deviner. • Chaque item
est renvoyé avec url, human_size, icon et icon_color ; la
réponse porte media, total, page, perPage et totalPages.
|
| Téléversement sécurisé 7 couches de validation | TéléverserValiderBloquerRandomiser le nom |
POST /admin/media/upload applique 7 couches strictes. •
Couche 1: liste blanche d'extensions (images
jpg/jpeg/png/gif/webp/svg/ico/avif, vidéos
mp4/webm/ogg/ogv/mov, audio mp3/wav/oga/m4a/flac,
documents
pdf/doc/docx/xls/xlsx/ppt/pptx/odt/ods/csv/txt/rtf/md,
archives zip/gz/tar, polices woff/woff2/ttf/otf). • Couche
2: liste noire d'extensions dangereuses (php, php3-8,
phtml, phar, asp, jsp, js, exe, sh, bat, htaccess, ini,
sql, etc.). • Couche 3: blocage des doubles extensions
(chaque segment intermédiaire vérifié contre la liste
noire). • Couche 4: MIME réel via finfo,
rejet de application/octet-stream et de tout MIME ne
correspondant pas à l'extension whitelistée. • Couche 5:
getimagesize obligatoire pour les images
(sauf ico) sinon rejet, avec récupération width/height. •
Couche 6: limite de taille (setting
general.upload_max_size, défaut 50 Mo). • Couche 7: nom
randomisé bin2hex(random_bytes(16)) + extension, rangé
sous storage/uploads/AAAA/MM. La même chaîne s'exécute
intégralement sur le remplacement de fichier.
|
| Assainissement SVG (SvgSanitizer) | NettoyerVérifierRejeter |
Couche 5b du téléversement: tout fichier SVG passe par
SvgSanitizer::clean() avant écriture disque.
• Les déclarations <!DOCTYPE> et
<!ENTITY> sont retirées par regex en
amont (parades XXE et « billion-laughs »). • Le DOM est
chargé avec LIBXML_NONET (aucun accès réseau) et
volontairement SANS LIBXML_NOENT (aucune expansion
d'entités). • Les balises actives (script, foreignObject,
iframe, embed, object, animate, animateTransform,
animateMotion, set, handler, listener) sont supprimées via
local-name() (indépendant du namespace), ainsi que les
attributs dangereux: gestionnaires on*, URIs javascript:
ou data:...script dans href/src/xlink:href, et styles
javascript:/expression(). • Défense en profondeur: la
sortie nettoyée est re-parsée et ré-inspectée ; s'il reste
quoi que ce soit de dangereux, le document entier est
rejeté (retour null), l'upload refusé (422) et l'événement
journalisé en catégorie « security ».
|
| Dérivés WebP responsives (300 / 768 / 1200) | GénérerRedimensionnerStocker |
Après un upload ou remplacement d'image raster (hors svg
et ico), generateThumbnails produit trois
dérivés WebP aux largeurs 300, 768 et 1200 px via GD. • Le
redimensionnement utilise
imagecopyresampled avec transparence
préservée (imagealphablending false + imagesavealpha true)
et une qualité WebP de 82. • Si la largeur d'origine est
inférieure ou égale à la largeur cible, l'image source est
réutilisée telle quelle (aucun agrandissement). • Les
fichiers sont nommés {stem}-{largeur}w.webp à côté de
l'original ; les chemins sont enregistrés dans path_300,
path_768 et path_1200 en conservant le préfixe « uploads/
» pour que la srcset résolve correctement /storage/...
(sinon les variantes renverraient 404).
|
| Poster vidéo (extraction de première image) | ExtraireTranscoderStocker |
À l'upload d'une vidéo (mime video/*),
VideoPosterService::generate tente d'extraire
une image de couverture via ffmpeg, invoqué par
proc_open avec un TABLEAU d'arguments (jamais
de shell, donc aucune injection possible), sous timeout
mural de 20 s + proc_terminate. • Il tente d'abord une
image à 1 s (évite une première frame souvent noire),
sinon repli à 0 s. • Le JPEG obtenu est transcodé en WebP
(qualité 82) via GD si disponible, sinon le JPEG est
conservé ; le chemin est stocké dans poster_path. •
Service OPTIONNEL et FAIL-OPEN: si proc_open est
désactivé, si aucun binaire ffmpeg n'est trouvé (chemin
configuré, PATH, puis emplacements courants) ou si
l'extraction échoue, generate renvoie null et l'upload
réussit sans poster. • Sur un remplacement vidéo vers
image, le poster obsolète est effacé (poster_path remis à
null).
|
| Édition des métadonnées | ModifierEnregistrer |
PUT /admin/media/{id} met à jour title, alt_text, caption
et description ; chaque champ retombe sur la valeur
existante s'il n'est pas fourni. • Les valeurs sont
appliquées via fill() puis persistées par
save(), et le média mis à jour est renvoyé en
JSON (404 si introuvable). • À l'upload, alt_text est
renseignable dès le POST et title est pré-rempli
automatiquement avec le nom de fichier d'origine sans son
extension.
|
| Remplacement de fichier (même ID) | RemplacerRevaliderRégénérer | POST /admin/media/{id}/replace remplace le binaire tout en conservant l'ID du média (les références de contenu restent valides). • La chaîne complète de validation en 7 couches (et l'assainissement SVG) est ré-exécutée sur le nouveau fichier. • Les anciens fichiers physiques (principal + dérivés 300/768/1200 + poster) sont supprimés via @unlink. • Un nouveau nom randomisé est généré, les colonnes filename, original_name, mime_type, extension, size, path, width et height sont mises à jour, puis les dérivés WebP et le poster sont régénérés tous réinitialisés pour le nouveau fichier, si bien qu'un remplacement vidéo vers image efface le poster (poster_path = null). |
| Éditeur d'image côté serveur (GD) | PivoterRetournerRecadrerRedimensionner |
POST /admin/media/{id}/edit-image édite l'image en place
via l'extension GD (les formats svg et ico sont refusés).
• Actions supportées: rotate (angle en degrés,
imagerotate appliqué en -angle), flip
(horizontal ou vertical via IMG_FLIP_HORIZONTAL/VERTICAL),
crop (x/y/w/h bornés à des valeurs sûres,
imagecrop), resize (w/h,
imagecreatetruecolor +
imagecopyresampled avec canal alpha
préservé). • La sauvegarde respecte le format d'origine:
imagepng, imagegif,
imagejpeg (qualité 90) ou par défaut
imagewebp (qualité 82). • width, height et
size sont recalculés et enregistrés, les dérivés WebP sont
régénérés, et l'URL renvoyée porte un cache-buster
?v=timestamp pour forcer le rafraîchissement.
|
| Suppression et suppression groupée (corbeille) | SupprimerSélectionnerPurger en différé |
DELETE /admin/media/{id} déplace le média vers la
Corbeille (TrashService::trash): les fichiers
sur disque ET les références article/page sont CONSERVÉS
pour permettre une restauration, et ne sont retirés qu'à
la purge définitive depuis la Corbeille ou par le cron de
rétention (forceDelete). • POST /admin/media/bulk-delete
accepte une liste d'IDs (tableau ou CSV), filtre les
valeurs inférieures ou égales à 0, plafonne à 100 éléments
(anti-abus) et déplace chacun vers la corbeille ; la
réponse renvoie le nombre supprimé, files_removed restant
à 0 puisque la suppression physique est différée.
|
| Scan d'utilisation | AnalyserLocaliser |
GET /admin/media/{id}/usage (et le champ usage de la fiche
détail) recherche où le fichier est effectivement
référencé. • findUsage cible le « stem »
unique (le hash aléatoire du nom de fichier) plutôt que
l'URL complète, afin de détecter aussi bien l'original que
n'importe quel dérivé -300w/-768w/-1200w qui partagent ce
stem ; si le stem fait moins de 8 caractères, repli sur
l'URL complète comme aiguille de recherche. • Il interroge
les tables articles et pages (content LIKE %stem% OU
featured_image = id, avec LIMIT 50 chacune) et renvoie,
par occurrence, le type, l'id, le titre et le lien
d'édition admin. • Échoue silencieusement (usage vide) si
les tables sont absentes.
|
| Générateur de plan thématique (cocon) formulaire et génération IA | OuvrirSaisirGénérer |
GET /admin/thematic (ThematicController) affiche le
formulaire avec les articles piliers existants (cocon_type
= pillar, statut published, triés par titre) et le budget
IA courant (checkBudget) ; redirige vers /admin/ai avec un
flash d'erreur si l'IA n'est pas configurée. • POST
/admin/thematic/generate produit le plan via
AiService::generateThematicPlan à partir d'un
thème (obligatoire, sinon 400), d'une langue (résolue par
LanguageResolver::forGeneration) et d'un
nombre d'articles borné entre 3 et 30. • La réponse du
modèle est débarrassée de ses clôtures ```json par regex
puis décodée en JSON (repli sur {raw:...} si non parsable)
; l'usage est renvoyé (tokens, coût USD, modèle).
|
| Application du plan (brouillons + génération en arrière-plan) | AppliquerCréer les brouillonsEnfiler la génération |
POST /admin/thematic/apply valide que le plan est un
tableau JSON de LISTE (array_is_list les
objets JSON sont rejetés en 400) plafonné à 100 articles.
• ThematicGeneratorService::applyPlan crée
d'abord SYNCHRONEMENT et sans appel IA les coquilles
brouillon: le pilier est traité en premier pour câbler
pillar_id et parent_id sur les clusters/supports ; chaque
coquille reçoit un slug unique, un uuid, un squelette
d'outline avec marqueur de langue invisible (commentaire
HTML) et generation_status = 'pending'. • Chaque mot-clé
de focus est contrôlé par
AntiCannibalizationService: un mot-clé déjà «
possédé » fait sauter l'article (skipped). • Une seule
tâche thematic_generate est ensuite enfilée
dans scheduled_tasks: le worker pseudo-cron écrit UN
article complet par tick (FPM-safe, un appel Claude,
non-piliers d'abord puis pilier), tisse jusqu'à 6 liens
internes (bloc « See also » / « In this series ») et ne
publie JAMAIS automatiquement (statut reste draft). • La
réponse renvoie created, skipped, failed, queued et ids.
|
| Suivi de la génération (polling + SSE) | SonderDiffuser la progression |
GET /admin/thematic/status (poller de apply) reçoit une
liste d'ids et renvoie le décompte par état de
generation_status: total, pending, generating, generated,
failed. • Un endpoint SSE de progression existe
(Sse::open(600)): l'ID de progression est
validé contre le path traversal (regex alphanumérique +
tirets, 1 à 64 caractères) et un fichier cache temporaire
est lu pour émettre des événements
phase/current/total/percent. • Comme la génération réelle
est pilotée par le cron et n'écrit pas ce fichier cache,
l'endpoint émet immédiatement un événement terminal
(status idle/done) plutôt que de tenir la connexion
ouverte pendant les 120 s de timeout.
|
Interactions
💬 Commentaires, Messages de contact, Formulaires, Newsletter
15 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Modération des commentaires (admin) | ListerFiltrerApprouverMarquer spamSupprimerActions groupées |
Liste paginée (20/page) via
GET /admin/comments avec badge du nombre en
attente et total dans l'en-tête. Filtrage par pastille de
statut Tous / En attente / Approuvés / Spam (tout statut
inconnu est ramené à « Tous »). Actions par ligne:
approuver (PUT .../{id}/approve), marquer
spam (PUT .../{id}/spam), suppression douce
vers la Corbeille (DELETE .../{id}) via
TrashService (donc restaurable, non définitive). Actions
groupées approuver / spam / supprimer sur sélection (POST /admin/comments/bulk) avec case « tout sélectionner » gérée en Alpine, plus
lien profond vers l'article parent. Permissions:
comments.view pour lister,
comments.moderate pour toute mutation.
|
| Soumission publique de commentaire (auto-modération) | SoumettreFiltrer anti-spamClassifierLimiter par IP |
POST /comment depuis un article publié, avec
redirection vers #comments portant un drapeau
?comment= de statut; un commentaire n'est
JAMAIS publié directement. Peut être coupé globalement par
antispam.comments_enabled (renvoie 404).
Défenses: honeypot (champ website) et
time-trap signé; un bot est silencieusement stocké en
pending sans fuite de signal. Validation: nom
requis (max 100), contenu requis (max 5000), email
facultatif au format vérifié; rejet si l'article est
inexistant ou non publié; CAPTCHA optionnel si un
fournisseur est activé pour « comments ». Plafond de
fréquence par IP
(antispam.comment_max_per_hour, défaut 5/h)
puis classification: trop de liens
(comment_max_links) ou mot banni
(comment_banned_words) => stocké en
spam, sinon pending; le
webhook/hook comment.created n'est déclenché
que pour les commentaires non-spam. Le rendu public
n'affiche que les commentaires approuvés via
Comment::approvedForArticle().
|
| Boîte de réception des messages de contact (admin) | ListerFiltrerChanger statutSupprimerActions groupéesRépondre |
Liste paginée (20/page) via
GET /admin/contact avec badge non-lus et
total. Filtrage par pastille Tous / Non lus / Lus /
Répondus / Spam et changement de statut par message (PUT .../{id}/status
vers unread/read/replied/spam). Suppression définitive par
ligne (DELETE .../{id}) SANS corbeille,
contrairement aux commentaires. Actions groupées marquer
lu / répondu / spam ou supprimer (POST /admin/contact/bulk) avec « tout sélectionner ». Chaque message affiche
l'adresse IP de l'expéditeur et propose un lien de réponse
rapide mailto: pré-rempli « RE: » avec email
et sujet. Permissions partagées avec les commentaires:
comments.view pour lister,
comments.moderate pour les mutations.
|
| Soumission publique du formulaire de contact | SoumettreValiderPersisterNotifier |
POST /page/contact renvoyant TOUJOURS du JSON
pour la soumission fetch du thème. Honeypot
et time-trap (form_min_seconds): un bot
reçoit un faux succès et rien n'est stocké. Validation nom
/ email (format) / message requis => 422 avec erreurs
par champ; plafonds nom 150, sujet 255, message 5000 et
sujet par défaut si vide. Le message est persisté en
statut unread avec l'IP dans
contact_messages; en cas d'échec DB,
journalisation et réponse 500. Plafond par IP (antispub.contact_max_per_hour
antispam.contact_max_per_hour, défaut 5/h)
renvoyant 429. Une notification email HTML est mise en
file asynchrone vers l'adresse from_email du
site (au mieux, le message n'est jamais perdu) et le
webhook/hook contact.created est déclenché.
|
| Constructeur de formulaires (admin, CRUD) | CréerÉditerSupprimerAjouter/réordonner champsConfigurer livraison/stockage/RGPD |
Liste des formulaires avec nombre de champs, nombre de
soumissions, shortcode et statut (GET /admin/forms); création, édition et suppression complètes (la
suppression détruit aussi toutes les soumissions, les
fichiers téléversés et le dossier d'upload). Schéma de
champs répétable côté client (Alpine) normalisé et validé
côté serveur: 8 types (text, email, tel, textarea, select,
checkbox, radio, file), max 40 champs,
ajout/retrait/déplacement haut-bas. Config par champ:
libellé, nom machine (auto-slug unifié), placeholder,
aide, obligatoire, options (select/radio/checkbox), regex
de validation vérifiée à la compilation (abandonnée si
invalide). Réglages du formulaire: slug (auto-unique via
SlugService), description, statut actif/inactif, message
de succès, bascule de notification email + email de
remplacement, bascule de stockage en base
store_submissions +
retention_days (0 = défaut global), bascule
consentement RGPD require_consent + texte
personnalisé. Génération auto d'UUID +
created_by, journalisation d'audit
form.create / form.update /
form.delete; permissions
forms.view / forms.create /
forms.edit / forms.delete.
|
| Soumissions de formulaire: boîte, export CSV, téléchargement fichiers (admin) | ListerFiltrerChanger statutSupprimerExporter CSVTélécharger fichier |
Boîte par formulaire (GET /admin/forms/{id}/submissions, 25/page) avec valeurs de champs rendues et mise en
évidence visuelle des non-lues + lien
mailto vers l'auteur. Filtrage Tous / Non lus
/ Lus / Spam et bascule de statut par soumission (PUT .../submissions/{sid}/status). Suppression d'une soumission avec ses fichiers
téléversés (DELETE .../submissions/{sid},
audit form.submission_delete) et actions
groupées lu/non-lu/spam ou supprimer (POST .../submissions/bulk). Export CSV de toutes les soumissions avec colonnes de
champs dynamiques + ID/Date/Statut/IP et BOM UTF-8 pour
Excel (GET .../submissions/export).
Téléchargement d'un fichier stocké pour un champ (GET .../submissions/{sid}/file/{field}) avec validation de chemin garantissant que le fichier
reste sous storage/forms (stockage privé).
|
| Formulaires publics (page /form/{slug}, shortcode, pipeline) | AfficherInsérer via shortcodeValiderTéléverserPersisterNotifier |
Page thématisée autonome
GET /form/{slug} (méta
noindex,follow, formulaires actifs
uniquement) et expansion du shortcode
[form slug="x"] ou
[form id=N] dans tout contenu de page/article
(filtre content.render). Rendu par type: file
avec liste accept, groupes checkbox
simple/multiple, radio, select, marqueurs requis, attribut
pattern regex, texte d'aide.
POST /form/{slug} renvoie du JSON en AJAX ou
une page-résultat thématisée avec formulaire re-rendu et
bandeau d'erreur en no-JS (amélioration progressive).
Défenses: honeypot + time-trap (form_min_seconds
/ form_max_age) => faux succès silencieux;
validation par champ (requis, email, motif tel, liste
blanche d'options, regex) => 422; consentement RGPD
imposé si require_consent; CAPTCHA optionnel
pour « forms »; plafond par IP
(antispam.form_max_per_hour, défaut 10/h
=> 429). Upload sécurisé: plafond de taille
(max_size_mb), liste blanche d'extensions,
blocage double-extension et exécutables, reniflage MIME
(rejet html/svg/php/js), nom de fichier aléatoire sous
storage/forms/{id}/Y/m privé. Persistance
(données JSON, nom/email extraits, IP, user-agent, drapeau
+ horodatage de consentement) et incrément de
submission_count si
store_submissions; notification email avec
tableau des champs vers notify_email/email du
site si activée; hook
form.submitted déclenché.
|
| Purge de rétention des soumissions (cron) | PurgerEffacer fichiers |
Tâche cron
cleanup_form_submissions déclenchée par le
PseudoCronMiddleware, appelant
FormService::purgeFormSubmissions(defaultDays). Chaque formulaire peut surcharger le délai global via
son retention_days; la valeur 0 conserve
indéfiniment. Avant la suppression définitive des lignes
hors fenêtre de rétention, les fichiers référencés par ces
lignes sont dissociés (supprimés) du disque. Suppression
permanente et irréversible (pas de corbeille).
|
| Abonnés newsletter (admin) | ListerFiltrerSupprimerExporter CSV |
Liste des abonnés (jusqu'à 200) avec email, nom, statut,
langue, source et date d'inscription (GET /admin/newsletter), et filtrage par puces avec compteurs en direct Tous /
Confirmés / En attente / Désinscrits. Effacement d'un
abonné (POST .../subscribers/{id}/delete,
audit newsletter.subscriber_delete). Export
CSV de tous les abonnés avec BOM UTF-8 (GET /admin/newsletter/export, audit newsletter.export) protégé contre
l'injection de formule (neutralisation CWE-1236: préfixage
des cellules commençant par = + - @).
Permission requise: newsletter.manage.
|
| Campagnes newsletter (admin) | ComposerPrévisualiserInsérer derniers articlesTesterEnvoyer |
Historique des campagnes avec statut Draft/Sending/Sent,
envoyés/total et date (GET /admin/newsletter/campaigns) et vue détail montrant statut, compteurs, dates
créé/envoyé et HTML brut du message (GET .../campaigns/{id}). Composition sujet + corps HTML (GET/POST .../campaigns/compose) avec aide « insérer les derniers articles » qui
pré-remplit le corps d'un digest HTML des 5 articles
publiés les plus récents (?prefill=latest).
Envoi d'un email de test du brouillon courant à une
adresse arbitraire (action=test). Envoi réel
à tous les abonnés confirmés (action=send):
la campagne est persistée puis un message par abonné
confirmé est mis en file, avec garde anti-double-envoi via
revendication d'état draft->sending et bouton désactivé
si 0 confirmé. Chaque message porte un pied de
désinscription automatique et un en-tête List-Unsubscribe
(RFC 8058); audit
newsletter.campaign_send avec le nombre mis
en file. Permission: newsletter.manage.
|
| Inscription publique newsletter (double opt-in) | S'inscrireConfirmerSe désinscrireAnti-spam |
Page publique thématisée
GET /newsletter (avec remontée des flash) et
POST /newsletter/subscribe qui crée un abonné
en attente et envoie un lien de confirmation (JSON en AJAX
ou redirection + flash). Défenses: honeypot + time-trap et
succès neutre pour les bots; anti-énumération via réponse
identique et neutre pour nouveau / en attente / déjà
abonné. Un jeton de confirmation à usage unique est stocké
HACHÉ (SHA-256) avec TTL 48h; le jeton brut n'est envoyé
qu'une fois par email, jamais stocké. Cooldown
anti-bombardement de 120s pour les ré-inscriptions en
attente répétées; horodatage du consentement + IP + source
+ langue enregistrés.
GET /newsletter/confirm/{token} termine le
double opt-in (atomique, usage unique, expiration
vérifiée);
GET /newsletter/unsubscribe/{token} =
désinscription humaine 1-clic (idempotente, jeton stable);
POST /newsletter/unsubscribe/{token} =
one-click RFC 8058 (exempt CSRF, authentifié par jeton,
réponse 200 « OK » minimale). Routes subscribe et
one-click limitées en débit.
|
| Widget d'inscription newsletter | AfficherConfigurerPoster (double opt-in) |
Formulaire de widget auto-suffisant rendu par
NewsletterService::widgetFormHtml() avec
titre / description / bouton configurables, exposé comme
type de widget « newsletter » dans le système de widgets.
Le markup embarque des styles en ligne, un jeton CSRF, le
champ honeypot website, un jeton de temps
signé et un champ caché source=widget. Il se
dépose dans n'importe quelle zone de widget du thème et
poste vers le point d'entrée du double opt-in, réutilisant
donc tout le pipeline anti-spam et de confirmation.
|
| Intégration RGPD export / effacement newsletter | ExporterEffacer par email |
Toutes les lignes d'abonné associées à un email peuvent
être exportées
(NewsletterService::exportForEmail) et
effacées (NewsletterService::deleteForEmail).
Ces opérations sont câblées dans le pipeline de données du
sujet RGPD via RgpdDataService sous la clé de couverture
newsletter_subscriptions (table des abonnés),
assurant à la fois le droit d'accès (portabilité) et le
droit à l'effacement (« droit à l'oubli ») par adresse
email.
|
| Moteur anti-spam (SpamFilterService) | Piéger honeypotSigner/valider time-trapClassifier contenuCompter par IP |
Moteur léger et sans dépendance à 4 couches indépendantes.
Honeypot: toute valeur non vide d'un champ invisible
trahit un bot. Time-trap: un horodatage de rendu est signé
par HMAC-SHA256 avec secret_key (jeton
timestamp:signature); à la soumission on
vérifie la signature par
hash_equals (anti-forge) puis que l'âge est
compris entre minSeconds et
maxAge un POST « à l'aveugle » sans jeton
valide ou trop rapide est rejeté. Classification de
contenu: comptage des liens (motifs
http(s)://, www.,
[url) au-delà de maxLinks, URL
détectée dans le nom d'auteur (url_in_name),
et mots bannis (sous-chaîne insensible à la casse depuis
une liste CSV) => spam avec raisons. Fréquence par IP:
comptage des lignes récentes dans une table sur liste
blanche (contact_messages,
comments, form_submissions) sur
une fenêtre en secondes; en cas d'erreur SQL le compteur
échoue « ouvert » (fail-open) pour ne jamais bloquer un
utilisateur légitime.
|
| CAPTCHA optionnel (CaptchaService) | Détecter configAfficher widgetVérifier côté serveur |
Défi CAPTCHA respectueux de la vie privée, entièrement
piloté par la configuration (config/app.php['antispam']
alimenté par .env), supportant Cloudflare
Turnstile ou hCaptcha. Inerte par défaut: sans fournisseur
ni les deux clés, isConfigured() est faux,
aucun script/widget n'est émis et
verify() passe le formulaire fonctionne alors
sur honeypot + time-trap seuls (dégradation gracieuse).
Activation par formulaire via
enabledFor('comments'/'contact'/'forms')
(antispam.captcha_on_{form}); le widget est
un conteneur div + script externe autorisé en
CSP (pas de code en ligne), le champ de réponse est
cf-turnstile-response ou
h-captcha-response. La vérification serveur
poste le token à l'API siteverify du
fournisseur (cURL avec repli sur contexte de flux, timeout
5s): jeton manquant => échec, et toute panne de
transport échoue « fermé » (fail-closed, journalisée) pour
qu'une panne provoquée ne soit pas une porte dérobée.
|
SEO & Sémantique
🔎 Gestion SEO & Redirections
19 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Tableau de bord SEO (vue d'ensemble) | ConsulterNaviguer |
Page d'accueil admin du SEO (route GET /seo ->
seo.view, SeoController::index) rendue par
app/Views/admin/seo/index.php. Affiche 4
cartes KPI calculées côté serveur (lignes 28-100 du
contrôleur): nombre de redirections actives, statut «
Actif » du sitemap, nombre de types de schémas JSON-LD
distincts, et score SEO moyen 0-100 coloré selon le palier
atteint. Propose des liens rapides vers le gestionnaire de
redirections et vers le /sitemap.xml public. Inclut un
tableau des redirections récentes (source, cible, badge de
type 301 ou 302, compteur de hits).
|
| Top mots-clés focus | ConsulterAnalyser |
Liste les 10 principaux focus keywords agrégés depuis la
table seo_meta, chacun accompagné du nombre
de pages qui le ciblent. Sert d'aperçu de la couverture
éditoriale et met en évidence une éventuelle
sur-représentation d'un même mot-clé (risque de
cannibalisation). Calcul réalisé dans
SeoController::index.
|
| Détection de pages orphelines | DétecterÉditer | Détecte, via une requête dédiée, les pages sur lesquelles aucun lien interne ne pointe. Chaque entrée expose un lien direct vers l'édition de la page concernée pour corriger le maillage interne. Outil de diagnostic de l'architecture de liens, affiché sur le tableau de bord SEO. |
| Éditeur robots.txt inline | ÉditerEnregistrer |
Éditeur Alpine.js intégré au tableau de bord permettant
d'afficher et de modifier le contenu de robots.txt sans
quitter la page. La sauvegarde POST vers /admin/settings
avec tab=seo et persiste le réglage
seo.robots_txt. Fournit une édition rapide du
fichier depuis le hub SEO.
|
| Éditeur de balises méta par entité | ChargerValiderEnregistrer |
Lit/écrit les balises SEO d'une entité via GET
/seo/meta/{entityType}/{entityId} (editMeta,
renvoie le méta en JSON) et POST /seo/meta
(saveMeta), avec whitelist stricte des types
article, page, category, tag, home, archive. Champs gérés:
meta_title, meta_description, meta_keywords, og_title,
og_description, og_type, twitter_card, twitter_title,
twitter_description, canonical_url, robots, focus_keyword,
secondary_keywords. Validation: canonical_url via
FILTER_VALIDATE_URL, robots (combinaisons index, noindex,
follow, nofollow), og_type (article, website, blog,
profile), twitter_card (summary, summary_large_image, app,
player). À l'enregistrement, recalcule et persiste
seo_score + readability_score quand focus keyword et
contenu sont présents, avec une cible de mots par type
(page vs article). Stockage via le modèle
SeoMeta (findForEntity,
findOrCreateForEntity).
|
| Moteur de score SEO (15 critères) | AnalyserNoter |
Analyseur déterministe 100% PHP
(ContentAnalyzer::analyze), sans appel IA et
quasi instantané (<100ms), avec garde-fou anti-DoS
(contenu >500 000 octets ignoré). Évalue 15 critères
totalisant 110 points bruts normalisés sur 0-100: mot-clé
en début de titre (10), longueur de titre 55-65 car. (8),
mot-clé en H1 (8), secondaires en H2 (10), lexicaux en H3
(8), mot-clé dans l'intro 300 premiers car. (8), densité
0,50-2,40% (10), mot-clé en <strong> (5),
paragraphes ≥8 mots (5), mots de transition ≥30% (7),
phrases longues ≤25% (7), présence d'images (5), attribut
alt (4), longueur min. 2500 mots article ou 1500 page
(10), méta-description 120-155 car. (5). Renvoie le détail
par critère (points gagnés, réussi ou non,
message-conseil) plus des points bonus informatifs (FAQ,
liens externes, tableau, média riche).
|
| Analyse de lisibilité Flesch-Kincaid FR | MesurerClasser |
Calcule un score de lisibilité 0-100 via la formule Flesch
adaptée au français: 207 − 1,015 × (mots/phrases) − 73,6 ×
(syllabes/mots). Le comptage syllabique s'appuie sur le
helper SyllableCounter et l'extraction de
phrases protège les abréviations courantes (M., Mme., Dr.,
etc.) pour ne pas fausser le découpage. Le score brut est
mappé sur un palier lisible: Très facile ≥90, Facile ≥80,
Assez facile ≥70, Standard ≥60, Assez difficile ≥50,
Difficile ≥30, Très difficile en dessous. Multilingue (fr
par défaut via normLang, + en, es, pt, de)
pour la détection des mots de transition. Retourne aussi
les moyennes mots/phrase et syllabes/mot.
|
| Score de citabilité GEO (IA) | ÉvaluerDiagnostiquer | Score informatif 0-100 estimant la probabilité qu'un moteur d'IA générative cite le contenu (optimisation GEO), somme de 6 facteurs: structure autoritative /20 (définitions, listes <ol>, statistiques), réponse directe /20 (paragraphes de 30-150 mots, 1er paragraphe définitionnel nommant le mot-clé), données structurées /15 (FAQ, <table>, listes), qualité des sources /15 (tournures de citation « selon », « d'après », liens externes https), couverture /15 (≥2000 mots, nombre de H2/H3), fraîcheur /15 (année courante ou précédente, mention « mis à jour »). Les motifs de détection (définition, citation, statistiques) sont spécifiques à la langue (fr, en, es, pt, de). Chaque facteur renvoie son détail (max, points obtenus, libellé). |
| Scoring à la demande & poids configurables | ScorerConfigurer |
POST /seo/score note un couple titre, contenu,
focus_keyword, slug et meta_description arbitraire, avec
secondaires et lexicaux optionnels et content_type
(article ou page) pilotant la cible de mots
(SeoController::calculateScore). GET
/api/seo/score/{articleId} renvoie le score stocké d'un
article, calculé par le même moteur unifié afin que
éditeur, meta box et API concordent. GET /api/seo-weights
expose les 10 poids de scoring configurables (meta_title,
meta_description, h1_unique, headings, content_length,
image_alt, internal_links, keyword_title, slug, excerpt).
Tout transite par SeoService::calculateScore,
simple wrapper qui délègue à
ContentAnalyzer::analyze (source unique de
vérité), et retourne le détail des 15 critères +
readability_score + citability_score.
|
| Gestionnaire de redirections (CRUD 301/302) | ListerCréerModifierSupprimer |
CRUD complet avec pagination (20 par page, page hors
bornes ramenée dans l'intervalle) affichant source, cible,
type, hits et statut actif. Création
(validateRedirectFields): la source doit
commencer par /, la cible être relative ou une URL http(s)
valide, ≤500 car., sans auto-boucle, type 301 ou 302, et
une source déjà existante est rejetée en 409. Modification
via PUT (mêmes règles, avec auto-exclusion lors du
contrôle de doublon). Suppression via DELETE. Interface:
app/Views/admin/seo/redirects.php (modales
d'ajout, d'édition et d'import, handlers JS).
|
| Import CSV en masse des redirections | ImporterValider |
Import groupé depuis un fichier CSV téléversé (≤1 Mo,
extension .csv uniquement, ligne d'en-tête ignorée).
Chaque ligne est revalidée avec les règles manuelles
(source /, cible valide, type 301 ou 302), les doublons
sont sautés et la note tronquée à 500 car., puis les
compteurs importés/ignorés sont renvoyés. Côté service,
RedirectionService::importCsv applique en
plus une validation d'URL bloquant //, javascript:, data:,
vbscript: et mailto:. Traitement dans
SeoController::importCsv (lignes 418-490).
|
| Auto-redirections sur changement de slug + fusion de chaînes | GénérerFusionnerDétecter |
Service RedirectionService qui crée
automatiquement une 301 marquée is_auto=1 lorsqu'un slug
change (createAutoRedirect(oldSlug,newSlug),
en création ou mise à jour).
mergeChains() aplatit itérativement les
chaînes A->B->C en A->C (maximum 10 passes) pour
ne conserver qu'un seul saut.
hasCircularRedirect() détecte les boucles
jusqu'à une profondeur de 10.
validateRedirectUrl() durcit les cibles en
bloquant //, javascript:, data:, vbscript: et mailto:.
|
| Middleware d'exécution runtime des redirections | IntercepterCompterRediriger |
Middleware public
SeoRedirectMiddleware::handle, enregistré
dans la pile publique (routes.php:217), qui à chaque
requête cherche une redirection active correspondant au
chemin courant via
SeoRedirect::findActiveBySource. En cas de
correspondance, il incrémente le compteur de hits pour
l'analytique (recordHit) puis émet une
réponse 301 ou 302 vers la cible. Exécution transparente
côté visiteur, sans intervention admin.
|
| Données structurées JSON-LD (Schema.org) | GénérerFusionner |
SchemaService produit des schémas génériques:
WebSite (+SearchAction), Organization (logo,
contactPoint), Article, WebPage, BreadcrumbList. Types
topiques « Cartes Topiques »: HowTo (étapes depuis
<ol> ou <h2>), QAPage,
ItemList (items depuis <h2>), Product,
Service (provider, areaServed), DefinedTerm, Dataset,
Review. Des assembleurs génèrent les bundles page
d'accueil (WebSite + Organization), article et page. Les
schémas custom stockés par entité (table
seo_schemas) sont fusionnés cumulativement
avec déduplication par @type (le custom l'emporte).
Extensible via le hook plugin schema.jsonld;
sortie conditionnée par le toggle
enable_schema des réglages.
|
| Détection automatique de FAQ (FAQPage) | DétecterGénérer |
Auto-détecte une FAQ dans le contenu (H2/H3 contenant « ?
», listes <dt>/<dd>) et
produit un schéma FAQPage
(extractFaqFromContent, max 10 entrées,
dédupliquées). Le résultat s'intègre à la sortie JSON-LD
de la page sans balisage manuel des questions/réponses.
Implémenté dans SchemaService (faqPage lignes
225-248, extraction 259-314).
|
| Rendu <head> SEO (Open Graph + Twitter Card) | AssemblerSurcharger |
SeoService::renderHead assemble tout le bloc
<head> SEO d'une entité: title, meta
description, keywords, robots et canonical (conscient du
base-path). Open Graph: og:title, og:description, og:type,
og:url, og:site_name, plus og:locale (locale courante via
mapping fr_FR, en_US...) et og:locale:alternate pour
chaque autre langue active. og:image en cascade: override
-> média par entité -> image sociale par défaut du
site (social.og_default_image, absolutisée si
racine-relative). Twitter Card: twitter:card (défaut
summary_large_image), twitter:title, twitter:description
et twitter:image avec repli sur les valeurs OG. API
override() par entité sans écriture en base;
hook plugin seo.head pour transformer le HTML
assemblé.
|
| Alternates hreflang (<head>) | GénérerInjecter |
HreflangService::generate produit les balises
<link rel=alternate hreflang> par langue
pour les entités article, page, category et tag, injectées
dans le <head> par
renderHead (encapsulé dans un try/catch pour
ne jamais casser la page). La langue par défaut emploie le
slug source non préfixé; les autres /{code}/{slug-traduit}
(repli sur le slug source sous préfixe). Émet x-default à
l'URL de langue par défaut, se désactive s'il n'existe
qu'une seule langue active, et exclut les entités en
corbeille (deleted_at). Ne génère un alternate que pour
les langues réellement traduites.
|
| Anti-cannibalisation (unicité du focus keyword) | VérifierSuggérerAuditer |
Garde contre deux contenus ciblant le même focus keyword.
POST /api/seo/check-keyword
(ApiController::checkKeyword) signale les
articles en conflit sur un mot-clé, avec exclude id
optionnel. validateKeyword() confronte le
mot-clé aux articles et catégories existants;
suggestAlternative() propose un mot-clé
différent via Claude (repli déterministe « keyword-cat-alt
»); auditAll() liste tous les focus keywords
dupliqués du site. Service:
AntiCannibalizationService.
|
| Réglages SEO (onglet Settings) | ConfigurerEnregistrer |
Surface de configuration SEO globale sous Réglages,
distincte de l'Agent SEO. Permet de définir
default_meta_title et default_meta_description,
l'identifiant google_analytics_id et la vérification
google_search_console, d'éditer directement robots_txt, de
basculer enable_schema (activation de la sortie JSON-LD),
et de fixer og_default_image comme repli de partage social
(onglet Social). Géré par
SettingsController (proxies seo/saveSeo
lignes 774-787, schéma seo 617-624, social 631).
|
SEO & Sémantique
🤖 Fichiers SEO publics & Agent SEO Complété
27 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Index des sitemaps XML | ServirFiltrer (hook)Horodater |
Sert l'index sur /sitemap.xml et /sitemap
(SitemapController->index →
SitemapService::generateIndex). Parcourt les 4 types
(articles, pages, categories, tags), saute tout type dont
SitemapConfig->is_included est faux, et émet une entrée
<sitemap> par type inclus avec
<loc> absolu (UrlHelper::sitemap) et
<lastmod> ISO 8601 UTC. Le document est
déclaré dans le namespace sitemaps.org 0.9 et passe par le
filtre plugin sitemap.index avant d'être
servi. Toutes les valeurs sont échappées via
htmlspecialchars ENT_XML1.
|
| Sitemaps par type + configuration | GénérerRouterClamper prioritéForcer homepage |
/sitemap-{type}.xml ou /sitemap/{type} dispatche via
generateForType vers un générateur dédié; tout type hors
des 4 connus renvoie un urlset vide (contrôleur en 404).
Chaque générateur lit SitemapConfig (changefreq, priority
bornée entre 0.0 et 1.0 par number_format, include_images)
avec des défauts par type (articles 0.8 weekly, pages 0.9
monthly, categories 0.6 monthly, tags 0.4 monthly). Le
sitemap pages force la page d'accueil en tête (priority
1.0, changefreq daily) puis exclut la page marquée
is_homepage de la boucle. Après génération, last_generated
est persisté; le hook sitemap.urlset reçoit
le XML et le type, et toutes les réponses sitemap portent
l'en-tête X-Robots-Tag: noindex.
|
| Annotations image dans le sitemap |
Émettre <image:image>Résoudre média
|
Uniquement dans le sitemap articles et si include_images
est actif: chaque article avec featured_image émet un bloc
<image:image> contenant
<image:loc> (URL média absolue),
<image:title> (alt_text) et
<image:caption> (caption) quand ces
champs existent. Le namespace image
(google.com/schemas/sitemap-image/1.1) n'est déclaré dans
<urlset> que lorsque les images sont
incluses (openUrlset(true)). L'image est résolue via
Article::featuredImage() puis UrlHelper::media.
|
| Annotations hreflang dans le sitemap |
Émettre <xhtml:link>Détecter traductionx-default
|
Pour les entités à slug (articles, pages), slugAlternates
ajoute une ligne
<xhtml:link rel="alternate"
hreflang="{code}">
par langue active plus un x-default. La langue par défaut
pointe vers le slug source non préfixé; les autres langues
interrogent la table {entity}_translations et ne sont
émises que si une traduction existe, avec le slug traduit
(ou le slug source sous /{code}/ en repli, résolu par P2).
Rien n'est produit si moins de deux langues actives ou
slug vide; x-default cible l'URL en langue par défaut.
Toutes les valeurs sont échappées ENT_XML1.
|
| robots.txt public dynamique | ServirReplierFiltrer (hook)Mettre en cache |
GET /robots.txt (RobotsController->index) sert le
réglage seo.robots_txt s'il est non vide, sinon un
fallback intégré (buildFallback) qui reproduit le groupe
User-agent: * du composeur. Le fallback autorise
explicitement /assets/, /themes/, /storage/media/,
/storage/uploads/, /storage/ai/, favicon.ico, favicon.svg,
manifest.json et interdit /admin/, /api/, les
sous-dossiers privés de storage (logs, backups, sessions,
exports, cache) ainsi que /search et /recherche, puis une
ligne Sitemap absolue. Il ne bloque JAMAIS /storage/ en
entier (cela masquerait la médiathèque) et intègre le base
path d'installation (sous-dossier). Le hook plugin
robots.txt peut transformer le corps (custom
ou fallback); la réponse porte Content-Type text/plain et
Cache-Control public, max-age=86400.
|
| Flux RSS global & par langue | ServirTraduirePréfixer URLs | /rss.xml ou /feed sert generateGlobal: les 20 derniers articles publiés (status=published, deleted_at NULL) triés par published_at DESC. Pour une locale non-défaut (/{lang}/rss.xml ou /{lang}/feed), chaque item est superposé avec sa traduction article_translations (title, slug, excerpt) et les URLs sont préfixées /{lang}. Le canal expose title, link, description (réglages généraux), la langue, un lastBuildDate au format RFC 2822 et un atom:link rel="self". La récupération DB (generate*) et l'assemblage XML (buildFeed, pur, sans DB) sont séparés; l'image est résolue en amont dans normalizeItem. |
| Flux RSS par catégorie | ServirJoindre articles404 sur slug inconnu | /rss/categorie/{slug}.xml ou /feed/categorie/{slug} appelle generateByCategory qui résout la catégorie par slug (deleted_at NULL) et renvoie null → 404 si inconnue. Les items sont les 20 articles publiés joints via article_categories à cette catégorie, triés par published_at DESC. Le canal reprend le nom, l'URL (UrlHelper::category), la description de la catégorie et un self link /rss/categorie/{slug}. |
| Enrichissement média & robustesse des items RSS | Enrichir image (3 formats)Deviner MIMENettoyer XML |
Chaque item porte son image de trois façons:
<enclosure> (url + length en octets +
type MIME), Media RSS
<media:content> (+ fileSize, width,
height) avec
<media:thumbnail> (vignette de 300px de
large, hauteur calculée sur le ratio source), et un
<content:encoded> en CDATA (img +
excerpt en HTML). resolveImage lit la table media (path,
path_300, mime_type, size, width, height), devine le MIME
par extension si absent et retombe sur la taille disque
(filesize sous storage/) si media.size manque. Avant
échappement ENT_XML1, les caractères de contrôle C0
interdits en XML 1.0 sont supprimés (sûr en UTF-8), et
toute séquence ]]> dans le CDATA est neutralisée.
|
| llms.txt / llms-full.txt (GEO) | Servir markdown404 tant que non publiéMettre en cache | /llms.txt sert le réglage seo.llms_txt et /llms-full.txt sert seo.llms_full_txt (LlmsController). Si le contenu trimé est vide, la réponse est 404 avec le corps « Not published yet. »; sinon Content-Type text/markdown et Cache-Control public, max-age=3600. Ces fichiers de guidage pour crawlers IA ne sont jamais servis tant qu'un admin n'a pas confirmé une proposition de l'Agent SEO, et leur contenu est versionné (seo_file_versions). |
Hreflang alternates (<head>)
|
GénérerRouter selon entitéExclure corbeille |
HreflangService::generate produit des
<link rel="alternate" hreflang="{code}">
pour article, page, category, tag. buildPath route l'URL
selon l'entité (article et page à la racine /{slug},
category /category/{slug}, tag /blog/tag/{slug}) pour
rester cohérent avec la canonical. La langue par défaut
utilise le slug source; les autres n'apparaissent que si
une traduction existe (slug traduit, sinon slug source
sous /{code}); x-default pointe vers l'URL par défaut et
rien n'est émis si moins de deux langues actives. Les
entités en corbeille (deleted_at non NULL) sont exclues
des alternates.
|
| Agent SEO Mission Fondation (SSE) | PercevoirComposerRaffiner (Claude)AuditerStocker | POST /admin/seo/agent/mission (action=foundation) ouvre un flux SSE (Sse::open(300)) émettant les labels d'étape (facts, draft, agent, guard, store) et les chunks du modèle. runFoundationMission collecte les faits, compose les brouillons déterministes (robots + squelettes llms et llms-full), puis Claude::stream (max_tokens 16000, temperature 0.3, timeout 300) renvoie un JSON (résumé, analyse, dossier GEO à 3 postures, note de gouvernance, ajustements robots, markdown llms, justifications, risques). Les ajustements whitelisted sont appliqués puis l'audit dur est rejoué; en cas d'échec après ajustements le brouillon pur est restauré. La proposition (fichiers, audit, diffs ligne à ligne, pédagogie, ajustements) est stockée en statut pending et supersède toute proposition foundation pending antérieure; le budget IA est vérifié avant lancement et la connexion DB reconnectée après le long stream. |
| Agent SEO Mission Veille (SSE) | Rechercher le webClasser par familleFiltrerProposer ajouts registre | action=watch lance runWatchMission qui interroge Claude avec recherche web (sendWithWebSearch) pour trouver les nouveaux user-agents crawlers documentés après une date, en excluant les bots déjà connus du registre. Chaque bot retourné est filtré: UA validé par regex (2 à 60 caractères), absent du registre, famille dans la liste autorisée (ai_training, ai_search, ai_assistant, search_engine, social, seo_tool, scraper); l'URL source issue du modèle n'est conservée que si http(s), sinon vidée (anti-XSS dans l'admin). last_watch_at est horodaté; si des bots sont trouvés une proposition « watch » (registry_additions) est stockée pour confirmation humaine, sinon un résumé « registre à jour » est renvoyé. |
| Agent SEO Ask (Q&A ancré, SSE) | Ancrer sur l'état réelRépondre en fluxVérifier budget | POST /admin/seo/agent/ask ouvre un SSE (180s) et streame la réponse de ask(): Claude répond à la question de l'admin (600 caractères max) STRICTEMENT à partir de l'état réel du site (faits collectés, robots.txt et llms.txt publiés, digest de mémoire), dans la langue de l'UI, en 10 à 20 lignes texte. Le budget IA est vérifié avant; la réponse n'est jamais publiée (contrairement aux missions). Des chips de questions préréglées sont proposées dans la vue agent. |
| Agent SEO Boîte de propositions + détail pédagogique | ListerConsulter JSONAfficher KPIHistorique versions | La vue agent liste les 20 dernières propositions (id, mission, statut, titre, résumé, dates) et un historique de 30 versions de fichiers, plus des KPI (nombre de bots du registre, version du registre, propositions pending, dernière veille). GET /admin/seo/agent/proposals/{id} renvoie le payload JSON décodé: pédagogie (résumé, analyse, dossier GEO à 3 postures avec impact, risque et réversibilité, note de gouvernance, risques résiduels), diff ligne à ligne (added/removed plafonnés), matrice d'audit URL×bot et contenu intégral des fichiers proposés. |
| Agent SEO Publier une proposition | Ré-auditer côté serveurRéclamer atomiquementVersionnerMémoriser | POST /admin/seo/agent/proposals/{id}/publish (clic humain) exécute publishProposal: pour une mission non-watch, l'audit déterministe est REJOUÉ côté serveur sur le contenu exact (frontière de confiance qu'un POST direct ne peut contourner, le bouton désactivé de l'UI n'étant que décoratif); un échec refuse la publication. Une réclamation atomique (UPDATE ... WHERE status='pending') garantit qu'un seul publish bascule pending → published. Foundation écrit chaque fichier (robots_txt, llms_txt, llms_full_txt) dans seo_file_versions avec version incrémentée et is_active=1 (les précédentes passant à 0) et met à jour le réglage servi; watch fusionne les registry_additions dans seo_agent.registry_extra. En cas d'erreur transactionnelle la réclamation est relâchée pour permettre une nouvelle tentative, et un succès est consigné en mémoire. |
| Agent SEO Rejeter une proposition | RejeterConsigner la raisonAlimenter la mémoire | POST /admin/seo/agent/proposals/{id}/reject fait passer la proposition pending à rejected avec la raison humaine (tronquée à 1000 caractères), horodatage et auteur. La raison est enregistrée dans la mémoire de décision (kind=rejected) afin d'influencer les futures missions via le memoryDigest injecté dans les prompts. Une proposition déjà décidée renvoie une erreur 422. |
| Agent SEO Rollback d'une version de fichier | Réactiver versionRepublierMémoriser | POST /admin/seo/agent/rollback réactive une version antérieure: la ligne seo_file_versions ciblée (par id + file) devient is_active=1, les autres du même fichier repassent à 0, et le réglage servi (seo.robots_txt, llms_txt ou llms_full_txt) est réécrit avec son contenu, republiant instantanément l'ancienne version. Un file hors mapping (robots, llms, llms_full) ou une version introuvable renvoie une erreur; l'opération est consignée en mémoire (kind=rollback). |
| Agent SEO Politique GEO / outils SEO | Définir posturesValiderMémoriserInviter à régénérer | POST /admin/seo/agent/policy enregistre trois réglages (groupe seo_agent): geo_posture (open, selective, closed), seo_tools_posture (allowed, limited, blocked) et auto_publish_minor (0 ou 1), avec validation stricte (422 sinon). Ces postures pilotent la composition déterministe du robots.txt via la matrice de posture (open = toutes familles IA autorisées, selective = entraînement bloqué mais recherche et assistants autorisés, closed = toutes familles IA bloquées). Le changement est enregistré en mémoire (kind=policy) et la réponse invite à relancer la mission pour régénérer les fichiers, la sauvegarde ne régénérant rien à elle seule. |
| Agent SEO Testeur URL × Bot | Tester crawlabilitéRendre le verdictBorner les entrées | GET /admin/seo/agent/test?url=&bot= renvoie un verdict allowed ou blocked contre le robots.txt PUBLIÉ via RobotsSimulator::decide, avec la règle décisive et le groupe user-agent correspondant. Les entrées sont bornées (url ≤ 500, bot ≤ 80 caractères, sinon 422). Si aucun robots.txt n'est publié, un verdict allow par défaut est renvoyé avec un message précisant que tout est autorisé sauf /admin et /api. C'est exactement la même logique de décision que celle utilisée par l'auditeur de régression. |
| Agent SEO Registre de bots crawler | CataloguerFusionner ajouts confirmésInterroger par familleExposer matrice | SeoBotRegistry::builtin fournit un catalogue versionné (VERSION 2026-06-10) d'environ 90 crawlers répartis en 7 familles (ai_training, ai_search, ai_assistant, search_engine, social, seo_tool, scraper), chacun avec organisation, drapeaux wildcards et crawl_delay, et une note. all() fusionne à la lecture les ajouts humains confirmés stockés en seo_agent.registry_extra (réglage de type json, déjà décodé). byFamily filtre par famille et postureMatrix expose l'autorisation par famille IA selon la posture; ces familles pilotent les groupes du robots.txt généré (moteurs et sociaux toujours autorisés, scrapers toujours bloqués, outils SEO pilotés par la policy). |
| Agent SEO Composeur robots.txt déterministe | Projeter policy+faits+registreGrouper par familleDédupliquer les paramètresSitemaps absolus | RobotsComposer::compose projette policy + faits + registre en un robots.txt: en-tête commenté (posture, langues), groupe par défaut (User-agent: *) avec les Allow d'assets et média must-allow, les Disallow standards (préfixes privés, routes de recherche) et la déduplication des paramètres de tracking (utm_, fbclid=, gclid=, msclkid=, ref=, ...) en /*? et /*&. Chaque famille IA reçoit son groupe Allow: / ou Disallow: / selon la posture; comme un bot ayant son propre groupe ignore le groupe * (RFC 9309), le bloc Disallow partagé est répété dans chaque groupe autorisé. Les outils SEO suivent leur posture (bloqués, ou Allow avec Crawl-delay: 10 quand limited et que le bot honore le crawl-delay), les scrapers reçoivent Disallow: /, les bots sociaux Allow: /, puis les lignes Sitemap absolues; les triples sauts de ligne sont normalisés. Le fichier est un pur produit dérivé, non éditable à la main. |
| Agent SEO Ajustements whitelisted + sanitisation des chemins | Appliquer 3 ops autoriséesSanitiser cheminsDécoder %-encoding | applyAdjustments n'accepte que trois opérations (add_disallow, add_allow, remove_rule) ciblant un groupe (* ou un user-agent exact); toute autre op ou valeur vide est rejetée et rapportée dans « dropped ». Les insertions se placent juste après la dernière ligne User-agent du groupe, et remove_rule ne balaie jamais au-delà de la frontière du groupe suivant. sanitizePaths exige un chemin commençant par /, sans espaces ni caractères de contrôle, et refuse les synonymes de blocage total (« / », « /* », « /$ »), les motifs sensibles (.env, .git, .sql, .bak, secret, password, .htaccess, .htpasswd) et tout lang= après décodage %-encoding jusqu'à point fixe (decodeFixpoint, 5 passes) pour empêcher les contournements type /%2Eenv ou /*?l%61ng=. |
| Agent SEO Parseur + simulateur robots (RFC 9309) | Parser groupes/wildcardsSélectionner et fusionner le groupeDécider (longest-match, fail-closed) | RobotsSimulator::parse implémente un parseur RFC 9309 (groupes, wildcards * et $) ignorant les commentaires sans casser l'état de groupe. matchGroup sélectionne par jeton user-agent le plus long (sinon *) et FUSIONNE tous les groupes partageant ce jeton (RFC 9309 §2.2.1). decide applique le verdict par correspondance la plus longue (à longueur égale, Allow l'emporte sur Disallow), « Disallow: » vide valant tout autoriser. pathMatches compile le motif en regex (* → n'importe quoi, $ ancre la fin) et renvoie null si PCRE échoue (backtracking); ce null est traité en fail-closed (bloqué), jamais fail-open, pour qu'un motif piégé ne fasse pas passer un blocage réel. |
| Agent SEO Auditeur robots.txt (règles dures + matrice de régression) | Vérifier syntaxe/tailleInterdire le blocage racineTester URLs réelles × bots | audit applique des règles inviolables: au moins un groupe, taille ≤ 500 Ko, la racine « / » jamais bloquée pour un bot inconnu (groupe *), au moins une ligne Sitemap et toutes absolues (http(s)), aucun chemin sensible listé et aucune règle Disallow bloquant lang= (après décodage %). Puis regressionMatrix teste une grille d'URLs RÉELLES du site (accueil, blog, article récent, CSS du thème actif, image média, sitemap, llms.txt attendus autorisés; /admin, /api, /search, ?utm_ attendus bloqués; catégorie et langue secondaire seulement si elles existent) contre les bots majeurs (Googlebot, Bingbot, GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Twitterbot, AhrefsBot). Toute régression (un bot indispensable ne peut plus crawler une URL autorisée) ou fuite (une URL censée être bloquée reste accessible) ajoute une erreur, et une seule erreur bloque toute proposition ou publication. |
| Agent SEO Auditeur llms.txt | Valider la structureVérifier l'intégrité des liensInterdire les zones privées | auditLlms exige un H1 (# ...), recommande un bloc de description (> ...), avertit au-delà de 60 Ko, et surtout vérifie l'intégrité des liens: chaque lien interne du markdown doit pointer vers une URL réelle connue des faits (top_articles, pillars, recent_articles, public_pages, categories, sitemaps, plus /, /blog, /llms.txt, /llms-full.txt), les variantes préfixées par langue étant acceptées; tout lien inventé est une erreur. Toute référence à /admin ou /api est refusée. Le résultat (ok, errors, warnings) conditionne l'acceptation de la version produite par l'agent, sinon le squelette déterministe (composeLlmsSkeleton, construit directement depuis les faits) est conservé. |
| Agent SEO Perception des faits | Collecter site/routes/assets/contenuIntrospecter routes.phpDétecter le drift | SeoFactsService::collect agrège: site (nom, description, url, langues actives, langue par défaut), routes (introspection SANS effet de bord de app/routes.php, avec suivi des prefixes de group pour classer public GET, privé /admin et /api, auth, recherche), sitemaps attendus, assets must-allow (assets, thème actif, storage/media, favicons, manifest), inventaire de contenu (compteurs publiés, catégories avec comptes, pool de curation = mieux notés × plus lus × plus récents, piliers via cocon_type, articles récents, pages publiques), policy courante et publishedFacts (nombre de lignes et tête des fichiers publiés, pour détecter le drift face aux drafts). Chaque collecteur est défensif: une source en échec retombe sur une valeur saine plutôt que de casser la mission. |
| Agent SEO Mémoire de décision | Enregistrer les décisionsDigérerInjecter dans les prompts | remember insère une entrée (kind = policy, published, rejected ou rollback + contenu tronqué à 2000 caractères) dans seo_agent_memory. memoryDigest relit les 20 dernières décisions et les formate (« [date] kind: contenu ») pour les injecter dans les prompts de la mission fondation et du Q&A, afin que l'agent respecte les choix humains passés. Le timestamp last_watch_at trace la dernière veille. Les échecs d'écriture en mémoire sont silencieux (error_log) et ne cassent jamais la mission en cours. |
SEO & Sémantique
🕸️ Topic Clusters & Cocon Sémantique
22 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Gestion des projets (liste, brouillons, CRUD) | ListerCréerOuvrirSupprimerEnregistrer/Éditer brouillon |
La liste (GET /admin/cocon) sépare les projets en statut
'draft' dans une section « Brouillons » et les projets
déjà générés. • L'ouverture d'un projet (GET /admin/cocon/{id}) renvoie l'arbre en HTML, ou l'arbre + la progression en JSON si l'en-tête Accept: application/json est présent. • La suppression (DELETE /admin/cocon/{id}) applique un contrôle de propriété masqué en 404 pour un projet d'un autre utilisateur. • L'enregistrement (POST /admin/cocon/store) route selon mode, draft_id et action: crée un nouveau projet ou met à jour un brouillon existant sur place. • L'action 'draft' correspond à « Enregistrer pour plus tard », l'action 'create' à « Créer et générer maintenant » (service CoconProjectService: list/create/updateDraft/delete). |
| Auto-save du wizard (brouillon réservé) | Sauvegarder autoReprendreÉditer brouillon nommé |
POST /admin/cocon/autosave fait un upsert en arrière-plan
(debounce ~2s) vers un unique emplacement de brouillon
réservé par type et renvoie {success,id}. • L'appel échoue en douceur (jamais de redirection ni d'erreur) si le mot-clé est vide ou en mode architect. • À la réouverture du wizard, le brouillon réservé est repris automatiquement via findAutoDraft ('classic' ou 'topical'). • L'édition d'un brouillon nommé précis via ?draft={id} est protégée par contrôle de propriété et de type, et désactive l'auto-save. • La vue create.php pilote l'état d'affichage (autoStatus / _autoTimer). |
| Wizard de création classique Cocon Sémantique (Auto vs Architect) | Choisir modeChoisir type de contenuRégler langue/profondeur/nb nœuds |
Formulaire configurant un cocon en mode Automatic (l'IA
construit tout l'arbre) ou Architect (semi-manuel). • content_type = articles ou pages. • Options réglables: langue, max_depth borné 0-10 (0 = auto), node_count_hint borné 5-100, et notes en texte libre. • Les bornes de mode, content_type, max_depth et node_count_hint sont validées côté CoconProjectService; les valeurs par défaut passent par wizardData. |
| Wizard « Cartes Topiques » Topical Map (2026, USA + International) avec auto-remplissage IA | Régler scope/marchéSaisir contexte businessSuggérer nom+marché (IA)Suggérer inputs business (IA) |
Wizard multi-étapes pour un Topical Authority Graph:
scope_preset (small, medium ou large whitelist stricte),
node_count_hint, max_depth et market/pays. • Contexte business saisi: produit, concurrents, personas, localisations. • « Suggérer nom et marché » (POST /admin/cocon/suggest-topic) déduit à partir du mot-clé + langue; « Suggérer inputs business » (POST /admin/cocon/suggest-business) à partir du mot-clé + langue + marché. • Ces deux aides IA s'exécutent AVANT que le projet existe (aucun id projet), et sont protégées par la permission cocon.create, la limite de débit IA et le budget IA quotidien. |
| Génération IA de l'arbre de mots-clés classic (SSE) | Ouvrir flux SSEGénérer multi-étapesAuto-lancer |
GET /admin/cocon/{id}/generate-tree ouvre un flux
Server-Sent Events émettant des événements progress,
complete, error et done, avec heartbeats. • La génération est multi-étapes via CoconTreeGenerator::generateTreeMultiStep avec callbacks de progression par étape. • Réservé aux projets classiques: un projet topical est refusé avec un code 409 (il doit utiliser generate-graph). • Auto-démarrage possible à l'ouverture de l'arbre via ?generate=1 (bouton « Lancer la création »); transport géré par startSse / sendSseEvent / sendSseHeartbeat. |
| Génération du Topical Authority Graph (SSE, dans la requête) | Ouvrir flux SSEConstruire graphe hub-and-spoke |
GET /admin/cocon/{id}/generate-graph diffuse en SSE
(progress/complete/error/done) et pré-valide existence,
propriété et mode avant d'ouvrir le flux. • Construit le graphe topique en étoile (hub-and-spoke) via TopicalGraphGenerator, en miroir de generateTree. • Réservé aux projets topical: refuse tout projet non-topical avec un 409. • Générateur « in-request » (exécuté dans la requête HTTP), à distinguer du build arrière-plan build-map pour les grosses cartes. |
| Build arrière-plan scalable des cartes topiques (build-map) | Mettre en fileSuivre progressionPauseRepriseAnnuler |
POST /admin/cocon/{id}/build-map met en file une
reconstruction fraîche: efface les nœuds + arêtes
existants, crée un job, marque le projet 'building' et
planifie la chaîne du worker. • GET .../build-map-status renvoie par polling le job {status, stage, nodes_created, target, current_depth, max_depth, percent, active, error}. • POST .../build-map-control gère pause, resume ou cancel du job actif du projet. • Le panneau de progression est amorcé sans flash via buildJob dans show(), et l'arbre se rafraîchit à mesure que les nœuds apparaissent. • Build résumable et checkpointé (de 100 à 20 000+ nœuds) piloté par le scheduler plutôt que par la requête HTTP (service CoconBuildService). |
| Worker de file d'attente (tâche cocon_build auto-replanifiée) | Tick cronTraiter jobÉtendre par lotsCompléter |
cronTick récupère les jobs 'running' bloqués (heartbeat
périmé > 5 min), sélectionne le prochain job en file,
se met en attente sur blocage budget ('waiting_budget') et
maintient la chaîne vivante. • processJob revendique atomiquement queued→running, crée la racine une seule fois, étend la frontière par tranches d'environ 45s puis cède la main en se re-mettant en file. • expandBatch fait un appel IA par lot de parents → jusqu'à 12 enfants chacun, avec page_type, funnel_stage et schema_type assainis par ENUM et déduplication des mots-clés à l'échelle du projet. • Garde-fou anti-poison: le job échoue après 10 échecs de lot consécutifs; complete() sauvegarde une version de l'arbre et passe le projet à 'ready'. • estimate() pré-calcule target/effective/branching et le nombre d'appels IA approximatif; plafond absolu de nœuds via advanced.cocon_max_nodes (défaut 20000). Worker breadth-first, budget-aware et résumable au crash. |
| Dashboard admin de la file de builds | Lister buildsContrôler jobsVoir santé schedulerLancer maintenant |
Écran unique (GET /admin/build-queue) listant les 40
builds les plus récents avec nom de carte, badge de
statut, barre de progression (fait/cible), profondeur et
date de mise à jour. • Contrôles par job: Pause, Resume, Cancel, Retry (POST /admin/build-queue/control). • Santé du scheduler: Actif ou Non-lancé (dernière exécution < 3 min via cron.lock), nombre de scheduled_tasks en attente, affichage du plafond de sécurité. • « Run now » déclenche immédiatement un tick du scheduler (POST /admin/build-queue/run-now). • Affiche l'indice d'étape « waiting for AI budget… » sur les jobs mis en attente budget. |
| Configuration du déclencheur worker (cron système + token web-cron) | Générer/rotater tokenDésactiver tokenAfficher instructions cron |
Génère ou fait tourner le token web-cron (POST
/admin/build-queue/token) en produisant une URL GET
/cron/run?token=… avec bouton de copie. • Désactive le token web-cron (POST /admin/build-queue/token-clear). • Affiche les instructions du cron système avec la commande exacte « php bin/console cron:run » (aucun token requis en CLI). • L'endpoint public GET /cron/run?token= accepte soit le CRON_TOKEN du .env, soit le web_token stocké en base (comparaison via hash_equals), puis exécute le tick du scheduler. • Setup clé-en-main: Option A crontab système, Option B URL web-cron secrète. |
| Édition manuelle de l'arbre / des nœuds | RenommerÉditer mots-clésTyperAjouterSupprimerDéplacerRéordonner |
Renommer un mot-clé de nœud (PUT /update-node, garde 255
caractères); éditer les mots-clés secondaires/lexicaux
(PUT /update-node-keywords, garde JSON valide). • Définir page_type + funnel_stage (PUT /update-node-type; whitelist ENUM de 12 types de page et TOFU/MOFU/BOFU). • Ajouter un nœud ou une branche racine (POST /add-node); supprimer avec ou sans enfants (DELETE /delete-node): re-parente les enfants, décrémente la profondeur, annule les pointeurs canonical orphelins. • Déplacer un nœud vers un nouveau parent (POST /move-node; garde anti-cycle empêchant de le déplacer dans son propre descendant); réordonner sort_order en masse (POST /reorder, validation tout-ou-rien). • CRUD complet protégé IDOR/propriété, avec recalcul de profondeur (recalcDepths) et nettoyage des canoniques. |
| Assist IA par nœud / branche | Régénérer nœudRégénérer brancheSuggérer enfantEnrichir un/tous |
Régénérer un mot-clé de nœud unique (POST
/regenerate-node); régénérer toute une branche (POST
/regenerate-branch: supprime puis recrée les
descendants). • Suggérer un mot-clé enfant via IA (POST /suggest-child). • Enrichir un nœud (POST /enrich-node): mots-clés secondaires + lexicaux, search_intent heuristique, target_word_count selon la profondeur, et brief IA. • Enrichir tous les nœuds (POST /enrich-all): enrichit les nœuds vides et génère le maillage interne (table cocon_links: liens ascendant/descendant/latéral, link_juice, drapeau pillar). |
| Outils de structure en masse (mode Architect) | Importer structureAjouter enfants en masseMarquer prêt |
Importer une structure (POST /import-nodes): coller du
texte indenté (2 espaces = 1 niveau), ce qui REMPLACE tout
l'arbre, max 200 nœuds, avec pré-validation complète avant
l'effacement. • Ajout en masse d'enfants (POST /add-multiple-nodes): jusqu'à 200 mots-clés sous un même parent. • Marquer le projet prêt (POST /mark-ready; exige au moins 2 nœuds). • Modal d'import avec aperçu en direct, insertion d'exemple et retrait automatique des préfixes (N0:, -, *). |
| Cascade sémantique | Propager contexte |
POST /admin/cocon/{id}/semantic-cascade
(applySemanticCascadeToProject) propage le contexte
sémantique de chaque parent vers ses enfants via IA pour
obtenir un arbre plus cohérent, et renvoie le nombre de
nœuds mis à jour. • Déclenchée par un bouton « Cascade » dans la vue tree.php. |
| Détection + résolution anti-cannibalisation (intra-map) | AnalyserCharger cacheRésoudreAuto-corriger |
Lancer l'analyse (POST /check-cannibalization): un SEUL
appel IA sur l'ensemble des nœuds, met les conflits en
cache sur le projet avec un checked_at. • Charger la dernière analyse en cache sans appel IA (GET /cannibalization). • Résoudre un conflit (POST /cannibalization/resolve) par merge, differentiate, canonical ou delete (avec gardes anti-boucle/self/descendant et repointage du canonical). • Auto-corriger tout (POST /cannibalization/autofix): applique jusqu'à 200 résolutions, avec échec en douceur par item. • UI: chips KEEP/CHANGE liés dans l'arbre, correctif recommandé + alternatives par conflit. Détecte les pages en concurrence pour une même intention. |
| Matrice de couverture sémantique | Afficher couvertureFiltrer |
GET /admin/cocon/{id}/coverage renvoie une carte
mot-clé→usages par nœud plus des statistiques:
total_keywords, orphan_keywords (usage unique),
well_covered (3 usages ou plus) et total_nodes. • Vue croisée montrant où chaque mot-clé est employé (rôle primary, secondary ou lexical), signalant les orphelins et les termes bien couverts. • Modal avec filtre et légende de rôle P/S/L. |
| Versioning / rollback de l'arbre | Snapshot autoLister versionsRestaurer |
saveTreeVersion() écrit un instantané après chaque
mutation (édition de nœud, déplacement, import, résolution
de conflit) et à l'achèvement d'un build. • GET /admin/cocon/{id}/versions liste les versions stockées. • POST /admin/cocon/{id}/restore-version restaure par version_index (reconstruit l'arbre; 422 sur index invalide). • API back-end (getVersions / restoreVersion côté CoconProjectService); aucun bouton de restauration dédié n'a été trouvé dans tree.php. |
| Construction de la structure en entités (SSE) | Vérifier étatDiffuser build SSE |
POST /admin/cocon/{id}/build vérifie que le projet est
dans un état constructible (ready ou draft). • GET /admin/cocon/{id}/build-progress diffuse en SSE (progress/complete/error/done) l'exécution de CoconStructureBuilder::build. • Transforme l'arbre en véritables entités page/article. |
| Writing Studio (rédaction IA du contenu des nœuds) | Lister nœudsEstimer coûtRédiger (SSE)Réécrire/AméliorerPause/Skip/StopMarquer terminé |
Liste les nœuds en attente/échec à rédiger (GET
/pending-nodes) et estime coût + appels IA + durée avec
coûts par modèle en direct et usage du compte (GET
/estimate-cost?action=generate ou write&model=). • Rédige le contenu (GET /write-progress en SSE) en mode node, branch ou all, avec override de modèle optionnel et maintien par heartbeat; describeError catégorise les échecs. • Re-vérifie le content_status d'un nœud après un flux interrompu (GET /node-status); réécrit un nœud (POST /rewrite-node SSE) et améliore un nœud (POST /improve-node SSE). • Demande ou annule l'annulation via un flag de settings projet (POST /cancel-writing, /clear-cancel); marque le projet terminé uniquement s'il ne reste aucun nœud en attente/échec (POST /mark-completed). • UI Studio: carrousel avec score SEO, Pause/Resume, Skip, Stop, attente inter-page de 30s et modal de confirmation de coût. Rédaction en streaming avec estimation de coût, sélection de modèle, budget affiché et cadençage par page. |
| Publication des entités du cocon | PublierDépublierDéclencher hook |
POST /admin/cocon/{id}/publish (mode node, branch ou all)
passe les articles/pages au statut published. • POST /admin/cocon/{id}/unpublish les repasse en draft. • Détecte la transition draft→published et déclenche Hooks::doAction('content.published') par entité (pour plugins et webhooks). • Publication/dépublication au périmètre nœud, branche ou projet entier (changeEntityStatus). |
| Export du projet (JSON / CSV) | Exporter fichier |
GET /admin/cocon/{id}/export?format=json ou csv télécharge
l'arbre du projet en pièce jointe (en-tête
Content-Disposition attachment, nom de fichier
cocon-<keyword>.<ext>). • Sérialisation gérée par CoconProjectService::export. |
| Modes de visualisation de l'arbre + inspecteur de nœud | Changer de vueAjuster/Exporter PNGRafraîchirFiltrerInspecter |
Six modes de vue sur l'arbre/graphe: List, Mind Map, Org
Chart, Tree H, Network (D3) et Graph (Cytoscape). • Mode Graph: fit-to-screen et Export PNG; arêtes verticales (parent) + latérales (topiques) via GET /graph-data. • Rafraîchissement JSON de l'arbre en direct (GET /tree-data) après les éditions et pendant le polling du build arrière-plan. • Recherche/filtre de mots-clés, Expand all / Collapse all, menu contextuel au clic-droit. • Panneau de détails du nœud: mot-clé, volume, difficulté, profondeur, page_type/funnel, mots-clés secondaires/lexicaux, target word count, brief et statut de contenu. |
Intelligence Artificielle
✨ IA Générateurs & tableaux de bord
22 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Tableau de bord IA (Assistant IA) | ConsulterSurveillerLancerRécupérer (JSON) |
Page d'atterrissage du sous-système IA (GET /admin/ai)
exposant l'état de configuration: API Claude (Active ou
Non configurée la simple présence de la clé sert de signal
d'activation, sans drapeau séparé), état image WaveSpeed,
et une carte de disponibilité du streaming SSE. Affiche les KPI de dépense/usage par utilisateur: coût du jour vs limite quotidienne et coût mensuel vs limite mensuelle avec barres de progression, plus le nombre de requêtes du jour et du mois. Propose des tuiles de lancement rapide (Générer du contenu, Générer des images la tuile image est masquée quand WaveSpeed n'est pas configuré) et une tuile 'AI Analytics' désactivée (placeholder 'Coming in Step 6', non implémentée). Un tableau des 10 dernières requêtes IA (type, modèle, tokens, coût, durée, date) est affiché, et l'endpoint GET /admin/ai/usage renvoie les stats d'usage en direct au format JSON; une bannière d'avertissement + lien 'Go to AI Settings' apparaît si non configuré. Route protégée par RateLimitMiddleware('ai') et permission ai.use. |
| Générateur de contenu mode Générer un article | GénérerDiffuser (SSE)Copier |
Atelier de contenu autonome (/admin/ai/content). Le mode
Générer produit un article soit en streaming SSE (GET
/ai/stream-article) avec chunks de tokens en temps réel,
soit en mode bloquant (POST /ai/generate-article). Paramètres: sujet/mot-clé, plan (outline), mots-clés secondaires et lexicaux, nombre de mots (borné entre 200 et 5000), langue. Quand aucun plan brut n'est fourni, le prompt est construit via PromptBuilder::buildStandard; le mode bloquant enregistre la sortie dans la table ai_generated_content. Le flux SSE relève max_execution_time à 600s et désactive le buffering de sortie (Sse::open) pour que les longues générations ne soient pas interrompues; chaque run affiche ses tokens, son coût et son modèle. |
| Générateur de contenu mode Améliorer/réécrire | AméliorerRéécrireDiffuser (SSE)Copier |
Le mode Améliorer prend un contenu collé + des
instructions libres et le réécrit en streaming SSE (GET ou
POST /ai/stream-improve). Il peut consommer un JSON analysis_results pour cibler des corrections précises (issues d'audit à corriger). Le prompt est bâti via PromptBuilder::buildImprove et le plafond max_tokens est relevé à 8192 afin de permettre une réelle expansion du texte. La sortie est copiable dans le presse-papiers et affiche les tokens, le coût et le modèle du run. |
| Générateur de contenu Méta SEO | GénérerCopier |
Le mode Méta (POST /ai/generate-meta) génère un couple
meta title + meta description avec des cibles de longueur
strictes: 55–65 caractères pour le titre, 140–155 pour la
description. La réponse renvoie des champs aplatis meta_title et meta_description directement exploitables. Le run expose les tokens, le coût et le modèle consommés. |
| Générateur de contenu Suggestions | Suggérer |
Le mode Suggestions (POST /ai/suggestions) produit trois
types de propositions, validés par type côté serveur avant
l'appel Claude: titres, plan (outline) ou mots-clés. Un type invalide est rejeté avant toute dépense IA. |
| Outils IA intégrés à l'éditeur (AJAX) | SuggérerRégénérerRésumerTaguer |
Assistants AJAX invoqués depuis les éditeurs
d'article/page/taxonomie. • Stratégie de mots-clés (POST /ai/suggest-keywords): depuis un mot-clé principal, renvoie 2–4 secondaires + 8–15 lexicaux et un titre suggéré de 55–65 caractères, sensible au content_type et au contexte. • Régénérer le titre (POST /ai/regenerate-title): 3 titres SEO alternatifs honorant des instructions libres optionnelles, mot-clé placé en tête, 55–65 caractères. • Régénérer la méta (POST /ai/regenerate-meta): meta title+description suivant les instructions. • Générer un extrait (POST /ai/generate-excerpt): résumé accrocheur ≤200 caractères depuis le contenu. • Suggérer des tags (POST /ai/suggest-tags): jusqu'à 5 tags pertinents depuis le contenu ou le mot-clé. |
| Analyse SEO par IA | AnalyserScorer |
POST /ai/analyze appelle Claude pour produire un score
0–100 accompagné de issues[] et strengths[] au format
JSON. C'est une analyse facturée (appel IA réel), distincte du scoring déterministe. Résultat destiné à guider les corrections dans le mode Améliorer. |
| Score de contenu déterministe (sans IA) | Scorer (sans IA) |
POST /ai/analyze-content exécute un scoring PHP purement
déterministe (ContentAnalyzer), SANS aucun appel IA ni
coût. Il parse une entrée mots-clés fournie au format JSON ou CSV. Il applique des seuils de mots minimum distincts selon qu'il s'agit d'une page ou d'un article. |
| Assistants taxonomie (catégories) | Générer |
Via /ai/generate-article avec un paramètre type, l'IA
assiste les éditeurs de catégorie/taxonomie. • category_description: description en texte brut. • category_keyword: mot-clé focus unique. • category_icon: icône Font Awesome issue d'une liste blanche (~130 choix curés) avec repli sûr sur fa-folder si aucun choix valide. |
| Générateur d'images IA (WaveSpeed FLUX) | GénérerDimensionnerTéléchargerInsérer |
Page de génération d'images WaveSpeed FLUX
(/admin/ai/images). Génère depuis un prompt libre (POST
/ai/generate-image) ou construit automatiquement un prompt
à partir d'un mot-clé via PromptRegistry
'image.article_cover'. Dimensions choisies parmi des tailles autorisées (512, 720, 768, 1024, 1280, 1536; dimensions invalides silencieusement écartées) avec presets Carré 1024, Paysage 1280×768, Portrait 768×1280, Large 1536×1024, et prompt négatif optionnel; exemples de prompts et astuces fournis. L'image générée est auto-téléchargée, crée un enregistrement média (is_ai_generated=1), fixe l'alt_text (mot-clé ou prompt) et génère des variantes WebP responsive 300/768/1200 (srcset), sauvegardées dans la Médiathèque. Affiche le solde de crédits WaveSpeed en direct, l'estimation d'images restantes et le coût par image, avec avertissements de solde faible ou épuisé. Une pré-vérification budgétaire renvoie HTTP 429 avec motif avant toute dépense; les erreurs WaveSpeed remontent en messages 502 actionnables; la dépense est journalisée même si le téléchargement/variante/insertion échoue ensuite (intégrité de facturation, statut 'failed'). |
| Générateur d'articles IA optimisés SEO (cocon-type) | GénérerDiffuser (SSE)Plafonner (budget) |
Flux séparé /admin/articles/generate (GET
/articles/generate pour le formulaire) qui génère un
article JSON complet (titre, contenu, extrait, métas,
mot-clé, tags), en mode bloquant (POST /articles/generate)
ou streaming SSE (GET /articles/generate/stream). Le cocon_type pilote longueur et modèle: pillar (~2500 mots, modèle content_gen), cluster (~1500), support/défaut (~1000) modèle résolu depuis les réglages (ai_model_pillar ou ai_model_article) avec repli config. La langue est résolue via LanguageResolver::forGeneration. Le prompt système injecte la LISTE COMPLÈTE des mots-clés focus existants marqués INTERDITS (garantie d'unicité, anti-cannibalisation). Le plafond budgétaire IA est vérifié avant génération (402 en cas de refus) et la dépense est journalisée dans ai_requests. |
| Tableau de bord des coûts IA | Filtrer (période)ConsulterVisualiser |
Analytique de dépense agrégée sur toutes les requêtes IA
(/admin/ai-costs). Bascule de période 7 jours / 30 jours / 1 an (?period=week ou month ou year). Totaux affichés: coût total, nombre de générations, budget mensuel, pourcentage d'utilisation du budget (code couleur au-delà de 50% et 80%) avec barre de progression. Tableau 'Coûts par modèle' (appels, tokens prompt/completion/total, coût, durée moyenne) et tableau 'Coûts par type' (appels, coût), plus un graphique à barres 'Coûts quotidiens' (Chart.js auto-hébergé). Le monthly_budget est lu depuis settings (group=ai). |
| AI Prompt Designer | ParcourirÉditerAméliorer (IA)Expliquer (IA)VersionnerRestaurerRéinitialiser |
Catalogue (/admin/prompts) pour voir, éditer,
améliorer/expliquer par IA, versionner et annuler chaque
prompt système du CMS; restreint à la permission
settings.edit. Prompts groupés par catégorie (writing, tools, theme, advanced-code-managed) avec badges Factory/Customized/Code/Contract et un compteur d'alertes pour les prompts personnalisés ayant cassé leur contrat de sortie. L'édition (GET /prompts/edit) montre le défaut usine, l'override courant, les {tokens} déclarés et l'historique complet des versions; la sauvegarde (POST /prompts/save) valide la présence des placeholders requis, refuse le vide, ignore les no-op, ajoute à un historique linéaire et écrit un journal d'audit. Restauration d'une version antérieure (POST /prompts/restore restaurer un marqueur de reset revient à l'usine, la restauration est elle-même versionnée) et réinitialisation usine (POST /prompts/reset, efface l'override mais conserve les versions custom). 'Improve with AI' (POST /prompts/assist mode=improve) déclenche un méta-prompt 'Principal AI Prompt Architect' renvoyant réécriture + évaluation + scores sur 8 dimensions + scores projetés + issues + stratégie + changements, en validant que placeholders et marqueurs de sortie requis sont conservés et qu'aucun schéma de sortie n'a fuité; 'Explain' (mode=explain) fournit une explication ligne par ligne + astuces dans la langue de l'admin. Les prompts gérés par le code (cocon.master, topical.graph, editorial.profile ou ideas, seo_agent.foundation, theme.compose, theme.custom_block, voice.dialogue) sont en lecture seule avec références fichier:ligne. |
| Routeur de modèles IA | AffecterRéinitialiserConsulterDemander un avis (IA) |
Matrice d'affectation modèle-par-tâche (/admin/ai/models),
restreinte à settings.edit: assigne un modèle
Claude/WaveSpeed précis à chacune des ~30 tâches IA,
groupées en 9 catégories (General, Topical map, Semantic
cocoon, Content & editor, Images, AI agents, Theme
Studio, System & internal, Plugins). Par tâche: pool de modèles autorisés, défaut config, override courant, et recommandation curée best/value avec une justification en une ligne; possibilité de laisser ' default '. Enregistrement de toute la matrice dans Setting ai.model_overrides (POST /ai/models/save, valide les ids contre les cartes de coût en direct); réinitialisation d'une tâche ou de tout (POST /ai/models/reset avec clé ou 'all'). 'Second opinion' (POST /ai/models/advise) fait recommander par Claude le meilleur modèle qualité et le meilleur rapport valeur pour une tâche parmi son menu autorisé, justification dans la langue de l'UI. Cartes de référence des modèles (nom, palier, badge de latence, prix calculé depuis la carte de coût en direct, descriptif, forces, best-for) incluant Opus 4.8/4.7/4.6, Sonnet 5/4.6, Haiku 4.5, Fable 5, FLUX schnell/dev/pro, les modèles legacy étant déconseillés. Les changements prennent effet dans toute l'app sans redéploiement (ClaudeClient::modelForTask et WaveSpeedClient lisent l'override au moment de l'appel); une option 'model' codée en dur l'emporte toujours sur le routeur; chaque save/reset écrit un journal d'audit. |
| Agent Éditorial Mission Profil | ProfilerExplorer le webRéviserPublierRejeter |
Mission Profil (SSE POST /editorial/agent/mission
action=profile): profile la niche, l'audience et le ton à
partir du contenu réel du site, puis effectue des
recherches web pour découvrir les concurrents avec URLs de
preuve; produit une proposition en attente (thématiques +
concurrents + pédagogie). Révision d'une proposition (GET /editorial/agent/proposals/{id}) puis validation/publication (POST .../publish → écrit thématiques et concurrents dans les réglages et fixe profile_done) ou rejet motivé (POST .../reject). |
| Agent Éditorial Mission Idéation & pipeline de garde | IdéerScorerFiltrer (garde)Dédupliquer |
Mission Idéation (SSE POST /editorial/agent/mission
action=ideation): trois scouts (tendances web, lacunes
concurrents, mineur interne sur recherches manquées,
cocons incomplets et articles obsolètes) + un stratège
produisent N briefs scorés de type
create/update/complete. Un pipeline de garde déterministe s'applique ensuite aux idées: validation de placement (catégorie, pilier, article cible doivent être réels), hygiène des URLs sources, anti-cannibalisation (correspondance exacte du focus_keyword + similarité de titre Jaccard qui reclasse un 'create' en 'update'), déduplication du lot vs idées récentes, et plafond de lot. |
| Agent Éditorial Inbox d'idées & décisions | ListerOuvrirApprouverRejeter |
Inbox d'idées: liste par statut (proposed, approved,
writing, drafted, failed, rejected, expired) et ouverture
du brief complet d'une idée en modale (GET
/editorial/agent/ideas/{id}). Approuver une idée l'envoie dans la file de rédaction (POST .../approve); la rejeter avec un motif fait mémoriser le retour par l'agent (POST .../reject). Chaque décision est enregistrée dans editorial_memory pour la traçabilité. |
| Agent Éditorial Rédaction du brouillon | RédigerDiffuser (SSE)Approuver-et-écrire |
'Approve & write now' (SSE POST
/editorial/agent/ideas/{id}/write) diffuse le brouillon en
direct et auto-approuve en un geste une idée encore
'proposed'. La rédaction produit un article DRAFT placé en catégorie/cocon avec seo_meta (mots-clés secondaires+lexicaux), tissage de liens internes depuis de vrais articles publiés, assainissement HTML, garde finale d'unicité du mot-clé, workflow_status=in_review et review_needed=1. Une checklist de finition signale ce qu'un humain doit compléter (catégorie, liens internes, image à la une, mots-clés secondaires/lexicaux, extrait, meta description) sur le brief + une Notification. L'agent ne publie JAMAIS: il ne crée que des brouillons. |
| Agent Éditorial Règle des 48h & balayage cron | AutomatiserBalayer (cron)Planifier |
Règle des 48h via balayage cron: les idées non tranchées
au-delà de leur échéance de 48h sont auto-approuvées
(meilleurs scores, dans le quota hebdomadaire) en
brouillons, ou expirées. Le sweep récupère aussi les idées 'writing' bloquées et écrit une idée approuvée par tick. Planification auto-entretenue (ensureSweepScheduled): une tâche editorial_sweep en attente existe toujours; le quota hebdomadaire de rédaction est appliqué. |
| Agent Éditorial Configuration & couches de prompts | ConfigurerPersonnaliser (prompts)Restaurer |
Configuration de l'agent (POST /editorial/agent/config):
ideas_per_batch (1–15), weekly_quota (1–10), cadence_days
(1–30), bascule auto_approve_48h, plus listes éditables de
thématiques et de concurrents (re-validées côté serveur,
liste blanche http(s), plafonds). Personnalisation des prompts (POST /editorial/agent/prompts, settings.edit uniquement): 4 COUCHES admin éditables (charter, ideation, writer, profile, ≤1500 caractères chacune) posées par-dessus un cœur verrouillé, avec restauration en un clic de la valeur précédente d'une couche; les changements sont journalisés dans la mémoire de l'agent. On peut consulter les prompts effectifs (cœur verrouillé + couches courantes) ainsi que la liste des brouillons. |
| Gouvernance des coûts IA, budgets & journalisation (transverse) | PlafonnerLimiter (débit)JournaliserStocker |
Couche d'enforcement traversée par chaque fonctionnalité
IA. Plafonds budgétaires quotidien et mensuel (la valeur AI des Settings DB l'emporte sur config/env, défauts durs 10 $/jour et 100 $/mois) vérifiés avant chaque appel IA, refus remontés avec motifs (429/402/503); une vérification en mode système (sans session) applique les mêmes plafonds à la dépense globale pour le cron. Limite de débit par minute (requests_per_minute) + RateLimitMiddleware('ai') au niveau route sur chaque endpoint /admin/ai/*. Journalisation de chaque requête texte dans ai_requests (provider, modèle, type, tokens prompt/completion/total, cost_usd, duration_ms, status) et de chaque image dans ai_requests + lignes de détail ai_images (modèle, prompt, negative_prompt, dimensions, steps, seed, output_url, status); le contenu généré est stocké dans ai_generated_content (drapeau is_applied, markApplied). logClaudeRequest permet aux services collaborateurs (Cocon, Éditorial, Theme) d'enregistrer leur dépense uniformément; des appels Claude avec recherche web (sendWithWebSearch) et un vrai tool-use (sendTools) sont disponibles pour les agents. |
| Assistants carte topique & cocon (AiService) | GénérerSuggérerAnalyser (cannibalisation) |
Générateurs Claude additionnels exposés via AiService et
la vue admin/ai/thematic.php. • Plan thématique (generateThematicPlan): 1 pilier + N briefs d'articles cluster (titre, slug, mot-clé, méta, extrait, plan) au format JSON. • Suggestion d'inputs business (suggestBusinessInputs): produit, vrais concurrents, personas et localisations depuis mot-clé + langue + marché. • Suggestion de méta de sujet (suggestTopicMeta): nom de projet + marché cible. • Analyse de cannibalisation par lot pour une carte topique entière (checkCannibalizationMap): un seul appel IA détecte les conflits de mots-clés intra-carte et propose des correctifs merge/differentiate/canonical/delete avec filtrage du bruit d'ascendance; un check mono-mot-clé avec remédiation IA existe aussi (checkCannibalization). L'intention sémantique du menu de commandes (commandIntent) est également routée via AiService. |
Intelligence Artificielle
🎙️ Assistant vocal / Commande vocale Nouveau
14 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Mot d'activation passif « OK Clustraly » (wake word) | ÉcouterDétecterRéveillerRelancer |
Écouteur côté client qui transforme la parole en requêtes
du menu de commandes : ce n'est qu'un canal au-dessus de
command-menu.js, jamais un second cerveau. • Détection de la phrase de réveil via findWake() avec variantes localisées (« ok clustraly ou okay clustraly ou ok clusterly ou ok cluster ly »), en retenant la dernière occurrence. • À la détection : passage en mode commande, avec consommation optionnelle des mots prononcés après le mot de réveil dans la même phrase (commande inline). • Écoute continue passive : l'API SpeechRecognition auto-arrêtante est relancée par un watchdog (backoff exponentiel 400ms→10s). • Écoute suspendue quand un input/textarea/select/contentEditable a le focus, quand l'onglet est masqué ou quand la permission micro est 'prompt'/'denied' ; garde-écho ignorant les résultats pendant que la TTS parle ; délai d'inactivité de 12s ramenant du mode commande au mode réveil. Les transcriptions intermédiaires alimentent cm.setQuery pour afficher ce qui est entendu. |
| Bouton micro push-to-talk | BasculerActiver voixDemander microAfficher l'état |
Un bouton micro d'en-tête (#voice-btn,
[data-voice-toggle]) et un micro dans le lanceur
([data-voice-ptt]) démarrent le mode commande au clic un
geste utilisateur qui peut (re)accorder le micro même
après un blocage. • Au premier clic : activation automatique de la voix (saveFlags voice_enabled=1) et demande du micro. • Indicateur d'état en direct (data-voice-state off/paused/listening/active) avec aria-pressed et une région de statut aria-live. • Les boutons restent masqués tant que voice-assistant.js n'a pas confirmé le support du navigateur et que l'utilisateur n'a pas activé la voix. |
| Navigation vocale et choix vocal des résultats | NaviguerProposerChoisirAnnulerÉnoncer |
À partir des scores lexicaux du lanceur, décide de
naviguer directement, d'offrir un choix multiple parlé, ou
d'escalader vers le dialogue IA. • Navigation directe quand le meilleur score ≥ 70 (STRONG_SCORE) ou qu'un seul vrai résultat existe, routée via cm.activate pour que frecency/apprentissage se déclenchent comme un Entrée clavier. • Plusieurs vrais résultats : énonce « J'ai trouvé N résultats lequel ? » et attend un choix ordinal. • Sélection par ordinal vocal : « premier/deuxième/troisième » (+ « numéro un/1 », etc., appariés sur mot entier pour les variantes courtes) ; annulation vocale (« cancel ou close ou never mind ») fermant le lanceur et sortant du mode commande. • Le libellé de destination est énoncé avant de naviguer, plafonné pour que la navigation ne soit jamais retardée > 2,5s. |
| Réponses parlées (synthèse vocale / TTS) | ÉnoncerActiver/désactiverInterrompre |
Narration optionnelle des réponses de l'assistant via
SpeechSynthesis, activable par utilisateur, dans la locale
de l'admin. • Énonce salutations, comptes de résultats, confirmations de navigation, résumés d'écriture et lignes d'erreur. • Conditionnée au drapeau par utilisateur voice_speak_replies et à la disponibilité de speechSynthesis dans le navigateur. • Annule l'énoncé précédent avant de parler, avec un plafond de temps strict pour que la TTS ne bloque jamais le flux (NAV_SPEECH_CAP_MS). • Utilise cfg.lang (VoiceService::bcp47 de la locale courante) comme tag BCP-47 de l'utterance. |
| Page de réglages vocaux par utilisateur | Basculer les drapeauxEnregistrerDemander microAfficher les notes |
Hub de réglages du propre compte à /admin/profile/voice
avec trois drapeaux, enregistrés par POST de formulaire
classique ou par une API JSON de fond utilisée par le
JS. • « Assistant vocal activé » (interrupteur maître ; le cocher demande la permission micro sur le geste via getUserMedia), « Toujours écouter "OK Clustraly" » vs push-to-talk, et « Réponses parlées ». • Enregistrement soit par POST de formulaire protégé CSRF (→ flash « Voice settings saved. » et voice_onboarded=1), soit par POST JSON /admin/api/voice-settings (fail-open {saved:bool}) utilisé pour l'accept/refus d'onboarding et l'auto-désactivation propre. • Affiche une note « non supporté dans ce navigateur » (Firefox/Brave/HTTP) au lieu du formulaire, et « désactivé par l'administrateur » quand le kill switch est actif. |
| Modale d'onboarding (première visite) | PrésenterTesterConfirmerPersisterIgnorer |
Modale en 4 étapes affichée une seule fois (quand le
drapeau voice_onboarded est absent et le kill switch
inactif) pour obtenir le geste micro et exécuter un test
de mot de réveil en direct. • Étape intro : pitch + avis RGPD avec « Activer le microphone »/« Pas maintenant ». • Étape test : essai en direct (mode 'waketest') réussi quand la phrase de réveil est entendue ; étape done : confirmation de succès puis entrée automatique en mode commande. • Étape denied : permission refusée → désactivation persistante propre, réactivable dans les réglages. • Toute fermeture (backdrop/Échap/« Pas maintenant ») force voice_onboarded=1 pour que la modale ne revienne jamais ; chaque issue persiste voice_enabled/voice_onboarded via l'endpoint JSON de réglages. |
| Dialogue IA vocal V2 (endpoint voice-dialogue) | NaviguerClarifierPréremplirPlafonnerRetomber (fallback) |
Quand la couche lexicale ne trouve rien, un tour Claude
tool-use résout l'intention sur l'index d'écrans filtré
par permissions + le registre d'actions ; l'état vit côté
client, plafonné à 6 tours utilisateur. • Outil navigate : ouvre un écran admin par son id, l'URL étant résolue côté serveur depuis l'index client, jamais depuis la sortie IA. • Outil ask_clarification : une question courte avec 2–3 options localisées, choisies par voix ou clic ; outil prefill_navigate : ouvre un écran de création avec un paramètre extrait par l'IA (ex. titre d'article/page) whitelisté et borné en longueur. • Plafond de 6 tours utilisateur imposé côté client et serveur, avec validation stricte de l'alternance user/assistant ; fail-open absolu (pas de clé IA, budget épuisé, timeout ou entrée malformée → {action:'fallback'}, retour au lanceur classique). • Le niveau-2 IA propre au lanceur est supprimé pendant que le dialogue pilote (un seul chemin facturé à la fois). Route POST /api/voice-dialogue protégée par RateLimit 'ai' + CSRF. |
| Clarifications apprises (voix → command_menu_learned) | ApprendreEnvoyerIgnorer |
Une navigation de dialogue résolue enseigne la PREMIÈRE
requête parlée → entrée résolue dans le même magasin
appris que le clavier, pour répondre au niveau 1 la
prochaine fois. • Sur une réponse navigate avec entryId non nul, la paire (première requête normalisée, id d'entrée) est POSTée vers /admin/api/command-learn via sendBeacon/fetch. • L'apprentissage est ignoré pour les cibles paramétrées/prefill (entryId null) des one-shots sans rien à apprendre. |
| Écritures vocales V3 (proposer → confirmer → exécuter) | ProposerValiderConfirmerExécuterJournaliser |
L'IA peut PROPOSER une écriture ; le serveur valide+résume
seulement, l'admin confirme sur une carte visuelle, puis
un POST CSRF vers /voice-execute la réalise. Les créations
arrivent en brouillon, les suppressions sont
corbeille-seulement, tout est journalisé via:voice. • Quatre actions : create-draft-article (article en BROUILLON depuis un titre dicté + contenu optionnel dicté, HTML-assaini, jamais publié depuis la voix) ; create-draft-page (page en brouillon) ; trash-current (met à la corbeille l'entité de la page en cours, réversible destructif → confirmation par clic uniquement, jamais par « oui » vocal) ; set-status-current (bascule le statut en draft ou published ; publier exige le droit .publish, brouillon le droit .edit). • Le serveur re-valide permission, paramètres et l'entité concrète (id issu du contexte de page de confiance, jamais de l'IA) fail-closed au moindre doute. • Carte de confirmation visuelle Confirmer/Annuler : écritures non destructives confirmables par « oui » vocal (variantes d'affirmation), destructives exigeant le clic. • Chaque écriture auditée (voice.create / voice.trash / voice.status avec via:voice) ; la création est aussi versionnée (VersionService « Voice draft ») et invalide le cache. Écritures contextuelles offertes seulement en édition d'article/page (window.__voiceContext). |
| Dictée vocale par champ V3b (éditeurs) | DicterInsérerPonctuerCapitaliserArrêter |
Dictée push-to-talk indépendante pour les champs de
l'éditeur d'article/page : les transcriptions sont
insérées au curseur avec ponctuation parlée et
auto-capitalisation ; n'écrit rien au serveur. • Un bouton micro par champ ([data-voice-dictate=fieldId]) bascule la dictée pour un input/textarea (titre, contenu) ; transcriptions finales insérées au curseur avec espacement de jointure et dispatch d'un événement input pour le comptage de mots/autosave Alpine. • Ponctuation parlée sur 10 locales : point, virgule, point d'interrogation, point d'exclamation, deux-points, point-virgule, nouvelle ligne, nouveau paragraphe (appariement du plus long segment) ; auto-capitalisation légère en début, après ponctuation de fin de phrase et après retours à la ligne, sensible à l'Unicode multi-écritures. • Cycle push-to-talk avec relance watchdog ; Échap/soumission/onglet masqué arrêtent la dictée, et focaliser le champ met en pause le reconnaisseur de réveil pour qu'ils ne se disputent jamais le micro. • Boutons révélés seulement sur navigateurs non-Brave avec SpeechRecognition + contexte sécurisé + voix activée. |
| Kill switch global (Réglages → Avancé) | BasculerMasquerNeutraliser |
Réglage avancé réservé admin (voice_kill_switch) qui
masque et stoppe l'assistant vocal pour tous les
utilisateurs ; quand actif, le serveur n'émet aucun
__voiceConfig donc tout le JS vocal devient no-op. • Quand actif : le layout saute __voiceConfig + l'onboarding, la page de réglages affiche « désactivé par l'administrateur » et les diagnostics le signalent en ligne rouge. • Lecture fail-open partout (un incident de réglages ne provoque jamais de 500 sur le layout). |
| Page de diagnostics et de réinitialisation | RapporterTesterPingVider le cacheRéinitialiser |
Page d'auto-test vert/rouge de tout le sous-système vocal
(serveur + ce navigateur) à /admin/voice/diagnostics, avec
tests micro/TTS en direct et outillage de remise à
zéro. • Rapport serveur : tables présentes (user_settings, command_menu_learned), clé IA Claude configurée, état du kill switch, les 4 drapeaux de l'utilisateur, présence des clés i18n, liste des endpoints vocaux ; signatures des fichiers déployés (existence, taille, mtime, md5 court, présence de chaînes-fonctionnalités : setQuery, voice-dialogue, voice-execute, requestMicPermission, buildChunk, voice-dictate-btn) pour détecter un upload FTP obsolète. • Vérifs client : support SpeechRecognition/synthesis, chargement de __voiceConfig/__voice/__commandMenu/__voiceDictation, balises script présentes, __commandMenuIndex inliné, contexte sécurisé, détection Brave, état de permission micro, bannière DIAGNOSIS en langage clair. • Boutons : ping d'endpoint (GET /admin/api/command-search), self-test, test micro en direct, test de réponse parlée (TTS), vidage du cache vocal local (localStorage cmdmenu-recents / *voice*). • Réinitialisations : « mon compte » (POST /voice/reset, supprime les 4 drapeaux pour rejouer l'onboarding) et « tout le monde/global » (POST /voice/reset-global, droit settings.edit supprime les drapeaux de tous, vide command_menu_learned, désactive le kill switch, audité voice.reset_global). |
| Détection de capacité du navigateur et masquage des points d'entrée | DétecterÉcrire un cookieÉlaguer l'indexMasquer |
Serveur et client coopèrent pour que le module « se
comporte comme s'il n'existait pas » sur les navigateurs
incapables d'entrée vocale, sans toucher au reste de
l'admin. • browserSupported() côté serveur fait confiance à un cookie de verdict client (clustraly_voice_cap 1/0), sinon infère depuis HTTP-vs-sécurisé et l'UA Firefox pour décider s'il faut même rendre les points d'entrée vocaux. • Le client écrit le cookie de verdict à chaque chargement (6 mois, SameSite=Lax, Secure sur https) ; détection de Firefox (pas de SpeechRecognition), Brave (livre l'API mais retire le moteur → navigator.brave.isBrave asynchrone) et contexte non sécurisé. • pruneVoiceMenu() retire les tuiles du lanceur vocal, lignes d'aide, liens de sidebar et les ids voice-assistant/voice-diagnostics de l'index de recherche local à la première visite (pré-cookie). • Fail-open : tout accroc laisse le module visible et l'admin pleinement fonctionnel au clavier. |
| Extensibilité plugin des actions vocales | EnregistrerFiltrerAssainir |
Les plugins enregistrent leurs propres actions vocales de
navigation et d'écriture via des filtres, garde-formés et
filtrés par permissions comme command.menu.items. • Filtre voice.actions : ajoute des actions de navigation prefill dialoguables (cibles /admin same-origin uniquement, javascript: et protocole-relatif rejetés, params whitelistés/bornés en longueur, filtrés via Auth::can). • Filtre voice.write.actions : ajoute des actions d'écriture create/trash/status (kind et entité whitelistés, chaque écriture forcée en confirmation visuelle). • Les champs de compat V1/V3 (destructive, confirm) sont assainis pour qu'une écriture destructive de plugin ne puisse jamais être confirmée par la voix. |
Design & Thèmes
🎨 Thèmes, Widgets, Menus & Design Presets
14 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Bibliothèque de thèmes (liste & gestion) | ListerEnrichir métadonnéesAfficher badgesSignaler erreursGuider (état vide) | Grille de tous les thèmes installés, ordonnés, enrichie en direct depuis le manifeste (image d'aperçu, tags, auteur, version, description). Chaque carte affiche un badge « Actif » sur le thème en ligne, un badge « IA » pour les thèmes ai_generated, et un badge de score qualité Studio (tonalité pass/warn/blocked) renvoyant vers la carte de score. Une bannière d'erreur par thème apparaît lorsqu'un thème a planté et que le site est retombé sur le thème par défaut. Un état vide propose d'importer un thème ou d'en créer un avec l'IA. Servi par ThemeController::index et la vue admin/themes/index.php. |
| Activation de thème (porte qualité) | ActiverVérifier dossierBloquerForcerJournaliser | L'activation (POST /admin/themes/{id}/activate) vérifie d'abord que le dossier du thème existe sur disque (sauf « default »). Les thèmes Studio (v2) doivent franchir une porte qualité: blocage dur et non contournable sur problème critique, et blocage si le score est inférieur à 90 sauf envoi explicite de « Publier quand même » (publish_anyway=1). Les thèmes v1 non-Studio sont « grandfathered » (porte autorisée d'office). Chaque tentative est journalisée (theme.activate, activate_blocked ou activate_forced). Principe fail-open: un analyseur cassé ne bloque jamais l'activation. Logique portée par le service ThemeQualityGate. |
| Prévisualisation de thème | PrévisualiserRendre sans activer | GET /admin/themes/{id}/preview redirige vers le site public en /?theme_preview={slug}. Le rendu applique le thème choisi même s'il est inactif, sans le mettre en ligne. Permet de contrôler le rendu réel d'un thème avant activation. |
| Duplication de thème | DupliquerGénérer slug uniqueCopier dossierRéécrire theme.json | POST /admin/themes/{id}/duplicate clone à la fois les fichiers et l'enregistrement DB. Un slug unique est généré par incrément ({slug}-copy, -copy-2, …), le dossier du thème est copié récursivement, puis theme.json (name et slug) est réécrit dans la copie. Un nouvel enregistrement DB inactif est créé, et l'historique de score obsolète d'un slug réutilisé est purgé pour repartir propre. |
| Suppression de thème | SupprimerProtéger actif/defaultNettoyer historique | DELETE /admin/themes/{id} supprime les fichiers, l'enregistrement DB et l'historique de score. L'opération refuse de supprimer le thème actif ainsi que le thème système « default ». Le dossier est effacé récursivement et le fichier d'historique de score qualité est retiré. L'interface protège l'action par une modale de confirmation. |
| Export de thème en ZIP | ExporterEmpaqueterStreamer | GET /admin/themes/{id}/export construit une archive nommée {slug}-{version}.zip. Le contenu du dossier est ajouté récursivement à l'archive. Le fichier est ensuite streamé en pièce jointe application/zip avec un en-tête Content-Length, téléchargeant l'intégralité du thème dans une archive versionnée. |
| Import de thème préconstruit (ZIP) | ImporterValiderFiltrer cheminsAssainir SVGCréer enregistrement | POST /admin/themes/import (déclenché par un input fichier caché à auto-soumission) installe un thème depuis un ZIP avec validation de sécurité en couches. Contrôles: extension .zip et taille max 50 Mo, theme.json valide avec slug obligatoire (assaini en [a-z0-9-]), rejet d'un slug déjà existant. Sécurité pré-extraction: filtre anti-traversée de chemin (« .. » ou « / » initial), rejet des liens symboliques dissimulés (parcours lstat), et assainissement de chaque SVG embarqué via SvgSanitizer avant dépôt dans le dossier servi. L'installation utilise rename() avec un repli copie-récursive en cas d'EXDEV, sans jamais laisser d'arbre partiel; un enregistrement DB inactif est créé depuis le manifeste et l'action journalisée (theme.import). |
| Carte de score qualité | ConsulterDétailler par catégorieHistoriserAgir inline | GET /admin/themes/{id}/score affiche un bulletin en lecture seule notant un thème Studio sur 100 avec verdict pass/warn/blocked et cadran de score. Répartition par catégories pondérées: Design & lisibilité (20), UX & conversion (15), Mobile & responsive (15), Vitesse (15), SEO (20), Code & sécurité (15) et Anti « AI-look » (13). Détail repliable par vérification avec sévérité (critique/majeur/mineur) et messages, liste des problèmes bloquants critiques non contournables et des correctifs manuels recommandés (non mécaniques). Une timeline d'historique tague chaque événement (generate, edit, ai-edit, undo, custom-block, autofix) et des actions inline Activer / Publier quand même / Éditer / Prévisualiser sont proposées. Les thèmes non-Studio affichent une notice « non soumis à la porte » et un panneau fail-open « analyse indisponible »; le bulletin n'est jamais lui-même une porte. |
| Correction qualité en un clic (autofix) | Corriger mécaniquementRecompilerHistoriserJournaliser | POST /admin/themes/{id}/autofix applique des réparations qualité mécaniques. Il re-résout le skin en mode AA-safe (abandon des couleurs verbatim pour que SkinEngine re-dérive une palette WCAG-AA) puis recompile le thème en place. Le score post-correction est capturé dans l'historique (événement « autofix ») et l'opération est journalisée (theme.autofix, avec correctifs appliqués et nouveau score). Un message no-op est renvoyé lorsqu'aucune correction mécanique n'est disponible. |
| Personnalisateur visuel de thème | PersonnaliserPrévisualiser en directEnregistrerRéinitialiser | GET /admin/themes/{id}/customize ouvre un personnalisateur en panneaux scindés avec aperçu live (sans IA). Édition des couleurs Clair + Sombre (primary, secondary, accent, surface, surface_alt, text, text_secondary, border, success, warning, danger) via sélecteur de couleur et champ hex; choix des polices titres et corps dans une liste de 16 polices auto-hébergées; rayon de bordure de mise en page (4, 8, 12, 16 ou 24px) et style d'en-tête (sticky-blur, transparent ou solid); bascules d'animations (scroll reveal, barre de progression, retour en haut, parallaxe). L'aperçu iframe met à jour les variables CSS en temps réel, avec bascule d'affichage Bureau / Tablette(768) / Mobile(390). L'enregistrement (POST .../customize) fait un upsert par clé dans theme_settings; la réinitialisation (POST .../reset) annule toutes les personnalisations après modale de confirmation. |
| Menus CRUD & constructeur imbriqué | ListerCréerÉditerSupprimerRéordonner (glisser-déposer) | Gestion des menus de navigation: liste avec compteur d'items (GET /admin/menus), création (POST /admin/menus) et mise à jour (PUT /admin/menus/{id}) avec slug auto-unique, suppression (DELETE /admin/menus/{id}) du menu et de tous ses items. Ajout d'items (libellé, URL, classe d'icône) via le panneau de gauche, avec type d'item validé à custom, article, page ou category. Réordonnancement par glisser-déposer (SortableJS) et retrait d'items dans un constructeur Alpine; à l'enregistrement, les items sont persistés par re-synchronisation complète (syncItems). Les traductions d'items par langue sont supportées au rendu. Un ancien endpoint de tri (MenuController::sort) subsiste dans le code mais sa route a été retirée. |
| Widgets zones, types & gestion inline | AssignerÉditer inlineSupprimerRéordonnerAssainir HTML | 8 zones de widgets (Sidebar, Left Sidebar, Footer 1, Footer 2, Footer 3, Header Top, Homepage Hero, Between Articles) affichent chacune leurs widgets assignés. Ajout (POST /admin/widgets): choix parmi 10 types, titre et zone cible, type et zone validés contre une liste blanche. Les 10 types: Articles récents, populaires et liés, Nuage de tags, Catégories, Recherche, HTML personnalisé, Liens sociaux, Table des matières, Newsletter. Édition inline (PUT /admin/widgets/{id}): titre, bascule is_active, corps HTML personnalisé, la config étant fusionnée et non écrasée; suppression (DELETE) qui purge aussi les assignations de zone; glisser-déposer pour réordonner et déplacer entre zones (PUT /admin/api/sort/widgets, JSON). Le HTML personnalisé est assaini via liste blanche DOM (retrait des gestionnaires d'événements, des URIs javascript:/vbscript:/data: non-image et des directives de framework); le widget Newsletter rend un vrai formulaire d'inscription double opt-in (honeypot + piège temporel + CSRF) postant vers /newsletter/subscribe. Traductions de widgets par langue supportées au rendu. |
| Design presets & moteur de skins | Rechercher (BM25)RecommanderConvertir paletteMapper polices | Jeux de données UI/UX en lecture seule alimentant la génération de thèmes: presets de style (styles.csv), palettes de couleurs (colors.csv, tokens shadcn), pairings de polices (typography.csv) et règles de raisonnement UI (ui-reasoning.csv). Recherche BM25 sur un ou tous les jeux (search) et recommandation complète de design-system à partir d'un brief (designSystem). Conversion d'une palette shadcn vers le contrat theme.json de 19 clés × clair/sombre avec auto-ajustement WCAG AA (themeColors), calcul du ratio de contraste WCAG 2.1 (contrastRatio) et mapping vers police auto-hébergée (mapFontFamily) évitant toute requête Google Fonts. SkinPresets expose des swatches curées, 5 pairings de polices et des dimensions raffinables (densité, rayon, motion) partagées par le wizard et l'éditeur. |
| Bibliothèques de recettes & sections (catalogue de composition) | CataloguerValider (liste blanche)Matérialiser en plan | Catalogue faisant autorité sur le système de fichiers, adossé au Studio et aux presets. 13 recettes couvrant les types de site (blog-classic/editorial/minimal, magazine-editorial/frontpage/visual, vitrine-classic/showcase/studio, landing-app/product/proof/saas), validées contre la liste blanche des sections live (les recettes malformées sont ignorées, fail-open). Familles de sections: header (5), footer (4), hero (8), posts (4), marketing (13), media (4), misc (4). La fonction materialize() transforme une recette + un skin en un plan de site complet et compilable. Un catalogue de personnalités de skin (personalities.json) fournit les presets editorial, galerie, magazine, studio, deepspace et aurora. |
Design & Thèmes
🧩 Theme Studio (cœur, IA) Nouveau
16 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Assistant Étape 1 : Brief | Ouvrir l'assistantDécrire le siteChoisir le typeSélectionner la languePréciser (options) | Moteur du cœur (distinct du plugin AI Studio Design), ouvert via GET /admin/themes/studio (l'ancienne route /themes/create y redirige). L'utilisateur décrit le site en texte libre, choisit un type parmi Blog, Magazine, Vitrine (showcase) ou Landing page, et une langue de contenu parmi 10 locales (en, fr, es, de, pt, zh, ar, ru, ja, id). Un volet « Plus d'options » permet de renseigner le Nom du site et le Secteur. Le tout est piloté par un stepper client à 4 étapes (Brief, Propositions, Refine, Générer). Rendu par ThemeStudioController::wizard et la vue studio-wizard.php. |
| Assistant Étape 2 : Propositions | Générer 3–4 propositionsPaginerPrévisualiserChoisir | POST /admin/themes/studio/propose dérive 3 à 4 propositions déterministes du brief, sans aucun appel IA (croisement recipe × skin). Un bouton « Autres propositions » pagine par offset. Chaque proposition est prévisualisée dans son propre iframe same-origin via GET /admin/themes/studio/preview-live, rendue à partir de sa recette par slot. Choisir une proposition amorce les curseurs de l'étape Refine depuis son skin réel. S'appuie sur StudioProposals.php et RecipeLibrary.php. |
| Assistant Étape 3 : Refine (skin live) | Choisir paletteChoisir typoRégler densité, coins, motionBasculer clair/sombrePrévisualiser | Contrôles de design bornés, recompilés côté serveur à chaque changement. Palette parmi 12 swatches curées (ids 93,4,76,64,63,3,50,36,16,34,28,39), typographie parmi 5 pairings auto-hébergés (editorial, magazine, warm, modern, technical). Réglages Densité (airy/regular/compact), Coins/rayon (sharp/soft/round/pill), Motion (calm/editorial/energetic) et mode d'apparence (clair/sombre). L'aperçu iframe est recompilé serveur via previewLive/refinedSkin, toutes les entrées étant validées et whitelistées. S'appuie sur SkinPresets.php. |
| Assistant Étape 4 : Génération IA (SSE) | Lancer la générationSuivre la progression SSERecevoir le thèmeActiver, Éditer, Prévisualiser ou Régénérer | POST /admin/themes/studio/generate protégé par CSRF, rate-limit IA et permission themes.edit. Le pipeline IA réel diffuse sa progression en SSE : plan → copy → images → signature → compile → audit, avec une checklist live (Composition, Rédaction, Images, Finitions, Compilation). La composition reste verrouillée sur la recette choisie : l'IA écrit le contenu mais ne recompose jamais la mise en page. Nom de thème par défaut localisé si le Nom du site est vide, slug unique translittéré. À la fin, émet slug, theme_id, name, audit et URLs preview/activate/customize, journalise theme.studio_generate, puis affiche un écran résultat (Activer, Éditer dans le customizer, Aperçu complet, Régénérer le contenu) avec gestion d'erreur « Réessayer ». Moteur ThemeStudioGenerator.php + ArtDirector.php. |
| Éditeur de blocs sélection & édition directe | Ouvrir l'éditeurSurvoler puis cliquer une sectionCharger le schémaÉditer variant, options et slotsAppliquer le patch | GET /admin/themes/{id}/edit (permission themes.edit) rend le thème en mode édition avec marqueurs de provenance par section via editor-frame. Le survol met en surbrillance et le clic sélectionne une section dans l'iframe, editor-section charge le schéma du bloc et ses valeurs courantes. On édite le Layout/variant, les options (toggle booléen ou select enum) et les slots : texte, média (URL + sélecteur de bibliothèque), répéteurs list<link>, list<text> et list<object> (ajout, retrait, réordonnancement, list<text> imbriquée). editor-patch applique un patch borné, re-valide le plan et recompile atomiquement, les clés refusées ou ignorées étant signalées honnêtement, journal theme.block_edit. BlockPatchService.php. |
| Éditeur de blocs sélecteur média (bibliothèque) | Ouvrir la modale médiaParcourir images et vidéosChoisir un média | Ouverte depuis un slot média ou un champ média de répéteur. Parcourt images et vidéos via GET /admin/media/browse?type= en réutilisant l'endpoint de l'éditeur d'articles. Choisir un item écrit une URL relative à l'origine puis déclenche patch + recompilation. Implémenté dans studio-editor.php (picker, openPicker, chooseMedia). |
| Éditeur de blocs retouche IA conversationnelle | Envoyer un prompt de blocRouter l'action IARecompilerKeep/Cancel | POST /admin/themes/studio/editor-ai protégé par CSRF, rate-limit IA et themes.edit c'est le « Retoucher en parlant ». Un seul appel IA route la demande vers une action : edit (delta borné), insert (section de bibliothèque), delete, move up/down, skin patch, ou custom (nouveau bloc généré). Chaque édition qui aboutit snapshote le plan pré-édition (undo) et recompile atomiquement. Fail-open : une sortie IA inexploitable ne change rien (applied:false). Affordance Keep/Cancel pour les éditions IA, journalisé par action. BlockEditorService.php. |
| Éditeur de blocs opérations structurelles de sections | Lister les sections insérablesAjouter ou insérerSupprimerDéplacer haut/bas | GET editor-addable liste les sections insérables par familles (hero, posts, marketing, media, misc). editor-add-section insère une section de bibliothèque après le bloc sélectionné, amorcée avec ses slots par défaut. editor-delete-section supprime la section sélectionnée mais refuse la dernière section restante. editor-move-section réordonne haut/bas. Chaque opération valide le plan, snapshote pour undo, recompile atomiquement et journalise theme.block_structure. BlockStructureService.php + theme-library/sections. |
| Éditeur de blocs blocs custom IA (réutilisables) | Créer un bloc customValider et écrire sur disqueLister « Mes blocs »Insérer un bloc existant | POST /admin/themes/studio/editor-newblock (CSRF + rate-limit IA) génère une section sur mesure depuis un prompt. CustomBlockValidator la valide, puis manifest + partial + css sont écrits sous custom-sections/, la section est insérée dans le plan et le thème recompilé. editor-blocks liste les blocs custom réutilisables du thème, editor-insert insère un bloc existant. En cas de refus, l'outil suggère la section de bibliothèque la plus proche, journal theme.custom_block. CustomBlockService.php + CustomBlockValidator.php. |
| Éditeur de blocs panneau skin local & undo | Lire le skin courantAppliquer un changement de skin bornéAnnuler la dernière opération | GET editor-skin lit les valeurs de skin courantes. editor-skin-apply applique un changement borné : swatch de palette, pairing typographique, densité, coins/rayon, motion, mode clair/sombre par défaut. editor-undo annule le dernier snapshot de plan et revient sur une opération edit, add, delete, move ou skin. Journalise theme.block_skin et theme.block_undo, et chaque édition snapshote le score qualité post-édition. BlockSkinService.php + PlanHistory.php. |
| Carte de score qualité /100 | Voir la carteLire le détail par catégorieLister les blocagesConsulter l'historiqueAgir en ligne | GET /admin/themes/{id}/score affiche un bulletin en lecture seule notant le thème /100 (cadran pass/warn/blocked), jamais un gate en soi. Décomposition par catégorie pondérée : Design & lisibilité (20), UX & conversion (15), Mobile & responsive (15), Vitesse (15), SEO (20), Code & sécurité (15), Anti « AI-look » (13). Détail par check repliable avec sévérité (critical/major/minor) et messages, liste des problèmes bloquants (critical) non contournables et des corrections manuelles recommandées. Timeline d'historique taguée par événement (generate, edit, ai-edit, undo, custom-block, autofix) et actions inline Activer, Publish-anyway, Éditer, Prévisualiser. Les thèmes non-Studio affichent un avis « non soumis au gate » et un panneau fail-open « analyse indisponible ». ThemeQualityAnalyzer.php + ThemeScoreHistory.php. |
| Gate d'activation (qualité) | Activer un thèmeVérifier le disqueBloquer sur critiqueAutoriser un override bornéJournaliser | POST /admin/themes/{id}/activate met un thème en ligne, mais les thèmes Studio (v2) doivent passer un gate qualité. Vérifie d'abord que le répertoire du thème existe sur disque (sauf default). Blocage dur sur tout problème qualité critique (non contournable), et blocage si le score est inférieur à 90 sauf envoi de « Publish anyway » (publish_anyway=1). Les thèmes non-Studio v1 sont grandfathered (gate autorisé d'office). Fail-open : un analyseur cassé ne bloque jamais l'activation. Journalise theme.activate, activate_blocked ou activate_forced. ThemeQualityGate.php. |
| Autofix qualité en un clic | Lancer l'autofixRe-dériver une palette AA-safeRecompilerSnapshoter le scoreJournaliser | POST /admin/themes/{id}/autofix applique des réparations qualité mécaniques. Il re-résout le skin AA-safe (retire les couleurs verbatim pour que SkinEngine re-dérive une palette WCAG-AA) puis recompile sur place. Le score post-fix est snapshoté dans l'historique (événement 'autofix') et theme.autofix est journalisé avec les correctifs appliqués et le nouveau score. Un message no-op s'affiche quand aucune correction mécanique n'est disponible. ThemeQualityGate.php. |
| Customizer visuel | Ouvrir le customizerÉditer les couleurs clair/sombreChoisir les policesRégler layout et animationsPrévisualiserEnregistrer ou Réinitialiser | GET /admin/themes/{id}/customize ouvre un customizer live à panneaux séparés. Édite les couleurs Clair + Sombre (primary, secondary, accent, surface, surface_alt, text, text_secondary, border, success, warning, danger) via color picker + champ hex, et choisit les polices titres/corps dans une liste de 16 fontes auto-hébergées. Règle le border-radius (4/8/12/16/24px) et le style d'en-tête (sticky-blur, transparent, solid), et bascule les animations (scroll reveal, barre de progression, back-to-top, parallax). Aperçu iframe live mettant à jour les variables CSS en temps réel, viewport commutable Desktop, Tablet(768) ou Mobile(390). Enregistrement (POST .../customize) en upsert par clé dans theme_settings, réinitialisation totale (POST .../reset) avec modale de confirmation. customize.php. |
| Moteur du cœur presets design & SkinEngine | Rechercher (BM25)Recommander un design-systemConvertir la palette en WCAG-AAMapper les fontes auto-hébergées | Moteur du cœur (et non le plugin) : datasets UI/UX en lecture seule styles.csv, palettes colors.csv (tokens shadcn), pairings typography.csv, règles ui-reasoning.csv. Recherche BM25 sur un ou tous les datasets (search) et recommandation complète de design-system pour un brief (designSystem). Convertit une palette shadcn dans le contrat theme.json (19 clés × clair/sombre) avec auto-ajustement WCAG AA (themeColors), calcule le ratio de contraste WCAG 2.1 (contrastRatio) et mappe les familles de polices auto-hébergées (mapFontFamily), sans jamais requêter Google Fonts. SkinPresets expose les swatches curées, 5 pairings et les dimensions raffinables (densité, rayon, motion) partagés par l'assistant et l'éditeur. DesignPresetService.php + SkinEngine.php + SkinPresets.php. |
| Moteur du cœur bibliothèques de recipes & sections | Fournir les recipesFournir les familles de sectionsMatérialiser un site-planExposer les personnalités de skin | Catalogue faisant autorité par le système de fichiers, qui alimente le Studio du cœur. 13 recipes couvrant les types de site (blog-classic/editorial/minimal, magazine-editorial/frontpage/visual, vitrine-classic/showcase/studio, landing-app/product/proof/saas), validées contre la whitelist de sections live (recette malformée ignorée, fail-open). Familles de sections : header (5), footer (4), hero (8), posts (4), marketing (13), media (4), misc (4). materialize() transforme recette + skin en un site-plan complet et compilable. Catalogue de personnalités de skin (personalities.json) : editorial, galerie, magazine, studio, deepspace, aurora. RecipeLibrary.php + SectionLibrary.php + theme-library/recipes/sections/skins. |
Administration & Système
⚙️ Administration
17 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Comptes utilisateurs (CRUD & cycle de vie) | ListerCréerÉditerActiver/désactiverSupprimer | La liste (GET /admin/users) affiche le libellé de rôle, l'état actif, l'activation du 2FA, la date de dernière connexion et un badge couronne pour les super-admins. La création (POST /admin/users) prend username, email, display_name, password, role et is_active, et marque automatiquement is_verified pour les comptes créés par un admin. L'édition (PUT /admin/users/{id}) met à jour profil, rôle et état actif, le mot de passe restant inchangé s'il est laissé vide. Un bouton rapide (POST /admin/users/{id}/toggle-active) désactive « en douceur » un compte qui ne peut alors plus se connecter, et la suppression se fait via DELETE. Validation serveur stricte : username regex 3-50 caractères, unicité username/email, email valide, mot de passe >=8 caractères avec au moins une lettre et un chiffre. Chaque action mutante est écrite dans le journal d'audit (user.create, update, toggle_active, delete, disable_2fa) sans jamais journaliser de secret. |
| Garde-fous anti-verrouillage & anti-escalade | Bloquer suppression/désactivationRestreindre rôles | Règles de sécurité invisibles empêchant un admin de se verrouiller dehors ou d'élever ses privilèges. On bloque la suppression, la désactivation ou la rétrogradation du DERNIER super-admin actif (rôle portant le joker « * »), le contrôle s'exécutant dans une transaction verrouillée (SELECT ... FOR UPDATE) pour éviter une course TOCTOU. On interdit aussi de désactiver ou supprimer son propre compte. L'assignation de rôle est restreinte : un acteur non super-admin ne peut attribuer qu'un rôle dont l'ensemble de permissions est un sous-ensemble du sien (le super-admin peut tout attribuer). Côté UI, les boutons activer/désactiver et supprimer sont masqués pour sa propre ligne et pour la ligne du dernier super-admin. |
| Réinitialisation admin du 2FA d'un utilisateur | Réinitialiser/désactiver 2FAAfficher statut | Un administrateur peut réinitialiser (désactiver) l'authentification à deux facteurs d'un autre utilisateur, typiquement en cas de perte de son appareil d'authentification (POST /admin/users/{id}/disable-2fa). Le bouton n'est proposé que si l'utilisateur a effectivement le 2FA activé. Après réinitialisation, l'utilisateur se connecte avec son seul mot de passe jusqu'à ce qu'il ré-enrôle le 2FA. Le formulaire d'édition affiche pour chaque utilisateur le statut 2FA activé ou non-activé. L'opération est tracée dans le journal d'audit sous user.disable_2fa. |
| Rôles & permissions | ListerCréerÉditerSynchroniser permissionsSupprimer | CRUD des rôles (GET /admin/roles) avec le nombre d'utilisateurs par rôle et une matrice de cases à cocher de permissions. La création prend un name (slug regex minuscules/chiffres/-/_), un label, une description et un jeu de permissions ; l'édition (PUT) re-synchronise l'ensemble des permissions. Protections des rôles système : le name est immuable, le rôle ne peut être supprimé, et le joker super-admin « * » est ré-affirmé pour qu'un admin natif ne puisse jamais être dépouillé de ses pouvoirs. On refuse de supprimer un rôle assigné à des utilisateurs. Anti-escalade sur l'édition des permissions : un non super-admin ne peut ajouter ou retirer que des permissions qu'il détient lui-même, celles qu'il n'a pas étant gelées (impossible d'accorder « * » ou de retirer une permission plus riche). Les identifiants de permissions soumis sont validés/dédupliqués contre les vraies lignes de permissions (évite les lignes pivot orphelines), le cache de permissions Auth est vidé, et l'audit journalise role.create/update/delete. |
| Paramètres du site groupés (11 onglets) | ConsulterEnregistrer par ongletValiderChiffrer secrets | Hub de paramètres onglet par onglet (GET/POST /admin/settings?tab=) couvrant : general, reading, seo, social, email, ai, appearance, rgpd, languages, backup, advanced, chacun avec sa propre validation stricte. Exemples : General (nom/description/URL du site, fuseau validé contre DateTimeZone, format de date, posts_per_page, upload_max_size, langue) • Reading (homepage_type posts/page/landing + pages validées existantes et publiées) • SEO (meta par défaut, Google Analytics, Search Console, robots_txt, enable_schema) • Social (URLs réseaux, handle Twitter, image OG, schémas d'URL dangereux javascript:/data: rejetés) • AI (clé API Claude, modèle validé contre le catalogue de coûts, budgets jour/mois numériques positifs, clé WaveSpeed, langue de génération active) • Appearance (couleur, polices, logo/favicon protégés par schéma, CSS et JS d'en-tête personnalisés) • RGPD (mode simple/advanced/none, bannière, URL de confidentialité, cookie/TTL, anonymize_ip, rétention sessions) • Languages (default_language, enable_multilang, url_strategy prefix/query/subdomain) • Advanced (cache, minify_html, debug_mode, maintenance_mode, voice_kill_switch, require_2fa, require_email_verification, rétentions audit/corbeille/formulaires, log_level en liste blanche). Les champs secrets (Setting::SECRET_KEYS) sont stockés chiffrés ; un POST vide préserve le secret existant (les champs mot de passe ne sont jamais réaffichés) et l'absence d'APP_ENCRYPTION_KEY affiche une bannière et refuse d'enregistrer les secrets. Des routes proxy dédiées existent pour appearance, seo, rgpd, et l'audit journalise settings.update en enregistrant uniquement les noms de champs, jamais les valeurs. |
| Délivrabilité e-mail (DKIM / SPF / DMARC / bounce) | Générer clé DKIMRecommander DNSVérifier DNSServir le guideConfigurer SMTP/bounce | Conseiller de délivrabilité de l'onglet e-mail. Génère une paire de clés DKIM 2048 bits (POST /admin/settings/email/dkim/generate) en stockant la clé privée chiffrée et en pré-remplissant dkim_domain avec le sélecteur « clustraly » (audit settings.dkim_generate). Recommande des enregistrements DNS SPF/DKIM/DMARC adaptés au fournisseur SMTP détecté, avec une vérification DNS en direct des enregistrements recommandés (POST /admin/settings/email/check-dns renvoyant du JSON). Sert le guide de délivrabilité en texte brut depuis docs/DELIVERABILITY.md avec en-tête nosniff. Configure le SMTP (host, port 1-65535, username, password, encryption tls/ssl/none, from email/nom validés), la signature DKIM (dkim_enabled/domain/selector/private_key), l'adresse de rapport DMARC (dmarc_rua, email validé) et la boîte de rebond (bounce_enabled/host/port/username/password/encryption, suppress_bounced) avec validation port et chiffrement. |
| Configuration des sauvegardes & test destination | PlanifierChoisir destinationChiffrer archivesTester connexion | Onglet backup configurant la planification (none/daily/weekly/monthly), l'heure (HH:MM validée), le jour de semaine, le type de sauvegarde et le mode de rétention (jours ou nombre de copies, bornés). Le pilote de destination est choisi et validé contre StorageDriverFactory::DRIVERS : local, FTP/FTPS/SFTP (protocole, host, port 1-65535, user, pass, chemin, mode passif, empreinte host SFTP), S3-compatible (bucket, key, secret, region, endpoint protégé par schéma, prefix, path_style), ou Google Drive OAuth (access/refresh token, client id/secret, folder id). Le chiffrement d'archive au repos peut être activé avec une passphrase obligatoire (refus d'activation sans passphrase) et une option keep-local. Un test en direct de la connexion et des identifiants de la destination distante est disponible (POST /admin/settings/backup/test renvoyant du JSON, audit backup.test_connection). |
| Ré-encryption des secrets | Ré-encrypter secrets en clairRapporter le compte | Action de maintenance de l'onglet Advanced qui chiffre tout paramètre secret encore stocké en clair, par exemple écrit avant que la clé de chiffrement n'existe (POST /admin/settings/reencrypt-secrets). Elle ré-encrypte les secrets en clair vers la forme enc:v1: et rapporte le nombre de secrets ré-encryptés. L'opération refuse de s'exécuter quand APP_ENCRYPTION_KEY est absente et journalise un avertissement de sécurité. Elle s'appuie sur Setting::reencryptPlaintextSecrets côté modèle. |
| Gestion des langues de publication | ListerCréerÉditerDéfinir défaut/repliSupprimer | CRUD des langues configurées du site (GET /admin/languages), triées par langue par défaut puis sort_order. La création prend code (regex ISO 639, ex. en ou en-US), name, native_name, emoji drapeau, is_active, is_rtl, sort_order, avec rejet des codes en doublon. L'édition permet aussi de fixer is_default et is_fallback : cocher l'un décoche automatiquement toutes les autres lignes, avec un contrôle de doublon de code excluant soi-même. Garde-fous de suppression : on ne peut pas supprimer la langue par défaut, et on refuse la suppression si une ligne *_translations (article, page, catégorie, tag, menu_item) référence encore le code de langue. |
| Couverture des traductions & édition manuelle | Afficher matriceFiltrerÉditer côte-à-côteEnregistrer | Matrice de couverture (GET /admin/translations) affichant, par type d'entité (article, category, tag, page, menu_item) croisé avec chaque langue active, le compte traduit/total et le pourcentage avec des badges à code couleur. On peut filtrer les articles publiés en « tous » ou « traduction manquante » pour une langue donnée, avec pagination 25 par page. L'édition (GET /admin/translations/edit?type=&id=&lang=) présente le contenu source à côté des champs traduisibles. L'enregistrement (POST /admin/translations/save) applique des jeux de champs par type (title, slug, excerpt, content, meta_title, meta_description, image_alt pour les articles, etc.) ; entity_type est validé en liste blanche contre l'injection SQL, lang est validé contre les langues actives, et le résultat est inséré/mis à jour (upsert) dans les tables *_translations. |
| Traduction automatique IA du contenu | Auto-traduire (1 ou toutes langues)Contrôler budgetEnregistrer | Traduction en un clic d'une entité par l'IA Claude (POST /admin/translations/auto/{entity}/{id}) vers une seule target_lang ou, si omise, vers toutes les langues actives. Garde-fous : bloquée quand les fonctions IA sont désactivées (Paramètres > AI) et quand le budget IA est dépassé (HTTP 429). Elle utilise une invite à délimiteurs (@@@field@@@) issue du PromptRegistry pour que le HTML soit renvoyé brut, avec repli champ par champ vers la source si le modèle omet un champ, et journalise chaque requête Claude avec son coût. Elle est limitée en débit via le RateLimitMiddleware « ai », insère/met à jour les résultats dans les *_translations, et renvoie en JSON le nombre de traductions traitées. |
| Boîte de notifications | ListerMarquer luTout marquer luSupprimer | Liste par utilisateur des 50 dernières notifications (GET /admin/notifications), incluant les diffusions (user_id NULL), avec compteur de non-lues, pastilles de priorité (critical/warning/info) et badges de catégorie. On peut marquer une notification lue (POST /admin/notifications/{id}/read, en AJAX), tout marquer lu (POST /admin/notifications/read-all) ou supprimer une notification (DELETE). Protection IDOR : mayAccess() n'autorise à agir que sur ses propres notifications ciblées ou sur les diffusions, sinon renvoie 404. Un bouton « Voir » de lien profond optionnel est proposé par notification. |
| Préférences de notifications | Afficher matriceBasculer In-App/EmailEnregistrer | Matrice de préférences par utilisateur (GET /admin/notifications/preferences) sur 6 catégories : Contenu publié, Génération IA, Alertes SEO, Événements système, Alertes de sécurité, Import/Export. Pour chaque catégorie, l'utilisateur bascule la livraison In-App (enabled) et Email puis enregistre (POST /admin/notifications/preferences). Les choix sont insérés/mis à jour (upsert) dans la table notification_preferences. La logique s'appuie sur NotificationPreference (CATEGORIES, forUser, save). |
| Profil : 2FA (TOTP) | Consulter statutEnrôler (QR/clé)ActiverDésactiver | Espace 2FA en libre-service (GET /admin/profile/security) où le secret candidat n'est conservé qu'en session tant qu'il n'est pas vérifié. L'enrôlement se fait en scannant un QR otpauth rendu côté serveur en SVG inline (sans JS ni CDN) ou en saisissant la clé de configuration base32 manuelle. L'activation (POST /admin/profile/security/enable) exige la vérification d'un code à 6 chiffres ; le secret chiffré n'est persisté qu'après vérification, et l'activation est refusée si APP_ENCRYPTION_KEY est absente. La désactivation (POST /admin/profile/security/disable) requiert un code d'authentificateur courant OU un code de récupération et alimente le limiteur de débit par IP. Les événements d'activation et de désactivation sont journalisés côté sécurité. |
| Profil : codes de récupération 2FA | Afficher une foisCompter restantsRégénérer | Des codes de récupération à usage unique sont émis juste après l'activation ou la régénération du 2FA et affichés exactement une fois via un message flash. L'écran de sécurité affiche le nombre de codes de récupération inutilisés restants. La régénération du lot (POST /admin/profile/security/recovery-codes) invalide les anciens codes, exige un code d'authentificateur courant et est limitée en débit. La mécanique repose sur RecoveryCodeService (generateBatch, storeBatch, consume, remaining). |
| Profil : langue de l'interface admin | Consulter localesEnregistrer langue UICookie AdminLang | Sélection par utilisateur de la langue de l'interface admin, indépendante des langues de publication du site (GET /admin/profile/preferences), listant les locales disponibles depuis les fichiers lang/*.json livrés. L'enregistrement (POST /admin/profile/preferences) écrit le UserSetting ui_locale ; une valeur vide ou inconnue signifie « suivre la langue par défaut du site ». Il pose ou efface un cookie AdminLang valable un an pour que les pages de login et 2FA conservent la langue choisie après déconnexion. Le choix est aussi piloté par le sélecteur de langue de la barre supérieure, qui revient de façon sûre vers le chemin interne /admin d'origine en préservant filtres et pagination. |
| Surveillance de session (statut & prolongation) | Lire temps restantProlonger la session | Endpoints AJAX soutenant la fenêtre modale d'expiration par inactivité. Le statut (GET /admin/api/session/status) renvoie remaining_seconds, expires_at, la durée de vie et le jeton CSRF courant ; il NE DOIT PAS faire glisser la session (exclu via AuthMiddleware NO_SLIDE_PATHS) et est limité en débit par le limiteur « api ». La prolongation (POST /admin/api/session/extend), le « Rester connecté », fait glisser la session d'une durée de vie complète et fait tourner (rotate) le jeton CSRF en renvoyant le nouveau jeton ; elle est protégée par CSRF et limitée en débit. La mécanique s'appuie sur Session (expiresAt, lifetime, touch) et CsrfMiddleware. |
Administration & Système
🔌 Intégration Plugins & API interne
14 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Découverte & liste des plugins | ScannerListerAfficher statutAfficher badges |
PluginManager::discover() scanne le dossier
plugins/, lit chaque
plugin.json, valide le manifeste et renvoie
une liste triée (ksort) avec slug, manifeste, codes
d'erreur et drapeau valid. Le contrôleur
PluginController::index (GET /admin/plugins,
permission plugins.view) croise la découverte
disque avec l'état en base (installedState()
lit la table plugins) pour déterminer
installé/actif. La vue affiche par plugin un statut
(Actif, Inactif ou Non installé), un badge Expérimental
(drapeau experimental du manifeste), un badge
Manifeste invalide listant les codes d'erreur, et un badge
d'intégrité (Vérifié, Modifié ou ). L'intégrité n'est
calculée que pour un plugin installé, puisqu'elle exige un
checksum stocké.
|
| Validation de manifeste & confinement de namespace | ValiderConfinerSignaler erreurs |
validate() contrôle le
plugin.json: slug sûr (regex
^[a-z0-9][a-z0-9-]{1,79}$), cohérence slug
(pas de slug_mismatch), champs requis
présents et de type chaîne (name, version, namespace,
main), version au format semver, et main en
identifiant de classe valide. Le namespace doit
impérativement débuter par la racine
Clustraly\Plugins\ (erreur
namespace_not_under_plugins_root), ce qui
garantit qu'un plugin ne peut jamais masquer une classe du
cœur ou de l'application. Chaque code d'erreur retourné
est remonté dans l'UI. Le confinement est ensuite
matérialisé au démarrage par un unique autoloader PSR-4
restreint aux préfixes sous NS_ROOT.
|
| Activation & installation automatique | ActiverInstallerMigrerVérifier version CMSRejeter conflitJournaliser |
POST /admin/plugins/{slug}/activate (CSRF + permission
plugins.manage) appelle
activate(), qui installe d'abord si besoin
via install(): revalidation du manifeste,
contrôle de requires_cms par
version_compare contre la version du CMS,
exécution des migrations SQL du plugin, calcul puis
stockage du checksum SHA-256, et upsert de la ligne
plugins (is_active inchangé). Les fichiers de
migration doivent être préfixés par le slug ({slug}_
ou {slug}-), sinon l'installation échoue, car
le journal de migrations partagé est indexé par nom de
fichier. activate() refuse ensuite
l'activation si un autre plugin actif possède déjà le même
namespace (namespace_conflict), puis passe
is_active = 1. Le succès ou l'échec est
affiché en flash et l'action réussie est auditée
plugin.activate.
|
| Désactivation (bascule douce) | DésactiverJournaliser |
POST /admin/plugins/{slug}/deactivate (CSRF +
plugins.manage) appelle
deactivate(), qui valide le slug puis
positionne is_active = 0 dans la table
plugins. C'est un soft toggle: les fichiers
du plugin, ses données et ses migrations restent en place,
seul le chargement au démarrage cesse (seuls les plugins
actifs sont autoloadés et bootés). L'opération est
toujours signalée en succès et auditée
plugin.deactivate.
|
| Désinstallation (fichiers + registre) | DésinstallerSupprimer ligneEffacer fichiersConfirmerJournaliser |
DELETE /admin/plugins/{slug} (CSRF +
plugins.manage) appelle
uninstall(), qui supprime la ligne de
registre (DELETE FROM plugins) puis efface
récursivement le dossier du plugin via
rrmdir(). La suppression disque est
strictement confinée: le realpath du dossier
ciblé doit commencer par le realpath du
dossier plugins/, sinon rien n'est effacé, et
les liens symboliques ne sont pas suivis lors du parcours
récursif (protection contre les évasions de chemin). L'UI
demande une confirmation avant l'action, et la
désinstallation réussie est auditée
plugin.uninstall.
|
| Intégrité SHA-256 & détection d'altération | Calculer checksumVérifierAfficher badgeBloquer au boot (option) |
computeChecksum() calcule un SHA-256
incrémental (hash_init puis hash_update) sur tous les
fichiers .php et .json du
plugin, en hachant à la fois le chemin relatif et le
contenu de chaque fichier trié. Ce checksum est capturé à
l'installation/activation et stocké en base;
verifyIntegrity() le recompare avec
hash_equals pour afficher le badge Vérifié ou
Modifié. En option, la variable d'environnement
PLUGIN_INTEGRITY_ENFORCE=true transforme
cette détection en barrière fail-closed: au démarrage, un
plugin actif dont le code sur disque ne correspond plus au
checksum est refusé au chargement et journalisé, sans
interrompre le reste du boot. Par défaut la vérification
est désactivée (coût nul par requête) et reste une défense
en profondeur, présupposant un attaquant disposant déjà
d'un accès en écriture.
|
| Chargement isolé au démarrage | Autoloader PSR-4Charger actifs seulementIsolerIgnorer collisions |
PluginManager::boot() (invoqué avant l'action
cms.boot) ne charge que les plugins actifs et
valides. Il enregistre un unique autoloader
spl_autoload_register confiné aux préfixes
sous Clustraly\Plugins\, instancie la classe
main de chaque plugin, vérifie qu'elle
implémente PluginInterface, puis appelle
register(). Tout est encadré par try/catch:
un plugin absent, une classe introuvable, un contrat non
respecté ou un register() qui lève une
exception sont journalisés sans jamais interrompre la
requête. Un garde anti-shadowing ignore de façon
déterministe tout plugin actif réclamant un namespace déjà
revendiqué, et une table plugins manquante
(avant migration) fait échouer proprement (retour vide).
|
| Système de hooks (actions & filtres) | Enregistrer listenerDéclencher actionAppliquer filtreIsoler |
La classe Hooks est le socle d'extension des
plugins, avec deux mécanismes façon WordPress sur un
registre unique: les actions
(addAction/doAction) exécutées
pour leurs effets de bord (retour ignoré), et les filtres
(addFilter/applyFilters) qui
chaînent et transforment une valeur passée en premier
argument. Les listeners sont ordonnés par priorité
croissante (défaut 10) et ne reçoivent que leurs
acceptedArgs premiers arguments (défaut 1).
Chaque listener s'exécute dans un try/catch: une exception
est journalisée sans casser la chaîne, et pour un filtre
la valeur courante est conservée intacte. La liste des
listeners est figée (snapshot) avant itération pour une
ré-entrance déterministe; le cœur expose des hooks
documentés (cms.boot, content.saved, content.published,
content.render, view.output, seo.head, sitemap.urlset,
ai.generating, etc.).
|
| API JSON admin opérations articles | ListerLireCréerAutosauvegarder |
Endpoints JSON de gestion d'articles sous /admin/api.
articles (GET, pagination page/per_page
plafonné à 50, filtre status) et
article (GET /{id}) appliquent un contrôle au
niveau objet: les rôles non élevés (sans
articles.edit_others) ne listent/lisent que
leurs propres articles via ownsOrCan, sinon
403 JSON. createArticle (POST) décode le
corps JSON, exige un titre, désinfecte le contenu par
HtmlSanitizer::clean, génère un slug unique
via SlugService::generateUnique et met
status/visibility en liste blanche contre leurs ENUM
(sinon MySQL non strict coerce en valeur vide), avant de
renvoyer 201. autosaveArticle (POST
/{id}/autosave) crée une version de brouillon via
VersionService::createVersion de type
autosave et renvoie l'horodatage.
|
| API JSON admin endpoints SEO | Lire pondérationsVérifier cannibalisationCalculer score |
seoWeights (GET /admin/api/seo-weights)
renvoie les dix pondérations du score SEO lues depuis les
réglages (Setting::getValue avec valeurs par
défaut). checkKeyword (POST
/admin/api/seo/check-keyword) effectue un contrôle
anti-cannibalisation: recherche dans
seo_meta les articles partageant le même
focus_keyword (avec exclusion optionnelle
d'un id) et renvoie les conflits détectés.
seoScore (GET
/admin/api/seo/score/{articleId}) recalcule le score via
le moteur unifié
SeoService::calculateScore en réutilisant
titre, contenu, mot-clé focus, mots-clés secondaires et
lexicaux, slug, meta description et cible de mots,
garantissant exactement le même score que l'éditeur.
Toutes exigent la permission seo.view (le
POST ajoute le CSRF).
|
| API JSON admin analytics, heatmap & stats dashboard | Récupérer pages vuesRécupérer heatmapAgréger stats |
analyticsPageviews (GET,
?days borné entre 1 et 90) renvoie l'aperçu,
les pages vues par jour et le top 10 via
AnalyticsService.
analyticsHeatmap (GET /{articleId}) renvoie
les stats et zones d'une carte de chaleur d'article via
HeatmapService, avec un try/catch qui
convertit toute erreur SQL (ex. table
heatmap_data manquante) en 500 JSON
exploitable au lieu d'un 500 HTML illisible côté client.
dashboardStats (GET) agrège des compteurs
(articles, publiés, pages, médias, commentaires en
attente, score SEO moyen, redirections, meta manquantes,
analytics), chaque sous-requête étant tolérante aux pannes
(try/catch renvoyant 0 si une table n'existe pas encore).
Les deux premiers exigent la permission
analytics.view.
|
| API JSON admin actions groupées (bulk) | PublierMettre en brouillonArchiverSupprimer vers corbeille |
POST /admin/api/bulk (CSRF + articles.create)
applique une action sur une liste d'ids d'articles:
publish, draft, archive ou delete. Le contrôle au niveau
objet filtre d'abord les ids non possédés pour les rôles
non élevés (sans articles.edit_others), et
l'action publish est en outre soumise au verrou éditorial
articles.publish (403 sinon, avec invitation
à soumettre pour relecture). publish/draft/archive
réalisent un UPDATE de statut en masse (publish renseigne
published_at via COALESCE); delete route
chaque article vers la corbeille restaurable via
TrashService::trash plutôt qu'une suppression
définitive. La réponse renvoie le nombre d'éléments
affectés et l'action appliquée.
|
| API JSON admin préférences, layout, tri & autocomplétion | Enregistrer préférencesEnregistrer layoutRéordonner catégoriesAutocompléter |
preferences (PUT, CSRF) n'accepte qu'une
liste blanche stricte de colonnes réellement existantes
(dashboard_layout) et rejette tout autre
champ en 400, encodant les tableaux en JSON.
dashboardLayout (PUT, CSRF) filtre le layout
contre la liste canonique des blocs
(DashboardController::BLOCKS) avec
array_unique, un tableau vide restant une valeur légitime
(« tout masquer »). sortCategories (PUT, CSRF
+ categories.edit) met à jour
sort_order et parent_id par lot.
searchAutocomplete (GET, min 2 caractères,
permission articles.view) et
tagsAutocomplete (GET, permission
tags.view) renvoient des suggestions de
recherche et de tags. La méthode
sortMenuItems existe encore mais sa route a
été retirée comme code mort (le tri des menus est persisté
par la sauvegarde du formulaire de menu).
|
| API JSON admin sécurité & contrôle d'accès | Authentifier sessionAutoriser par permissionExiger CSRFLimiter le débit |
Tous les endpoints /admin/api sont regroupés sous un
RateLimitMiddleware('api') (limitation de
débit par seau « api ») et protégés par l'authentification
de session du groupe admin. La plupart des routes portent
un AuthorizeMiddleware de permission fine
(articles.view, articles.create, articles.edit, seo.view,
analytics.view, tags.view, categories.edit), et toutes les
mutations (POST/PUT) ajoutent
CsrfMiddleware pour bloquer les requêtes
inter-sites; quelques routes (stats du dashboard, layout,
préférences) s'appuient sur la session, plus le CSRF pour
les PUT. Le contrôle d'accès au niveau objet
(Auth::can, Auth::ownsOrCan)
restreint en plus lecture et actions aux ressources
possédées pour les rôles non élevés. Les réponses sont
uniformément en JSON avec des codes HTTP adaptés (403,
404, 422, 500).
|
Administration & Système
🔗 Intégration Jetons API, Webhooks & API REST publique Complété
16 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Jetons API liste et statuts | Consulter la listeVoir le préfixe masquéVoir propriétaire/scopes/dernière utilisation/expirationLire le statut | GET /admin/api-tokens affiche un tableau admin de tous les jetons émis. Chaque ligne présente le nom, un préfixe masqué (clustraly_xxxx…), le propriétaire (nom d'affichage ou username), les scopes accordés, l'horodatage de dernière utilisation, l'expiration et un statut dérivé. Le secret complet n'est jamais réaffiché ici seul le préfixe reste visible. Le rendu est produit par ApiTokenService::listForAdmin et le statut (Actif, Expiré ou Révoqué) est calculé dynamiquement par la méthode status. |
| Création de jeton avec révélation unique du secret | Créer un jetonChoisir les scopesFixer l'expirationCopier le secretJournaliser (audit) | POST /admin/api-tokens émet un jeton à haute entropie au format clustraly_<préfixe>_<secret>, dont seule l'empreinte SHA-256 est stockée en base (le secret en clair n'est jamais persisté). L'admin renseigne un nom, coche les scopes (content:read pré-coché, content:write) et définit une expiration en jours (0 = jamais). La valeur brute est affichée une seule fois via un message flash « one-shot » doté d'un bouton Copier dans le presse-papiers, puis devient irrécupérable. L'action api_token.create est journalisée dans l'audit, sans jamais y consigner le jeton brut. |
| Révocation de jeton | RévoquerConfirmerJournaliser (audit) | DELETE /admin/api-tokens/{id} révoque un jeton via un UPDATE idempotent et « race-safe » (résistant aux appels concurrents), de sorte que toute application l'utilisant cesse immédiatement d'être authentifiée. Une boîte de dialogue de confirmation avertit l'admin avant l'opération. L'action api_token.revoke est enregistrée dans l'audit. Le statut du jeton bascule alors définitivement sur « Révoqué ». |
| Modèle de scopes, expiration et dernière utilisation | Normaliser/dé-dupliquer les scopesVérifier le jetonHorodater l'usageDériver le statut | Chaque jeton porte un ensemble de scopes JSON normalisé et dé-dupliqué contre KNOWN_SCOPES (content:read, content:write) plus le joker '*'. À chaque appel authentifié, la méthode verify contrôle que le jeton est actif, non révoqué et non expiré. L'usage déclenche un marquage « best-effort » de last_used_at et last_used_ip (IP appelante) via touch. Le statut est dérivé à la volée en actif, révoqué ou expiré selon l'état de révocation et la date d'expiration. |
| Webhooks CRUD des points de terminaison | ListerCréerÉditerMettre à jourSupprimerBasculer actif/inactifJournaliser (audit) | Les écrans /admin/webhooks permettent de créer (POST), éditer (GET .../edit), mettre à jour (PUT) et supprimer (DELETE) des points de terminaison sortants, chacun défini par un nom, une URL HTTPS, les événements souscrits et un interrupteur actif/inactif. L'URL est soumise à une validation anti-SSRF stricte via SafeHttpClient: HTTPS obligatoire et adresse IP publique uniquement, ce qui bloque les cibles internes ou privées. La suppression d'un webhook supprime aussi son journal de livraisons associé. Les actions webhook.create, webhook.update et webhook.delete sont journalisées dans l'audit. |
| Gestion du secret de signature | Générer le secretRévéler à l'éditionRégénérer (rotation)Chiffrer au repos | Chaque webhook possède un secret de signature HMAC au format whsec_…, généré une seule fois et affiché une seule fois à la création. Le secret courant est révélable sur l'écran d'édition (clic pour sélectionner via data-select) et peut être régénéré lors d'une mise à jour en cochant regenerate_secret, ce qui réaffiche une seule fois le nouveau secret. Au repos, le secret est chiffré en AES-256-GCM (via le mécanisme Setting) dès qu'une clé de chiffrement est configurée. Ce secret sert à signer le corps de chaque livraison sortante. |
| Catalogue d'événements souscriptibles | Sélectionner via cases à cocherSouscrire au joker '*'S'abonner aux événements contenu/commentaire/contact/erreur | Un webhook s'abonne à un sous-ensemble des 9 événements dispatchables ou au joker '*' (qui se réduit à l'ensemble des événements). Le catalogue couvre article.published, article.updated, page.published, page.updated, comment.created, contact.created, ainsi que les alertes Error-Intelligence error.new, error.regression et error.spike. Les événements contenu, commentaire et contact sont câblés depuis le système de hooks (registerHooks). La normalisation (normalizeEvents) gère à la fois le joker et la sélection par cases à cocher. |
| Journal de livraison, ping de test et renvoi | Consulter les livraisonsEnvoyer un événement de testRenvoyer une livraison stockéeJournaliser (audit) | Un journal de livraisons à corps signé affiche, pour chaque envoi, le statut (Delivered, Failed ou Pending), le code HTTP de réponse, le compteur tentatives/max, l'erreur éventuelle et l'horodatage en vue globale (50 dernières) et par webhook (100). Le bouton « Send test event » (POST .../test) déclenche un ping synthétique vers l'endpoint. Le bouton « Resend » (POST .../deliveries/{id}/resend) rejoue une livraison déjà stockée. Les actions webhook.test et webhook.resend sont enregistrées dans l'audit. |
| Moteur de livraison signée et de retry | Mettre en fileSigner HMAC-SHA256Envoyer via client anti-SSRFRéessayer avec backoffMarquer succès/échec | Pour chaque webhook souscrit, une livraison est mise en file sous forme d'une ligne scheduled_tasks, puis envoyée par le worker cron. Chaque POST porte une signature HMAC-SHA256 dans l'en-tête X-Clustraly-Signature: sha256=…, accompagnée d'en-têtes event, delivery, webhook et timestamp, et transite par le SafeHttpClient (HTTPS/IP publique, délais d'expiration, réponse plafonnée). En cas d'échec, la livraison est réessayée avec un backoff exponentiel (1m, 5m, 30m, 2h, 6h) jusqu'à un maximum de 6 tentatives avant d'être marquée « failed ». Chaque tentative met à jour last_status et last_delivery_at du webhook. |
| API REST publique lecture des articles (/api/v1) | Lister les articles paginésObtenir un article par slugFiltrer par langue (?lang) | GET /api/v1/articles retourne des résumés paginés d'articles publiés et à visibilité publique, avec paramètre ?lang. GET /api/v1/articles/{slug} renvoie le détail complet (auteur, catégories, tags, image à la une). Le périmètre « publié + public » est imposé côté serveur via publishedArticles dans BaseController. L'authentification Bearer et le scope content:read sont requis. La sérialisation applique la locale multilingue et un whitelisting de champs (jamais Model::toArray), écartant toute fuite de champ interne. |
| API REST publique écriture des articles (/api/v1) | Créer un articleMettre à jour par slugAssainir le HTMLDéclencher les hooks contenuJournaliser (audit) | POST /api/v1/articles et PUT /api/v1/articles/{slug} créent ou mettent à jour des articles et exigent le scope content:write. Le HTML est nettoyé via HtmlSanitizer::clean, comme dans le chemin admin, en protection contre le XSS stocké. Le statut est limité à draft ou published (normalizeStatus), le slug est auto-unifié avec redirection en cas de changement, et le nombre de mots et le temps de lecture sont recalculés. Les hooks content.saving, content.saved et content.published sont déclenchés (ce qui fait donc partir les webhooks), et les actions api.article.create/api.article.update sont journalisées avec l'id du jeton appelant. |
| API REST publique lecture des pages (/api/v1) | Lister les pages paginéesObtenir une page par slugFiltrer par langue (?lang) | GET /api/v1/pages retourne une liste paginée de pages publiées à visibilité publique (avec ?lang), et GET /api/v1/pages/{slug} renvoie le détail (template, méta, image à la une). L'accès est en lecture seule, authentifié Bearer, et exige le scope content:read. Le périmètre publié+public est imposé côté serveur via publishedPages. La sérialisation applique la locale et un whitelisting de champs par ApiTransformer. |
| API REST publique taxonomies catégories et tags (/api/v1) | Lister les catégoriesObtenir une catégorie + ses articlesLister les tagsObtenir un tag + ses articles | GET /api/v1/categories et GET /api/v1/tags listent les termes en lecture seule. GET /api/v1/categories/{id} et GET /api/v1/tags/{id} renvoient le terme accompagné de ses articles publiés, paginés. Le scope content:read est requis sur ces routes. La sérialisation passe par ApiTransformer avec whitelisting de champs. |
| API REST publique recherche (/api/v1/search) | Rechercher (min 2 caractères)Re-filtrer en directRetourner suggestion/correction | GET /api/v1/search?q= effectue une recherche plein texte sur l'index préconstruit, avec un minimum de 2 caractères, un paramètre ?lang et une pagination. Les résultats sont re-filtrés en direct pour retirer les articles et pages non publics (dropNonPublic), en défense en profondeur. La réponse inclut d'éventuelles métadonnées de suggestion orthographique ou de correction. Le scope content:read est requis. |
| API REST publique doc de découverte/health & préflight CORS | Obtenir le document de découverte/healthRépondre au préflight CORS (OPTIONS) | GET /api/v1 renvoie un document de découverte/health contenant le nom de l'application, les versions api et cms, le nom et les scopes du jeton appelant, la liste des scopes disponibles et une carte des ressources. Ce document requiert le scope content:read. Les requêtes OPTIONS /api/v1 et /api/v1/{any} fournissent un repli de préflight CORS. Il sert de point d'entrée d'auto-documentation pour les intégrateurs. |
| API REST publique auth Bearer, gating de scope, CORS & rate-limit | Authentifier par BearerContrôler le scope par routeGérer le CORSLimiter le débit par jeton | Chaque endpoint /api/v1 exige un en-tête Authorization: Bearer résolu vers une ligne de jeton active, faute de quoi un 401 JSON est renvoyé avec un en-tête WWW-Authenticate (ApiAuthMiddleware). Un contrôle de scope par route (ApiScopeMiddleware) impose content:read ou content:write, le joker '*' satisfaisant n'importe quel scope, avec un 403 JSON insufficient_scope si le scope est insuffisant. Le CORS est pris en charge et une limitation de débit par jeton s'applique via le bucket 'api'. Ces middlewares s'appliquent au groupe de routes puis, plus finement, par route. |
Administration & Système
🛠️ Maintenance Sauvegardes, Mises à jour, Import/Export
14 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Création manuelle de sauvegarde | CréerChoisir le typeRecevoir la réponse |
L'administrateur déclenche une sauvegarde à la demande via
POST /admin/backups/run, qui produit une archive ZIP du
dump SQL et/ou des médias et assets. Trois types sont acceptés: full (défaut), database (BDD seule) ou files (fichiers seuls); tout type inconnu est ramené à full. La réponse est renvoyée en AJAX ou en redirection avec l'identifiant, le nom de fichier, le statut et la taille lisible. L'opération est journalisée dans l'audit sous backup.create et une notification est émise à l'achèvement ou en cas d'échec. |
| Liste / tableau de bord des sauvegardes | ConsulterVoir statutsVoir planning |
GET /admin/backups affiche les 20 dernières sauvegardes
via Backup::latest(20) avec nom de fichier, type, taille,
emplacement de stockage, statut, date et une icône cadenas
pour les archives chiffrées. La destination courante (Local, FTP/SFTP, S3 ou Google Drive) est indiquée avec un lien vers Réglages > Sauvegarde. Un panneau « Sauvegardes automatiques » résume la planification, la dernière exécution (badge de statut et erreur éventuelle) et la prochaine exécution, ou « Imminent (prochain passage cron) ». Chaque ligne porte un badge de statut: Terminé, En cours, Échoué ou Inconnu. |
| Restauration de sauvegarde | RestaurerConfirmerDéchiffrer si besoin |
POST /admin/backups/{id}/restore (permission
backups.restore, CSRF) rejoue le dump SQL et recrée les
fichiers médias/assets, après une confirmation UI
avertissant que les données actuelles seront écrasées. Le mécanisme est « remote-aware »: si aucune copie locale n'existe, l'artefact est récupéré depuis la destination distante, puis déchiffré s'il est chiffré (passphrase requise). Pour un rejeu fidèle inter-hôtes, le mode SQL est relâché (SET SESSION sql_mode=''); statements_total et statements_failed sont comptés et la restauration échoue si au moins une instruction échoue. Un garde anti-« path traversal » filtre les entrées du ZIP, bloque les extensions exécutables (php, phtml, phar, cgi, pl, py, sh, htaccess) et assainit les SVG via SvgSanitizer; l'action est journalisée backup.restore avec notification de succès. |
| Suppression de sauvegarde | SupprimerConfirmer |
DELETE /admin/backups/{id} (via _method=DELETE, permission
backups.delete, CSRF, confirmation UI) supprime la
sauvegarde partout où elle réside. La purge « best-effort » cible le fichier local, l'objet distant et la ligne en base de données. Si la sauvegarde est introuvable, une réponse 404 (JSON ou flash) est renvoyée. L'opération est journalisée backup.delete. |
| Téléchargement de sauvegarde | Télécharger |
GET /admin/backups/{id}/download (permission backups.view)
n'est proposé que pour les sauvegardes terminées. resolveLocalArtifact récupère l'archive depuis la destination distante lorsqu'aucune copie locale n'est présente; la copie temporaire est supprimée après mise en tampon (buffering). L'artefact est servi tel qu'il est stocké donc encore chiffré s'il l'était. L'action est journalisée backup.download. |
| Envoi (upload) d'une sauvegarde existante vers le distant | EnvoyerSurcharger le driver |
POST /admin/backups/{id}/upload (permission
backups.create, CSRF) pousse manuellement une sauvegarde
locale déjà créée vers une destination distante; le bouton
n'apparaît que si la destination configurée n'est pas «
local ». Un paramètre « driver » optionnel permet de surcharger la cible, validé contre les drivers connus. Si la cible résout vers le local, l'envoi est rejeté (422). Le résultat est journalisé backup.upload avec le driver et un indicateur ok, puis renvoyé en flash ou JSON (502 en cas d'échec distant). |
| Sauvegardes automatiques / planifiées | PlanifierChoisir fréquence, heure, typeLaisser le cron exécuter |
La planification se règle dans Réglages >
Sauvegarde: backup_schedule (aucune, quotidienne,
hebdomadaire, mensuelle), backup_time (HH:MM),
backup_weekday (0-6) et backup_type (full, database,
files). enqueueIfDue() dépose une tâche one-shot auto_backup dans scheduled_tasks et empêche l'empilement (une seule tâche en attente ou en cours à la fois). Un modèle déterministe « déclencher une fois par période à HH:MM » calcule la prochaine exécution. L'exécution effective est assurée par le pseudo-cron ou GET /cron/run qui appelle BackupService::run(); l'UI affiche le résumé du planning, le statut/erreur de la dernière tâche auto et la prochaine exécution prévue. |
| Politique de rétention | Choisir le modeDéfinir le seuil |
Deux stratégies s'appliquent à la fois aux fichiers locaux
et aux objets distants: par âge
(backup_retention_mode=days, backup_retention de 1 à 3650,
défaut 30 suppression au-delà de N jours) ou par nombre
(mode count, backup_retention_copies de 1 à 1000, défaut 7
conserver les N copies les plus récentes). pruneOld() purge les lignes en base et les fichiers locaux; pruneRemote() purge les objets distants en « best-effort », l'âge étant déduit du mtime ou du nom de fichier. La plus récente sauvegarde terminée est toujours conservée, de sorte que le site ne se retrouve jamais sans aucune sauvegarde. |
| Destinations de stockage distant (multi-driver) | Choisir la destinationConfigurerTester la connexionPurger la copie locale (option) |
backup_destination sélectionne la cible: local, ftp, s3 ou
gdrive, construite par une fabrique de drivers
(StorageDriverFactory) qui déchiffre les identifiants
stockés. FTP couvre protocole ftp/ftps/sftp, hôte, port, utilisateur, mot de passe chiffré, chemin, mode passif et empreinte (fingerprint) d'hôte SFTP; S3 couvre bucket, région, clé, secret chiffré, endpoint personnalisé, préfixe et bascule path-style; Google Drive couvre jetons d'accès et de rafraîchissement chiffrés, id de dossier, client id et client secret chiffré. POST /admin/settings/backup/test sonde en JSON les identifiants de la configuration ENREGISTRÉE (journalisé backup.test_connection). La bascule backup_keep_local permet de supprimer la copie locale après un envoi distant réussi. |
| Chiffrement au repos des archives | ActiverDéfinir la passphrase |
Activé par backup_encrypt plus une passphrase
backup_encrypt_key; l'activation est bloquée si aucune
passphrase n'est définie. ArchiveCipher::encryptFile produit une archive .enc et positionne le drapeau is_encrypted; la restauration et le téléchargement détectent et déchiffrent automatiquement. Il s'agit d'un chiffrement côté serveur appliqué à l'archive avant qu'elle ne quitte le serveur. Fail-safe: si le chiffrement est demandé mais que la passphrase est illisible (APP_ENCRYPTION_KEY manquante ou renouvelée), la copie en clair est conservée EN LOCAL, l'envoi distant est ignoré et une notification critique est émise aucun texte clair n'est jamais expédié au distant. |
| Mises à jour système / migrations de base | Consulter la versionAppliquer les migrationsConfirmer |
GET /admin/system/updates (permission system.update)
affiche la version courante, le nombre de migrations
appliquées, les noms des migrations en attente et, en
option, une bannière « nouvelle version disponible » issue
d'un manifeste distant avec lien vers les notes de
version. POST /admin/system/updates/run (CSRF, confirmation UI) applique les migrations en attente de façon sûre: sauvegarde BDD automatique et instantané .env en 0600, puis exécution des migrations, purge des caches et journalisation system.update. Un court-circuit « déjà à jour » évite toute sauvegarde ou purge inutile. La vérification du manifeste distant se fait via update.manifest_url à travers un client SafeHttpClient anti-SSRF (comparaison par version_compare). |
| Export de données | Choisir les entitésExporter en JSONExporter en CSV |
Sélection par cases à cocher des entités (articles,
catégories, tags, pages chacune avec sa table
*_translations). POST /admin/export/download en format=json (défaut) télécharge clustraly-export-AAAA-MM-JJ.json contenant cms, version, format_version et checksum (format v2 avec empreinte SHA-256). En format=csv, il télécharge clustraly-export-AAAA-MM-JJ.zip avec un fichier CSV par entité. Permission settings.edit, CSRF; opération journalisée data.export avec les entités et le format. |
| Import de données | Téléverser le JSONChoisir la stratégie de doublons |
POST /admin/import/upload (multipart, permission
settings.edit, CSRF) importe un fichier d'export JSON
Clustraly. Deux stratégies de doublons: skip (INSERT IGNORE, ignore les slugs existants) ou merge (INSERT ... ON DUPLICATE KEY UPDATE). La validation vérifie l'entête cms=clustraly, met en liste blanche les tables d'entités et de traductions, et intersecte les lignes avec les colonnes réelles de chaque table (protection contre l'injection SQL). Le résultat rapporte « X enregistrements importés », émet une notification importCompleted et est journalisé data.import avec le nombre d'enregistrements et la stratégie; un chemin d'import CSV (importCsv) existe dans le service mais n'est actuellement pas référencé par le contrôleur. |
| Exécution planifiée (cron / pseudo-cron) | Exposer l'URL cronExécuter les tâches dues |
GET /cron/run, protégé par token, exécute sous verrou les
tâches scheduled_tasks arrivées à échéance. Côté sauvegarde, il dispatche le type auto_backup vers BackupService::run; à chaque tick, BackupScheduleService::enqueueIfDue() met en file la sauvegarde planifiée si elle est due c'est le moteur d'exécution de la planification automatique. Le même runner assure d'autres tâches de maintenance et une relève de file, mais son rôle ici est de faire tourner les sauvegardes programmées. BuildQueueController expose l'URL web-cron /cron/run?token=… destinée aux planificateurs externes. |
Administration & Système
📧 Maintenance Email (file, délivrabilité) & Cache Complété
12 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| File d'attente email (outbox asynchrone) | ListerFiltrerConsulter le détail |
Page GET /admin/emails (permission
emails.manage) qui liste les emails en file
avec des chips de filtrage par statut (Tous, En attente,
Envoyé, Échoué, Annulé) et un compteur par statut. Le
détail (GET /admin/emails/{id}) expose le
destinataire, le sujet, le contexte, le reply-to, le
compteur de tentatives N/max, les horodatages de mise en
file, d'envoi et de prochaine tentative, la dernière
erreur et le corps brut du message. Le modèle est
asynchrone: la requête web se contente d'enfiler le
message tandis qu'un worker cron effectue l'envoi réel. La
file est persistée en base, ce qui autorise historique,
ré-essais et traçabilité.
|
| Renvoi & annulation d'un email | RenvoyerAnnuler |
Renvoyer (POST /admin/emails/{id}/resend,
CSRF) remet le message en file en réinitialisant son
compteur de tentatives; l'action n'est offerte que pour
les statuts Échoué, Envoyé ou Annulé et est journalisée
email.resend. Annuler (POST /admin/emails/{id}/cancel, CSRF) stoppe un (ré)envoi; proposée pour les statuts En
attente, En cours d'envoi ou Échoué, journalisée
email.cancel. Ces deux commandes donnent un
contrôle manuel sur la livraison sans relancer tout le
pipeline.
|
| Mécanique de livraison & retry (backoff) | EnfilerRéessayerRécupérer(Dés)activer la file |
Réclamation atomique du statut pending vers sending pour
éviter qu'un même message soit envoyé deux fois par des
ticks cron concurrents. En cas d'échec, back-off
exponentiel selon la grille [1m, 5m, 30m, 2h, 6h] jusqu'à
max_attempts (5 tentatives). La routine
reap() récupère les lignes bloquées ou
orphelines restées en « sending ». Un interrupteur
email.queue_enabled active ou désactive la
mise en file. L'exécution est portée par le cron (type de
tâche email_send vers
MailQueueService::sendTask), déclenché à
chaque tick.
|
| Transport SMTP (mailer) | EnvoyerMettre en fileEnvoyer en critique |
Mailer sans dépendance externe exposant
send() (immédiat),
queue() (asynchrone) et
sendCritical() (envoi immédiat, puis bascule
en file si échec). Il construit un corps multipart
texte+HTML conforme RFC 5322 (quoted-printable) avec
Message-ID, en-têtes List-Unsubscribe et one-click RFC
8058, ainsi que Precedence: bulk. Il gère
STARTTLS/SSL et AUTH LOGIN; en l'absence d'hôte SMTP
configuré, il se replie sur la fonction PHP
mail() avec expéditeur d'enveloppe
-f. Les CR/LF sont retirés des en-têtes pour
bloquer l'injection d'en-têtes. Deux points d'extension
sont exposés: email.sending (filtre) et
email.sent (action).
|
| Liste de suppression (bounces/plaintes) | ConsulterAjouter (suppression manuelle)Retirer |
Liste
GET /admin/emails/suppressions (permission
emails.manage) affichant adresse, badge de
type (Hard bounce, Soft bounce, Complaint, Manual), motif,
nombre de hits et date du dernier rebond. Ajout manuel
d'une adresse (POST .../suppressions/add,
CSRF, adresse validée; journalisé
email.suppress_add) et retrait par id (POST .../suppressions/{id}/remove, CSRF; journalisé email.suppress_remove).
Le mailer et la file sautent les destinataires supprimés
de type hard, complaint ou manual, préservant la
réputation d'expéditeur. Un interrupteur
suppress_bounced gouverne la prise en compte
des rebonds.
|
| Polling POP3 des rebonds (DSN/ARF) | ConfigurerInterroger la boîte (cron)ClasserAuto-supprimer |
Configuration dans Réglages > Email:
bounce_enabled, bounce_host,
bounce_port, bounce_username,
mot de passe chiffré et
bounce_encryption (ssl/tls). Le cron (type
process_bounces vers
BounceService::poll()) implémente un client
POP3 en pur PHP (mise à niveau STLS, commandes RETR/DELE,
50 messages max par passe).
parseBounce classe chaque message en hard,
soft ou complaint à partir des DSN standard, de
X-Failed-Recipients, des plaintes ARF et des adresses 5xx;
seuls les hard et complaint sont persistés dans
email_suppressions via un upsert protégé
contre les accès concurrents. La même boîte sert aussi
d'expéditeur d'enveloppe (Return-Path / -f)
pour l'alignement SPF.
|
| Conseiller de délivrabilité (SPF/DKIM/DMARC) | Recommander les enregistrementsVérifier le DNS en directConsulter le guide |
Panneau de recommandations dans Réglages > Email qui
construit les enregistrements DNS conseillés pour le
domaine d'envoi: SPF (include du fournisseur
auto-détecté parmi Google, M365, SendGrid, Mailgun, SES,
Brevo, Mailjet, Postmark, etc.), DKIM (valeur
p= réelle si une clé existe) et DMARC (p=none
de monitoring avec rua). Vérification DNS en
direct (POST .../email/check-dns, JSON)
renvoyant par enregistrement un statut
ok/warn/missing/unknown assorti de conseils de politique
(avertissement sur +all,
p= manquant). Le domaine d'envoi est dérivé
de from_email, sinon de l'hôte de
site_url, sinon de l'hôte de
app.url. Un guide de délivrabilité (GET .../email/guide) sert le fichier docs/DELIVERABILITY.md en
text/plain.
|
| Signature DKIM & génération de clé | Générer la paire de clésConfigurerSigner les messages |
Génération en un clic d'une paire DKIM (POST .../email/dkim/generate, CSRF, permission settings.edit): RSA 2048
bits, la clé privée est stockée chiffrée, l'opération
pré-remplit dkim_domain et le sélecteur «
clustraly »; journalisé
settings.dkim_generate. Configuration
associée: dkim_enabled,
dkim_domain, dkim_selector, clé
privée chiffrée et dmarc_rua. Le mailer signe
chaque message SMTP (rsa-sha256, canonicalisation
relaxed/relaxed) uniquement lorsque DKIM est configuré ET
que le domaine du From s'aligne sur d= (sinon
il saute la signature et le journalise). La valeur de clé
publique à publier en DNS est affichée à l'admin, et des
candidats de configuration OpenSSL de repli couvrent les
builds Windows/Docker minimalistes.
|
| Vider le cache | ViderInvalider OPcache |
POST /admin/cache/clear (permission
settings.edit, CSRF) vide le répertoire
storage/cache/pages et invalide l'OPcache
pour les fichiers clés. L'action émet une notification
cacheCleared et répond soit en JSON (requête
AJAX) soit par une redirection vers le referer. C'est la
purge manuelle complète du cache HTML de pages.
|
| Statistiques du cache | Consulter les stats |
GET /admin/api/cache/stats (permission
settings.view, endpoint à débit limité)
renvoie le nombre de fichiers, la taille en octets et en
format lisible, ainsi que la stratégie de purge effective.
Sert au suivi de l'occupation du cache disque depuis
l'admin.
|
| Auto-purge du cache (FIFO/LRU) | Purger automatiquement |
Lorsque la taille dépasse cache.max_size_mb,
une auto-purge ramène l'occupation à environ 80% de la
limite. Deux stratégies: fifo par défaut ou
lru; le mode lru exécute une
sonde de fiabilité de l'atime et se rétrograde
automatiquement en fifo si l'atime n'est pas
fiable. La fraîcheur des entrées (TTL) est évaluée via le
mtime des fichiers.
|
| Invalidation ciblée du cache | Invalider un article, une catégorie ou une page |
Helpers programmatiques invalidateArticle,
invalidateCategory et
invalidatePage qui purgent les entrées de
cache d'une entité donnée à travers toutes les variantes
de langue actives. Cela permet de rafraîchir seulement le
contenu réellement modifié plutôt que de vider tout le
cache. La fraîcheur repose sur le
mtime (TTL).
|
Sécurité & Conformité
🔐 Sécurité Authentification, 2FA, Anti-intrusion Complété
12 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Journal des tentatives de connexion (dashboard) | ConsulterFiltrer par emailFiltrer par IPPaginer |
Visionneuse admin en lecture seule (permission
security.view) listant chaque tentative de
connexion, succès comme échec, à raison de 50 par page et
les plus récentes en premier.• Chaque ligne porte un badge de statut coloré (vert = succès, rouge = échec). • Filtres par email (correspondance LIKE sur email_tried) et par adresse IP (LIKE sur
ip_address), combinables et conservés à
travers la pagination.• Côté backend, RateLimitMiddleware::recordAttempt() insère
chaque tentative dans login_attempts et purge
les lignes échouées d'une IP dès qu'une connexion réussit.
|
| Verrouillage progressif après échecs & rate-limiting à fenêtre glissante | Verrouiller progressivementLimiter par minuteRenvoyer 429Échouer en mode ouvert |
Défense anti-force-brute à deux étages. • Verrouillage IP escaladé sur le bucket auth (POST uniquement): 3 échecs/24h → 30
min, 6 → 1h, 10 → 24h.• Limiteur générique à compteur par fenêtre glissante, par identifiant (id de token, sinon id utilisateur, sinon IP): auth 10/min, api 60/min,
ai 20/min (surchargeable par variables
d'environnement, valeur <=0 désactive).• Un dépassement renvoie une réponse 429 avec en-têtes Retry-After et
X-RateLimit-Limit/Remaining/Reset, formatée
en JSON, HTML ou enveloppe API selon l'appelant.• recentFailedAttempts(ip) est exposé pour
piloter l'affichage du CAPTCHA de connexion.• Comportement fail-open sur erreur de stockage (la connexion continue via login_attempts même si
la table rate_limits est absente).
|
| CAPTCHA de connexion (Turnstile ou hCaptcha) | Détecter le fournisseurAfficher le widgetVérifier le tokenÉmettre la CSP |
CAPTCHA optionnel respectueux de la vie privée, à
auto-détection du fournisseur Cloudflare Turnstile ou
hCaptcha (inerte tant que non configuré). • Le widget n'apparaît sur le formulaire de connexion qu'une fois recentFailedAttempts >=
antispam.login_captcha_after
(défaut 3), c.-à-d. après que l'IP a franchi le seuil
d'échecs.• Le token résolu est vérifié côté serveur AVANT tout contrôle des identifiants, en fail-closed (une erreur de transport bloque la connexion). • Des flags par formulaire ( captcha_on_login,
captcha_on_rgpd, etc.) activent le CAPTCHA
indépendamment.• Le service émet aussi les directives CSP propres au fournisseur, rend le HTML du widget et expose le nom du champ de réponse. |
| Piège à bots honeypot |
Piéger les botsRediriger en silenceDéclencher le hook auth.failed
|
Champ honeypot caché (website) injecté sur
les formulaires de connexion et de mot de passe oublié.• Si la valeur est remplie (signature typique d'un bot), la requête est silencieusement redirigée sans traitement des identifiants, sur les deux formulaires. • Le hook de plugin auth.failed est déclenché à chaque
tentative d'identifiants échouée.
|
| Journal d'audit visionneuse admin | ConsulterFiltrer par utilisateurFiltrer par actionFiltrer par datesPaginer |
Piste d'audit en lecture seule (permission
security.view), paginée à 50 entrées par
page, les plus récentes d'abord.• Filtres: nom d'utilisateur (LIKE), action via liste déroulante peuplée par distinctActions(), et plage de dates
from/to (validées au format AAAA-MM-JJ et bornées à la
journée entière).• Les filtres se combinent et persistent à travers la pagination. • Chaque entrée affiche l'action, le type et l'id d'entité, l'IP, le user-agent et l'horodatage. |
| Moteur de journalisation d'audit | EnregistrerCaviarder les secretsPlafonner la chargePurger |
Enregistreur append-only appelé partout dans le CMS via
record(action, entityType, entityId, meta)
utilisé notamment par auth.login,
auth.logout, auth.login_failed,
user.disable_2fa, etc.• Résout automatiquement l'utilisateur agissant et le client (IP, user-agent). • Les valeurs meta sensibles (clés pass, pwd, secret, token, api_key, totp, otp, recovery, cookie, salt...) sont caviardées et la longueur des valeurs bornée. • La charge meta est plafonnée à 8 Ko (stocke {_truncated:true} en cas de dépassement) et
la méthode ne lève jamais d'exception.• Fournit purge(days) pour la rétention (suppression
des entrées plus vieilles que N jours) et
distinctActions() pour alimenter le filtre.
|
| Activation 2FA (TOTP) | Afficher le statutGénérer secret + QRActiver par vérificationDésactiver par code |
Hub 2FA en self-service (aucune permission requise, sur
son propre compte) depuis la page de sécurité. • Génère un secret candidat (en session uniquement) accompagné d'un QR code SVG inline ( QrCodeService) et d'une clé base32
saisissable manuellement.• Activation via POST /profile/security/enable (CSRF + rate
limit) après vérification d'un code TOTP; le secret n'est
persisté (chiffré AES) qu'une fois un code vérifié, et
l'activation est refusée si
APP_ENCRYPTION_KEY manque (jamais de secret
en clair).• La désactivation exige un code TOTP ou de récupération valide (alimente le throttle IP). • TOTP autonome conforme RFC 6238: secret base32, HMAC-SHA1, 6 chiffres, période 30s, tolérance ±1 pas, vérification à temps constant. |
| Codes de récupération 2FA | Émettre le lotRégénérerConsommerAfficher le restant |
À l'activation de la 2FA, un lot de 10 codes (80 bits
chacun) est émis et affiché une seule fois via message
flash. • Régénération possible: invalide les anciens et exige le code TOTP courant. • Un code est consommé à la connexion ou pour désactiver la 2FA via un UPDATE à usage unique protégé contre les accès concurrents (single-use, race-safe). • La page de sécurité affiche le nombre de codes inutilisés restants. • Seuls les hachages SHA-256 sont stockés; l'affichage formate les codes en blocs de 4 caractères. |
| Étape de connexion 2FA | Différer la connexionVérifier le TOTPBasculer vers un code de récupérationFinaliser |
Second facteur imposé après un mot de passe correct
lorsque la 2FA est active. • La connexion est mise en attente ( _2fa_pending, TTL 600s) avec
régénération de session (anti-fixation).• Le prompt GET /admin/2fa n'est atteignable qu'en cours
de login.• On vérifie d'abord le code TOTP, à défaut un code de récupération à usage unique (chaque tentative est enregistrée dans le throttle IP). • La connexion est finalisée puis auditée auth.login avec
via=2fa ou via=2fa_recovery, et
le nombre de codes de récupération restants est affiché
quand l'un est utilisé.• Délibérément, aucun succès n'est enregistré à l'étape mot de passe afin de maintenir le throttle IP sur la vérification du code. |
Politique require_2fa imposée & reset 2FA
par admin
|
Imposer la 2FARediriger vers le setupRéinitialiser la 2FA d'un utilisateurEffacer secret + codes |
Le réglage require_2fa positionne
_2fa_setup_required, ce qui fait canaliser
l'utilisateur vers la mise en place de la 2FA par
AuthMiddleware.• Un admin peut réinitialiser la 2FA d'un autre utilisateur (appareil perdu) via POST /users/{id}/disable-2fa (permission
users.edit + CSRF), action auditée
user.disable_2fa.• La désactivation efface le secret et l'intégralité des codes de récupération de l'utilisateur ciblé. |
| Mot de passe oublié / réinitialisation | Demander la réinitialisationÉmettre le tokenRéinitialiserInvalider les sessions |
GET/POST /admin/forgot-password répond
toujours de façon neutre (pas d'énumération de comptes) et
comporte un honeypot.• Un token de 256 bits (seul le SHA-256 est stocké) est émis et envoyé une seule fois par Mailer::sendCritical, avec anti-bombardement
(pas de renvoi si un token frais a été demandé dans les
90s) et purge des tokens périmés.• GET/POST /admin/reset-password/{token} valide
le token à usage unique (TTL 60 min) et exige un mot de
passe de 8 caractères minimum avec confirmation.• Consommation atomique du token, puis mise à jour du hash et purge de login_attempts et
locked_until.• Toutes les sessions existantes de l'utilisateur sont invalidées après la réinitialisation. |
| Vérification d'email | Vérifier l'adresseRenvoyer le lienImposer la vérification |
GET /admin/verify-email/{token} confirme
l'adresse et fonctionne déconnecté via une page de
résultat autonome.• POST /admin/verify-email/resend renvoie le
lien (CSRF + rate limit, anti-bombardement 90s).• GET /admin/verify-email/notice sert la page
d'atterrissage lorsque la vérification est imposée.• La politique require_email_verification canalise les
utilisateurs non vérifiés via
AuthMiddleware.• Le token à usage unique (TTL 24h, SHA-256 stocké) n'est validé que si l'adresse email courante correspond toujours à celle liée au token. |
Sécurité & Conformité
🛡️ Sécurité Intelligence des erreurs & RGPD Complété
17 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Console d'intelligence des erreurs (liste groupée) | ConsulterFiltrerRechercherDéclencher test |
Tableau de bord admin (permission errors.view) listant les
erreurs backend, frontend et CSP capturées puis
dédupliquées en groupes par empreinte (fingerprint), 30
par page, triées par last_seen. Filtres combinables : statut (open, investigating, resolved, ignored, regressed), sévérité (critical, error, warning) et catégorie (db, permission, validation, ai, network, plugin, template, csp, http, performance, other), plus une recherche plein-texte sur le titre et la classe d'exception. Des onglets récapitulatifs affichent le compte par statut (all, open, investigating, resolved, ignored, regressed). Un bouton déclenche une erreur de test capturée de bout en bout (permission errors.manage + jeton CSRF). |
| Détail d'un groupe d'erreurs | Afficher pileVoir contexteLire breadcrumbsConsulter timeline |
Page d'incident par groupe montrant l'évènement le plus
récent ou l'échantillon avec une pile d'appels enrichie
(±6 lignes de code extraites des fichiers app/, ligne
fautive « culprit » surlignée). Le contexte de requête est assaini : route, méthode HTTP, rôle, user_id, IP anonymisée, user-agent, requête caviardée. Affiche aussi les breadcrumbs (entrées de log récentes caviardées), une timeline des occurrences récentes (jusqu'à 25) avec request_id, un sparkline des évènements par jour sur 14 jours et la ventilation des occurrences par version applicative. |
| Gestion du cycle de vie (statut) | Changer statutRésoudreDétecter régressionSupprimer groupe |
Transitions manuelles d'état sur un groupe (open,
investigating, resolved, ignored) protégées par la
permission errors.manage + CSRF. La résolution horodate automatiquement resolved_by, resolved_at et resolved_version. Si une empreinte déjà résolue réapparaît, le groupe bascule automatiquement en « regressed » avec regressed_version et regressed_at. Un groupe et l'ensemble de ses évènements peuvent être supprimés (errors.manage + CSRF + confirmation). |
| Tableau de bord des tendances (spikes) | Choisir périodeLire KPIRepérer picsCorréler versions |
Analytique inter-groupes sur une fenêtre sélectionnable de
7, 14, 30 ou 90 jours. Tuiles KPI : groupes ouverts, total d'occurrences (résistant à l'échantillonnage), évènements sur la période, nombre de régressions. Barres journalières avec détection de pics (spike si >= plancher de 5 ET >= 3x la moyenne), tableau des erreurs les plus fréquentes (par volume sur la fenêtre), indicateur de corrélation par version (évènements par app_version) et comptes par sévérité. Un indicateur « sampled » signale quand l'échantillonnage anti-flood est actif. |
| Diagnostic IA d'erreur | DiagnostiquerRe-diagnostiquerAuto (cron)Gérer budget |
Demande à Claude de diagnostiquer un groupe une seule fois
(résultat mis en cache), construit uniquement à partir des
données stockées déjà caviardées. Mode manuel inline (errors.manage + CSRF, l'admin attend le résultat) avec bouton de re-diagnostic forçant l'écrasement ; mode auto mettant en file une tâche planifiée « error_diagnose » asynchrone sur chaque nouveau groupe (dédupliquée, reprogrammée selon le budget). Produit un diagnostic structuré : root_cause, explanation, culprit_file/line, category, severity, confidence, suggested_fix, fix_prompt, patch, prevention. Un garde-fou de budget (manuel par utilisateur ou global cron) gère le dépassement (« budget exceeded ») et échoue de façon ouverte (fail-open) si l'IA est indisponible ; le diagnostic, le modèle et la confiance sont stockés sur le groupe avec barre de confiance et horodatage/modèle affichés. |
| Auto-fix assisté (staging uniquement) | Préparer correctifSauvegarder BDDAuditer par hashRéviser patch |
Application guidée et fail-closed d'un patch de diagnostic
(permission errors.autofix + CSRF), re-vérifiant
l'environnement et l'opt-in côté serveur. Garde-barrière stricte de staging (liste blanche STAGING_ENVS ; APP_ENV non défini ou inconnu traité comme production, donc bloqué) et opt-in obligatoire ERROR_AUTOFIX_ENABLED. Prend une sauvegarde de sécurité de la base (BackupService) avant application, puis journalise l'action error.autofix avec l'environnement, le nom du fichier de sauvegarde et le hash du patch (jamais le contenu du patch). Le patch proposé, non vérifié, est présenté pour revue manuelle ; le bouton est masqué hors staging et le service n'écrit jamais dans les fichiers app/. |
| Export fix-package (dossier Claude-Code) | Télécharger .mdCopier promptCopier package complet |
Dossier markdown autonome, téléchargeable (permission
errors.view) ou copiable dans le presse-papiers via un
mécanisme CSP-propre (data-copy). Il regroupe le résumé de l'erreur, le diagnostic IA, la pile enrichie, le contexte de reproduction, les breadcrumbs, un rappel des conventions et un prompt de correction prêt à coller. Deux copies possibles : le prompt de correction concis, ou le package complet. Fournit un prompt de repli lorsqu'aucun fix_prompt IA n'est disponible. |
| Export rapport d'incident | Télécharger .mdCopier rapport |
Rapport markdown partageable et sûr en matière de
caviardage, audité comme error.export lors du
téléchargement (permission errors.view). Contient le résumé et les statistiques, le calcul de la durée d'incident active (active-span), l'historique résolu/régressé/dernière-alerte, un tableau de corrélation par version, la timeline des occurrences récentes et un résumé du diagnostic. Volontairement exempt de payload, de pile d'appels et de breadcrumbs. Également copiable dans le presse-papiers. |
| Alerting multi-canal (notif/email/webhook) | Notifier adminEmailerWebhook signéLimiter (throttle) |
Déclenche des alertes sur les évènements new, regression,
spike et critical (configurable via error_intel.alert_on),
avec un plancher de sévérité qui supprime les « new »/«
spike » de niveau warning (ex. bruit CSP). Trois canaux : une Notification admin persistante diffusée, un email asynchrone vers error_intel.alert_email (Mailer::queue) et un webhook sortant signé error.{trigger} via WebhookService. Chaque groupe est limité (throttle) par comparaison de last_alert_at avec alert_throttle (défaut 900s) et par une vérification du taux de pics (spike_per_min). L'ensemble est conditionné par la configuration. |
| Capture d'erreurs frontend (beacon JS navigateur) | Recevoir POSTCaviarderInjecter trackerPlafonner |
Endpoint public POST /api/client-error, sans session ni
CSRF, limité au bucket « api », plafonné à 16 Ko et
répondant toujours 204. Conditionné par ERROR_CAPTURE_FRONTEND (no-op silencieux si désactivé). Une config avec nonce et le script error-tracker.js sont injectés dans public.footer avec un taux d'échantillonnage. La charge utile non fiable (message, source, stack) est caviardée puis normalisée en évènement de type « frontend », avec un plafond global anti-flood par heure. |
| Beacon de rapport de violation CSP | Recevoir POSTParser 3 formatsNormaliserPlafonner |
Endpoint public POST /api/csp-report, sans session, limité
au bucket « api », plafonné à 16 Ko, répondant toujours
204 et conditionné par ERROR_CAPTURE_CSP (no-op silencieux
si désactivé). Il analyse trois formats : legacy « csp-report », Reporting API (tableau body) et objet nu. Chaque violation est normalisée en évènement CspViolation de sévérité warning, groupé par directive + hôte bloqué. Un plafond anti-flood par heure borne l'ingestion. |
| Rétention / échantillonnage anti-flood | Purger (cron)Rogner évènementsÉchantillonnerPlafonner sources |
Tâche quotidienne cleanup_error_events qui supprime les
groupes ignored/resolved (et leurs évènements) au-delà de
la rétention, et rogne les évènements plus anciens que
error_intel.retention_days (défaut 30 ; <=0
désactive) en préservant les lignes sample_event_id. À la capture, un échantillonnage anti-flood (error_intel.sample_threshold) conserve 1 évènement sur 10 dès qu'un groupe dépasse le seuil d'évènements par minute. Un plafond par source non fiable (untrusted_max_per_hour, défaut 500) borne les beacons frontend et CSP. |
| Demande RGPD publique self-service (double opt-in) | Afficher formulaireCréer demandeAnti-spamConfirmer email |
Formulaire visiteur GET /privacy/data-request (avec
en-têtes de sécurité) et soumission POST
/privacy/data-request (CSRF + rate limit) créant une
demande d'export ou d'effacement. Protections anti-spam : honeypot, time-trap (âge min/max du formulaire) et CAPTCHA RGPD optionnel, toutes repliées vers le même message neutre. Garde anti-doublon de 24h par email. Un lien de confirmation à usage unique est envoyé par email (TTL 48h, hash SHA-256 stocké) et la demande reste inactionnable tant qu'elle n'est pas confirmée ; GET /privacy/data-request/confirm/{token} confirme la propriété de l'adresse avec des réponses neutres. |
| Gestion admin des demandes RGPD | ListerExporter JSON ou HTMLSupprimer/anonymiserVérifier |
Console admin (permission settings.view) listant les
demandes paginées avec badges type + statut +
en-attente/confirmé. Export JSON téléchargeable (POST /rgpd/{id}/export?format=json, audité rgpd.export) ou rapport HTML lisible (format=html, ouvert dans un nouvel onglet). Suppression/anonymisation (POST /rgpd/{id}/delete) avec gardes serveur : type=delete, demande non déjà complétée, email confirmé (audité rgpd.delete). Vérification (POST /rgpd/{id}/verify) pour un contrôle d'identité manuel hors bande (audité rgpd.verify) ; toute action divulgatrice ou destructrice est bloquée tant que l'email n'est pas confirmé (fail-closed), et une note signale un compte privilégié laissé intact. |
| Moteur d'export/effacement RGPD (~25 tables) | Recenser sourcesExporterProtéger secretsEffacer en transaction |
Registre déclaratif des sources de données personnelles
(compte, commentaires, soumissions contact/formulaire,
newsletter, login_attempts, sessions, password_resets,
recovery_codes, email_verifications, api_tokens, emails en
file ou supprimés, audit_logs, article_reviews,
notifications, rgpd_requests,
articles/pages/cocoon/media/révisions rédigés,
ai_requests/images/content) couvrant environ 25 tables. Export structuré (sujet + lignes par source + generated_at) en JSON indenté ou rapport HTML autonome échappé ; safeColumns() écarte toute colonne au nom sensible (password_hash, totp_secret, token, payload, stripe_*...) même déclarée ou ajoutée par plugin, et le journalise. L'effacement s'exécute en UNE transaction (rollback sur erreur) avec stratégie par source : suppression, anonymisation en place, ou conservation pour intégrité ; la ligne de compte est anonymisée en place (hash de mot de passe inutilisable, PII nettoyée, 2FA effacée) en gardant l'id pour l'intégrité référentielle. Un garde-fou protège les comptes privilégiés (permission wildcard) appariés par email seul, saute les sources par user-id et les signale pour revue manuelle ; les fichiers uploadés de formulaires sont physiquement supprimés (unlink) avant la ligne. L'analytique pseudonymisée (hash IP à sel rotatif, sans email ni user id) est rapportée hors périmètre, et le moteur est extensible via le hook rgpd.data_sources (ex. shop_orders). |
| Consentement, bannière cookies & gating analytics | Gérer modesLire/écrire cookieConditionner trackingPseudonymiser IP |
Modes de consentement (le mode « none » désactive la
bannière) avec lecture/écriture d'un cookie rgpd_consent à
catégories necessary, analytics et preferences. shouldShowBanner() décide de l'affichage et la bannière côté client (accepter/refuser) écrit le cookie en SameSite=Strict. canTrackAnalytics() et hasConsent(type) conditionnent la capture analytics et heatmap (via RgpdMiddleware et HeatmapApiController). La pseudonymisation hashIp() utilise un pepper secret persistant et non public (setting chiffré, sinon setting en clair, sinon repli par processus). |
| Réglages RGPD | ConsulterEnregistrer |
Onglet d'administration GET /admin/settings/rgpd
(permission settings.view) et enregistrement POST
/admin/settings/rgpd (permission settings.edit + CSRF). Configure rgpd_mode (mode de consentement), rgpd_banner_text (texte de la bannière), rgpd_cookie_ttl (durée de vie du cookie de consentement) et rgpd_cookie_name (nom du cookie). |
Analytics & Productivité
📊 Analytics, Statistiques, Heatmap & Tableau de bord Nouveau
22 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Page Analytics Vue d'ensemble & filtre de période | ConsulterFiltrer |
Route GET /admin/analytics (permission analytics.view),
rendu serveur par AnalyticsController::index. Le paramètre
period est validé contre une liste blanche
7/14/30/90 (toute autre valeur retombe à 30 jours), puis
la fenêtre est calculée de
-{jours} days 00:00:00 à aujourd'hui
23:59:59. Quatre cartes KPI affichent Pageviews (COUNT sur
analytics_pageviews), Sessions (COUNT sur
analytics_sessions), Pages/session moyennes (AVG
pageview_count arrondi à 0,1) et Durée moyenne en secondes
(AVG duration_sec arrondi). Les pills de période
rechargent la vue ou rafraîchissent les graphiques via
l'API chart-data.
|
| Graphique « Traffic Overview » | Visualiser | Courbe Chart.js traçant les pageviews et les sessions par jour. Les séries proviennent de pageviewsPerDay() et sessionsPerDay() (GROUP BY DATE). Une boucle serveur comble les jours manquants de -jours à 0: chaque date absente vaut 0 et les libellés sont formatés « M j » (ex. « Mar 5 »). Garantit une courbe continue sans trous même un jour sans trafic. |
| Répartition par appareil (doughnut) | Visualiser | Doughnut Chart.js de la répartition des sessions par device_type (desktop, mobile, tablet, bot) via deviceBreakdown() (GROUP BY device_type sur analytics_sessions, device_type NON NULL). Légende et pourcentages calculés côté client. Les bots étant exclus dès la capture, la part « bot » reste normalement nulle. |
| Top navigateurs (barres horizontales) | Visualiser | Diagramme à barres horizontales des 5 navigateurs les plus fréquents via browserBreakdown() (GROUP BY browser sur analytics_sessions, tri décroissant, LIMIT 5). Les valeurs Edge, Opera, Chrome, Firefox, Safari, IE ou Other proviennent de la détection User-Agent effectuée au moment de la création de la session. |
| Top pages (tableau) | Consulter | Tableau des 10 URLs les plus vues sur la période via topPages() (GROUP BY url, ORDER BY views DESC, LIMIT 10 sur analytics_pageviews). Colonnes URL et nombre de vues. |
| Top référents (tableau) | Consulter | Tableau des 10 référents les plus fréquents via topReferrers(), en excluant explicitement les référents vides ou NULL (WHERE referrer != '' AND referrer IS NOT NULL), GROUP BY referrer, ORDER BY count DESC, LIMIT 10. |
| API JSON chart-data (rafraîchissement AJAX) | InterrogerRafraîchir | GET /admin/analytics/chart-data (analytics.view). Renvoie en JSON labels, pageviews, sessions (mêmes séries comblées que la page), plus overview et devices pour la période validée (7/14/30/90, défaut 30). Sert à rafraîchir les graphiques en AJAX sans recharger la page. |
| API JSON temps réel (realtime) | InterrogerSuivre en direct | GET /admin/analytics/realtime. Fenêtre glissante des 30 dernières minutes (time() − 1800). Renvoie active_visitors (= sessions démarrées), pageviews et les 5 pages les plus vues. Alimente un widget « temps réel ». |
| API JSON pageviews | Interroger |
GET /admin/api/analytics/pageviews. Le paramètre
days est borné entre 1 et 90 (min/max, défaut
30). Renvoie overview (KPI), per_day (map date vers
compteur) et top_pages (10). Endpoint JSON générique pour
widgets ou intégrations.
|
| API JSON heatmap par article | Interroger | GET /admin/api/analytics/heatmap/{articleId} (analytics.view). Un articleId ≤ 0 renvoie 400. Sinon renvoie les stats agrégées et les zones d'attention. Le tout est enveloppé dans un try/catch: en cas d'erreur SQL (table heatmap_data absente en production, etc.) il renvoie un JSON d'erreur 500 (« Heatmap data unavailable ») au lieu d'un HTML 500, préservant le contrat JSON pour le client. L'erreur est journalisée via error_log. |
| Suivi serveur des pageviews & sessions (RGPD, sans cookie) | SuivreEnregistrer | AnalyticsMiddleware s'exécute sur le groupe public. Il ne trace que les requêtes GET aboutissant à une réponse 200, hors préfixes /admin, /api, /storage et /sitemap. Il exige le consentement RGPD (RgpdService::canTrackAnalytics) sinon ne trace rien. AnalyticsService::trackPageview construit un identifiant de session SANS cookie: sha256 de IP + User-Agent + heure (format Y-m-d-H), donc une session par visiteur et par heure. La session est créée (INSERT) ou mise à jour (UPDATE pageview_count +1, last_active). Le pageview est inséré avec url/referrer/user_agent tronqués (500/512), ip_hash, device_type, duration_sec = 0 et viewed_at. L'IP est hachée via RgpdService::hashIp; le fuseau horaire vient des réglages. Échec silencieux (try/catch) pour ne jamais casser la page. |
| Détection appareil / navigateur / OS & exclusion des bots | DétecterExclure |
detectDevice() classe l'User-Agent (en minuscules):
• bot si présence de bot, crawler, spider, slurp, mediapartners, lighthouse, pagespeed ou headlesschrome la capture s'arrête alors immédiatement, aucun enregistrement • tablet si ipad, ou android sans « mobile » • mobile si mobile, iphone, ipod, android, blackberry, opera mini ou windows phone • sinon desktop. detectBrowser() reconnaît Edge, Opera, Chrome, Firefox, Safari, IE, sinon Other; detectOS() reconnaît Windows, macOS, Linux, Android, iOS, sinon Other. Heuristiques par sous-chaînes, l'ordre des tests évitant les faux positifs (tablette avant mobile, Chrome avant Safari). |
| Événements personnalisés (trackEvent) | Enregistrer | AnalyticsService::trackEvent insère dans analytics_events: session_id (même hash horaire cookieless), event_name, event_category, event_label, event_value (numérique nullable), url tronquée à 500, metadata en JSON (json_encode, nullable) et created_at. API purement programmatique (pas d'interface dédiée) pour tracer des interactions personnalisées rattachées à une session sans cookie. |
| Collecte heatmap endpoint public & gate de consentement | CollecterValider | POST /api/heatmap (rate-limité, profil « api »), géré par HeatmapApiController::store. Refuse avec 403 {consent_required} si le consentement analytics manque (RgpdService::canTrackAnalytics). Le corps JSON est décodé; sans article_id il renvoie 400 {invalid}. Sinon HeatmapService::track persiste la charge (enveloppé dans un try/catch, échec silencieux) et renvoie 200 {ok}. Endpoint public conçu pour recevoir les beacons du tracker frontend. |
| Tracker heatmap frontend (scroll, zones H2, clics, temps) | MesurerEnvoyer | heatmap-tracker.js ne s'active que si un élément <article data-article-id> existe. Le session_id est un UUID stocké en sessionStorage (crypto.randomUUID). Il mesure: la profondeur de scroll maximale (0-100, arrondie) via un écouteur scroll passif; le temps par zone H2 via IntersectionObserver (seuil 0,5) qui cumule les secondes d'intersection par id de H2 (ou les 40 premiers caractères du titre); les clics (x/y en pageX/pageY, balise cible, temps relatif) plafonnés à 100. À l'envoi il ferme les timers de zone ouverts, compose l'objet {article_id, session_id, scroll_depth, time_per_zone, click_positions, total_time} et l'émet via navigator.sendBeacon vers (base)+/api/heatmap, avec repli fetch keepalive. Déclenché sur beforeunload ET toutes les 30 s. Le script n'est injecté par le layout public qu'après consentement analytics (lecture du cookie rgpd_consent). |
| Agrégation heatmap par article (stats + zones) | AgrégerCalculer | HeatmapService::getArticleStats délègue à HeatmapData::articleStats: une requête agrège total_sessions (COUNT), avg_scroll_depth (AVG max_scroll_depth), avg_reading_time (AVG total_time), complete_reads (SUM read_complete) et completion_rate = SUM(read_complete) / GREATEST(COUNT, 1) × 100 arrondi à 0,1. read_complete vaut 1 dès que max_scroll_depth ≥ 90 (fixé à l'enregistrement, la profondeur étant bornée à 100). getZoneHeatmap lit tous les time_per_zone de l'article, décode chaque JSON, somme les secondes par zone puis divise par le nombre de sessions: on obtient l'attention moyenne (en secondes, arrondie à 0,1) par zone H2. Résultats surfacés via l'API heatmap admin. |
| Tableau de bord d'accueil admin (blocs KPI, activité, sous-systèmes) | ConsulterNaviguer |
GET /admin (DashboardController::index), rendu serveur,
composé de jusqu'à 12 blocs: • stats (Articles avec split publiés/brouillons, Pages, Media, Commentaires en attente) • analytics 30 j (Pageviews, Sessions, Pages/session) • sparkline 7 j • actions rapides (Nouvel article/page, Génération IA, Analytics complet, SEO, Backups) • articles récents (5 derniers avec badges de statut) • commentaires en attente • aperçu SEO (score moyen, redirections, meta manquantes chargé en différé) • usage IA (coût jour/mois vs limites, requêtes) • notifications (5 dernières + badge non-lus) • backups (dernier backup, taille, total) • cache (pages cachées, taille, stratégie de purge) • système (version PHP, utilisateur, version app, session). Les blocs backups et cache sont masqués selon les permissions (backups.view, settings.view) pour ne pas divulguer ce que leurs pages dédiées refusent. Chaque bloc optionnel est récupéré en fail-open (try/catch vers valeur neutre) et seulement s'il est effectivement affiché. |
| Sparkline pageviews 7 jours | Visualiser | Mini-graphique Chart.js des pageviews des 7 derniers jours. Les séries sont construites côté serveur depuis pageviewsPerDay(-7 j), les jours manquants valant 0, avec des libellés en jour de la semaine (« D »). Theme-aware: il se redessine lors du basculement clair/sombre pour rester lisible. |
| Personnalisation des blocs par utilisateur | PersonnaliserEnregistrer | Panneau « Customize » avec une case à cocher par bloc parmi les 12 canoniques (DashboardController::BLOCKS). Sauvegarde via PUT /admin/api/dashboard/layout (protégé CSRF): le serveur ne garde que les chaînes, les intersecte avec la liste blanche BLOCKS et déduplique (array_unique), puis stocke la liste JSON dans users.dashboard_layout (colonne TEXT). Sémantique: NULL = jamais personnalisé (jeu par défaut = blocs autorisés), tableau vide [] = tout masqué (valeur légitime conservée telle quelle). Toast de succès puis rechargement; toast ou modale d'erreur en cas d'échec. Persistance alternative via PUT /admin/api/preferences (liste blanche stricte réduite à dashboard_layout, sinon 400). Au rendu, la disposition sauvegardée est ré-intersectée avec les blocs autorisés par permission. |
| API JSON stats du dashboard (fail-open) | Interroger | GET /admin/api/dashboard/stats. Renvoie articles, published, pages, media, comments_pending, overview analytics 30 j, avgSeoScore, redirectCount et missingMeta. Chaque métrique est isolée dans son propre try/catch: une table manquante (seo_meta, seo_redirects, analytics…) dégrade la valeur à 0 au lieu de provoquer un 500. missingMeta = nombre de publiés − entités disposant d'une meta_description non vide (borné à 0). Un try/catch externe renvoie, en dernier recours, un objet entièrement à zéro accompagné d'un champ error. |
| Monitoring d'erreurs externe (Sentry / webhook) | ConfigurerTransférer | Service optionnel (Point #48), activé uniquement si monitoring.dsn (variable MONITORING_DSN) est défini; register() l'enregistre comme reporter du Logger, si bien que chaque log error ou critical est transféré. Deux drivers auto-détectés par parseDsn: DSN Sentry (PUBLIC_KEY@host/PROJECT_ID) vers /api/{project}/store/ avec en-tête X-Sentry-Auth, mapping de niveau (critical vers fatal, etc.), release/environment/server_name/transaction/tags/extra et données d'exception; sinon webhook https générique en JSON structuré (service, level, message, channel, request_id, env, release, url, method, exception, context) pour Slack, Discord ou un collecteur maison. Sécurité: HTTPS uniquement, plafond de 10 envois par requête, garde de réentrance, timeouts courts (4 s / 3 s), client SafeHttpClient anti-SSRF, ne lève jamais d'exception et retire systématiquement la query string des URLs. captureException/captureMessage sont exposés pour un signalement direct. |
| Endpoints de health-check / uptime | SonderSurveiller | GET /health (statut global: base + stockage + version + horodatage, 200 ok / 503 degraded) et GET /health/db (connectivité base seule, 200 ok / 503 down). Non authentifiés et volontairement minimalistes, dispatchés AVANT Database::init() donc /health répond même pendant une panne DB (sa raison d'être). La sonde base est rapide et bornée: elle réutilise une connexion déjà ouverte, sinon effectue un pré-check TCP fsockopen (plafond ~1 s, fiable même sous Windows) puis une connexion PDO à timeout court (2 s); elle ne divulgue jamais l'hôte, les identifiants ni la raison de l'échec. checkStorage vérifie que logs, cache et storage sont des dossiers accessibles en écriture (et les crée s'ils sont absents). Réponses avec en-têtes Cache-Control: no-store et X-Request-Id. |
Analytics & Productivité
⌨️ Command Menu (Ctrl+K), Prévisualisation & PWA Nouveau
20 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Lanceur du command menu (ouverture/fermeture, focus) | Ouvrir (Ctrl+K/Cmd+K)Ouvrir (double-Shift)Ouvrir ([data-cm-open])Fermer (backdrop/Escape)Piéger et restaurer le focus |
Lanceur admin global de type Spotlight ouvert au clavier
(Ctrl+K ou Cmd+K, actif même à l'intérieur des champs de
saisie), par deux appuis Shift nus en moins de 400 ms, ou
par tout déclencheur [data-cm-open]. La fermeture se fait par clic sur le fond (backdrop), via [data-cm-close] ou par Escape. Escape suit un comportement en deux temps: le 1er appui vide la requête, le 2e ferme le panneau (comportement spotlight). Le focus est piégé dans le panneau puis restauré sur l'élément déclencheur à la fermeture; le raccourci agit en bascule ouvrir/fermer. |
| Grille de 9 sections + bande supérieure + raccourcis chiffres | Afficher 9 sections métierAfficher bande Dashboard/AnalyticsSauter via chiffres 1-9Afficher puces de séquence et infobulles |
À l'état de repos, l'overlay affiche une carte de 9
sections métier teintées (Contenu, Audience, SEO, IA,
Apparence, Administration, Intégrations, Maintenance,
Sécurité & Journaux) plus une bande supérieure de
destinations (Dashboard, Analytics). Les touches 1 à 9 ouvrent la première entrée de la section n (uniquement à l'état de repos). Chaque entrée affiche sa puce de go-séquence et une infobulle de description. Les paires de teintes clair/sombre par section sont validées en contraste AA; l'ensemble est filtré par permissions côté serveur. |
| Recherche floue locale pondérée (niveau 1) | Scorer (exact>préfixe>mot>sous-chaîne>alias>initiales>section)Surligner via markGrouper (Pages, Actions)Naviguer au clavier |
Matcher client insensible aux accents et à la casse
opérant sur l'index inliné filtré par permissions
(libellés, alias, initiales, section). Scoring pondéré: correspondance exacte > préfixe > mot > sous-chaîne > alias > initiales > section; en multi-mots chaque token doit correspondre et les scores sont moyennés. Les alias i18n par entrée (cmdmenu.alias.<id>) sont résolus pour la locale courante et les correspondances surlignées via <mark> en tenant compte des accents. Résultats groupés (Pages, Actions) avec état sans-résultat (message, astuce, suggestions populaires: articles/settings/seo). Navigation clavier: flèches haut/bas, Entrée (ouvrir), Ctrl/Cmd+Entrée (nouvel onglet), Tab (groupe suivant/précédent). |
| Séquences clavier 'g…' / 'n…' + hotkeys sidebar/aide | Naviguer (g+lettre)Créer (n+lettre)Basculer sidebar ([)Ouvrir l'aide (?) |
Séquences globales à deux touches: 'g' + lettre navigue
vers une section (ex. g a → Articles, g s → SEO), 'n' +
lettre déclenche une action de création (ex. n a → nouvel
article). Touches simples: '[' bascule la sidebar, '?' ouvre la modale d'aide des raccourcis. Une puce d'indice de séquence en attente s'affiche et s'auto-annule après 1,5 s. Les séquences sont limitées à l'index filtré par permissions et neutralisées dans les inputs/textareas/contenteditable ainsi que lorsqu'une autre modale est ouverte. |
| Modale d'aide des raccourcis (?) | Ouvrir/fermer (?, [data-cm-help], contrôle)Lister les raccourcisPiéger le focus |
Dialogue d'aide rendu côté serveur à partir du même index
filtré par permissions que le lanceur, garantissant que
ses puces ne peuvent jamais diverger de celles du
lanceur. Il liste les raccourcis du lanceur, la catégorie Autres (?, [), et les puces Création (n…) et Navigation (g…). Il possède son propre piège de focus. Les lignes dépourvues de séquence sont omises. |
| Actions rapides de création ('n…') | Créer article/page/média/utilisateur/formulaire/widgetCréer cocon pilierCréer cluster topical |
Raccourcis de création accessibles uniquement via la
recherche, regroupés sous Actions, jamais affichés dans la
sidebar ni dans la grille. Entrées: nouvel article, nouvelle page, upload média, nouvel utilisateur, nouveau formulaire, nouveau widget, nouveau cocon pilier, nouveau cluster topical (assistants de cluster SEO). Chaque action est protégée par sa permission de création (articles.create, pages.create, media.upload, users.create, forms.create, widgets.edit, cocon.view). |
| Recherche de contenu réel (groupe Contenus) | Rechercher titre (articles/pages/médias)Filtrer par permission et scoper BOLADébouncer/annuler côté clientFail-open par type |
GET /admin/api/command-search renvoie jusqu'à 8
articles/pages/médias correspondants (brouillons inclus)
via une recherche LIKE sur le titre, chaque type étant
indépendamment protégé par permission (articles.view,
pages.view, media.view). Scoping objet BOLA: les utilisateurs non privilégiés ne voient que leurs propres articles sauf permission articles.edit_others. Les médias correspondent sur original_name ou alt_text et renvoient le mime en méta; un libellé de statut (draft/published/scheduled/archived) est fourni. Fail-open par type avec journalisation d'erreur, requête minimale de 2 caractères, maximum 8 lignes. Côté client: debounce de 200 ms, annulation des requêtes obsolètes, ajout incrémental sous les résultats locaux. |
| Repli IA sémantique (niveau 2, Haiku) | Appeler AiService::commandIntent (Claude Haiku)Retourner ≤3 suggestionsLimiter (rate-limit 'ai' + CSRF)Fail-open absolu |
POST /admin/api/command-intent envoie la requête plus
l'index d'entrées filtré par permissions à Claude Haiku
(AiService::commandIntent, budget #39, journalisé dans
ai_requests) et renvoie au plus 3 suggestions ordonnées
par confiance avec une raison de 1 à 3 mots. Fail-open absolu: pas de clé API, budget épuisé, timeout ou toute erreur → groupe vide en HTTP 200. Protégé par rate-limit (bucket 'ai') et CSRF; longueur de requête 3-190. Le client ne déclenche que si le meilleur score lexical est ≤ 45, 600 ms après l'arrêt de la frappe, avec un seul appel facturable en cours. Rendu d'un groupe 'AI suggestions ✨ · <raison>', dédupliqué face aux lignes déjà à l'écran. |
| Boucle d'apprentissage d'intention auto-apprenante (niveau 3) | Apprendre au clic (beacon)Mémoriser requête→entrée par localeBooster (+150) sans IAPurger (180 j) |
POST /admin/api/command-learn mémorise l'association
requête normalisée → entry_id par locale (globale) dans la
table command_menu_learned, via un beacon déclenché au
clic sur une suggestion IA ou sur un résultat lexical mal
classé (rang ≥ 3). L'intention apprise est classée en premier grâce à un boost de +150, sans nouvel appel IA, de sorte qu'une intention comprise répond instantanément au niveau 1 la fois suivante. Les ids hors de l'index filtré par permissions de l'utilisateur sont refusés. Table auto-créée (DDL idempotent) avec purge opportuniste de rétention à 180 jours; carte apprise servie inline, filtrée par permissions, top 200 par locale. Fail-open: les erreurs de stockage renvoient learned:false / carte vide. |
| Destinations récentes (frecency) | Enregistrer chaque clic (compteur + horodatage)Scorer (demi-vie 14 j, bonus +10)Afficher top-5 en pillsPlafonner/purger le magasin |
Magasin localStorage par utilisateur (clé
cl-cmdmenu-recents-<user>) enregistrant
chaque clic de destination du lanceur (compteur +
horodatage de dernière utilisation). Scoring de frecency à demi-vie de 14 jours, avec un bonus Recent de +10 dans le score de recherche. Quand la requête est vide, les 5 destinations les plus récentes s'affichent sous forme de pills. Le magasin est plafonné à 40 entrées et purge les entrées qui ne figurent plus dans l'index (permission révoquée ou plugin désactivé). |
| Extensibilité plugin du command menu (command.menu.items) | Fusionner via le filtre command.menu.itemsGarder la forme (fail-open)Assainir couleurs/URLNormaliser les items |
Les plugins actifs peuvent enregistrer des
sections/entrées/actions via le filtre command.menu.items,
qui alimentent d'un coup la sidebar, le lanceur, la
recherche, l'aide et les séquences. Fail-open guardé par forme: les contributions malformées sont ignorées et le cœur est préservé. Sécurité: liste blanche de couleurs hex pour les teintes de section (pas d'injection CSS), et les URL de plugin doivent être des chemins admin de même origine (rejet de javascript:, data:, protocole-relatif et externes). Les items de plugin sont normalisés avec des valeurs par défaut sûres (icône, type, description). |
| Modèle de navigation sidebar (source de vérité partagée) | Rendre la sidebar filtrée (navItems)Exposer via le filtre legacy admin.menuMémoïser par requêteFail-open |
CommandMenuService est l'unique source alimentant à la
fois la sidebar admin (forme plate legacy via le filtre
admin.menu) et le lanceur, les gardant synchronisés. navItems() rend la sidebar filtrée par permissions avec en-têtes de section; les sections entièrement non autorisées disparaissent (en-tête compris). Le résultat est mémoïsé par requête. Fail-open vers un menu vide en cas d'erreur de stockage. |
| Hooks d'intégration du canal vocal (additifs) | Exposer window.__commandMenuRéutiliser le chemin EntréeSupprimer l'IA niveau 2 en vocalFiltrer les entrées requiresVoice |
Le lanceur expose une petite surface JS publique
(window.__commandMenu: setQuery, resultCount, resultLabel,
activate, topScore, setIntentSuppressed) pour que
l'assistant vocal pousse des transcriptions et active des
résultats via le même moteur de matching/apprentissage. L'activation vocale réutilise le chemin clavier Entrée (frecency et apprentissage se déclenchent). Le repli IA de niveau 2 est supprimé pendant le dialogue vocal (un seul chemin facturable). Les entrées requiresVoice (Voice Assistant, Voice Diagnostics) sont filtrées sur les navigateurs non supportés ou en kill switch, et un bouton micro push-to-talk caché n'est révélé que si la voix est supportée et activée. |
| Génération / révocation de lien de prévisualisation partageable | Créer (POST preview-token)Révoquer (DELETE)RégénérerPurger les jetons obsolètes |
Depuis l'éditeur d'article/page, génère ou révoque un lien
de prévisualisation privé et expirant via POST/DELETE
/admin/articles/{id}/preview-token et
/admin/pages/{id}/preview-token. Jeton aléatoire de 256 bits, hash SHA-256 stocké, TTL de 7 jours, révocable; créer un nouveau lien révoque les précédents (un seul lien actif par entité). L'URL brute n'est affichée (flashée) qu'une seule fois pour copie, seul le hash est persisté. Garde d'appartenance au niveau objet pour les articles (auteurs limités aux leurs; articles.edit_others ou '*' pour tous), purge opportuniste des jetons obsolètes/révoqués à la création, protégé par CSRF + articles.edit / pages.edit. |
| Panneau de partage de prévisualisation dans l'éditeur | Afficher l'URL unique + copierAfficher statut/expirationGénérer/Régénérer/RévoquerAfficher l'état vide |
Carte d'éditeur partagée affichant le statut du lien actif
et, à la création, l'URL brute unique avec un bouton de
copie dans le presse-papiers. Lorsqu'un lien existe, elle montre son statut actif et sa date d'expiration. Formulaires Générer / Régénérer / Révoquer, la révocation étant protégée par une modale de confirmation. Un état vide invite à créer un lien de relecture privé et expirant. |
| Prévisualisation publique tokenisée du brouillon | Résoudre le jeton → entité (sans le consommer)Rendre le brouillon (article/page)Ajouter une bannière brouillonForcer noindex/no-store |
GET /preview/{token} rend en lecture seule un brouillon
d'article ou de page non publié dans le thème actif,
autorisé uniquement par le jeton expirant et révocable. Le jeton brut est résolu vers l'entité (validation d'une longueur 64-hex, contrôle révoqué/expiré) sans le consommer. L'article est rendu avec les filtres content.render (shortcodes / table des matières), auteur, catégories, tags et image à la une; la page en équivalent. Une bannière rouge fixe 'brouillon non publié' (CSP-safe, sans script) est ajoutée, avec noindex,nofollow forcé (meta robots + X-Robots-Tag) et en-têtes no-store/no-cache. Seule la cible est exposée (pas de commentaires, pas d'incrément de vues, pas de données structurées, pas de liste de brouillons); un jeton invalide/expiré mène à une page 404 durcie. |
| Prévisualisation live du brouillon non sauvegardé (éditeur) | Assainir titre/contenu/slugRendre via le thèmeRefléter catégories/tags/imageForcer noindex |
POST /admin/articles/preview et /admin/pages/preview
rendent le contenu de formulaire en cours (non sauvegardé)
dans le thème pour un aperçu rapide. Le titre/contenu/slug soumis sont assainis puis rendus via le template de thème blog/page. Les catégories, tags et image à la une sélectionnés dans le formulaire sont reflétés. Override SEO noindex,nofollow, entité factice éphémère (non persistée), protégé par CSRF + articles.create / pages.create. |
| Manifest PWA dynamique | Émettre name/short_name/start_url/scopeDéclarer icônes 192/512Fail-open vers défauts statiquesCacher 1 h |
GET /manifest.json est servi via PHP pour porter le
nom/langue/base path en direct depuis les réglages et
rester immunisé contre les 403 de permissions de fichiers
statiques. Émet name, short_name (≤12 caractères), description, start_url et scope depuis les réglages live + base path. Icônes 192 et 512 en purposes 'any' et 'maskable', display standalone, theme_color #2563eb, background_color #0d0d18, categories, lang. Fail-open vers des défauts statiques si la DB/les réglages sont indisponibles, mis en cache 1 h; un public/manifest.json statique subsiste comme repli. |
| Service worker offline (cache-first / network-first) | Cache-first pour les actifsNetwork-first pour le HTML publicNe jamais cacher admin/api/SSEPurger via CACHE_VERSION |
public/service-worker.js fournit le cache offline PWA,
enregistré sur les layouts admin et public, compatible
sous-répertoire. Cache-first pour les actifs statiques sous /assets/, le manifest et le favicon; network-first pour les navigations HTML publiques, avec repli sur le cache puis offline.html / repli inline. Ne cache/sert jamais l'admin, /api/ ou les flux SSE (toujours en direct); contourne le non-GET, le cross-origin et les requêtes range. Purge de cache basée sur CACHE_VERSION à l'activation, avec skipWaiting + clients.claim; le base path est dérivé de l'emplacement du worker (fonctionne à / ou /blog/). |
| Meta d'installation PWA + enregistrement du service worker | Lier le manifest (link rel=manifest)Déclarer les meta Apple web-appEnregistrer le SW au chargement |
Les deux layouts (admin et public) lient le manifest via
<link rel=manifest> et déclarent les meta
Apple web-app (apple-mobile-web-app-capable,
status-bar-style, title). Le service worker est enregistré via navigator.serviceWorker.register('<base>/service-worker.js') au chargement de la fenêtre (window load). L'appel est nonce'd (compatible CSP) et échoue en silence (fail-silent). |
E-commerce
🛒 Plugin Boutique (E-commerce)
23 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
Catalogue storefront et fiche produit
([shop_products])
|
ParcourirConsulter ficheAjouter au panierS'abonner |
Le shortcode [shop_products] s'injecte dans
n'importe quelle page CMS via le hook
content.render et affiche une grille des
produits actifs, mis en avant d'abord puis les plus
récents, limitée à 60 items.• Une fiche produit détaillée est sélectionnée par ?produit=slug
(findActiveBySlug), avec prix formaté selon
la devise de la boutique (EUR symbole après, autres
devises symbole avant).• Un badge d'intervalle d'abonnement (« / mois », « / 3 mois ») s'affiche sur les produits récurrents; les produits physiques épuisés montrent « Rupture de stock » et masquent l'ajout au panier. • Message « boutique indisponible » quand la boutique est désactivée; la fiche propose « Ajouter au panier » (achat unique) ou « S'abonner » (abonnement) avec champ quantité, et retombe sur l'image legacy image_path si le produit n'a aucun média
enfant.
|
| Galerie média produit (images + vidéos, pur CSS) | Afficher vidéoBasculer miniaturesServir srcset |
Galerie multi-images/vidéos où la vidéo principale est
présentée en premier (poster + balise
<video> native avec contrôles) quand le
produit en possède une.• Le passage d'un média à l'autre se fait via une bande de miniatures 100% CSS (radio :checked, sans JavaScript, compatible
CSP) qui échange la miniature et la scène.• Les images sont servies en srcset responsive
(largeurs 300/768/1200) avec indice sizes; le
poster_path d'une vidéo sert de vignette de
grille pour les produits vidéo seule.• Repli sur l'image unique legacy quand le produit n'a pas de lignes média enfant (table shop_product_media).
|
| Fichiers téléchargeables gratuits (publics) | ListerTéléchargerLibeller |
La fiche produit liste les fichiers téléchargeables non
protégés du produit sous forme de liens directs publics
(freeFiles).• Le libellé provient du label du fichier ou, à défaut, de l' original_name
du média ou du nom de base du fichier.• Les fichiers protégés (payants, isProtected) ne sont
jamais listés ici et restent réservés à la livraison par
token après achat.
|
Panier de session ([shop_cart])
|
AjouterModifier quantitéRetirerVider |
Panier stocké en session sous forme
[productId => qty]; les prix et le stock
sont toujours relus depuis la base à chaque affichage,
jamais mémorisés.• Actions POST /shop/cart/add (ajout/incrément),
/shop/cart/update (quantité, 0 supprime la
ligne) et /shop/cart/remove, toutes protégées
CSRF et rate-limitées; quantité plafonnée à 99 par
ligne.• Règles métier: refus des produits d'une autre devise ( currency_mismatch), règle « un
abonnement s'achète seul » (ajouter un abonnement
réinitialise le panier et bloque le mélange), rejet des
quantités hors stock pour les produits physiques.• Les produits archivés ou supprimés sont retirés des lignes résolues; le récapitulatif affiche sous-total, livraison, TVA incluse et total; le panier est vidé automatiquement au retour de Stripe. |
| Stripe Checkout (redirection paiement hébergée) | Saisir email et nomSaisir adresseConsentirRediriger |
Transforme le panier en une commande « pending » persistée
(avec snapshots prix/TVA/quantité par ligne) avant de
contacter Stripe, puis crée une Checkout Session et
redirige l'acheteur en 303 vers la page hébergée Stripe
(mode paiement ou abonnement); aucune carte ne transite
par le serveur (PCI SAQ-A). • Capture l'email client (validé) et le nom complet, exige l'adresse de livraison (ligne/ville/code postal/pays) quand le panier contient des biens physiques. • Impose côté serveur le consentement de vente à distance UE ( terms_accepted
+ privacy_accepted) et affiche le droit de
rétractation 14 jours avec exception contenu numérique.• Refuse le checkout si la boutique est désactivée, le panier vide, Stripe non configuré ou l'identité vendeur incomplète; construit les line_items avec
intervalle récurrent pour les abonnements, ajoute la
livraison forfaitaire en
shipping_option (paiements uniques
seulement).• Passe l'uuid de commande comme clé d'idempotence, client_reference_id et
metadata; marque la commande « failed » si la session ne
peut être créée; les URLs succès/annulation portent
l'uuid.
|
Page de confirmation de commande
([shop_order])
|
Afficher statutLister itemsOuvrir factureVider panier |
Confirmation post-checkout lisant
?order=uuid (ou la session), avec bannière
verte « paiement reçu » (paid/fulfilled) ou ambre « en
cours de confirmation » (pending).• Liste les articles de la commande et le total, et affiche un libellé de statut localisé ( statusLabel).• Propose un lien vers la facture légale une fois l' invoice_number
attribué, et un lien vers l'avoir quand un
credit_note_number existe.• Vide le panier quand l'acheteur revient pour sa dernière commande. |
Livraison sécurisée de téléchargements numériques
(/shop/download/{token})
|
Valider tokenVérifier droitStreamer fichier |
Diffuse un fichier numérique acheté depuis le dossier
privé storage/downloads/, protégé uniquement
par un token de capacité impossible à deviner (64
caractères hexadécimaux, stocké haché en sha256).• Valide le format du token (64 hex) puis le recherche par hash sha256; applique l'expiration du lien (30 jours par défaut) et le plafond d'utilisations par lien (5 par défaut). • Vérifie le droit de la commande (payée et ni remboursée ni annulée), résout le token par fichier ( product_file_id) ou retombe sur le
download_path legacy.• Durcit le chemin contre les traversées et octets nuls en confirmant qu'il reste dans le répertoire de base, incrémente downloads_used en best-effort (fail-open sur
fichier payé) et streame le fichier en pièce jointe
(jamais servable directement par le serveur web).
|
Facture légale et avoir client (public,
/shop/invoice/{uuid})
|
Consulter factureConsulter avoirImprimer/PDF |
L'uuid de commande agit comme capacité pour consulter une
facture HTML imprimable (impression/enregistrement PDF par
le navigateur). • L'avoir (credit note) se consulte via ?doc=credit sur la même URL.• Renvoie une 404 quand la commande n'est pas payée ou que le numéro de document demandé est absent; seules les commandes payées portant le numéro demandé sont servies. |
Portail de facturation client self-service ([shop_account]
+ /shop/account/portal)
|
Lister abonnementsOuvrir portail StripeGérer carteAnnuler |
Les clients connectés voient la liste de leurs abonnements
(montant, statut, date de renouvellement) via le shortcode
[shop_account].• L'action POST /shop/account/portal (authentifiée, CSRF,
rate-limitée) ouvre une nouvelle session courte du Portail
de Facturation Stripe pour l'auto-gestion (changer la
carte, annuler, télécharger les factures).• L'identifiant client Stripe est résolu d'abord depuis les abonnements de l'utilisateur, puis depuis ses commandes. • Invite à se connecter si non authentifié, et affiche « aucun compte de facturation » quand aucun identifiant Stripe n'est trouvé. |
Récepteur webhook Stripe
(/shop/webhook/stripe)
|
Vérifier signatureDédupliquerRouter événements |
Webhook exempté de CSRF et vérifié par signature
HMAC-SHA256 (schéma v1, fenêtre anti-rejeu de 300 s);
c'est l'UNIQUE chemin qui fait avancer l'état de
paiement. • Idempotent grâce à une table de réclamation d' event id (INSERT IGNORE) qui
neutralise les livraisons dupliquées, avec dédup fail-open
et libération de la réclamation en cas d'erreur de handler
(500).• Route les événements: checkout.session.completed → commande payée
(avec attente de paiement asynchrone),
checkout.session.expired → annulation de la
commande pending abandonnée,
customer.subscription.created/updated/deleted
→ synchronisation d'abonnement.• Traite aussi invoice.paid (reçu de renouvellement +
avancement de période),
invoice.payment_failed (relance + past_due),
charge.refunded (réconciliation de
remboursement) et
charge.dispute.created/closed; renvoie 200
sur les types non gérés pour que Stripe cesse de
réessayer.
|
| Pipeline de traitement des commandes payées (piloté par webhook) | Promouvoir payéeNuméroter factureDécrémenter stockÉmettre liens |
Sur une Checkout Session payée et vérifiée, promeut
atomiquement la commande en « paid » et estampille
paid_at / payment_intent /
identifiant client (idempotent sur
paid_at).• Attend le règlement asynchrone pour SEPA/iDEAL/Bacs (quand payment_status != paid); alloue le numéro de
facture gapless dans la même transaction.• Décrément de stock atomique et conditionnel (journalise une survente plutôt que de plafonner), enregistre l'abonnement Stripe pour les commandes en mode abonnement. • Émet des liens de téléchargement sécurisés par fichier protégé, puis met en file l'email de confirmation après commit. |
| Emails transactionnels de boutique | Envoyer confirmationNotifier expéditionRelancerNotifier annulation |
Six emails de marque (en-tête/pied identité vendeur), mis
en file via le Mailer du cœur, chacun fail-open pour
qu'une erreur d'envoi ne casse jamais le flux. • Confirmation de commande (totaux détaillés, lien facture, liens de téléchargement sécurisés + note d'expiration) et confirmation de remboursement (lien avoir, remboursements complets). • Notification d'expédition (transporteur + référence de suivi) et reçu de renouvellement d'abonnement (uniquement pour subscription_cycle, avec date du prochain
renouvellement).• Email de relance (dunning) sur échec de renouvellement (lien vers le portail self-service) et avis d'annulation d'abonnement (avec date de fin d'accès). |
Dashboard admin de la boutique (/admin/shop)
|
Afficher KPIsLister commandes récentesAlerter configuration |
Tableau de bord KPI (permission shop.view)
affichant chiffre d'affaires payé, nombre de produits,
nombre de commandes, commandes payées, commandes en
attente et abonnements actifs.• Liste les 10 commandes les plus récentes avec liens vers leur détail. • Affiche des bannières d'alerte quand Stripe n'est pas configuré (checkout désactivé) ou que le secret webhook manque, avec un lien vers les réglages Stripe. |
Gestion des produits admin / CRUD
(/admin/shop/products)
|
CréerÉditerSupprimerAttacher médias et fichiers |
CRUD produit complet: liste (nom, type, facturation, prix,
stock, statut), création avec uuid auto, slug auto-unique
et created_by, édition et suppression via
modale de confirmation.• Champs configurables: nom, slug, descriptions courte/longue, type (numérique/physique), facturation (achat unique/abonnement), prix TTC, taux de TVA, SKU, stock (vide = illimité, physique seulement). • Intervalle d'abonnement (jour/semaine/mois/année) + nombre d'intervalles, bascule « mis en avant sur le storefront », statut (actif/brouillon/archivé). • Attache des médias produit via le sélecteur de médiathèque (images + vidéos, choix de la vidéo principale) et un répéteur de fichiers téléchargeables protégés (payants) ou gratuits (publics); l'image principale et le premier fichier protégé sont mirrorés dans les colonnes legacy (liste = shop.view, écritures =
shop.manage).
|
Upload de fichier privé durci (AJAX
/admin/shop/upload-file)
|
TéléverserValider MIMEBloquer exécutablesStocker privé |
Point AJAX pour les fichiers produit payants (protégés),
stockés sous le répertoire privé
storage/downloads/{Y/m}/ avec six couches de
défense.• Rejette les fichiers non whitelistés, les exécutables bloqués et les doubles extensions; vérifie le vrai type MIME via finfo contre l'extension
déclarée.• Impose une taille maximale configurable (512 Mo par défaut via general.download_max_size) et enregistre sous
un nom de fichier aléatoire.• Renvoie une réponse JSON contenant le chemin relatif privé du fichier stocké. |
Gestion des commandes admin
(/admin/shop/orders)
|
ListerConsulter détailChanger statutExpédier |
Liste des commandes (max 200) filtrable par statut
(pending/paid/failed/canceled/refunded/fulfilled), avec
vue détaillée: articles, totaux, client, adresse de
livraison, PI Stripe, montant remboursé et facture. • Change le statut opérationnel vers « fulfilled » ou « canceled » (les états de paiement ne proviennent QUE de Stripe), en bloquant les transitions illégales via la carte TRANSITIONS (ex. jamais-payé →
fulfilled impossible).• Capture transporteur + numéro de suivi et estampille shipped_at au
passage en « fulfilled », en déclenchant l'email de
notification d'expédition.• Re-stocke automatiquement (une seule fois) les articles d'une commande physique payée à l'annulation; consultation de la facture/avoir légal via /admin/shop/orders/{id}/invoice (liste/vue =
shop.view, écritures =
shop.orders).
|
Remboursements Stripe complets et partiels
(/admin/shop/orders/{id}/refund)
|
Rembourser totalRembourser partielRe-stockerNotifier |
Émet un vrai remboursement Stripe contre le
payment_intent de la commande: remboursement
complet (montant vide = solde remboursable restant) ou
partiel (validé ≤ montant remboursable).• Utilise une clé d'idempotence liée à la position cumulée remboursée (pas de double remboursement sur double soumission) et bascule atomiquement paid/fulfilled → refunded (sûr face à la course entre action admin et webhook, via applyRefund partagé avec
charge.refunded).• Alloue un numéro d'avoir (credit note) gapless sur remboursement complet, re-stocke les articles physiques une seule fois, et suit refunded_total cumulé /
refunded_at /
stripe_refund_id.• Envoie l'email de confirmation de remboursement et écrit une entrée d'audit; refuse le remboursement sur commande jamais payée / annulée / échouée / déjà intégralement remboursée. |
| Gestion des litiges / chargebacks | Notifier ouvertureNotifier clôtureAuditer |
Les webhooks
charge.dispute.created/closed remontent les
litiges aux admins sans changement d'état automatique
(jugement humain requis).• Notification « Chargeback opened » (criticité critique) avec lien vers la commande et rappel de la date limite du dashboard Stripe. • Notification à la clôture d'un litige (gagné/perdu) avec sévérité selon l'issue. • Écriture des entrées d'audit shop.order.dispute_opened /
dispute_closed.
|
Gestion des abonnements admin
(/admin/shop/subscriptions)
|
ListerAnnuler en fin de périodeRéconcilier |
Liste les abonnements (client, statut, montant,
intervalle, date de renouvellement, identifiant
Stripe). • Permet à un opérateur d'annuler un abonnement en fin de période courante via Stripe ( cancel_at_period_end); rejette l'annulation
en l'absence de référence Stripe ou si déjà annulé.• Les états de renouvellement/échec sont pilotés par webhook; le webhook customer.subscription.deleted réconcilie vers
« canceled » et notifie le client par email (liste =
shop.view, annulation =
shop.orders).
|
Réglages de la boutique
(/admin/shop/settings)
|
Activer/désactiverConfigurer devise et TVAConfigurer StripeRenseigner identité vendeur |
Écran de configuration (permission
shop.manage) avec bascule boutique
activée/désactivée.• Devise (ISO-3), taux de TVA par défaut, taux de TVA livraison (0 = exonéré), frais de livraison forfaitaires et seuil de franco de port. • Stripe: mode (test/live), clé publiable (en clair), clé secrète + secret webhook (chiffrés, laisser vide conserve l'existant), affichage de l'URL d'endpoint webhook et de la liste d'événements requis, avec avertissement si APP_ENCRYPTION_KEY manque.• Slugs des pages storefront (shop/cart/order/account) mappés aux shortcodes, et identité légale vendeur: raison sociale, adresse postale, numéro de TVA, immatriculation (SIRET), email de contact, préfixe de numéro de facture et mentions légales en texte libre imprimées sur les factures. |
| Facturation légale numérotation séquentielle sans trou et ventilation TVA | Allouer numéroVentiler TVARendre factureRendre avoir |
Factures et avoirs conformes UE/FR tirés d'un compteur
atomique gapless par série et par année (idiome
LAST_INSERT_ID), le numéro de facture étant
alloué dans la transaction de paiement.• Allocation d'un numéro d'avoir gapless sur remboursement complet; rendu de la facture avec identité vendeur + acheteur, lignes, récapitulatif de TVA par taux et totaux TTC/HT. • La ventilation de TVA par taux est dérivée des prix TTC ( taxFromInclusive) avec réconciliation de la
TVA de livraison; l'avoir porte des montants négatifs, sa
propre date d'émission et référence la facture
d'origine.• Impression/enregistrement PDF depuis le navigateur, avec un pied de page de mentions légales configurable. |
Couverture RGPD (hook rgpd.data_sources)
|
Exporter PIIAnonymiserRapprocher par email ou user_id |
Déclare shop_orders et
shop_subscriptions comme sources de données
personnelles pour l'export du cœur.• Sur une demande d'accès (art. 15), exporte les PII de commande et d'abonnement; sur une demande d'effacement (art. 17), anonymise sur place customer_email, nom,
facturation, livraison et
stripe_customer_id tout en conservant les
lignes pour l'intégrité comptable.• Rapproche le sujet à la fois par customer_email et par
user_id, ce qui couvre les commandes invité
identifiées uniquement par email.
|
| Navigation admin unifiée et permissions de boutique | Afficher navigationFiltrer par permissionContrôler accès |
Contribue la section « Shop » à la barre latérale et au
lanceur/recherche/aide Ctrl+K en une seule inscription,
chaque item filtré par permission côté serveur
(Auth::can).• Items: Shop / Products / Orders / Subscriptions ( shop.view) et Shop
settings (shop.manage).• Adossé à trois permissions granulaires accordées aux rôles admin/éditeur: shop.view (dashboard, listes
produits/commandes), shop.manage (écritures
produits/réglages) et shop.orders (actions
commandes/abonnements).
|
Design & Thèmes
🏭 Plugin AI Studio Design
22 fonctionnalités| Fonctionnalité | Actions | Fonctionnement exact |
|---|---|---|
| Architecture usine & distinction avec le Theme Studio du cœur | IsolerProduireDécoupler |
AISD est une USINE, jamais un runtime : chaque thème qu'il
produit est un thème classique 100% autonome sous
themes/{slug}/ qui continue de fonctionner à
l'octet près même si le plugin est désactivé ou
désinstallé.C'est la distinction nette avec le Theme Studio du cœur : le plugin se branche au cœur EXCLUSIVEMENT via des hooks ( routes.register.web,
command.menu.items, admin.head)
et ne touche jamais les fichiers du cœur, ni
app/Services/ThemeStudio/*, ni
theme-library/*.Tous les écrans vivent sous /admin/aisd, protégés par auth +
permission aisd.use ; l'activation exige
aisd.publish + CSRF ; la file de revue exige
aisd.library.review.Les 3 permissions sont amorcées par migrations/ai-studio-design_2026_07_19_000001_permissions.sql. Aucune route n'est déclarée dans
app/routes.php : tout passe par les hooks de
Plugin.php.
|
| Accueil du studio / liste des thèmes AISD | ListerTrierActiverOuvrir |
L'écran d'accueil (StudioController::index)
scanne le système de fichiers (aisdThemes)
pour lister tous les thèmes AISD avec nom, slug, type de
site, skin, build, score qualité /100 et nombre de
problèmes critiques.Le thème actif est repéré et trié en tête avec un badge « Active » ( Theme::activeIncludingDefault), avec repli
fail-open sur le filesystem si la BDD échoue.Chaque ligne offre les liens Éditer (ouvre l'éditeur canvas) et Voir le score (bulletin qualité), plus une action Activer (formulaire POST) affichée uniquement pour les thèmes non actifs. L'écran héberge aussi le formulaire de création de thème (brief, référence, capture, nom du site, type, langue). |
| Assistant de génération (brief → 4 directions) | SoumettreChoisirGénérerPrévisualiser |
Assistant multi-étapes
(WizardController::create) transformant un
brief (≤2000 car.) + référence optionnelle (≤2000) + nom
de site (≤80) en 4 directions de design divergentes via
ArtDirector::direct.Le type de site se choisit parmi vitrine, blog, landing, saas, portfolio ( ManifestValidator::SITE_TYPES autorise aussi
ecommerce) ; la langue de sortie parmi 10 locales : fr,
en, es, de, pt, ru, ja, zh, ar, id.Chaque direction est compilée en aperçu stocké sous storage/aisd/cache/wizard/{token} puis rendue
en direct dans une carte via un aperçu shadow-DOM à styles
cloisonnés, mis à l'échelle.Les états d'assistant périmés sont purgés automatiquement après un TTL de 24h. |
| Direction pilotée par une référence (URL / description / capture) | FournirTéléverserMapperDédupliquer |
Une référence peut être fournie comme URL de site ou
courte description texte
(withReferenceDirection), ou via une capture
d'écran téléversée (PNG, JPEG, WEBP, GIF) qui prend le pas
sur le texte
(withReferenceImageDirection).La référence est mappée sur un couple recette+skin curé de la bibliothèque puis ajoutée EN TÊTE comme première des 4 directions ( mergeReferenceProposal),
dédupliquée par (recette, skin) et plafonnée à 4.Il s'agit d'une composition, jamais d'une copie. Comportement fail-open : une référence inexploitable laisse silencieusement les 4 directions issues du brief. Traitée par ReferenceImporter et
ReferenceAnalyzer.
|
| Analyse de capture → brief éditable (vision) | AnalyserAuto-remplirÉditer |
Endpoint AJAX (WizardController::analyze)
déclenché par « Analyse the screenshot », qui POST l'image
vers /admin/aisd/wizard/analyze et renvoie du
JSON.Un modèle de vision ( ScreenshotBriefer) lit la capture et
auto-remplit les champs brief + référence + type de site,
que l'utilisateur peut ensuite éditer (approche
glass-box).Un overlay de scan animé affiche des indices de progression rotatifs. Messages d'erreur fail-open granulaires : aucun fichier, limite d'upload, trop volumineux (>15MB), type invalide, dimensions invalides, PHP-GD absent, vision non supportée ( ScreenshotIntake).
|
| Curseurs / ajustements de direction (recompilation déterministe) | RéglerRecompilerRepeindre |
Contrôles par direction
(WizardController::adjust) recompilant de
façon DÉTERMINISTE la direction choisie côté serveur, SANS
aucune dépense IA.Options en enums whitelistés uniquement ( ArtDirector::ADJUSTMENTS) :
Densité (compact, regular, airy), Rayon (sharp, soft,
round, pill), Intensité de mouvement (calm, editorial,
energetic), Mode par défaut (light, dark).Le POST /admin/aisd/wizard/adjust recompile et
repeint l'aperçu.La direction ajustée est persistée dans state.json.
|
| Aperçus d'assistant (page autonome + données JSON) | OuvrirRécupérerValider |
Deux endpoints GET adossés à chaque carte de direction.preview
ouvre une direction en page autonome complète dans un
nouvel onglet (mode + locale + nom appliqués).previewData
renvoie le CSS/HTML/mode d'aperçu en JSON pour peindre
l'hôte shadow-DOM dans la carte
(PreviewRenderer).Accès sécurisé par token validé (16 caractères hex) et index borné (0..3). |
| Flux SSE de génération de thème | DémarrerDiffuserRediriger |
Endpoint Server-Sent-Events
(WizardController::generate) démarré via
EventSource GET
/admin/aisd/wizard/{token}/generate/{i}.Il diffuse les étapes de progression compose, write, illustrate, build, score sur un overlay animé ( ArtDirector::generate), le payload
score.done portant le score +
allowed.Un événement « done » émet le nouveau slug/score et redirige vers le bulletin qualité ; un événement « failed » émet la liste d'erreurs. Durcissement anti-buffering : backoff de retry, commentaire de padding, en-tête X-Accel-Buffering, plus un indice de
récupération en cas de flux interrompu.
|
| Bulletin qualité / écran de score | AfficherDétaillerActiver |
Rapport qualité par thème
(StudioController::score,
QualityAnalyzer) affichant le score total
/100 avec code couleur et le verdict du gate
Autorisé/Refusé (seuil ≥90 ET 0 critique,
QualityGate).Barres par catégorie : Design, UX, Mobile, Speed, SEO, Code (score sur max). Il liste les vérifications échouées par catégorie via leur id technique avec pénalité en points, les problèmes critiques bloquants (id + message), et les pénalités de design générique (id, -1 chacune). Affiche aussi les métadonnées build id, skin, type de site, un bouton Activer en ligne quand le gate autorise hors fail-open, et un lien vers l'écran de suggestions IA. |
| Activation de thème sous gate qualité | ActiverVérifierJournaliser |
POST
/admin/aisd/theme/{slug}/activate protégé par
la permission aisd.publish + CSRF
(StudioController::activate).L'activation est refusée quand le gate n'autorise pas (score<90 ou critiques>0), avec redirection vers le score et une erreur. Fail-open : si le gate est indisponible, l'activation est autorisée avec un flash d'avertissement. En cas de succès, la ligne Theme du cœur est upsertée ( upsertThemeRow :
type=aisd, name/description/version/author/license depuis
theme.json) puis activée via le modèle du
cœur. Chaque activation, refus ou échec est journalisé via
AisdLog.
|
| Suggestions d'amélioration IA à la demande (boucle visuelle) | AnalyserListerRenvoyer |
GET
/admin/aisd/theme/{slug}/suggest
(StudioController::suggest) fait passer le
critique (VisualCritic,
VisualLoop) sur le plan de travail + le
rapport qualité.Il liste les suggestions survivantes sous forme prompt + chemin cible + raison. État vide honnête quand l'exécution est impossible (pas de clé API, pas de budget, pas de plan) ; endpoint en GET pour que la dépense IA soit explicite ; budget-gated et fail-open. Rien n'est appliqué automatiquement : un lien « Ouvrir dans l'éditeur » permet d'appliquer manuellement via l'éditeur normal. |
| Coquille de l'éditeur canvas (cliquer + décrire) | SélectionnerBasculerNaviguer |
Éditeur visuel (EditorController::edit) avec
aperçu live shadow-DOM où l'on clique n'importe quel nœud
adressable (data-aisd-path) pour le
sélectionner et l'éditer.Bascules : largeur de viewport 375, 768, 1440 ; thème light/dark ; direction de texte LTR/RTL ; plus l'ouverture du panneau Direction artistique. Un fil d'Ariane montre le chemin du nœud sélectionné. Une chronologie d'historique liste les snapshots avec le snapshot courant surligné. Assets injectés via le hook admin.head (CSP-nonced,
pages AISD uniquement).
|
| Panneau de réglages du nœud (piloté par manifeste, zéro IA) | RésoudreÉditerRéorganiser |
Panneau contextuel (EditorController::node,
buildPanel) rendant les contrôles éditables
du nœud sélectionné à partir de son manifeste de
bibliothèque (LibraryScanner), sans aucune
IA.Il résout le type de nœud : chrome, section, pattern, primitive, scene, skin. Contrôles : choix de variante (si >1 variante), options enum (menu déroulant), options booléennes (case à cocher), slots texte/url (champs texte avec longueur max). Pour une scene : choix de tonalité (base, alt, inverse, inverse-alt), déplacer une section haut/bas, la retirer, insérer une section de contenu du catalogue. Pour skin/direction artistique : choix du skin et de l'intensité de mouvement (calm, editorial, energetic). |
| Entonnoir de mutation par patch typé (apply) | AppliquerNégocierValiderSnapshoter |
Entonnoir unique
(EditorController::apply/applyOps,
PatchApplier) pour toutes les éditions
directes via des ops typées : set_variant,
set_option, set_slot,
set_tone, set_stage,
override_token, insert_section,
remove_section, move_section,
set_skin,
set_motion_profile.Négociation de contrainte : les éditions violant le contraste WCAG (<4.5) sont refusées avec la contre-proposition conforme la plus proche, applicable en un clic ( ConstraintNegotiator).Garde structurelle : refus de supprimer le hero ou le H1 de page, et refus d'ajouter un second hero. Le plan entier est revalidé (refus par défaut), puis un snapshot d'historique est poussé et l'aperçu en mode édition recompilé + la chronologie sont renvoyés (recompilation sub-seconde). |
| Barre de prompt IA contextuelle (langage naturel → patch) | DécrireCompilerPrévisualiserEnregistrer |
Barre de prompt (EditorController::prompt) où
l'on saisit un changement (≤500 car.) porté sur le chemin
du nœud sélectionné (défaut « skin »).Le prompt est compilé en ops typées par le compléteur IA ( PatchCompiler) puis passe par le MÊME
entonnoir apply (mêmes validations et gardes).Un prompt ambigu produit jusqu'à 3 alternatives prévisualisées, chacune applicable en un clic. Un prompt insoluble est enregistré dans le backlog de bibliothèque (boucle d'apprentissage, sans PII) avec une raison remontée à l'utilisateur ( LibraryBacklog).
|
| Navigation d'historique annuler/rétablir | AnnulerRétablirPersister |
POST .../edit/undo revient au snapshot de
plan précédent ; POST .../edit/redo avance
(EditorController::undo/redo,
step).Le plan restauré est persisté dans le dépôt et l'aperçu recompilé + la chronologie mise à jour sont renvoyés ( HistoryService).À la première ouverture d'un thème, l'historique est amorcé par un snapshot « initial ». Le mécanisme est purement basé sur des snapshots, couvrant toutes les mutations de l'éditeur. |
| Publication / republication depuis l'éditeur | ReconstruireRedirigerSignaler |
POST .../edit/publish reconstruit
themes/{slug}/ à partir du plan de travail
courant (EditorController::publish,
ThemeAssembler).En cas de succès, le build id est renvoyé et l'utilisateur redirigé vers l'écran de score. En cas d'échec, les erreurs de l'assembleur sont remontées avec un code 422. La sortie reste un thème classique autonome, indépendant du plugin. |
| Endpoint d'aperçu du plan de travail (éditeur) | RendreRésoudreValider |
GET .../edit/preview renvoie le CSS/HTML/mode
en mode édition + la chronologie en JSON
(EditorController::preview,
previewResponse).Le plan de travail est résolu par ordre de priorité : curseur d'historique → miroir du dépôt → .aisd/site-plan.json du
thème (ré-éditabilité), validé avant usage
(loadWorkingPlan).Sert à (re)peindre le canvas de l'éditeur. La présence de .aisd/site-plan.json dans le thème garantit
qu'un thème déjà bâti reste réouvrable et modifiable.
|
| File de revue de composants + backlog de bibliothèque | ListerAfficherFiltrer |
Écran (ReviewController::index) listant les
composants brouillons avec id, requête d'origine et badge
de statut (pending, approved, rejected), derrière la
permission dédiée
aisd.library.review.Il affiche aussi le backlog de la boucle d'apprentissage : le top 30 des choses les plus demandées que la bibliothèque ne couvre pas (compte + échantillon, LibraryBacklog).Chaque brouillon est lié à sa page de détail/revue ( ComponentDraftStore).C'est le portail humain de gouvernance des composants générés par l'IA avant leur entrée en bibliothèque. |
| Détail de brouillon de composant + approuver/rejeter | ConsulterApprouverRejeter |
Écran de détail (ReviewController::show)
montrant le partial.php et le
primitive.css générés (échappés, en lecture
seule) et le verdict du bac à sable : réussi (validé,
isolé, XSS-échappé, CSP-clean) ou la liste des erreurs de
sandbox (ComponentSandbox).« Approuver & ajouter à la bibliothèque » (POST + CSRF) n'est proposé que pour les brouillons pending ; le store relance le sandbox À L'APPROBATION comme ultime défense ( ReviewController::approve).Le rejet du brouillon (POST + CSRF) est également disponible ( ReviewController::reject).
|
| Garde 404 propre pour sous-chemins AISD inconnus | IntercepterRenvoyer 404 |
GET /admin/aisd/{any} renvoie
Response::notFound()
(StudioController::notFound), derrière auth +
aisd.use.Cette route de repli garantit un vrai 404 pour tout chemin /admin/aisd/* non implémenté, l'empêchant de
retomber sur le catch-all public de contenu
/{any} (où il serait résolu comme un slug de
contenu).Les routes réelles des phases sont enregistrées AVANT cette garde, l'ordre d'enregistrement l'emportant. |
| Entrées de navigation unifiées (sidebar + lanceur Ctrl+K) | EnregistrerGrouperFiltrer |
Un SEUL enregistrement (Plugin::commandMenuItems
via le hook command.menu.items) alimente à la
fois la sidebar admin, la recherche et le lanceur
Ctrl+K.Il ajoute l'item « Ai Studio Design » → /admin/aisd (permission
aisd.use, correspondance exacte) et l'item «
Component review » →
/admin/aisd/library/review (permission
aisd.library.review), groupés sous une
section « Ai Studio Design » avec icône et description.Les items sont filtrés côté serveur par Auth::can par item.Le filtre legacy admin.menu n'est délibérément PAS enregistré
en plus, sinon la section serait rendue en double.
|
Aucune fonctionnalité ne correspond à votre recherche.