| 1 |
<?php |
| 2 |
defined('ABSPATH') or die('Unauthorized Access'); |
| 3 |
|
| 4 |
class Phpinfo_WP_Report { |
| 5 |
|
| 6 |
const OPT_BRANDING = 'phpinfowp_report_branding'; |
| 7 |
|
| 8 |
private static function _pro(): bool { return Phpinfo_WP_License::is_valid(); } |
| 9 |
|
| 10 |
// ───────────────────────────────────────────────────────────────── |
| 11 |
// Branding (white-label) |
| 12 |
// ───────────────────────────────────────────────────────────────── |
| 13 |
|
| 14 |
public static function get_branding(): array { |
| 15 |
return wp_parse_args(get_option(self::OPT_BRANDING, []), [ |
| 16 |
'enabled' => false, |
| 17 |
'company' => '', |
| 18 |
'tagline' => '', |
| 19 |
'footer_note' => '', |
| 20 |
'accent' => '#777BB3', |
| 21 |
'logo_url' => '', |
| 22 |
'logo_id' => 0, |
| 23 |
]); |
| 24 |
} |
| 25 |
|
| 26 |
public static function save_branding(array $b): void { |
| 27 |
if (!self::_pro()) return; |
| 28 |
$accent = trim((string)($b['accent'] ?? '')); |
| 29 |
if (!preg_match('/^#[0-9a-fA-F]{3,6}$/', $accent)) $accent = '#777BB3'; |
| 30 |
|
| 31 |
// Resolve a logo: prefer the attachment_id (re-derives URL each time |
| 32 |
// so a moved/CDN-routed media file still points at the right asset). |
| 33 |
// Fall back to a raw URL if the user pasted one and we have no ID. |
| 34 |
$logo_id = (int) ($b['logo_id'] ?? 0); |
| 35 |
$logo_url = esc_url_raw(trim((string) ($b['logo_url'] ?? ''))); |
| 36 |
if ($logo_id > 0) { |
| 37 |
$resolved = wp_get_attachment_image_url($logo_id, 'medium'); |
| 38 |
if ($resolved) $logo_url = $resolved; |
| 39 |
else $logo_id = 0; // attachment got deleted — drop the stale id |
| 40 |
} |
| 41 |
|
| 42 |
update_option(self::OPT_BRANDING, [ |
| 43 |
'enabled' => !empty($b['enabled']), |
| 44 |
'company' => sanitize_text_field($b['company'] ?? ''), |
| 45 |
'tagline' => sanitize_text_field($b['tagline'] ?? ''), |
| 46 |
'footer_note' => sanitize_textarea_field($b['footer_note'] ?? ''), |
| 47 |
'accent' => $accent, |
| 48 |
'logo_url' => $logo_url, |
| 49 |
'logo_id' => $logo_id, |
| 50 |
], false); |
| 51 |
} |
| 52 |
|
| 53 |
// ───────────────────────────────────────────────────────────────── |
| 54 |
// Main report build — aggregates every subsystem + scores + priorities |
| 55 |
// ───────────────────────────────────────────────────────────────── |
| 56 |
|
| 57 |
public static function build(): array { |
| 58 |
|
| 59 |
$eol = Phpinfo_WP_EOL::status(); |
| 60 |
$grader = Phpinfo_WP_Config_Grader::run(); |
| 61 |
$headers = Phpinfo_WP_Security_Headers::get_cached(); |
| 62 |
$ssl = Phpinfo_WP_SSL::check_all(); |
| 63 |
$opcache = Phpinfo_WP_OPcache::is_available() ? Phpinfo_WP_OPcache::status() : null; |
| 64 |
$db = Phpinfo_WP_DB_Health::server_info(); |
| 65 |
$autoload = Phpinfo_WP_DB_Health::autoload_size(); |
| 66 |
$db_size = Phpinfo_WP_DB_Health::db_size(); |
| 67 |
$cron = Phpinfo_WP_Cron_Monitor::summary(); |
| 68 |
$compat = Phpinfo_WP_Compat::get_result(); |
| 69 |
|
| 70 |
// Per-category subscores (each 0-100, with null = not applicable so |
| 71 |
// we can recompute weights instead of penalising for missing data). |
| 72 |
$sub = [ |
| 73 |
'config' => isset($grader['score']) ? (int) $grader['score'] : null, |
| 74 |
'headers' => !isset($headers['error']) && isset($headers['score']) ? (int) $headers['score'] : null, |
| 75 |
'php_eol' => self::eol_score($eol), |
| 76 |
'db_eol' => $db ? self::db_eol_score($db) : null, |
| 77 |
'opcache' => $opcache ? self::opcache_score($opcache) : null, |
| 78 |
'ssl' => $ssl ? self::ssl_score($ssl) : null, |
| 79 |
'cron' => $cron ? self::cron_score($cron) : null, |
| 80 |
]; |
| 81 |
|
| 82 |
$weights = [ |
| 83 |
'config' => 30, |
| 84 |
'headers' => 20, |
| 85 |
'php_eol' => 15, |
| 86 |
'db_eol' => 10, |
| 87 |
'opcache' => 10, |
| 88 |
'ssl' => 10, |
| 89 |
'cron' => 5, |
| 90 |
]; |
| 91 |
|
| 92 |
$overall_score = self::weighted_average($sub, $weights); |
| 93 |
[$verdict, $verdict_label, $verdict_color] = self::score_to_verdict($overall_score); |
| 94 |
|
| 95 |
// Bar chart data (just the named categories the user expects on a card) |
| 96 |
$bars = []; |
| 97 |
if ($sub['config'] !== null) $bars[] = ['label' => 'PHP Config', 'score' => $sub['config']]; |
| 98 |
if ($sub['headers'] !== null) $bars[] = ['label' => 'Security Headers', 'score' => $sub['headers']]; |
| 99 |
if ($sub['opcache'] !== null) $bars[] = ['label' => 'OPcache', 'score' => $sub['opcache']]; |
| 100 |
if ($sub['php_eol'] !== null) $bars[] = ['label' => 'PHP Support', 'score' => $sub['php_eol']]; |
| 101 |
if ($sub['ssl'] !== null) $bars[] = ['label' => 'SSL', 'score' => $sub['ssl']]; |
| 102 |
if ($sub['db_eol'] !== null) $bars[] = ['label' => 'Database Support', 'score' => $sub['db_eol']]; |
| 103 |
|
| 104 |
// Extra free-tier-flavored checks the user asked for explicitly |
| 105 |
$extras = [ |
| 106 |
'wp_debug' => self::wp_debug_state(), |
| 107 |
'https' => self::https_state(), |
| 108 |
'updates' => self::updates_state(), |
| 109 |
'backup' => self::detect_backup(), |
| 110 |
'permissions' => self::permission_state(), |
| 111 |
]; |
| 112 |
|
| 113 |
// Compute issues — anything ≥ critical surfaces in the banner; |
| 114 |
// critical + warning together feed the "Top 3 Actions" picker. |
| 115 |
$issues = self::collect_issues($eol, $grader, $headers, $ssl, $opcache, $db, $autoload, $cron, $extras); |
| 116 |
$criticals = array_values(array_filter($issues, function ($i) { |
| 117 |
return $i['urgency'] === 'critical'; |
| 118 |
})); |
| 119 |
$warnings = array_values(array_filter($issues, function ($i) { |
| 120 |
return $i['urgency'] === 'warning'; |
| 121 |
})); |
| 122 |
$priorities = self::prioritize($issues, 3); |
| 123 |
|
| 124 |
return [ |
| 125 |
'site' => get_bloginfo('name'), |
| 126 |
'url' => get_site_url(), |
| 127 |
'generated_at' => time(), |
| 128 |
'php' => PHP_VERSION, |
| 129 |
'wp' => get_bloginfo('version'), |
| 130 |
'overall' => [ |
| 131 |
'score' => $overall_score, |
| 132 |
'grade' => self::score_to_grade($overall_score), |
| 133 |
'verdict' => $verdict, |
| 134 |
'verdict_label' => $verdict_label, |
| 135 |
'verdict_color' => $verdict_color, |
| 136 |
], |
| 137 |
'bars' => $bars, |
| 138 |
'criticals' => $criticals, |
| 139 |
'warnings' => $warnings, |
| 140 |
'priorities' => $priorities, |
| 141 |
'extras' => $extras, |
| 142 |
// Raw subsystem data (kept for the existing report sections) |
| 143 |
'eol' => $eol, |
| 144 |
'grader' => $grader, |
| 145 |
'headers' => $headers, |
| 146 |
'ssl' => $ssl, |
| 147 |
'opcache' => $opcache, |
| 148 |
'db' => $db, |
| 149 |
'autoload' => $autoload, |
| 150 |
'db_size' => $db_size, |
| 151 |
'cron' => $cron, |
| 152 |
'compat' => $compat, |
| 153 |
'branding' => self::get_branding(), |
| 154 |
]; |
| 155 |
} |
| 156 |
|
| 157 |
// ───────────────────────────────────────────────────────────────── |
| 158 |
// Scoring helpers |
| 159 |
// ───────────────────────────────────────────────────────────────── |
| 160 |
|
| 161 |
public static function score_to_grade(int $s): string { |
| 162 |
if ($s >= 95) return 'A+'; |
| 163 |
if ($s >= 85) return 'A'; |
| 164 |
if ($s >= 75) return 'B'; |
| 165 |
if ($s >= 60) return 'C'; |
| 166 |
if ($s >= 45) return 'D'; |
| 167 |
return 'F'; |
| 168 |
} |
| 169 |
|
| 170 |
/** @return array{0:string,1:string,2:string} verdict, label, hex color */ |
| 171 |
public static function score_to_verdict(int $s): array { |
| 172 |
if ($s >= 85) return ['ok', 'Excellent', '#00a32a']; |
| 173 |
if ($s >= 70) return ['ok', 'Good', '#5e9b1e']; |
| 174 |
if ($s >= 50) return ['warn', 'Needs attention', '#dba617']; |
| 175 |
return ['critical', 'Critical', '#d63638']; |
| 176 |
} |
| 177 |
|
| 178 |
private static function weighted_average(array $scores, array $weights): int { |
| 179 |
$sum_w = 0; |
| 180 |
$sum = 0; |
| 181 |
foreach ($weights as $key => $w) { |
| 182 |
if (!isset($scores[$key]) || $scores[$key] === null) continue; |
| 183 |
$sum_w += $w; |
| 184 |
$sum += $scores[$key] * $w; |
| 185 |
} |
| 186 |
if ($sum_w === 0) return 0; |
| 187 |
return (int) round($sum / $sum_w); |
| 188 |
} |
| 189 |
|
| 190 |
private static function eol_score(array $eol): ?int { |
| 191 |
if (!isset($eol['status'])) return null; |
| 192 |
if ($eol['status'] === 'eol') return 0; |
| 193 |
if ($eol['status'] === 'unknown') return null; |
| 194 |
$days = (int) ($eol['days'] ?? 0); |
| 195 |
if ($days < 90) return 25; |
| 196 |
if ($days < 180) return 55; |
| 197 |
if ($days < 365) return 80; |
| 198 |
return 100; |
| 199 |
} |
| 200 |
|
| 201 |
private static function db_eol_score(array $db): ?int { |
| 202 |
if (empty($db['eol'])) return null; |
| 203 |
$eol_ts = strtotime($db['eol']); |
| 204 |
if (!$eol_ts) return null; |
| 205 |
$days = (int) floor(($eol_ts - time()) / DAY_IN_SECONDS); |
| 206 |
if ($days < 0) return 0; |
| 207 |
if ($days < 90) return 25; |
| 208 |
if ($days < 180) return 55; |
| 209 |
if ($days < 365) return 80; |
| 210 |
return 100; |
| 211 |
} |
| 212 |
|
| 213 |
private static function opcache_score(array $op): int { |
| 214 |
if (!($op['enabled'] ?? false)) return 0; |
| 215 |
$rate = (float) ($op['hit_rate'] ?? 0); |
| 216 |
if ($rate >= 95) return 100; |
| 217 |
if ($rate >= 90) return 90; |
| 218 |
if ($rate >= 70) return 70; |
| 219 |
if ($rate >= 50) return 45; |
| 220 |
return max(10, (int) $rate); // anything below 50% is broken-looking |
| 221 |
} |
| 222 |
|
| 223 |
private static function ssl_score(array $ssl): ?int { |
| 224 |
$min = null; |
| 225 |
foreach ($ssl as $c) { |
| 226 |
if (!empty($c['error'])) continue; |
| 227 |
$days = (int) ($c['days'] ?? 0); |
| 228 |
if ($min === null || $days < $min) $min = $days; |
| 229 |
} |
| 230 |
if ($min === null) return null; |
| 231 |
if ($min < 0) return 0; |
| 232 |
if ($min < 14) return 30; |
| 233 |
if ($min < 30) return 60; |
| 234 |
if ($min < 90) return 85; |
| 235 |
return 100; |
| 236 |
} |
| 237 |
|
| 238 |
private static function cron_score(array $cron): int { |
| 239 |
if (empty($cron)) return 100; |
| 240 |
$score = 100; |
| 241 |
$score -= min(50, ((int) ($cron['overdue'] ?? 0)) * 8); |
| 242 |
$score -= min(30, ((int) ($cron['orphan'] ?? 0)) * 4); |
| 243 |
return max(0, $score); |
| 244 |
} |
| 245 |
|
| 246 |
// ───────────────────────────────────────────────────────────────── |
| 247 |
// Extra checks (added in 7.0.3 for the report overhaul) |
| 248 |
// ───────────────────────────────────────────────────────────────── |
| 249 |
|
| 250 |
private static function wp_debug_state(): array { |
| 251 |
return [ |
| 252 |
'debug' => defined('WP_DEBUG') && WP_DEBUG, |
| 253 |
'debug_log' => defined('WP_DEBUG_LOG') && WP_DEBUG_LOG !== false, |
| 254 |
'debug_display' => defined('WP_DEBUG_DISPLAY') ? (bool) WP_DEBUG_DISPLAY : true, |
| 255 |
]; |
| 256 |
} |
| 257 |
|
| 258 |
private static function https_state(): array { |
| 259 |
$siteurl = (string) get_option('siteurl', ''); |
| 260 |
$home = (string) get_option('home', ''); |
| 261 |
$site_https = strncmp($siteurl, 'https://', strlen('https://')) === 0; |
| 262 |
$home_https = strncmp($home, 'https://', strlen('https://')) === 0; |
| 263 |
return [ |
| 264 |
'is_https' => is_ssl(), |
| 265 |
'siteurl_https' => $site_https, |
| 266 |
'home_https' => $home_https, |
| 267 |
'mixed_risk' => !($site_https && $home_https), |
| 268 |
]; |
| 269 |
} |
| 270 |
|
| 271 |
private static function updates_state(): array { |
| 272 |
// wp_get_update_data is in WP 3.5+ — safe to call directly |
| 273 |
$data = function_exists('wp_get_update_data') ? wp_get_update_data() : ['counts' => []]; |
| 274 |
$c = $data['counts'] ?? []; |
| 275 |
return [ |
| 276 |
'core' => (int) ($c['wordpress'] ?? 0), |
| 277 |
'plugins' => (int) ($c['plugins'] ?? 0), |
| 278 |
'themes' => (int) ($c['themes'] ?? 0), |
| 279 |
'translations' => (int) ($c['translations'] ?? 0), |
| 280 |
]; |
| 281 |
} |
| 282 |
|
| 283 |
private static function detect_backup(): array { |
| 284 |
// Map of plugin_basename → display name. We check active_plugins |
| 285 |
// because a backup plugin that's installed-but-deactivated isn't |
| 286 |
// running any scheduled backup. |
| 287 |
$known = [ |
| 288 |
'updraftplus/updraftplus.php' => 'UpdraftPlus', |
| 289 |
'backwpup/backwpup.php' => 'BackWPup', |
| 290 |
'duplicator/duplicator.php' => 'Duplicator', |
| 291 |
'duplicator-pro/duplicator-pro.php' => 'Duplicator Pro', |
| 292 |
'wpvivid-backuprestore/wpvivid-backuprestore.php' => 'WPvivid Backup', |
| 293 |
'all-in-one-wp-migration/all-in-one-wp-migration.php' => 'All-in-One WP Migration', |
| 294 |
'backupbuddy/backupbuddy.php' => 'Solid Backups', |
| 295 |
'wp-time-capsule/wp-time-capsule.php' => 'WP Time Capsule', |
| 296 |
'akeeba-backup-core-for-wordpress/akeeba-solo-wp.php' => 'Akeeba Backup', |
| 297 |
'wp-staging/wp-staging.php' => 'WP Staging', |
| 298 |
'jetpack/jetpack.php' => 'Jetpack (VaultPress)', |
| 299 |
'blogvault-real-time-backup/blogvault.php' => 'BlogVault', |
| 300 |
]; |
| 301 |
$active = (array) get_option('active_plugins', []); |
| 302 |
foreach ($known as $path => $name) { |
| 303 |
if (in_array($path, $active, true)) { |
| 304 |
return ['detected' => true, 'plugin' => $name]; |
| 305 |
} |
| 306 |
} |
| 307 |
return ['detected' => false, 'plugin' => null]; |
| 308 |
} |
| 309 |
|
| 310 |
private static function permission_state(): array { |
| 311 |
$files = [ |
| 312 |
'wp-config.php' => ABSPATH . 'wp-config.php', |
| 313 |
'.htaccess' => ABSPATH . '.htaccess', |
| 314 |
]; |
| 315 |
$out = []; |
| 316 |
foreach ($files as $name => $path) { |
| 317 |
if (!file_exists($path)) { $out[$name] = ['exists' => false]; continue; } |
| 318 |
$raw = @fileperms($path); |
| 319 |
$perms = $raw === false ? '?' : substr(sprintf('%o', $raw), -3); |
| 320 |
// Last octal digit > 0 = world-perms set. For wp-config we want |
| 321 |
// 600/640 ideally; 644 is the WP default and is widely tolerated. |
| 322 |
$world = is_string($perms) && strlen($perms) === 3 ? (int) $perms[2] : 0; |
| 323 |
$verdict = 'ok'; |
| 324 |
if ($name === 'wp-config.php' && $world > 4) $verdict = 'warn'; |
| 325 |
if ($world > 5) $verdict = 'critical'; |
| 326 |
$out[$name] = ['exists' => true, 'perms' => $perms, 'verdict' => $verdict]; |
| 327 |
} |
| 328 |
return $out; |
| 329 |
} |
| 330 |
|
| 331 |
// ───────────────────────────────────────────────────────────────── |
| 332 |
// Issue collection — produces the criticals banner + priority cards |
| 333 |
// ───────────────────────────────────────────────────────────────── |
| 334 |
|
| 335 |
/** |
| 336 |
* @return array<int, array{title:string,reason:string,how:string,urgency:string,category:string}> |
| 337 |
*/ |
| 338 |
private static function collect_issues( |
| 339 |
array $eol, array $grader, array $headers, array $ssl, ?array $opcache, |
| 340 |
array $db, array $autoload, array $cron, array $extras |
| 341 |
): array { |
| 342 |
$out = []; |
| 343 |
|
| 344 |
// PHP EOL |
| 345 |
if (($eol['status'] ?? '') === 'eol') { |
| 346 |
$out[] = self::issue('critical', 'Compatibility', |
| 347 |
'PHP has reached end-of-life', |
| 348 |
sprintf('You are running PHP %s, which is past its support window. New security patches will not be issued.', PHP_VERSION), |
| 349 |
'Ask your host to upgrade to PHP 8.2 or newer. Most managed hosts (Kinsta, WP Engine, SiteGround, Cloudways) have a one-click PHP switcher in the control panel.' |
| 350 |
); |
| 351 |
} elseif (($eol['status'] ?? '') === 'warning' && ($eol['days'] ?? 9999) < 90) { |
| 352 |
$out[] = self::issue('critical', 'Compatibility', |
| 353 |
sprintf('PHP nears end-of-life in %d days', (int) $eol['days']), |
| 354 |
sprintf('PHP %s loses official security support on %s.', $eol['minor'] ?? PHP_VERSION, $eol['eol'] ?? '—'), |
| 355 |
'Plan a PHP upgrade with your host this month. Run the Compatibility Scanner first to catch plugins that need PHP 8.x.' |
| 356 |
); |
| 357 |
} elseif (($eol['status'] ?? '') === 'warning') { |
| 358 |
$out[] = self::issue('warning', 'Compatibility', |
| 359 |
sprintf('PHP loses support in %d days', (int) $eol['days']), |
| 360 |
sprintf('PHP %s EOL date is %s. After that, no more security fixes.', $eol['minor'] ?? PHP_VERSION, $eol['eol'] ?? '—'), |
| 361 |
'Schedule a PHP upgrade in your maintenance window before that date.' |
| 362 |
); |
| 363 |
} |
| 364 |
|
| 365 |
// Database EOL |
| 366 |
if (!empty($db['eol'])) { |
| 367 |
$eol_ts = strtotime($db['eol']); |
| 368 |
if ($eol_ts) { |
| 369 |
$days = (int) floor(($eol_ts - time()) / DAY_IN_SECONDS); |
| 370 |
if ($days < 0) { |
| 371 |
$out[] = self::issue('critical', 'Database', |
| 372 |
sprintf('%s %s is past end-of-life', $db['engine'], $db['version']), |
| 373 |
sprintf('Your database server reached EOL on %s. New security patches are no longer issued.', $db['eol']), |
| 374 |
sprintf('Ask your host to migrate to a supported %s version, or move to MySQL 8 / MariaDB 10.11 LTS.', $db['engine']) |
| 375 |
); |
| 376 |
} elseif ($days < 90) { |
| 377 |
$out[] = self::issue('critical', 'Database', |
| 378 |
sprintf('%s %s EOL in %d days', $db['engine'], $db['version'], $days), |
| 379 |
sprintf('Your database loses official support on %s — that\'s within the next 90 days.', $db['eol']), |
| 380 |
sprintf('Coordinate a %s upgrade with your host this month. Test a staging copy first.', $db['engine']) |
| 381 |
); |
| 382 |
} elseif ($days < 365) { |
| 383 |
$out[] = self::issue('warning', 'Database', |
| 384 |
sprintf('%s %s EOL in %d days', $db['engine'], $db['version'], $days), |
| 385 |
sprintf('Database EOL date is %s. Plan an upgrade well before then.', $db['eol']), |
| 386 |
sprintf('Move to a supported %s version on your host.', $db['engine']) |
| 387 |
); |
| 388 |
} |
| 389 |
} |
| 390 |
} |
| 391 |
|
| 392 |
// OPcache |
| 393 |
if ($opcache) { |
| 394 |
$rate = (float) ($opcache['hit_rate'] ?? 0); |
| 395 |
if (($opcache['enabled'] ?? false) === false) { |
| 396 |
$out[] = self::issue('critical', 'Performance', |
| 397 |
'OPcache is disabled', |
| 398 |
'PHP\'s bytecode cache is off. Every request re-compiles all PHP files from scratch.', |
| 399 |
'Set <code>opcache.enable=1</code> in php.ini, or ask your host to enable OPcache. Most modern hosts ship it on by default.' |
| 400 |
); |
| 401 |
} elseif (!empty($opcache['full'])) { |
| 402 |
$out[] = self::issue('critical', 'Performance', |
| 403 |
'OPcache memory is full', |
| 404 |
'The bytecode cache filled up — new scripts can\'t be cached, so they recompile on every hit.', |
| 405 |
'Increase <code>opcache.memory_consumption</code> (try 256MB) and <code>opcache.max_accelerated_files</code> (try 20000) in php.ini.' |
| 406 |
); |
| 407 |
} elseif ($rate > 0 && $rate < 50) { |
| 408 |
$out[] = self::issue('critical', 'Performance', |
| 409 |
sprintf('OPcache hit rate is %.1f%%', $rate), |
| 410 |
sprintf('A healthy site sits at 95%%+. Yours is at %.1f%% — meaning %d%% of PHP requests recompile from scratch.', $rate, 100 - (int) $rate), |
| 411 |
'Usually means the cache is too small or being reset constantly. Raise <code>opcache.memory_consumption</code>, check <code>opcache.validate_timestamps</code>, and stop any plugin that calls opcache_reset() on schedule.' |
| 412 |
); |
| 413 |
} elseif ($rate > 0 && $rate < 90) { |
| 414 |
$out[] = self::issue('warning', 'Performance', |
| 415 |
sprintf('OPcache hit rate is %.1f%% (target: 95%%+)', $rate), |
| 416 |
'Some PHP requests are recompiling. Usually fine on busy sites just after a deploy, but persistent low rates mean the cache is undersized.', |
| 417 |
'Increase <code>opcache.memory_consumption</code> and re-check after 24 hours.' |
| 418 |
); |
| 419 |
} |
| 420 |
} |
| 421 |
|
| 422 |
// Config grader (the overall score is more useful than per-check noise here) |
| 423 |
if (!empty($grader) && isset($grader['score'])) { |
| 424 |
$s = (int) $grader['score']; |
| 425 |
if ($s < 60) { |
| 426 |
$out[] = self::issue('critical', 'Configuration', |
| 427 |
sprintf('PHP config grade is %s', $grader['grade'] ?? 'F'), |
| 428 |
sprintf('Several PHP directives are misconfigured (score %d/100). This usually means slow page loads or weakened security.', $s), |
| 429 |
'Open <strong>Config Grader</strong> and click <em>Fix it</em> on each failing row — auto-fix writes the recommended value to .htaccess with rollback if anything breaks.' |
| 430 |
); |
| 431 |
} elseif ($s < 80) { |
| 432 |
$out[] = self::issue('warning', 'Configuration', |
| 433 |
sprintf('PHP config grade is %s', $grader['grade'] ?? 'C'), |
| 434 |
sprintf('%d/100 — a handful of directives are not at recommended values.', $s), |
| 435 |
'Open Config Grader and apply auto-fixes for the failing rows.' |
| 436 |
); |
| 437 |
} |
| 438 |
} |
| 439 |
|
| 440 |
// Security headers |
| 441 |
if (!isset($headers['error']) && isset($headers['score'])) { |
| 442 |
$s = (int) $headers['score']; |
| 443 |
$missing = array_filter($headers['results'] ?? [], function ($h) { |
| 444 |
return !$h['present']; |
| 445 |
}); |
| 446 |
$n = count($missing); |
| 447 |
if ($s < 50) { |
| 448 |
$out[] = self::issue('critical', 'Security', |
| 449 |
sprintf('Missing %d security header%s', $n, $n === 1 ? '' : 's'), |
| 450 |
sprintf('Score %d/100. Missing headers like Content-Security-Policy and Strict-Transport-Security let attackers downgrade HTTPS, embed your pages in iframes, or run injected scripts.', $s), |
| 451 |
'Open <strong>Security Headers</strong> in the plugin → click each missing header for the exact .htaccess/Nginx snippet to add.' |
| 452 |
); |
| 453 |
} elseif ($s < 80) { |
| 454 |
$out[] = self::issue('warning', 'Security', |
| 455 |
sprintf('%d security header%s missing', $n, $n === 1 ? '' : 's'), |
| 456 |
sprintf('Score %d/100. Some lower-impact headers are absent.', $s), |
| 457 |
'Add the recommended headers from Security Headers → fix list.' |
| 458 |
); |
| 459 |
} |
| 460 |
} |
| 461 |
|
| 462 |
// SSL |
| 463 |
foreach ($ssl as $cert) { |
| 464 |
if (!empty($cert['error'])) continue; |
| 465 |
$days = (int) ($cert['days'] ?? 0); |
| 466 |
$host = $cert['host'] ?? 'site'; |
| 467 |
if ($days < 0) { |
| 468 |
$out[] = self::issue('critical', 'Security', |
| 469 |
sprintf('SSL certificate for %s has expired', $host), |
| 470 |
sprintf('Expired %d day(s) ago. Browsers now show a full-page warning before letting visitors continue.', abs($days)), |
| 471 |
'Renew the certificate via your host\'s SSL panel or Let\'s Encrypt. Most managed hosts auto-renew — check the SSL settings page.' |
| 472 |
); |
| 473 |
} elseif ($days < 14) { |
| 474 |
$out[] = self::issue('critical', 'Security', |
| 475 |
sprintf('SSL expires in %d day(s) for %s', $days, $host), |
| 476 |
'You are inside the 14-day expiry window. Visitors will get a browser warning when this lapses.', |
| 477 |
'Trigger a renewal now (most hosts have a "Renew" button), or check that Let\'s Encrypt auto-renewal is healthy.' |
| 478 |
); |
| 479 |
} elseif ($days < 30) { |
| 480 |
$out[] = self::issue('warning', 'Security', |
| 481 |
sprintf('SSL expires in %d days for %s', $days, $host), |
| 482 |
'Renewal hasn\'t fired yet. If your host auto-renews, this should clear automatically within the next 7 days.', |
| 483 |
'Confirm with your host that auto-renewal is on. Otherwise queue a manual renewal.' |
| 484 |
); |
| 485 |
} |
| 486 |
} |
| 487 |
|
| 488 |
// WP_DEBUG_DISPLAY on (security) |
| 489 |
if (!empty($extras['wp_debug']['debug']) && !empty($extras['wp_debug']['debug_display'])) { |
| 490 |
$out[] = self::issue('critical', 'Security', |
| 491 |
'WP_DEBUG_DISPLAY is on in production', |
| 492 |
'PHP errors and notices are being printed to every visitor. Stack traces can leak credentials, table prefixes, and plugin paths.', |
| 493 |
'In wp-config.php set <code>define(\'WP_DEBUG_DISPLAY\', false);</code> and <code>@ini_set(\'display_errors\', \'0\');</code>. Keep WP_DEBUG_LOG on so you still see errors in the log.' |
| 494 |
); |
| 495 |
} |
| 496 |
|
| 497 |
// HTTPS mismatch |
| 498 |
if (!empty($extras['https']) && $extras['https']['mixed_risk']) { |
| 499 |
$out[] = self::issue('warning', 'Security', |
| 500 |
'WordPress URLs are not fully on HTTPS', |
| 501 |
sprintf('siteurl=%s, home=%s. Browsers will block mixed-content resources and search engines treat HTTP as a downgrade.', $extras['https']['siteurl_https'] ? 'HTTPS' : 'HTTP', $extras['https']['home_https'] ? 'HTTPS' : 'HTTP'), |
| 502 |
'Update both URLs in Settings → General to use https://. If the site is already serving HTTPS, also run a search-replace in the database (with a tool like wp-cli search-replace or Better Search Replace).' |
| 503 |
); |
| 504 |
} |
| 505 |
|
| 506 |
// Autoload bloat |
| 507 |
if (!empty($autoload) && (int) $autoload['bytes'] > 1024 * 1024) { |
| 508 |
$size = size_format((int) $autoload['bytes']); |
| 509 |
$out[] = self::issue('warning', 'Performance', |
| 510 |
sprintf('Autoload data is %s', $size), |
| 511 |
sprintf('WordPress loads %s of options on every page request. Anything above ~1 MB starts to slow down each page load measurably.', $size), |
| 512 |
'Open <strong>Database Health</strong> and prune the largest autoloaded options — they\'re usually leftover from removed plugins.' |
| 513 |
); |
| 514 |
} |
| 515 |
|
| 516 |
// Outdated WP / plugins / themes |
| 517 |
$u = $extras['updates'] ?? null; |
| 518 |
if ($u) { |
| 519 |
if ($u['core'] > 0) { |
| 520 |
$out[] = self::issue('warning', 'Maintenance', |
| 521 |
'WordPress core has an update available', |
| 522 |
'Running an outdated WP version means missing security patches and bug fixes.', |
| 523 |
'Open <strong>Dashboard → Updates</strong> and update WordPress. Back up first — your backup plugin (if installed) can do this in one click.' |
| 524 |
); |
| 525 |
} |
| 526 |
$pt = $u['plugins'] + $u['themes']; |
| 527 |
if ($pt >= 5) { |
| 528 |
$out[] = self::issue('warning', 'Maintenance', |
| 529 |
sprintf('%d plugin/theme updates pending', $pt), |
| 530 |
'Updates often include security patches. Letting them pile up grows the surface area for attacks.', |
| 531 |
'Update in batches: back up, run the updates, smoke-test the site, repeat. Consider enabling auto-updates for plugins from trusted vendors.' |
| 532 |
); |
| 533 |
} |
| 534 |
} |
| 535 |
|
| 536 |
// Backups |
| 537 |
if (!empty($extras['backup']) && empty($extras['backup']['detected'])) { |
| 538 |
$out[] = self::issue('warning', 'Maintenance', |
| 539 |
'No backup plugin detected', |
| 540 |
'We couldn\'t find UpdraftPlus, BackWPup, Duplicator, BlogVault, or any other major backup plugin running on this site.', |
| 541 |
'Install a backup plugin and schedule daily off-site backups. Without one, a single bad update can mean a manual restore from your host\'s nightly snapshot — if they keep one.' |
| 542 |
); |
| 543 |
} |
| 544 |
|
| 545 |
// File permissions |
| 546 |
foreach (($extras['permissions'] ?? []) as $name => $info) { |
| 547 |
if (empty($info['exists'])) continue; |
| 548 |
if ($info['verdict'] === 'critical') { |
| 549 |
$out[] = self::issue('critical', 'Security', |
| 550 |
sprintf('%s is world-writable (perms %s)', $name, $info['perms']), |
| 551 |
'Any user on the server can rewrite this file. On a shared host this is an immediate compromise risk.', |
| 552 |
sprintf('SSH or use your host\'s file manager to <code>chmod 600 %s</code> (or 640).', $name) |
| 553 |
); |
| 554 |
} elseif ($info['verdict'] === 'warn') { |
| 555 |
$out[] = self::issue('warning', 'Security', |
| 556 |
sprintf('%s permissions are loose (perms %s)', $name, $info['perms']), |
| 557 |
'Tightening permissions on this file reduces the blast radius if another user account on the server is compromised.', |
| 558 |
sprintf('Run <code>chmod 600 %s</code> for the strictest setting, or 640 if your hosting setup needs group read.', $name) |
| 559 |
); |
| 560 |
} |
| 561 |
} |
| 562 |
|
| 563 |
// Cron |
| 564 |
if (!empty($cron)) { |
| 565 |
$overdue = (int) ($cron['overdue'] ?? 0); |
| 566 |
$orphan = (int) ($cron['orphan'] ?? 0); |
| 567 |
if ($overdue >= 10) { |
| 568 |
$out[] = self::issue('critical', 'Maintenance', |
| 569 |
sprintf('%d overdue cron events', $overdue), |
| 570 |
'Scheduled tasks aren\'t running. Backups, email digests, transient cleanup, plugin-defined jobs — all stalled.', |
| 571 |
'WP-Cron usually fires when someone visits the site. If traffic is low, set up a real system cron calling <code>wp-cron.php</code> every 5 min, or set <code>DISABLE_WP_CRON</code> and call it from your host\'s scheduler.' |
| 572 |
); |
| 573 |
} elseif ($overdue > 0) { |
| 574 |
$out[] = self::issue('warning', 'Maintenance', |
| 575 |
sprintf('%d cron event(s) overdue', $overdue), |
| 576 |
'Some scheduled tasks didn\'t fire on time. Usually fine on busy sites — just means cron processed the queue slowly.', |
| 577 |
'If this persists, switch to a system cron triggering wp-cron.php on a fixed interval.' |
| 578 |
); |
| 579 |
} |
| 580 |
if ($orphan > 0) { |
| 581 |
$out[] = self::issue('warning', 'Maintenance', |
| 582 |
sprintf('%d orphan cron hook(s)', $orphan), |
| 583 |
'These cron events have no callback registered — usually leftovers from plugins you removed. They fire forever doing nothing.', |
| 584 |
'Open <strong>WP Cron Monitor</strong> → click <em>Purge hook</em> on orphan rows.' |
| 585 |
); |
| 586 |
} |
| 587 |
} |
| 588 |
|
| 589 |
return $out; |
| 590 |
} |
| 591 |
|
| 592 |
private static function issue(string $urgency, string $category, string $title, string $reason, string $how): array { |
| 593 |
return compact('urgency', 'category', 'title', 'reason', 'how'); |
| 594 |
} |
| 595 |
|
| 596 |
/** |
| 597 |
* Build the top-N action list. Pull all criticals (sorted as they came in |
| 598 |
* — collect_issues lists them by impact), then top up with the highest |
| 599 |
* warnings until we reach N. |
| 600 |
*/ |
| 601 |
private static function prioritize(array $issues, int $n): array { |
| 602 |
$crit = array_values(array_filter($issues, function ($i) { |
| 603 |
return $i['urgency'] === 'critical'; |
| 604 |
})); |
| 605 |
$warn = array_values(array_filter($issues, function ($i) { |
| 606 |
return $i['urgency'] === 'warning'; |
| 607 |
})); |
| 608 |
$out = array_merge($crit, $warn); |
| 609 |
return array_slice($out, 0, $n); |
| 610 |
} |
| 611 |
|
| 612 |
// ───────────────────────────────────────────────────────────────── |
| 613 |
// Plain-English captions for tech jargon in the report |
| 614 |
// ───────────────────────────────────────────────────────────────── |
| 615 |
|
| 616 |
public static function plain_english(string $key): string { |
| 617 |
static $map = [ |
| 618 |
'php_version' => 'The PHP version running your site. Newer = faster and safer.', |
| 619 |
'wp_version' => 'WordPress core version. Older versions miss security patches.', |
| 620 |
'config_grade' => 'A–F grade of your PHP config against best practices.', |
| 621 |
'headers' => 'HTTP response headers that tell browsers how to protect users.', |
| 622 |
'opcache' => "PHP's bytecode cache — speeds up every page load.", |
| 623 |
'opcache_hit' => '% of requests that found pre-compiled code in cache. Target: 95%+.', |
| 624 |
'autoload' => 'Data WordPress loads on every page request. Big = slow.', |
| 625 |
'db_engine' => 'Database server. Each version has a security support window.', |
| 626 |
'overdue_cron' => 'Scheduled tasks that should have run but didn\'t.', |
| 627 |
'orphan_cron' => 'Scheduled tasks whose plugin is gone — they run forever doing nothing.', |
| 628 |
'mixed_content' => 'HTTPS pages loading HTTP resources — browser blocks them.', |
| 629 |
'wp_debug_display' => 'Whether PHP errors are printed to visitors (should be off in production).', |
| 630 |
'memory_peak' => 'Highest RAM PHP used today. If it nears the limit, raise memory_limit.', |
| 631 |
]; |
| 632 |
return $map[$key] ?? ''; |
| 633 |
} |
| 634 |
|
| 635 |
// ───────────────────────────────────────────────────────────────── |
| 636 |
// Small UI helper used by the view to render a score donut |
| 637 |
// ───────────────────────────────────────────────────────────────── |
| 638 |
|
| 639 |
public static function render_donut(int $score, int $size = 160, string $color = '#7c3aed', string $bg = '#eef0f4', int $stroke = 14): string { |
| 640 |
$score = max(0, min(100, $score)); |
| 641 |
$r = ($size - $stroke) / 2; |
| 642 |
$circ = 2 * M_PI * $r; |
| 643 |
$dash = $circ * (100 - $score) / 100; |
| 644 |
$half = $size / 2; |
| 645 |
// Slightly bigger if score < 50 — emphasises the gap visually. |
| 646 |
ob_start(); |
| 647 |
?> |
| 648 |
<svg width="<?php echo (int) $size; ?>" height="<?php echo (int) $size; ?>" viewBox="0 0 <?php echo (int) $size; ?> <?php echo (int) $size; ?>" class="phpinfowp-donut" role="img" aria-label="Score <?php echo (int) $score; ?> out of 100"> |
| 649 |
<circle cx="<?php echo $half; ?>" cy="<?php echo $half; ?>" r="<?php echo $r; ?>" fill="none" stroke="<?php echo esc_attr($bg); ?>" stroke-width="<?php echo (int) $stroke; ?>"></circle> |
| 650 |
<circle cx="<?php echo $half; ?>" cy="<?php echo $half; ?>" r="<?php echo $r; ?>" fill="none" |
| 651 |
stroke="<?php echo esc_attr($color); ?>" stroke-width="<?php echo (int) $stroke; ?>" |
| 652 |
stroke-dasharray="<?php echo $circ; ?>" stroke-dashoffset="<?php echo $dash; ?>" |
| 653 |
stroke-linecap="round" transform="rotate(-90 <?php echo $half; ?> <?php echo $half; ?>)"></circle> |
| 654 |
</svg> |
| 655 |
<?php |
| 656 |
return (string) ob_get_clean(); |
| 657 |
} |
| 658 |
} |
| 659 |
|