| 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 |
|