Skip to content

Commit 11fa959

Browse files
Copilotswissspidy
andcommitted
Fix: Handle JSON-encoded URLs in search-replace for WordPress font data
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
1 parent 7547eb8 commit 11fa959

File tree

3 files changed

+96
-1
lines changed

3 files changed

+96
-1
lines changed

features/search-replace.feature

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,55 @@ Feature: Do global search/replace
238238
| key | value |
239239
| header_image_data | {"url":"https:\/\/example.com\/foo.jpg"} |
240240

241+
@require-mysql
242+
Scenario: Search and replace handles JSON-encoded URLs in post content
243+
Given a WP install
244+
And a create-post-with-json-content.php file:
245+
"""
246+
<?php
247+
$post_id = wp_insert_post( [
248+
'post_type' => 'post',
249+
'post_status' => 'publish',
250+
'post_title' => 'Font Test',
251+
'post_content' => json_encode( [ 'src' => 'http://example.com/wp-content/uploads/fonts/test.woff2', 'fontWeight' => '400' ] ),
252+
] );
253+
echo $post_id;
254+
"""
255+
And I run `wp eval-file create-post-with-json-content.php`
256+
Then save STDOUT as {POST_ID}
257+
258+
When I run `wp post get {POST_ID} --field=post_content`
259+
Then STDOUT should contain:
260+
"""
261+
http:\/\/example.com
262+
"""
263+
264+
When I run `wp search-replace 'http://example.com' 'http://newdomain.com' wp_posts --include-columns=post_content`
265+
Then STDOUT should be a table containing rows:
266+
| Table | Column | Replacements | Type |
267+
| wp_posts | post_content | 1 | SQL |
268+
269+
When I run `wp post get {POST_ID} --field=post_content`
270+
Then STDOUT should contain:
271+
"""
272+
http:\/\/newdomain.com
273+
"""
274+
And STDOUT should not contain:
275+
"""
276+
http:\/\/example.com
277+
"""
278+
279+
When I run `wp search-replace 'http://newdomain.com' 'http://example.com' wp_posts --include-columns=post_content --precise`
280+
Then STDOUT should be a table containing rows:
281+
| Table | Column | Replacements | Type |
282+
| wp_posts | post_content | 1 | PHP |
283+
284+
When I run `wp post get {POST_ID} --field=post_content`
285+
Then STDOUT should contain:
286+
"""
287+
http:\/\/example.com
288+
"""
289+
241290
@require-mysql
242291
Scenario: Search and replace with quoted strings
243292
Given a WP install

src/Search_Replace_Command.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,19 +652,29 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) {
652652

653653
$table_sql = self::esc_sql_ident( $table );
654654
$col_sql = self::esc_sql_ident( $col );
655+
$old_json = self::json_encode_strip_quotes( $old );
656+
$new_json = self::json_encode_strip_quotes( $new );
655657
if ( $this->dry_run ) {
656658
if ( $this->log_handle ) {
657659
$count = $this->log_sql_diff( $col, $primary_keys, $table, $old, $new );
658660
} else {
659661
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
660662
$count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%' ) );
663+
if ( $old_json !== $old ) {
664+
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
665+
$count += (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old_json ) . '%' ) );
666+
}
661667
}
662668
} else {
663669
if ( $this->log_handle ) {
664670
$this->log_sql_diff( $col, $primary_keys, $table, $old, $new );
665671
}
666672
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
667673
$count = $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old, $new ) );
674+
if ( $old_json !== $old ) {
675+
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
676+
$count += (int) $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old_json, $new_json ) );
677+
}
668678
}
669679

670680
if ( $this->verbose && 'table' === $this->format ) {
@@ -686,8 +696,12 @@ private function php_handle_col( $col, $primary_keys, $table, $old, $new ) {
686696
$base_key_condition = '';
687697
$where_key = '';
688698
if ( ! $this->regex ) {
699+
$old_json = self::json_encode_strip_quotes( $old );
689700
$base_key_condition = "$col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old ) . '%' );
690-
$where_key = "WHERE $base_key_condition";
701+
if ( $old_json !== $old ) {
702+
$base_key_condition = "( $base_key_condition OR $col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old_json ) . '%' ) . ' )';
703+
}
704+
$where_key = "WHERE $base_key_condition";
691705
}
692706

693707
$escaped_primary_keys = self::esc_sql_ident( $primary_keys );
@@ -917,6 +931,19 @@ private static function esc_like( $old ) {
917931
return $old;
918932
}
919933

934+
/**
935+
* Returns the JSON-encoded representation of a string with the surrounding quotes stripped.
936+
* This is used to also handle values stored as raw JSON in the database (e.g. WordPress font data).
937+
* Returns the original string unchanged if JSON encoding fails (e.g. invalid UTF-8).
938+
*
939+
* @param string $str The string to encode.
940+
* @return string The JSON-encoded string without surrounding quotes, or the original string on failure.
941+
*/
942+
private static function json_encode_strip_quotes( $str ) {
943+
$encoded = json_encode( $str ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
944+
return false !== $encoded ? substr( $encoded, 1, -1 ) : $str;
945+
}
946+
920947
/**
921948
* Escapes (backticks) MySQL identifiers (aka schema object names) - i.e. column names, table names, and database/index/alias/view etc names.
922949
* See https://dev.mysql.com/doc/refman/5.5/en/identifiers.html

src/WP_CLI/SearchReplacer.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ class SearchReplacer {
1717
*/
1818
private $to;
1919

20+
/**
21+
* @var string
22+
*/
23+
private $from_json;
24+
25+
/**
26+
* @var string
27+
*/
28+
private $to_json;
29+
2030
/**
2131
* @var bool
2232
*/
@@ -78,6 +88,12 @@ public function __construct( $from, $to, $recurse_objects = false, $regex = fals
7888
$this->logging = $logging;
7989
$this->clear_log_data();
8090

91+
// Compute JSON-encoded versions (stripping outer quotes) for handling raw JSON values in the database.
92+
$from_encoded = json_encode( $from ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
93+
$this->from_json = false !== $from_encoded ? substr( $from_encoded, 1, -1 ) : $from;
94+
$to_encoded = json_encode( $to ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
95+
$this->to_json = false !== $to_encoded ? substr( $to_encoded, 1, -1 ) : $to;
96+
8197
// Get the XDebug nesting level. Will be zero (no limit) if no value is set
8298
$this->max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) );
8399
}
@@ -204,6 +220,9 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis
204220
$data = $result;
205221
} else {
206222
$data = str_replace( $this->from, $this->to, $data );
223+
if ( $this->from_json !== $this->from ) {
224+
$data = str_replace( $this->from_json, $this->to_json, $data );
225+
}
207226
}
208227
if ( $this->logging && $old_data !== $data ) {
209228
$this->log_data[] = $old_data;

0 commit comments

Comments
 (0)