put_contents($tmp, $content, $stat ? ($stat['mode'] & 0777) : FS_CHMOD_FILE); clearstatcache(true, $tmp); // Meme proprietaire et meme groupe, sinon le remplacement les changerait. if ($ok && (int) $fs->size($tmp) === strlen($content) && (!$stat || (fileowner($tmp) === $stat['uid'] && filegroup($tmp) === $stat['gid'])) // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Remplacement atomique voulu : $fs->move() supprime la cible avant de renommer. && rename($tmp, $path)) { return true; } if (file_exists($tmp)) { $fs->delete($tmp); } } if (!$exists) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Test de droits, aucune ecriture. self::$write_error = is_writable($dir) ? 'space' : 'readonly'; return false; } return self::write_in_place($path, $content); } /** * Ecriture sur place d'un fichier existant (il garde son * inode : point de montage, proprietaire, groupe et droits inchanges). * * Le nouveau contenu est d'abord ajoute a la suite de l'ancien : si la * place manque, pas un octet de l'ancien contenu n'a ete touche et le * fichier est ramene a sa taille. Ensuite seulement, il est ecrit en tete * (place deja reservee) et le fichier est coupe a sa longueur. Le * resultat est relu ; s'il differe, l'ancien contenu est remis. * * WP_Filesystem n'a pas d'ecriture qui garde l'ancien contenu tant que le * nouveau n'est pas sur le disque (put_contents() vide d'abord le * fichier) : d'ou les fonctions de fichier de PHP. * * @param string $path Fichier existant, inscriptible. * @param string $content Contenu complet. * @return bool */ private static function write_in_place($path, $content) { $fs = self::init_filesystem(); $old = $fs ? $fs->get_contents($path) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Voir le docblock : ecriture sans troncature prealable. $handle = is_string($old) ? fopen($path, 'c+b') : false; if (false === $handle) { self::$write_error = 'readonly'; return false; } $length = strlen($content); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Voir le docblock. $reserved = (0 === fseek($handle, 0, SEEK_END)) && $length === fwrite($handle, $content); if (!$reserved) { ftruncate($handle, strlen($old)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Voir le docblock. fclose($handle); self::$write_error = 'space'; return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Voir le docblock. $written = rewind($handle) && $length === fwrite($handle, $content) && ftruncate($handle, $length); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Voir le docblock. fclose($handle); clearstatcache(true, $path); if ($written && md5_file($path) === md5($content)) { return true; } // Ecart constate apres coup (systeme de fichiers qui alloue aussi a la // reecriture) : l'ancien contenu est remis, puis verifie. self::$write_error = 'space'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Voir le docblock. $handle = fopen($path, 'c+b'); if (false !== $handle) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Voir le docblock. fwrite($handle, $old); ftruncate($handle, strlen($old)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Voir le docblock. fclose($handle); } clearstatcache(true, $path); if (md5_file($path) !== md5($old)) { self::debug_log('A file could not be put back after a failed write: ' . $path); } return false; } /** * Assez de place pour ecrire $bytes octets dans $dir, avec une * marge (blocs du systeme de fichiers). Inconnu (fonction desactivee, * erreur) : on tente, l'ecriture est verifiee. * * @param string $dir Dossier. * @param int $bytes Taille a ecrire. * @return bool */ private static function has_room($dir, $bytes) { if (!function_exists('disk_free_space')) { return true; } $free = disk_free_space($dir); return (false === $free) || $free >= $bytes + 32768; } /** * Fichiers temporaires laisses par une ecriture interrompue * (PHP arrete entre l'ecriture et le renommage) : supprimes apres 10 * minutes. Une ecriture en cours ne dure qu'une fraction de seconde. * * @param string $dir Dossier. */ private static function delete_stale_temp_files($dir) { $dir = rtrim($dir, '/\\'); $files = []; // Nom actuel, et ancien nom (fichier.mgenius-*.tmp). foreach (['/.htmgenius-*.tmp', '/*.mgenius-*.tmp', '/.*.mgenius-*.tmp'] as $pattern) { $found = glob($dir . $pattern); if (is_array($found)) { $files = array_merge($files, $found); } } foreach (array_unique($files) as $file) { $mtime = filemtime($file); if (false !== $mtime && $mtime < time() - 600) { wp_delete_file($file); } } } /** * Verrou de transition atomique (meme principe que * WP_Upgrader::create_lock() : INSERT IGNORE ; add_option() ne l'est pas). * Une seule entree ou sortie de maintenance a la fois ; reentrant dans la * requete ; libere aussi si PHP s'arrete (erreur fatale, delai depasse). * * @param string $name Nom du verrou. * @param int $wait Secondes d'attente (0 : on renonce tout de suite). * @return bool */ private static function acquire_lock($name, $wait = 0) { if (!empty(self::$locks[$name])) { self::$locks[$name]++; return true; } // Horloge monotone : un changement de l'heure du serveur ne raccourcit // ni n'allonge l'attente. $until = hrtime(true) + (int) $wait * 1000000000; do { if (self::try_lock($name)) { self::$locks[$name] = 1; register_shutdown_function([__CLASS__, 'release_lock_on_shutdown'], $name); return true; } if ($wait > 0) { usleep(200000); } } while (hrtime(true) < $until); return false; } /** * La ligne du verrou porte « depuis|jeton » : le jeton est * propre a la requete qui le tient, et seule cette requete le libere. * * @param string $name Nom du verrou. * @return bool */ private static function try_lock($name) { global $wpdb; $key = 'mgenius_lock_' . $name; $value = time() . '|' . wp_generate_password(20, false); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verrou : INSERT IGNORE est atomique. $got = $wpdb->query($wpdb->prepare("INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $key, $value)); if (!$got) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lecture du verrou existant. $held = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $key)); if (null === $held || (time() - (int) $held) < self::LOCK_TTL) { return false; } // Verrou abandonne (processus tue) : une seule requete le reprend, // en remplacant exactement la valeur lue (heure et jeton). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reprise atomique du verrou perime. $got = $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $value, $key, (string) $held)); } wp_cache_delete($key, 'options'); if (!$got) { return false; } self::$lock_values[$name] = $value; return true; } /** * @param string $name Nom du verrou. */ private static function release_lock($name) { // Jamais le verrou d'une autre requete. if (empty(self::$locks[$name])) { return; } if (--self::$locks[$name] > 0) { return; } unset(self::$locks[$name]); $value = isset(self::$lock_values[$name]) ? self::$lock_values[$name] : ''; unset(self::$lock_values[$name]); global $wpdb; // La ligne n'est supprimee que si elle porte encore notre jeton : un // verrou repris par une autre requete (le notre avait depasse // LOCK_TTL) reste le sien. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Liberation du verrou par son proprietaire. $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", 'mgenius_lock_' . $name, $value)); wp_cache_delete('mgenius_lock_' . $name, 'options'); } /** * Rappel d'arret de PHP : libere un verrou encore tenu. * * @param string $name Nom du verrou. */ public static function release_lock_on_shutdown($name) { if (!empty(self::$locks[$name])) { self::$locks[$name] = 1; self::release_lock($name); } } /** * Transition demandee par l'administrateur : attend son tour. * * @return bool */ public static function begin_transition() { return self::acquire_lock('transition', 20); } public static function end_transition() { self::release_lock('transition'); } /** * Vrai si la requete courante tient le verrou de transition. * MGENIUS_Settings::save() s'en sert : hors transition, une ecriture des * reglages ne change jamais l'etat de la maintenance. * * @return bool */ public static function in_transition() { return !empty(self::$locks['transition']); } /** * Le .htaccess lu par Apache pour l'adresse du site. * * WordPress installe dans son propre dossier (siteurl .../wp, home .../) : * les regles sont dans le .htaccess de la racine du site, pas dans * ABSPATH. get_home_path() depend de SCRIPT_FILENAME et renvoie « / » * sur une requete publique : le chemin est donc deduit d'ABSPATH. * * Publique : uninstall.php s'en sert aussi. * * @return string */ public static function htaccess_file() { $home = rtrim((string) preg_replace('#^https?://#i', '', (string) get_option('home')), '/'); $site = rtrim((string) preg_replace('#^https?://#i', '', (string) get_option('siteurl')), '/'); $dir = rtrim(str_replace('\\', '/', ABSPATH), '/'); if ('' !== $home && 0 !== strcasecmp($home, $site) && 0 === stripos($site, $home . '/')) { $rel = '/' . trim(substr($site, strlen($home)), '/'); $len = strlen($rel); if ($len > 1 && strlen($dir) > $len && 0 === strcasecmp(substr($dir, -$len), $rel)) { $dir = substr($dir, 0, -$len); } } return $dir . '/.htaccess'; } /** * Problemes d'ecriture signales dans l'administration : code, * raison (voir write_file()), fichier concerne, et si l'administrateur a * masque l'avis. Un avis masque ne revient que si la raison ou le fichier * change ; il disparait de lui-meme quand le probleme est regle. * * @return array */ private static function cache_warnings() { $warnings = get_option(self::WARNINGS_OPTION, []); if (!is_array($warnings)) { return []; } $clean = []; foreach ($warnings as $code => $warning) { $warning = is_array($warning) ? $warning : []; $clean[(string) $code] = [ 'time' => isset($warning['time']) ? (int) $warning['time'] : 0, 'reason' => isset($warning['reason']) ? (string) $warning['reason'] : '', 'file' => isset($warning['file']) ? (string) $warning['file'] : '', 'dismissed' => !empty($warning['dismissed']), ]; } return $clean; } /** * @param string $code htaccess_strip, htaccess_restore, filesystem. * @param string $reason direct, readonly, space, missing, empty. * @param string $file Fichier concerne. */ private static function set_cache_warning($code, $reason = '', $file = '') { $warnings = self::cache_warnings(); if (isset($warnings[$code]) && $warnings[$code]['reason'] === (string) $reason && $warnings[$code]['file'] === (string) $file) { return; } $warnings[$code] = ['time' => time(), 'reason' => (string) $reason, 'file' => (string) $file, 'dismissed' => false]; update_option(self::WARNINGS_OPTION, $warnings, false); } private static function clear_cache_warning($code) { $warnings = self::cache_warnings(); if (!isset($warnings[$code])) { return; } unset($warnings[$code]); if (empty($warnings)) { delete_option(self::WARNINGS_OPTION); } else { update_option(self::WARNINGS_OPTION, $warnings, false); } } /** * Avertissements qui ne valent que pendant la maintenance * (Cache Guard, retrait des regles) : effaces a la sortie. */ private static function clear_maintenance_warnings() { self::clear_cache_warning('htaccess_strip'); } /** * Masque un avis (bouton de fermeture de l'avis). */ public static function ajax_dismiss_cache_warning() { check_ajax_referer('mgenius_dismiss_warning', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(); } $code = isset($_POST['code']) ? sanitize_key(wp_unslash($_POST['code'])) : ''; $warnings = self::cache_warnings(); if (isset($warnings[$code])) { $warnings[$code]['dismissed'] = true; update_option(self::WARNINGS_OPTION, $warnings, false); } wp_send_json_success(); } /** * Textes des avertissements en cours, pertinents maintenant. * * @param bool $include_dismissed Inclure les avis masques (reponse d'une * bascule : elle rend compte de son propre * resultat). * @return array Code => texte. */ public static function cache_warning_messages($include_dismissed = true) { $warnings = self::cache_warnings(); if (empty($warnings)) { return []; } // Pertinents maintenant seulement : le Cache Guard et le retrait des // regles ne comptent que pendant une maintenance ; la remise des // regles, tant qu'il en reste a remettre. $settings = MGENIUS_Settings::load(); $active = !empty($settings['maintenance_enabled']); $pending = null !== get_option(self::HTACCESS_OPTION, null) || null !== get_option(self::HTACCESS_ORIGINAL_OPTION, null); $reasons = [ 'direct' => __('WordPress does not write files directly on this server: it uses FTP or SSH.', '1001web-maintenance-launch-pages'), 'readonly' => __('The file or its folder is not writable by WordPress.', '1001web-maintenance-launch-pages'), 'space' => __('The file could not be written completely: the disk may be full.', '1001web-maintenance-launch-pages'), 'missing' => __('The file does not exist any more.', '1001web-maintenance-launch-pages'), 'empty' => __('The file was empty when it was read, so it was left as it is.', '1001web-maintenance-launch-pages'), ]; $messages = []; foreach ($warnings as $code => $warning) { if (!$include_dismissed && $warning['dismissed']) { continue; } $reason = isset($reasons[$warning['reason']]) ? $reasons[$warning['reason']] : ''; if ('htaccess_strip' === $code && $active) { $file = '' !== $warning['file'] ? $warning['file'] : self::htaccess_file(); /* translators: 1: file path, 2: reason the file could not be written */ $messages[$code] = sprintf(__('The cache rules could not be removed from %1$s. %2$s While maintenance is on, your server may still serve cached pages to visitors. The plugin tries again at most once a minute, when visitors open the site.', '1001web-maintenance-launch-pages'), $file, $reason); } elseif ('htaccess_restore' === $code && $pending) { $file = '' !== $warning['file'] ? $warning['file'] : self::htaccess_file(); /* translators: 1: file path, 2: reason the file could not be written */ $messages[$code] = sprintf(__('The cache rules removed during maintenance could not be put back into %1$s. %2$s They are kept: once this is fixed, reload this page and the plugin puts them back.', '1001web-maintenance-launch-pages'), $file, $reason); } elseif ('filesystem' === $code) { $messages[$code] = __('Scheduled maintenance did not start: WordPress does not write files directly on this server (it uses FTP or SSH). The plugin needs direct file access to suspend cache plugins safely. Your host can enable it, or you can define FS_METHOD as \'direct\' in wp-config.php if PHP is allowed to write to your WordPress files.', '1001web-maintenance-launch-pages'); } } return $messages; } /** * Ecrans ou les avertissements sont affiches : tableau de * bord, extensions et page du plugin. * * @return bool */ private static function is_warning_screen() { if (!current_user_can('manage_options') || !function_exists('get_current_screen')) { return false; } $screen = get_current_screen(); return $screen && in_array($screen->id, ['dashboard', 'plugins', 'toplevel_page_mgenius-settings'], true); } /** * Avertissements visibles, masquables, seulement sur les ecrans * de is_warning_screen() et tant qu'ils sont pertinents. */ public static function cache_warning_notices() { if (!self::is_warning_screen()) { return; } foreach (self::cache_warning_messages(false) as $code => $message) { printf( '

