true ) ); } $home = home_url( '/' ); // 1. Setup: files, constants, background tasks. $setup = array(); if ( defined( 'ZEROCONFIG_DISABLE' ) && ZEROCONFIG_DISABLE ) { $setup[] = array( 'bad', 'disabled_constant' ); } $dropin = WP_CONTENT_DIR . '/advanced-cache.php'; if ( is_file( $dropin ) && false === strpos( (string) file_get_contents( WP_CONTENT_DIR . '/advanced-cache.php' ), ZeroConfig_Page_Cache::DROPIN_MARKER ) ) { $setup[] = array( 'warn', 'dropin_foreign' ); } elseif ( ! is_file( $dropin ) ) { $setup[] = array( 'warn', is_writable( WP_CONTENT_DIR ) ? 'dropin_missing' : 'dropin_write' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- a diagnostic, nothing is written } elseif ( ! defined( 'WP_CACHE' ) || ! WP_CACHE ) { $setup[] = array( 'warn', 'wpconfig_readonly' ); } $next = wp_next_scheduled( ZeroConfig_Cron::HEARTBEAT ); if ( $next && $next < time() - 2 * HOUR_IN_SECONDS ) { $setup[] = array( 'warn', 'cron_stalled' ); } $rows['setup'] = self::worst( $setup ); // 2. Page cache: ask twice as a visitor; the second answer must come from the cache. // The whole check fits in about 10 s: people press "Check again" when the site is slow, and a // check that runs into PHP's time limit gives them an error page instead of an answer. $t0 = microtime( true ); $left = function () use ( $t0 ) { return 10 - ( microtime( true ) - $t0 ); }; $first = ZeroConfig_Probe::fetch( $home, 'd', false, 6 ); $second = 'error' === $first['layer'] || $left() < 1.5 ? $first : ZeroConfig_Probe::fetch( $home, 'd', false, (int) min( 4, floor( $left() ) ) ); // The page timed out while this check runs inside a PHP request (the admin button, cron). If a // static file from the same server comes back at once, the web server is fine and PHP was busy: // on a server with one PHP worker, busy with this very check. That is a host setting, not a slow // page. (From WP-CLI the check runs outside PHP's workers, so a timeout there means slow.) $single = false; if ( apply_filters( 'zeroconfig_doctor_in_request', PHP_SAPI !== 'cli' ) && ZeroConfig_Probe::timed_out( $first ) && 'loopback' === $first['via'] && $left() >= 1.5 ) { $static = ZeroConfig_Probe::fetch_static( includes_url( 'css/dashicons.min.css' ), (int) min( 2, floor( $left() ) ) ); $single = 200 === $static['code']; } $stored = is_file( ZeroConfig_Probe::file_for( $home, 'd' ) ); $cache = array(); $seen = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) : ''; if ( '' !== $seen && is_admin() && ! self::same_host( $seen ) ) { $cache[] = array( 'warn', 'host_mismatch', array( $seen, (string) wp_parse_url( $home, PHP_URL_HOST ) ) ); } $layer = $second['layer']; $public = 'public' === $second['via']; if ( $single ) { $cache[] = array( 'warn', 'single_worker', array( (string) round( $first['ms'] / 1000 ) ) ); } elseif ( ZeroConfig_Probe::timed_out( $second ) ) { $cache[] = array( 'warn', 'slow_answer', array( (string) round( $second['ms'] / 1000 ) ) ); // no verdict, and that is the finding } elseif ( 'error' === $layer ) { $cache[] = array( 'warn', 'loopback_blocked', array( $second['error'] ) ); } elseif ( 0 === strpos( $layer, 'http_3' ) ) { $cache[] = array( 'bad', 'redirected', array( (string) $second['code'] ) ); } elseif ( 0 === strpos( $layer, 'http_' ) ) { $cache[] = array( 'bad', 'http_error', array( (string) $second['code'] ) ); } elseif ( in_array( $layer, array( 'foreign', 'unknown' ), true ) && $public ) { // Reached over the public address and a CDN answered: the server itself was never asked. // Unknown, not broken. $cache[] = array( 'na', 'edge_answered', array( implode( ', ', $second['foreign'] ) ) ); } elseif ( 'foreign' === $layer ) { $cache[] = array( 'warn', 'foreign_page_cache', array( implode( ', ', $second['foreign'] ) ) ); } elseif ( 'unknown' === $layer ) { $cache[] = array( 'warn', 'not_ours' ); } elseif ( 'miss' === $layer ) { $cache[] = array( 'bad', $stored ? 'not_served' : 'not_stored' ); } // The warm-up never falls back to the public address; say so when it had to stop for that reason. $reached_itself = 'loopback' === $first['via'] && 'error' !== $first['layer']; if ( ! $reached_itself && 'loopback_blocked' === ( ZeroConfig_Probe::health()['warm']['stop'] ?? '' ) ) { $cache[] = array( 'warn', 'warm_skipped' ); } $rows['cache'] = self::worst( $cache ); // 3. Web server: on Apache/LiteSpeed the fastest path skips PHP entirely. Decided from two signals, // never from our header alone: the ping rule (do the .htaccess rules run at all) and who answered. $server = strtolower( self::server_software() ); if ( '' === $server ) { $server = strtolower( (string) ( $second['server'] ?? '' ) ); // from the command line: what the server calls itself } $srv = array(); $ping = ''; if ( '' !== trim( (string) wp_parse_url( $home, PHP_URL_PATH ), '/' ) ) { // Site at example.com/blog/: our rules are written for sites at the root, so they are not // installed. Our limit, not the host's: never send this owner to their host. $srv[] = array( 'na', 'subdir_site', array( (string) wp_parse_url( $home, PHP_URL_PATH ) ) ); } elseif ( '' === $server ) { $srv[] = array( 'na', 'server_unknown' ); } elseif ( false === strpos( $server, 'apache' ) && false === strpos( $server, 'litespeed' ) ) { $srv[] = array( 'na', 'nginx' ); } elseif ( ! ZeroConfig_Htaccess::installed() ) { $srv[] = array( 'warn', 'htaccess_not_written' ); } elseif ( 'error' === $first['layer'] || $single || $left() < 1.5 ) { $srv[] = array( 'na', 'server_unknown' ); // no time or no answer left to ping with } else { $ping = ZeroConfig_Probe::ping( (int) min( 3, floor( $left() ) ) ); $verdict = self::apache_verdict( $ping, $layer, (string) ( $second['browser'] ?? '' ) ); if ( $verdict ) { $srv[] = $verdict; } } $perma = (string) get_option( 'permalink_structure' ); if ( '' !== $perma && '/' !== substr( $perma, -1 ) ) { $srv[] = array( 'warn', 'permalink_no_slash' ); } $rows['server'] = self::worst( $srv ); // 4. Edge: images, CSS and JS from the edge. $edge = array(); if ( ! ZeroConfig_Options::is_connected() ) { $edge[] = array( 'warn', 'not_connected' ); } elseif ( ! empty( $o['cdn_paused'] ) ) { $edge[] = array( 'warn', 'quota_paused' ); } elseif ( 'down' === get_transient( 'zeroconfig_edge' ) ) { // the last state; asking now could flip it and purge the cache $edge[] = array( 'bad', 'edge_unreachable', array( (string) wp_parse_url( ZEROCONFIG_API, PHP_URL_HOST ) ) ); } $rows['edge'] = self::worst( $edge ); // 5. Features the Safety Check switched off. $off = array_keys( array_filter( (array) $o['disabled'] ) ); $rows['features'] = self::worst( $off ? array( array( 'warn', 'safemode_off', array( implode( ', ', $off ) ) ) ) : array() ); return self::save( array( 'rows' => $rows, 'probe' => array( 'first' => $first, 'second' => $second, 'stored' => $stored, 'ping' => $ping ), 'server' => $server, ) ); } /** * The web server row on Apache/LiteSpeed with our rules installed, from three observations: the ping * ('ok' | 'no' | 'unknown'), the layer that answered the second request, and whether that answer told * the browser anything about caching ('told' | 'none'). Null when the shortcut works. */ public static function apache_verdict( $ping, $layer, $browser ) { if ( 'no' === $ping ) { return array( 'warn', 'htaccess_ignored' ); } if ( 'unknown' === $ping ) { return array( 'na', 'server_unknown' ); } if ( 'noted' === $layer ) { // Rules run, our copy came back, our header did not. Without mod_expires either, nothing tells // browsers to recheck the page: then it is more than cosmetic. return 'none' === $browser ? array( 'warn', 'headers_missing' ) : array( 'warn', 'headers_stripped' ); } if ( in_array( $layer, array( 'dropin', 'wp' ), true ) ) { return array( 'warn', 'htaccess_skipped' ); } if ( 'htaccess' !== $layer ) { return array( 'na', 'server_unknown' ); // the page cache row already says why } return null; } /** Is $host (as a browser sends it, port included) one of this site's addresses? Compared in one form. */ public static function same_host( $host ) { $norm = function ( $h ) { return preg_replace( '/^www\./', '', preg_replace( '/[^a-z0-9.\-]/', '', strtolower( (string) $h ) ) ); }; return in_array( $norm( $host ), array_map( $norm, ZeroConfig_Page_Cache::hosts( true ) ), true ); } private static function save( array $result ) { $result['at'] = time(); ZeroConfig_Probe::save_health( array( 'doctor' => $result ) ); return $result; } /** Status of a row: its worst finding, and every finding for the details. */ private static function worst( array $findings ) { $rank = array( 'ok' => 0, 'na' => 1, 'warn' => 2, 'bad' => 3 ); $status = 'ok'; foreach ( $findings as $f ) { if ( $rank[ $f[0] ] > $rank[ $status ] ) { $status = $f[0]; } } $items = array(); foreach ( $findings as $f ) { $items[] = array( 'code' => $f[1], 'args' => isset( $f[2] ) ? array_map( 'strval', $f[2] ) : array() ); } return array( 'status' => $status, 'items' => $items ); } /** Server software, remembered from web requests (cron from the command line does not know it). */ public static function server_software() { $now = isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : ''; if ( '' !== $now ) { $h = ZeroConfig_Probe::health(); if ( ( $h['server_software'] ?? '' ) !== $now ) { ZeroConfig_Probe::save_health( array( 'server_software' => $now ) ); } return $now; } return (string) ( ZeroConfig_Probe::health()['server_software'] ?? '' ); } /* ---------- words ---------- */ public static function row_label( $row ) { $labels = array( 'setup' => __( 'Setup', 'zeroconfig' ), 'cache' => __( 'Page cache', 'zeroconfig' ), 'server' => __( 'Web server shortcut', 'zeroconfig' ), 'edge' => __( 'Edge (images, CSS, JS)', 'zeroconfig' ), 'features' => __( 'Features', 'zeroconfig' ), ); return isset( $labels[ $row ] ) ? $labels[ $row ] : $row; } public static function owner_label( $owner ) { $labels = array( 'you' => __( 'You can fix this', 'zeroconfig' ), 'host' => __( 'Ask your host', 'zeroconfig' ), 'us' => __( 'On us', 'zeroconfig' ), 'none' => __( 'Expected', 'zeroconfig' ), ); return isset( $labels[ $owner ] ) ? $labels[ $owner ] : $owner; } /** Code → owner, one sentence, and steps that can be pasted as they are. Plain text: escape on output. */ public static function reason( $code, array $args = array() ) { $a = array_pad( array_map( 'strval', $args ), 2, '' ); switch ( $code ) { case 'disabled_constant': return array( 'you', __( 'ZEROCONFIG_DISABLE is set in wp-config.php, so nothing is served from cache.', 'zeroconfig' ), __( "Remove the line define( 'ZEROCONFIG_DISABLE', true ); from wp-config.php.", 'zeroconfig' ) ); case 'dropin_foreign': return array( 'you', __( 'Another caching plugin owns wp-content/advanced-cache.php. Cached pages still work, through WordPress.', 'zeroconfig' ), __( 'Deactivate the other caching plugin, then turn ZeroConfig off and on again. Two page caches only fight each other.', 'zeroconfig' ) ); case 'dropin_write': return array( 'host', __( 'wp-content is not writable, so the fast cache file could not be installed. Cached pages still work, through WordPress.', 'zeroconfig' ), __( 'Message for your host: "Please make the wp-content folder writable for the web server user; a caching plugin needs to create wp-content/advanced-cache.php."', 'zeroconfig' ) ); case 'dropin_missing': return array( 'us', __( 'The fast cache file is missing. Cached pages still work, through WordPress.', 'zeroconfig' ), __( 'Turn ZeroConfig off and on again to reinstall it.', 'zeroconfig' ) ); case 'wpconfig_readonly': return array( 'host', __( 'WP_CACHE could not be switched on in wp-config.php. Cached pages still work, through WordPress.', 'zeroconfig' ), __( "Add this line to wp-config.php, above the line /* That's all, stop editing! */ : define( 'WP_CACHE', true );", 'zeroconfig' ) ); case 'cron_stalled': /* translators: %s: the site's wp-cron.php address */ return array( 'host', __( 'Background tasks (WP-Cron) have not run for over two hours, so expired pages are not cleaned up on time.', 'zeroconfig' ), sprintf( __( 'If wp-config.php has DISABLE_WP_CRON, add a server cron job: */5 * * * * wget -q -O - %s >/dev/null 2>&1', 'zeroconfig' ), site_url( 'wp-cron.php' ) ) ); case 'host_mismatch': /* translators: 1: address in the browser, 2: address WordPress uses */ return array( 'you', sprintf( __( 'You are visiting %1$s, but WordPress thinks the site is %2$s. Pages are cached for %2$s only.', 'zeroconfig' ), $a[0], $a[1] ), sprintf( __( 'Settings → General: set both addresses to the one your visitors use, or redirect %1$s to %2$s.', 'zeroconfig' ), $a[0], $a[1] ) ); case 'loopback_blocked': /* translators: %s: error message */ return array( 'host', sprintf( __( 'This server cannot open its own pages (%s), so the cache could not be checked from here.', 'zeroconfig' ), $a[0] ), __( 'Message for your host: "Please allow this site to make HTTP requests to its own address (loopback requests)." WordPress Tools → Site Health reports the same problem.', 'zeroconfig' ) ); case 'redirected': /* translators: %s: HTTP status code */ return array( 'you', sprintf( __( 'The home page answers with a redirect (HTTP %s) instead of the page.', 'zeroconfig' ), $a[0] ), __( 'Settings → General: make the WordPress Address and Site Address exactly the address visitors use (https and www included).', 'zeroconfig' ) ); case 'http_error': /* translators: %s: HTTP status code */ return array( 'you', sprintf( __( 'The home page answers HTTP %s to a normal visitor.', 'zeroconfig' ), $a[0] ), __( 'Open the home page in a private window. Maintenance mode, a login wall or a security plugin may be blocking visitors.', 'zeroconfig' ) ); case 'foreign_page_cache': /* translators: %s: cache headers */ return array( 'you', sprintf( __( 'Another cache in front of WordPress answered (%s). Visitors get its copy before ZeroConfig sees the visit.', 'zeroconfig' ), $a[0] ), __( 'Turn off page (HTML) caching in that layer and keep it for images and files, or exclude HTML from it. Your host or CDN dashboard has the switch.', 'zeroconfig' ) ); case 'not_ours': return array( 'host', __( 'Something other than this WordPress answered the home page.', 'zeroconfig' ), __( 'A proxy, firewall or host cache replaced the answer. Check the caching or security layer in your hosting panel.', 'zeroconfig' ) ); case 'not_stored': return array( 'us', __( 'Pages are built but not stored in the cache.', 'zeroconfig' ), __( 'A theme or plugin may mark every page as not cacheable or set a cookie on every visit. Use "Something looks off?" below so we can look.', 'zeroconfig' ) ); case 'not_served': return array( 'us', __( 'Pages are stored but not served from the cache.', 'zeroconfig' ), __( 'Turn ZeroConfig off and on again. If this stays, use "Something looks off?" below.', 'zeroconfig' ) ); case 'warm_skipped': return array( 'host', __( 'Cached pages are not refilled after a purge: this server cannot open its own pages, so the first visitor after each purge waits for a fresh page.', 'zeroconfig' ), __( "If the site sits behind a proxy that ends HTTPS (Docker, a load balancer), add this line to wp-config.php with the web server's own address: define( 'ZEROCONFIG_LOOPBACK', 'http://127.0.0.1:80' ); Otherwise ask your host to allow loopback requests.", 'zeroconfig' ) ); case 'single_worker': /* translators: %s: seconds */ return array( 'host', sprintf( __( 'Your web server answered at once, but PHP did not answer within %s seconds while this check was running: the site runs one PHP request at a time, or all PHP workers were busy. While it builds a page for one visitor, the next one waits.', 'zeroconfig' ), $a[0] ), __( 'Message for your host: "Please allow more than one PHP worker for this site (for example PHP-FPM pm.max_children of 4 or more)."', 'zeroconfig' ) ); case 'slow_answer': /* translators: %s: seconds */ return array( 'you', sprintf( __( 'Your home page did not answer within %s seconds, so the check stopped there. A page this slow is the first thing to look at.', 'zeroconfig' ), $a[0] ), __( 'Open the home page yourself. If it is this slow, turn ZeroConfig off for a minute to see whether it is us; otherwise look at recently added plugins, or ask your host about the server load.', 'zeroconfig' ) ); case 'subdir_site': /* translators: %s: folder, e.g. /blog/ */ return array( 'us', sprintf( __( 'Your site lives in %s, so the web server shortcut is not used yet: PHP serves the cached pages (still fast).', 'zeroconfig' ), $a[0] ), __( 'Nothing to change on your side or your host\'s. This is a current limit of ZeroConfig.', 'zeroconfig' ) ); case 'edge_answered': /* translators: %s: cache headers */ return array( 'none', sprintf( __( 'A CDN in front answered with its own copy (%s), so this check could not see your server itself.', 'zeroconfig' ), $a[0] ), '' ); case 'headers_stripped': return array( 'host', __( 'Apache serves cached pages straight from disk, but without ZeroConfig\'s headers: the mod_headers module is off. Pages are still fast and browsers are still told not to keep them.', 'zeroconfig' ), __( 'Message for your host: "Please enable the Apache module mod_headers for this site."', 'zeroconfig' ) ); case 'headers_missing': return array( 'host', __( 'Apache serves cached pages straight from disk, but with no caching headers at all: mod_headers and mod_expires are both off. Browsers may keep a page longer than they should.', 'zeroconfig' ), __( 'Message for your host: "Please enable the Apache modules mod_headers and mod_expires for this site."', 'zeroconfig' ) ); case 'htaccess_skipped': return array( 'us', __( 'The .htaccess rules run, but did not pick up the home page; PHP served it from cache instead (still fast).', 'zeroconfig' ), __( 'Use "Something looks off?" below so we can look.', 'zeroconfig' ) ); case 'nginx': return array( 'none', __( 'Not Apache (Nginx or similar): cached pages are served through PHP by the drop-in. That is the expected fast path here.', 'zeroconfig' ), '' ); case 'server_unknown': return array( 'none', __( 'Depends on the page cache check above.', 'zeroconfig' ), '' ); case 'htaccess_not_written': return array( 'host', __( 'The .htaccess rules could not be written, so cached pages go through PHP (still fast).', 'zeroconfig' ), __( 'Make the .htaccess file in the site folder writable, then turn ZeroConfig off and on again.', 'zeroconfig' ) ); case 'htaccess_ignored': return array( 'host', __( 'The .htaccess rules are in place, but the web server ignores them, so cached pages go through PHP (still fast).', 'zeroconfig' ), __( 'Message for your host: "Please enable mod_rewrite and AllowOverride All (at least FileInfo) for this site\'s document root."', 'zeroconfig' ) ); case 'permalink_no_slash': return array( 'none', __( 'Your permalinks do not end with a slash, so the web server shortcut does not apply to posts. PHP still serves them from cache.', 'zeroconfig' ), __( 'Nothing to do. Permalinks ending in / get the very fastest path.', 'zeroconfig' ) ); case 'not_connected': return array( 'us', __( 'Waiting for the edge to verify this site. The page cache already works.', 'zeroconfig' ), __( 'Usually a few minutes. If it stays like this, press Try again.', 'zeroconfig' ) ); case 'quota_paused': return array( 'us', __( 'The CDN is paused for this site after unusually high use: images and files come from your server for now. Nothing is broken.', 'zeroconfig' ), __( 'We have e-mailed you about it; reply to that e-mail or write to hello@zeroconfigwp.com.', 'zeroconfig' ) ); case 'edge_unreachable': /* translators: %s: edge host name */ return array( 'host', sprintf( __( 'This server cannot reach the edge (%s), so images and files come from your server for now.', 'zeroconfig' ), $a[0] ), sprintf( __( 'Message for your host: "Please allow outgoing HTTPS (port 443) to %s."', 'zeroconfig' ), $a[0] ) ); case 'safemode_off': /* translators: %s: feature names */ return array( 'us', sprintf( __( 'The Safety Check switched off %s because it changed how your pages look.', 'zeroconfig' ), $a[0] ), __( 'That keeps your site looking right. After changing theme or plugins, press Re-check.', 'zeroconfig' ) ); } return array( 'us', $code, '' ); } /** One honest sentence for the top of the card. */ public static function summary() { $h = ZeroConfig_Probe::health(); $w = isset( $h['warm'] ) ? $h['warm'] : array(); if ( ! empty( $w['stored'] ) ) { $addresses = count( array_unique( wp_list_pluck( array_filter( (array) ( $w['items'] ?? array() ), function ( $i ) { return ! empty( $i['stored'] ); } ), 'url' ) ) ); /* translators: 1: addresses refilled, 2: addresses tried, 3: seconds */ $line = sprintf( __( 'Refilled after the last purge: %1$d of %2$d addresses, desktop and mobile, in %3$s s.', 'zeroconfig' ), $addresses, (int) $w['urls'], number_format_i18n( (float) $w['seconds'], 1 ) ); if ( ! empty( $w['warm_ms'] ) ) { /* translators: %d: milliseconds */ $line .= ' ' . sprintf( __( 'A cached page then answered in %d ms.', 'zeroconfig' ), (int) $w['warm_ms'] ); } $why = array( 'time' => __( 'It stopped early: the time limit was reached.', 'zeroconfig' ), 'slow' => __( 'It stopped early: the server was answering slowly.', 'zeroconfig' ), 'load' => __( 'It stopped early: the server was busy.', 'zeroconfig' ), 'purged' => __( 'It stopped early: the cache was cleared again meanwhile.', 'zeroconfig' ), 'loopback_blocked' => __( 'It stopped early: this server could not open its own pages.', 'zeroconfig' ), ); return empty( $w['stop'] ) || ! isset( $why[ $w['stop'] ] ) ? $line : $line . ' ' . $why[ $w['stop'] ]; } $d = isset( $h['doctor']['probe']['second'] ) ? $h['doctor']['probe']['second'] : array(); if ( ! empty( $d['ms'] ) && in_array( $d['layer'] ?? '', array( 'htaccess', 'noted', 'dropin', 'wp' ), true ) ) { /* translators: %d: milliseconds */ return sprintf( __( 'Your home page was served from cache in %d ms.', 'zeroconfig' ), (int) $d['ms'] ); } return ''; } }