mirror of
https://github.com/Heretek-AI/heretek-control-core.git
synced 2026-10-11 21:09:58 +00:00
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:
+177
-92
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' ) );
|
||||
|
||||
@@ -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/**
|
||||
Reference in New Issue
Block a user