1001Web Maintenance & Launch Pages — %2$s

', esc_attr($code), esc_html($message) ); } } /** * Fermer un avis le masque pour de bon (requete AJAX). */ public static function cache_warning_script() { if (!self::is_warning_screen() || empty(self::cache_warning_messages(false))) { return; } wp_add_inline_script( 'common', 'jQuery(function($){$(document).on("click",".mgenius-cache-warning .notice-dismiss",function(){' . 'var code=$(this).closest(".mgenius-cache-warning").data("mgenius-warning");' . '$.post(window.ajaxurl,{action:"mgenius_dismiss_cache_warning",code:code,nonce:' . wp_json_encode(wp_create_nonce('mgenius_dismiss_warning')) . '});' . '});});' ); } /** * Maintenance coupee, mais une entree precedente a laisse des * traces (sortie interrompue, .htaccess non inscriptible a la sortie). * * @return bool */ private static function offline_state_pending() { foreach (['mgenius_cache_saved_states', self::HTACCESS_OPTION, self::HTACCESS_ORIGINAL_OPTION] as $option) { wp_cache_delete($option, 'options'); if (null !== get_option($option, null)) { return true; } } return false; } /** * Remet ce que la maintenance avait change (reglages des * plugins de cache, regles .htaccess, Cache Guard) quand elle est deja * coupee. Aucune purge : un site sain garde son cache. A appeler sous le * verrou de transition, maintenance coupee. * * @return bool True s'il y avait quelque chose a remettre. */ public static function repair_offline_state() { if (!self::offline_state_pending()) { return false; } self::restore_cache_plugins(); self::restore_htaccess_blocks(); self::clear_maintenance_warnings(); return true; } /** * Chaque page d'administration ouverte par un administrateur * refait cette remise si elle reste a faire (fichier rendu inscriptible * depuis) : c'est ce que dit l'avis « reload this page ». */ public static function maybe_repair_offline_state() { if (wp_doing_ajax() || !current_user_can('manage_options')) { return; } // Acces direct aux fichiers retabli : l'avis de la planification // refusee n'a plus lieu d'etre. $warnings = self::cache_warnings(); if (isset($warnings['filesystem']) && self::filesystem_ready()) { self::clear_cache_warning('filesystem'); } $settings = MGENIUS_Settings::load(); if (!empty($settings['maintenance_enabled']) || !self::offline_state_pending()) { return; } if (!self::acquire_lock('transition')) { return; } $settings = MGENIUS_Settings::load_fresh(); if (empty($settings['maintenance_enabled'])) { self::repair_offline_state(); } self::release_lock('transition'); } /** * Initialise le plugin */ public static function init() { self::$settings = MGENIUS_Settings::load(); self::maybe_upgrade_db(); // Comparaison avant/apres en tache de fond (fin de compte a // rebours ou de plage planifiee). add_action('mgenius_post_exit_check', ['MGENIUS_Healthcheck', 'run_scheduled_verify']); // La planification bascule la maintenance par le parcours // normal (caches, Cache Guard, releve, abonnes, comparaison). self::maybe_apply_schedule(); add_action('template_redirect', [__CLASS__, 'check_maintenance'], 1); // v2.9.174 — the REST content API no longer shows pages and posts to // anonymous visitors while the site is in maintenance. add_filter('rest_pre_dispatch', [__CLASS__, 'maybe_block_rest_content'], 10, 3); add_action('upgrader_process_complete', [__CLASS__, 'on_upgrade'], 10, 2); // Problemes d'ecriture visibles dans l'administration // (masquables), et remise de ce qu'une sortie n'a pas pu remettre. add_action('admin_notices', [__CLASS__, 'cache_warning_notices']); add_action('admin_enqueue_scripts', [__CLASS__, 'cache_warning_script']); add_action('admin_init', [__CLASS__, 'maybe_repair_offline_state']); add_action('wp_ajax_mgenius_dismiss_cache_warning', [__CLASS__, 'ajax_dismiss_cache_warning']); if (!empty(self::$settings['maintenance_enabled'])) { // WP-CLI ne purge rien et ne reecrit rien, comme le // Cache Guard (« wp core update » pendant une maintenance). if (defined('WP_CLI') && WP_CLI) { return; } // Verrou horaire pose AVANT la purge (une erreur ne la // relance plus a chaque requete) ; etat relu en base sous le // verrou de transition : une requete lancee avant la sortie de // maintenance ne recoupe plus les caches apres la sortie. if (!get_transient('mgenius_init_purged') && self::acquire_lock('transition')) { set_transient('mgenius_init_purged', 1, 3600); $fresh = MGENIUS_Settings::load_fresh(); if (!empty($fresh['maintenance_enabled'])) { self::disable_cache_plugins(); self::delete_directory_contents(WP_CONTENT_DIR . '/cache/all/'); self::delete_directory_contents(WP_CONTENT_DIR . '/cache/wpfc-minified/'); self::delete_directory_contents(WP_CONTENT_DIR . '/cache/seraphinite-accelerator/'); self::purge_w3tc_page_cache(); self::strip_cache_htaccess_rules(); } self::release_lock('transition'); } } } /** * Hook d'activation */ public static function on_activation() { // Data of a version up to 2.9.161 (short « mg_ » prefix) is migrated // first: creating default settings before it hid the user's settings // and lost access links and subscribers. self::maybe_upgrade_db(); if (!get_option('mgenius_settings')) { $defaults = MGENIUS_Settings::get_defaults(); MGENIUS_Settings::save($defaults); // Token d'accès visiteur : option dédiée. MGENIUS_Settings::get_visitor_token(); } MGENIUS_Settings::migrate_stats_from_settings(); MGENIUS_Settings::migrate_visitor_duration_to_seconds(); MGENIUS_Settings::migrate_custom_bg_to_bg_image(); MGENIUS_Settings::migrate_notification_emails_from_settings(); MGENIUS_Settings::ensure_settings_autoload_off(); update_option('mgenius_db_version', MGENIUS_VERSION, false); // Extension coupee sans son crochet de desactivation // (dossier renomme ou supprime) : la maintenance ne reprend pas d'elle- // meme a la reactivation, et les caches retrouvent leur etat. Verrou // occupe (une autre transition en cours) : on la laisse faire. if (!self::acquire_lock('transition', 20)) { return; } $settings = MGENIUS_Settings::load_fresh(); if (is_array($settings) && !empty($settings['maintenance_enabled'])) { $settings['maintenance_enabled'] = false; $settings['schedule_auto_on'] = false; MGENIUS_Settings::save($settings); } self::restore_cache_plugins(); self::restore_htaccess_blocks(); self::clear_maintenance_warnings(); self::release_lock('transition'); } /** * Migration DB one-shot, déclenchée quand mg_db_version != MGENIUS_VERSION. * Idempotente : migrate_stats_from_settings() et ensure_settings_autoload_off() * ne font rien si déjà appliquées. */ public static function maybe_upgrade_db() { if (get_option('mgenius_db_version') === MGENIUS_VERSION) { return; } self::migrate_legacy_prefix(); MGENIUS_Settings::migrate_stats_from_settings(); MGENIUS_Settings::migrate_visitor_duration_to_seconds(); MGENIUS_Settings::migrate_custom_bg_to_bg_image(); MGENIUS_Settings::migrate_notification_emails_from_settings(); MGENIUS_Settings::ensure_settings_autoload_off(); update_option('mgenius_db_version', MGENIUS_VERSION, false); } /** Option recording that the « mg_ » migration has run (« migrated » or « none »). */ const LEGACY_FLAG = 'mgenius_legacy_prefix'; /** * Options of versions up to 2.9.161 (short « mg_ » prefix) and their new * names. Flags are dropped once their new copy exists; data is never * deleted while its new copy holds something else. * * @return array old => [new, is_flag] */ private static function legacy_option_map() { return array( 'mg_settings' => array('mgenius_settings', false), 'mg_access_logs' => array('mgenius_access_logs', false), 'mg_access_tokens' => array('mgenius_access_tokens', false), 'mg_active_theme' => array('mgenius_active_theme', true), 'mg_blacklist_logs' => array('mgenius_blacklist_logs', false), 'mg_cache_saved_states' => array('mgenius_cache_saved_states', false), 'mg_custom_bg_migrated' => array('mgenius_custom_bg_migrated', true), 'mg_custom_presets' => array('mgenius_custom_presets', false), 'mg_diagnostic_results' => array('mgenius_diagnostic_results', true), 'mg_health_baseline' => array('mgenius_health_baseline', false), 'mg_health_last_result' => array('mgenius_health_last_result', false), 'mg_notification_emails' => array('mgenius_notification_emails', false), 'mg_notifications' => array('mgenius_notifications', false), 'mg_salt' => array('mgenius_salt', true), 'mg_stats' => array('mgenius_stats', false), 'mg_stats_visits' => array('mgenius_stats_visits', false), 'mg_support_sent' => array('mgenius_support_sent', true), 'mg_theme_snapshot' => array('mgenius_theme_snapshot', false), 'mg_visit_stats' => array('mgenius_visit_stats', false), 'mg_wizard_completed' => array('mgenius_wizard_completed', true), 'mg_diag_banner_dismissed_email' => array('mgenius_diag_banner_dismissed_email', true), ); } /** * Options of modules removed from earlier versions and technical values * with no use any more. Some hold secrets (API key, access token). * * @return string[] */ public static function legacy_orphan_options() { return array( 'mg_anthropic_key', 'mg_license_key', 'mg_license_valid', 'mg_remote_config', 'mg_remote_logs', 'mg_monitoring_events', 'mg_monitoring_status', 'mg_monitoring_last_notification', 'mg_last_release_sync', 'mg_download_count', 'mg_dashboard_widget', 'mg_diag_probe', 'mg_cache_check', 'mg_visitor_token', 'mg_db_version', 'mg_guard_purged', 'mg_init_purged', ); } /** * True when the « mg_ » options of this site were written by Maintenance * GENIUS: either the migration recognized them, or mg_settings is a * settings array of this plugin (maintenance_enabled and, for most of its * keys, names of this plugin's settings). The options of another * extension using the same short names are never touched. * * Public: uninstall.php applies the same rule. * * @return bool */ public static function is_legacy_data_ours() { if ('migrated' === get_option(self::LEGACY_FLAG, '')) { return true; } $old = get_option('mg_settings', null); if (!is_array($old) || !array_key_exists('maintenance_enabled', $old)) { return false; } $known = array_merge( array_keys(MGENIUS_Settings::get_defaults()), array('visitor_token', 'stats_visits', 'stats_unique_visitors', 'stats_visitors_ips', 'hide_powered_by', 'blacklist_referrers', 'blacklist_countries') ); $hits = count(array_intersect(array_keys($old), $known)); return $hits >= 5 && ($hits * 2) >= count($old); } /** * Renommage du préfixe d'options court « mg_ » vers « mgenius_ ». * * Exigé par WordPress.org : un préfixe doit faire plus de quatre caractères. * Les réglages d'une installation existante doivent survivre à la mise à jour, * sans quoi l'utilisateur retrouve un plugin vierge. * * Runs once per site (LEGACY_FLAG), and only on data recognized as this * plugin's (is_legacy_data_ours()). An old option is deleted only after * its value reached the new name; when the new option already holds other * data, the old one is kept (nothing is lost), except default settings * created by an activation that ran before this migration. */ private static function migrate_legacy_prefix() { if (false !== get_option(self::LEGACY_FLAG, false)) { return; } $ours = self::is_legacy_data_ours(); if ($ours) { foreach (self::legacy_option_map() as $old => $target) { list($new, $is_flag) = $target; $value = get_option($old, null); if (null === $value || !self::legacy_value_is_ours($old, $value)) { continue; } $current = get_option($new, null); $pristine = ('mgenius_settings' === $new && self::settings_are_default($current)); if (null === $current || $pristine) { $autoload = ('mgenius_settings' === $new) ? false : null; if (null !== $current || false === add_option($new, $value, '', $autoload)) { update_option($new, $value, $autoload); } // Read back from the database (not from the object cache) // before the old copy is deleted. wp_cache_delete($new, 'options'); wp_cache_delete('alloptions', 'options'); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- A number written as an option is read back as a string. if (get_option($new, null) == $value) { delete_option($old); } } elseif ($is_flag) { delete_option($old); } } foreach (self::legacy_orphan_options() as $orphan) { delete_option($orphan); } } add_option(self::LEGACY_FLAG, $ours ? 'migrated' : 'none', '', false); // Le cache objet (Redis, Memcached) peut encore servir les anciennes clés. foreach (self::legacy_option_map() as $old => $target) { wp_cache_delete($old, 'options'); wp_cache_delete($target[0], 'options'); } wp_cache_delete('alloptions', 'options'); } /** * True when the settings are still the defaults written at activation * (never configured): an old installation's settings may replace them. * * @param mixed $settings Value of mgenius_settings. * @return bool */ private static function settings_are_default($settings) { if (!is_array($settings)) { return false; } foreach (MGENIUS_Settings::get_defaults() as $key => $default) { // Default changed (empty for new installations): the former one is not a user choice. if ('whitelist_user_agents' === $key) { continue; } // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- Values written by an older version may differ in type only (« 1 » and 1). if (array_key_exists($key, $settings) && $settings[$key] != $default) { return false; } } return true; } /** * Shape of the old options that grant access or feed a list: another * value under the same name is left where it is. * * @param string $name Old option name. * @param mixed $value Its value. * @return bool */ private static function legacy_value_is_ours($name, $value) { if ('mg_settings' === $name) { return is_array($value); } if ('mg_access_tokens' === $name || 'mg_notification_emails' === $name) { if (!is_array($value)) { return false; } $field = ('mg_access_tokens' === $name) ? 'name' : 'email'; foreach ($value as $item) { if (!is_array($item) || !array_key_exists($field, $item)) { return false; } } return true; } if ('mg_stats' === $name) { return is_array($value) && (array_key_exists('unique', $value) || array_key_exists('visitor_ips', $value)); } if ('mg_stats_visits' === $name) { return is_numeric($value); } return true; } /** * Hook de mise à jour du plugin */ public static function on_upgrade($upgrader, $options) { if ( isset($options['action'], $options['type'], $options['plugins']) && $options['action'] === 'update' && $options['type'] === 'plugin' && is_array($options['plugins']) ) { // Comparaison au chemin reel du plugin plutot qu'a une // chaine ecrite en dur. // // Une mise a jour pendant une maintenance peut remettre des reglages // de cache : ils sont coupes de nouveau. // // plugin_basename() suit automatiquement le nom du dossier, y compris // si WordPress.org le renomme au deploiement. $mgenius_self = plugin_basename(MGENIUS_PLUGIN_FILE); foreach ($options['plugins'] as $plugin) { if ($plugin === $mgenius_self) { // Sous le verrou de transition, etat relu en base. if (self::acquire_lock('transition', 20)) { $settings = MGENIUS_Settings::load_fresh(); if (!empty($settings['maintenance_enabled'])) { self::disable_cache_plugins(); } self::release_lock('transition'); } break; } } } } /** * Vérifie si on doit afficher la page de maintenance */ // CHECK MAINTENANCE — Point d'entrée principal (hook: template_redirect) // Ordre: planification → admin bypass → preview → token → cookie → // rôle → IP whitelist → URL whitelist → afficher public static function check_maintenance() { // Ceinture : template_redirect ne se déclenche pas en CLI, mais un // appel programmatique resterait possible. Aucune page de maintenance // ne doit jamais être servie à WP-CLI (updates silencieusement ratées). if (defined('WP_CLI') && WP_CLI) { return; } // Only front-end page requests are handled here. Admin screens, AJAX, // cron, REST and the login screen never reach template_redirect: these // tests only protect a direct call. They read what WordPress has set // up for the request, never a part of its URL (a path containing // « /wp-admin » or « /wp-json/ » used to open the site to anyone). // wp_doing_ajax() / wp_doing_cron() are deliberately not tested: a // plugin may define DOING_AJAX for a front-end URL (WooCommerce < 9.9 // with « ?wc-ajax= »), which would open the site to anyone. if (is_admin() || (defined('REST_REQUEST') && REST_REQUEST) || (isset($GLOBALS['pagenow']) && 'wp-login.php' === $GLOBALS['pagenow'])) { return; } self::check_countdown_auto_disable(); self::$settings = MGENIUS_Settings::load(); $client_ip = self::get_client_ip(); $user_agent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ); if (!empty(self::$settings['blacklist_enabled'])) { $blocked = false; $reason = ''; if (self::is_ip_in_list($client_ip, self::$settings['blacklist_ips'] ?? [])) { $blocked = true; $reason = 'IP'; } if (!$blocked && self::is_in_list($user_agent, self::$settings['blacklist_user_agents'] ?? [])) { $blocked = true; $reason = 'User-Agent'; } if ($blocked) { if (!empty(self::$settings['blacklist_logging'])) { self::log_blocked($client_ip, $user_agent, $reason); } self::show_blocked_page(); exit; } } if (current_user_can('manage_options')) { if (isset($_GET['mgenius_preview']) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'mgenius_preview')) { self::show_maintenance_page(); exit; } return; } $is_maintenance_active = self::is_maintenance_active(); if (!$is_maintenance_active) { return; } // Controle a la minute sous le verrou de transition, sur // l'etat relu en base (une requete lancee avant la sortie de // maintenance ne recoupe plus les caches apres la sortie). if (!get_transient('mgenius_cache_check') && self::acquire_lock('transition')) { set_transient('mgenius_cache_check', 1, 60); $fresh = MGENIUS_Settings::load_fresh(); if (!empty($fresh['maintenance_enabled'])) { self::ensure_caches_disabled(); } self::release_lock('transition'); } if (!empty(self::$settings['whitelist_enabled'])) { $allowed_roles = self::$settings['whitelist_roles'] ?? ['administrator']; if (is_user_logged_in()) { $user = wp_get_current_user(); foreach ((array) $allowed_roles as $role) { if (is_string($role) && in_array($role, (array) $user->roles, true)) { return; } } } if (self::is_ip_in_list($client_ip, self::$settings['whitelist_ips'] ?? [])) { return; } if (self::is_url_whitelisted(self::request_uri(), self::$settings['whitelist_urls'] ?? [], (string) wp_parse_url(home_url('/'), PHP_URL_PATH)) && !self::request_selects_other_content()) { return; } if (self::is_in_list($user_agent, self::$settings['whitelist_user_agents'] ?? [])) { return; } } // Les liens ont porte ?mg_access= jusqu'a la 2.9.161. Ceux deja // envoyes a des clients doivent continuer de fonctionner. $token = ''; if ( isset( $_GET['mgenius_access'] ) ) { $token = sanitize_text_field( wp_unslash( $_GET['mgenius_access'] ) ); } elseif ( isset( $_GET['mg_access'] ) ) { $token = sanitize_text_field( wp_unslash( $_GET['mg_access'] ) ); } if ('' !== $token) { $token_data = MGENIUS_Settings::validate_access_token($token, $client_ip); if ($token_data !== false) { MGENIUS_Settings::log_token_visit($token, $client_ip, $user_agent); $expiry = !empty($token_data['expires']) ? MGENIUS_Settings::local_to_timestamp($token_data['expires']) : 0; if ($expiry <= time()) { $expiry = time() + DAY_IN_SECONDS; } $cookie_hash = hash('sha256', $token . $expiry . SECURE_AUTH_KEY); $cookie_value = $token . '.' . $expiry . '.' . $cookie_hash; // Lax, like the visitor password cookie: an access link is // usually opened from an email (another site); with Strict the // browser kept the cookie for the redirect and the visitor // stayed on the maintenance page. setcookie('mgenius_token_access', $cookie_value, [ 'expires' => $expiry, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax' ]); wp_safe_redirect(home_url()); exit; } } if (isset( $_COOKIE['mgenius_token_access'] )) { $cookie_parts = explode('.', sanitize_text_field( wp_unslash( $_COOKIE['mgenius_token_access'] ) )); if (count($cookie_parts) === 3) { $token = $cookie_parts[0]; $expiry = intval($cookie_parts[1]); $hash = $cookie_parts[2]; if ($expiry > time()) { $expected_hash = hash('sha256', $token . $expiry . SECURE_AUTH_KEY); if (hash_equals($expected_hash, $hash)) { $token_data = MGENIUS_Settings::validate_access_token($token, $client_ip); if ($token_data !== false) { return; } } } } } // Le cookie visiteur n'ouvre le site que si l'acces // visiteur est toujours active (le desactiver revoque les cookies). if (!empty(self::$settings['visitor_access_enabled']) && !empty(self::$settings['visitor_password']) && isset( $_COOKIE['mgenius_visitor_access'] )) { $cookie_parts = explode('.', sanitize_text_field( wp_unslash( $_COOKIE['mgenius_visitor_access'] ) )); if (count($cookie_parts) === 2) { $expiry = intval($cookie_parts[0]); $hash = $cookie_parts[1]; $token = MGENIUS_Settings::get_visitor_token(); if ($expiry > time()) { $expected_hash = hash('sha256', $expiry . $token . SECURE_AUTH_KEY); if (hash_equals($expected_hash, $hash)) { return; } } } } register_shutdown_function([__CLASS__, 'purge_page_cache']); self::show_maintenance_page(); exit; } /** * v2.9.174 — During maintenance, the core content routes (/wp/v2/...) * answer 503 to visitors who could not see the site either: not logged * in, not on the whitelisted IPs, without a valid access link or visitor * password cookie. Other namespaces (contact forms, shops, oEmbed) are * left alone so that whitelisted pages keep working. * * @param mixed $result Response to send instead, or null. * @param WP_REST_Server $server Server instance. * @param WP_REST_Request $request Current request. * @return mixed */ public static function maybe_block_rest_content($result, $server, $request) { if (null !== $result || !($request instanceof WP_REST_Request)) { return $result; } // WordPress matches routes without regard to case (« /WP/V2/posts » // works): compare a normalized copy. $route = strtolower((string) preg_replace('#/+#', '/', (string) $request->get_route())); if (0 !== strpos($route, '/wp/v2/') && '/wp/v2' !== $route) { return $result; } $settings = MGENIUS_Settings::load(); if (empty($settings['maintenance_enabled']) || empty($settings['rest_content_block'])) { return $result; } if (self::rest_user_has_access($settings) || self::request_has_visitor_access($settings)) { return $result; } return new WP_Error( 'mgenius_maintenance', __('This site is in maintenance mode. Please come back later.', '1001web-maintenance-launch-pages'), array('status' => 503) ); } /** * v2.9.174 — Logged-in users keep the REST API when they can edit content * (the block editor needs it), or when their role may see the site during * maintenance. A subscriber or customer gets the 503 like a visitor. * Cookie-authenticated requests need the REST nonce (WordPress rule). * * @param array $settings Plugin settings. * @return bool */ private static function rest_user_has_access($settings) { if (!is_user_logged_in()) { return false; } if (current_user_can('edit_posts') || current_user_can('manage_options')) { return true; } if (!empty($settings['whitelist_enabled'])) { $user = wp_get_current_user(); foreach ((array) ($settings['whitelist_roles'] ?? []) as $role) { if (is_string($role) && in_array($role, (array) $user->roles, true)) { return true; } } } return false; } /** * v2.9.174 — Read-only check used by the REST filter: does this request * carry an access that would show it the real site (whitelisted IP, * valid access link cookie, valid visitor password cookie)? Same cookie * and IP rules as check_maintenance(), without side effects; the * user-agent whitelist (easy to fake) is not used for the REST API. * * @param array $settings Plugin settings. * @return bool */ private static function request_has_visitor_access($settings) { $client_ip = self::get_client_ip(); if (!empty($settings['whitelist_enabled']) && self::is_ip_in_list($client_ip, $settings['whitelist_ips'] ?? [])) { return true; } if (isset($_COOKIE['mgenius_token_access'])) { $parts = explode('.', sanitize_text_field(wp_unslash($_COOKIE['mgenius_token_access']))); if (3 === count($parts) && intval($parts[1]) > time() && hash_equals(hash('sha256', $parts[0] . intval($parts[1]) . SECURE_AUTH_KEY), $parts[2]) && false !== MGENIUS_Settings::validate_access_token($parts[0], $client_ip)) { return true; } } if (!empty($settings['visitor_access_enabled']) && !empty($settings['visitor_password']) && isset($_COOKIE['mgenius_visitor_access'])) { $parts = explode('.', sanitize_text_field(wp_unslash($_COOKIE['mgenius_visitor_access']))); if (2 === count($parts) && intval($parts[0]) > time() && hash_equals(hash('sha256', intval($parts[0]) . MGENIUS_Settings::get_visitor_token() . SECURE_AUTH_KEY), $parts[1])) { return true; } } return false; } /** * Vérifie si une IP est dans une liste (supporte wildcard et CIDR) */ private static function is_ip_in_list($ip, $list) { if (empty($list) || !is_array($list)) { return false; } foreach ($list as $pattern) { // Only text entries (a corrupted or hand-made import must not // break every page with a fatal error). if (!is_string($pattern)) { continue; } $pattern = trim($pattern); if (empty($pattern)) continue; // Same address written another way (upper case, full IPv6 // form): compared as binary addresses, not as text. if ($pattern === $ip || self::ip_in_ranges($ip, array($pattern))) return true; // Joker : le motif est echappe puis seul * devient .* // (avant, « 192.168.* » ne correspondait jamais a aucune IP). if (strpos($pattern, '*') !== false) { $regex = '/^' . str_replace('\\*', '[0-9a-fA-F.:]*', preg_quote($pattern, '/')) . '$/i'; if (preg_match($regex, $ip)) return true; } // CIDR IPv4 et IPv6 par ip_in_ranges(), qui ignore les // masques invalides (l'ancien calcul acceptait tout IPv6 et pouvait // provoquer une erreur fatale sur une faute de frappe). if (strpos($pattern, '/') !== false) { if (self::ip_in_ranges($ip, [$pattern])) return true; } } return false; } /** * Vérifie si la maintenance est active (avec support programmation) * @return bool */ // LOGIQUE ACTIVE — Vérifie si maintenance ON + planification private static function is_maintenance_active() { // La planification agit maintenant sur maintenance_enabled // (maybe_apply_schedule) : une seule source de verite. return !empty(self::$settings['maintenance_enabled']); } /** * Debut et fin de la plage planifiee, en horodatages Unix. * * @param array $settings * @return array|null ['start' => int, 'end' => int] ou null si incomplete. */ public static function schedule_window($settings) { $start_date = $settings['schedule_start_date'] ?? ''; $end_date = $settings['schedule_end_date'] ?? ''; if (empty($start_date) || empty($end_date)) { return null; } $start_time = !empty($settings['schedule_start_time']) ? $settings['schedule_start_time'] : '00:00'; $end_time = !empty($settings['schedule_end_time']) ? $settings['schedule_end_time'] : '23:59'; try { $tz = MGENIUS_Settings::schedule_timezone($settings); $start = \DateTime::createFromFormat('Y-m-d H:i', $start_date . ' ' . $start_time, $tz); $end = \DateTime::createFromFormat('Y-m-d H:i', $end_date . ' ' . $end_time, $tz); } catch (\Exception $e) { return null; } if (!$start || !$end || $end->getTimestamp() <= $start->getTimestamp()) { return null; } return ['start' => $start->getTimestamp(), 'end' => $end->getTimestamp()]; } /** * Applique la planification. Elle n'eteint que ce qu'elle a * allume : une coupure manuelle pendant la plage n'est pas annulee, et une * maintenance allumee a la main avant la plage n'est pas eteinte a la fin. */ private static function maybe_apply_schedule() { if (defined('WP_CLI') && WP_CLI) { return; } $enabled = !empty(self::$settings['maintenance_enabled']); // La maintenance actuelle a ete allumee par la planification (tout // geste manuel remet ce drapeau a false : le geste manuel l'emporte). $owned = !empty(self::$settings['schedule_auto_on']); $scheduled = !empty(self::$settings['schedule_enabled']); $window = $scheduled ? self::schedule_window(self::$settings) : null; $now = time(); $start_due = false; $end_due = false; $signature = ''; if ($window) { // Signature fondee sur le debut seul : modifier la fin d'une plage // en cours ne la fait pas redemarrer, et la nouvelle fin s'applique. $signature = (string) $window['start']; $started = (string) (self::$settings['schedule_started'] ?? ''); $start_due = !$enabled && $now >= $window['start'] && $now <= $window['end'] && $started !== $signature; } if ($enabled && $owned) { // Plage terminee, planification decochee ou plage devenue invalide : // la maintenance allumee par la planification se termine. $end_due = !$window || $now > $window['end']; } if (!$start_due && !$end_due) { return; } // Verrou : deux visites simultanees ne basculent qu'une fois, et les // requetes du releve de reference ne relancent pas la bascule. // Verrou atomique : get_transient() puis set_transient() // laissait passer deux visites (double restauration du .htaccess). if (!self::acquire_lock('transition')) { return; } // Decision reprise sur l'etat relu en base, sous le verrou. $fresh = MGENIUS_Settings::load_fresh(); if (!empty($fresh['maintenance_enabled']) !== $enabled || !empty($fresh['schedule_auto_on']) !== $owned || (string) ($fresh['schedule_started'] ?? '') !== (string) (self::$settings['schedule_started'] ?? '')) { self::$settings = $fresh; self::release_lock('transition'); return; } if ($start_due && !self::filesystem_ready()) { // Meme regle que l'interrupteur : sans acces direct aux // fichiers, la maintenance n'est pas activee. La plage est marquee // pour ne pas reessayer a chaque visite ; l'administration le dit. $fresh['schedule_started'] = $signature; MGENIUS_Settings::save($fresh); self::set_cache_warning('filesystem', 'direct'); self::$settings = MGENIUS_Settings::load_fresh(); self::release_lock('transition'); return; } $emails = []; if ($start_due) { MGENIUS_Healthcheck::capture_baseline(); $settings = MGENIUS_Settings::load_fresh(); $settings['maintenance_enabled'] = true; $settings['schedule_started'] = $signature; $settings['schedule_auto_on'] = true; MGENIUS_Settings::save($settings); self::flush_all_caches(); self::debug_log('Scheduled maintenance started'); } else { $settings = MGENIUS_Settings::load_fresh(); $settings['maintenance_enabled'] = false; $settings['schedule_auto_on'] = false; // v2.9.174 — a finished window is unticked (it stayed ticked // with past dates), and the admin is told it ended. if ($window && $now > $window['end']) { $settings['schedule_enabled'] = false; } MGENIUS_Settings::save($settings); update_option('mgenius_auto_ended', array('time' => time(), 'by' => 'schedule'), false); self::flush_all_caches(); // Liste des abonnes prise et videe sous le verrou, // courriels envoyes apres l'avoir libere (comme a la fin du compte // a rebours) : un envoi lent ne bloque plus l'administrateur. $emails = MGENIUS_Settings::get_notification_emails(); if (!empty($emails)) { MGENIUS_Settings::set_notification_emails([]); } self::schedule_exit_check(); self::debug_log('Scheduled maintenance ended'); } self::$settings = MGENIUS_Settings::load_fresh(); self::release_lock('transition'); if (!empty($emails) && class_exists('MGENIUS_Ajax')) { MGENIUS_Ajax::send_notifications($emails); } } /** * Programme la comparaison avant/apres en tache de fond. */ public static function schedule_exit_check() { if (!wp_next_scheduled('mgenius_post_exit_check')) { wp_schedule_single_event(time() + 30, 'mgenius_post_exit_check'); } } /** * Vérifie si une valeur contient un élément de la liste */ private static function is_in_list($value, $list) { if (empty($list) || !is_array($list) || empty($value) || !is_string($value)) { return false; } $value_lower = strtolower($value); foreach ($list as $item) { if (!is_string($item)) { continue; } $item = trim($item); // v2.9.174 — 1 or 2 characters match almost every browser (« a » // is in « AppleWebKit »): such entries are ignored. if (empty($item) || strlen($item) < 3) continue; if (stripos($value_lower, strtolower($item)) !== false) { return true; } } return false; } /** * Raw URI of the current request, used for comparisons only (never * printed nor stored). sanitize_text_field() would strip the %XX octets * of non-ASCII addresses and change the path being compared. * * @return string */ private static function request_uri() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compared only (see above), decoded and checked in request_path(). return isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : ''; } /** * Decoded path of a request URI, or false when the path could be read * differently by WordPress: dot segments (« /a/../b/ », WordPress serves * « /b/ »), empty segments, backslashes, control characters, or octets * still encoded after one decoding. Such a path never matches the * whitelist. * * @param string $uri Request URI (path and optional query string). * @return string|false */ private static function request_path($uri) { $uri = (string) $uri; $path = substr($uri, 0, strcspn($uri, '?#')); // Absolute-form request target (sent through a proxy): path only. if (preg_match('#^[a-z][a-z0-9+.-]*://[^/]*#i', $path, $m)) { $path = (string) substr($path, strlen($m[0])); if ('' === $path) { $path = '/'; } } if ('' === $path || '/' !== $path[0]) { return false; } $path = rawurldecode($path); if (preg_match('/%[0-9a-f]{2}/i', $path) || preg_match('#/\.\.?(/|$)#', $path) || false !== strpos($path, '//') || false !== strpos($path, '\\') || preg_match('/[\x00-\x1f\x7f]/', $path)) { return false; } return $path; } /** * True when the query string (or posted data) carries a WordPress query * variable that makes WordPress serve another content than the page at * this address: « /contact/?pagename=launch » shows the launch page, * « /?s=word » the search results, « /?feed=rss2 » the whole feed. Such a * request is not covered by a whitelisted address. Paging and sorting * variables stay allowed. * * @return bool */ private static function request_selects_other_content() { global $wp; $vars = (isset($wp) && is_object($wp) && !empty($wp->public_query_vars)) ? (array) $wp->public_query_vars : array(); $vars = array_diff($vars, array('paged', 'page', 'cpage', 'order', 'orderby')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- Parameter names only, compared with the query variables WordPress reads; no value is used. $sent = array_merge(array_keys((array) $_GET), array_keys((array) $_POST)); return (bool) array_intersect($sent, $vars); } /** * Whitelisted address. An entry opens its exact path, with or without the * trailing slash; an entry ending with « * » also opens everything below * it, on a segment boundary: « /shop/* » (or « /shop* ») opens /shop/ and * /shop/cart/, never /shopping/. « / » opens the home page only. Paths are * compared decoded (non-ASCII slugs), with and without the folder of a * site installed in a subdirectory. * * @param string $uri Request URI. * @param array $list Entries. * @param string $home_path Path of the home URL (« /blog/ »), optional. * @return bool */ private static function is_url_whitelisted($uri, $list, $home_path = '') { if (empty($list) || !is_array($list)) { return false; } // Chemin seul : une chaine de requete (« ?x=/page ») ne // doit pas suffire a passer pour une page autorisee. $path = self::request_path($uri); if (false === $path) { return false; } $paths = array($path); $home = rtrim(rawurldecode((string) $home_path), '/'); if ('' !== $home && ($path === $home || 0 === strpos($path, $home . '/'))) { $relative = (string) substr($path, strlen($home)); $paths[] = ('' === $relative) ? '/' : $relative; } foreach ($list as $pattern) { if (!is_string($pattern)) { continue; } $pattern = trim(rawurldecode($pattern)); if (false !== strpos($pattern, '://')) { // Full address pasted from the browser: its path. $pattern = (string) wp_parse_url($pattern, PHP_URL_PATH); } $pattern = substr($pattern, 0, strcspn($pattern, '?#')); if ('' === $pattern) { continue; } if ('/' !== $pattern[0]) { $pattern = '/' . $pattern; } $wildcard = ('*' === substr($pattern, -1)); $base = rtrim($wildcard ? rtrim($pattern, '*') : $pattern, '/'); foreach ($paths as $candidate) { if (rtrim($candidate, '/') === $base) { return true; } if ($wildcard && 0 === strpos($candidate, $base . '/')) { return true; } } } return false; } /** * Logger un blocage */ private static function log_blocked($ip, $user_agent, $reason) { $logs = get_option('mgenius_blacklist_logs', []); // Corrupted value (not a list): start a new log instead of a fatal // error on every blocked request. if (!is_array($logs)) { $logs = []; } $logs[] = [ 'time' => current_time('mysql'), 'ip' => $ip, 'user_agent' => substr($user_agent, 0, 200), 'reason' => $reason, 'uri' => sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ) ]; if (count($logs) > 500) { $logs = array_slice($logs, -500); } update_option('mgenius_blacklist_logs', $logs, false); } /** * Obtenir l'IP du client */ // UTILITAIRES IP — Détection, CIDR, wildcards private static function get_client_ip() { return self::client_ip(); } /** * Adresse IP du client, non falsifiable. * * Les en-tetes CF-Connecting-IP, X-Forwarded-For et X-Real-IP sont envoyes * par le client : n'importe qui peut les fabriquer. Leur faire confiance * permettait de contourner le verrouillage anti-force brute, la limite de * debit, et d'usurper une IP de la liste blanche pour passer la maintenance. * * Ils ne sont lus que si la connexion vient reellement d'un proxy de * confiance : REMOTE_ADDR doit appartenir a une plage connue. Un attaquant * ne peut pas faire provenir sa connexion TCP d'une adresse Cloudflare. * * Plages par defaut : Cloudflare (https://www.cloudflare.com/ips/). * Filtre `mgenius_trusted_proxies` pour les etendre ou les remplacer. * * @return string */ public static function client_ip() { $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! filter_var( $remote, FILTER_VALIDATE_IP ) ) { return '0.0.0.0'; } $trusted = (array) apply_filters( 'mgenius_trusted_proxies', self::cloudflare_ranges() ); if ( ! self::ip_in_ranges( $remote, $trusted ) ) { return $remote; } foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP' ) as $key ) { if ( empty( $_SERVER[ $key ] ) ) { continue; } $ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); if ( strpos( $ip, ',' ) !== false ) { $ip = trim( explode( ',', $ip )[0] ); } if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $ip; } } return $remote; } /** * Plages publiees par Cloudflare, verifiees le 2026-09-22. * * @return string[] */ private static function cloudflare_ranges() { return array( '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22', '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32', '2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32', ); } /** * Appartenance d'une IP a une liste de plages CIDR, IPv4 et IPv6. * * @param string $ip * @param string[] $ranges * @return bool */ private static function ip_in_ranges( $ip, $ranges ) { // filter_var avant inet_pton : inet_pton emet un avertissement sur une // entree invalide, et l'operateur @ est refuse par les standards WP. if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return false; } $ip_bin = inet_pton( $ip ); foreach ( $ranges as $range ) { $range = trim( (string) $range ); if ( strpos( $range, '/' ) === false ) { if ( filter_var( $range, FILTER_VALIDATE_IP ) && inet_pton( $range ) === $ip_bin ) { return true; } continue; } list( $subnet, $bits ) = explode( '/', $range, 2 ); if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) { continue; } $subnet_bin = inet_pton( $subnet ); if ( strlen( $subnet_bin ) !== strlen( $ip_bin ) ) { continue; } // Masque numerique et dans les limites (0-32 ou 0-128). if ( ! ctype_digit( (string) $bits ) || (int) $bits > strlen( $ip_bin ) * 8 ) { continue; } $bits = (int) $bits; $bytes = intdiv( $bits, 8 ); $rest = $bits % 8; if ( $bytes && substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) ) { continue; } if ( $rest ) { $mask = chr( ( 0xFF << ( 8 - $rest ) ) & 0xFF ); if ( ( $ip_bin[ $bytes ] & $mask ) !== ( $subnet_bin[ $bytes ] & $mask ) ) { continue; } } return true; } return false; } /** * Affiche la page de blocage (blacklist) */ private static function show_blocked_page() { status_header(403); header('Content-Type: text/html; charset=utf-8'); $message = self::$settings['blacklist_message'] ?? __('Access denied.', '1001web-maintenance-launch-pages'); ?> <?php esc_html_e('403 - Access Denied', '1001web-maintenance-launch-pages'); ?>

403

getTimestamp(); } /** * Vrai si un compte a rebours actif, a desactivation * automatique, est deja echu : la maintenance serait coupee a la premiere * visite publique. * * @param array $settings Reglages du plugin. * @return bool */ public static function countdown_has_expired($settings) { if (empty($settings['countdown_enabled']) || empty($settings['countdown_auto_disable'])) { return false; } $target = self::countdown_target_timestamp($settings); return ($target > 0 && $target <= time()); } /** * True if a countdown is on while its end is already past, whether or not * it ends maintenance. Every write path (save, import, theme, switch, * wizard) then turns the countdown off: a past date must never end the * maintenance a few seconds after being saved. * * @param array $settings Plugin settings. * @return bool */ public static function countdown_end_is_past($settings) { if (!is_array($settings) || empty($settings['countdown_enabled'])) { return false; } $target = self::countdown_target_timestamp($settings); return ($target > 0 && $target <= time()); } /** * Point d'entree public : l'ecran d'administration demande * l'etat du site (mgenius_get_state) a la fin du compte a rebours ; la * maintenance doit alors etre coupee meme si aucun visiteur n'est passe. */ public static function maybe_end_countdown() { self::check_countdown_auto_disable(false); } /** * @param bool $redirect Rediriger le visiteur vers l'accueil (requete * publique). False pour un appel AJAX d'administration. */ private static function check_countdown_auto_disable($redirect = true) { // Pre-check on the cached settings: the options cache is only // invalidated when a countdown may really have ended, not on every // public page view. $settings = MGENIUS_Settings::load(); if (empty($settings['maintenance_enabled']) || !self::countdown_has_expired($settings)) { return; } // Une seule requete termine le compte a rebours : sans // verrou, deux visites simultanees restauraient le .htaccess ensemble // (fichier reduit aux seuls blocs de cache) et envoyaient les courriels // deux fois. Les autres visites passent leur tour. if (!self::acquire_lock('transition')) { return; } $settings = MGENIUS_Settings::load_fresh(); if (empty($settings['maintenance_enabled']) || !self::countdown_has_expired($settings)) { self::release_lock('transition'); return; } $notification_emails = MGENIUS_Settings::get_notification_emails(); $email_count = count($notification_emails); self::debug_log('Countdown ended - ' . $email_count . ' email(s) to send'); $settings['maintenance_enabled'] = false; $settings['schedule_auto_on'] = false; $settings['countdown_enabled'] = false; MGENIUS_Settings::save($settings); update_option('mgenius_auto_ended', array('time' => time(), 'by' => 'countdown'), false); // Fin de compte a rebours = sortie de maintenance : les // plugins de cache et le .htaccess retrouvent leur etat, comme lors // d'une desactivation manuelle. Auparavant, ils restaient coupes. // Meme parcours que la coupure manuelle (restauration et // purges des caches). self::flush_all_caches(); // Comparaison avant/apres, en tache de fond. self::schedule_exit_check(); MGENIUS_Settings::set_notification_emails( [] ); self::release_lock('transition'); if (!empty($notification_emails)) { self::send_countdown_notifications($notification_emails); } self::debug_log('Maintenance switched off automatically at ' . gmdate('Y-m-d H:i:s') . ' UTC'); if (!$redirect) { return; } wp_safe_redirect(home_url()); exit; } /** * Envoie les notifications email aux inscrits * @param array $emails Liste des emails à notifier */ private static function send_countdown_notifications($emails) { if (empty($emails)) { return; } $site_name = get_bloginfo('name'); $site_url = home_url(); /* translators: %s: site name */ $subject = sprintf(__('🎉 %s is now online!', '1001web-maintenance-launch-pages'), $site_name); $message = sprintf( /* translators: 1: site name, 2: site URL */ __("Hello,\n\nGreat news! The site %1\$s is now online.\n\nYou can visit it right now:\n%2\$s\n\nThank you for your patience!\n\n---\nThis email was sent automatically because you requested to be notified.", '1001web-maintenance-launch-pages'), $site_name, $site_url ); $headers = [ 'Content-Type: text/plain; charset=UTF-8', 'From: ' . $site_name . ' <' . get_option('admin_email') . '>' ]; $sent_count = 0; $failed_count = 0; foreach ($emails as $email_data) { $email = $email_data['email'] ?? ''; if (!empty($email) && is_email($email)) { // Aucune adresse dans le journal (donnee personnelle). if (wp_mail($email, $subject, $message, $headers)) { $sent_count++; // Envoi reussi : compte dans le resume ci-dessous. } else { $failed_count++; self::debug_log('An email could not be sent'); } } } self::debug_log('Result - ' . $sent_count . ' sent, ' . $failed_count . ' failed'); } /** * Affiche la page de maintenance */ /** * Purge les fichiers cache après avoir servi la page de maintenance * Appelé via register_shutdown_function pour supprimer les caches * que WPFC ou d'autres plugins auraient pu créer pendant cette requête */ // CACHE MANAGEMENT — Purge et désactivation des plugins cache // Compatible: WP Super Cache, W3TC, WP Rocket, LiteSpeed, // WP Fastest Cache, Autoptimize, SG Optimizer, etc. public static function purge_page_cache() { $dirs = [ WP_CONTENT_DIR . '/cache/all/', WP_CONTENT_DIR . '/cache/wpfc-minified/', WP_CONTENT_DIR . '/cache/wpfc-mobile-cache/', WP_CONTENT_DIR . '/cache/seraphinite-accelerator/', ]; foreach ($dirs as $dir) { if (is_dir($dir)) { self::delete_directory_contents($dir); } } } // RENDU — Instancie MGENIUS_Frontend et affiche la page 503 /** * 'robots' for the robots.txt request, 'sitemap' for an XML sitemap or its * stylesheet (WordPress core, Yoast-style names), '' for anything else. * Core is asked first (query variables); the path test is anchored on the * whole path, so a query string or a deeper path never matches. * * @return string */ private static function crawler_file_request() { if (function_exists('is_robots') && is_robots()) { return 'robots'; } if (get_query_var('sitemap') || get_query_var('sitemap-stylesheet')) { return 'sitemap'; } $path = self::request_path(self::request_uri()); if (false === $path) { return ''; } $home = untrailingslashit((string) wp_parse_url(home_url('/'), PHP_URL_PATH)); if ('' !== $home && 0 === strpos($path, $home . '/')) { $path = (string) substr($path, strlen($home)); } if ('/robots.txt' === strtolower($path)) { return 'robots'; } if (preg_match('#^/(sitemap(_index)?\.xml|wp-sitemap[a-z0-9_-]*\.xml|[a-z0-9_-]+-sitemap[0-9]*\.xml|[a-z0-9_-]*sitemap[a-z0-9_-]*\.xsl)$#i', $path)) { return 'sitemap'; } return ''; } private static function show_maintenance_page() { // v2.9.180 — pages shown with HTTP 200 (Coming Soon, or maintenance // without the 503 option): robots.txt and sitemaps must not come back // as an HTML page with a 200, which crawlers read as an invalid file. // With the 503 page, the 503 is already the right signal and is kept. $mgenius_is_200 = ('coming_soon' === (self::$settings['page_mode'] ?? 'maintenance')) || empty(self::$settings['seo_503_enabled']); if ($mgenius_is_200) { $mgenius_crawler = self::crawler_file_request(); if ('' !== $mgenius_crawler) { while (ob_get_level() > 0) { ob_end_clean(); } nocache_headers(); header('Content-Type: text/plain; charset=utf-8'); if ('robots' === $mgenius_crawler) { status_header(200); echo "User-agent: *\nDisallow: /\n"; } else { status_header(404); echo "Not found\n"; } exit; } } // Constantes standard lues par les plugins de cache et d'optimisation : // leur nom est impose par ces plugins, il ne peut pas etre prefixe. // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound if (!defined('DONOTMINIFY')) define('DONOTMINIFY', true); if (!defined('DONOTASYNCCSS')) define('DONOTASYNCCSS', true); if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true); // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound while (ob_get_level() > 0) { ob_end_clean(); } // Le code HTTP et Retry-After sont fixes par le rendu, // selon les reglages : 200 en Coming Soon, 503 seulement si l'option // est active, Retry-After selon la duree choisie (aucun si 0). header('Content-Type: text/html; charset=utf-8'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: Thu, 01 Jan 1970 00:00:00 GMT'); header('X-LiteSpeed-Cache-Control: no-cache'); header('CDN-Cache-Control: no-store'); require_once MGENIUS_PLUGIN_DIR . 'includes/class-frontend.php'; $frontend = new MGENIUS_Frontend(self::$settings); $frontend->render(); } /** * Vérifie et désactive les caches si MM est actif * Appelé une fois par minute via transient pour ne pas impacter la perf * * Appele sous le verrou de transition, maintenance confirmee * en base. Un plugin de cache rallume pendant la maintenance est recoupe ; * la premiere capture de ses reglages reste celle d'avant la maintenance. */ private static function ensure_caches_disabled() { self::disable_cache_plugins(); foreach (['all', 'wpfc-minified', 'seraphinite-accelerator', 'wpo-cache'] as $sub) { self::delete_directory_contents(WP_CONTENT_DIR . '/cache/' . $sub . '/'); } // Regles de cache remises dans le .htaccess pendant la maintenance. $htaccess = self::htaccess_file(); if (file_exists($htaccess)) { $fs = self::init_filesystem(); $content = $fs ? $fs->get_contents($htaccess) : false; if (is_string($content) && self::has_cache_rules($content)) { self::strip_cache_htaccess_rules(); } } } /** * Gère les plugins de cache lors du toggle MM * MM ON → sauvegarde l'état des caches, les désactive, purge les fichiers * MM OFF → restaure l'état sauvegardé des caches * * Une seule transition a la fois (interrupteur, compte a * rebours, planification, controles d'init) : l'appelant attend son tour. * * @return bool False si une autre transition n'a pas rendu la main a temps. */ public static function flush_all_caches() { if (!self::acquire_lock('transition', 20)) { return false; } self::$settings = MGENIUS_Settings::load_fresh(); $is_active = !empty(self::$settings['maintenance_enabled']); if ($is_active) { self::disable_cache_plugins(); // Retrait immediat des regles de cache du .htaccess : // une page servie par Apache n'atteint jamais WordPress. self::strip_cache_htaccess_rules(); self::purge_w3tc_page_cache(); } else { self::restore_cache_plugins(); self::restore_htaccess_blocks(); // Cache Guard et retrait des regles : sans objet hors // maintenance (la remise du .htaccess a son propre avis). self::clear_maintenance_warnings(); } if (self::filesystem_ready()) { self::clear_cache_warning('filesystem'); } $cache_dirs = [ WP_CONTENT_DIR . '/cache/all/', WP_CONTENT_DIR . '/cache/wpfc-minified/', WP_CONTENT_DIR . '/cache/wp-rocket/', WP_CONTENT_DIR . '/cache/supercache/', WP_CONTENT_DIR . '/cache/cache-enabler/', WP_CONTENT_DIR . '/cache/seraphinite-accelerator/', WP_CONTENT_DIR . '/cache/wpo-cache/', WP_CONTENT_DIR . '/litespeed/cssjs/', ]; foreach ($cache_dirs as $dir) { if (is_dir($dir)) { self::delete_directory_contents($dir); } } if (function_exists('wpfc_clear_all_cache')) { wpfc_clear_all_cache(true); } elseif (isset($GLOBALS['wp_fastest_cache']) && method_exists($GLOBALS['wp_fastest_cache'], 'deleteCache')) { $GLOBALS['wp_fastest_cache']->deleteCache(true); } if (function_exists('wp_cache_clear_cache')) wp_cache_clear_cache(); if (function_exists('w3tc_flush_all')) w3tc_flush_all(); if (function_exists('rocket_clean_domain')) rocket_clean_domain(); // WP-Optimize : son advanced-cache.php sert les pages deja en cache // avant WordPress. Sa purge officielle vide WPO_CACHE_FILES_DIR ; il // respecte DONOTCACHEPAGE et ne met pas en cache les reponses >= 400, // donc le cache reste vide pendant la maintenance. if (function_exists('WP_Optimize')) { $wpo = WP_Optimize(); if (is_object($wpo) && is_callable(array($wpo, 'get_page_cache'))) { $wpo_cache = $wpo->get_page_cache(); if (is_object($wpo_cache) && is_callable(array($wpo_cache, 'purge'))) { $wpo_cache->purge(); } } } // Crochets publics de purge documentes par LiteSpeed, Hummingbird et // Breeze : on les declenche, on ne les declare pas. // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound if (class_exists('LiteSpeed\Purge') || defined('LSCWP_V')) do_action('litespeed_purge_all'); if (class_exists('autoptimizeCache')) autoptimizeCache::clearall(); if (class_exists('Cache_Enabler')) Cache_Enabler::clear_total_cache(); if (class_exists('Hummingbird\\Core\\Utils')) do_action('wphb_clear_page_cache'); if (function_exists('sg_cachepress_purge_everything')) sg_cachepress_purge_everything(); if (class_exists('Breeze_PurgeCache')) do_action('breeze_clear_all_cache'); // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound if (function_exists('wp_cache_flush')) wp_cache_flush(); self::release_lock('transition'); return true; } /** * Désactive tous les plugins de cache détectés et sauvegarde leur état * * La premiere capture fait foi : une valeur deja capturee * n'est jamais remplacee. * * Releve, puis instantane ecrit et relu, puis seulement les * modifications : un arret de PHP en cours de route (delai depasse, * erreur d'un plugin tiers) ne fait plus perdre un reglage d'origine. * Valeurs lues en base : la copie d'alloptions d'une requete longue * pouvait contenir les valeurs deja coupees. Seuls les reglages dont * l'effet est etabli dans le code du plugin de cache sont modifies. * WP Super Cache, W3 Total Cache et Cache Enabler ne voient aucun de * leurs reglages change : ils respectent DONOTCACHEPAGE, et leurs regles * .htaccess et dossiers de cache sont traites a part. */ private static function disable_cache_plugins() { wp_cache_delete('alloptions', 'options'); wp_cache_delete('mgenius_cache_saved_states', 'options'); $existing = get_option('mgenius_cache_saved_states', []); if (!is_array($existing)) { $existing = []; } // 1. Releve, sans rien modifier. $found = []; // WP Fastest Cache teste isset($options->wpFastestCacheStatus) // (createCache()) : une valeur vide laissait le cache actif. Couper, // c'est retirer la cle, comme le fait sa case « Enable ». $wpfc = get_option('WpFastestCache'); $wpfc_data = is_string($wpfc) ? json_decode($wpfc, true) : null; if (is_array($wpfc_data) && array_key_exists('wpFastestCacheStatus', $wpfc_data)) { $found['wpfc'] = $wpfc; } // LiteSpeed Cache : le cache n'est actif que si litespeed.conf.cache vaut 1. $lscache = get_option('litespeed.conf.cache'); if (false !== $lscache && '0' !== (string) $lscache) { $found['litespeed_cache'] = $lscache; } // Hummingbird, SiteGround Optimizer, Breeze : interrupteur du cache de pages. $hb = get_option('wphb_settings'); if (is_array($hb) && !empty($hb['page_cache']['enabled'])) { $found['hummingbird'] = $hb; } $sg = get_option('siteground_optimizer_enable_cache'); if (false !== $sg && '0' !== (string) $sg) { $found['sg_optimizer'] = $sg; } $breeze = get_option('breeze_basic_settings'); if (is_array($breeze) && isset($breeze['breeze-active']) && '0' !== (string) $breeze['breeze-active']) { $found['breeze'] = $breeze; } // 2. Instantane ecrit, puis relu, AVANT toute modification. $merged = $existing + $found; if ($merged !== $existing) { update_option('mgenius_cache_saved_states', $merged, false); wp_cache_delete('mgenius_cache_saved_states', 'options'); if (get_option('mgenius_cache_saved_states') != $merged) { self::debug_log('Cache settings could not be saved: nothing was changed'); return; } } // 3. Modifications. if (isset($found['wpfc'])) { unset($wpfc_data['wpFastestCacheStatus']); update_option('WpFastestCache', wp_json_encode(empty($wpfc_data) ? new stdClass() : $wpfc_data)); self::strip_cache_htaccess_rules(); } if (isset($found['litespeed_cache'])) { update_option('litespeed.conf.cache', 0); } if (isset($found['hummingbird'])) { $hb['page_cache']['enabled'] = false; update_option('wphb_settings', $hb); } if (isset($found['sg_optimizer'])) { update_option('siteground_optimizer_enable_cache', 0); } if (isset($found['breeze'])) { $breeze['breeze-active'] = '0'; update_option('breeze_basic_settings', $breeze); } // WP Rocket : aucune ecriture dans ses reglages. Il est // suspendu par le Cache Guard (DONOTCACHEPAGE et le filtre // do_rocket_generate_caching_files). // Son cache n'est plus vide ici : ce controle tourne chaque // minute pendant la maintenance, et rocket_clean_domain() declenche // aussi les purges branchees sur before_rocket_clean_domain (Varnish, // hebergements LiteSpeed et GoDaddy dans WP Rocket 3.23). // flush_all_caches() le vide a l'entree et a la sortie ; le Cache // Guard vide cache/wp-rocket/ chaque minute. } /** * Restaure l'état des plugins de cache sauvegardé * * Seule la valeur coupee est remise, et seulement si elle porte * encore la valeur posee par la maintenance : un reglage modifie ou migre * pendant la maintenance (mise a jour du plugin de cache) est conserve. * Sans autre changement, l'option retrouve sa valeur d'origine octet pour * octet. L'option d'un plugin desinstalle entre-temps n'est pas recreee. */ private static function restore_cache_plugins() { wp_cache_delete('alloptions', 'options'); wp_cache_delete('mgenius_cache_saved_states', 'options'); $saved = get_option('mgenius_cache_saved_states', []); if (!is_array($saved) || empty($saved)) { delete_option('mgenius_cache_saved_states'); // Same as the end of a full restore: the next maintenance // starts with a first Cache Guard purge, even within a minute. delete_transient('mgenius_guard_purged'); return; } // WP Fastest Cache : la cle retiree revient (2.9.170 la vidait, ainsi // que la prechauffe : sauvegardes d'une mise a jour en pleine maintenance). if (isset($saved['wpfc']) && is_string($saved['wpfc'])) { $old = json_decode($saved['wpfc'], true); $raw = get_option('WpFastestCache'); $cur = is_string($raw) ? json_decode($raw, true) : null; if (is_array($old) && is_array($cur)) { $new = $cur; if (array_key_exists('wpFastestCacheStatus', $old) && (!isset($cur['wpFastestCacheStatus']) || '' === $cur['wpFastestCacheStatus'])) { $new['wpFastestCacheStatus'] = $old['wpFastestCacheStatus']; } if (!empty($old['wpFastestCachePreload']) && isset($cur['wpFastestCachePreload']) && '' === $cur['wpFastestCachePreload']) { $new['wpFastestCachePreload'] = $old['wpFastestCachePreload']; } // Ordre des cles ignore (la cle retiree revient en fin de tableau), // valeurs comparees strictement. $new_sorted = $new; $old_sorted = $old; ksort($new_sorted); ksort($old_sorted); if ($new_sorted === $old_sorted) { update_option('WpFastestCache', $saved['wpfc']); } elseif ($new !== $cur) { update_option('WpFastestCache', wp_json_encode($new)); } } } if (isset($saved['litespeed_cache']) && '0' === (string) get_option('litespeed.conf.cache', '')) { update_option('litespeed.conf.cache', $saved['litespeed_cache']); } if (isset($saved['hummingbird']['page_cache']) && is_array($saved['hummingbird'])) { $cur = get_option('wphb_settings'); if (is_array($cur) && isset($cur['page_cache']) && is_array($cur['page_cache']) && empty($cur['page_cache']['enabled'])) { $new = $cur; $new['page_cache']['enabled'] = isset($saved['hummingbird']['page_cache']['enabled']) ? $saved['hummingbird']['page_cache']['enabled'] : true; update_option('wphb_settings', ($new === $saved['hummingbird']) ? $saved['hummingbird'] : $new); } } if (isset($saved['sg_optimizer']) && '0' === (string) get_option('siteground_optimizer_enable_cache', '')) { update_option('siteground_optimizer_enable_cache', $saved['sg_optimizer']); } if (isset($saved['breeze']['breeze-active']) && is_array($saved['breeze'])) { $cur = get_option('breeze_basic_settings'); if (is_array($cur) && isset($cur['breeze-active']) && '0' === (string) $cur['breeze-active']) { $new = $cur; $new['breeze-active'] = $saved['breeze']['breeze-active']; update_option('breeze_basic_settings', ($new === $saved['breeze']) ? $saved['breeze'] : $new); } } delete_option('mgenius_cache_saved_states'); // Les verrous horaires restent poses (au lieu d'etre // supprimes) : une requete lancee avant la sortie ne refait pas la // coupure apres la sortie. L'entree suivante fait tout elle-meme. set_transient('mgenius_cache_check', 1, 60); set_transient('mgenius_init_purged', 1, 3600); delete_transient('mgenius_guard_purged'); } /** * Retire du .htaccess les blocs des plugins de cache qui font * servir des pages par Apache sans passer par WordPress (WP Fastest Cache, * WP Super Cache, W3 Total Cache). Le fichier d'origine est conserve et * remis a l'identique a la sortie (restore_htaccess_blocks). * * Le .htaccess lu par Apache (WordPress dans son propre * dossier), ecriture atomique et verifiee, echec signale. */ private static function strip_cache_htaccess_rules() { $htaccess_file = self::htaccess_file(); if (!file_exists($htaccess_file)) { return; } $fs = self::init_filesystem(); if (!$fs) { // Acces direct perdu pendant une maintenance : signale // seulement s'il y a des regles a retirer. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Lecture d'un fichier local, sans WP_Filesystem disponible. $raw = is_readable($htaccess_file) ? file_get_contents($htaccess_file) : false; if (is_string($raw) && self::has_cache_rules($raw)) { self::set_cache_warning('htaccess_strip', 'direct', $htaccess_file); } return; } $content = $fs->get_contents($htaccess_file); if (!is_string($content) || '' === $content) return; $new_content = $content; foreach (self::htaccess_block_patterns() as $pattern) { $stripped = preg_replace($pattern, '', $new_content); if (null !== $stripped) { $new_content = $stripped; } } if ($new_content === $content) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Test de droits, aucune ecriture. if (!is_writable($htaccess_file)) { self::set_cache_warning('htaccess_strip', 'readonly', $htaccess_file); return; } // Fichier change depuis la derniere ecriture du plugin : copie et blocs // repartent de lui (voir remember_htaccess_original()). $original = get_option(self::HTACCESS_ORIGINAL_OPTION, []); $refresh = !is_array($original) || !isset($original['content'], $original['written']) || md5($content) !== $original['written']; self::save_htaccess_blocks($content, $refresh); self::remember_htaccess_original($content, $new_content, $htaccess_file); if (self::write_file($htaccess_file, $new_content)) { self::clear_cache_warning('htaccess_strip'); } else { self::set_cache_warning('htaccess_strip', self::$write_error, $htaccess_file); } } /** * Le contenu contient des regles de cache que la maintenance * retire (memes blocs que htaccess_block_patterns()). * * @param string $content Contenu du .htaccess. * @return bool */ private static function has_cache_rules($content) { return (bool) preg_match('/# BEGIN (?:LBC|Gzip)?WpFastestCache|# BEGIN WPSuperCache|# BEGIN W3TC Page Cache core/', (string) $content); } /** * Vide le cache de pages de W3 Total Cache (Disk: Enhanced). * Sa purge renomme les pages en « _old » sans les supprimer. Seuls les * sous-dossiers sont vides : le .htaccess propre a W3TC reste en place. */ private static function purge_w3tc_page_cache() { $dirs = glob(WP_CONTENT_DIR . '/cache/page_enhanced/*', GLOB_ONLYDIR); if (!is_array($dirs) || empty($dirs)) { return; } foreach ($dirs as $dir) { if (is_string($dir) && 0 === strpos($dir, WP_CONTENT_DIR . '/cache/page_enhanced/')) { self::delete_directory_contents($dir . '/'); } } } /** * Conserve la copie integrale du .htaccess d'origine (la * premiere capture fait foi) et l'empreinte du contenu ecrit par la * maintenance. A la sortie, si le fichier n'a pas change entre-temps, il * est remis octet pour octet ; sinon, les blocs sont reinseres. * * Si le fichier a change depuis la derniere ecriture du plugin * (autre extension, plugin de cache qui remet ses regles, session * precedente non restauree), c'est lui l'original a jour : une ancienne * copie ne doit plus effacer ces changements a la sortie. * * @param string $original Contenu lu avant modification. * @param string $written Contenu qui va etre ecrit. * @param string $file Fichier concerne. */ private static function remember_htaccess_original($original, $written, $file) { $saved = get_option(self::HTACCESS_ORIGINAL_OPTION, []); if (!is_array($saved) || !isset($saved['content'], $saved['written']) || md5((string) $original) !== $saved['written']) { $saved = ['content' => (string) $original]; } $saved['written'] = md5((string) $written); $saved['file'] = (string) $file; update_option(self::HTACCESS_ORIGINAL_OPTION, $saved, false); } /** * Expressions des blocs .htaccess que la maintenance retire. */ public static function htaccess_block_patterns() { return [ '/# BEGIN WpFastestCache.*?# END WpFastestCache\s*/s', '/# BEGIN LBCWpFastestCache.*?# END LBCWpFastestCache\s*/s', '/# BEGIN GzipWpFastestCache.*?# END GzipWpFastestCache\s*/s', '/# BEGIN WPSuperCache.*?# END WPSuperCache\s*/s', // W3 Total Cache (Disk: Enhanced) : ces regles font // servir les pages en cache par Apache, sans passer par WordPress. '/# BEGIN W3TC Page Cache core.*?# END W3TC Page Cache core\s*/s', ]; } /** * Memorise les blocs avant de les retirer. * * Un bloc trouve dans le fichier est authentique : sa version * la plus recente est gardee. $reset repart de zero quand le fichier a * change depuis la derniere ecriture du plugin. * * @param string $content Contenu actuel du .htaccess. * @param bool $reset Oublier les blocs memorises auparavant. */ private static function save_htaccess_blocks($content, $reset = false) { $saved = $reset ? [] : get_option(self::HTACCESS_OPTION, []); if (!is_array($saved)) { $saved = []; } $before = $saved; foreach (self::htaccess_block_patterns() as $pattern) { if (preg_match($pattern, $content, $m)) { $marker = strtok(trim($m[0]), "\n"); $saved[$marker] = rtrim($m[0]) . "\n"; } } if ($saved !== $before || $reset) { update_option(self::HTACCESS_OPTION, $saved, false); } } /** * Remet en tete du .htaccess les blocs retires pendant la * maintenance, dans leur ordre d'origine, s'ils n'y sont pas deja (le * plugin de cache a pu les reecrire lui-meme entre-temps). * * Sauvegardes supprimees seulement si l'ecriture a reussi ; * sinon elles sont gardees et l'administration le signale. Un fichier lu * vide (ecriture en cours d'un autre processus) n'est jamais traite comme * « modifie ». * * @return bool */ public static function restore_htaccess_blocks() { $saved = get_option(self::HTACCESS_OPTION, []); $original = get_option(self::HTACCESS_ORIGINAL_OPTION, []); $saved = is_array($saved) ? $saved : []; $original = is_array($original) ? $original : []; if (empty($saved) && empty($original)) { delete_option(self::HTACCESS_OPTION); delete_option(self::HTACCESS_ORIGINAL_OPTION); self::clear_cache_warning('htaccess_restore'); return true; } // Chemin enregistre a l'entree : accepte seulement s'il designe un // fichier .htaccess (jamais un autre fichier, meme si l'option a ete // modifiee entre-temps). $htaccess_file = (isset($original['file']) && is_string($original['file']) && '.htaccess' === basename($original['file'])) ? $original['file'] : self::htaccess_file(); $fs = self::init_filesystem(); $exists = file_exists($htaccess_file); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Test de droits, aucune ecriture. $writable = $exists && is_writable($htaccess_file); $reason = !$fs ? 'direct' : (!$exists ? 'missing' : ($writable ? '' : 'readonly')); $content = ('' === $reason) ? $fs->get_contents($htaccess_file) : false; if ('' === $reason && !is_string($content)) { $reason = 'readonly'; } if ('' !== $reason) { self::set_cache_warning('htaccess_restore', $reason, $htaccess_file); return false; } // Cas normal : personne n'a touche au fichier depuis que la // maintenance l'a modifie. On remet la copie d'origine, a l'identique // (ordre des blocs, lignes vides, horodatage compris). // Teste en premier : un fichier que la maintenance a vide // (il ne contenait que des blocs de cache) est aussi remis. if (isset($original['content'], $original['written']) && md5($content) === $original['written']) { if (!self::write_file($htaccess_file, $original['content'])) { self::set_cache_warning('htaccess_restore', self::$write_error, $htaccess_file); return false; } } else { // Lu vide alors qu'il ne devait pas l'etre (ecriture en cours d'un // autre processus) : on n'y touche pas. if ('' === trim($content, " \t\n\r\0\x0B") && !empty($original['content'])) { self::set_cache_warning('htaccess_restore', 'empty', $htaccess_file); return false; } // Le fichier a change pendant la maintenance (autre plugin, edition // manuelle) : on ne l'ecrase pas, on reinsere seulement les blocs // manquants. $prefix = ''; foreach ($saved as $marker => $block) { if (is_string($block) && strpos($content, (string) $marker) === false) { $prefix .= $block . "\n"; } } if ($prefix !== '' && !self::write_file($htaccess_file, $prefix . $content)) { self::set_cache_warning('htaccess_restore', self::$write_error, $htaccess_file); return false; } } delete_option(self::HTACCESS_OPTION); delete_option(self::HTACCESS_ORIGINAL_OPTION); self::clear_cache_warning('htaccess_restore'); return true; } /** * Desactivation du plugin pendant une maintenance : les plugins * de cache et le .htaccess retrouvent leur etat. Le site revient en ligne * pour de bon : maintenance_enabled * passe a false et la maintenance ne reprend pas a la reactivation. */ public static function on_deactivation() { // Sous le verrou de transition (une sortie a la fois). // Verrou occupe : la remise des caches se fait quand meme (l'extension // s'en va) ; l'etat ON restant est remis a OFF a la reactivation. $locked = self::acquire_lock('transition', 20); // Desactiver le plugin remet le site en ligne pour de bon : // a la reactivation, la maintenance ne reprend pas d'elle-meme. $settings = MGENIUS_Settings::load_fresh(); if ($locked && is_array($settings) && !empty($settings['maintenance_enabled'])) { $settings['maintenance_enabled'] = false; $settings['schedule_auto_on'] = false; MGENIUS_Settings::save($settings); } self::restore_cache_plugins(); self::restore_htaccess_blocks(); self::clear_maintenance_warnings(); self::clear_cache_warning('filesystem'); delete_transient('mgenius_init_purged'); delete_transient('mgenius_guard_purged'); delete_transient('mgenius_guard_dirs_purged'); wp_clear_scheduled_hook('mgenius_post_exit_check'); if ($locked) { self::release_lock('transition'); } } /** * Supprime récursivement le contenu d'un dossier */ private static function delete_directory_contents($dir) { if (!is_dir($dir) || is_link(rtrim($dir, '/'))) return; // Un sous-dossier supprime par une requete concurrente, ou // illisible, levait une UnexpectedValueException fatale (activation et // visites en erreur 500). Un lien symbolique est supprime lui-meme : // getRealPath() supprimait sa cible, meme hors du site. Les dossiers // vides sont retires par WP_Filesystem (acces direct seulement). $fs = null; try { $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST, \RecursiveIteratorIterator::CATCH_GET_CHILD ); foreach ($files as $file) { $path = $file->getPathname(); if ($file->isDir() && !$file->isLink()) { if (null === $fs) { $fs = self::init_filesystem(); } if ($fs) { $fs->rmdir($path); } } else { wp_delete_file($path); } } } catch (\Exception $e) { self::debug_log('Cache folder skipped: ' . $e->getMessage()); } } /** * Getter pour les settings (utile pour d'autres classes) */ public static function get_settings() { if (self::$settings === null) { self::$settings = MGENIUS_Settings::load(); } return self::$settings; } }