Refactoring : sécurité (XSS), découpage en modules inc/* et js/admin/*, IDs résolus par slug, perf (caches, cron Gravatar, assets auto-hébergés), tests

This commit is contained in:
2026-06-10 21:30:25 +02:00
parent e6b73df516
commit 9280c3b9ce
44 changed files with 3209 additions and 2907 deletions

405
js/admin/admin-base.js Normal file
View File

@@ -0,0 +1,405 @@
/**
* Socle partagé des customisations admin (namespace window.ThalimAdmin).
* Chargé sur toutes les pages admin, avant les scripts de contexte
* (admin-rename, admin-post-edit, admin-profile, admin-taxonomy-list,
* admin-pods-modal) qui en dépendent.
*/
(function($) {
'use strict';
// ── Configuration ──────────────────────────────────────────
// Sélecteurs et identifiants Pods utilisés par les modules admin,
// centralisés pour qu'un renommage côté Pods ne demande qu'une édition ici.
var CONFIG = {
// Options désactivées dans le select Pods « Type d'annonce » (term IDs)
disabledCategoryIds: ['1', '12', '5', '20'],
// Catégorie « Séance de séminaire » (verrouillée dans la modale Pods)
seanceCategoryId: '12',
// Select Pods de la catégorie
categorySelect: '#pods-form-ui-pods-meta-categorie',
// IDs des éditeurs TinyMCE Pods à réparer (reinitEditor)
editors: {
bodyEn: 'pods-form-ui-pods-meta-body-en',
refBib: 'pods-form-ui-pods-meta-reference-bibliographique'
},
// Metaboxes Pods déplacées / observées
boxes: {
bodyEn: '#pods-meta-body-en',
typeDannonce: '#pods-meta-type-dannonce',
affichageAccueil: '#pods-meta-affichage-sur-laccueil',
thematique: '#pods-meta-thematique',
champsContextuels: '#pods-meta-champs-contextuels',
documentsJoints: '#pods-meta-documents-joints',
membres: '#pods-meta-membres'
},
// Classes des lignes Pods conditionnelles observées (MutationObserver)
rows: {
axes: 'pods-form-ui-row-name-axes-thematiques',
refBib: 'pods-form-ui-row-name-reference-bibliographique'
},
// Taxonomies dont la page liste reçoit l'info-bulle « FR // EN »
translateTaxonomies: ['axe_thematique', 'programme_de_recherche', 'post_tag']
};
// Exécute un bloc d'init de façon isolée : une exception dans un module
// n'empêche pas les modules suivants de s'initialiser.
function safeRun(name, fn) {
try {
fn();
} catch (err) {
if (window.console && console.error) {
console.error('[thalim-admin] ' + name + ' failed:', err);
}
}
}
function isPostEditPage() {
return window.pagenow === 'post'
|| window.pagenow === 'post-new'
// On CPTs, pagenow is the post_type slug — also catch them via the
// body classes WP sets for any post.php / post-new.php screen.
|| document.body.classList.contains('post-php')
|| document.body.classList.contains('post-new-php');
}
function isProfileEditPage() {
return window.pagenow === 'profile' || window.pagenow === 'user-edit' || window.pagenow === 'user-new';
}
function getProfileForm() {
return document.querySelector('#your-profile, #createuser');
}
function isPodsModal() {
return new URLSearchParams(window.location.search).has('pods_modal');
}
function updatePostboxVisibility() {
document.querySelectorAll('.postbox').forEach(function(postBox) {
if (postBox.id.startsWith('pods')) {
// body-en is controlled by language tabs — never auto-hide it
if ('#' + postBox.id === CONFIG.boxes.bodyEn) return;
var fields = postBox.querySelectorAll('tr');
var hasVisibleFields = Array.from(fields).some(function(field) {
return field.style.display !== 'none';
});
postBox.style.display = hasVisibleFields ? 'block' : 'none';
}
});
}
// Force Visual (TinyMCE) mode on page load.
// WP stores the last-used editor mode in localStorage and restores it at document.ready.
// When Code mode is restored, TinyMCE is never initialised — tinymce.get() returns null.
// Instead, check the wrapper's CSS class:
// tmce-active = Visual mode (fine)
// html-active = Code mode (switch to Visual)
function ensureVisualMode(editorId, attempt) {
attempt = attempt || 0;
if (attempt > 15) return;
var wrap = document.getElementById('wp-' + editorId + '-wrap');
if (!wrap) {
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
return;
}
if (wrap.classList.contains('html-active')) {
var ed = window.tinymce && tinymce.get(editorId);
if (!ed || !ed.initialized) {
// TinyMCE not ready yet — retry rather than calling switchEditors.go() prematurely
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
return;
}
if (typeof switchEditors !== 'undefined') {
switchEditors.go(editorId, 'tmce');
}
return;
}
if (!wrap.classList.contains('tmce-active')) {
// Mode not yet determined — retry
setTimeout(function() { ensureVisualMode(editorId, attempt + 1); }, 200);
}
}
// Rebuild a TinyMCE editor whose iframe is broken (empty/non-interactive).
// This happens when TinyMCE is initialised on a hidden (display:none) element:
// the iframe can't measure dimensions and its document body stays empty.
//
// We reinit from tinyMCEPreInit.mceInit — first trying the editor's own config
// (registered by Pods server-side), falling back to 'content' (the native WP editor).
//
// Inline toolbar positioning fix:
// TinyMCE's 'wordpress' plugin captures document.getElementById(id+'_ifr') during
// 'preinit' — before the iframe is created — so mceIframe is always null.
// Fix: intercept getElementById during preinit so the 'wordpress' plugin captures
// a proxyIframe instead of null. After init, proxy delegates to the real iframe.
function reinitEditor(editorId) {
var ed = window.tinymce && tinymce.get(editorId);
// Preserve existing content before destroying the instance
var savedContent = '';
if (ed) {
try { savedContent = ed.getContent(); } catch (e) {}
ed.remove();
}
if (!savedContent) {
var ta = document.getElementById(editorId);
if (ta) savedContent = ta.value || '';
}
if (!window.tinyMCEPreInit || !window.tinymce) return;
// Use the editor's own server-side config if available, else clone from 'content'
var baseInit = (tinyMCEPreInit.mceInit && tinyMCEPreInit.mceInit[editorId])
|| (tinyMCEPreInit.mceInit && tinyMCEPreInit.mceInit['content']);
if (!baseInit) return;
// Proxy iframe: getBoundingClientRect() falls back to the editor wrap
var wrapId = 'wp-' + editorId + '-wrap';
var proxyIframe = {
getBoundingClientRect: function() {
var el = document.getElementById(wrapId);
return el ? el.getBoundingClientRect()
: { top: 0, left: 0, right: window.innerWidth,
bottom: window.innerHeight, width: window.innerWidth,
height: window.innerHeight };
}
};
var savedGetById = document.getElementById;
var origSetup = baseInit.setup;
var content = savedContent;
tinymce.init($.extend({}, baseInit, {
selector: '#' + editorId,
setup: function(editor) {
if (typeof origSetup === 'function') origSetup(editor);
editor.on('focus', function() {
window.wpActiveEditor = editorId;
});
editor.on('preinit', function() {
document.getElementById = function(id) {
if (id === editorId + '_ifr') return proxyIframe;
return savedGetById.call(document, id);
};
setTimeout(function() {
document.getElementById = savedGetById;
}, 0);
});
editor.on('init', function() {
// Point proxy to real iframe
var realIframe = savedGetById.call(document, editorId + '_ifr');
if (realIframe) {
proxyIframe.getBoundingClientRect = function() {
return realIframe.getBoundingClientRect();
};
}
// Restore content that was in the textarea
if (content) {
editor.setContent(content);
}
});
}
}));
}
// ── Info-popovers (post / user / taxonomy) ─────────────────
var INFO_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 11v6"/><path d="M12 8v.01" stroke-width="2"/></svg>';
var TRANSLATE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
var TRANSLATE_LINES = [
'Traduction en anglais après //',
'ex : Texte en français // English text'
];
// Tips without a `page` key default to 'post'.
// type: 'translate' uses the globe icon + green button style.
var INFO_TIPS = [
// --- post edit page: info ---
{
selector: '.wp-heading-inline',
lines: [
'Saisir le titre anglais après //',
'ex : Titre de lannonce // Title of the announcement'
]
},
{
selector: '#pods-meta-documents-joints .postbox-header h2',
lines: [
'Ajouter les images dans les documents.',
'Ajouter les légendes comme titre du document.'
]
},
{
selector: '#pods-meta-membres .postbox-header h2',
lines: [
'Le champ fonction change le libellé de la liste de personnes citées.',
'Le champ membre permet de lister les membres de Thalim liés à lannonce.',
'Le champ autre personnes permet de lister des personnes extérieures à Thalim.'
]
},
{
selector: '#pods-meta-dates .postbox-header h2',
lines: [
'Pour entrer une date sans lheure, régler lheure sur 00:00.'
]
},
{
selector: '#pods-meta-affichage-sur-laccueil .postbox-header h2',
lines: [
'Épingler lannonce dans le diaporama la fait safficher avant les autres.'
]
},
{
selector: '#pods-meta-medias .postbox-header h2',
lines: [
'Pour ajouter un média Canal U, copier le lien depuis « Citer cette ressource ».',
'ex : https://www.canal-u.tv/166564'
]
},
// --- post edit page: translate ---
{ type: 'translate', selector: '#pods-meta-documents-joints .postbox-header h2', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-sous-titre th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-lieu th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-titre-du-lien-externe-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-organisation th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-intervention th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-candidat th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-realisation th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-dirige th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-redaction th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-auteur th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-fonction-responsable th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-autre th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-concerne th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-directeur th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-direction-d-ouvrage th',lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-intervenant th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-autre-fonction-participants th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-type-autre th', lines: TRANSLATE_LINES },
// --- contenu_general edit page: translate ---
{ type: 'translate', selector: '.pods-form-ui-row-name-umr th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-thalim th', lines: TRANSLATE_LINES },
{ type: 'translate', selector: '.pods-form-ui-row-name-siecles th', lines: TRANSLATE_LINES },
// --- user/profile edit page: translate ---
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-du-lien-4 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-complement-de-role-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-1 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-2 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affichage-du-statut-3 th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-affiliation-autre th', lines: TRANSLATE_LINES },
{ type: 'translate', page: 'user', selector: '.pods-form-ui-row-name-titre-de-these th', lines: TRANSLATE_LINES },
// --- taxonomy edit pages: translate ---
{ type: 'translate', page: 'taxonomy', selector: 'label[for="name"]', lines: TRANSLATE_LINES },
// --- user/profile edit page: info ---
{
page: 'user',
selector: '.pods-form-ui-label-pods-meta-identifiant-hal',
lines: [
'Renseigner votre idHAL (en lettres), pas votre PersonId (en chiffres).'
]
},
{
page: 'user',
selector: '.pods-form-ui-label-pods-meta-affichage-du-statut-1',
lines: [
'Texte de statut affiché sur le profil publique.'
]
}
];
var _popoverCloseHandlerRegistered = false;
function initInfoPopovers(currentPage) {
currentPage = currentPage || 'post';
INFO_TIPS.forEach(function(tip) {
if ((tip.page || 'post') !== currentPage) return;
var el = document.querySelector(tip.selector);
if (!el) return;
var isTranslate = tip.type === 'translate';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = isTranslate ? 'thalim-translate-btn' : 'thalim-info-btn';
btn.setAttribute('aria-label', isTranslate ? 'Traduction bilingue' : 'Informations');
btn.innerHTML = isTranslate ? TRANSLATE_ICON : INFO_ICON;
var popover = document.createElement('div');
popover.className = 'thalim-info-popover' + (isTranslate ? ' thalim-translate-popover' : '');
popover.innerHTML = tip.lines.map(function(line) {
return '<p>' + line + '</p>';
}).join('');
var wrapper = document.createElement('span');
wrapper.className = 'thalim-info-wrapper';
wrapper.appendChild(btn);
wrapper.appendChild(popover);
el.appendChild(wrapper);
btn.addEventListener('click', function(e) {
e.stopPropagation();
var isOpen = popover.classList.contains('is-open');
document.querySelectorAll('.thalim-info-popover.is-open').forEach(function(p) {
p.classList.remove('is-open');
});
if (!isOpen) {
var rect = btn.getBoundingClientRect();
popover.style.top = (rect.bottom + 6) + 'px';
popover.style.left = (rect.left + rect.width / 2) + 'px';
popover.classList.add('is-open');
}
});
popover.addEventListener('click', function(e) {
e.stopPropagation();
});
});
if (!_popoverCloseHandlerRegistered) {
_popoverCloseHandlerRegistered = true;
document.addEventListener('click', function() {
document.querySelectorAll('.thalim-info-popover.is-open').forEach(function(p) {
p.classList.remove('is-open');
});
});
}
}
// ── Reveal (#wpbody est masqué en CSS sur post/profil jusqu'à l'init) ──
function markReady() {
document.body.classList.add('admin-mods-ready');
}
// Fallback global : force le reveal après 2 s même si le script de
// contexte a planté ou n'a pas été chargé.
$(document).ready(function() {
setTimeout(markReady, 2000);
});
window.ThalimAdmin = {
CONFIG: CONFIG,
safeRun: safeRun,
isPostEditPage: isPostEditPage,
isProfileEditPage: isProfileEditPage,
getProfileForm: getProfileForm,
isPodsModal: isPodsModal,
ensureVisualMode: ensureVisualMode,
reinitEditor: reinitEditor,
updatePostboxVisibility: updatePostboxVisibility,
initInfoPopovers: initInfoPopovers,
markReady: markReady
};
})(jQuery);