PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / core / search-replace-engine-trait.php

search-replace-engine-trait.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/abilities/core/search-replace-engine-trait.php

511 lines 21.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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