| 1 |
<?php |
| 2 |
/** |
| 3 |
* Search-Replace Engine Trait — text replacement across WordPress tables. |
| 4 |
* It is aware of serialized PHP. |
| 5 |
* |
| 6 |
* The code was moved out of RunWpCli to keep that class small. This trait |
| 7 |
* is private to the RunWpCli ability. It depends only on $wpdb, WordPress |
| 8 |
* core (`is_serialized`) and the standard Response helper. It does not |
| 9 |
* depend on RunWpCli state. |
| 10 |
* |
| 11 |
* @package zip-ai |
| 12 |
*/ |
| 13 |
|
| 14 |
namespace ZipAI\MCP\Classes\Abilities\Core; |
| 15 |
|
| 16 |
defined( 'ABSPATH' ) || exit; |
| 17 |
|
| 18 |
use ZipAI\MCP\Classes\Core\Response; |
| 19 |
use ZipAI\MCP\Classes\Security\Protected_Options_Filter; |
| 20 |
|
| 21 |
/** |
| 22 |
* Trait for `wp search-replace` execution. |
| 23 |
* |
| 24 |
* It walks every text column of every selected table. It deserializes a |
| 25 |
* payload when one is present. This keeps the length prefix consistent. A |
| 26 |
* naïve UPDATE … REPLACE() would corrupt that prefix. It always skips |
| 27 |
* `guid`, `user_pass` and `option_name`. It never walks the user, usermeta |
| 28 |
* or sitemeta tables. It never walks protected `wp_options` rows. |
| 29 |
*/ |
| 30 |
trait Search_Replace_Engine_Trait { |
| 31 |
|
| 32 |
/** |
| 33 |
* Handle "search-replace <old> <new> [--all-tables] [--skip-columns=…] [--dry-run]". |
| 34 |
* |
| 35 |
* It is aware of serialized PHP. When a cell holds a serialized structure, |
| 36 |
* the code deserializes it. It replaces the value recursively. It then |
| 37 |
* reserializes it. So it does not corrupt the length prefix. A naïve |
| 38 |
* UPDATE ... REPLACE() would break that prefix. |
| 39 |
* |
| 40 |
* The code ALWAYS skips the `guid`, `user_pass` and `option_name` columns. |
| 41 |
* `guid` must not change after publish. It identifies the post in RSS |
| 42 |
* readers. `user_pass` is a bcrypt hash. A string replace would break |
| 43 |
* logins. `option_name` is the identity of the option. |
| 44 |
* |
| 45 |
* Without `--all-tables`, the code walks only core WP tables |
| 46 |
* (`$wpdb->tables()`). With it, the code walks every table with the wpdb |
| 47 |
* prefix. Either way it excludes the user, usermeta and sitemeta tables. |
| 48 |
* It also filters out protected `wp_options` rows. See the notes at each |
| 49 |
* site. |
| 50 |
* |
| 51 |
* @param string[] $positional Remaining positional args ([0]=old, [1]=new). |
| 52 |
* @param array<string,string|bool> $flags Parsed flags. |
| 53 |
* @return array<string,mixed> |
| 54 |
*/ |
| 55 |
private function handle_search_replace( array $positional, array $flags ): array { |
| 56 |
global $wpdb; |
| 57 |
/** |
| 58 |
* WordPress database access layer. |
| 59 |
* |
| 60 |
* @var \wpdb $wpdb |
| 61 |
*/ |
| 62 |
$old = $positional[0] ?? null; |
| 63 |
$new = $positional[1] ?? null; |
| 64 |
|
| 65 |
if ( null === $old || '' === $old ) { |
| 66 |
return Response::error( |
| 67 |
'Usage: search-replace "<old>" "<new>" [--all-tables] [--skip-columns=col1,col2] [--dry-run]' |
| 68 |
); |
| 69 |
} |
| 70 |
if ( null === $new ) { |
| 71 |
return Response::error( 'Replacement string missing. Use "" to delete occurrences.' ); |
| 72 |
} |
| 73 |
|
| 74 |
// Hard cap. This is defensive. A 5 KB needle has no real site use. |
| 75 |
if ( strlen( $old ) > 5000 || strlen( (string) $new ) > 5000 ) { |
| 76 |
return Response::error( 'Search or replace string is larger than 5 KB. Run smaller substitutions.' ); |
| 77 |
} |
| 78 |
|
| 79 |
// Refuse a no-op replacement early. It would walk the whole database |
| 80 |
// for nothing. |
| 81 |
if ( $old === $new ) { |
| 82 |
return Response::error( 'Old and new strings are identical, no replacement to perform.' ); |
| 83 |
} |
| 84 |
|
| 85 |
// Structured storage cannot survive a byte-level splice of JSON |
| 86 |
// metacharacters. Gutenberg block attributes are JSON inside HTML |
| 87 |
// comments in `post_content`; many options and meta hold JSON too. A |
| 88 |
// `"` or `\` in EITHER direction corrupts them: in the replacement it |
| 89 |
// lands unescaped inside an encoded string; in the OLD string it |
| 90 |
// splices a quote/backslash OUT of the encoding (that JSON is not |
| 91 |
// PHP-serialized, so the serialized round-trip guard never fires). |
| 92 |
// Either way the document stops parsing and (for blocks) every |
| 93 |
// attribute — including styling classes — drops on the next editor |
| 94 |
// save. There is no per-cell way to know which text sits inside JSON, |
| 95 |
// so refuse outright — fail closed. |
| 96 |
foreach ( array( |
| 97 |
'Search' => (string) $old, |
| 98 |
'Replacement' => (string) $new, |
| 99 |
) as $side => $value ) { |
| 100 |
if ( false !== strpos( $value, '"' ) || false !== strpos( $value, '\\' ) ) { |
| 101 |
return Response::error( |
| 102 |
$side . ' string contains `"` or `\\`, which would corrupt JSON-encoded content (block attributes, settings) via byte-level replacement. Edit those values through the REST/content tools instead.' |
| 103 |
); |
| 104 |
} |
| 105 |
} |
| 106 |
|
| 107 |
$dry_run = ! empty( $flags['dry-run'] ); |
| 108 |
$all_tables = ! empty( $flags['all-tables'] ) || ! empty( $flags['all-tables-with-prefix'] ); |
| 109 |
|
| 110 |
// Always-skip columns. These are identity and control columns. They |
| 111 |
// are not the text content a replacement is for. `guid` is the post |
| 112 |
// permalink that feed readers key on. `user_pass` is a bcrypt hash. |
| 113 |
// `option_name` is the identity of an option. A rewrite deletes the |
| 114 |
// option as far as `get_option()` sees it. `autoload` is a `yes`/`no` |
| 115 |
// control column. A needle as ordinary as `no` would stop options |
| 116 |
// loading across the whole site. |
| 117 |
// |
| 118 |
// The same rule covers the rest of this list. `post_type` and |
| 119 |
// `taxonomy` are row identity — rewriting them to an unregistered |
| 120 |
// value makes every affected post/term vanish from the site and |
| 121 |
// wp-admin (`search-replace page landing` unregisters every page). |
| 122 |
// `post_status` and `comment_approved` are control enums stored as |
| 123 |
// text (`search-replace publish live` unpublishes the whole site). |
| 124 |
// `meta_key` is meta identity — a rewrite orphans the meta for every |
| 125 |
// consumer. `post_mime_type` and `comment_type` are typed selectors |
| 126 |
// queries filter on. |
| 127 |
$always_skip = array( |
| 128 |
'guid', |
| 129 |
'user_pass', |
| 130 |
'option_name', |
| 131 |
'autoload', |
| 132 |
'post_type', |
| 133 |
'post_status', |
| 134 |
'post_mime_type', |
| 135 |
'meta_key', |
| 136 |
'taxonomy', |
| 137 |
'comment_type', |
| 138 |
'comment_approved', |
| 139 |
); |
| 140 |
$user_skip = array(); |
| 141 |
if ( ! empty( $flags['skip-columns'] ) ) { |
| 142 |
$user_skip = array_filter( |
| 143 |
array_map( 'trim', explode( ',', (string) $flags['skip-columns'] ) ) |
| 144 |
); |
| 145 |
} |
| 146 |
$skip_columns = array_unique( array_merge( $always_skip, $user_skip ) ); |
| 147 |
|
| 148 |
// Resolve the set of tables to walk. |
| 149 |
if ( $all_tables ) { |
| 150 |
$like = $wpdb->esc_like( $wpdb->prefix ) . '%'; |
| 151 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared |
| 152 |
$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) ); |
| 153 |
} else { |
| 154 |
$tables = array_values( $wpdb->tables() ); |
| 155 |
} |
| 156 |
|
| 157 |
// The code never walks identity and privilege tables. This holds with |
| 158 |
// or without `--all-tables`. This engine writes cells with raw |
| 159 |
// `$wpdb->update()`. So no `pre_update_option_*` or `update_user_meta` |
| 160 |
// filter observes it (DSA-15). These columns are authentication and |
| 161 |
// capability state. They are not the text content a search-replace is |
| 162 |
// for. `users.user_email` is the password-recovery address. |
| 163 |
// `usermeta.wp_capabilities` is the per-user role map. |
| 164 |
// `sitemeta.site_admins` is the network admin list. `blogs.domain` and |
| 165 |
// `site.domain` are subsite and network hostnames. `signups` and |
| 166 |
// `registration_log` hold pending-registration emails. |
| 167 |
// |
| 168 |
// Only multisite sets the multisite entries (`ms_global_tables`). They |
| 169 |
// matter because `--all-tables` from the MAIN site resolves `SHOW |
| 170 |
// TABLES LIKE '{$wpdb->prefix}%'` with the bare base prefix. So the list |
| 171 |
// holds every global table. It also holds the tables of every OTHER |
| 172 |
// subsite. |
| 173 |
// |
| 174 |
// The match is case-insensitive. `SHOW TABLES` reports names as MySQL |
| 175 |
// stored them. A case mismatch here would disable the exclusion. It |
| 176 |
// would not fail loudly. |
| 177 |
// phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.user_meta__wpdb__users -- the table NAMES are read to exclude them from the walk; no user data is queried. |
| 178 |
$excluded_tables = array( strtolower( $wpdb->users ), strtolower( $wpdb->usermeta ) ); |
| 179 |
foreach ( array( 'sitemeta', 'blogs', 'site', 'signups', 'registration_log' ) as $ms_table ) { |
| 180 |
if ( ! empty( $wpdb->$ms_table ) ) { |
| 181 |
$excluded_tables[] = strtolower( $wpdb->$ms_table ); |
| 182 |
} |
| 183 |
} |
| 184 |
|
| 185 |
$report = array(); |
| 186 |
$total_changes = 0; |
| 187 |
$skipped_tables = array(); |
| 188 |
|
| 189 |
foreach ( (array) $tables as $table ) { |
| 190 |
if ( ! is_scalar( $table ) ) { |
| 191 |
continue; |
| 192 |
} |
| 193 |
$table = (string) $table; |
| 194 |
if ( '' === $table ) { |
| 195 |
continue; |
| 196 |
} |
| 197 |
if ( in_array( strtolower( $table ), $excluded_tables, true ) ) { |
| 198 |
$skipped_tables[] = $table; |
| 199 |
continue; |
| 200 |
} |
| 201 |
$primary_key = $this->table_primary_key( $table ); |
| 202 |
if ( null === $primary_key ) { |
| 203 |
continue; // Skip tables without a single-column PK. |
| 204 |
} |
| 205 |
$text_columns = $this->table_text_columns( $table ); |
| 206 |
if ( empty( $text_columns ) ) { |
| 207 |
continue; |
| 208 |
} |
| 209 |
foreach ( $text_columns as $column ) { |
| 210 |
if ( in_array( $column, $skip_columns, true ) ) { |
| 211 |
continue; |
| 212 |
} |
| 213 |
$changed = $this->search_replace_column( $table, $column, $primary_key, (string) $old, (string) $new, $dry_run ); |
| 214 |
if ( $changed > 0 ) { |
| 215 |
if ( ! isset( $report[ $table ] ) ) { |
| 216 |
$report[ $table ] = array(); |
| 217 |
} |
| 218 |
$report[ $table ][ $column ] = $changed; |
| 219 |
$total_changes += $changed; |
| 220 |
} |
| 221 |
} |
| 222 |
} |
| 223 |
|
| 224 |
// The walk writes cells with raw $wpdb->update(), which no cache layer |
| 225 |
// observes. With a persistent object cache the `alloptions`/`posts` |
| 226 |
// groups would keep serving pre-replace values, and a later legitimate |
| 227 |
// update_option() comparing against the stale pre-image could no-op or |
| 228 |
// resurrect it. One flush after the batch is the correct price for a |
| 229 |
// DB-level bulk rewrite (wp-cli's own search-replace tells users to |
| 230 |
// flush for the same reason). |
| 231 |
if ( ! $dry_run && $total_changes > 0 ) { |
| 232 |
wp_cache_flush(); |
| 233 |
} |
| 234 |
|
| 235 |
return Response::success( |
| 236 |
array( |
| 237 |
'dry_run' => $dry_run, |
| 238 |
'total_changes' => $total_changes, |
| 239 |
'tables_changed' => $report, |
| 240 |
'skipped_columns' => array_values( $skip_columns ), |
| 241 |
'skipped_tables' => $skipped_tables, |
| 242 |
) |
| 243 |
); |
| 244 |
} |
| 245 |
|
| 246 |
/** |
| 247 |
* Whether a table is an options table whose rows the protect-list covers. |
| 248 |
* |
| 249 |
* Matches the FAMILY, not just the current blog's table. `--all-tables` run |
| 250 |
* from a multisite MAIN site resolves `SHOW TABLES LIKE |
| 251 |
* '{$wpdb->prefix}%'` with the bare base prefix, so every OTHER subsite's |
| 252 |
* `{base}_{id}_options` is in the walk. Comparing against `$wpdb->options` |
| 253 |
* alone left those unfiltered — the same raw-`$wpdb->update()` bypass this |
| 254 |
* change exists to close, one prefix over. |
| 255 |
* |
| 256 |
* `base_prefix` equals `prefix` on single-site, so this is one code path for |
| 257 |
* both. The `\d+_` segment is what keeps a plugin's own |
| 258 |
* `{base}_myplugin_options` from matching. |
| 259 |
* |
| 260 |
* @param string $table Table name (with prefix). |
| 261 |
* @return bool |
| 262 |
*/ |
| 263 |
private function is_options_table( string $table ): bool { |
| 264 |
global $wpdb; |
| 265 |
/** |
| 266 |
* WordPress database access layer. |
| 267 |
* |
| 268 |
* @var \wpdb $wpdb |
| 269 |
*/ |
| 270 |
return 1 === preg_match( |
| 271 |
'/^' . preg_quote( $wpdb->base_prefix, '/' ) . '(\d+_)?options$/i', |
| 272 |
$table |
| 273 |
); |
| 274 |
} |
| 275 |
|
| 276 |
/** |
| 277 |
* Return the single-column primary key of a table, or null when the table |
| 278 |
* has a composite or no primary key. |
| 279 |
* |
| 280 |
* @param string $table Fully-qualified table name (with prefix). |
| 281 |
* @return string|null |
| 282 |
*/ |
| 283 |
private function table_primary_key( string $table ): ?string { |
| 284 |
global $wpdb; |
| 285 |
/** |
| 286 |
* WordPress database access layer. |
| 287 |
* |
| 288 |
* @var \wpdb $wpdb |
| 289 |
*/ |
| 290 |
// `SHOW KEYS` cannot use placeholders for identifiers, but the table |
| 291 |
// name has been resolved from $wpdb->tables() / SHOW TABLES — never |
| 292 |
// from user input — and is backtick-quoted defensively. |
| 293 |
$safe_table = str_replace( '`', '', $table ); |
| 294 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 295 |
$rows = $wpdb->get_results( "SHOW KEYS FROM `{$safe_table}` WHERE Key_name = 'PRIMARY'", ARRAY_A ); |
| 296 |
if ( ! is_array( $rows ) || 1 !== count( $rows ) ) { |
| 297 |
return null; |
| 298 |
} |
| 299 |
$first = $rows[0]; |
| 300 |
$col_raw = $first['Column_name'] ?? ''; |
| 301 |
$col = is_scalar( $col_raw ) ? (string) $col_raw : ''; |
| 302 |
return '' === $col ? null : $col; |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Return the names of text-typed columns in a table (char/varchar/text |
| 307 |
* family). Only these columns can hold the kind of values search-replace |
| 308 |
* needs to touch. |
| 309 |
* |
| 310 |
* @param string $table Table name (with prefix). |
| 311 |
* @return string[] |
| 312 |
*/ |
| 313 |
private function table_text_columns( string $table ): array { |
| 314 |
global $wpdb; |
| 315 |
/** |
| 316 |
* WordPress database access layer. |
| 317 |
* |
| 318 |
* @var \wpdb $wpdb |
| 319 |
*/ |
| 320 |
$safe_table = str_replace( '`', '', $table ); |
| 321 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 322 |
$rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$safe_table}`", ARRAY_A ); |
| 323 |
if ( ! is_array( $rows ) ) { |
| 324 |
return array(); |
| 325 |
} |
| 326 |
$out = array(); |
| 327 |
foreach ( $rows as $row ) { |
| 328 |
$type_raw = $row['Type'] ?? ''; |
| 329 |
$type = is_string( $type_raw ) ? strtolower( $type_raw ) : ''; |
| 330 |
$field_raw = $row['Field'] ?? ''; |
| 331 |
if ( preg_match( '/^(char|varchar|tinytext|text|mediumtext|longtext|enum|set)/', $type ) && is_scalar( $field_raw ) ) { |
| 332 |
$out[] = (string) $field_raw; |
| 333 |
} |
| 334 |
} |
| 335 |
return $out; |
| 336 |
} |
| 337 |
|
| 338 |
/** |
| 339 |
* Replace occurrences of $old with $new in one column of one table. |
| 340 |
* |
| 341 |
* The scan is keyed by primary-key list rather than offset paging so |
| 342 |
* concurrent UPDATEs that move rows out of the LIKE result set don't |
| 343 |
* shift the cursor. |
| 344 |
* |
| 345 |
* @param string $table Table name. |
| 346 |
* @param string $column Column name. |
| 347 |
* @param string $primary_key Primary-key column. |
| 348 |
* @param string $old Search string. |
| 349 |
* @param string $new Replacement string. |
| 350 |
* @param bool $dry_run When true, count matches without writing. |
| 351 |
* @return int Number of rows whose value changed. |
| 352 |
*/ |
| 353 |
private function search_replace_column( string $table, string $column, string $primary_key, string $old, string $new, bool $dry_run ): int { |
| 354 |
global $wpdb; |
| 355 |
/** |
| 356 |
* WordPress database access layer. |
| 357 |
* |
| 358 |
* @var \wpdb $wpdb |
| 359 |
*/ |
| 360 |
$safe_table = str_replace( '`', '', $table ); |
| 361 |
$safe_column = str_replace( '`', '', $column ); |
| 362 |
$safe_pk = str_replace( '`', '', $primary_key ); |
| 363 |
$like = '%' . $wpdb->esc_like( $old ) . '%'; |
| 364 |
|
| 365 |
// Protected options are excluded at row-selection, the single choke |
| 366 |
// point every write below passes through. `Protected_Options_Filter` |
| 367 |
// cannot cover this engine — it hooks `pre_update_option_<key>` and we |
| 368 |
// write the cell with raw `$wpdb->update()`, so the bypass was total |
| 369 |
// and silent (DSA-15): `search-replace admin@old attacker@evil` rewrote |
| 370 |
// `admin_email` with no refusal and no log line. |
| 371 |
$protected_where = ''; |
| 372 |
$protected_bindings = array(); |
| 373 |
if ( $this->is_options_table( $table ) ) { |
| 374 |
$protected_bindings = Protected_Options_Filter::write_protected_keys(); |
| 375 |
if ( ! empty( $protected_bindings ) ) { |
| 376 |
$protected_where = ' AND option_name NOT IN (' |
| 377 |
. implode( ',', array_fill( 0, count( $protected_bindings ), '%s' ) ) |
| 378 |
. ')'; |
| 379 |
} |
| 380 |
// `write_protected_keys()` can only name the CURRENT blog's role map |
| 381 |
// (`{$wpdb->prefix}user_roles`), but on multisite this table may |
| 382 |
// belong to another subsite, whose map is `{base}_{id}_user_roles`. |
| 383 |
// Match the suffix instead — the same rule applied server-side for |
| 384 |
// the same reason. Refuses an unrelated `*_user_roles` option too, |
| 385 |
// which is the safe direction. |
| 386 |
$protected_where .= ' AND option_name NOT LIKE %s'; |
| 387 |
$protected_bindings[] = '%' . $wpdb->esc_like( 'user_roles' ); |
| 388 |
} |
| 389 |
|
| 390 |
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- identifiers bind via %i; the concatenated {$protected_where} is a controlled list of %s tokens; the statement is fully prepared. |
| 391 |
$pk_sql = $wpdb->prepare( |
| 392 |
// Identifiers bind via %i (backtick-quoted by wpdb); values bind via %s. |
| 393 |
'SELECT %i FROM %i WHERE %i LIKE %s' . $protected_where, |
| 394 |
array_merge( array( $safe_pk, $safe_table, $safe_column, $like ), $protected_bindings ) |
| 395 |
); |
| 396 |
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared |
| 397 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared |
| 398 |
$pks = $wpdb->get_col( $pk_sql ); |
| 399 |
if ( empty( $pks ) ) { |
| 400 |
return 0; |
| 401 |
} |
| 402 |
|
| 403 |
$changes = 0; |
| 404 |
$batch_size = 200; |
| 405 |
|
| 406 |
foreach ( array_chunk( $pks, $batch_size ) as $chunk ) { |
| 407 |
$placeholders = implode( ',', array_fill( 0, count( $chunk ), '%s' ) ); |
| 408 |
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifiers bind via %i; the interpolated {$placeholders} is a controlled list of %s tokens; the statement is fully prepared. |
| 409 |
$batch_sql = $wpdb->prepare( |
| 410 |
"SELECT %i AS pk, %i AS val FROM %i WHERE %i IN ({$placeholders})", |
| 411 |
array_merge( array( $safe_pk, $safe_column, $safe_table, $safe_pk ), $chunk ) |
| 412 |
); |
| 413 |
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 414 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared |
| 415 |
$rows = $wpdb->get_results( $batch_sql ); |
| 416 |
if ( ! is_array( $rows ) ) { |
| 417 |
continue; |
| 418 |
} |
| 419 |
foreach ( $rows as $row ) { |
| 420 |
$val_raw = $row->val ?? ''; |
| 421 |
$original = is_scalar( $val_raw ) ? (string) $val_raw : ''; |
| 422 |
$replaced = $this->replace_recursively( $original, $old, $new ); |
| 423 |
if ( ! is_string( $replaced ) || $replaced === $original ) { |
| 424 |
continue; |
| 425 |
} |
| 426 |
if ( $dry_run ) { |
| 427 |
++$changes; |
| 428 |
continue; |
| 429 |
} |
| 430 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 431 |
$updated = $wpdb->update( |
| 432 |
$table, |
| 433 |
array( $column => $replaced ), |
| 434 |
array( $primary_key => $row->pk ) |
| 435 |
); |
| 436 |
// wpdb::update() returns the number of rows changed, 0 when the row |
| 437 |
// no longer matched (e.g. a concurrent writer), or false on a DB |
| 438 |
// error. Count only rows that actually changed so the reported |
| 439 |
// total never over-reports persisted writes. |
| 440 |
if ( $updated > 0 ) { |
| 441 |
++$changes; |
| 442 |
} |
| 443 |
} |
| 444 |
} |
| 445 |
|
| 446 |
return $changes; |
| 447 |
} |
| 448 |
|
| 449 |
/** |
| 450 |
* Replace $old with $new inside $data, descending through serialized |
| 451 |
* payloads (arrays / objects / nested serialized strings). |
| 452 |
* |
| 453 |
* Returns a string in all branches so the caller can compare against the |
| 454 |
* original cell value verbatim. Serialized inputs are returned as |
| 455 |
* serialized strings; plain strings as plain strings. |
| 456 |
* |
| 457 |
* `unserialize` is called with `allowed_classes => false` so a poisoned |
| 458 |
* payload cannot instantiate plugin/theme classes during the walk. |
| 459 |
* |
| 460 |
* @param mixed $data Value to recurse into. |
| 461 |
* @param string $old Search string. |
| 462 |
* @param string $new Replacement string. |
| 463 |
* @param int $depth Recursion guard. |
| 464 |
* @return mixed |
| 465 |
*/ |
| 466 |
private function replace_recursively( $data, string $old, string $new, int $depth = 0 ) { |
| 467 |
if ( $depth > 50 ) { |
| 468 |
return $data; |
| 469 |
} |
| 470 |
if ( is_string( $data ) ) { |
| 471 |
if ( is_serialized( $data ) ) { |
| 472 |
$unserialized = @unserialize( $data, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged -- round-trips WP's own serialized DB values; allowed_classes=false is safer than maybe_unserialize, which instantiates objects. |
| 473 |
if ( false !== $unserialized || 'b:0;' === $data ) { |
| 474 |
$replaced = $this->replace_recursively( $unserialized, $old, $new, $depth + 1 ); |
| 475 |
return serialize( $replaced ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- re-serialize to preserve WP's stored format after in-place search-replace. |
| 476 |
} |
| 477 |
// The cell LOOKS serialized but does not round-trip (truncated |
| 478 |
// value, or a `C:`-format Serializable payload is_serialized() |
| 479 |
// cannot classify). A byte-level replace here would break the |
| 480 |
// length prefixes and destroy the cell permanently — the exact |
| 481 |
// corruption this walker exists to prevent. Skip the cell |
| 482 |
// (returning it unchanged means the caller writes nothing). |
| 483 |
return $data; |
| 484 |
} |
| 485 |
return str_replace( $old, $new, $data ); |
| 486 |
} |
| 487 |
if ( is_array( $data ) ) { |
| 488 |
foreach ( $data as $k => $v ) { |
| 489 |
$data[ $k ] = $this->replace_recursively( $v, $old, $new, $depth + 1 ); |
| 490 |
} |
| 491 |
return $data; |
| 492 |
} |
| 493 |
if ( is_object( $data ) ) { |
| 494 |
// Only stdClass round-trips safely. With allowed_classes=false a |
| 495 |
// class'd payload arrives as __PHP_Incomplete_Class whose private/ |
| 496 |
// protected properties keep their NUL-mangled names ("\0Class\0prop"); |
| 497 |
// assigning through those throws, and re-serializing the incomplete |
| 498 |
// class stamps the wrong class name. Leave such cells unchanged. |
| 499 |
if ( ! ( $data instanceof \stdClass ) ) { |
| 500 |
return $data; |
| 501 |
} |
| 502 |
$clone = clone $data; |
| 503 |
foreach ( get_object_vars( $clone ) as $k => $v ) { |
| 504 |
$clone->$k = $this->replace_recursively( $v, $old, $new, $depth + 1 ); |
| 505 |
} |
| 506 |
return $clone; |
| 507 |
} |
| 508 |
return $data; |
| 509 |
} |
| 510 |
} |
| 511 |
|