http = $http ?? new HttpClient(); } public function register(): void { register_rest_route( RestRegistrar::NAMESPACE, '/connect', [ 'methods' => 'POST', 'callback' => [ $this, 'handle' ], 'permission_callback' => [ $this->permission, 'is_admin' ], 'args' => [ 'connection_info' => [ 'required' => true, 'type' => 'string', ], 'label' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => [ Sanitizer::class, 'label' ], 'default' => '', ], ], ] ); } public function handle( WP_REST_Request $request ): WP_REST_Response|WP_Error { $info_raw = trim( (string) $request->get_param( 'connection_info' ) ); // Format is "URL SECRET_KEY", separated by a single space. $space = strpos( $info_raw, ' ' ); if ( false === $space ) { return new WP_Error( 'shiftora_invalid_connection_info', __( 'Invalid Connection Info format. Expected: https://remote-site.com SECRET_KEY, make sure to copy the complete Connection Info string from the remote site\'s Connections page, including both the URL and the secret key.', 'shiftora' ), [ 'status' => 422 ] ); } $raw_url = trim( substr( $info_raw, 0, $space ) ); $remote_secret = trim( substr( $info_raw, $space + 1 ) ); if ( ! str_starts_with( $raw_url, 'http://' ) && ! str_starts_with( $raw_url, 'https://' ) ) { return new WP_Error( 'shiftora_invalid_url', __( 'The Connection Info URL must start with http:// or https://. Check that you copied the full URL.', 'shiftora' ), [ 'status' => 422 ] ); } $remote_url = Sanitizer::url( $raw_url ); if ( empty( $remote_url ) ) { return new WP_Error( 'shiftora_invalid_url', __( 'The Connection Info contains an invalid URL. Check for typos or extra characters.', 'shiftora' ), [ 'status' => 422 ] ); } if ( strlen( $remote_secret ) < 20 ) { return new WP_Error( 'shiftora_invalid_secret', __( 'The Connection Info contains an invalid or incomplete secret key.', 'shiftora' ), [ 'status' => 422 ] ); } if ( null === Sanitizer::safe_remote_url( $remote_url ) ) { return new WP_Error( 'shiftora_invalid_url', __( 'This site cannot connect to the remote URL because it appears to be a local or private address that is not reachable from a public server.', 'shiftora' ), [ 'status' => 422 ] ); } // Refuse a self-connection, comparing normalized URLs (www, ports, scheme variants). if ( Sanitizer::normalize_url_for_comparison( $remote_url ) === Sanitizer::normalize_url_for_comparison( get_site_url() ) ) { return new WP_Error( 'shiftora_self_connect', __( 'Cannot connect a site to itself.', 'shiftora' ), [ 'status' => 422 ] ); } $requested_label = (string) $request->get_param( 'label' ); $label = $requested_label; if ( empty( $label ) ) { $label = wp_parse_url( $remote_url, PHP_URL_HOST ) ?: $remote_url; } // $label is the caller's own nickname for the connection. The remote shows the inbound // pairing under this site's host name instead, or "Production" would become how the // remote displays this site. $this_site_identity = wp_parse_url( get_site_url(), PHP_URL_HOST ) ?: get_site_url(); $handshake_url = rtrim( $remote_url, '/' ) . '/wp-json/shiftora/v1/handshake'; global $wpdb; $payload = wp_json_encode( [ 'remote_url' => get_site_url(), 'remote_token' => $remote_secret, 'label' => $this_site_identity, 'plugin_version' => SHIFTORA_VERSION, 'db_prefix' => $wpdb->prefix, 'ajax_url' => admin_url( 'admin-ajax.php' ), 'capabilities' => PairingCapabilities::advertised(), ] ); try { $response = $this->http->post( $handshake_url, $payload, [ // The http fallback is for the handshake only; migrations never downgrade mid-run. '_shiftora_allow_http_fallback' => '1', ] ); } catch ( RuntimeException $e ) { // Pairing has no connection yet, so a refused credential is not a "rejected connection" here. $explained = RemoteErrorExplainer::explain( $e ); if ( null !== $explained ) { ServerLog::write( '[Shiftora] handshake failed: ' . $e->getMessage() ); return new WP_Error( $explained['code'], $explained['message'], [ 'status' => 502, 'hint' => $explained['hint'], 'detail' => $explained['detail'], ] ); } return new WP_Error( 'shiftora_handshake_failed', sprintf( /* translators: %s: error message from the remote site */ __( 'Could not reach the remote site: %s', 'shiftora' ), $e->getMessage() ), [ 'status' => 502 ] ); } $remote_data = json_decode( $response['body'], true ); $effective_remote_url = $remote_url; if ( ! empty( $response['effective_url'] ) && is_string( $response['effective_url'] ) ) { $effective_remote_url = preg_replace( '#/wp-json/shiftora/v1/handshake$#', '', (string) $response['effective_url'] ) ?: $remote_url; $effective_remote_url = Sanitizer::url( $effective_remote_url ) ?: $remote_url; } if ( empty( $remote_data['local_token'] ) ) { return new WP_Error( 'shiftora_handshake_invalid_response', __( 'Remote site returned an unexpected response. Check the Connection Info and try again.', 'shiftora' ), [ 'status' => 502 ] ); } // This site presents this token when calling the remote for migrations. $remote_local_token = (string) $remote_data['local_token']; // A site on a build that predates capabilities sends none, and pairs as before. One // that sends them must prove them with the pairing secret, or nothing is stored. $remote_capabilities = []; if ( isset( $remote_data['capabilities'] ) ) { $remote_capabilities = is_array( $remote_data['capabilities'] ) ? $remote_data['capabilities'] : []; if ( ! PairingCapabilities::verify( $remote_secret, $remote_local_token, $remote_capabilities, (string) ( $remote_data['capabilities_signature'] ?? '' ) ) ) { return new WP_Error( 'shiftora_handshake_invalid_response', __( 'The remote site answered with capabilities that do not match its signature, so the connection was not saved. Check the Connection Info and try again.', 'shiftora' ), [ 'status' => 502 ] ); } } // Lets pull migrations translate table names across differing prefixes. $remote_db_prefix = isset( $remote_data['db_prefix'] ) ? (string) $remote_data['db_prefix'] : 'wp_'; // Where the remote's admin-ajax fallback lives; nothing is guessed if it says nothing. HttpClient::remember_ajax_url( $effective_remote_url, $remote_data['ajax_url'] ?? null ); $remote_allow_pull = isset( $remote_data['allow_pull'] ) ? (int) (bool) $remote_data['allow_pull'] : 1; // The remote's own get_site_url(), as its database stores URLs; it can differ from the typed URL (www, scheme, port). $remote_site_url = isset( $remote_data['site_url'] ) ? esc_url_raw( (string) $remote_data['site_url'] ) : $effective_remote_url; $remote_version = isset( $remote_data['version'] ) ? sanitize_text_field( (string) $remote_data['version'] ) : ''; // Pairing again with a site already pinned cannot lower what it announced before. if ( PairingCapabilities::was_lowered( $effective_remote_url, $remote_capabilities ) ) { return new WP_Error( 'shiftora_handshake_invalid_response', __( 'The remote site no longer announces a security capability it announced before, so the connection was not changed.', 'shiftora' ), [ 'status' => 502 ] ); } // Used if the remote ever calls back. $own_local_token = TokenGenerator::generate(); $was_existing = false; try { // An existing connection to this URL gets its tokens updated, not a duplicate row. $existing = $this->connections->find_by_url( $effective_remote_url ); if ( $existing ) { $this->connections->update_tokens( (int) $existing->id, $own_local_token, $remote_local_token, $remote_site_url, $remote_version, $remote_db_prefix, '' !== $requested_label ? $requested_label : null ); $this->connections->set_remote_permissions( (int) $existing->id, $remote_allow_pull ); $connection_id = (int) $existing->id; $was_existing = true; } else { $connection_id = $this->connections->create( $label, $effective_remote_url, $own_local_token, $remote_local_token, $remote_db_prefix, $remote_allow_pull, $remote_site_url, $remote_version ); $this->connections->activate( $connection_id ); } } catch ( RuntimeException $e ) { return new WP_Error( 'shiftora_db_error', __( 'Connection established with remote site but failed to save locally. Please try again.', 'shiftora' ), [ 'status' => 500 ] ); } PairingCapabilities::pin( $effective_remote_url, $remote_capabilities ); return new WP_REST_Response( [ 'success' => true, 'connection_id' => $connection_id, 'remote_url' => $effective_remote_url, 'updated' => $was_existing, ], 201 ); } }