booking
/
includes
/
page-setup-wizard
/
first-run-launcher
/
class-wpbc-setup-wizard-first-install-state.php
class-wpbc-setup-wizard-first-install-state.php in Booking Calendar 11.9, at includes/page-setup-wizard/first-run-launcher/class-wpbc-setup-wizard-first-install-state.php
| 1 | <?php |
| 2 | /** |
| 3 | * First-install state and activation-intent contracts for Setup Wizard. |
| 4 | * |
| 5 | * @package Booking Calendar |
| 6 | */ |
| 7 | |
| 8 | if ( ! defined( 'ABSPATH' ) ) { |
| 9 | exit; |
| 10 | } |
| 11 | |
| 12 | require_once dirname( __DIR__, 2 ) . '/_functions/class-wpbc-environment-policy.php'; |
| 13 | |
| 14 | /** |
| 15 | * Own immutable first-install markers and versioned activation redirect intent. |
| 16 | * |
| 17 | * Genuine-install detection runs before activation creates tables and options. |
| 18 | * This class converts that request-scoped result into a small transient payload |
| 19 | * which the following administration request can route without re-detecting an |
| 20 | * already-mutated installation. |
| 21 | */ |
| 22 | final class WPBC_Setup_Wizard_First_Install_State { |
| 23 | |
| 24 | /** Current activation redirect payload version. */ |
| 25 | const REDIRECT_SCHEMA_VERSION = 2; |
| 26 | |
| 27 | /** Schema version for updater-to-runtime navigation handoff records. */ |
| 28 | const UPDATE_CONTEXT_SCHEMA_VERSION = 1; |
| 29 | |
| 30 | /** Setup Wizard-owned transient containing the structured activation intent. */ |
| 31 | const ACTIVATION_INTENT_TRANSIENT = '_booking_setup_wizard_activation_redirect_intent'; |
| 32 | |
| 33 | /** Lifetime of immediate activation navigation state, matching the released redirect contract. */ |
| 34 | const ACTIVATION_INTENT_TTL = 30; |
| 35 | |
| 36 | /** Lifetime of a manual-update redirect waiting for the initiating administrator. */ |
| 37 | const MANUAL_UPDATE_INTENT_TTL = 3600; |
| 38 | |
| 39 | /** Maximum age of an updater-to-runtime handoff record. */ |
| 40 | const UPDATE_CONTEXT_TTL = 3600; |
| 41 | |
| 42 | /** Redirect destination for a genuine first installation. */ |
| 43 | const DESTINATION_SETUP_WIZARD = 'setup_wizard'; |
| 44 | |
| 45 | /** Legacy first-install destination accepted during the 11.9 development transition. */ |
| 46 | const DESTINATION_BOOKING_LISTING = 'booking_listing_setup_prompt'; |
| 47 | |
| 48 | /** Redirect destination for an update or reactivation. */ |
| 49 | const DESTINATION_WHATS_NEW = 'whats_new'; |
| 50 | |
| 51 | /** Persistent option carrying updater context across replaced plugin files. */ |
| 52 | const UPDATE_CONTEXT_OPTION = 'booking_update_navigation_context'; |
| 53 | |
| 54 | /** Interactive single-plugin update source. */ |
| 55 | const UPDATE_SOURCE_MANUAL = 'manual_single_plugin_update'; |
| 56 | |
| 57 | /** Automatic, command-line, or otherwise non-interactive update source. */ |
| 58 | const UPDATE_SOURCE_SUPPRESSED = 'non_interactive_update'; |
| 59 | |
| 60 | /** |
| 61 | * Check the activation-owned persistent genuine-install marker. |
| 62 | * |
| 63 | * @return bool True only for a site initialized by the genuine first-install path. |
| 64 | */ |
| 65 | public static function is_initial_install_site() { |
| 66 | return 'On' === get_bk_option( 'booking_setup_wizard_initial_install' ); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Build the transient redirect intent before activation mutates install state. |
| 71 | * |
| 72 | * @param bool $is_initial_install Whether activation began from an empty installation. |
| 73 | * @param string $plugin_version Booking Calendar version being activated. |
| 74 | * @param string $source Request source used for diagnostics and routing. |
| 75 | * @param int|null $user_id WordPress user who should receive the redirect. Defaults to the current user. |
| 76 | * |
| 77 | * @return array{schema_version:int,destination:string,plugin_version:string,source:string,user_id:int} Redirect intent. |
| 78 | */ |
| 79 | public static function create_activation_redirect_intent( $is_initial_install, $plugin_version, $source = 'activation', $user_id = null ) { |
| 80 | if ( null === $user_id ) { |
| 81 | $user_id = function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0; |
| 82 | } |
| 83 | |
| 84 | return array( |
| 85 | 'schema_version' => self::REDIRECT_SCHEMA_VERSION, |
| 86 | 'destination' => $is_initial_install ? self::DESTINATION_SETUP_WIZARD : self::DESTINATION_WHATS_NEW, |
| 87 | 'plugin_version' => sanitize_text_field( (string) $plugin_version ), |
| 88 | 'source' => sanitize_key( (string) $source ), |
| 89 | 'user_id' => absint( $user_id ), |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Determine whether a transient contains the current first-install intent. |
| 95 | * |
| 96 | * Boolean values created by earlier Booking Calendar versions deliberately |
| 97 | * return false so the released What's New redirect remains their safe fallback. |
| 98 | * |
| 99 | * @param mixed $redirect_intent Stored activation redirect transient. |
| 100 | * |
| 101 | * @return bool True for a current, explicit first-install redirect intent. |
| 102 | */ |
| 103 | public static function is_first_install_redirect_intent( $redirect_intent ) { |
| 104 | if ( ! is_array( $redirect_intent ) ) { |
| 105 | return false; |
| 106 | } |
| 107 | |
| 108 | $schema_version = absint( isset( $redirect_intent['schema_version'] ) ? $redirect_intent['schema_version'] : 0 ); |
| 109 | $destination = isset( $redirect_intent['destination'] ) ? $redirect_intent['destination'] : ''; |
| 110 | |
| 111 | return ( |
| 112 | self::REDIRECT_SCHEMA_VERSION === $schema_version |
| 113 | && self::DESTINATION_SETUP_WIZARD === $destination |
| 114 | ) || ( |
| 115 | 1 === $schema_version |
| 116 | && self::DESTINATION_BOOKING_LISTING === $destination |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Determine whether a transient requests the current What's New page. |
| 122 | * |
| 123 | * Version 1 payloads are accepted so a request that crosses a package update |
| 124 | * remains compatible with the earlier 11.9 development contract. |
| 125 | * |
| 126 | * @param mixed $redirect_intent Stored activation redirect transient. |
| 127 | * |
| 128 | * @return bool True for a supported What's New redirect intent. |
| 129 | */ |
| 130 | public static function is_whats_new_redirect_intent( $redirect_intent ) { |
| 131 | if ( ! is_array( $redirect_intent ) ) { |
| 132 | return false; |
| 133 | } |
| 134 | |
| 135 | $schema_version = absint( isset( $redirect_intent['schema_version'] ) ? $redirect_intent['schema_version'] : 0 ); |
| 136 | $destination = isset( $redirect_intent['destination'] ) ? $redirect_intent['destination'] : ''; |
| 137 | |
| 138 | return in_array( $schema_version, array( 1, self::REDIRECT_SCHEMA_VERSION ), true ) |
| 139 | && self::DESTINATION_WHATS_NEW === $destination; |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Check whether a redirect intent belongs to the current administrator. |
| 144 | * |
| 145 | * Older payloads and command-line payloads do not contain a positive user ID, |
| 146 | * so they retain the released site-scoped behavior. |
| 147 | * |
| 148 | * @param mixed $redirect_intent Stored activation redirect transient. |
| 149 | * |
| 150 | * @return bool True when the current request may consume the intent. |
| 151 | */ |
| 152 | public static function is_redirect_intent_for_current_user( $redirect_intent ) { |
| 153 | if ( ! is_array( $redirect_intent ) ) { |
| 154 | return false; |
| 155 | } |
| 156 | |
| 157 | $intent_user_id = absint( isset( $redirect_intent['user_id'] ) ? $redirect_intent['user_id'] : 0 ); |
| 158 | $current_user_id = function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0; |
| 159 | |
| 160 | return 0 === $intent_user_id || $intent_user_id === absint( $current_user_id ); |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Persist the completed updater request context for the newly installed package. |
| 165 | * |
| 166 | * WordPress continues executing the previously loaded PHP after replacing a |
| 167 | * plugin. The installed file header is therefore used as the target version, |
| 168 | * allowing the next request to run the new activation code and decide whether |
| 169 | * navigation is appropriate without confusing an automatic update with a |
| 170 | * manual Update now action. |
| 171 | * |
| 172 | * @param string $plugin_file Absolute path to the plugin's main file. |
| 173 | * @param string $loaded_version Version of the PHP code loaded for the updater request. |
| 174 | * |
| 175 | * @return bool True when a newer package context was stored. |
| 176 | */ |
| 177 | public static function record_completed_plugin_update( $plugin_file, $loaded_version ) { |
| 178 | $plugin_file = (string) $plugin_file; |
| 179 | $loaded_version = sanitize_text_field( (string) $loaded_version ); |
| 180 | $installed_header = is_readable( $plugin_file ) && function_exists( 'get_file_data' ) |
| 181 | ? get_file_data( $plugin_file, array( 'version' => 'Version' ), 'plugin' ) |
| 182 | : array(); |
| 183 | $installed_version = isset( $installed_header['version'] ) |
| 184 | ? sanitize_text_field( (string) $installed_header['version'] ) |
| 185 | : ''; |
| 186 | |
| 187 | if ( '' === $installed_version || '' === $loaded_version || version_compare( $installed_version, $loaded_version, '<=' ) ) { |
| 188 | return false; |
| 189 | } |
| 190 | |
| 191 | $contexts = get_bk_option( self::UPDATE_CONTEXT_OPTION ); |
| 192 | $contexts = is_array( $contexts ) ? $contexts : array(); |
| 193 | $context_key = self::get_update_context_key( $plugin_file ); |
| 194 | $contexts[ $context_key ] = array( |
| 195 | 'schema_version' => self::UPDATE_CONTEXT_SCHEMA_VERSION, |
| 196 | 'target_version' => $installed_version, |
| 197 | 'source' => self::is_manual_single_plugin_update_request( $plugin_file ) |
| 198 | ? self::UPDATE_SOURCE_MANUAL |
| 199 | : self::UPDATE_SOURCE_SUPPRESSED, |
| 200 | 'user_id' => function_exists( 'get_current_user_id' ) ? absint( get_current_user_id() ) : 0, |
| 201 | 'recorded_at' => time(), |
| 202 | ); |
| 203 | |
| 204 | update_bk_option( self::UPDATE_CONTEXT_OPTION, $contexts ); |
| 205 | |
| 206 | return true; |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Read a valid updater context for one package and target version. |
| 211 | * |
| 212 | * @param string $plugin_file Absolute path to the plugin's main file. |
| 213 | * @param string $plugin_version Version now loaded from the installed package. |
| 214 | * |
| 215 | * @return array|false Valid context, or false for an external/stale replacement. |
| 216 | */ |
| 217 | public static function get_plugin_update_context( $plugin_file, $plugin_version ) { |
| 218 | $contexts = get_bk_option( self::UPDATE_CONTEXT_OPTION ); |
| 219 | $context_key = self::get_update_context_key( $plugin_file ); |
| 220 | $context = is_array( $contexts ) && isset( $contexts[ $context_key ] ) && is_array( $contexts[ $context_key ] ) |
| 221 | ? $contexts[ $context_key ] |
| 222 | : array(); |
| 223 | $recorded_at = absint( isset( $context['recorded_at'] ) ? $context['recorded_at'] : 0 ); |
| 224 | $current_time = time(); |
| 225 | |
| 226 | if ( |
| 227 | self::UPDATE_CONTEXT_SCHEMA_VERSION !== absint( isset( $context['schema_version'] ) ? $context['schema_version'] : 0 ) |
| 228 | || sanitize_text_field( (string) $plugin_version ) !== ( isset( $context['target_version'] ) ? $context['target_version'] : '' ) |
| 229 | || 0 === $recorded_at |
| 230 | || $recorded_at > ( $current_time + 300 ) |
| 231 | || $recorded_at < ( $current_time - self::UPDATE_CONTEXT_TTL ) |
| 232 | || ! in_array( |
| 233 | isset( $context['source'] ) ? $context['source'] : '', |
| 234 | array( self::UPDATE_SOURCE_MANUAL, self::UPDATE_SOURCE_SUPPRESSED ), |
| 235 | true |
| 236 | ) |
| 237 | ) { |
| 238 | return false; |
| 239 | } |
| 240 | |
| 241 | return $context; |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Remove one package's updater handoff after activation has completed. |
| 246 | * |
| 247 | * @param string $plugin_file Absolute path to the plugin's main file. |
| 248 | * |
| 249 | * @return void |
| 250 | */ |
| 251 | public static function clear_plugin_update_context( $plugin_file ) { |
| 252 | $contexts = get_bk_option( self::UPDATE_CONTEXT_OPTION ); |
| 253 | if ( ! is_array( $contexts ) ) { |
| 254 | return; |
| 255 | } |
| 256 | |
| 257 | $context_key = self::get_update_context_key( $plugin_file ); |
| 258 | unset( $contexts[ $context_key ] ); |
| 259 | |
| 260 | if ( empty( $contexts ) ) { |
| 261 | delete_bk_option( self::UPDATE_CONTEXT_OPTION ); |
| 262 | return; |
| 263 | } |
| 264 | |
| 265 | update_bk_option( self::UPDATE_CONTEXT_OPTION, $contexts ); |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Detect WordPress's interactive, single-plugin Update now request shapes. |
| 270 | * |
| 271 | * AJAX `update-plugin` and the legacy `update.php?action=upgrade-plugin` |
| 272 | * request are intentionally accepted. Cron, WP-CLI, and Network Admin updater |
| 273 | * requests are excluded so they cannot take over later navigation. WordPress |
| 274 | * sends queued Plugins-screen bulk updates through the same `update-plugin` |
| 275 | * request shape, so those remain interactive administrator updates. |
| 276 | * |
| 277 | * @param string $plugin_file Absolute path to the plugin's main file. |
| 278 | * |
| 279 | * @return bool True only for an interactive update of this exact plugin. |
| 280 | */ |
| 281 | public static function is_manual_single_plugin_update_request( $plugin_file ) { |
| 282 | if ( |
| 283 | ( defined( 'WP_CLI' ) && WP_CLI ) |
| 284 | || ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) |
| 285 | || is_network_admin() |
| 286 | ) { |
| 287 | return false; |
| 288 | } |
| 289 | |
| 290 | $expected_plugin = function_exists( 'plugin_basename' ) ? plugin_basename( $plugin_file ) : basename( $plugin_file ); |
| 291 | |
| 292 | if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { |
| 293 | // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Core verifies the updater nonce before this post-install filter runs. |
| 294 | $request_action = isset( $_POST['action'] ) && is_scalar( $_POST['action'] ) ? sanitize_key( wp_unslash( $_POST['action'] ) ) : ''; |
| 295 | // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only request classification after the authorized core update. |
| 296 | $request_plugin = isset( $_POST['plugin'] ) && is_scalar( $_POST['plugin'] ) ? sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) : ''; |
| 297 | |
| 298 | return 'update-plugin' === $request_action |
| 299 | && $expected_plugin === ( function_exists( 'plugin_basename' ) ? plugin_basename( $request_plugin ) : $request_plugin ); |
| 300 | } |
| 301 | |
| 302 | global $pagenow; |
| 303 | if ( 'update.php' !== $pagenow ) { |
| 304 | return false; |
| 305 | } |
| 306 | |
| 307 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Core verifies the updater nonce before this post-install filter runs. |
| 308 | $request_action = isset( $_GET['action'] ) && is_scalar( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; |
| 309 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only request classification after the authorized core update. |
| 310 | $request_plugin = isset( $_GET['plugin'] ) && is_scalar( $_GET['plugin'] ) ? sanitize_text_field( wp_unslash( $_GET['plugin'] ) ) : ''; |
| 311 | |
| 312 | return 'upgrade-plugin' === $request_action |
| 313 | && $expected_plugin === ( function_exists( 'plugin_basename' ) ? plugin_basename( $request_plugin ) : $request_plugin ); |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Build an option key that keeps Free and paid package handoffs independent. |
| 318 | * |
| 319 | * @param string $plugin_file Absolute path to the plugin's main file. |
| 320 | * |
| 321 | * @return string Stable non-sensitive package key. |
| 322 | */ |
| 323 | private static function get_update_context_key( $plugin_file ) { |
| 324 | $plugin_basename = function_exists( 'plugin_basename' ) ? plugin_basename( $plugin_file ) : basename( $plugin_file ); |
| 325 | |
| 326 | return md5( strtolower( (string) $plugin_basename ) ); |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * Check environments where automatic onboarding must never take over navigation. |
| 331 | * |
| 332 | * Manual access to Setup Wizard remains governed by its normal access and |
| 333 | * environment policies. This restriction applies only to automatic activation |
| 334 | * redirects and the one-time Booking Listing invitation. |
| 335 | * |
| 336 | * @return bool True when automatic onboarding must remain suppressed. |
| 337 | */ |
| 338 | public static function is_automatic_onboarding_restricted() { |
| 339 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only activation context detection. |
| 340 | $is_bulk_activation = isset( $_GET['activate-multi'] ); |
| 341 | |
| 342 | return ! WPBC_Environment_Policy::allows_automatic_setup_onboarding() |
| 343 | || is_network_admin() |
| 344 | || $is_bulk_activation |
| 345 | || defined( 'IFRAME_REQUEST' ); |
| 346 | } |
| 347 | } |
| 348 |