Branch => Y-m-d. */ public static function php_support_ends() { /** * Filters the end of security support of PHP branches. * * @param array $dates Branch (e.g. "8.3") => date (Y-m-d). */ return apply_filters( 'maintained_log_php_support_ends', array( '7.4' => '2022-11-28', '8.0' => '2023-11-26', '8.1' => '2025-12-31', '8.2' => '2026-12-31', '8.3' => '2027-12-31', '8.4' => '2028-12-31', '8.5' => '2029-12-31', ) ); } /** * Why each item matters, in plain words for the client. * * @return array Item key => explanation. */ public static function reasons() { /** * Filters the plain-language explanation of why each technical item matters. * * @param array $reasons Item key (https, php, wp, health) => sentence. */ return apply_filters( 'maintained_log_tech_reasons', array( 'https' => __( 'Why it matters: it protects what visitors send through the site (forms, passwords, orders), and without it browsers show the site as "Not secure" and Google ranks it lower.', 'maintained-log' ), 'php' => __( 'Why it matters: PHP is the language the website runs on. A version that still receives security updates protects the server against known attacks and keeps plugins compatible.', 'maintained-log' ), 'wp' => __( 'Why it matters: each WordPress version fixes security problems and errors found in the previous ones, so staying on the latest one closes known doors.', 'maintained-log' ), 'health' => __( 'Why it matters: this WordPress tool reviews the server configuration (security, performance and compatibility). No critical issues means the base of the site is solid.', 'maintained-log' ), ) ); } /** * Manual notes explaining items that are not green, by item key. * * @return array */ public static function notes() { $notes = get_option( self::NOTES_OPTION, array() ); return is_array( $notes ) ? $notes : array(); } /** * Saves, changes or removes (empty text) the note of an item, and logs it. * * @param string $key Item key. * @param string $text Note text. * @return bool Whether anything changed. */ public static function save_note( $key, $text ) { $notes = self::notes(); $text = trim( sanitize_textarea_field( $text ) ); $old = isset( $notes[ $key ]['text'] ) ? $notes[ $key ]['text'] : ''; if ( $old === $text ) { return false; } $items = self::get()['items']; $label = isset( $items[ $key ]['label'] ) ? $items[ $key ]['label'] : $key; if ( '' === $text ) { unset( $notes[ $key ] ); /* translators: %s: item name. */ $title = sprintf( __( 'Technical status note removed: %s', 'maintained-log' ), $label ); } else { $notes[ $key ] = array( 'text' => $text, 'updated_at' => current_time( 'mysql', true ), 'user_id' => get_current_user_id(), ); /* translators: %s: item name. */ $title = '' === $old ? sprintf( __( 'Technical status note added: %s', 'maintained-log' ), $label ) : sprintf( __( 'Technical status note changed: %s', 'maintained-log' ), $label ); } update_option( self::NOTES_OPTION, $notes, false ); Log::add( array( 'type' => Log::TYPE_SYSTEM, 'category' => 'technical', 'title' => $title, 'details' => array( 'notes' => $text ), 'source' => Log::SOURCE_MANUAL, ) ); return true; } /** * Stored status, refreshed if missing. * * @return array{checked_at:string,items:array} */ public static function get() { $stored = get_option( self::OPTION ); if ( ! is_array( $stored ) || ! isset( $stored['items'] ) ) { return self::refresh(); } // The PHP and WordPress versions cost nothing to check, so they are never // shown out of date (e.g. right after an update); the certificate and // Site Health keep their twice-daily schedule. $items = $stored['items']; foreach ( array( 'php' => self::check_php(), 'wp' => self::check_wordpress(), ) as $key => $item ) { if ( $item ) { $items[ $key ] = isset( $items[ $key ]['issues'] ) ? $item + array( 'issues' => $items[ $key ]['issues'] ) : $item; } } if ( $items === $stored['items'] ) { return $stored; } return self::save( $items, $stored ); } /** * Whether any item needs attention. * * @return bool */ public static function has_problems() { foreach ( self::get()['items'] as $item ) { if ( self::OK !== $item['status'] ) { return true; } } return false; } /** * Recomputes every item, logs changes and alerts the administrator. * * @return array{checked_at:string,items:array} */ public static function refresh() { $previous = get_option( self::OPTION ); $items = array_filter( array( 'https' => self::check_https(), 'php' => self::check_php(), 'wp' => self::check_wordpress(), 'health' => self::check_site_health(), ) ); return self::save( $items, $previous ); } /** * Stores the items, logs the ones whose status changed and alerts about new problems. * * @param array $items Items by key. * @param array|bool $previous Previously stored status, if any. * @return array{checked_at:string,items:array} */ private static function save( array $items, $previous ) { $status = array( 'checked_at' => current_time( 'mysql', true ), 'items' => $items, ); update_option( self::OPTION, $status, false ); $before = is_array( $previous ) && isset( $previous['items'] ) ? wp_list_pluck( $previous['items'], 'status' ) : null; $changes = array(); foreach ( $items as $key => $item ) { if ( null === $before || ! isset( $before[ $key ] ) || $before[ $key ] !== $item['status'] ) { $changes[ $key ] = $item; } } if ( $changes ) { $worst = self::worst( $items ); Log::add( array( 'type' => Log::TYPE_CHECK, 'category' => 'technical', 'status' => self::OK === $worst ? Log::STATUS_SUCCESS : ( self::ERROR === $worst ? Log::STATUS_ERROR : Log::STATUS_WARNING ), 'title' => null === $before ? __( 'Technical status recorded', 'maintained-log' ) /* translators: %s: comma-separated item names. */ : sprintf( __( 'Technical status changed: %s', 'maintained-log' ), implode( ', ', wp_list_pluck( $changes, 'label' ) ) ), 'details' => array( 'items' => $items ), 'source' => Log::SOURCE_SYSTEM, 'user_id' => 0, ) ); $new_problems = array_filter( $changes, static function ( $item ) { return self::OK !== $item['status']; } ); if ( $new_problems && Settings::get( 'tech_alert' ) ) { Alerts::send_tech_problems( $new_problems ); } } return $status; } /** * Worst status among items. * * @param array $items Items. * @return string */ public static function worst( array $items ) { $statuses = wp_list_pluck( $items, 'status' ); if ( in_array( self::ERROR, $statuses, true ) ) { return self::ERROR; } return in_array( self::WARNING, $statuses, true ) ? self::WARNING : self::OK; } /** * HTTPS and certificate expiry. * * @return array */ private static function check_https() { $label = __( 'Security certificate (HTTPS)', 'maintained-log' ); $home = home_url( '/' ); if ( 'https' !== wp_parse_url( $home, PHP_URL_SCHEME ) ) { return self::item( $label, self::ERROR, __( 'The site is not served over HTTPS.', 'maintained-log' ), '' ); } $expires = self::certificate_expiry( (string) wp_parse_url( $home, PHP_URL_HOST ), (int) ( wp_parse_url( $home, PHP_URL_PORT ) ? wp_parse_url( $home, PHP_URL_PORT ) : 443 ) ); if ( ! $expires ) { // The site uses HTTPS but the certificate could not be read from this server: report HTTPS only. return self::item( $label, self::OK, __( 'The site uses HTTPS. The certificate expiry date could not be read from the server.', 'maintained-log' ), __( 'The site uses a secure connection (HTTPS).', 'maintained-log' ) ); } $days = (int) floor( ( $expires - time() ) / DAY_IN_SECONDS ); $date = wp_date( get_option( 'date_format' ), $expires ); if ( $days < 0 ) { /* translators: %s: date. */ return self::item( $label, self::ERROR, sprintf( __( 'The certificate expired on %s.', 'maintained-log' ), $date ), '' ); } if ( $days < 14 ) { /* translators: 1: number of days, 2: date. */ return self::item( $label, self::WARNING, sprintf( _n( 'The certificate expires in %1$d day (%2$s). Check that it renews automatically.', 'The certificate expires in %1$d days (%2$s). Check that it renews automatically.', $days, 'maintained-log' ), $days, $date ), '' ); } return self::item( $label, self::OK, /* translators: %s: date. */ sprintf( __( 'Valid until %s.', 'maintained-log' ), $date ), /* translators: %s: date. */ sprintf( __( 'The site uses a secure connection (HTTPS). The certificate is valid until %s.', 'maintained-log' ), $date ) ); } /** * PHP version support. * * @return array|null */ private static function check_php() { $label = __( 'PHP version', 'maintained-log' ); $branch = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; $ends = self::php_support_ends(); if ( ! isset( $ends[ $branch ] ) ) { return null; } $end = strtotime( $ends[ $branch ] . ' 23:59:59 UTC' ); $date = wp_date( 'F Y', $end ); if ( $end < time() ) { /* translators: 1: PHP version, 2: month and year. */ return self::item( $label, self::WARNING, sprintf( __( 'PHP %1$s stopped receiving security updates in %2$s. Ask the hosting provider to upgrade it.', 'maintained-log' ), PHP_VERSION, $date ), '' ); } if ( $end - time() < 90 * DAY_IN_SECONDS ) { /* translators: 1: PHP version, 2: month and year. */ return self::item( $label, self::WARNING, sprintf( __( 'PHP %1$s stops receiving security updates in %2$s. Plan the upgrade.', 'maintained-log' ), PHP_VERSION, $date ), '' ); } return self::item( $label, self::OK, /* translators: 1: PHP version, 2: month and year. */ sprintf( __( 'PHP %1$s, supported until %2$s.', 'maintained-log' ), PHP_VERSION, $date ), /* translators: 1: PHP version, 2: month and year. */ sprintf( __( 'The server runs PHP %1$s, which receives security updates until %2$s.', 'maintained-log' ), $branch, $date ) ); } /** * WordPress core version. * * @return array */ private static function check_wordpress() { if ( ! function_exists( 'get_core_updates' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } $label = __( 'WordPress version', 'maintained-log' ); $version = get_bloginfo( 'version' ); $updates = get_core_updates(); if ( is_array( $updates ) ) { foreach ( $updates as $offer ) { // The offer can outlive the update by hours when WordPress' update data is cached. if ( isset( $offer->response, $offer->current ) && 'upgrade' === $offer->response && version_compare( $version, $offer->current, '<' ) ) { /* translators: 1: installed version, 2: available version. */ return self::item( $label, self::WARNING, sprintf( __( 'WordPress %1$s is installed and %2$s is available.', 'maintained-log' ), $version, $offer->current ), '' ); } } } return self::item( $label, self::OK, /* translators: %s: version. */ sprintf( __( 'WordPress %s, the latest version.', 'maintained-log' ), $version ), /* translators: %s: version. */ sprintf( __( 'WordPress is on the latest version (%s).', 'maintained-log' ), $version ) ); } /** * WordPress Site Health result, as stored by its weekly check. * * @return array|null */ private static function check_site_health() { $issues = self::run_site_health_tests(); if ( null === $issues ) { // The tests could not run here: fall back to the counts of WordPress' own weekly check. $result = json_decode( (string) get_transient( 'health-check-site-status-result' ), true ); if ( ! is_array( $result ) || ! isset( $result['critical'] ) ) { return null; } $critical = (int) $result['critical']; $recommended = (int) $result['recommended']; $issues = array(); } else { $critical = count( wp_list_filter( $issues, array( 'status' => 'critical' ) ) ); $recommended = count( wp_list_filter( $issues, array( 'status' => 'recommended' ) ) ); } $label = __( 'WordPress Site Health', 'maintained-log' ); if ( $critical > 0 ) { $item = self::item( $label, self::WARNING, /* translators: %d: number of issues. */ sprintf( _n( 'Site Health reports %d critical issue.', 'Site Health reports %d critical issues.', $critical, 'maintained-log' ), $critical ) . ' ' . __( 'See Tools → Site Health.', 'maintained-log' ), '' ); } else { $item = self::item( $label, self::OK, /* translators: %d: number of recommendations. */ sprintf( _n( 'No critical issues, %d recommendation.', 'No critical issues, %d recommendations.', $recommended, 'maintained-log' ), $recommended ), __( 'WordPress\'s own Site Health tool finds no critical issues.', 'maintained-log' ) ); } $item['issues'] = $issues; return $item; } /** * Runs the Site Health tests that WordPress itself runs in the background * (direct tests, plus async tests that can run without a browser) and * returns the ones that are not "good", critical first. * * @return array[]|null Each with status, label and description (plain text); null if unavailable. */ private static function run_site_health_tests() { if ( ! class_exists( 'WP_Site_Health' ) ) { if ( ! file_exists( ABSPATH . 'wp-admin/includes/class-wp-site-health.php' ) ) { return null; } require_once ABSPATH . 'wp-admin/includes/admin.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; } $health = \WP_Site_Health::get_instance(); $tests = \WP_Site_Health::get_tests(); $callbacks = array(); if ( method_exists( $health, 'is_development_environment' ) && $health->is_development_environment() ) { unset( $tests['async']['https_status'] ); } foreach ( (array) $tests['direct'] as $test ) { if ( ! empty( $test['skip_cron'] ) ) { continue; } if ( is_string( $test['test'] ) && method_exists( $health, 'get_test_' . $test['test'] ) ) { $callbacks[] = array( $health, 'get_test_' . $test['test'] ); } elseif ( is_callable( $test['test'] ) ) { $callbacks[] = $test['test']; } } foreach ( (array) $tests['async'] as $test ) { if ( empty( $test['skip_cron'] ) && ! empty( $test['async_direct_test'] ) && is_callable( $test['async_direct_test'] ) ) { $callbacks[] = $test['async_direct_test']; } } if ( ! $callbacks ) { return null; } $issues = array(); foreach ( $callbacks as $callback ) { try { // Same core filter WordPress applies to every Site Health result. $result = apply_filters( 'site_status_test_result', call_user_func( $callback ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter. } catch ( \Throwable $e ) { continue; } if ( ! is_array( $result ) || empty( $result['status'] ) || 'good' === $result['status'] ) { continue; } $issues[] = array( 'status' => 'critical' === $result['status'] ? 'critical' : 'recommended', 'label' => self::plain_text( isset( $result['label'] ) ? (string) $result['label'] : '' ), 'description' => self::first_sentence( self::plain_text( isset( $result['description'] ) ? (string) $result['description'] : '' ) ), ); } usort( $issues, static function ( $a, $b ) { return ( 'critical' === $b['status'] ) <=> ( 'critical' === $a['status'] ); } ); return $issues; } /** * HTML from Site Health as one line of plain text. * * @param string $html HTML. * @return string */ private static function plain_text( $html ) { // Tags become spaces so "

One.

Two

" does not read "One.Two". $text = preg_replace( '/<[^>]*>/', ' ', $html ); $text = html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return trim( preg_replace( '/\s+/u', ' ', $text ) ); } /** * First sentence of a text, which in Site Health explains the issue. * * @param string $text Text. * @return string */ private static function first_sentence( $text ) { if ( preg_match( '/^.{20,}?[.!?](?=\s|$)/us', $text, $m ) ) { $text = $m[0]; } return mb_strlen( $text ) > 240 ? rtrim( mb_substr( $text, 0, 237 ) ) . '…' : $text; } /** * Expiry timestamp of the certificate served for a host. * * @param string $host Host. * @param int $port Port. * @return int|false */ private static function certificate_expiry( $host, $port ) { if ( ! function_exists( 'openssl_x509_parse' ) || ! function_exists( 'stream_socket_client' ) || '' === $host ) { return false; } $context = stream_context_create( array( 'ssl' => array( 'capture_peer_cert' => true, 'verify_peer' => false, 'verify_peer_name' => false, 'SNI_enabled' => true, 'peer_name' => $host, ), ) ); $errno = 0; $errstr = ''; $client = @stream_socket_client( 'ssl://' . $host . ':' . $port, $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $context ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Failure is handled below. if ( ! $client ) { return false; } $params = stream_context_get_params( $client ); fclose( $client ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( empty( $params['options']['ssl']['peer_certificate'] ) ) { return false; } $cert = openssl_x509_parse( $params['options']['ssl']['peer_certificate'] ); return isset( $cert['validTo_time_t'] ) ? (int) $cert['validTo_time_t'] : false; } /** * Builds an item. * * @param string $label Short name. * @param string $status ok|warning|error. * @param string $detail Detail for the administrator. * @param string $client Plain sentence for the client, used when ok. * @return array */ private static function item( $label, $status, $detail, $client ) { return array( 'label' => $label, 'status' => $status, 'detail' => $detail, 'client' => $client, ); } }