|null */ private ?array $remote_capabilities = null; public function __construct( ?ConnectionManager $connections = null, ?ConnectionValidator $validator = null, ?HttpClient $http = null, ?MigrationRequestAuthenticator $request_authenticator = null ) { $this->connections = $connections ?? new ConnectionManager(); $this->http = $http ?? new HttpClient(); $this->validator = $validator ?? new ConnectionValidator( $this->http ); $this->request_authenticator = $request_authenticator ?? new MigrationRequestAuthenticator( $this->connections ); } /** * Run all preflight checks for a proposed pull. * * @return array Results keyed by check name. */ public function run( int $connection_id ): array { $results = []; // Local fact, so it goes first and holds even when the connection check fails. $results['host_upload_limit'] = $this->check_host_upload_limit( HostUploadLimits::from_php() ); $connection = $this->connections->find_by_id( $connection_id ); // --- Check: connection exists and is active --- if ( null === $connection || 'active' !== $connection->status ) { $results['connection_active'] = [ 'pass' => false, 'message' => __( 'The selected connection is not active. Please re-connect the sites.', 'shiftora' ), ]; return $results; // No point running further checks. } $results['connection_active'] = [ 'pass' => true, 'message' => __( 'Connection is active.', 'shiftora' ), ]; $remote_allow_pull = isset( $connection->remote_allow_pull ) ? (bool) $connection->remote_allow_pull : true; $remote_version = isset( $connection->remote_plugin_version ) ? trim( (string) $connection->remote_plugin_version ) : ''; // --- Check: remote site is reachable --- $ping = null; try { $ping = $this->validator->ping( $connection->remote_url ); if ( ! empty( $ping['version'] ) ) { $remote_version = trim( (string) $ping['version'] ); } if ( array_key_exists( 'allow_pull', $ping ) ) { $remote_allow_pull = (bool) $ping['allow_pull']; } if ( isset( $connection->id ) ) { $this->connections->set_remote_permissions( (int) $connection->id, (int) $remote_allow_pull ); if ( '' !== $remote_version ) { $this->connections->set_remote_version( (int) $connection->id, $remote_version ); } } $results['remote_reachable'] = [ 'pass' => true, 'message' => sprintf( /* translators: %d: latency in milliseconds */ __( 'Remote site reachable (%d ms).', 'shiftora' ), $ping['latency_ms'] ), ]; } catch ( RuntimeException $e ) { $results['remote_reachable'] = [ 'pass' => false, 'message' => self::readable_failure( $e ), ]; } // --- Check: the source lets other sites pull from it --- $results['permission'] = [ 'pass' => $remote_allow_pull, 'message' => $remote_allow_pull ? __( 'The remote site allows this site to pull from it.', 'shiftora' ) : __( 'The remote site is not allowing other sites to pull from it right now.', 'shiftora' ), ]; // --- Check: exact plugin version parity between both sites --- $local_version = defined( 'SHIFTORA_VERSION' ) ? (string) SHIFTORA_VERSION : ''; $results['version_compat'] = $this->check_plugin_version( $local_version, $remote_version ); // --- Check: no other migration is currently running for this connection --- $results['no_active_migration'] = $this->check_no_active_migration( $connection_id ); // --- Check: HTTPS to HTTP mismatch warning --- $results['ssl_mismatch'] = $this->check_ssl_mismatch( $connection->remote_url ); // --- Check: sufficient disk space --- $results['disk_space'] = $this->check_disk_space(); // --- Check: writable uploads directory for backups --- $results['backup_dir_writable'] = $this->check_backup_dir(); // --- Check: PHP environment limits --- $results['php_limits'] = $this->check_php_limits(); // --- Check: MySQL version compatibility --- $results['mysql_version'] = $this->check_mysql_version(); // --- Check: end-to-end connection token handshake --- // Populates $this->remote_capabilities, which the two checks below read. $results['encryption_handshake'] = $this->check_encryption_handshake( $connection ); // --- Check: the remote server's MySQL version clears the same floor --- $results['remote_mysql_version'] = $this->check_remote_mysql_version(); // --- Check: table-name case folding matches between the two servers --- $results['table_name_case'] = $this->check_table_name_case(); // --- Check: Action Scheduler (background worker) availability --- $results['action_scheduler'] = $this->check_action_scheduler(); return $results; } /** * What to show for a failed call to the other site: the readable explanation, with * the raw text kept in the server log. Any other failure keeps its own message. */ private static function readable_failure( RuntimeException $e ): string { $line = RemoteErrorExplainer::line_for( $e ); if ( null === $line ) { return $e->getMessage(); } ServerLog::write( '[Shiftora] preflight remote failure: ' . $e->getMessage() ); return $line; } /** * Require exact plugin version parity for pull safety. * * @return array{pass: bool, message: string} */ private function check_plugin_version( string $local_version, string $remote_version ): array { $local_version = trim( $local_version ); $remote_version = trim( $remote_version ); if ( '' === $remote_version ) { return [ 'pass' => false, 'message' => __( 'Could not verify the remote Shiftora plugin version. Reconnect the sites and try again.', 'shiftora' ), ]; } if ( $local_version !== $remote_version ) { return [ 'pass' => false, 'message' => sprintf( /* translators: 1: local version, 2: remote version */ __( 'Plugin version mismatch: local %1$s, remote %2$s. Pulls are blocked until both sites run the same Shiftora version. Update both sites and reconnect them before retrying.', 'shiftora' ), $local_version, $remote_version ), ]; } return [ 'pass' => true, 'message' => sprintf( /* translators: %s: version */ __( 'Plugin versions match (%s).', 'shiftora' ), $local_version ), ]; } /** * The version a peer advertised, or 0 when nothing usable. * * @param mixed $advertised Raw value from the ping payload. */ public static function advertised_protocol( mixed $advertised ): int { return TransferProtocol::advertised_version( $advertised ); } /** * True if all checks passed. * * @param array $results Output from run(). */ public static function all_passed( array $results ): bool { foreach ( $results as $check ) { if ( ! $check['pass'] ) { return false; } } return true; } /** * Warn when migrating from HTTPS to HTTP, where credentials may travel in * plaintext. Warning only (pass = true): local setups are often HTTP-only. * * @return array{pass: bool, message: string} */ private function check_ssl_mismatch( string $remote_url ): array { $local_is_https = 'https' === wp_parse_url( get_site_url(), PHP_URL_SCHEME ); $remote_is_https = 'https' === wp_parse_url( $remote_url, PHP_URL_SCHEME ); if ( $local_is_https && ! $remote_is_https ) { return [ 'pass' => true, // Warning only, does not block. 'message' => __( 'Warning: the remote site uses HTTP while this site uses HTTPS. Migration data will be sent over an unencrypted connection.', 'shiftora' ), ]; } return [ 'pass' => true, 'message' => __( 'SSL configuration is consistent between sites.', 'shiftora' ), ]; } /** * Check that no migration for this connection is currently running. * * @return array{pass: bool, message: string} */ private function check_no_active_migration( int $connection_id ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE connection_id = %d AND status IN ('pending','running','paused')", $wpdb->prefix . 'shiftora_migrations', $connection_id ) ); return [ 'pass' => $count === 0, 'message' => $count === 0 ? __( 'No active migration in progress.', 'shiftora' ) : __( 'A migration is already running for this connection. Please wait for it to complete.', 'shiftora' ), ]; } /** * Require at least 100 MB free disk space. * * @return array{pass: bool, message: string} */ private function check_disk_space(): array { $upload_dir = wp_upload_dir(); $path = $upload_dir['basedir']; $free_bytes = disk_free_space( $path ); if ( false === $free_bytes ) { return [ 'pass' => true, // Cannot determine, allow. 'message' => __( 'Could not determine available disk space.', 'shiftora' ), ]; } $min_required = 100 * 1024 * 1024; // 100 MB. $free_bytes_i = (int) $free_bytes; return [ 'pass' => $free_bytes_i >= $min_required, 'message' => $free_bytes_i >= $min_required ? sprintf( /* translators: %s: free disk space formatted (e.g. "2.50 GB") */ __( 'Sufficient disk space available (%s free).', 'shiftora' ), size_format( $free_bytes_i ) ) : sprintf( /* translators: %s: free disk space formatted */ __( 'Insufficient disk space (%s free). At least 100 MB required.', 'shiftora' ), size_format( $free_bytes_i ) ), ]; } /** * Check that the Shiftora backup directory is writable. * * @return array{pass: bool, message: string} */ private function check_backup_dir(): array { $backup_dir = DataDirectory::ensure( DataDirectory::BACKUPS ); $writable = '' !== $backup_dir && is_writable( $backup_dir ); return [ 'pass' => $writable, 'message' => $writable ? __( 'Backup directory is writable.', 'shiftora' ) : __( 'Backup directory is not writable. Please check file permissions.', 'shiftora' ), ]; } /** * State the host's upload ceiling before a transfer starts. Always passes: the * ceiling belongs to the server, and failing on it would refuse transfers the * host can handle. * * @return array{pass: bool, message: string, effective_bytes: int, binding_setting: ?string} */ private function check_host_upload_limit( HostUploadLimits $limits ): array { $report = $limits->report(); return [ 'pass' => true, 'message' => $report['summary'], 'effective_bytes' => $report['effective_bytes'], 'binding_setting' => $report['binding_setting'], ]; } /** * Check PHP limits that could stop a large migration. * * Warns if max_execution_time < 30 (unless 0). Fails if memory_limit < 64 MB. * * @return array{pass: bool, message: string} */ private function check_php_limits(): array { // Memory check. $memory_limit = (int) wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) ); $min_memory = 64 * 1024 * 1024; // 64 MB. if ( $memory_limit > 0 && $memory_limit < $min_memory ) { return [ 'pass' => false, 'message' => sprintf( /* translators: %s: current memory limit (e.g. "32M") */ __( 'PHP memory_limit is too low (%s). At least 64M is required for migrations.', 'shiftora' ), size_format( $memory_limit ) ), ]; } // Execution time warning. $max_exec = (int) ini_get( 'max_execution_time' ); if ( $max_exec > 0 && $max_exec < 30 ) { return [ 'pass' => true, // Warning only. 'message' => sprintf( /* translators: %d: max_execution_time value */ __( 'Warning: max_execution_time is %d seconds. Long-running chunks may be interrupted.', 'shiftora' ), $max_exec ), ]; } return [ 'pass' => true, 'message' => __( 'PHP environment limits are acceptable.', 'shiftora' ), ]; } /** * Check the local MySQL version. Floors are MySQL 5.7 and MariaDB 10.2; below * them the JSON operators and stored functions the migration uses are unreliable. * * @return array{pass: bool, message: string} */ private function check_mysql_version(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $version = (string) $wpdb->get_var( 'SELECT VERSION()' ); if ( empty( $version ) ) { return [ 'pass' => true, // Cannot determine, allow. 'message' => __( 'Could not determine MySQL version.', 'shiftora' ), ]; } $is_mariadb = false !== stripos( $version, 'mariadb' ); // MariaDB reports a fake "5.5.5-" prefix ahead of the real version. $version = (string) preg_replace( '/^5\.5\.5-/', '', $version ); $numeric = (string) preg_replace( '/[^0-9.].*/', '', $version ); if ( $is_mariadb ) { if ( version_compare( $numeric, '10.2', '<' ) ) { return [ 'pass' => false, 'message' => sprintf( /* translators: %s: MySQL version string */ __( 'MariaDB %s is not supported. Version 10.2 or higher is required for pulls.', 'shiftora' ), esc_html( $version ) ), ]; } return [ 'pass' => true, 'message' => sprintf( /* translators: %s: MySQL version string */ __( 'MariaDB %s is compatible.', 'shiftora' ), esc_html( $version ) ), ]; } if ( version_compare( $numeric, '5.7', '<' ) ) { return [ 'pass' => false, 'message' => sprintf( /* translators: %s: MySQL version string */ __( 'MySQL %s is not supported. Version 5.7 or higher is required for pulls.', 'shiftora' ), esc_html( $version ) ), ]; } return [ 'pass' => true, 'message' => sprintf( /* translators: %s: MySQL version string */ __( 'MySQL %s is compatible.', 'shiftora' ), esc_html( $version ) ), ]; } /** * Hold the remote MySQL server to the local floor. A peer that reports no version * is passed, so a working older connection does not regress. * * @return array{pass: bool, message: string} */ private function check_remote_mysql_version(): array { $caps = $this->remote_capabilities; $numeric = is_array( $caps ) ? (string) ( $caps['numeric_version'] ?? '' ) : ''; if ( '' === $numeric ) { return [ 'pass' => true, 'message' => __( 'Remote database version could not be read; skipping that check.', 'shiftora' ), ]; } $is_mariadb = ! empty( $caps['is_mariadb'] ); $shown = (string) ( $caps['mysql_version'] ?? $numeric ); $floor = $is_mariadb ? '10.2' : '5.7'; $flavor = $is_mariadb ? 'MariaDB' : 'MySQL'; if ( version_compare( $numeric, $floor, '<' ) ) { return [ 'pass' => false, 'message' => sprintf( /* translators: 1: database flavor, 2: version string, 3: minimum version */ __( 'The remote site runs %1$s %2$s. Version %3$s or higher is required on both sites for pulls.', 'shiftora' ), $flavor, esc_html( $shown ), $floor ), ]; } return [ 'pass' => true, 'message' => sprintf( /* translators: 1: database flavor, 2: version string */ __( 'Remote database (%1$s %2$s) is compatible.', 'shiftora' ), $flavor, esc_html( $shown ) ), ]; } /** * Warn when the two servers fold table-name case differently * (lower_case_table_names = 1 can make mixed-case names collide or vanish). * Advisory only: Shiftora's own tables are lowercase. * * @return array{pass: bool, message: string} */ private function check_table_name_case(): array { global $wpdb; $caps = $this->remote_capabilities; $remote = is_array( $caps ) && array_key_exists( 'lower_case_table_names', $caps ) ? $caps['lower_case_table_names'] : null; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $local = $wpdb->get_var( 'SELECT @@lower_case_table_names' ); if ( null === $remote || null === $local ) { return [ 'pass' => true, 'message' => __( 'Table-name case setting could not be compared; skipping.', 'shiftora' ), ]; } if ( (int) $remote === (int) $local ) { return [ 'pass' => true, 'message' => __( 'Both servers fold table-name case the same way.', 'shiftora' ), ]; } return [ 'pass' => true, 'message' => __( 'The two servers treat table-name case differently. Standard WordPress tables are unaffected; custom tables with uppercase letters in their names may not transfer cleanly.', 'shiftora' ), ]; } /** * Verify connection-token auth end to end by calling the source's verify-auth * endpoint before any data moves. * * @param object $connection Active connection record with a valid remote_token. * * @return array{pass: bool, message: string} */ private function check_encryption_handshake( object $connection ): array { $route_path = '/shiftora/v1/export/verify-auth'; $url = rtrim( $connection->remote_url, '/' ) . '/wp-json' . $route_path; $signs_responses = false; try { $headers = $this->request_authenticator->create_auth_headers_for_connection( $connection, 'GET', $route_path ); $response = $this->http->get( $url, [ ...$headers, ] ); $payload = json_decode( $response['body'], true ); if ( ! is_array( $payload ) || empty( $payload['ok'] ) ) { return [ 'pass' => false, 'message' => __( 'Remote site did not confirm authentication handshake. Check token and ensure Shiftora is active on the remote site.', 'shiftora' ), ]; } if ( isset( $payload['capabilities'] ) && is_array( $payload['capabilities'] ) ) { $this->remote_capabilities = $payload['capabilities']; } $signs_responses = true === ( $this->remote_capabilities['signs_responses'] ?? false ); if ( true === ( $this->remote_capabilities['accepts_key_id'] ?? false ) ) { HttpClient::expect_key_id( $url ); } if ( $signs_responses ) { // From here an unsigned answer from this site is refused, not just noted. HttpClient::expect_signed_responses( $url ); } if ( isset( $payload['db_space'] ) && is_array( $payload['db_space'] ) ) { $this->remote_db_space = $payload['db_space']; } } catch ( RuntimeException $e ) { return [ 'pass' => false, 'message' => sprintf( /* translators: %s: error message */ __( 'Encryption handshake failed: %s', 'shiftora' ), self::readable_failure( $e ) ), ]; } return [ 'pass' => true, 'message' => $signs_responses ? __( 'Authentication handshake verified.', 'shiftora' ) : __( 'Authentication handshake verified. The other site is on an earlier build and does not sign its responses; update Shiftora there.', 'shiftora' ), ]; } /** * Check that Action Scheduler or WP-Cron is available to run chunks asynchronously. * * @return array{pass: bool, message: string} */ private function check_action_scheduler(): array { // The bundled copy is loaded early in shiftora.php. $bundled_as_path = defined( 'SHIFTORA_DIR' ) ? SHIFTORA_DIR . 'vendor/woocommerce/action-scheduler/action-scheduler.php' : null; $bundled_as = $bundled_as_path && file_exists( $bundled_as_path ); if ( function_exists( 'as_enqueue_async_action' ) ) { $as_message = $bundled_as ? __( 'Bundled Action Scheduler loaded and available for background task processing.', 'shiftora' ) : __( 'Action Scheduler is available for background task processing.', 'shiftora' ); return [ 'pass' => true, 'message' => $as_message, ]; } $has_wp_cron = function_exists( 'wp_schedule_single_event' ) && function_exists( 'wp_next_scheduled' ); if ( $has_wp_cron ) { if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { return [ 'pass' => false, 'message' => __( 'WP-Cron is disabled (DISABLE_WP_CRON) and Action Scheduler is unavailable. Enable WP-Cron or ensure Action Scheduler loads for migrations to run.', 'shiftora' ), ]; } return [ 'pass' => true, 'message' => __( 'Action Scheduler not found; using WP-Cron fallback for background task scheduling. Migrations may pause if WP-Cron is blocked or unreliable.', 'shiftora' ), ]; } return [ 'pass' => false, 'message' => __( 'No background task scheduler found. Migrations require Action Scheduler (or WP-Cron) to run asynchronously.', 'shiftora' ), ]; } // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_is_writable }