PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 4.0.0
WP 2FA – Two-factor authentication for WordPress v4.0.0
4.0.0 1.7.1 2.0.0 2.0.1 2.1.0 2.2.0 2.2.1 2.3.0 2.4.0 2.4.1 2.4.2 2.5.0 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.8.0 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0 3.0.1 3.1.0 3.1.1 3.1.1.2 trunk 1.2.0 1.3.0 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.6.0 1.6.1 1.6.2 1.7.0
wp-2fa / includes / classes / Utils / class-upgrade-guard.php
wp-2fa / includes / classes / Utils Last commit date
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&hellip;</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