PluginProbe
phpinfo() WP – Site Health, PHP Compatibility & Server Audit / 7.2.5
phpinfo() WP – Site Health, PHP Compatibility & Server Audit v7.2.5
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-update-audit.php

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

723 lines 36.1 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 /**
5 * Update Guard — pre-update WordPress core readiness audit.
6 *
7 * Sibling of Phpinfo_WP_Compat, but where that one targets a PHP version,
8 * this one targets a WordPress *core* version and answers "what in my
9 * plugins/themes will break (or start whining) when I click Update".
10 *
11 * Three signals are blended into one verdict (Safe / Caution / Risky):
12 *
13 * A. Static code scan (on-server, no network) — the FREE headline engine.
14 * - PHP: calls to WordPress core functions that core has deprecated
15 * (they still run but emit _deprecated_function notices and are on the
16 * path to removal).
17 * - JS: jQuery APIs removed from the jQuery bundled with modern core
18 * (WP 5.7 dropped jQuery Migrate's default load). These break only when
19 * Migrate isn't loaded, so we check the live front end once and label
20 * them "breaks" (Migrate absent) vs "deprecated" (Migrate still present)
21 * rather than crying wolf.
22 *
23 * B. Metadata risk (WP.org API) — PRO. Per installed plugin/theme:
24 * "Tested up to" gap vs the target core, abandonment (last-updated age,
25 * closed listing), and requires_php vs the PHP the new core needs.
26 *
27 * Free vs Pro split mirrors Phpinfo_WP_Compat: the scan engine and a verdict
28 * are FREE (capture demand); Pro raises the file cap, adds the WP.org metadata
29 * layer, the per-item AI explanation, the pre-update interception banner on
30 * the core update screen, and per-file/line drill-down.
31 */
32 class Phpinfo_WP_Update_Audit {
33
34 const OPT_RESULT = 'phpinfowp_update_audit_result';
35 const TRANSIENT_META = 'phpinfowp_ua_meta_'; // + md5(slug)
36 const TRANSIENT_RULESET = 'phpinfowp_ua_ruleset'; // cloud ruleset cache (Pro)
37 const RULESET_URL = 'https://exeebit.com/api/update-guard/ruleset';
38 const MAX_FILE_SIZE = 1048576; // 1 MB — skip bigger files
39 const FREE_MAX_FILES = 1500;
40 const PRO_MAX_FILES = 8000;
41 const META_TTL = 12 * HOUR_IN_SECONDS;
42 const META_MAX_LOOKUPS = 40; // cap WP.org calls per run
43
44 private static function _pro(): bool { return Phpinfo_WP_License::is_valid(); }
45
46 // -------------------------------------------------------------------------
47 // Version helpers
48 // -------------------------------------------------------------------------
49
50 public static function current_wp(): string {
51 return (string) get_bloginfo('version');
52 }
53
54 /** The upgrade core is offering, if any (e.g. "7.0"). Null when up to date. */
55 public static function available_core_update(): ?string {
56 $u = get_site_transient('update_core');
57 if (is_object($u) && !empty($u->updates) && is_array($u->updates)) {
58 foreach ($u->updates as $upd) {
59 if (($upd->response ?? '') === 'upgrade' && !empty($upd->current)) {
60 return (string) $upd->current;
61 }
62 }
63 }
64 return null;
65 }
66
67 /**
68 * What core version to audit against. An offered update wins; otherwise we
69 * synthesise the next major so the user can dry-run a future jump. An
70 * explicit, validated ?target overrides both.
71 */
72 /**
73 * Retrieve the actual latest stable WordPress version from the local core update transient.
74 */
75 public static function latest_real_wp(): string {
76 $u = get_site_transient('update_core');
77 if (is_object($u) && !empty($u->updates) && is_array($u->updates)) {
78 foreach ($u->updates as $upd) {
79 if (!empty($upd->current)) {
80 return (string) $upd->current;
81 }
82 }
83 }
84 return self::current_wp();
85 }
86
87 private static function major_minor(string $v): string {
88 if (preg_match('/^(\d+\.\d+)/', $v, $m)) {
89 return $m[1];
90 }
91 return $v;
92 }
93
94 private static function next_major(string $v): string {
95 $v = self::major_minor($v);
96 if (preg_match('/^(\d+)\.(\d+)/', $v, $m)) {
97 $maj = (int) $m[1]; $min = (int) $m[2];
98 if ($min >= 9) return ($maj + 1) . '.0';
99 return $maj . '.' . ($min + 1);
100 }
101 return $v;
102 }
103
104 public static function default_target(): string {
105 $avail = self::available_core_update();
106 if ($avail) return self::major_minor($avail);
107
108 $latest_real = self::latest_real_wp();
109 $current = self::current_wp();
110
111 if (version_compare($current, $latest_real, '<')) {
112 return self::major_minor($latest_real);
113 }
114
115 return self::next_major($latest_real);
116 }
117
118 // -------------------------------------------------------------------------
119 // Rulesets
120 // -------------------------------------------------------------------------
121
122 /**
123 * WordPress core functions deprecated by a given core version. `since` is
124 * the core version that deprecated them. WordPress almost never *removes*
125 * functions, so these are notice-level by default — but a deprecated call
126 * is the clearest static signal that a plugin is no longer maintained
127 * against current core, and core has removed deprecated APIs before.
128 */
129 private static function baked_php_rules(): array {
130 return [
131 ['re' => '/(?<![\w>$])get_currentuserinfo\s*\(/', 'name' => 'get_currentuserinfo()', 'since' => '4.5', 'fix' => 'Use wp_get_current_user()'],
132 ['re' => '/(?<![\w>$])get_userdatabylogin\s*\(/', 'name' => 'get_userdatabylogin()', 'since' => '3.3', 'fix' => "Use get_user_by('login', …)"],
133 ['re' => '/(?<![\w>$])get_user_by_email\s*\(/', 'name' => 'get_user_by_email()', 'since' => '3.3', 'fix' => "Use get_user_by('email', …)"],
134 ['re' => '/(?<![\w>$])wp_get_http\s*\(/', 'name' => 'wp_get_http()', 'since' => '4.4', 'fix' => 'Use wp_remote_get()'],
135 ['re' => '/(?<![\w>$])screen_icon\s*\(/', 'name' => 'screen_icon()', 'since' => '3.8', 'fix' => 'No replacement — remove the call'],
136 ['re' => '/(?<![\w>$])get_settings\s*\(/', 'name' => 'get_settings()', 'since' => '2.1', 'fix' => 'Use get_option()'],
137 ['re' => '/(?<![\w>$])attribute_escape\s*\(/', 'name' => 'attribute_escape()', 'since' => '2.8', 'fix' => 'Use esc_attr()'],
138 ['re' => '/(?<![\w>$])clean_url\s*\(/', 'name' => 'clean_url()', 'since' => '3.0', 'fix' => 'Use esc_url()'],
139 ['re' => '/(?<![\w>$])js_escape\s*\(/', 'name' => 'js_escape()', 'since' => '2.8', 'fix' => 'Use esc_js()'],
140 ['re' => '/(?<![\w>$])wp_specialchars\s*\(/', 'name' => 'wp_specialchars()', 'since' => '2.8', 'fix' => 'Use esc_html()'],
141 ['re' => '/(?<![\w>$])like_escape\s*\(/', 'name' => 'like_escape()', 'since' => '4.0', 'fix' => 'Use $wpdb->esc_like()'],
142 ['re' => '/(?<![\w>$])image_resize\s*\(/', 'name' => 'image_resize()', 'since' => '3.5', 'fix' => 'Use wp_get_image_editor()'],
143 ['re' => '/(?<![\w>$])wp_load_image\s*\(/', 'name' => 'wp_load_image()', 'since' => '3.5', 'fix' => 'Use wp_get_image_editor()'],
144 ['re' => '/(?<![\w>$])add_object_page\s*\(/', 'name' => 'add_object_page()', 'since' => '4.5', 'fix' => "Use add_menu_page()"],
145 ['re' => '/(?<![\w>$])add_utility_page\s*\(/', 'name' => 'add_utility_page()', 'since' => '4.5', 'fix' => 'Use add_menu_page()'],
146 ['re' => '/(?<![\w>$])wp_get_sites\s*\(/', 'name' => 'wp_get_sites()', 'since' => '4.6', 'fix' => 'Use get_sites()'],
147 ['re' => '/(?<![\w>$])wp_make_content_images_responsive\s*\(/', 'name' => 'wp_make_content_images_responsive()', 'since' => '5.5', 'fix' => 'Use wp_filter_content_tags()'],
148 ['re' => '/(?<![\w>$])wp_get_user_request_data\s*\(/', 'name' => 'wp_get_user_request_data()', 'since' => '4.9.6', 'fix' => 'Use wp_get_user_request()'],
149 ['re' => '/(?<![\w>$])get_page_by_title\s*\(/', 'name' => 'get_page_by_title()', 'since' => '6.2', 'fix' => 'Use WP_Query'],
150 ['re' => '/(?<![\w>$])get_the_author_email\s*\(/', 'name' => 'get_the_author_email()', 'since' => '2.8', 'fix' => "Use get_the_author_meta('email')"],
151 // PHP4-style constructors core no longer calls (real breakage on modern PHP).
152 ['re' => '/function\s+WP_Widget\s*\(/', 'def' => true, 'name' => 'PHP4-style WP_Widget constructor', 'since' => '4.3', 'fix' => 'Use __construct() and parent::__construct()'],
153 ];
154 }
155
156 /**
157 * jQuery APIs removed from the jQuery core bundled with modern WordPress.
158 * WP 5.6 shipped jQuery 3.5.x and stopped loading jQuery Migrate by
159 * default, so code using these silently fails on any current core.
160 * `since` = the core version where this became a hard break.
161 */
162 private static function baked_js_rules(): array {
163 return [
164 ['re' => '/\.live\s*\(/', 'name' => '.live()', 'since' => '5.7', 'fix' => 'Use .on() with delegation'],
165 ['re' => '/\.die\s*\(/', 'name' => '.die()', 'since' => '5.7', 'fix' => 'Use .off()'],
166 ['re' => '/\.size\s*\(\s*\)/', 'name' => '.size()', 'since' => '5.7', 'fix' => 'Use .length'],
167 ['re' => '/\bjQuery\.browser\b/', 'name' => 'jQuery.browser', 'since' => '5.7', 'fix' => 'Feature-detect instead'],
168 ['re' => '/\$\.browser\b/', 'name' => '$.browser', 'since' => '5.7', 'fix' => 'Feature-detect instead'],
169 ['re' => '/\.andSelf\s*\(/', 'name' => '.andSelf()', 'since' => '5.7', 'fix' => 'Use .addBack()'],
170 ['re' => '/\.toggle\s*\(\s*function/', 'name' => '.toggle(handler, handler)', 'since' => '5.7', 'fix' => 'Bind click handlers manually'],
171 ['re' => '/\bjQuery\.sub\s*\(/', 'name' => 'jQuery.sub()', 'since' => '5.7', 'fix' => 'No replacement — refactor'],
172 ['re' => '/\bjQuery\.fn\.error\s*\(/', 'name' => '.error() event', 'since' => '5.7', 'fix' => "Use .on('error', …)"],
173 ['re' => '/\bjQuery\.parseJSON\s*\(/', 'name' => 'jQuery.parseJSON()', 'since' => '5.7', 'fix' => 'Use JSON.parse()'],
174 ['re' => '/\b\$\.parseJSON\s*\(/', 'name' => '$.parseJSON()', 'since' => '5.7', 'fix' => 'Use JSON.parse()'],
175 ['re' => '/\bjQuery\.isArray\s*\(/', 'name' => 'jQuery.isArray()', 'since' => '5.7', 'fix' => 'Use Array.isArray()'],
176 ['re' => '/\bjQuery\.trim\s*\(/', 'name' => 'jQuery.trim()', 'since' => '5.7', 'fix' => 'Use String.prototype.trim()'],
177 ];
178 }
179
180 // -------------------------------------------------------------------------
181 // Cloud ruleset (Tier 1) — Pro pulls the latest rules from exeebit so new
182 // WordPress deprecations ship without a plugin update. Falls back to the
183 // baked-in sets above when free, offline, or the payload looks wrong.
184 // -------------------------------------------------------------------------
185
186 private static function php_rules(): array {
187 $r = self::remote_ruleset();
188 return ($r && !empty($r['php_rules'])) ? $r['php_rules'] : self::baked_php_rules();
189 }
190
191 private static function js_rules(): array {
192 $r = self::remote_ruleset();
193 return ($r && !empty($r['js_rules'])) ? $r['js_rules'] : self::baked_js_rules();
194 }
195
196 /** Where the active ruleset came from — drives a small UI badge. */
197 public static function ruleset_source(): array {
198 $r = self::remote_ruleset();
199 if ($r && (!empty($r['php_rules']) || !empty($r['js_rules']))) {
200 return ['source' => 'cloud', 'version' => (string) ($r['schema_version'] ?? ''), 'at' => (int) ($r['fetched_at'] ?? 0)];
201 }
202 return ['source' => 'built-in', 'version' => '', 'at' => 0];
203 }
204
205 /**
206 * Fetch + cache the cloud ruleset (Pro only). Cached 12h on success, 1h on
207 * failure so a flaky network doesn't hammer the endpoint. Returns null to
208 * mean "use baked-in".
209 */
210 private static function remote_ruleset(): ?array {
211 static $mem = null;
212 if ($mem !== null) return $mem ?: null;
213
214 if (!self::_pro()) { $mem = false; return null; }
215
216 $cached = get_transient(self::TRANSIENT_RULESET);
217 if (is_array($cached)) { $mem = $cached ?: false; return $cached ?: null; }
218
219 $resp = wp_remote_post(self::RULESET_URL, [
220 'timeout' => 6,
221 'body' => [
222 'license_key' => Phpinfo_WP_License::get_key(),
223 'site_url' => get_site_url(),
224 'plugin_v' => PHPINFOWP_VERSION,
225 ],
226 ]);
227
228 $data = [];
229 if (!is_wp_error($resp) && wp_remote_retrieve_response_code($resp) === 200) {
230 $body = json_decode(wp_remote_retrieve_body($resp), true);
231 if (is_array($body) && (!empty($body['php_rules']) || !empty($body['js_rules']))) {
232 $data = [
233 'schema_version' => $body['schema_version'] ?? 0,
234 'php_rules' => self::sanitize_rules($body['php_rules'] ?? []),
235 'js_rules' => self::sanitize_rules($body['js_rules'] ?? []),
236 'core_php_floor' => (isset($body['core_php_floor']) && is_array($body['core_php_floor'])) ? $body['core_php_floor'] : [],
237 'fetched_at' => time(),
238 ];
239 }
240 }
241
242 // Empty array = "fetched, nothing usable" → fall back, retry in 1h.
243 set_transient(self::TRANSIENT_RULESET, $data, $data ? self::META_TTL : HOUR_IN_SECONDS);
244 $mem = $data ?: false;
245 return $data ?: null;
246 }
247
248 /**
249 * Harden remotely-supplied rules before we ever run them through preg_*:
250 * enforce types, a delimiter, a sane count, and that the pattern actually
251 * compiles (rejects ReDoS-flavoured junk and the removed /e modifier).
252 */
253 private static function sanitize_rules($rules): array {
254 if (!is_array($rules)) return [];
255 $out = [];
256 foreach (array_slice($rules, 0, 300) as $r) {
257 if (!is_array($r)) continue;
258 $re = isset($r['re']) ? (string) $r['re'] : '';
259 $name = isset($r['name']) ? (string) $r['name'] : '';
260 $since = isset($r['since']) ? (string) $r['since'] : '';
261 $fix = isset($r['fix']) ? (string) $r['fix'] : '';
262 if ($re === '' || $name === '' || $since === '') continue;
263 if (strlen($re) < 3 || $re[0] !== '/') continue; // require /…/ form
264 if (@preg_match($re, '') === false) continue; // must compile
265 $rule = ['re' => $re, 'name' => $name, 'since' => $since, 'fix' => $fix];
266 if (!empty($r['def'])) $rule['def'] = true;
267 $out[] = $rule;
268 }
269 return $out;
270 }
271
272 // -------------------------------------------------------------------------
273 // Result storage
274 // -------------------------------------------------------------------------
275
276 public static function get_result(): ?array {
277 $r = get_option(self::OPT_RESULT, null);
278 return is_array($r) ? $r : null;
279 }
280
281 public static function clear(): void {
282 delete_option(self::OPT_RESULT);
283 }
284
285 // -------------------------------------------------------------------------
286 // Scan
287 // -------------------------------------------------------------------------
288
289 public static function scan(string $target): array {
290 @set_time_limit(180);
291 $started = microtime(true);
292
293 if (!preg_match('/^\d+\.\d+(\.\d+)?$/', $target)) {
294 return ['error' => 'Invalid target WordPress version.'];
295 }
296
297 $current = self::current_wp();
298 $is_pro = self::_pro();
299 $max_files = $is_pro ? self::PRO_MAX_FILES : self::FREE_MAX_FILES;
300
301 // Pre-filter rules to those that apply at/under the target core.
302 $php_rules = array_filter(self::php_rules(), function ($r) use ($target) {
303 return version_compare($target, $r['since'], '>=');
304 });
305 $js_rules = array_filter(self::js_rules(), function ($r) use ($target) {
306 return version_compare($target, $r['since'], '>=');
307 });
308
309 // jQuery removals only *break* when jQuery Migrate isn't loaded. Migrate
310 // still ships with WordPress and many themes/plugins re-enqueue it, so we
311 // check the live front end once and downgrade "breaks" → "deprecated"
312 // when Migrate is present. Honest beats alarmist.
313 $jqm = $js_rules ? self::jquery_migrate_status() : 'unknown';
314
315 $issues_by_owner = [];
316 $files_scanned = 0;
317 $files_skipped = 0;
318 $owners_seen = [];
319
320 // Don't audit ourselves: this plugin's own source lists every WP
321 // deprecated function name (as rule patterns + labels), so scanning it
322 // produces a comically false "21 deprecations" report on us.
323 $self_dir = basename(rtrim(PHPINFOWP_DIR, '/\\'));
324 $self_owner = 'plugin/' . $self_dir;
325
326 $roots = [
327 'plugin' => WP_PLUGIN_DIR,
328 'theme' => get_theme_root(),
329 'mu-plugin' => defined('WPMU_PLUGIN_DIR') ? WPMU_PLUGIN_DIR : WP_CONTENT_DIR . '/mu-plugins',
330 ];
331
332 foreach ($roots as $type => $root) {
333 if (!is_dir($root)) continue;
334 try {
335 $it = new RecursiveIteratorIterator(
336 new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
337 RecursiveIteratorIterator::LEAVES_ONLY
338 );
339 } catch (Throwable $e) { continue; }
340
341 foreach ($it as $file) {
342 if ($files_scanned + $files_skipped >= $max_files) break 2;
343 if (!$file->isFile()) continue;
344 $ext = strtolower($file->getExtension());
345 if ($ext !== 'php' && $ext !== 'js') continue;
346 if ($ext === 'js' && substr($file->getFilename(), -7) === '.min.js') { continue; }
347 if ($file->getSize() > self::MAX_FILE_SIZE) { $files_skipped++; continue; }
348
349 $abs = $file->getPathname();
350 $rel = ltrim(str_replace($root, '', $abs), '/\\');
351 $owner = self::owner_of($type, $rel);
352 if ($owner === null || $owner === $self_owner) continue;
353
354 $content = @file_get_contents($abs);
355 if ($content === false) { $files_skipped++; continue; }
356 $files_scanned++;
357 $owners_seen[$owner] = true;
358
359 $rules = $ext === 'php' ? $php_rules : $js_rules;
360 $kind = $ext === 'php' ? 'php' : 'js';
361 // JS only counts as a hard break when Migrate is confirmed absent;
362 // otherwise it's "deprecated but currently working".
363 $js_sev = $jqm === 'absent' ? 'breaks' : 'deprecated';
364 foreach ($rules as $rule) {
365 if (preg_match_all($rule['re'], $content, $m, PREG_OFFSET_CAPTURE)) {
366 foreach ($m[0] as $hit) {
367 // Skip false positives on a plugin's OWN code: a function
368 // definition (`function get_settings(`) or a static/scoped
369 // call (`Foo::get_settings(`) isn't a call to the core fn.
370 // Rules flagged 'def' deliberately match a definition (the
371 // PHP4 constructor), so they're exempt from this guard.
372 if ($kind === 'php' && empty($rule['def'])) {
373 if ($hit[1] > 0 && $content[$hit[1] - 1] === ':') continue;
374 $pre = substr($content, max(0, $hit[1] - 12), min(12, $hit[1]));
375 if (preg_match('/function\s+$/', $pre)) continue;
376 }
377 $line = substr_count((string) substr($content, 0, $hit[1]), "\n") + 1;
378 $issues_by_owner[$owner][] = [
379 'kind' => $kind,
380 'file' => $rel,
381 'line' => $line,
382 'name' => $rule['name'],
383 'fix' => $rule['fix'],
384 'since' => $rule['since'],
385 'severity' => $kind === 'js' ? $js_sev : 'deprecated',
386 'migrate' => $kind === 'js',
387 // True when this rule only becomes relevant *because* of this update.
388 'new_now' => version_compare($rule['since'], $current, '>'),
389 ];
390 }
391 }
392 }
393 }
394 }
395
396 // ---- Metadata layer (Pro) ----
397 $meta = [];
398 if ($is_pro) {
399 $meta = self::collect_metadata($target);
400 }
401
402 // ---- Per-owner rollup + verdict ----
403 $owners = [];
404 foreach ($owners_seen as $owner => $_) {
405 $list = $issues_by_owner[$owner] ?? [];
406 $breaks = 0; $depr = 0;
407 foreach ($list as $i) {
408 if ($i['severity'] === 'breaks') $breaks++; else $depr++;
409 }
410 $m = $meta[$owner] ?? null;
411 $owners[$owner] = [
412 'breaks' => $breaks,
413 'depr' => $depr,
414 'meta' => $m,
415 'verdict' => self::owner_verdict($breaks, $depr, $m),
416 'issues' => $list,
417 ];
418 }
419
420 $result = [
421 'target' => $target,
422 'current' => $current,
423 'verdict' => self::overall_verdict($owners),
424 'owners' => $owners,
425 'owner_count' => count($owners_seen),
426 'with_issues' => count($issues_by_owner),
427 'total_breaks' => array_sum(array_map(function ($o) { return $o['breaks']; }, $owners)),
428 'total_depr' => array_sum(array_map(function ($o) { return $o['depr']; }, $owners)),
429 'files' => $files_scanned,
430 'skipped' => $files_skipped,
431 'max_files' => $max_files,
432 'truncated' => ($files_scanned + $files_skipped) >= $max_files,
433 'duration' => round(microtime(true) - $started, 2),
434 'scanned_at' => time(),
435 'is_pro_result' => $is_pro,
436 'meta_checked' => $is_pro,
437 'jquery_migrate'=> $jqm,
438 ];
439
440 update_option(self::OPT_RESULT, $result, false);
441 return $result;
442 }
443
444 private static function owner_of(string $type, string $rel): ?string {
445 $parts = preg_split('#[\\\\/]+#', $rel);
446 if (!$parts) return null;
447 $first = $parts[0];
448 if ($first === '' || strncmp($first, '.', 1) === 0) return null;
449 // Themes always live in their own directory (style.css inside). A loose
450 // file in the themes root — like WordPress's "silence is golden"
451 // index.php — is not a theme.
452 if ($type === 'theme') {
453 return count($parts) >= 2 ? 'theme/' . $first : null;
454 }
455 // A single top-level .php file is a single-file plugin (e.g. Hello Dolly),
456 // EXCEPT the WordPress drop-in silence guard, which isn't a plugin.
457 if (count($parts) === 1) {
458 return strtolower($first) === 'index.php' ? null : $type . '/' . pathinfo($first, PATHINFO_FILENAME);
459 }
460 return $type . '/' . $first;
461 }
462
463 /**
464 * Is jQuery Migrate actually loaded on the front end? One cached loopback
465 * request, parsed for the migrate script handle. 'present' | 'absent' |
466 * 'unknown' (couldn't fetch — we then avoid the alarmist "breaks" label).
467 */
468 public static function jquery_migrate_status(): string {
469 $cached = get_transient('phpinfowp_ua_jqm');
470 if (is_string($cached) && $cached !== '') return $cached;
471
472 $resp = wp_remote_get(home_url('/'), [
473 'timeout' => 6,
474 'sslverify' => false,
475 'user-agent' => 'phpinfo-wp Update Guard',
476 ]);
477 $status = 'unknown';
478 if (!is_wp_error($resp) && wp_remote_retrieve_response_code($resp) < 400) {
479 $body = (string) wp_remote_retrieve_body($resp);
480 $status = stripos($body, 'jquery-migrate') !== false ? 'present' : 'absent';
481 }
482 set_transient('phpinfowp_ua_jqm', $status, 6 * HOUR_IN_SECONDS);
483 return $status;
484 }
485
486 // -------------------------------------------------------------------------
487 // Verdict logic
488 // -------------------------------------------------------------------------
489
490 // A single plugin/theme: risky if it has hard breaks or looks abandoned on
491 // a major jump; caution on deprecations or a meaningful tested-up-to gap.
492 private static function owner_verdict(int $breaks, int $depr, ?array $meta): string {
493 if ($breaks > 0) return 'risky';
494 if ($meta) {
495 // Hard signals: abandoned, or needs a PHP the site doesn't have.
496 if (!empty($meta['abandoned'])) return 'risky';
497 if (!empty($meta['php_blocks'])) return 'risky';
498 // "Tested up to" lag is a soft signal — caution at most, never risky
499 // on its own (authors routinely lag the header without breaking).
500 if ((int) ($meta['tested_gap'] ?? 0) >= 1) return 'caution';
501 }
502 if ($depr > 0) return 'caution';
503 return 'safe';
504 }
505
506 private static function overall_verdict(array $owners): string {
507 $worst = 'safe';
508 foreach ($owners as $o) {
509 if ($o['verdict'] === 'risky') return 'risky';
510 if ($o['verdict'] === 'caution') $worst = 'caution';
511 }
512 return $worst;
513 }
514
515 // -------------------------------------------------------------------------
516 // Metadata layer (Pro) — WP.org plugin/theme directory
517 // -------------------------------------------------------------------------
518
519 private static function collect_metadata(string $target): array {
520 $out = [];
521 $lookups = 0;
522 $php_need = self::core_php_requirement($target);
523 // Hard wall-clock budget so a slow/unreachable WP.org never hangs the
524 // scan. Cached slugs are near-instant; only live lookups burn the clock.
525 $deadline = microtime(true) + 20.0;
526
527 if (!function_exists('get_plugins')) {
528 require_once ABSPATH . 'wp-admin/includes/plugin.php';
529 }
530 $plugins = function_exists('get_plugins') ? get_plugins() : [];
531 foreach ($plugins as $file => $data) {
532 if ($lookups >= self::META_MAX_LOOKUPS || microtime(true) > $deadline) break;
533 $slug = self::plugin_slug($file);
534 $owner = 'plugin/' . dirname($file);
535 if (dirname($file) === '.') $owner = 'plugin/' . pathinfo($file, PATHINFO_FILENAME);
536 $info = self::wporg_plugin($slug);
537 $lookups++;
538 $out[$owner] = self::score_meta($info, $target, $php_need, $data);
539 }
540
541 // Active theme (and parent) — themes break visibly, so always worth a look.
542 if (function_exists('wp_get_theme')) {
543 $theme = wp_get_theme();
544 foreach (array_filter([$theme, $theme->parent() ?: null]) as $t) {
545 if ($lookups >= self::META_MAX_LOOKUPS || microtime(true) > $deadline) break;
546 $slug = $t->get_stylesheet();
547 $owner = 'theme/' . $slug;
548 $info = self::wporg_theme($slug);
549 $lookups++;
550 $out[$owner] = self::score_meta($info, $target, $php_need, [
551 'RequiresPHP' => $t->get('RequiresPHP'),
552 ]);
553 }
554 }
555 return $out;
556 }
557
558 /** Best-effort: what PHP the target core needs. Cloud floor map wins. */
559 private static function core_php_requirement(string $target): string {
560 $r = self::remote_ruleset();
561 if ($r && !empty($r['core_php_floor']) && is_array($r['core_php_floor'])) {
562 $floor = $r['core_php_floor'];
563 $best = $floor['default'] ?? '';
564 $best_v = '';
565 foreach ($floor as $wp => $php) {
566 if ($wp === 'default' || !preg_match('/^\d+\.\d+/', (string) $wp)) continue;
567 if (version_compare($target, (string) $wp, '>=') && version_compare((string) $wp, $best_v ?: '0', '>=')) {
568 $best = (string) $php;
569 $best_v = (string) $wp;
570 }
571 }
572 if ($best !== '') return $best;
573 }
574 // Baked fallback: WP 6.x needs PHP 7.2.24+; WP 7.0 raises it to 7.4.
575 if (version_compare($target, '7.0', '>=')) return '7.4';
576 return '7.2.24';
577 }
578
579 private static function plugin_slug(string $file): string {
580 $dir = dirname($file);
581 return $dir === '.' ? pathinfo($file, PATHINFO_FILENAME) : $dir;
582 }
583
584 private static function score_meta(?array $info, string $target, string $php_need, array $headers): array {
585 // No directory listing → likely premium/custom; we can't crowd-judge it,
586 // but the local RequiresPHP header still tells us about the PHP floor.
587 $tested = $info['tested'] ?? '';
588 $updated = $info['last_updated'] ?? '';
589 $closed = !empty($info['closed']);
590 $req_php = $headers['RequiresPHP'] ?? ($info['requires_php'] ?? '');
591
592 // "Tested up to" gap, measured in WP minor releases (6.x ↔ 7.x bridged
593 // by treating each major as 10 minors). 3+ minors behind → caution,
594 // 6+ behind (e.g. tested 6.2, updating to 7.0) → strong signal.
595 $tested_gap = 0;
596 if ($tested && preg_match('/^(\d+)\.(\d+)/', $tested, $tm)
597 && preg_match('/^(\d+)\.(\d+)/', $target, $gm)) {
598 $diff = ((int) $gm[1] * 10 + (int) $gm[2]) - ((int) $tm[1] * 10 + (int) $tm[2]);
599 $tested_gap = $diff >= 6 ? 2 : ($diff >= 3 ? 1 : 0);
600 }
601
602 $stale_days = $updated ? (int) floor((time() - strtotime($updated)) / DAY_IN_SECONDS) : null;
603 $abandoned = $closed || ($stale_days !== null && $stale_days > 730); // 2 years
604
605 // The plugin requires a PHP newer than this site currently runs — so it
606 // is already (or will be) incompatible regardless of the core jump.
607 $php_blocks = $req_php && version_compare(PHP_VERSION, $req_php, '<');
608
609 return [
610 'in_directory' => $info !== null,
611 'tested' => $tested,
612 'tested_gap' => $tested_gap,
613 'last_updated' => $updated,
614 'stale_days' => $stale_days,
615 'abandoned' => $abandoned,
616 'requires_php' => $req_php,
617 'php_need' => $php_need,
618 'php_blocks' => $php_blocks,
619 ];
620 }
621
622 private static function wporg_plugin(string $slug): ?array {
623 $cached = get_transient(self::TRANSIENT_META . md5('p:' . $slug));
624 if (is_array($cached)) return $cached ?: null;
625
626 $url = 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information'
627 . '&request[slug]=' . rawurlencode($slug)
628 . '&request[fields][tested]=1&request[fields][requires_php]=1&request[fields][last_updated]=1';
629 $resp = wp_remote_get($url, ['timeout' => 5]);
630 $info = self::parse_wporg($resp);
631 set_transient(self::TRANSIENT_META . md5('p:' . $slug), $info ?: [], self::META_TTL);
632 return $info;
633 }
634
635 private static function wporg_theme(string $slug): ?array {
636 $cached = get_transient(self::TRANSIENT_META . md5('t:' . $slug));
637 if (is_array($cached)) return $cached ?: null;
638
639 $url = 'https://api.wordpress.org/themes/info/1.2/?action=theme_information'
640 . '&request[slug]=' . rawurlencode($slug)
641 . '&request[fields][tested]=1&request[fields][last_updated]=1';
642 $resp = wp_remote_get($url, ['timeout' => 5]);
643 $info = self::parse_wporg($resp);
644 set_transient(self::TRANSIENT_META . md5('t:' . $slug), $info ?: [], self::META_TTL);
645 return $info;
646 }
647
648 private static function parse_wporg($resp): ?array {
649 if (is_wp_error($resp) || wp_remote_retrieve_response_code($resp) !== 200) return null;
650 $body = json_decode(wp_remote_retrieve_body($resp), true);
651 if (!is_array($body) || isset($body['error'])) return null;
652 return [
653 'tested' => (string) ($body['tested'] ?? ''),
654 'requires_php' => (string) ($body['requires_php'] ?? ''),
655 'last_updated' => (string) ($body['last_updated'] ?? ''),
656 'closed' => !empty($body['closed']),
657 ];
658 }
659
660 // -------------------------------------------------------------------------
661 // Pre-update interception (Pro) — banner on the core update screen
662 // -------------------------------------------------------------------------
663
664 public static function register(): void {
665 add_action('admin_notices', [self::class, 'maybe_intercept']);
666 }
667
668 public static function maybe_intercept(): void {
669 if (!current_user_can('update_core')) return;
670 $screen = function_exists('get_current_screen') ? get_current_screen() : null;
671 $base = $screen ? $screen->id : '';
672 if ($base !== 'update-core' && $base !== 'dashboard') return;
673
674 $avail = self::available_core_update();
675 if (!$avail) return; // nothing to update to — stay quiet
676
677 $audit_url = admin_url('admin.php?page=phpinfowp-update-audit&target=' . rawurlencode($avail));
678 $result = self::get_result();
679 $fresh = $result && ($result['target'] ?? '') === $avail
680 && (time() - (int) $result['scanned_at']) < WEEK_IN_SECONDS;
681
682 // Free users get a soft nudge — only on the Updates screen, never on
683 // the main Dashboard (that would nag on every login). The live verdict
684 // banner and the Dashboard placement are the Pro perk.
685 if (!self::_pro()) {
686 if ($base !== 'update-core') return;
687 printf(
688 '<div class="notice notice-info"><p><strong>phpinfo() WP:</strong> WordPress %s is available. '
689 . '<a href="%s">Run a pre-update audit</a> to see which plugins/themes may break. '
690 . '<a href="https://exeebit.com/phpinfo-wp#pricing" target="_blank">Pro</a> warns you here automatically before every core update.</p></div>',
691 esc_html($avail), esc_url($audit_url)
692 );
693 return;
694 }
695
696 if (!$fresh) {
697 printf(
698 '<div class="notice notice-warning"><p><strong>Update Guard:</strong> WordPress %s is available but you haven\'t audited for it yet. '
699 . '<a href="%s">Run the pre-update audit →</a></p></div>',
700 esc_html($avail), esc_url($audit_url)
701 );
702 return;
703 }
704
705 $v = $result['verdict'];
706 if ($v === 'safe') {
707 printf(
708 '<div class="notice notice-success"><p><strong>Update Guard:</strong> audited against WordPress %s — no breakages detected across %d plugins/themes. Safe to update. <a href="%s">View report</a></p></div>',
709 esc_html($avail), (int) $result['owner_count'], esc_url($audit_url)
710 );
711 } else {
712 $color = $v === 'risky' ? 'error' : 'warning';
713 $word = $v === 'risky' ? 'likely to break' : 'may need attention';
714 printf(
715 '<div class="notice notice-%s"><p><strong>Update Guard:</strong> %d plugin(s)/theme(s) are %s on WordPress %s '
716 . '(%d hard breaks, %d deprecations). <a href="%s">Review before updating →</a></p></div>',
717 esc_attr($color), (int) $result['with_issues'], esc_html($word), esc_html($avail),
718 (int) $result['total_breaks'], (int) $result['total_depr'], esc_url($audit_url)
719 );
720 }
721 }
722 }
723