PluginProbe
phpinfo() WP – Site Health, PHP Compatibility & Server Audit / 7.2.6
phpinfo() WP – Site Health, PHP Compatibility & Server Audit v7.2.6
7.2.7 7.2.6 7.2.5 7.2.4 7.2.3 7.2.0 7.2.1 7.2.2 7.1.0 7.0.3 7.0.4 7.0.5 trunk 6.0 7.0.0 7.0.1 7.0.2
phpinfo-wp / includes / class-report.php

class-report.php in phpinfo() WP – Site Health, PHP Compatibility & Server Audit 7.2.6, at includes/class-report.php

659 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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