class-abstract-migration.php
5 days ago
class-date-time-utils.php
5 days ago
class-debugging.php
5 days ago
class-generate-modal.php
5 days ago
class-migration.php
5 days ago
class-request-utils.php
5 days ago
class-settings-utils.php
5 days ago
class-upgrade-guard.php
5 days ago
class-user-utils.php
5 days ago
class-validator.php
5 days ago
class-white-label.php
5 days ago
index.php
5 days ago
class-upgrade-guard.php
413 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Upgrade Guard - prevents fatal errors during plugin version transitions. |
| 4 | * |
| 5 | * When WordPress updates a plugin, file replacement is not atomic. This means: |
| 6 | * 1. OPcache may serve stale bytecode from the old version's files. |
| 7 | * 2. Serialized objects in the DB may reference classes/methods that no longer exist. |
| 8 | * 3. Scheduled cron hooks may point to removed callbacks. |
| 9 | * 4. The autoloader classmap may reference files that have been renamed or deleted. |
| 10 | * |
| 11 | * This class mitigates these issues by: |
| 12 | * - Invalidating OPcache for the plugin directory after an update. |
| 13 | * - Detecting version transitions and running in a degraded "safe mode". |
| 14 | * - Providing a custom error handler during autoload to catch missing file errors. |
| 15 | * |
| 16 | * @package wp2fa |
| 17 | * @subpackage utils |
| 18 | * @copyright 2026 Melapress |
| 19 | * @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 |
| 20 | * |
| 21 | * @since 4.0.0 |
| 22 | */ |
| 23 | |
| 24 | declare(strict_types=1); |
| 25 | |
| 26 | namespace WP2FA\Utils; |
| 27 | |
| 28 | defined( 'ABSPATH' ) || exit; |
| 29 | |
| 30 | if ( ! class_exists( '\WP2FA\Utils\Upgrade_Guard' ) ) { |
| 31 | |
| 32 | /** |
| 33 | * Handles safe plugin transitions during updates. |
| 34 | * |
| 35 | * @since 4.0.0 |
| 36 | */ |
| 37 | class Upgrade_Guard { |
| 38 | |
| 39 | /** |
| 40 | * Option name storing the last known file-version hash. |
| 41 | * |
| 42 | * @var string |
| 43 | */ |
| 44 | private const VERSION_HASH_OPTION = 'wp_2fa_file_version_hash'; |
| 45 | |
| 46 | /** |
| 47 | * Transient flag set during an active upgrade transition. |
| 48 | * |
| 49 | * @var string |
| 50 | */ |
| 51 | private const UPGRADING_TRANSIENT = 'wp_2fa_upgrading'; |
| 52 | |
| 53 | /** |
| 54 | * Whether safe mode is currently active. |
| 55 | * |
| 56 | * @var bool |
| 57 | */ |
| 58 | private static $safe_mode = false; |
| 59 | |
| 60 | /** |
| 61 | * Initialize the upgrade guard. |
| 62 | * |
| 63 | * Must be called BEFORE the Composer autoloader is loaded, |
| 64 | * so it can register the error handler and detect transitions. |
| 65 | * |
| 66 | * @return void |
| 67 | * |
| 68 | * @since 4.0.0 |
| 69 | */ |
| 70 | public static function init(): void { |
| 71 | // Register a custom error handler for autoload failures. |
| 72 | self::register_autoload_error_handler(); |
| 73 | |
| 74 | // Hook into the upgrader to clear caches after plugin update. |
| 75 | \add_action( 'upgrader_process_complete', array( __CLASS__, 'on_upgrader_complete' ), 5, 2 ); |
| 76 | |
| 77 | // Also handle updates via direct file replacement (e.g., WP Playground, WP-CLI). |
| 78 | self::detect_version_transition(); |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Check if the plugin is currently in safe mode (mid-upgrade transition). |
| 83 | * |
| 84 | * @return bool |
| 85 | * |
| 86 | * @since 4.0.0 |
| 87 | */ |
| 88 | public static function is_safe_mode(): bool { |
| 89 | return self::$safe_mode; |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Detect a version transition by comparing file version to stored hash. |
| 94 | * |
| 95 | * If the file version changed but the migration hasn't run yet, |
| 96 | * we're in a transitional state where classes may be inconsistent. |
| 97 | * |
| 98 | * @return void |
| 99 | * |
| 100 | * @since 4.0.0 |
| 101 | */ |
| 102 | private static function detect_version_transition(): void { |
| 103 | // filemtime() can return false if the file is being replaced mid-update. |
| 104 | $mtime = @filemtime( WP_2FA_FILE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 105 | if ( false === $mtime ) { |
| 106 | // File is actively being replaced — enable safe mode, skip further checks. |
| 107 | self::$safe_mode = true; |
| 108 | return; |
| 109 | } |
| 110 | |
| 111 | $current_hash = md5( WP_2FA_VERSION . '|' . $mtime ); |
| 112 | $stored_hash = \get_option( self::VERSION_HASH_OPTION, '' ); |
| 113 | |
| 114 | if ( $stored_hash !== $current_hash ) { |
| 115 | // Version transition detected — invalidate caches. |
| 116 | self::invalidate_opcache(); |
| 117 | \update_option( self::VERSION_HASH_OPTION, $current_hash, true ); |
| 118 | |
| 119 | // Check if migration has already run for this version. |
| 120 | // Use get_option directly — Settings_Utils is not available yet (autoloader hasn't loaded). |
| 121 | $stored_version = (string) \get_option( 'wp_2fa_plugin_version', '0.0.0' ); |
| 122 | if ( version_compare( $stored_version, WP_2FA_VERSION, '<' ) ) { |
| 123 | // Migration hasn't run yet — we're in a transitional state. |
| 124 | self::$safe_mode = true; |
| 125 | \set_transient( self::UPGRADING_TRANSIENT, '1', 60 ); |
| 126 | } |
| 127 | } elseif ( \get_transient( self::UPGRADING_TRANSIENT ) ) { |
| 128 | self::$safe_mode = true; |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Callback for upgrader_process_complete. |
| 134 | * |
| 135 | * Invalidates OPcache and marks the plugin as transitioning |
| 136 | * when this plugin is being updated. |
| 137 | * |
| 138 | * @param \WP_Upgrader $upgrader Upgrader instance. |
| 139 | * @param array $options Update options with type and plugins/themes. |
| 140 | * |
| 141 | * @return void |
| 142 | * |
| 143 | * @since 4.0.0 |
| 144 | */ |
| 145 | public static function on_upgrader_complete( $upgrader, $options ): void { |
| 146 | if ( 'update' !== ( $options['action'] ?? '' ) || 'plugin' !== ( $options['type'] ?? '' ) ) { |
| 147 | return; |
| 148 | } |
| 149 | |
| 150 | $plugins = $options['plugins'] ?? array(); |
| 151 | if ( ! is_array( $plugins ) ) { |
| 152 | $plugins = array( $plugins ); |
| 153 | } |
| 154 | |
| 155 | $our_basename = \plugin_basename( WP_2FA_FILE ); |
| 156 | if ( ! in_array( $our_basename, $plugins, true ) ) { |
| 157 | return; |
| 158 | } |
| 159 | |
| 160 | // Plugin was just updated — flush OPcache for our directory. |
| 161 | self::invalidate_opcache(); |
| 162 | |
| 163 | // Set a short-lived transient to signal the next request that we're transitioning. |
| 164 | \set_transient( self::UPGRADING_TRANSIENT, '1', 60 ); |
| 165 | |
| 166 | // Clear config hash so the next load detects the new version. |
| 167 | \delete_option( self::VERSION_HASH_OPTION ); |
| 168 | |
| 169 | // Clear any cached class references. |
| 170 | \delete_transient( 'wp_2fa_config_file_hash' ); |
| 171 | |
| 172 | // Record whether this was a manual (single plugin) update for the new interface dialog. |
| 173 | $is_manual_update = ! ( defined( 'WP_CLI' ) && WP_CLI ) && empty( $options['bulk'] ); |
| 174 | \set_transient( 'wp_2fa_manual_update', $is_manual_update ? '1' : '0', 300 ); |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Invalidate OPcache for the entire plugin directory. |
| 179 | * |
| 180 | * OPcache caches compiled PHP bytecode. After a plugin update, |
| 181 | * stale bytecode from old files causes "class not found" or |
| 182 | * "method not found" fatals because the cached code references |
| 183 | * the old file structure. |
| 184 | * |
| 185 | * @return void |
| 186 | * |
| 187 | * @since 4.0.0 |
| 188 | */ |
| 189 | private static function invalidate_opcache(): void { |
| 190 | if ( ! function_exists( 'opcache_invalidate' ) ) { |
| 191 | return; |
| 192 | } |
| 193 | |
| 194 | if ( function_exists( 'opcache_reset' ) && ! self::is_opcache_file_invalidation_available() ) { |
| 195 | // If per-file invalidation isn't available, reset the entire cache. |
| 196 | // This is a last resort — it affects all PHP files on the server. |
| 197 | \opcache_reset(); |
| 198 | return; |
| 199 | } |
| 200 | |
| 201 | // Invalidate all PHP files in the plugin directory. |
| 202 | $plugin_dir = WP_2FA_PATH; |
| 203 | self::invalidate_directory( $plugin_dir ); |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Check if OPcache supports per-file invalidation. |
| 208 | * |
| 209 | * @return bool |
| 210 | * |
| 211 | * @since 4.0.0 |
| 212 | */ |
| 213 | private static function is_opcache_file_invalidation_available(): bool { |
| 214 | // OPcache is typically disabled for CLI (opcache.enable_cli=0). |
| 215 | if ( 'cli' === PHP_SAPI && ! filter_var( ini_get( 'opcache.enable_cli' ), FILTER_VALIDATE_BOOLEAN ) ) { |
| 216 | return false; |
| 217 | } |
| 218 | |
| 219 | // opcache.restrict_api can prevent invalidation from certain paths. |
| 220 | $restrict_api = (string) ini_get( 'opcache.restrict_api' ); |
| 221 | if ( '' !== $restrict_api ) { |
| 222 | // Check if current script path starts with the restriction. |
| 223 | // In CLI/WP-CLI, SCRIPT_FILENAME may be absent or point to the phar. |
| 224 | $script_path = $_SERVER['SCRIPT_FILENAME'] ?? ''; |
| 225 | if ( '' === $script_path || 0 !== strpos( $script_path, $restrict_api ) ) { |
| 226 | return false; |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | return true; |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * Recursively invalidate OPcache for all PHP files in a directory. |
| 235 | * |
| 236 | * @param string $dir Directory path. |
| 237 | * |
| 238 | * @return void |
| 239 | * |
| 240 | * @since 4.0.0 |
| 241 | */ |
| 242 | private static function invalidate_directory( string $dir ): void { |
| 243 | // Canonicalize to prevent symlink traversal outside the plugin boundary. |
| 244 | $real_dir = realpath( $dir ); |
| 245 | if ( false === $real_dir || ! is_dir( $real_dir ) ) { |
| 246 | return; |
| 247 | } |
| 248 | |
| 249 | try { |
| 250 | $iterator = new \RecursiveIteratorIterator( |
| 251 | new \RecursiveDirectoryIterator( $real_dir, \RecursiveDirectoryIterator::SKIP_DOTS ), |
| 252 | \RecursiveIteratorIterator::LEAVES_ONLY |
| 253 | ); |
| 254 | |
| 255 | foreach ( $iterator as $file ) { |
| 256 | if ( ! $file->isFile() || 'php' !== $file->getExtension() ) { |
| 257 | continue; |
| 258 | } |
| 259 | |
| 260 | // Skip symlinks that resolve outside the plugin directory. |
| 261 | $real_path = $file->getRealPath(); |
| 262 | if ( false === $real_path || 0 !== strpos( $real_path, $real_dir ) ) { |
| 263 | continue; |
| 264 | } |
| 265 | |
| 266 | \opcache_invalidate( $real_path, true ); |
| 267 | } |
| 268 | } catch ( \UnexpectedValueException $e ) { |
| 269 | // Directory structure changed mid-iteration (files being replaced). |
| 270 | // Silently bail — OPcache will be invalidated on next request. |
| 271 | return; |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | /** |
| 276 | * Register a custom autoload error handler. |
| 277 | * |
| 278 | * Catches "file not found" or "class not found" issues during |
| 279 | * autoloading that happen when the classmap references stale paths. |
| 280 | * |
| 281 | * @return void |
| 282 | * |
| 283 | * @since 4.0.0 |
| 284 | */ |
| 285 | private static function register_autoload_error_handler(): void { |
| 286 | // Register a shutdown function to catch fatal autoload errors. |
| 287 | \register_shutdown_function( array( __CLASS__, 'handle_fatal_autoload_error' ) ); |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * Handle fatal errors caused by stale autoloader references. |
| 292 | * |
| 293 | * If a fatal error occurs because a class file was removed during |
| 294 | * an update, log the error and attempt to redirect to a safe state |
| 295 | * rather than showing a white screen. |
| 296 | * |
| 297 | * @return void |
| 298 | * |
| 299 | * @since 4.0.0 |
| 300 | */ |
| 301 | public static function handle_fatal_autoload_error(): void { |
| 302 | $error = error_get_last(); |
| 303 | if ( null === $error ) { |
| 304 | return; |
| 305 | } |
| 306 | |
| 307 | // Only handle fatal errors. |
| 308 | if ( ! in_array( $error['type'], array( E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR ), true ) ) { |
| 309 | return; |
| 310 | } |
| 311 | |
| 312 | // Check if it's related to our plugin. |
| 313 | if ( false === strpos( $error['file'], 'wp-2fa' ) && false === strpos( $error['message'], 'WP2FA' ) ) { |
| 314 | return; |
| 315 | } |
| 316 | |
| 317 | // Check if it's a class/file not found error typical of version transitions. |
| 318 | $transition_patterns = array( |
| 319 | 'Class .* not found', |
| 320 | 'Failed opening required.*wp-2fa', |
| 321 | 'include\(\).*wp-2fa.*failed to open stream', |
| 322 | 'Call to undefined method.*WP2FA', |
| 323 | 'Interface .* not found', |
| 324 | 'Trait .* not found', |
| 325 | ); |
| 326 | |
| 327 | $is_transition_error = false; |
| 328 | foreach ( $transition_patterns as $pattern ) { |
| 329 | if ( preg_match( '/' . $pattern . '/i', $error['message'] ) ) { |
| 330 | $is_transition_error = true; |
| 331 | break; |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | if ( ! $is_transition_error ) { |
| 336 | return; |
| 337 | } |
| 338 | |
| 339 | // Log the error for debugging. |
| 340 | if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { |
| 341 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 342 | error_log( |
| 343 | sprintf( |
| 344 | '[WP 2FA Upgrade Guard] Caught transition error: %s in %s on line %d. ' |
| 345 | . 'This typically resolves on the next page load after the update completes.', |
| 346 | $error['message'], |
| 347 | $error['file'], |
| 348 | $error['line'] |
| 349 | ) |
| 350 | ); |
| 351 | } |
| 352 | |
| 353 | // Invalidate OPcache to help the next request succeed. |
| 354 | if ( function_exists( 'opcache_invalidate' ) && isset( $error['file'] ) ) { |
| 355 | \opcache_invalidate( $error['file'], true ); |
| 356 | } |
| 357 | |
| 358 | // Set the upgrading transient so the next request knows. |
| 359 | // Wrapped in try-catch: during fatal shutdown the DB connection may be gone. |
| 360 | if ( function_exists( 'set_transient' ) ) { |
| 361 | try { |
| 362 | \set_transient( self::UPGRADING_TRANSIENT, '1', 60 ); |
| 363 | } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch |
| 364 | // Best-effort; not critical if it fails. |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | // If we're in an admin HTTP context, show a "retry" page instead of a white screen. |
| 369 | // Skip for CLI (WP-CLI / cron) — no browser to refresh. |
| 370 | if ( defined( 'WP_CLI' ) || 'cli' === PHP_SAPI ) { |
| 371 | // In CLI context, just let the error propagate naturally — the user |
| 372 | // will see the error message in their terminal and can retry. |
| 373 | return; |
| 374 | } |
| 375 | |
| 376 | if ( ! headers_sent() && defined( 'WP_ADMIN' ) && WP_ADMIN ) { |
| 377 | // Clear output buffer if any. |
| 378 | while ( ob_get_level() > 0 ) { |
| 379 | ob_end_clean(); |
| 380 | } |
| 381 | |
| 382 | // Send a "retry" response that tells the browser to try again. |
| 383 | http_response_code( 503 ); |
| 384 | header( 'Retry-After: 2' ); |
| 385 | header( 'Content-Type: text/html; charset=utf-8' ); |
| 386 | header( 'X-Content-Type-Options: nosniff' ); |
| 387 | header( 'X-Frame-Options: DENY' ); |
| 388 | header( 'Cache-Control: no-store, no-cache, must-revalidate' ); |
| 389 | echo '<!DOCTYPE html><html><head><meta http-equiv="refresh" content="2"></head>'; |
| 390 | // Use a hardcoded string — esc_html__() may not be loaded during fatal shutdown. |
| 391 | echo '<body><p>WP 2FA is updating. The page will reload momentarily…</p></body></html>'; |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | /** |
| 396 | * Safely check if a class method exists before it's called via a hook. |
| 397 | * |
| 398 | * Use this when registering callbacks that reference methods which may |
| 399 | * be renamed or removed in future versions. |
| 400 | * |
| 401 | * @param string $class_name Fully-qualified class name. |
| 402 | * @param string $method_name Method name. |
| 403 | * |
| 404 | * @return bool |
| 405 | * |
| 406 | * @since 4.0.0 |
| 407 | */ |
| 408 | public static function method_available( string $class_name, string $method_name ): bool { |
| 409 | return class_exists( $class_name, true ) && method_exists( $class_name, $method_name ); |
| 410 | } |
| 411 | } |
| 412 | } |
| 413 |