*/
function astsp_errlog_assess( array $s ) {
$s = array_merge(
array(
'debug' => false,
'display' => true,
'ini_display' => false,
'log_enabled' => false,
'log_exists' => false,
'log_size' => 0,
'log_in_web' => false,
'public' => null,
'server' => 'other',
'blocked' => false,
),
$s
);
$out = array();
if ( ( $s['debug'] && $s['display'] ) || $s['ini_display'] ) {
$out[] = array(
'key' => 'errors_displayed',
'status' => 'critical',
'label' => __( 'PHP errors are shown to visitors', 'astute-speed' ),
'why' => __( 'Any warning a plugin or theme raises is printed into the page, with the server\'s folder names. Visitors see a broken page, search engines index the message, and an attacker learns where the files are.', 'astute-speed' ),
'fix' => "define( 'WP_DEBUG_DISPLAY', false );\n@ini_set( 'display_errors', 0 );",
'action' => null,
);
}
if ( true === $s['public'] ) {
$out[] = array(
'key' => 'log_public',
'status' => 'critical',
'label' => __( 'The debug log can be read by anyone', 'astute-speed' ),
'why' => __( 'Anyone who opens the log\'s address can read every error the site has logged: server paths, plugin names and versions, sometimes database queries. Scanners request wp-content/debug.log on every WordPress site they find.', 'astute-speed' ),
'fix' => 'nginx' === $s['server']
? astsp_errlog_nginx_rule()
: implode( "\n", astsp_errlog_htaccess_lines() ),
'action' => 'apache' === $s['server'] && ! $s['blocked'] ? 'block' : null,
);
} elseif ( null === $s['public'] && $s['log_exists'] && $s['log_in_web'] && ! $s['blocked'] ) {
$out[] = array(
'key' => 'log_in_web',
'status' => 'recommended',
'label' => __( 'The debug log is inside the website\'s folder', 'astute-speed' ),
'why' => __( 'Whether the web server hands it out could not be checked from here. Blocking .log files costs nothing.', 'astute-speed' ),
'fix' => 'nginx' === $s['server']
? astsp_errlog_nginx_rule()
: implode( "\n", astsp_errlog_htaccess_lines() ),
'action' => 'apache' === $s['server'] ? 'block' : null,
);
}
if ( $s['debug'] ) {
$out[] = array(
'key' => 'debug_on',
'status' => 'recommended',
'label' => __( 'WordPress debug mode is on', 'astute-speed' ),
'why' => __( 'Debug mode is for finding a problem, not for a live site: it reports every notice, which slows pages a little and fills the log.', 'astute-speed' ),
'fix' => "define( 'WP_DEBUG', false );\ndefine( 'WP_DEBUG_LOG', false );\ndefine( 'WP_DEBUG_DISPLAY', false );",
'action' => null,
);
}
if ( $s['log_exists'] && $s['log_size'] > ASTSP_ERRLOG_LARGE ) {
$out[] = array(
'key' => 'log_large',
'status' => 'recommended',
'label' => sprintf(
/* translators: %s: file size, e.g. 386 MB */
__( 'The debug log is %s', 'astute-speed' ),
astsp_errlog_size( $s['log_size'] )
),
'why' => __( 'A log this size uses disk space and backup time, and nobody reads it. Most of it repeats the same few warnings.', 'astute-speed' ),
'fix' => __( 'Download it if you want to keep it, then delete it with your host\'s file manager.', 'astute-speed' ),
'action' => 'empty',
);
}
return $out;
}
/**
* A byte count for people: 386 MB, 12 KB.
*
* @param int $bytes Bytes.
* @return string
*/
function astsp_errlog_size( $bytes ) {
$bytes = max( 0, (int) $bytes );
if ( $bytes >= 1024 * 1024 * 1024 ) {
return round( $bytes / ( 1024 * 1024 * 1024 ), 1 ) . ' GB';
}
if ( $bytes >= 1024 * 1024 ) {
return round( $bytes / ( 1024 * 1024 ) ) . ' MB';
}
return max( 0, round( $bytes / 1024 ) ) . ' KB';
}
// ── Gathering the state on this site ─────────────────────────────────────────
/**
* The log file to judge: the one WordPress writes to, or an old
* wp-content/debug.log left behind after logging was turned off.
*
* @return string|null
*/
function astsp_errlog_file() {
// WP_CONTENT_DIR is where WordPress itself puts debug.log (wp_debug_mode()).
$path = astsp_errlog_log_path( defined( 'WP_DEBUG_LOG' ) ? WP_DEBUG_LOG : false, WP_CONTENT_DIR );
if ( null === $path ) {
$default = WP_CONTENT_DIR . '/debug.log';
$path = file_exists( $default ) ? $default : null;
}
return $path;
}
/**
* The log's web address, when it sits inside the website at all.
*
* @param string $path Log path.
* @return string|null
*/
function astsp_errlog_url( $path ) {
// WordPress itself writes debug.log to WP_CONTENT_DIR (wp_debug_mode()), and
// content_url() is that folder's address; no function returns the folder.
$relative = astsp_errlog_relative( $path, WP_CONTENT_DIR );
if ( null !== $relative ) {
return content_url( $relative );
}
// A log in the WordPress folder itself. network_site_url() is the main
// WordPress address, so a subdirectory multisite does not add a subsite path.
$relative = astsp_errlog_relative( $path, ABSPATH );
if ( null !== $relative ) {
return network_site_url( $relative );
}
return null;
}
/**
* A file's path below a folder, starting with "/", or null when it is not inside. Pure.
*
* @param string $path File path.
* @param string $root Folder.
* @return string|null
*/
function astsp_errlog_relative( $path, $root ) {
if ( ! astsp_errlog_inside( $path, $root ) ) {
return null;
}
$root = rtrim( astsp_errlog_normalize( $root ), '/' );
return substr( astsp_errlog_normalize( $path ), strlen( $root ) );
}
/**
* Forward slashes, as wp_normalize_path() gives them (also when it is not loaded). Pure.
*
* @param string $path Path.
* @return string
*/
function astsp_errlog_normalize( $path ) {
return function_exists( 'wp_normalize_path' ) ? wp_normalize_path( (string) $path ) : str_replace( '\\', '/', (string) $path );
}
/**
* Asks the web server for the log, the way a scanner would. Only the first
* kilobyte is requested; the answer is kept for six hours.
*
* @param string|null $url Log URL.
* @param bool $fresh Ignore the kept answer.
* @return bool|null Null when it could not be checked.
*/
function astsp_errlog_probe( $url, $fresh = false ) {
if ( null === $url ) {
return false;
}
$kept = get_transient( 'astsp_errlog_public' );
if ( ! $fresh && is_array( $kept ) && ( $kept['url'] ?? '' ) === $url ) {
return $kept['public'];
}
$response = wp_remote_get(
add_query_arg( 'astute-speed-check', time(), $url ),
array(
'timeout' => 10,
// http:// to https:// is normal; a redirect to a page is judged as that page.
'redirection' => 3,
'headers' => array( 'Range' => 'bytes=0-1023' ),
)
);
$public = is_wp_error( $response )
? null
: astsp_errlog_looks_public(
(int) wp_remote_retrieve_response_code( $response ),
(string) wp_remote_retrieve_header( $response, 'content-type' ),
substr( (string) wp_remote_retrieve_body( $response ), 0, 1024 )
);
set_transient(
'astsp_errlog_public',
array(
'url' => $url,
'public' => $public,
),
6 * HOUR_IN_SECONDS
);
return $public;
}
/**
* Apache (or LiteSpeed, which reads .htaccess too), nginx, or something else.
*
* @return string
*/
function astsp_errlog_server() {
global $is_apache, $is_nginx;
if ( ! empty( $is_apache ) ) {
return 'apache';
}
return ! empty( $is_nginx ) ? 'nginx' : 'other';
}
/**
* Everything astsp_errlog_assess() needs, read from this site.
*
* @param bool $probe Ask the web server for the log (otherwise only a kept answer is used).
* @param bool $fresh Ignore the kept answer.
* @return array
*/
function astsp_errlog_state( $probe = true, $fresh = false ) {
$file = astsp_errlog_file();
$size = null !== $file && is_file( $file ) ? (int) @filesize( $file ) : 0; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable is the same as absent here
$url = null !== $file ? astsp_errlog_url( $file ) : null;
$public = null;
if ( null === $file || ! is_file( $file ) ) {
$public = false;
} elseif ( $probe ) {
$public = astsp_errlog_probe( $url, $fresh );
} else {
$kept = get_transient( 'astsp_errlog_public' );
$public = is_array( $kept ) && ( $kept['url'] ?? '' ) === $url ? $kept['public'] : null;
}
return array(
'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG,
'display' => ! defined( 'WP_DEBUG_DISPLAY' ) || false !== WP_DEBUG_DISPLAY,
// WP-CLI always prints errors; only what web visitors get counts.
'ini_display' => 'cli' !== PHP_SAPI && in_array( strtolower( (string) ini_get( 'display_errors' ) ), array( '1', 'on', 'stdout', 'stderr' ), true ),
'log_enabled' => null !== astsp_errlog_log_path( defined( 'WP_DEBUG_LOG' ) ? WP_DEBUG_LOG : false, WP_CONTENT_DIR ),
'log_exists' => null !== $file && is_file( $file ),
'log_size' => $size,
'log_in_web' => null !== $url,
'log_url' => $url,
'public' => $public,
'server' => astsp_errlog_server(),
'blocked' => false,
);
}
// ── Site Health ───────────────────────────────────────────────────────────────
add_filter(
'site_status_tests',
static function ( $tests ) {
$tests['direct']['astsp_error_logs'] = array(
'label' => __( 'PHP errors and the debug log', 'astute-speed' ),
'test' => 'astsp_errlog_site_health',
);
return $tests;
}
);
/**
* One Site Health result: the worst finding, with the others listed.
*
* @return array
*/
function astsp_errlog_site_health() {
$findings = astsp_errlog_assess( astsp_errlog_state() );
$result = array(
'label' => __( 'PHP errors are hidden and the debug log is private', 'astute-speed' ),
'status' => 'good',
'badge' => array(
'label' => __( 'Security', 'astute-speed' ),
'color' => 'blue',
),
'description' => '' . esc_html__( 'Visitors do not see PHP errors, and no debug log can be read from the web.', 'astute-speed' ) . '
',
'actions' => '',
'test' => 'astsp_error_logs',
);
if ( ! $findings ) {
return $result;
}
$worst = 'recommended';
$items = '';
foreach ( $findings as $f ) {
if ( 'critical' === $f['status'] ) {
$worst = 'critical';
}
$items .= '' . esc_html( $f['label'] ) . '. ' . esc_html( $f['why'] ) . '';
}
$result['label'] = $findings[0]['label'];
$result['status'] = $worst;
$result['badge']['color'] = 'critical' === $worst ? 'red' : 'orange';
$result['description'] = '';
$result['actions'] = '' . esc_html__( 'See how to fix it', 'astute-speed' ) . '
';
return $result;
}
// ── Page Speed screen ─────────────────────────────────────────────────────────
/**
* The "PHP errors and the debug log" box. Shows nothing extra on a clean site
* beyond one line saying so.
*
* @return void
*/
function astsp_errlog_render_box() {
$fresh = isset( $_POST['astsp_errlog_recheck'], $_POST['astsp_errlog_nonce'] )
&& wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['astsp_errlog_nonce'] ) ), 'astsp_errlog_recheck' );
$state = astsp_errlog_state( true, $fresh );
$findings = astsp_errlog_assess( $state );
$pro = false;
echo '' . esc_html__( 'PHP errors and the debug log', 'astute-speed' ) . '
';
if ( ! $findings ) {
echo ' ' . esc_html__( 'Visitors do not see PHP errors, and no debug log can be read from the web.', 'astute-speed' ) . '
';
}
foreach ( $findings as $f ) {
$where = in_array( $f['key'], array( 'errors_displayed', 'debug_on' ), true )
? __( 'Change these lines in wp-config.php, above "That\'s all, stop editing!". Keep a copy of the file first.', 'astute-speed' )
: ( 'log_large' === $f['key'] ? '' : ( 'nginx' === $state['server'] ? __( 'Add this to the site\'s nginx server block, then reload nginx:', 'astute-speed' ) : __( 'Add this to the site\'s .htaccess:', 'astute-speed' ) ) );
$pro = $pro || null !== $f['action'];
echo '';
echo '
' . esc_html( $f['label'] ) . '
' . esc_html( $f['why'] ) . '
';
if ( '' !== $where ) {
echo '
' . esc_html( $where ) . '
';
}
echo '
' . esc_html( $f['fix'] ) . '
';
echo '
';
}
if ( $pro ) {
echo '' . esc_html__( 'Astute Speed Pro blocks the log and empties a large one with a click, keeping a private copy.', 'astute-speed' ) . ' ' . esc_html__( 'About Astute Speed Pro', 'astute-speed' ) . '
';
}
echo '';
}