0) { $permalink = get_permalink($shop); if ($permalink) { $urls[] = $permalink; } } } // Les pages clés du site : celles du menu principal (thème // classique), sinon les pages publiées dans l'ordre du site (thème à // blocs, sans emplacement de menu). Liens internes seulement. $urls = array_merge($urls, self::key_page_urls()); /** * Filtre la liste des URLs vérifiées avant et après une maintenance. * * @param string[] $urls Liste d'URLs absolues. */ $urls = apply_filters('mgenius_healthcheck_urls', $urls); // Doublons : « /contact » et « /contact/ » désignent la même page. $unique = []; foreach (array_filter((array) $urls, 'is_string') as $url) { $key = untrailingslashit(strtolower($url)); if (!isset($unique[$key])) { $unique[$key] = $url; } } $urls = array_values($unique); return array_slice($urls, 0, self::MAX_URLS); } /** * Interroge une URL et retourne son état observé. * * @param string $url * @return array{code:int,time:float,size:int,error:string} */ protected static function probe($url) { $start = microtime(true); $response = wp_remote_get($url, [ 'timeout' => self::TIMEOUT, 'redirection' => 2, // Meme regle que la verification visiteur et que les // requetes de bouclage de WordPress : un certificat local // auto-signe ne doit pas rendre la comparaison impossible. 'sslverify' => apply_filters('https_local_ssl_verify', false), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Crochet du coeur de WordPress pour ses requetes de bouclage. 'headers' => ['Cache-Control' => 'no-cache'], 'user-agent' => 'MaintenanceGENIUS-Healthcheck/' . MGENIUS_VERSION, ]); $elapsed = round(microtime(true) - $start, 3); if (is_wp_error($response)) { return [ 'code' => 0, 'time' => $elapsed, 'size' => 0, 'error' => $response->get_error_message(), ]; } $body = wp_remote_retrieve_body($response); return [ 'code' => (int) wp_remote_retrieve_response_code($response), 'time' => $elapsed, 'size' => strlen($body), 'error' => '', ]; } /** * Pages clés : liens internes du menu principal, ou à défaut * les pages publiées (ordre du site). * * @return string[] */ protected static function key_page_urls() { $urls = []; $host = wp_parse_url(home_url('/'), PHP_URL_HOST); $locations = get_nav_menu_locations(); $menu_id = 0; if (!empty($locations)) { foreach (['primary', 'main', 'main-menu', 'menu-1', 'header', 'header-menu'] as $slot) { if (!empty($locations[$slot])) { $menu_id = (int) $locations[$slot]; break; } } if (!$menu_id) { $menu_id = (int) reset($locations); } } if ($menu_id) { $items = wp_get_nav_menu_items($menu_id); if (is_array($items)) { foreach ($items as $item) { if (!empty($item->menu_item_parent) || empty($item->url)) { continue; } if (wp_parse_url($item->url, PHP_URL_HOST) !== $host) { continue; } $urls[] = $item->url; } } } if (empty($urls)) { $pages = get_pages([ 'sort_column' => 'menu_order,post_title', 'post_status' => 'publish', 'parent' => 0, 'number' => self::MAX_URLS, ]); if (is_array($pages)) { foreach ($pages as $page) { $permalink = get_permalink($page); if ($permalink) { $urls[] = $permalink; } } } } return $urls; } /** * Ce que reçoit réellement un visiteur anonyme. * * Appelée juste après l'activation de la maintenance : le serveur demande * la page d'accueil publique, sans cookie, par la même route qu'un * visiteur (DNS, proxy, CDN compris). On ne se fie pas à l'écran * d'administration : on vérifie la page servie, le code HTTP, l'en-tête * Retry-After et l'interdiction de mise en cache. * * @return array{status:string,code:int,checks:array,message:string,error_page?:bool} */ public static function check_visitor_view() { $settings = MGENIUS_Settings::load_fresh(); $coming_soon = (isset($settings['page_mode']) && 'coming_soon' === $settings['page_mode']); $expects_503 = !$coming_soon && !empty($settings['seo_503_enabled']); $response = wp_remote_get(home_url('/'), [ 'timeout' => self::TIMEOUT, 'redirection' => 2, // Requête du site vers lui-même : même règle que WordPress pour // ses propres requêtes de bouclage (filtre https_local_ssl_verify). 'sslverify' => apply_filters('https_local_ssl_verify', false), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Crochet du coeur de WordPress pour ses requetes de bouclage. 'cookies' => [], 'user-agent' => 'MaintenanceGENIUS-VisitorCheck/' . MGENIUS_VERSION, ]); if (is_wp_error($response)) { return [ 'status' => 'unknown', 'code' => 0, 'checks' => [], 'message' => sprintf( /* translators: %s: technical error message */ __('The visitor check could not reach your site from the server (%s). Open your site in a private window to confirm what visitors see.', '1001web-maintenance-launch-pages'), $response->get_error_message() ), ]; } $code = (int) wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $retry_after = (string) wp_remote_retrieve_header($response, 'retry-after'); $cache_control = strtolower((string) wp_remote_retrieve_header($response, 'cache-control')); $is_page = (false !== strpos($body, 'maintenance-container')); $checks = []; $checks[] = [ 'label' => __('Maintenance page served', '1001web-maintenance-launch-pages'), 'ok' => $is_page, ]; $expects_retry = $expects_503 && !empty($settings['retry_after_hours']); if ($expects_503) { $checks[] = [ /* translators: %d: HTTP status code actually received */ 'label' => sprintf(__('HTTP 503 status (received: %d)', '1001web-maintenance-launch-pages'), $code), 'ok' => (503 === $code), ]; } if ($expects_retry) { $checks[] = [ 'label' => __('Retry-After header', '1001web-maintenance-launch-pages'), 'ok' => ('' !== $retry_after), ]; } $checks[] = [ 'label' => __('Not cacheable (Cache-Control: no-store or no-cache)', '1001web-maintenance-launch-pages'), 'ok' => (false !== strpos($cache_control, 'no-store') || false !== strpos($cache_control, 'no-cache')), ]; // Une réponse qui ne vient pas de ce WordPress (défi de // sécurité d'un CDN ou d'un pare-feu, page d'un proxy) ne prouve pas // que les visiteurs voient le site normal : on ne conclut pas. $link_header = strtolower((string) wp_remote_retrieve_header($response, 'link')); $from_this_wp = (false !== strpos($link_header, 'api.w.org')) || (false !== strpos($body, '/wp-content/')) || (false !== strpos($body, '/wp-includes/')); // Page d'erreur de WordPress (erreur fatale, wp_die()) : elle vient bien // de ce site, et les visiteurs ne voient pas la page de maintenance. $is_error_page = !$is_page && (false !== strpos($body, 'wp-die-message')); if ($is_error_page) { $status = 'fail'; /* translators: %d: HTTP status code received */ $message = sprintf(__('Visitors receive a WordPress error page (HTTP %d) instead of the maintenance page: a PHP error is stopping your site.', '1001web-maintenance-launch-pages'), $code); } elseif (!$is_page && !$from_this_wp) { $status = 'unknown'; /* translators: %d: HTTP status code received */ $message = sprintf(__('Your server received a response that does not come from this site (HTTP %d), for example a security check from a CDN or a firewall. Open your site in a private window to confirm what visitors see.', '1001web-maintenance-launch-pages'), $code); } elseif (!$is_page) { $status = 'fail'; $message = __('Visitors are NOT receiving the maintenance page. A cache layer (plugin, server or CDN) or an access rule that matches your server is serving the normal site.', '1001web-maintenance-launch-pages'); } else { $status = 'ok'; foreach ($checks as $check) { if (!$check['ok']) { $status = 'warn'; break; } } $message = ('ok' === $status) ? __('Checked from your server as an anonymous visitor: the maintenance page is what visitors receive.', '1001web-maintenance-launch-pages') : __('Visitors receive the maintenance page, but some headers are not as expected. See the details below.', '1001web-maintenance-launch-pages'); } return [ 'status' => $status, 'code' => $code, 'checks' => $checks, 'message' => $message, 'error_page' => $is_error_page, ]; } /** * Relève l'état du site AVANT activation de la maintenance. * * À appeler pendant que le site est encore servi normalement : c'est ce * relevé qui sert de référence. Sans lui, aucune comparaison n'est possible * et la vérification de sortie s'annonce simplement indisponible. * * @return bool True si au moins une URL a répondu. */ public static function capture_baseline() { $baseline = [ 'captured_at' => time(), 'urls' => [], ]; $urls = self::get_urls(); $answered = false; $started = microtime(true); foreach ($urls as $index => $url) { $state = self::probe($url); $baseline['urls'][$url] = $state; // Aucune réponse : les autres URLs échoueraient pour la // même raison. On garde la cause pour l'expliquer à l'admin. if (0 === $state['code']) { $baseline['error'] = $state['error']; // Seulement si aucune page n'a encore répondu : une page lente, // une boucle de redirections ou une adresse invalide plus loin // dans la liste n'empêche pas d'enregistrer les suivantes. if (!$answered) { break; } } else { $answered = true; } if ((microtime(true) - $started) > self::BUDGET) { // Pages restantes : non enregistrées, signalées à la sortie. $skipped = array_slice($urls, $index + 1); if (!empty($skipped)) { $baseline['skipped'] = $skipped; } break; } } $usable = false; foreach ($baseline['urls'] as $state) { if ($state['code'] >= 200 && $state['code'] < 400) { $usable = true; break; } } $baseline['usable'] = $usable; self::write_option(self::BASELINE_OPTION, $baseline); self::write_option(self::RESULT_OPTION, []); return $usable; } /** * Remise en maintenance après une casse (« Put the site back in * maintenance ») : la référence prise quand le site était sain est gardée. * Un nouveau relevé enregistrerait les pages cassées comme l'état normal, * et la sortie suivante ne les signalerait plus. * * @return bool False s'il n'y a pas de référence utilisable (il faut alors * faire un nouveau relevé). */ public static function keep_baseline() { $baseline = get_option(self::BASELINE_OPTION, []); if (!is_array($baseline) || empty($baseline['usable']) || empty($baseline['urls'])) { return false; } self::write_option(self::RESULT_OPTION, []); return true; } /** * Rejoue le relevé après désactivation et compare à la référence. * * @return array Résultat structuré, prêt pour l'affichage. */ public static function verify() { $baseline = get_option(self::BASELINE_OPTION, []); if (empty($baseline['urls'])) { $result = [ 'status' => 'unavailable', 'message' => __('No reference snapshot was taken before this maintenance, so no comparison is possible.', '1001web-maintenance-launch-pages'), 'checks' => [], 'verified_at' => time(), ]; self::write_option(self::RESULT_OPTION, $result); return $result; } // Un relevé a été tenté mais le serveur n'a pas pu joindre // le site : on le dit, avec la cause, au lieu de se taire. if (empty($baseline['usable'])) { $result = [ 'status' => 'unverified', 'message' => !empty($baseline['error']) /* translators: %s: technical error message */ ? sprintf(__('When maintenance started, your server could not reach your site to record the key pages (%s). No comparison is possible: check your key pages in a private window.', '1001web-maintenance-launch-pages'), $baseline['error']) : __('When maintenance started, none of the key pages answered normally, so no comparison is possible. Check your key pages in a private window.', '1001web-maintenance-launch-pages'), 'checks' => [], 'verified_at' => time(), ]; self::write_option(self::RESULT_OPTION, $result); return $result; } $checks = []; $not_checked = []; $regression = false; $warning = false; $unverified = false; $verified = 0; $started = microtime(true); foreach ($baseline['urls'] as $url => $before) { // Page sans réponse au moment du relevé : rien à comparer, et le // rapport le dit au lieu de l'omettre. if (0 === (int) $before['code']) { $not_checked[$url] = __('Not checked: this page did not answer when maintenance started.', '1001web-maintenance-launch-pages'); continue; } // Une URL qui était déjà cassée avant n'est pas une régression. if ($before['code'] < 200 || $before['code'] >= 400) { continue; } // Le serveur ne joint plus le site, ou le temps est écoulé : les // pages restantes ne sont pas comparées, et elles sont listées. if ($unverified) { $not_checked[$url] = __('Not checked: your server could not reach your site.', '1001web-maintenance-launch-pages'); continue; } if ((microtime(true) - $started) > self::BUDGET) { $not_checked[$url] = __('Not checked: the time limit for the comparison was reached.', '1001web-maintenance-launch-pages'); continue; } $after = self::probe($url); $status = 'ok'; $note = ''; if ($after['code'] === 0 && 0 === $verified && empty($checks)) { // Première page sans réponse alors qu'elle répondait // avant : le serveur ne joint plus le site (bouclage), ce n'est // pas la preuve d'une casse. Les autres URLs ne sont pas sondées. $status = 'unknown'; $unverified = true; /* translators: %s: error message returned by the HTTP request */ $note = sprintf(__('Your server could not reach this page: %s', '1001web-maintenance-launch-pages'), $after['error']); } elseif (0 === $after['code'] && $after['time'] >= self::TIMEOUT - 0.1) { // Pas de réponse dans le délai : la page peut être seulement // lente (cache vide juste après la maintenance). Signalée, mais // ce n'est pas la preuve d'une casse. $status = 'warn'; $warning = true; /* translators: %d: number of seconds the check waited */ $note = sprintf(__('No answer within %d seconds: the page may only be slow. Check it in a private window.', '1001web-maintenance-launch-pages'), self::TIMEOUT); } elseif ($after['code'] === 0) { // Le site répond (une page précédente a répondu) : une page qui // ne répond plus est une régression, par exemple un blocage. $status = 'fail'; $regression = true; /* translators: %s: error message returned by the HTTP request */ $note = sprintf(__('No response: %s', '1001web-maintenance-launch-pages'), $after['error']); } elseif ($after['code'] >= 500) { $status = 'fail'; $regression = true; /* translators: 1: HTTP status code observed now, 2: HTTP status code observed before */ $note = sprintf(__('Returns %1$d, was %2$d before maintenance.', '1001web-maintenance-launch-pages'), $after['code'], $before['code']); } elseif ($after['code'] >= 400) { $status = 'fail'; $regression = true; /* translators: 1: HTTP status code observed now, 2: HTTP status code observed before */ $note = sprintf(__('Returns %1$d, was %2$d before maintenance.', '1001web-maintenance-launch-pages'), $after['code'], $before['code']); } elseif ($before['size'] > 0 && $after['size'] < ($before['size'] * 0.5)) { // Une page qui perd la moitié de son contenu en répondant 200 // est le symptôme classique d'un gabarit cassé. $status = 'warn'; $warning = true; $note = __('Page is much shorter than before — a template may be broken.', '1001web-maintenance-launch-pages'); } // Le temps de réponse ne déclenche plus d'alerte. // // La première requête après une sortie de maintenance frappe un // cache vide : le plugin de cache n'a encore rien reconstruit et // l'OPcache est froid. On comparait donc le pire moment possible // au meilleur, et l'écart dépassait le seuil à chaque sortie. // Observé en production : 0,061 s avant, 0,505 s après, sur un site // parfaitement sain. // // Une alerte qui se déclenche à chaque exécution cesse d'être lue, // et elle interdit surtout tout rollback automatique : la décision // remettrait le site en maintenance à chaque sortie, en boucle. // // Un ralentissement reste une information utile : elle est affichée // dans le rapport, mais ne conditionne plus le verdict, qui repose // désormais sur ce qui prouve réellement une casse — un code HTTP // anormal ou une page qui a perdu la moitié de son contenu. if ('ok' === $status && $before['time'] > 0 && $after['time'] > ($before['time'] * self::SLOWDOWN_FACTOR)) { /* translators: 1: current response time in seconds, 2: reference response time in seconds */ $note = sprintf(__('Slower than before: %1$ss versus %2$ss. A cold cache after maintenance is normal.', '1001web-maintenance-launch-pages'), $after['time'], $before['time']); } if ('unknown' !== $status) { $verified++; } $checks[] = [ 'url' => $url, 'status' => $status, 'note' => $note, 'before' => $before, 'after' => $after, ]; } // Pages que le relevé n'a pas eu le temps d'enregistrer. if (!empty($baseline['skipped']) && is_array($baseline['skipped'])) { foreach ($baseline['skipped'] as $url) { if (is_string($url) && !isset($not_checked[$url])) { $not_checked[$url] = __('Not checked: there was no time to record this page when maintenance started.', '1001web-maintenance-launch-pages'); } } } $no_answer = ['code' => 0, 'time' => 0, 'size' => 0, 'error' => '']; foreach ($not_checked as $url => $note) { $checks[] = [ 'url' => $url, 'status' => 'unknown', 'note' => $note, 'before' => isset($baseline['urls'][$url]) ? $baseline['urls'][$url] : $no_answer, 'after' => $no_answer, ]; } if ($unverified && !$regression && !$warning && 0 === $verified) { $result = [ 'status' => 'unverified', 'message' => __('Your site is back online, but your server could not reach it to compare the key pages. Check them in a private window.', '1001web-maintenance-launch-pages'), 'checks' => $checks, 'verified_at' => time(), ]; self::write_option(self::RESULT_OPTION, $result); return $result; } if ($unverified) { $warning = true; } if ($regression) { $status = 'fail'; $message = __('Your site is back online, but something broke during maintenance.', '1001web-maintenance-launch-pages'); } elseif ($warning) { $status = 'warn'; $message = __('Your site is back online. A few things look different from before.', '1001web-maintenance-launch-pages'); } elseif (!empty($not_checked)) { // Comparaison incomplète : jamais « ok ». $status = 'warn'; $message = sprintf( /* translators: %d: number of key pages that could not be compared */ _n('Your site is back online, but %d key page could not be checked. Check it in a private window.', 'Your site is back online, but %d key pages could not be checked. Check them in a private window.', count($not_checked), '1001web-maintenance-launch-pages'), count($not_checked) ); } else { $status = 'ok'; $message = __('Your site is back online. No key page returns an error, and none lost more than half of its content.', '1001web-maintenance-launch-pages'); } $result = [ 'status' => $status, 'message' => $message, 'checks' => $checks, 'verified_at' => time(), ]; self::write_option(self::RESULT_OPTION, $result); return $result; } /** * Résumé du relevé de référence en cours, pour l'affichage * pendant la maintenance (carte de l'onglet Général). * * @return array{usable:bool,count:int,message:string} */ public static function baseline_summary() { $baseline = get_option(self::BASELINE_OPTION, []); if (empty($baseline['urls']) || !is_array($baseline['urls'])) { return ['usable' => false, 'count' => 0, 'message' => '']; } $count = 0; foreach ($baseline['urls'] as $state) { if (isset($state['code']) && $state['code'] >= 200 && $state['code'] < 400) { $count++; } } if ($count > 0) { $message = sprintf( /* translators: %d: number of pages recorded */ _n('Maintenance is on. %d key page was recorded and will be compared when you switch maintenance off.', 'Maintenance is on. %d key pages were recorded and will be compared when you switch maintenance off.', $count, '1001web-maintenance-launch-pages'), $count ); } else { $message = !empty($baseline['error']) /* translators: %s: technical error message */ ? sprintf(__('Maintenance is on, but your server could not reach your site to record the key pages (%s). Check them in a private window after your update.', '1001web-maintenance-launch-pages'), $baseline['error']) : __('Maintenance is on, but none of the key pages answered normally when it started. Check them in a private window after your update.', '1001web-maintenance-launch-pages'); } return ['usable' => $count > 0, 'count' => $count, 'message' => $message]; } /** * Comparaison lancée en tâche de fond (fin de compte à rebours * ou de plage planifiée) : rien ne ralentit le visiteur qui la déclenche. */ public static function run_scheduled_verify() { // Maintenance rallumée entre-temps : toutes les pages répondraient 503. $settings = MGENIUS_Settings::load_fresh(); if (!empty($settings['maintenance_enabled'])) { return; } self::verify(); } /** * Dernier résultat connu, pour l'affichage admin. * * @return array */ public static function get_last_result() { $result = get_option(self::RESULT_OPTION, []); return is_array($result) ? $result : []; } /** * Écriture Redis-safe : $wpdb direct + invalidation du cache d'objets. * * update_option() peut être servi depuis un cache d'objets persistant et * ne jamais atteindre la base — motif éprouvé sur les installs Redis. * * @param string $key * @param mixed $value * @return void */ protected static function write_option($key, $value) { global $wpdb; // v2.9.181 — langue des messages enregistrés (voir health-card.php). if (self::RESULT_OPTION === $key && is_array($value) && !empty($value)) { $value['locale'] = get_user_locale(); } $serialized = maybe_serialize($value); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Redis-safe write: update_option() can be served from a persistent object cache and never reach the database. Cache is invalidated explicitly right after. $exists = $wpdb->get_var($wpdb->prepare( "SELECT option_id FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $key )); if ($exists) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Redis-safe write: update_option() can be served from a persistent object cache and never reach the database. Cache is invalidated explicitly right after. $wpdb->update( $wpdb->options, ['option_value' => $serialized], ['option_name' => $key], ['%s'], ['%s'] ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Redis-safe write: update_option() can be served from a persistent object cache and never reach the database. Cache is invalidated explicitly right after. $wpdb->insert( $wpdb->options, [ 'option_name' => $key, 'option_value' => $serialized, 'autoload' => 'no', ], ['%s', '%s', '%s'] ); } wp_cache_delete($key, 'options'); wp_cache_delete('alloptions', 'options'); } }