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-compat.php

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

305 lines 15.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_Compat {
5
6 const OPT_RESULT = 'phpinfowp_compat_result';
7 const TARGETS = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'];
8 const MAX_FILE_SIZE = 1048576;
9 const MAX_FILES_PER_RUN = 5000;
10
11 private static function _pro(): bool { return Phpinfo_WP_License::is_valid(); }
12
13 const FREE_MAX_FILES = 1500;
14
15 // We now use a token-based rule engine.
16 // 'func_exact' maps exact function names to rules.
17 // 'func_prefix' maps prefixes.
18 // 'special' maps other PHP tokens.
19 private static function rules(): array {
20 return [
21 'func_exact' => [
22 'split' => ['name' => 'split()', 'in' => '5.3', 'out' => '7.0', 'severity' => 'removed', 'fix' => 'Use preg_split() or explode()'],
23 'sql_regcase' => ['name' => 'sql_regcase()', 'in' => '5.3', 'out' => '7.0', 'severity' => 'removed', 'fix' => 'No replacement — build the pattern manually'],
24 'create_function' => ['name' => 'create_function()', 'in' => '7.2', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Use anonymous functions (closures)'],
25 'each' => ['name' => 'each()', 'in' => '7.2', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Use foreach()'],
26 'get_magic_quotes_gpc' => ['name' => 'get_magic_quotes_gpc()', 'in' => '7.4', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Always returns false in 7.4+; remove the call'],
27 'get_magic_quotes_runtime' => ['name' => 'get_magic_quotes_runtime()', 'in' => '7.4', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Always returns false in 7.4+; remove the call'],
28 'money_format' => ['name' => 'money_format()', 'in' => '7.4', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Use NumberFormatter from the intl extension'],
29 'image2wbmp' => ['name' => 'image2wbmp()', 'in' => '7.3', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Use imagewbmp()'],
30 'convert_cyr_string' => ['name' => 'convert_cyr_string()', 'in' => '7.4', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Use iconv() or mb_convert_encoding()'],
31 'hebrevc' => ['name' => 'hebrevc()', 'in' => '7.4', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Use hebrev() + nl2br()'],
32 'is_real' => ['name' => 'is_real()', 'in' => '7.4', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Use is_float()'],
33 'restore_include_path' => ['name' => 'restore_include_path() with args', 'in' => '7.4', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Call with no arguments'],
34 'strftime' => ['name' => 'strftime()', 'in' => '8.1', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use IntlDateFormatter::format() or date_format()'],
35 'gmstrftime' => ['name' => 'gmstrftime()', 'in' => '8.1', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use IntlDateFormatter with UTC timezone'],
36 'date_sunrise' => ['name' => 'date_sunrise()', 'in' => '8.1', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use date_sun_info()'],
37 'date_sunset' => ['name' => 'date_sunset()', 'in' => '8.1', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use date_sun_info()'],
38 'utf8_encode' => ['name' => 'utf8_encode()', 'in' => '8.2', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use mb_convert_encoding($s, "UTF-8", "ISO-8859-1")'],
39 'utf8_decode' => ['name' => 'utf8_decode()', 'in' => '8.2', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use mb_convert_encoding($s, "ISO-8859-1", "UTF-8")'],
40 'get_class' => ['name' => 'get_class() with no args', 'in' => '8.3', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use get_class($this) or self::class'],
41 'get_parent_class' => ['name' => 'get_parent_class() with no args', 'in' => '8.3', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use get_parent_class($this) or parent::class'],
42 ],
43 'func_prefix' => [
44 'mysql_' => ['name' => 'mysql_*()', 'in' => '5.5', 'out' => '7.0', 'severity' => 'removed', 'fix' => 'Use mysqli_* or PDO'],
45 'ereg' => ['name' => 'ereg_*()', 'in' => '5.3', 'out' => '7.0', 'severity' => 'removed', 'fix' => 'Use preg_* equivalents'], // catches ereg, eregi, ereg_replace
46 'mcrypt_' => ['name' => 'mcrypt_*()', 'in' => '7.1', 'out' => '7.2', 'severity' => 'removed', 'fix' => 'Use openssl_encrypt/decrypt or sodium_crypto_*'],
47 'mhash' => ['name' => 'mhash_*()', 'in' => '8.1', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use hash_* functions'], // catches mhash, mhash_keygen_s2k
48 ],
49 'special' => [
50 'real_cast' => ['name' => '(real) cast', 'in' => '7.4', 'out' => '8.0', 'severity' => 'removed', 'fix' => 'Use (float) instead'],
51 'dollar_brace'=>['name' => '${var} string interpolation', 'in' => '8.2', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use {$var} syntax'],
52 'short_open' => ['name' => 'Short open tag <?', 'in' => '7.4', 'out' => null, 'severity' => 'deprecated', 'fix' => 'Use <?php — short_open_tag may be off'],
53 ]
54 ];
55 }
56
57 public static function targets(): array { return self::TARGETS; }
58
59 public static function get_result(): ?array {
60 $r = get_option(self::OPT_RESULT, null);
61 return is_array($r) ? $r : null;
62 }
63
64 public static function clear(): void {
65 delete_option(self::OPT_RESULT);
66 }
67
68 public static function scan(string $target = '8.2'): array {
69 @set_time_limit(120);
70 $started = microtime(true);
71
72 $rules = self::filter_rules($target);
73 if (!$rules) return ['error' => 'Target version invalid.'];
74
75 $max_files = self::_pro() ? self::MAX_FILES_PER_RUN : self::FREE_MAX_FILES;
76
77 $issues_by_owner = [];
78 $files_scanned = 0;
79 $files_skipped = 0;
80 $owners_seen = [];
81
82 $roots = [
83 'plugins' => WP_PLUGIN_DIR,
84 'themes' => get_theme_root(),
85 'mu-plugins' => defined('WPMU_PLUGIN_DIR') ? WPMU_PLUGIN_DIR : WP_CONTENT_DIR . '/mu-plugins',
86 ];
87
88 foreach ($roots as $type => $root) {
89 if (!is_dir($root)) continue;
90 try {
91 $it = new RecursiveIteratorIterator(
92 new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
93 RecursiveIteratorIterator::LEAVES_ONLY
94 );
95 } catch (Throwable $e) { continue; }
96
97 foreach ($it as $file) {
98 if ($files_scanned + $files_skipped >= $max_files) break 2;
99 if (!$file->isFile()) continue;
100 if (strtolower($file->getExtension()) !== 'php') continue;
101 if ($file->getSize() > self::MAX_FILE_SIZE) { $files_skipped++; continue; }
102
103 $abs = $file->getPathname();
104 $rel = ltrim(str_replace($root, '', $abs), '/\\');
105 $owner = self::owner_of($type, $rel);
106 if ($owner === null) continue;
107
108 $content = @file_get_contents($abs);
109 if ($content === false) { $files_skipped++; continue; }
110 if (strpos($content, '<?') === false) { $files_scanned++; continue; }
111
112 $files_scanned++;
113 $owners_seen[$owner] = true;
114
115 // Smart Tokenizer Loop
116 try {
117 $tokens = @token_get_all($content);
118 } catch (Throwable $e) {
119 continue;
120 }
121
122 $count = count($tokens);
123 for ($i = 0; $i < $count; $i++) {
124 $t = $tokens[$i];
125 if (!is_array($t)) continue;
126
127 $token_id = $t[0];
128 $text = $t[1];
129 $line = $t[2];
130
131 $match_rule = null;
132 $snippet = $text;
133
134 if ($token_id === T_STRING) {
135 // Function call check
136 $lower = strtolower($text);
137
138 // Look backwards to ensure it's not a method or function declaration
139 $prev_is_safe = false;
140 for ($j = $i - 1; $j >= 0; $j--) {
141 if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) continue;
142 if (is_array($tokens[$j])) {
143 $pt = $tokens[$j][0];
144 if ($pt === T_OBJECT_OPERATOR || $pt === T_DOUBLE_COLON || $pt === T_FUNCTION || $pt === T_NEW) {
145 $prev_is_safe = true;
146 }
147 }
148 break;
149 }
150 if ($prev_is_safe) continue;
151
152 // Look forwards to ensure it has opening parentheses
153 $next_is_paren = false;
154 for ($j = $i + 1; $j < $count; $j++) {
155 if (is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) continue;
156 if ($tokens[$j] === '(') {
157 $next_is_paren = true;
158 }
159 break;
160 }
161 if (!$next_is_paren) continue;
162
163 if (isset($rules['func_exact'][$lower])) {
164 $match_rule = $rules['func_exact'][$lower];
165 $snippet = $text . '()';
166 } else {
167 foreach ($rules['func_prefix'] as $prefix => $rule) {
168 if (strpos($lower, $prefix) === 0) {
169 $match_rule = $rule;
170 $snippet = $text . '()';
171 break;
172 }
173 }
174 }
175
176 } elseif ($token_id === T_DOUBLE_CAST) {
177 if (strpos(strtolower($text), 'real') !== false && isset($rules['special']['real_cast'])) {
178 $match_rule = $rules['special']['real_cast'];
179 $snippet = '(real)';
180 }
181 } elseif ($token_id === T_DOLLAR_OPEN_CURLY_BRACES) {
182 if (isset($rules['special']['dollar_brace'])) {
183 $match_rule = $rules['special']['dollar_brace'];
184 $snippet = '${';
185 }
186 } elseif ($token_id === T_OPEN_TAG) {
187 if (trim($text) === '<?' && isset($rules['special']['short_open'])) {
188 $match_rule = $rules['special']['short_open'];
189 $snippet = '<?';
190 }
191 }
192
193 if ($match_rule) {
194 $issues_by_owner[$owner][] = [
195 'file' => $rel,
196 'line' => $line,
197 'match' => $snippet,
198 'name' => $match_rule['name'],
199 'fix' => $match_rule['fix'],
200 'severity' => $match_rule['severity'],
201 'in' => $match_rule['in'],
202 'out' => $match_rule['out'],
203 ];
204 }
205 }
206 }
207 }
208
209 $total = 0;
210 foreach ($issues_by_owner as $list) $total += count($list);
211
212 $result = [
213 'target' => $target,
214 'total' => $total,
215 'owners' => count($owners_seen),
216 'with_issues' => count($issues_by_owner),
217 'files' => $files_scanned,
218 'skipped' => $files_skipped,
219 'max_files' => $max_files,
220 'truncated' => ($files_scanned + $files_skipped) >= $max_files,
221 'duration' => round(microtime(true) - $started, 2),
222 'scanned_at' => time(),
223 'issues' => $issues_by_owner,
224 'is_pro_result' => self::_pro(),
225 ];
226
227 update_option(self::OPT_RESULT, $result, false);
228 return $result;
229 }
230
231 private static function filter_rules(string $target): array {
232 $t = (float) $target;
233 if ($t < 7.0 || $t > 8.4) return [];
234
235 $filtered = ['func_exact' => [], 'func_prefix' => [], 'special' => []];
236 $all = self::rules();
237
238 foreach ($all as $category => $rules) {
239 foreach ($rules as $key => $r) {
240 $in = (float) $r['in'];
241 $out = $r['out'] ? (float) $r['out'] : null;
242 if ($out !== null && $t >= $out) { $filtered[$category][$key] = $r; continue; }
243 if ($r['severity'] === 'deprecated' && $t >= $in) { $filtered[$category][$key] = $r; continue; }
244 }
245 }
246
247 // If all sub-arrays are empty, return empty array
248 if (empty($filtered['func_exact']) && empty($filtered['func_prefix']) && empty($filtered['special'])) {
249 return [];
250 }
251
252 return $filtered;
253 }
254
255 private static function owner_of(string $type, string $rel): ?string {
256 $parts = preg_split('#[\\\\/]+#', $rel);
257 if (!$parts) return null;
258 $first = $parts[0];
259 if ($first === '' || strncmp($first, '.', strlen('.')) === 0) return null;
260 if ($type !== 'themes' && count($parts) === 1) {
261 return $type . '/' . pathinfo($first, PATHINFO_FILENAME);
262 }
263 return $type . '/' . $first;
264 }
265
266 public static function register_update_warnings(): void {
267 add_action('admin_init', function() {
268 $updates = get_site_transient('update_plugins');
269 if (!$updates || empty($updates->response)) return;
270 foreach ($updates->response as $file => $info) {
271 add_action('in_plugin_update_message-' . $file, [self::class, 'render_update_warning'], 10, 2);
272 }
273 });
274 }
275
276 public static function render_update_warning($plugin_data, $response): void {
277 $req_php = '';
278 if (is_object($response) && !empty($response->requires_php)) {
279 $req_php = (string) $response->requires_php;
280 } elseif (is_array($response) && !empty($response['requires_php'])) {
281 $req_php = (string) $response['requires_php'];
282 }
283
284 if (!$req_php) return;
285 if (version_compare(PHP_VERSION, $req_php, '>=')) return;
286
287 $is_pro = self::_pro();
288 $upgrade_url = $is_pro
289 ? admin_url('admin.php?page=phpinfowp-compat')
290 : 'https://exeebit.com/phpinfo-wp#pricing';
291 $cta = $is_pro ? 'Run full compatibility scan →' : 'Get pre-upgrade auto-scan with Pro →';
292
293 printf(
294 '<br><span style="display:inline-block;margin-top:8px;padding:6px 10px;background:#fcf0f1;border-left:3px solid #d63638;color:#7a1a1a;font-weight:600">' .
295 '⚠ This update requires PHP %s but your site runs PHP %s — installing it will likely break the plugin. ' .
296 '<a href="%s" style="color:#7a1a1a;text-decoration:underline" target="_blank">%s</a>' .
297 '</span>',
298 esc_html($req_php),
299 esc_html(PHP_VERSION),
300 esc_url($upgrade_url),
301 esc_html($cta)
302 );
303 }
304 }
305