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 ) {
- +

@@ -56,7 +56,7 @@ if ( $emcp_notice ) { - +


@@ -102,7 +102,7 @@ if ( $emcp_notice ) { - +

- +
- +
diff --git a/includes/migrate/class-db-exporter.php b/includes/migrate/class-db-exporter.php index 7acf3ce..0ad4347 100644 --- a/includes/migrate/class-db-exporter.php +++ b/includes/migrate/class-db-exporter.php @@ -80,7 +80,6 @@ class EMCP_Tools_DB_Exporter { return 0; } $col_list = '`' . implode( '`, `', $columns ) . '`'; - $col_ph = '(' . implode( ', ', array_fill( 0, count( $columns ), '%s' ) ) . ')'; $offset = 0; $rows = 0; diff --git a/includes/migrate/class-db-importer.php b/includes/migrate/class-db-importer.php index b2870a1..92dd631 100644 --- a/includes/migrate/class-db-importer.php +++ b/includes/migrate/class-db-importer.php @@ -60,36 +60,41 @@ class EMCP_Tools_DB_Importer { $stats['skipped']++; continue; } - $result = $wpdb->query( $trimmed ); // phpcs:ignore WordPress.DB -- validated dump statement; admin-authorized restore. - if ( false === $result ) { - $stats['errors']++; - if ( count( $stats['error_details'] ) < 20 ) { - $stats['error_details'][] = array( - 'error' => $wpdb->last_error, - 'stmt' => self::truncate( $trimmed, 200 ), - ); - } - } else { - $stats['executed']++; - } + self::run_statement( $trimmed, $stats ); } } // Trailing statement with no final ';' — execute it if anything remains. - $buffer = self::strip_leading_comments( $buffer ); - if ( '' !== trim( $buffer ) && ! self::is_skip_directive( trim( $buffer ) ) ) { + $trimmed = self::strip_leading_comments( $buffer ); + if ( '' !== $trimmed && ! self::is_skip_directive( $trimmed ) ) { $stats['statements']++; - $trimmed = trim( $buffer ); - $result = $wpdb->query( $trimmed ); // phpcs:ignore WordPress.DB -- validated dump statement. - if ( false === $result ) { - $stats['errors']++; - } else { - $stats['executed']++; - } + self::run_statement( $trimmed, $stats ); } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions return $stats; } + /** + * Run one dump statement through the live connection, updating stats. + * + * @param string $sql Trimmed statement (directives already filtered). + * @param array $stats In/out counters. + */ + private static function run_statement( string $sql, array &$stats ): void { + global $wpdb; + $result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB -- validated dump statement; admin-authorized restore. + if ( false === $result ) { + $stats['errors']++; + if ( count( $stats['error_details'] ) < 20 ) { + $stats['error_details'][] = array( + 'error' => $wpdb->last_error, + 'stmt' => self::truncate( $sql, 200 ), + ); + } + } else { + $stats['executed']++; + } + } + /** * Drop full-line `--` comments that lead a statement so a directive after a * dump banner (which begins with `--`) is still recognised as such. @@ -108,7 +113,7 @@ class EMCP_Tools_DB_Importer { * @param string $buffer In/out accumulator (reference). * @return string|null The statement including its trailing ';', or null when no complete statement exists yet. */ - private static function extract_statement( string &$buffer ) { + private static function 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 = false; // Single-quoted string. diff --git a/includes/migrate/class-packager.php b/includes/migrate/class-packager.php index 4d4b1a9..71dc791 100644 --- a/includes/migrate/class-packager.php +++ b/includes/migrate/class-packager.php @@ -20,6 +20,9 @@ class EMCP_Tools_Packager { const FORMAT_VERSION = 2; + /** Portable archive extension. */ + const EXT = '.emcp'; + /** * Absolute directory .emcp archives live in (uploads/emcp-backups). * @@ -40,7 +43,7 @@ class EMCP_Tools_Packager { */ public static function archive_path( string $name ): string { $name = sanitize_file_name( $name ); - if ( '' === $name || '.emcp' !== substr( $name, -5 ) || false !== strpos( $name, '/' ) || false !== strpos( $name, '..' ) ) { + if ( '' === $name || self::EXT !== substr( $name, -5 ) || false !== strpos( $name, '/' ) || false !== strpos( $name, '..' ) ) { return ''; } $path = wp_normalize_path( self::backup_dir() . '/' . $name ); @@ -70,8 +73,8 @@ class EMCP_Tools_Packager { if ( '' === $name ) { $name = 'backup-' . gmdate( 'Y-m-d-His' ); } - if ( '.emcp' !== substr( $name, -5 ) ) { - $name .= '.emcp'; + if ( self::EXT !== substr( $name, -5 ) ) { + $name .= self::EXT; } $zip_file = $dir . '/' . $name; $tmp_sql = $dir . '/.tmp-' . wp_generate_password( 8, false ) . '.sql'; @@ -167,7 +170,7 @@ class EMCP_Tools_Packager { */ public static function list_archives(): array { $dir = self::backup_dir(); - $files = glob( $dir . '/*.emcp' ) ?: array(); // phpcs:ignore WordPress.WP.AlternativeFunctions + $files = glob( $dir . '/*' . self::EXT ) ?: array(); // phpcs:ignore WordPress.WP.AlternativeFunctions $out = array(); foreach ( $files as $f ) { $size = filesize( $f ); @@ -219,34 +222,16 @@ class EMCP_Tools_Packager { continue; } $count = 0; - $it = new RecursiveIteratorIterator( - new RecursiveCallbackFilterIterator( - new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), - static function ( $current ) use ( $skip_dirs ): bool { - if ( $current->isDir() ) { - $path = wp_normalize_path( $current->getPathname() ); - foreach ( $skip_dirs as $skip ) { - if ( '' !== $skip && ( $path === $skip || 0 === strpos( $path, $skip . '/' ) ) ) { - return false; - } - } - return '.' !== $current->getFilename(); - } - return true; - } - ), - RecursiveIteratorIterator::LEAVES_ONLY - ); + $it = self::file_iterator( $root, $skip_dirs ); foreach ( $it as $file ) { if ( ! $file->isFile() ) { continue; } - $local = wp_normalize_path( $file->getPathname() ); - if ( 0 !== strpos( $local, $content_dir . '/' ) ) { + $zip_name = self::zip_relative_name( wp_normalize_path( $file->getPathname() ), $content_dir ); + if ( '' === $zip_name ) { continue; // Outside wp-content — never bundled. } - $zip_name = 'files/' . ltrim( substr( $local, strlen( $content_dir ) ), '/' ); - $zip->addFile( $local, $zip_name ); + $zip->addFile( $file->getPathname(), $zip_name ); $count++; } if ( $count > 0 ) { @@ -256,6 +241,60 @@ class EMCP_Tools_Packager { return $added; } + /** + * Recursive file iterator over a root, pruning excluded directories. + * + * @param string $root Root directory (normalized). + * @param string[] $skip_dirs Directories never descended into. + * @return \RecursiveIteratorIterator + */ + private static function file_iterator( string $root, array $skip_dirs ) { + return new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), + static function ( $current ) use ( $skip_dirs ): bool { + if ( $current->isDir() ) { + return ! self::dir_is_skipped( $current->getPathname(), $skip_dirs ); + } + return true; + } + ), + RecursiveIteratorIterator::LEAVES_ONLY + ); + } + + /** + * Whether a directory is inside an excluded path. + * + * @param string $path Normalized path. + * @param string[] $skip_dirs Excluded roots. + * @return bool + */ + private static function dir_is_skipped( string $path, array $skip_dirs ): bool { + $path = wp_normalize_path( $path ); + foreach ( $skip_dirs as $skip ) { + if ( '' !== $skip && ( $path === $skip || 0 === strpos( $path, $skip . '/' ) ) ) { + return true; + } + } + return false; + } + + /** + * Archive name for a file: files/, or '' when the file + * is outside wp-content. + * + * @param string $local Normalized absolute path. + * @param string $content_dir Normalized wp-content path. + * @return string + */ + private static function zip_relative_name( string $local, string $content_dir ): string { + if ( 0 !== strpos( $local, $content_dir . '/' ) ) { + return ''; + } + return 'files/' . ltrim( substr( $local, strlen( $content_dir ) ), '/' ); + } + /** * Uploads base dir (normalized). * diff --git a/includes/migrate/class-restore-engine.php b/includes/migrate/class-restore-engine.php index 234ff7a..fc2ef95 100644 --- a/includes/migrate/class-restore-engine.php +++ b/includes/migrate/class-restore-engine.php @@ -30,215 +30,240 @@ defined( 'ABSPATH' ) || exit; */ class EMCP_Tools_Restore_Engine { - const LOG_OPTION = 'emcp_migrate_restore_log'; - const LOG_CAP = 20; + const LOG_OPTION = 'emcp_migrate_restore_log'; + const LOG_CAP = 20; - /** - * Restore one .emcp archive. - * - * @param string $filename Archive filename (basename enforced by Packager). - * @param array $opts { - * @type bool $import_db Import database.sql (default true). - * @type bool $place_files Place files/ entries (default true). - * @type bool $search_replace Apply URL search-replace (default true; only when URLs differ). - * @type string $new_url Destination URL (default home_url()). - * } - * @return array|\WP_Error Stats, or a WP_Error when verification fails. - */ - public static function restore( string $filename, array $opts = array() ) { - if ( ! class_exists( 'ZipArchive' ) ) { - return new WP_Error( 'zip_missing', __( 'The PHP ZipArchive extension is required to restore an archive.', 'emcp-tools' ) ); - } - if ( ! class_exists( 'EMCP_Tools_Packager' ) || ! class_exists( 'EMCP_Tools_DB_Importer' ) ) { - return new WP_Error( 'engine_unavailable', __( 'The restore engine classes are not available.', 'emcp-tools' ) ); - } + /** + * Restore one .emcp archive. + * + * @param string $filename Archive filename (basename enforced by Packager). + * @param array $opts { + * @type bool $import_db Import database.sql (default true). + * @type bool $place_files Place files/ entries (default true). + * @type bool $search_replace Apply URL search-replace (default true; only when URLs differ). + * @type string $new_url Destination URL (default home_url()). + * } + * @return array|\WP_Error Stats, or a WP_Error when verification fails. + */ + public static function restore( string $filename, array $opts = array() ) { + if ( ! class_exists( 'ZipArchive' ) ) { + return new WP_Error( 'zip_missing', __( 'The PHP ZipArchive extension is required to restore an archive.', 'emcp-tools' ) ); + } + if ( ! class_exists( 'EMCP_Tools_Packager' ) || ! class_exists( 'EMCP_Tools_DB_Importer' ) ) { + return new WP_Error( 'engine_unavailable', __( 'The restore engine classes are not available.', 'emcp-tools' ) ); + } - $import_db = ! array_key_exists( 'import_db', $opts ) || ! empty( $opts['import_db'] ); - $place_files = ! array_key_exists( 'place_files', $opts ) || ! empty( $opts['place_files'] ); - $sr_on = ! array_key_exists( 'search_replace', $opts ) || ! empty( $opts['search_replace'] ); + $import_db = ! array_key_exists( 'import_db', $opts ) || ! empty( $opts['import_db'] ); + $place_files = ! array_key_exists( 'place_files', $opts ) || ! empty( $opts['place_files'] ); - $path = EMCP_Tools_Packager::archive_path( $filename ); - if ( '' === $path ) { - return new WP_Error( 'archive_missing', __( 'Archive not found.', 'emcp-tools' ) ); - } - $manifest = EMCP_Tools_Packager::read_manifest( $filename ); - if ( null === $manifest || empty( $manifest['emcp'] ) ) { - return new WP_Error( 'invalid_archive', __( 'Not a valid .emcp archive (manifest missing or unreadable).', 'emcp-tools' ) ); - } - if ( isset( $manifest['format_version'] ) && (int) $manifest['format_version'] > EMCP_Tools_Packager::FORMAT_VERSION ) { - return new WP_Error( 'newer_format', __( 'This archive uses a newer format than this plugin can restore.', 'emcp-tools' ) ); - } + $path = EMCP_Tools_Packager::archive_path( $filename ); + if ( '' === $path ) { + return new WP_Error( 'archive_missing', __( 'Archive not found.', 'emcp-tools' ) ); + } + $manifest = EMCP_Tools_Packager::read_manifest( $filename ); + if ( null === $manifest || empty( $manifest['emcp'] ) ) { + return new WP_Error( 'invalid_archive', __( 'Not a valid .emcp archive (manifest missing or unreadable).', 'emcp-tools' ) ); + } + if ( isset( $manifest['format_version'] ) && (int) $manifest['format_version'] > EMCP_Tools_Packager::FORMAT_VERSION ) { + return new WP_Error( 'newer_format', __( 'This archive uses a newer format than this plugin can restore.', 'emcp-tools' ) ); + } - if ( function_exists( 'set_time_limit' ) ) { - set_time_limit( 0 ); // phpcs:ignore WordPress.PHP -- restore is an admin-authorized batch op. - } + if ( function_exists( 'set_time_limit' ) ) { + set_time_limit( 0 ); // phpcs:ignore WordPress.PHP -- restore is an admin-authorized batch op. + } - $stats = array( - 'action' => 'restore', - 'filename' => $filename, - 'files_placed' => 0, - 'search_replace' => null, - 'errors' => array(), - ); + $stats = array( + 'action' => 'restore', + 'filename' => $filename, + 'files_placed' => 0, + 'search_replace' => null, + 'errors' => array(), + ); - $workdir = EMCP_Tools_Packager::backup_dir() . '/.restore-' . wp_generate_password( 8, false ); - wp_mkdir_p( $workdir ); - $zip = new ZipArchive(); - if ( true !== $zip->open( $path ) ) { - $stats['errors'][] = 'zip_open_failed'; - self::append_log( $filename, $stats ); - return $stats; - } + $workdir = EMCP_Tools_Packager::backup_dir() . '/.restore-' . wp_generate_password( 8, false ); + wp_mkdir_p( $workdir ); + $zip = new ZipArchive(); + if ( true !== $zip->open( $path ) ) { + $stats['errors'][] = 'zip_open_failed'; + self::append_log( $stats ); + return $stats; + } - try { - if ( $import_db && $zip->locateName( 'database.sql' ) !== false ) { - $db_file = $workdir . '/database.sql'; - $src = $zip->getStream( 'database.sql' ); - $dst = fopen( $db_file, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions - if ( false === $src || false === $dst ) { - $stats['errors'][] = 'db_stream_failed'; - } else { - stream_copy_to_stream( $src, $dst ); - fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions - fclose( $dst ); // phpcs:ignore WordPress.WP.AlternativeFunctions - } + try { + if ( $import_db && $zip->locateName( 'database.sql' ) !== false ) { + $abort = self::restore_database( $zip, $workdir, $manifest, $opts, $stats ); + if ( $abort ) { + self::append_log( $stats ); + $zip->close(); + self::cleanup( $workdir ); + return new WP_Error( 'hash_mismatch', __( 'Archive failed its integrity check (database.sql does not match the manifest hash). Nothing was imported.', 'emcp-tools' ) ); + } + } - if ( is_file( $db_file ) ) { - $expected = isset( $manifest['database_sha256'] ) ? $manifest['database_sha256'] : ''; - $actual = hash_file( 'sha256', $db_file ); - if ( '' !== $expected && ! hash_equals( $expected, $actual ) ) { - self::append_log( $filename, array( 'action' => 'restore', 'filename' => $filename, 'errors' => array( 'hash_mismatch' ), 'files_placed' => 0, 'search_replace' => null ) ); - $zip->close(); - self::cleanup( $workdir ); - return new WP_Error( 'hash_mismatch', __( 'Archive failed its integrity check (database.sql does not match the manifest hash). Nothing was imported.', 'emcp-tools' ) ); - } + // Place files/ entries, validated against path traversal. + if ( $place_files ) { + $stats['files_placed'] = self::place_files( $zip ); + } + } finally { + $zip->close(); + self::cleanup( $workdir ); + } - $import = EMCP_Tools_DB_Importer::import_from_file( $db_file ); - $stats['db'] = ( false === $import ) ? array( 'statements' => 0, 'executed' => 0, 'skipped' => 0, 'errors' => 1 ) : $import; - if ( ! empty( $stats['db']['error_details'] ) ) { - $stats['errors'] = array_merge( $stats['errors'], array_slice( $stats['db']['error_details'], 0, 5 ) ); - } + self::append_log( $stats ); + return $stats; + } - // URL rewrite (source → this site) when the URLs differ. - if ( $sr_on && class_exists( 'EMCP_Tools_Serialized_Search_Replace' ) ) { - $old_url = isset( $manifest['site_url'] ) ? (string) $manifest['site_url'] : ''; - $new_url = (string) ( $opts['new_url'] ?? home_url() ); - if ( '' !== $old_url && $old_url !== $new_url ) { - global $wpdb; - $engine = 'EMCP_Tools_Serialized_Search_Replace'; - $total = 0; - foreach ( $engine::data_tables( $wpdb ) as $table ) { - $r = $engine::walk_table( $wpdb, $table, $old_url, $new_url ); - $total += (int) $r['affected']; - } - $stats['search_replace'] = array( 'old' => $old_url, 'new' => $new_url, 'rows' => $total ); - } - } - } else { - $stats['errors'][] = 'db_missing'; - } - } + /** + * Stream database.sql out of the archive, verify its hash, import it, and + * rewrite the source URL for this site. + * + * @param \ZipArchive $zip Open archive. + * @param string $workdir Scratch directory. + * @param array $manifest Parsed manifest. + * @param array $opts Restore options (search_replace, new_url). + * @param array $stats In/out stats. + * @return bool True when the DB failed integrity and the whole restore must abort. + */ + private static function restore_database( ZipArchive $zip, string $workdir, array $manifest, array $opts, array &$stats ): bool { + $sr_on = ! array_key_exists( 'search_replace', $opts ) || ! empty( $opts['search_replace'] ); - // Place files/ entries, validated against path traversal. - if ( $place_files ) { - $stats['files_placed'] = self::place_files( $zip ); - } - } finally { - $zip->close(); - self::cleanup( $workdir ); - } + $db_file = $workdir . '/database.sql'; + $src = $zip->getStream( 'database.sql' ); + $dst = fopen( $db_file, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( false === $src || false === $dst ) { + if ( is_resource( $src ) ) { + fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + $stats['errors'][] = 'db_stream_failed'; + return false; + } + stream_copy_to_stream( $src, $dst ); + fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions + fclose( $dst ); // phpcs:ignore WordPress.WP.AlternativeFunctions - self::append_log( $filename, $stats ); - return $stats; - } + if ( ! is_file( $db_file ) ) { + $stats['errors'][] = 'db_missing'; + return false; + } - /** - * Extract files/ entries from the archive into wp-content, safely. - * - * @param \ZipArchive $zip Open archive. - * @return int Number of files placed. - */ - private static function place_files( ZipArchive $zip ): int { - $content_dir = wp_normalize_path( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ABSPATH . 'wp-content' ); - $backups_dir = wp_normalize_path( EMCP_Tools_Packager::backup_dir() ); - $placed = 0; + $expected = isset( $manifest['database_sha256'] ) ? $manifest['database_sha256'] : ''; + $actual = hash_file( 'sha256', $db_file ); + if ( '' !== $expected && ! hash_equals( $expected, $actual ) ) { + $stats['errors'][] = 'hash_mismatch'; + return true; // Nothing was imported — abort the whole restore. + } - for ( $i = 0; $i < $zip->numFiles; $i++ ) { - $name = (string) $zip->getNameIndex( $i ); - if ( 0 !== strpos( $name, 'files/' ) ) { - continue; - } - $rel = substr( $name, strlen( 'files/' ) ); - if ( '' === $rel || 0 === strpos( $rel, 'wp-config.php' ) || false !== strpos( $rel, '..' ) ) { - continue; // Never outside wp-content, never wp-config. - } - $dest = wp_normalize_path( $content_dir . '/' . $rel ); - if ( 0 !== strpos( $dest, $content_dir . '/' ) || 0 === strpos( $dest, $backups_dir . '/' ) || is_dir( $dest ) ) { - continue; - } - $dir = dirname( $dest ); - if ( ! is_dir( $dir ) ) { - wp_mkdir_p( $dir ); - } - $src = $zip->getStream( $name ); - $dst = @fopen( $dest, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors -- direct file placement; caller audits. - if ( false === $src || false === $dst ) { - if ( is_resource( $src ) ) { - fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions - } - continue; - } - stream_copy_to_stream( $src, $dst ); - fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions - fclose( $dst ); // phpcs:ignore WordPress.WP.AlternativeFunctions - $placed++; - } - return $placed; - } + $import = EMCP_Tools_DB_Importer::import_from_file( $db_file ); + $stats['db'] = ( false === $import ) ? array( 'statements' => 0, 'executed' => 0, 'skipped' => 0, 'errors' => 1 ) : $import; + if ( ! empty( $stats['db']['error_details'] ) ) { + $stats['errors'] = array_merge( $stats['errors'], array_slice( $stats['db']['error_details'], 0, 5 ) ); + } - /** - * Append a restore result to the capped log option. - * - * @param string $filename Archive name. - * @param array $entry Result entry. - */ - private static function append_log( string $filename, array $entry ): void { - $entry['time'] = gmdate( 'c' ); - $log = (array) get_option( self::LOG_OPTION, array() ); - array_unshift( $log, $entry ); - $log = array_slice( $log, 0, self::LOG_CAP ); - update_option( self::LOG_OPTION, $log, false ); - } + // URL rewrite (source → this site) when the URLs differ. + if ( $sr_on && class_exists( 'EMCP_Tools_Serialized_Search_Replace' ) ) { + $old_url = isset( $manifest['site_url'] ) ? (string) $manifest['site_url'] : ''; + $new_url = (string) ( $opts['new_url'] ?? home_url() ); + if ( '' !== $old_url && $old_url !== $new_url ) { + global $wpdb; + $engine = 'EMCP_Tools_Serialized_Search_Replace'; + $total = 0; + foreach ( $engine::data_tables( $wpdb ) as $table ) { + $r = $engine::walk_table( $wpdb, $table, $old_url, $new_url ); + $total += (int) $r['affected']; + } + $stats['search_replace'] = array( 'old' => $old_url, 'new' => $new_url, 'rows' => $total ); + } + } + return false; + } - /** - * Remove a temp work directory. - * - * @param string $dir Work directory. - */ - private static function cleanup( string $dir ): void { - if ( ! is_dir( $dir ) ) { - return; - } - $it = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), - RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ( $it as $f ) { - if ( $f->isDir() ) { - @rmdir( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors - } else { - @unlink( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors - } - } - @rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors - } + /** + * Extract files/ entries from the archive into wp-content, safely. + * + * @param \ZipArchive $zip Open archive. + * @return int Number of files placed. + */ + private static function place_files( ZipArchive $zip ): int { + $content_dir = wp_normalize_path( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ABSPATH . 'wp-content' ); + $backups_dir = wp_normalize_path( EMCP_Tools_Packager::backup_dir() ); + $placed = 0; - /** - * Read the capped restore log. - * - * @return array[] - */ - public static function get_log(): array { - return (array) get_option( self::LOG_OPTION, array() ); - } + for ( $i = 0; $i < $zip->numFiles; $i++ ) { + $name = (string) $zip->getNameIndex( $i ); + if ( 0 !== strpos( $name, 'files/' ) ) { + continue; + } + $rel = substr( $name, strlen( 'files/' ) ); + if ( '' === $rel || 0 === strpos( $rel, 'wp-config.php' ) || false !== strpos( $rel, '..' ) ) { + continue; // Never outside wp-content, never wp-config. + } + $dest = wp_normalize_path( $content_dir . '/' . $rel ); + if ( 0 !== strpos( $dest, $content_dir . '/' ) || 0 === strpos( $dest, $backups_dir . '/' ) || is_dir( $dest ) ) { + continue; + } + $dir = dirname( $dest ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + $src = $zip->getStream( $name ); + $dst = @fopen( $dest, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors -- direct file placement; caller audits. + if ( false === $src || false === $dst ) { + if ( is_resource( $src ) ) { + fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + continue; + } + stream_copy_to_stream( $src, $dst ); + fclose( $src ); // phpcs:ignore WordPress.WP.AlternativeFunctions + fclose( $dst ); // phpcs:ignore WordPress.WP.AlternativeFunctions + $placed++; + } + return $placed; + } + + /** + * Append a restore result to the capped log option. + * + * @param string $filename Archive name. + * @param array $entry Result entry. + */ + private static function append_log( array $entry ): void { + $entry['time'] = gmdate( 'c' ); + $log = (array) get_option( self::LOG_OPTION, array() ); + array_unshift( $log, $entry ); + $log = array_slice( $log, 0, self::LOG_CAP ); + update_option( self::LOG_OPTION, $log, false ); + } + + /** + * Remove a temp work directory. + * + * @param string $dir Work directory. + */ + private static function cleanup( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $it as $f ) { + if ( $f->isDir() ) { + @rmdir( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors + } else { + @unlink( $f->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors + } + } + @rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors + } + + /** + * Read the capped restore log. + * + * @return array[] + */ + public static function get_log(): array { + return (array) get_option( self::LOG_OPTION, array() ); + } } diff --git a/includes/migrate/class-serialized-search-replace.php b/includes/migrate/class-serialized-search-replace.php index 772f9ed..31b0a44 100644 --- a/includes/migrate/class-serialized-search-replace.php +++ b/includes/migrate/class-serialized-search-replace.php @@ -31,261 +31,261 @@ defined( 'ABSPATH' ) || exit; */ class EMCP_Tools_Serialized_Search_Replace { - /** - * Replace an escaped (`\/`) plus an unescaped URL occurrence in a raw - * string value (JSON or plain text). No length prefixes to fix in JSON, so - * a raw str_replace over both variants is byte-safe here. - * - * The escaped variant is replaced first so the unescaped pass never - * re-touches the slashes the escaped pass just produced. - * - * @param string $value Raw value. - * @param string $search Old URL (unescaped). - * @param string $replace New URL (unescaped). - * @return string - */ - public static function pair_replace( string $value, string $search, string $replace ): string { - if ( '' === $search || '' === $value || $search === $replace ) { - return $value; - } - $search_esc = str_replace( '/', '\\/', $search ); - $replace_esc = str_replace( '/', '\\/', $replace ); - if ( false !== strpos( $value, $search_esc ) ) { - $value = str_replace( $search_esc, $replace_esc, $value ); - } - if ( false !== strpos( $value, $search ) ) { - $value = str_replace( $search, $replace, $value ); - } - return $value; - } + /** + * Replace an escaped (`\/`) plus an unescaped URL occurrence in a raw + * string value (JSON or plain text). No length prefixes to fix in JSON, so + * a raw str_replace over both variants is byte-safe here. + * + * The escaped variant is replaced first so the unescaped pass never + * re-touches the slashes the escaped pass just produced. + * + * @param string $value Raw value. + * @param string $search Old URL (unescaped). + * @param string $replace New URL (unescaped). + * @return string + */ + public static function pair_replace( string $value, string $search, string $replace ): string { + if ( '' === $search || '' === $value || $search === $replace ) { + return $value; + } + $search_esc = str_replace( '/', '\\/', $search ); + $replace_esc = str_replace( '/', '\\/', $replace ); + if ( false !== strpos( $value, $search_esc ) ) { + $value = str_replace( $search_esc, $replace_esc, $value ); + } + if ( false !== strpos( $value, $search ) ) { + $value = str_replace( $search, $replace, $value ); + } + return $value; + } - /** - * Rewrite every PHP-serialized string token whose quoted value contains the - * search, adjusting only its s:LEN prefix. Never unserializes, so a serialized - * object whose class is missing on the target can never fatal. - * - * A URL swap adds or removes no `"` or `\` bytes, so each replaced occurrence - * changes the logical length — and therefore the s:LEN prefix — by exactly - * ( strlen( $replace ) - strlen( $search ) ). Escape sequences elsewhere in a - * token are preserved verbatim and never recounted. - * - * @param string $value A value that is_serialized() already accepted. - * @param string $search Old URL. - * @param string $replace New URL. - * @return string Rewritten serialized value (still is_serialized()). - */ - public static function fix_serialized_strings( string $value, string $search, string $replace ): string { - if ( '' === $search || $search === $replace ) { - return $value; - } - $delta = strlen( $replace ) - strlen( $search ); + /** + * Rewrite every PHP-serialized string token whose quoted value contains the + * search, adjusting only its s:LEN prefix. Never unserializes, so a serialized + * object whose class is missing on the target can never fatal. + * + * A URL swap adds or removes no `"` or `\` bytes, so each replaced occurrence + * changes the logical length — and therefore the s:LEN prefix — by exactly + * ( strlen( $replace ) - strlen( $search ) ). Escape sequences elsewhere in a + * token are preserved verbatim and never recounted. + * + * @param string $value A value that is_serialized() already accepted. + * @param string $search Old URL. + * @param string $replace New URL. + * @return string Rewritten serialized value (still is_serialized()). + */ + public static function fix_serialized_strings( string $value, string $search, string $replace ): string { + if ( '' === $search || $search === $replace ) { + return $value; + } + $delta = strlen( $replace ) - strlen( $search ); - // Match s:LEN:"..." string tokens. `\\.` consumes an escaped quote or - // backslash so the closing quote is only the real delimiter. - return preg_replace_callback( - '/s:(\d+):("((?:[^"\\\\]|\\\\.)*)")/', - static function ( array $m ) use ( $search, $replace, $delta ): string { - $inner = $m[3]; // Quoted representation between the delimiters. - if ( false === strpos( $inner, $search ) ) { - return $m[0]; // No occurrence — leave the token byte-identical. - } - $count = substr_count( $inner, $search ); - $new = str_replace( $search, $replace, $inner ); - return 's:' . ( (int) $m[1] + ( $count * $delta ) ) . ':"' . $new . '"'; - }, - $value - ); - } + // Match s:LEN:"..." string tokens. `\\.` consumes an escaped quote or + // backslash so the closing quote is only the real delimiter. + return preg_replace_callback( + '/s:(\d+):("((?:[^"\\\\]|\\\\.)*)")/', + static function ( array $m ) use ( $search, $replace, $delta ): string { + $inner = $m[3]; // Quoted representation between the delimiters. + if ( false === strpos( $inner, $search ) ) { + return $m[0]; // No occurrence — leave the token byte-identical. + } + $count = substr_count( $inner, $search ); + $new = str_replace( $search, $replace, $inner ); + return 's:' . ( (int) $m[1] + ( $count * $delta ) ) . ':"' . $new . '"'; + }, + $value + ); + } - /** - * Recursive replace over a DB value (string, array, or scalar). - * - * - string -> serialized? fix_serialized_strings() : pair_replace() - * - array -> recurse into keys and values - * - object -> returned untouched (never touched: a live object here would - * already be an __PHP_Incomplete_Class we must not walk) - * - * @param mixed $data Value from the database. - * @param string $search Old URL. - * @param string $replace New URL. - * @return mixed - */ - public static function replace( $data, string $search, string $replace ) { - if ( is_string( $data ) ) { - if ( '' === $data ) { - return $data; - } - if ( function_exists( 'is_serialized' ) && is_serialized( $data ) ) { - return self::fix_serialized_strings( $data, $search, $replace ); - } - return self::pair_replace( $data, $search, $replace ); - } + /** + * Recursive replace over a DB value (string, array, or scalar). + * + * - string -> serialized? fix_serialized_strings() : pair_replace() + * - array -> recurse into keys and values + * - object -> returned untouched (never touched: a live object here would + * already be an __PHP_Incomplete_Class we must not walk) + * + * @param mixed $data Value from the database. + * @param string $search Old URL. + * @param string $replace New URL. + * @return mixed + */ + public static function replace( $data, string $search, string $replace ) { + if ( is_string( $data ) ) { + if ( '' === $data ) { + return $data; + } + if ( function_exists( 'is_serialized' ) && is_serialized( $data ) ) { + return self::fix_serialized_strings( $data, $search, $replace ); + } + return self::pair_replace( $data, $search, $replace ); + } - if ( is_array( $data ) ) { - $out = array(); - foreach ( $data as $k => $v ) { - $out[ self::replace( (string) $k, $search, $replace ) ] = self::replace( $v, $search, $replace ); - } - return $out; - } + if ( is_array( $data ) ) { + $out = array(); + foreach ( $data as $k => $v ) { + $out[ self::replace( (string) $k, $search, $replace ) ] = self::replace( $v, $search, $replace ); + } + return $out; + } - return $data; - } + return $data; + } - /** - * Text-bearing tables the site-wide search-replace should walk. - * - * All `{prefix}` tables, minus internal/operational tables whose values are - * paths, keys, or session data that must never be URL-rewritten. Each walk - * only touches rows that actually contain $search, so over-approximating is - * cheap; the exclusions are the tables where a match would be a false hit. - * - * @param \wpdb $wpdb WordPress database. - * @return string[] Table names. - */ - public static function data_tables( $wpdb ): array { - $exclude = array( - $wpdb->prefix . 'emcp_redirects', - $wpdb->prefix . 'emcp_migrate_targets', - $wpdb->prefix . 'emcp_migrate_backups', - $wpdb->prefix . 'emcp_migrate_jobs', - $wpdb->prefix . 'emcp_search_index', - $wpdb->prefix . 'woocommerce_sessions', - $wpdb->prefix . 'actionscheduler_actions', - $wpdb->prefix . 'actionscheduler_logs', - $wpdb->prefix . 'actionscheduler_groups', - $wpdb->prefix . 'actionscheduler_claims', - $wpdb->options, // Walked explicitly first below. - ); - $tables = array( $wpdb->options, $wpdb->posts, $wpdb->postmeta, $wpdb->comments, $wpdb->commentmeta, $wpdb->termmeta, $wpdb->usermeta ); - if ( $wpdb->links ) { - $tables[] = $wpdb->links; - } - $rows = $wpdb->get_col( 'SHOW TABLES' ); // phpcs:ignore WordPress.DB -- identifier enumeration. - foreach ( $rows as $table ) { - if ( 0 !== strpos( $table, $wpdb->prefix ) || in_array( $table, $exclude, true ) || in_array( $table, $tables, true ) ) { - continue; - } - $tables[] = $table; - } - return $tables; - } + /** + * Text-bearing tables the site-wide search-replace should walk. + * + * All `{prefix}` tables, minus internal/operational tables whose values are + * paths, keys, or session data that must never be URL-rewritten. Each walk + * only touches rows that actually contain $search, so over-approximating is + * cheap; the exclusions are the tables where a match would be a false hit. + * + * @param \wpdb $wpdb WordPress database. + * @return string[] Table names. + */ + public static function data_tables( $wpdb ): array { + $exclude = array( + $wpdb->prefix . 'emcp_redirects', + $wpdb->prefix . 'emcp_migrate_targets', + $wpdb->prefix . 'emcp_migrate_backups', + $wpdb->prefix . 'emcp_migrate_jobs', + $wpdb->prefix . 'emcp_search_index', + $wpdb->prefix . 'woocommerce_sessions', + $wpdb->prefix . 'actionscheduler_actions', + $wpdb->prefix . 'actionscheduler_logs', + $wpdb->prefix . 'actionscheduler_groups', + $wpdb->prefix . 'actionscheduler_claims', + $wpdb->options, // Walked explicitly first below. + ); + $tables = array( $wpdb->options, $wpdb->posts, $wpdb->postmeta, $wpdb->comments, $wpdb->commentmeta, $wpdb->termmeta, $wpdb->usermeta ); + if ( $wpdb->links ) { + $tables[] = $wpdb->links; + } + $rows = $wpdb->get_col( 'SHOW TABLES' ); // phpcs:ignore WordPress.DB -- identifier enumeration. + foreach ( $rows as $table ) { + if ( 0 !== strpos( $table, $wpdb->prefix ) || in_array( $table, $exclude, true ) || in_array( $table, $tables, true ) ) { + continue; + } + $tables[] = $table; + } + return $tables; + } - /** - * Single-column primary key for a table, or '' when the table has none / - * a composite key (those are skipped — a row cannot be addressed safely). - * - * @param \wpdb $wpdb WordPress database. - * @param string $table Table name. - * @return string Column name, or ''. - */ - public static function primary_key( $wpdb, string $table ): string { - $rows = $wpdb->get_results( "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY'" ); // phpcs:ignore WordPress.DB -- identifier quoted, admin-audited path. - if ( count( $rows ) !== 1 ) { - return ''; - } - return (string) $rows[0]->Column_name; - } + /** + * Single-column primary key for a table, or '' when the table has none / + * a composite key (those are skipped — a row cannot be addressed safely). + * + * @param \wpdb $wpdb WordPress database. + * @param string $table Table name. + * @return string Column name, or ''. + */ + public static function primary_key( $wpdb, string $table ): string { + $rows = $wpdb->get_results( "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY'" ); // phpcs:ignore WordPress.DB -- identifier quoted, admin-audited path. + if ( count( $rows ) !== 1 ) { + return ''; + } + return (string) $rows[0]->Column_name; + } - /** - * Walk one table and rewrite every text row containing $search. - * - * Only char/text/json columns are considered; only rows that contain the - * search are fetched; only columns that actually change are written back. - * Returns the rows touched (their full before-image) so the caller can hand - * them to the change ledger for History rollback. - * - * @param \wpdb $wpdb WordPress database. - * @param string $table Table name. - * @param string $search Old URL. - * @param string $replace New URL. - * @param int $max_rows Optional hard cap on rows walked per table (0 = unlimited). - * @param int $before_cap Max before-images returned (for the ledger); further rows are still rewritten but flagged partial. - * @return array { table, pk, affected, columns, partial, before_rows } - */ - public static function walk_table( $wpdb, string $table, string $search, string $replace, int $max_rows = 0, int $before_cap = 200 ): array { - $result = array( - 'table' => $table, - 'pk' => '', - 'affected' => 0, - 'columns' => array(), - 'partial' => false, - 'before_rows' => array(), - ); - if ( '' === $search || '' === $table || $search === $replace ) { - return $result; - } + /** + * Walk one table and rewrite every text row containing $search. + * + * Only char/text/json columns are considered; only rows that contain the + * search are fetched; only columns that actually change are written back. + * Returns the rows touched (their full before-image) so the caller can hand + * them to the change ledger for History rollback. + * + * @param \wpdb $wpdb WordPress database. + * @param string $table Table name. + * @param string $search Old URL. + * @param string $replace New URL. + * @param int $max_rows Optional hard cap on rows walked per table (0 = unlimited). + * @param int $before_cap Max before-images returned (for the ledger); further rows are still rewritten but flagged partial. + * @return array { table, pk, affected, columns, partial, before_rows } + */ + public static function walk_table( $wpdb, string $table, string $search, string $replace, int $max_rows = 0, int $before_cap = 200 ): array { + $result = array( + 'table' => $table, + 'pk' => '', + 'affected' => 0, + 'columns' => array(), + 'partial' => false, + 'before_rows' => array(), + ); + if ( '' === $search || '' === $table || $search === $replace ) { + return $result; + } - // Text columns only. - $columns = array(); - $describe = $wpdb->get_results( "DESCRIBE `{$table}`" ); // phpcs:ignore WordPress.DB -- identifier quoted. - foreach ( (array) $describe as $col ) { - $type = strtolower( (string) $col->Type ); - if ( false !== strpos( $type, 'char' ) || false !== strpos( $type, 'text' ) || false !== strpos( $type, 'json' ) ) { - $columns[] = $col->Field; - } - } - if ( empty( $columns ) ) { - return $result; - } - $result['columns'] = $columns; + // Text columns only. + $columns = array(); + $describe = $wpdb->get_results( "DESCRIBE `{$table}`" ); // phpcs:ignore WordPress.DB -- identifier quoted. + foreach ( (array) $describe as $col ) { + $type = strtolower( (string) $col->Type ); + if ( false !== strpos( $type, 'char' ) || false !== strpos( $type, 'text' ) || false !== strpos( $type, 'json' ) ) { + $columns[] = $col->Field; + } + } + if ( empty( $columns ) ) { + return $result; + } + $result['columns'] = $columns; - $pk = self::primary_key( $wpdb, $table ); - if ( '' === $pk ) { - $result['partial'] = true; // Composite/no-PK: cannot update rows safely. - return $result; - } - $result['pk'] = $pk; + $pk = self::primary_key( $wpdb, $table ); + if ( '' === $pk ) { + $result['partial'] = true; // Composite/no-PK: cannot update rows safely. + return $result; + } + $result['pk'] = $pk; - $like = '%' . $wpdb->esc_like( $search ) . '%'; - $where_parts = array(); - $where_args = array(); - foreach ( $columns as $col ) { - $where_parts[] = "`{$col}` LIKE %s"; - $where_args[] = $like; - } - $where_sql = implode( ' OR ', $where_parts ); - $limit = ( $max_rows > 0 ) ? $max_rows : 2000; - $fetch = $limit + 1; // +1 detects that more matching rows remain. - $args = array_merge( $where_args, array( $fetch ) ); - $sql = "SELECT `{$pk}`, `" . implode( '`, `', $columns ) . "` FROM `{$table}` WHERE ({$where_sql}) LIMIT %d"; + $like = '%' . $wpdb->esc_like( $search ) . '%'; + $where_parts = array(); + $where_args = array(); + foreach ( $columns as $col ) { + $where_parts[] = "`{$col}` LIKE %s"; + $where_args[] = $like; + } + $where_sql = implode( ' OR ', $where_parts ); + $limit = ( $max_rows > 0 ) ? $max_rows : 2000; + $fetch = $limit + 1; // +1 detects that more matching rows remain. + $args = array_merge( $where_args, array( $fetch ) ); + $sql = "SELECT `{$pk}`, `" . implode( '`, `', $columns ) . "` FROM `{$table}` WHERE ({$where_sql}) LIMIT %d"; - $rows = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A ); // phpcs:ignore WordPress.DB -- prepared; identifier-quoted above. - if ( null === $rows ) { - $result['partial'] = true; - return $result; - } - if ( count( $rows ) > $limit ) { - $result['partial'] = true; - $rows = array_slice( $rows, 0, $limit ); - } + $rows = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A ); // phpcs:ignore WordPress.DB -- prepared; identifier-quoted above. + if ( null === $rows ) { + $result['partial'] = true; + return $result; + } + if ( count( $rows ) > $limit ) { + $result['partial'] = true; + $rows = array_slice( $rows, 0, $limit ); + } - foreach ( $rows as $row ) { - $changes = array(); - foreach ( $columns as $col ) { - if ( ! array_key_exists( $col, $row ) || ! is_string( $row[ $col ] ) ) { - continue; - } - $old = $row[ $col ]; - if ( false === strpos( $old, $search ) ) { - continue; - } - $new = self::replace( $old, $search, $replace ); - if ( $new !== $old ) { - $changes[ $col ] = $new; - } - } - if ( empty( $changes ) ) { - continue; - } - $wpdb->update( $table, $changes, array( $pk => $row[ $pk ] ) ); // phpcs:ignore WordPress.DB -- keyed by PK. - $result['affected']++; - if ( count( $result['before_rows'] ) < $before_cap ) { - $result['before_rows'][] = $row; - } else { - $result['partial'] = true; - } - } - return $result; - } + foreach ( $rows as $row ) { + $changes = array(); + foreach ( $columns as $col ) { + if ( ! array_key_exists( $col, $row ) || ! is_string( $row[ $col ] ) ) { + continue; + } + $old = $row[ $col ]; + if ( false === strpos( $old, $search ) ) { + continue; + } + $new = self::replace( $old, $search, $replace ); + if ( $new !== $old ) { + $changes[ $col ] = $new; + } + } + if ( empty( $changes ) ) { + continue; + } + $wpdb->update( $table, $changes, array( $pk => $row[ $pk ] ) ); // phpcs:ignore WordPress.DB -- keyed by PK. + $result['affected']++; + if ( count( $result['before_rows'] ) < $before_cap ) { + $result['before_rows'][] = $row; + } else { + $result['partial'] = true; + } + } + return $result; + } } diff --git a/includes/modules/class-migrate-module.php b/includes/modules/class-migrate-module.php index 54ba952..ebf10e0 100644 --- a/includes/modules/class-migrate-module.php +++ b/includes/modules/class-migrate-module.php @@ -41,12 +41,17 @@ class EMCP_Tools_Migrate_Module extends EMCP_Tools_Module { } } + /** URL of the Backup & Migrate admin tab. */ + private static function migrate_page_url(): string { + return admin_url( 'admin.php?page=' . \EMCP_Tools_Admin::PAGE_SLUG . '-migrate' ); + } + /** Stream a installable connector-plugin zip (emcp-connector/). */ public function handle_download_connector(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to download the connector.', 'emcp-tools' ) ); } - check_admin_referer( self::ADMIN_ACTION, '_emcp_nonce' ); + check_admin_referer( self::ADMIN_ACTION ); $this->stream_connector_zip(); } @@ -55,7 +60,7 @@ class EMCP_Tools_Migrate_Module extends EMCP_Tools_Module { $source = EMCP_Tools_Pro_Loader::path( 'connector/emcp-connector.php' ); if ( '' === $source || ! is_readable( $source ) ) { $this->set_notice( 'error', __( 'Connector plugin file not found in this build.', 'emcp-tools' ) ); - wp_safe_redirect( admin_url( 'admin.php?page=' . \EMCP_Tools_Admin::PAGE_SLUG . '-migrate' ) ); + wp_safe_redirect( self::migrate_page_url() ); exit; } $tmp = EMCP_Tools_Packager::backup_dir() . '/.emcp-connector-' . wp_generate_password( 6, false ) . '.zip'; @@ -66,7 +71,7 @@ class EMCP_Tools_Migrate_Module extends EMCP_Tools_Module { } if ( ! is_file( $tmp ) ) { $this->set_notice( 'error', __( 'Could not build the connector zip.', 'emcp-tools' ) ); - wp_safe_redirect( admin_url( 'admin.php?page=' . \EMCP_Tools_Admin::PAGE_SLUG . '-migrate' ) ); + wp_safe_redirect( self::migrate_page_url() ); exit; } header( 'Content-Type: application/zip' ); @@ -86,11 +91,10 @@ class EMCP_Tools_Migrate_Module extends EMCP_Tools_Module { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to manage backups.', 'emcp-tools' ) ); } - check_admin_referer( self::ADMIN_ACTION, '_emcp_nonce' ); + check_admin_referer( self::ADMIN_ACTION ); $action = sanitize_key( (string) ( $_POST['emcp_migrate_action'] ?? '' ) ); $file = sanitize_file_name( (string) ( $_POST['archive'] ?? '' ) ); - $back = admin_url( 'admin.php?page=' . \EMCP_Tools_Admin::PAGE_SLUG . '-migrate' ); switch ( $action ) { case 'create': @@ -98,22 +102,22 @@ class EMCP_Tools_Migrate_Module extends EMCP_Tools_Module { break; case 'restore': - $this->admin_restore( $file, $back ); + $this->admin_restore( $file ); break; case 'delete': - $this->admin_delete( $file, $back ); + $this->admin_delete( $file ); break; case 'download': - $this->admin_download( $file, $back ); + $this->admin_download( $file ); break; default: $this->set_notice( 'error', __( 'Unknown action.', 'emcp-tools' ) ); } - wp_safe_redirect( $back ); + wp_safe_redirect( self::migrate_page_url() ); exit; } @@ -131,7 +135,7 @@ class EMCP_Tools_Migrate_Module extends EMCP_Tools_Module { } /** Restore an archive (verification + import + optional URL rewrite + files). */ - private function admin_restore( string $file, string $back ): void { + private function admin_restore( string $file ): void { if ( '' === $file ) { $this->set_notice( 'error', __( 'No archive selected to restore.', 'emcp-tools' ) ); return; @@ -173,7 +177,7 @@ class EMCP_Tools_Migrate_Module extends EMCP_Tools_Module { } /** Delete an archive (admin-only). */ - private function admin_delete( string $file, string $back ): void { + private function admin_delete( string $file ): void { if ( '' === $file ) { $this->set_notice( 'error', __( 'No archive selected to delete.', 'emcp-tools' ) ); return; @@ -188,7 +192,7 @@ class EMCP_Tools_Migrate_Module extends EMCP_Tools_Module { } /** Stream an archive to the browser and stop. */ - private function admin_download( string $file, string $back ): void { + private function admin_download( string $file ): void { $path = EMCP_Tools_Packager::archive_path( $file ); if ( '' === $path ) { $this->set_notice( 'error', __( 'Archive not found for download.', 'emcp-tools' ) ); diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..c970fec --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,16 @@ +# SonarCloud analysis scope for Heretek-AI_elementor-mcp. +# +# The PHP quality profile deactivates the camelCase naming rules (php:S100 / +# php:S101 / php:S116) and "too many returns" (php:S1142) because this is a +# WordPress plugin: snake_case function/class names and WP_Error-guard early +# returns are the platform convention. php:S3776 (cognitive complexity) runs +# with a raised threshold so only genuinely huge functions flag. +# +# The standalone test harnesses under tests/ deliberately mirror WordPress core +# function names (wp_normalize_path, esc_url, get_results, ...) so they can run +# without a database; analysing them as app code produces only noise, so they +# are excluded here. vendor/ holds committed Composer runtime deps. +sonar.projectKey=Heretek-AI_elementor-mcp +sonar.projectName=Heretek-AI elementor-mcp +sonar.sourceEncoding=UTF-8 +sonar.exclusions=tests/**,**/vendor/**