loader = new Loader(); $this->register_services(); } /** Initializes the plugin on `plugins_loaded`; bootstraps once. */ public static function init(): void { if ( null === self::$instance ) { self::$instance = new self(); self::$instance->loader->run(); } } /** Returns the singleton, or null before init() runs. */ public static function instance(): ?self { return self::$instance; } /** Registers all service hooks with the Loader. */ private function register_services(): void { // Role-key repair that runs once a cross-prefix pull flags it pending. $this->loader->add_action( 'init', $this, 'repair_prefix_bound_access_keys', 5 ); // Covers sites copied in rather than activated. Loader only takes object callbacks. add_action( 'admin_init', [ Installer::class, 'maybe_upgrade' ] ); add_action( 'admin_init', [ DataDirectory::class, 'adopt_legacy_once' ] ); // Stall recovery: custom two-minute cron interval. add_filter( 'cron_schedules', static function ( array $schedules ): array { if ( ! isset( $schedules[ StallRecovery::CRON_INTERVAL ] ) ) { $schedules[ StallRecovery::CRON_INTERVAL ] = [ 'interval' => 120, 'display' => __( 'Every 2 Minutes', 'shiftora' ), ]; } return $schedules; } ); // Schedules the event if it is not already queued. add_action( 'admin_init', [ StallRecovery::class, 'schedule_cron' ] ); add_action( StallRecovery::CRON_HOOK, static function (): void { global $wpdb; StallRecovery::run( $wpdb, static fn( int $migration_id ) => ServiceFactory::migration_engine()->finish_stuck_cancel( $migration_id ) ); } ); // Re-trigger recovery for failed migrations that hold a recovery lock and are not yet recovered. add_action( StallRecovery::CRON_HOOK, static function (): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE status = %s', $wpdb->prefix . 'shiftora_migrations', MigrationStatus::Failed->value ) ); if ( empty( $rows ) ) { return; } $recovery = ServiceFactory::recovery_manager(); foreach ( $rows as $row ) { $migration_id = (int) $row->id; $lock = $recovery->get_lock( $migration_id ); if ( null === $lock ) { continue; } // Skip runs recovery already attempted or declined, or they would be re-failed every two minutes. if ( in_array( $lock['status'] ?? '', [ 'recovery_failed', 'recovery_skipped' ], true ) ) { continue; } if ( function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( 'shiftora_auto_recovery', [ $migration_id ], 'shiftora' ) ) { continue; } $recovery->mark_failed( $migration_id, 'Auto-recovery re-triggered by cron after stall.' ); } } ); if ( is_admin() ) { $menu = new AdminMenu(); $assets = new Assets(); $retired = new RetiredPages(); $this->loader->add_action( 'admin_menu', $menu, 'register' ); $this->loader->add_action( 'admin_head', $menu, 'prune_sidebar' ); $this->loader->add_action( 'admin_enqueue_scripts', $assets, 'enqueue', 10, 1 ); $this->loader->add_filter( 'admin_body_class', $assets, 'body_class', 10, 1 ); $this->loader->add_filter( 'wp_auth_check_load', $assets, 'auth_check_load', 10, 2 ); } // A sign-out or a password reset revokes every secret that could sign a page back in. $handoff = new SessionHandoff(); $this->loader->add_action( 'wp_logout', $handoff, 'forget_all' ); $this->loader->add_action( 'after_password_reset', $handoff, 'forget_all' ); if ( is_admin() ) { // Fires just before WordPress refuses an unregistered page= slug, the last point to redirect a retired one. $this->loader->add_action( 'admin_page_access_denied', $retired, 'maybe_redirect' ); } // Detects unfinished migrations, offers recovery and handles scheduled auto-recovery. $recovery = ServiceFactory::recovery_manager(); $recovery->register_hooks(); $this->loader->add_action( 'admin_init', $recovery, 'detect_and_offer_recovery' ); // Settings forms and the recovery notice post to admin-post.php; each handler checks its own nonce and capability. AdminPostHandlers::register(); // REST API. $rest = new RestRegistrar(); $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); // Admin-AJAX fallback for when the remote REST API returns 404 (broken permalinks, a security plugin). add_action( 'init', [ AdminAjaxProxy::class, 'register' ] ); // File downloads. On admin_init because rest_api_init does not fire on admin-post.php requests. add_action( 'admin_init', static function (): void { $export_file = new \Shiftora\Api\Endpoints\ExportFileEndpoint(); add_action( 'admin_post_shiftora_export_file', [ $export_file, 'serve_download' ] ); global $wpdb; $backup = new \Shiftora\Api\Endpoints\BackupEndpoint( $wpdb ); add_action( 'admin_post_shiftora_download_backup', [ $backup, 'serve_download' ] ); } ); // Chunk processing hooks. MigrationJob::register(); MigrationJob::register_file_hook(); // Daily prune of log rows older than 30 days. add_action( 'admin_init', static function (): void { if ( ! wp_next_scheduled( 'shiftora_prune_logs' ) ) { wp_schedule_event( time(), 'daily', 'shiftora_prune_logs' ); } } ); add_action( 'shiftora_prune_logs', static function (): void { MigrationLogger::prune( 30 ); } ); } /** Repairs role and capability keys after a cross-prefix import; only scans when a pull flagged it pending, and clears the flag however it exits. */ public function repair_prefix_bound_access_keys(): void { if ( ! get_option( 'shiftora_repair_prefix_pending' ) ) { return; } try { $this->repair_prefix_bound_access_keys_inner(); } finally { delete_option( 'shiftora_repair_prefix_pending' ); } } /** Scan-and-repair pass, only called when repair is pending. */ private function repair_prefix_bound_access_keys_inner(): void { global $wpdb; if ( ! ( $wpdb instanceof \wpdb ) ) { return; } $local_prefix = (string) $wpdb->prefix; $options_table = $wpdb->prefix . 'options'; $usermeta_table = $wpdb->prefix . 'usermeta'; $role_option = $local_prefix . 'user_roles'; if ( null === get_option( $role_option, null ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the roles row of another prefix, fresh read required $foreign_roles = $wpdb->get_row( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name LIKE %s AND option_name <> %s ORDER BY option_id DESC LIMIT 1', $options_table, '%' . $wpdb->esc_like( 'user_roles' ), $role_option ), ARRAY_A ); if ( is_array( $foreign_roles ) && isset( $foreign_roles['option_value'] ) ) { update_option( $role_option, maybe_unserialize( $foreign_roles['option_value'] ), false ); } } $local_like = $wpdb->esc_like( $local_prefix ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the capability rows of another prefix, fresh read required $foreign_meta = $wpdb->get_results( $wpdb->prepare( 'SELECT user_id, meta_key FROM %i WHERE (meta_key LIKE %s OR meta_key LIKE %s) AND meta_key NOT LIKE %s', $usermeta_table, '%' . $wpdb->esc_like( 'capabilities' ), '%' . $wpdb->esc_like( 'user_level' ), $local_like ), ARRAY_A ); if ( ! is_array( $foreign_meta ) ) { return; } foreach ( $foreign_meta as $row ) { $user_id = (int) ( $row['user_id'] ?? 0 ); $meta_key = (string) ( $row['meta_key'] ?? '' ); if ( $user_id <= 0 || '' === $meta_key ) { continue; } if ( ! preg_match( '/^(?:.+?_)?((?:\d+_)?(?:capabilities|user_level))$/', $meta_key, $matches ) ) { continue; } $target_key = $local_prefix . $matches[1]; if ( ! metadata_exists( 'user', $user_id, $target_key ) && metadata_exists( 'user', $user_id, $meta_key ) ) { update_user_meta( $user_id, $target_key, get_user_meta( $user_id, $meta_key, true ) ); } delete_user_meta( $user_id, $meta_key ); clean_user_cache( $user_id ); } } }