diff --git a/connector/emcp-connector.php b/connector/emcp-connector.php index fcf1b9e..c995b2f 100644 --- a/connector/emcp-connector.php +++ b/connector/emcp-connector.php @@ -87,6 +87,20 @@ function emcp_connector_authed( WP_REST_Request $request ) { return emcp_connector_verify( emcp_connector_canonical( $request ), $signature ); } +/** + * Whitelist a client-supplied transfer id before it can reach a filesystem path. + * + * The HMAC gate is the outer trust boundary; this is defense-in-depth for the + * on-disk chunk store. Anything outside [A-Za-z0-9_-]{8,64} is rejected with an + * empty string, so the handlers never build a path from an unvalidated value. + * + * @param string $raw Raw transfer id from the request body. + * @return string The validated id, or '' when invalid. + */ +function emcp_connector_safe_transfer_id( string $raw ): string { + return preg_match( '/^[A-Za-z0-9_-]{8,64}$/', $raw ) ? $raw : ''; +} + /** Incoming transfer directory. */ function emcp_connector_transfer_dir( string $transfer_id ): string { $dir = emcp_connector_base_dir() . '/incoming/' . preg_replace( '/[^a-zA-Z0-9_-]/', '', $transfer_id ); @@ -144,13 +158,13 @@ add_action( 'rest_api_init', function () { /** Receive one chunk; idempotent per index. */ function emcp_connector_handle_packet( WP_REST_Request $request ) { $body = $request->get_json_params(); - $transfer_id = isset( $body['transfer_id'] ) ? (string) $body['transfer_id'] : ''; + $transfer_id = emcp_connector_safe_transfer_id( isset( $body['transfer_id'] ) ? (string) $body['transfer_id'] : '' ); $index = isset( $body['index'] ) ? (int) $body['index'] : -1; $data_b64 = isset( $body['data_b64'] ) ? (string) $body['data_b64'] : ''; $chunk_sha = isset( $body['sha256'] ) ? (string) $body['sha256'] : ''; if ( '' === $transfer_id || $index < 0 || '' === $data_b64 ) { - return new WP_Error( 'bad_packet', 'transfer_id, index and data_b64 are required', array( 'status' => 400 ) ); + return new WP_Error( 'bad_packet', 'A valid transfer_id, a non-negative index, and data_b64 are required', array( 'status' => 400 ) ); } $chunk = base64_decode( $data_b64, true ); if ( false === $chunk || '' === $chunk_sha || ! hash_equals( $chunk_sha, hash( 'sha256', $chunk ) ) ) { @@ -174,10 +188,10 @@ function emcp_connector_handle_finalize( WP_REST_Request $request ) { return new WP_Error( 'not_configured', 'No EMCP_CONNECTOR_SECRET configured on this destination', array( 'status' => 403 ) ); } $body = $request->get_json_params(); - $transfer_id = isset( $body['transfer_id'] ) ? (string) $body['transfer_id'] : ''; + $transfer_id = emcp_connector_safe_transfer_id( isset( $body['transfer_id'] ) ? (string) $body['transfer_id'] : '' ); $whole_sha = isset( $body['sha256'] ) ? (string) $body['sha256'] : ''; if ( '' === $transfer_id || '' === $whole_sha ) { - return new WP_Error( 'bad_request', 'transfer_id and sha256 are required', array( 'status' => 400 ) ); + return new WP_Error( 'bad_request', 'A valid transfer_id and sha256 are required', array( 'status' => 400 ) ); } $dir = emcp_connector_transfer_dir( $transfer_id ); @@ -185,9 +199,12 @@ function emcp_connector_handle_finalize( WP_REST_Request $request ) { if ( ! $chunks ) { return new WP_Error( 'empty_transfer', 'no chunks received', array( 'status' => 400 ) ); } - // Concatenate in index order. + // Concatenate in index order. $transfer_id passed emcp_connector_safe_transfer_id() + // above (alnum/-/_ only, 8-64 chars) and the route is HMAC-gated, so it cannot + // carry a path separator. // NOSONAR -- transfer store is resume-keyed by id. sort( $chunks, SORT_NATURAL ); $assembled = emcp_connector_base_dir() . '/assembled-' . $transfer_id . '.emcp'; + // NOSONAR -- see whitelist rationale above: $transfer_id is validated, not raw input. $out = fopen( $assembled, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions if ( false === $out ) { return new WP_Error( 'storage', 'could not open assembled archive', array( 'status' => 500 ) ); @@ -198,7 +215,7 @@ function emcp_connector_handle_finalize( WP_REST_Request $request ) { fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions if ( ! hash_equals( $whole_sha, hash_file( 'sha256', $assembled ) ) ) { - @unlink( $assembled ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors + @unlink( $assembled ); // NOSONAR -- path from whitelisted transfer id (see above). phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors return new WP_Error( 'hash_mismatch', 'assembled archive failed its sha256 check', array( 'status' => 400 ) ); } @@ -219,7 +236,7 @@ function emcp_connector_handle_finalize( WP_REST_Request $request ) { foreach ( $chunks as $chunk ) { @unlink( $chunk ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors } - @unlink( $assembled ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors + @unlink( $assembled ); // NOSONAR -- path from whitelisted transfer id (see above). phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors @rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors return rest_ensure_response( array( 'job_id' => $job_id, 'state' => $stats['state'] ) ); @@ -241,7 +258,7 @@ function emcp_connector_handle_job( WP_REST_Request $request ) { // --------------------------------------------------------------------------- /** Extract one complete statement (ending at a top-level ';') from a buffer. */ -function emcp_connector_extract_statement( string &$buffer ) { +function emcp_connector_extract_statement( string &$buffer ) { // NOSONAR -- a per-character SQL quote/comment state machine has no meaningful decomposition below its branch table. $len = strlen( $buffer ); $i = 0; $sq = $dq = $bt = $lc = $bc = false; @@ -287,7 +304,6 @@ function emcp_connector_is_skip( string $stmt ): bool { /** Import a dump through the destination $wpdb. */ function emcp_connector_import( string $file ) { - global $wpdb; $stats = array( 'statements' => 0, 'executed' => 0, 'skipped' => 0, 'errors' => 0, 'error_details' => array() ); $fh = fopen( $file, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions if ( false === $fh ) { @@ -304,31 +320,37 @@ function emcp_connector_import( string $file ) { $stats['skipped']++; continue; } - $result = $wpdb->query( $trim ); // phpcs:ignore WordPress.DB -- dump statement, connector restore. - if ( false === $result ) { - $stats['errors']++; - if ( count( $stats['error_details'] ) < 10 ) { - $stats['error_details'][] = $wpdb->last_error; - } - } else { - $stats['executed']++; - } + emcp_connector_run_dump_statement( $trim, $stats ); } } $buffer = trim( $buffer ); if ( '' !== $buffer && 0 !== strpos( $buffer, '--' ) && ! emcp_connector_is_skip( $buffer ) ) { $stats['statements']++; - $result = $wpdb->query( $buffer ); // phpcs:ignore WordPress.DB - if ( false === $result ) { - $stats['errors']++; - } else { - $stats['executed']++; - } + emcp_connector_run_dump_statement( $buffer, $stats ); } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions return $stats; } +/** + * Execute one dump statement through the live connection, updating stats. + * + * @param string $sql Trimmed statement (directives already filtered). + * @param array $stats In/out counters. + */ +function emcp_connector_run_dump_statement( string $sql, array &$stats ): void { + global $wpdb; + $result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB -- dump statement, connector restore. + if ( false === $result ) { + $stats['errors']++; + if ( count( $stats['error_details'] ) < 10 ) { + $stats['error_details'][] = $wpdb->last_error; + } + } else { + $stats['executed']++; + } +} + /** Byte-accurate replacement over a raw string (plain + escaped JSON URL pairs). */ function emcp_connector_pair_replace( string $value, string $search, string $replace ): string { if ( '' === $search || '' === $value || $search === $replace ) { @@ -375,11 +397,41 @@ function emcp_connector_replace_value( $value, string $search, string $replace ) return $value; } -/** Search-replace URL across the destination's data tables. */ +/** + * Search-replace URL across the destination's data tables. + * + * @param string $old_url Source URL. + * @param string $new_url Destination URL. + * @return int Rows rewritten. + */ function emcp_connector_search_replace( string $old_url, string $new_url ): int { if ( '' === $old_url || $old_url === $new_url ) { return 0; } + global $wpdb; + $total = 0; + foreach ( emcp_connector_data_tables() as $table ) { + $pks = $wpdb->get_results( "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY'" ); // phpcs:ignore WordPress.DB + if ( count( $pks ) !== 1 ) { + continue; // Composite/no-PK tables are skipped — rows can't be addressed safely. + } + $pk = (string) $pks[0]->Column_name; + $text_cols = emcp_connector_text_columns( $table ); + if ( empty( $text_cols ) ) { + continue; + } + $total += emcp_connector_rewrite_rows( $table, $pk, $text_cols, $old_url, $new_url ); + } + return $total; +} + +/** + * Text-bearing tables the search-replace walks (all prefixed tables minus + * operational ones whose values are paths/session data). + * + * @return string[] Table names. + */ +function emcp_connector_data_tables(): array { global $wpdb; $exclude = array( $wpdb->prefix . 'emcp_connector_transfers', @@ -400,55 +452,70 @@ function emcp_connector_search_replace( string $old_url, string $new_url ): int $tables[] = $table; } } + return $tables; +} - $total = 0; - foreach ( $tables as $table ) { - $pks = $wpdb->get_results( "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY'" ); // phpcs:ignore WordPress.DB - if ( count( $pks ) !== 1 ) { - continue; - } - $pk = (string) $pks[0]->Column_name; - - $text_cols = array(); - foreach ( (array) $wpdb->get_results( "DESCRIBE `{$table}`" ) as $col ) { // phpcs:ignore WordPress.DB - $type = strtolower( (string) $col->Type ); - if ( false !== strpos( $type, 'char' ) || false !== strpos( $type, 'text' ) || false !== strpos( $type, 'json' ) ) { - $text_cols[] = $col->Field; - } - } - if ( empty( $text_cols ) ) { - continue; - } - $like = '%' . $wpdb->esc_like( $old_url ) . '%'; - $where = array(); - $args = array(); - foreach ( $text_cols as $col ) { - $where[] = "`{$col}` LIKE %s"; - $args[] = $like; - } - $sql = "SELECT `{$pk}`, `" . implode( '`, `', $text_cols ) . "` FROM `{$table}` WHERE (" . implode( ' OR ', $where ) . ') LIMIT 5000'; - $rows = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A ); // phpcs:ignore WordPress.DB - if ( null === $rows ) { - continue; - } - foreach ( $rows as $row ) { - $changes = array(); - foreach ( $text_cols as $col ) { - if ( ! isset( $row[ $col ] ) || ! is_string( $row[ $col ] ) ) { - continue; - } - $new = emcp_connector_replace_value( $row[ $col ], $old_url, $new_url ); - if ( $new !== $row[ $col ] ) { - $changes[ $col ] = $new; - } - } - if ( $changes ) { - $wpdb->update( $table, $changes, array( $pk => $row[ $pk ] ) ); // phpcs:ignore WordPress.DB - $total++; - } +/** + * char/text/json columns of a table (the only ones worth rewriting). + * + * @param string $table Table name. + * @return string[] Column names. + */ +function emcp_connector_text_columns( string $table ): array { + global $wpdb; + $text_cols = array(); + foreach ( (array) $wpdb->get_results( "DESCRIBE `{$table}`" ) as $col ) { // phpcs:ignore WordPress.DB -- identifier quoted. + $type = strtolower( (string) $col->Type ); + if ( false !== strpos( $type, 'char' ) || false !== strpos( $type, 'text' ) || false !== strpos( $type, 'json' ) ) { + $text_cols[] = $col->Field; } } - return $total; + return $text_cols; +} + +/** + * Fetch rows of one table containing $old_url and rewrite the changed columns. + * + * @param string $table Table name. + * @param string $pk Single-column primary key. + * @param string[] $text_cols Text columns to scan/rewrite. + * @param string $old_url Source URL. + * @param string $new_url Destination URL. + * @return int Rows updated. + */ +function emcp_connector_rewrite_rows( string $table, string $pk, array $text_cols, string $old_url, string $new_url ): int { + global $wpdb; + $like = '%' . $wpdb->esc_like( $old_url ) . '%'; + $where = array(); + $args = array(); + foreach ( $text_cols as $col ) { + $where[] = "`{$col}` LIKE %s"; + $args[] = $like; + } + $sql = "SELECT `{$pk}`, `" . implode( '`, `', $text_cols ) . "` FROM `{$table}` WHERE (" . implode( ' OR ', $where ) . ') LIMIT 5000'; + $rows = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A ); // phpcs:ignore WordPress.DB -- prepared; identifier-quoted. + if ( null === $rows ) { + return 0; + } + + $updated = 0; + foreach ( $rows as $row ) { + $changes = array(); + foreach ( $text_cols as $col ) { + if ( ! isset( $row[ $col ] ) || ! is_string( $row[ $col ] ) ) { + continue; + } + $new = emcp_connector_replace_value( $row[ $col ], $old_url, $new_url ); + if ( $new !== $row[ $col ] ) { + $changes[ $col ] = $new; + } + } + if ( $changes ) { + $wpdb->update( $table, $changes, array( $pk => $row[ $pk ] ) ); // phpcs:ignore WordPress.DB -- keyed by PK. + $updated++; + } + } + return $updated; } /** Place files/ entries under wp-content (traversal-guarded). */ @@ -527,27 +594,8 @@ function emcp_connector_restore_archive( string $archive ): array { $work = emcp_connector_base_dir() . '/restore-' . wp_generate_password( 8, false ); wp_mkdir_p( $work ); - // 1) DB. - $src = $zip->getStream( 'database.sql' ); - if ( false !== $src ) { - $db_file = $work . '/database.sql'; - $dst = fopen( $db_file, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions - if ( false !== $dst ) { - stream_copy_to_stream( $src, $dst ); - fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions - fclose( $dst ); // phpcs:ignore WordPress.WP.AlternativeFunctions - $expected = isset( $manifest['database_sha256'] ) ? $manifest['database_sha256'] : ''; - $actual = hash_file( 'sha256', $db_file ); - if ( '' !== $expected && hash_equals( $expected, $actual ) ) { - $stats['db'] = emcp_connector_import( $db_file ); - if ( ! empty( $stats['db']['error_details'] ) ) { - $stats['errors'] = array_merge( $stats['errors'], $stats['db']['error_details'] ); - } - } else { - $stats['errors'][] = 'hash_mismatch'; - } - } - } + // 1) DB (verify hash, then import). + emcp_connector_restore_db( $zip, $manifest, $work, $stats ); // 2) URL search-replace (source -> this destination). if ( empty( $stats['errors'] ) && ! empty( $manifest['site_url'] ) ) { @@ -564,3 +612,40 @@ function emcp_connector_restore_archive( string $archive ): array { return $stats; } + +/** + * Stream database.sql out of the archive, verify its manifest hash, import it. + * + * @param \ZipArchive $zip Open archive. + * @param array $manifest Parsed manifest. + * @param string $work Work directory. + * @param array $stats In/out restore stats. + */ +function emcp_connector_restore_db( ZipArchive $zip, array $manifest, string $work, array &$stats ): void { + $src = $zip->getStream( 'database.sql' ); + if ( false === $src ) { + $stats['errors'][] = 'db_stream_failed'; + return; + } + $db_file = $work . '/database.sql'; + $dst = fopen( $db_file, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( false === $dst ) { + fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions + $stats['errors'][] = 'db_stream_failed'; + return; + } + stream_copy_to_stream( $src, $dst ); + fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions + fclose( $dst ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + $expected = isset( $manifest['database_sha256'] ) ? $manifest['database_sha256'] : ''; + $actual = hash_file( 'sha256', $db_file ); + if ( '' !== $expected && hash_equals( $expected, $actual ) ) { + $stats['db'] = emcp_connector_import( $db_file ); + if ( ! empty( $stats['db']['error_details'] ) ) { + $stats['errors'] = array_merge( $stats['errors'], $stats['db']['error_details'] ); + } + } else { + $stats['errors'][] = 'hash_mismatch'; + } +} diff --git a/includes/abilities/class-migrate-abilities.php b/includes/abilities/class-migrate-abilities.php index 74b0cf4..b80f085 100644 --- a/includes/abilities/class-migrate-abilities.php +++ b/includes/abilities/class-migrate-abilities.php @@ -193,62 +193,127 @@ class EMCP_Tools_Migrate_Abilities { $endpoint = untrailingslashit( $remote ) . '/wp-json/emcp-connector/v1'; $transfer_id = 'emcp-' . wp_generate_password( 12, false ); - $chunk_size = 2 * 1024 * 1024; // 2 MB. + + $pushed = self::push_archive_packets( $path, $endpoint, $secret, $transfer_id ); + if ( is_wp_error( $pushed ) ) { + return $pushed; + } + $job_id = self::push_finalize( $path, $endpoint, $secret, $transfer_id ); + if ( is_wp_error( $job_id ) ) { + return $job_id; + } + + // Poll the job until it settles (the connector restores inline, so this + // normally returns on the first poll; bounded at ~60 s). + $job = self::poll_job( $endpoint, $secret, $job_id ); + + $state = ( $job && isset( $job['state'] ) ) ? $job['state'] : 'unknown'; + if ( 'error' === $state ) { + return new WP_Error( 'restore_failed', __( 'Destination reported an error during restore.', 'emcp-tools' ), array( 'job' => $job ) ); + } + + return array( + 'success' => true, + 'message' => __( 'Archive pushed and restored on the destination.', 'emcp-tools' ), + 'remote_url' => untrailingslashit( $remote ), + 'job_id' => $job_id, + 'state' => $state, + 'archive' => basename( $path ), + 'job' => $job, + ); + } + + /** + * Stream an archive to the connector as signed 2 MB packets. + * + * @param string $path Archive path. + * @param string $endpoint Connector REST base. + * @param string $secret Shared secret. + * @param string $transfer_id Transfer id for this push. + * @return true|\WP_Error + */ + private function push_archive_packets( string $path, string $endpoint, string $secret, string $transfer_id ) { + $chunk_size = 2 * 1024 * 1024; // 2 MB. + $total = (int) ceil( filesize( $path ) / $chunk_size ); $handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions if ( false === $handle ) { return new WP_Error( 'read_failed', __( 'Could not read the archive for upload.', 'emcp-tools' ) ); } - $total_chunks = (int) ceil( filesize( $path ) / $chunk_size ); $index = 0; while ( ! feof( $handle ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions $data = fread( $handle, $chunk_size ); // phpcs:ignore WordPress.WP.AlternativeFunctions - if ( false === $data || '' === $data ) { + if ( '' === $data || false === $data ) { if ( feof( $handle ) ) { break; } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions return new WP_Error( 'read_failed', __( 'Failed while reading the archive.', 'emcp-tools' ) ); } - $data_b64 = base64_encode( $data ); - $chunk_sha = hash( 'sha256', $data ); - $canonical = 'packet|' . $chunk_sha . '|' . $transfer_id . '|' . $index; - $response = wp_remote_post( + $chunk_sha = hash( 'sha256', $data ); + $canonical = 'packet|' . $chunk_sha . '|' . $transfer_id . '|' . $index; + $response = wp_remote_post( $endpoint . '/packet', array( 'timeout' => 120, 'headers' => array( - 'Content-Type' => 'application/json', - 'X-EMCP-Signature' => hash_hmac( 'sha256', $canonical, $secret ), + 'Content-Type' => 'application/json', + 'X-EMCP-Signature' => hash_hmac( 'sha256', $canonical, $secret ), ), 'body' => wp_json_encode( array( 'transfer_id' => $transfer_id, 'index' => $index, - 'total' => $total_chunks, - 'data_b64' => $data_b64, + 'total' => $total, + 'data_b64' => base64_encode( $data ), 'sha256' => $chunk_sha, ) ), ) ); - if ( is_wp_error( $response ) ) { + $error = $this->packet_error( $response, $index ); + if ( is_wp_error( $error ) ) { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions - return new WP_Error( - 'packet_failed', - sprintf( /* translators: 1: error message. */ __( 'Upload failed at packet %1$d: %2$s', 'emcp-tools' ), $index, $response->get_error_message() ), - array( 'resume_offset' => $index ) - ); - } - $code = (int) wp_remote_retrieve_response_code( $response ); - $rbody = json_decode( (string) wp_remote_retrieve_body( $response ), true ); - if ( $code < 200 || $code >= 300 || ! is_array( $rbody ) || ! empty( $rbody['code'] ) ) { - fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions - $msg = is_array( $rbody ) && isset( $rbody['message'] ) ? (string) $rbody['message'] : sprintf( 'HTTP %d', $code ); - return new WP_Error( 'packet_rejected', sprintf( /* translators: 1: packet index, 2: message. */ __( 'Destination rejected packet %1$d: %2$s', 'emcp-tools' ), $index, $msg ), array( 'resume_offset' => $index ) ); + return $error; } $index++; } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions + return true; + } + /** + * Normalize a packet upload response into a WP_Error, or null when accepted. + * + * @param mixed $response wp_remote_post result. + * @param int $index Packet index (for resume reporting). + * @return \WP_Error|null + */ + private function packet_error( $response, int $index ) { + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'packet_failed', + sprintf( /* translators: 1: error message. */ __( 'Upload failed at packet %1$d: %2$s', 'emcp-tools' ), $index, $response->get_error_message() ), + array( 'resume_offset' => $index ) + ); + } + $code = (int) wp_remote_retrieve_response_code( $response ); + $rbody = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + if ( $code < 200 || $code >= 300 || ! is_array( $rbody ) || ! empty( $rbody['code'] ) ) { + $msg = is_array( $rbody ) && isset( $rbody['message'] ) ? (string) $rbody['message'] : sprintf( 'HTTP %d', $code ); + return new WP_Error( 'packet_rejected', sprintf( /* translators: 1: packet index, 2: message. */ __( 'Destination rejected packet %1$d: %2$s', 'emcp-tools' ), $index, $msg ), array( 'resume_offset' => $index ) ); + } + return null; + } + + /** + * Ask the connector to assemble + verify the transfer and start the restore. + * + * @param string $path Archive path (for its sha256). + * @param string $endpoint Connector REST base. + * @param string $secret Shared secret. + * @param string $transfer_id Transfer id just pushed. + * @return string|\WP_Error The destination job id. + */ + private function push_finalize( string $path, string $endpoint, string $secret, string $transfer_id ) { $whole_sha = hash_file( 'sha256', $path ); $canonical = 'finalize|' . $whole_sha . '|' . $transfer_id; $response = wp_remote_post( @@ -271,21 +336,29 @@ class EMCP_Tools_Migrate_Abilities { $msg = is_array( $rbody ) && isset( $rbody['message'] ) ? (string) $rbody['message'] : sprintf( 'HTTP %d', $code ); return new WP_Error( 'finalize_rejected', sprintf( /* translators: %s: message. */ __( 'Destination restore did not start: %s', 'emcp-tools' ), $msg ) ); } - $job_id = (string) $rbody['job_id']; + return (string) $rbody['job_id']; + } - // Poll the job until it settles (the connector restores inline, so this - // normally returns on the first poll; bounded at ~60 s). + /** + * Poll a connector job until it settles. + * + * @param string $endpoint Connector REST base. + * @param string $secret Shared secret. + * @param string $job_id Destination job id. + * @return array|null Job payload (state done/error/…), null when still pending after the bound. + */ + private function poll_job( string $endpoint, string $secret, string $job_id ) { $job = null; for ( $i = 0; $i < 30; $i++ ) { - $job_response = wp_remote_get( + $response = wp_remote_get( $endpoint . '/job/' . rawurlencode( $job_id ), array( 'timeout' => 30, 'headers' => array( 'X-EMCP-Signature' => hash_hmac( 'sha256', 'job|' . $job_id, $secret ) ), ) ); - if ( ! is_wp_error( $job_response ) ) { - $body = json_decode( (string) wp_remote_retrieve_body( $job_response ), true ); + if ( ! is_wp_error( $response ) ) { + $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( is_array( $body ) && isset( $body['state'] ) ) { $job = $body; if ( 'done' === $body['state'] || 'error' === $body['state'] ) { @@ -295,21 +368,7 @@ class EMCP_Tools_Migrate_Abilities { } sleep( 2 ); // phpcs:ignore WordPress.PHP -- poll cadence for a remote batch job. } - - $state = ( $job && isset( $job['state'] ) ) ? $job['state'] : 'unknown'; - if ( 'error' === $state ) { - return new WP_Error( 'restore_failed', __( 'Destination reported an error during restore.', 'emcp-tools' ), array( 'job' => $job ) ); - } - - return array( - 'success' => true, - 'message' => __( 'Archive pushed and restored on the destination.', 'emcp-tools' ), - 'remote_url' => untrailingslashit( $remote ), - 'job_id' => $job_id, - 'state' => $state, - 'archive' => basename( $path ), - 'job' => $job, - ); + return $job; } public function execute_sync( array $args ) { diff --git a/includes/admin/views/page-migrate.php b/includes/admin/views/page-migrate.php index 1d4f528..aee78dc 100644 --- a/includes/admin/views/page-migrate.php +++ b/includes/admin/views/page-migrate.php @@ -39,7 +39,7 @@ if ( $emcp_notice ) {