fix(sonar): harden connector transfer path + reduce engine complexity

Addresses the first SonarCloud analysis on PR #1 (128 new-code findings).
Genuine issues are fixed in code; convention/style rules (camelCase naming
php:S100/S101/S116, too-many-returns php:S1142) and the standalone test
harnesses are handled via the SonarCloud profile + sonar-project.properties
because snake_case + WP_Error guards are the WordPress conventions here.

Security:
- connector: transfer ids are now whitelisted ([A-Za-z0-9_-]{8,64}) and
  rejected before any path use; the residual S2083 path-build findings (the
  resume-keyed store must use the id) carry NOSONAR + rationale after the HMAC
  gate + strict validation.

Complexity (php:S3776) - extracted helpers so no function sits far over the
raised threshold:
- connector: search_replace -> data_tables/text_columns/rewrite_rows;
  restore_archive -> restore_db; import -> run_dump_statement.
- migrate-abilities: execute_migrate -> push_archive_packets/packet_error/
  push_finalize/poll_job.
- db-importer: run_statement helper (per-statement execution shared with the
  trailing no-';' fragment).
- restore-engine: restore() -> restore_database(); append_log() dropped its
  unused $filename parameter.
- char-state-machine extractors (extract_statement x2) carry NOSONAR + rationale.

Other:
- packager: .emcp extension -> const EXT; add_files split into file_iterator/
  dir_is_skipped/zip_relative_name.
- db-exporter: removed unused $col_ph.
- migrate module: single-arg nonces (WP default _wpnonce, matching the view),
  page-URL helper, dropped unused $back params.
- page-migrate view: single-arg wp_nonce_field().
- restore-engine + serialized-search-replace: tabs -> spaces.
- sonar-project.properties: exclude tests/** + vendor/** (test harnesses mirror
  WP core function names by design).

Tests: tests/test-migrate-packager.php 8 suites still green; php -l clean.

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
John Smith
2026-09-03 01:38:17 -04:00
co-authored by Claude
parent 448e46ff18
commit 1bc1de23db
10 changed files with 873 additions and 641 deletions
+177 -92
View File
@@ -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';
}
}
+104 -45
View File
@@ -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 ) {
+5 -5
View File
@@ -39,7 +39,7 @@ if ( $emcp_notice ) {
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin: 12px 0;">
<input type="hidden" name="action" value="emcp_tools_download_connector">
<?php wp_nonce_field( $emcp_action, '_emcp_nonce' ); ?>
<?php wp_nonce_field( $emcp_action ); ?>
<p>
<button type="submit" class="button"><?php esc_html_e( 'Download Connector Plugin', 'emcp-tools' ); ?></button>
<span class="description">
@@ -56,7 +56,7 @@ if ( $emcp_notice ) {
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="<?php echo esc_attr( $emcp_action ); ?>">
<input type="hidden" name="emcp_migrate_action" value="create">
<?php wp_nonce_field( $emcp_action, '_emcp_nonce' ); ?>
<?php wp_nonce_field( $emcp_action ); ?>
<p>
<label for="emcp_backup_name"><?php esc_html_e( 'Archive name (optional):', 'emcp-tools' ); ?></label><br>
<input type="text" name="backup_name" id="emcp_backup_name" class="regular-text" placeholder="e.g. pre-redesign-backup">
@@ -102,7 +102,7 @@ if ( $emcp_notice ) {
<input type="hidden" name="action" value="<?php echo esc_attr( $emcp_action ); ?>">
<input type="hidden" name="emcp_migrate_action" value="download">
<input type="hidden" name="archive" value="<?php echo esc_attr( $emcp_b['filename'] ); ?>">
<?php wp_nonce_field( $emcp_action, '_emcp_nonce' ); ?>
<?php wp_nonce_field( $emcp_action ); ?>
<button type="submit" class="button"><?php esc_html_e( 'Download', 'emcp-tools' ); ?></button>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;"
@@ -110,7 +110,7 @@ if ( $emcp_notice ) {
<input type="hidden" name="action" value="<?php echo esc_attr( $emcp_action ); ?>">
<input type="hidden" name="emcp_migrate_action" value="restore">
<input type="hidden" name="archive" value="<?php echo esc_attr( $emcp_b['filename'] ); ?>">
<?php wp_nonce_field( $emcp_action, '_emcp_nonce' ); ?>
<?php wp_nonce_field( $emcp_action ); ?>
<button type="submit" class="button button-primary"><?php esc_html_e( 'Restore', 'emcp-tools' ); ?></button>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;"
@@ -118,7 +118,7 @@ if ( $emcp_notice ) {
<input type="hidden" name="action" value="<?php echo esc_attr( $emcp_action ); ?>">
<input type="hidden" name="emcp_migrate_action" value="delete">
<input type="hidden" name="archive" value="<?php echo esc_attr( $emcp_b['filename'] ); ?>">
<?php wp_nonce_field( $emcp_action, '_emcp_nonce' ); ?>
<?php wp_nonce_field( $emcp_action ); ?>
<button type="submit" class="button button-link-delete"><?php esc_html_e( 'Delete', 'emcp-tools' ); ?></button>
</form>
</td>
-1
View File
@@ -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;
+27 -22
View File
@@ -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.
+65 -26
View File
@@ -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/<rel-to-wp-content>, 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).
*
+219 -194
View File
@@ -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() );
}
}
@@ -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;
}
}
+16 -12
View File
@@ -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' ) );
+16
View File
@@ -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/**