PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-database-prefix.php +473 -83 2.9.52.11.8 View file →
@@ -11,10 +11,8 @@
11 11 if ( ! defined( 'ABSPATH' ) ) {
12 12 exit;
13 13 }
14 14
15 -// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter
16 -
17 15 /**
18 16 * Class Vigilante_Database_Prefix
19 17 *
20 18 * Changes the WordPress database prefix safely
@@ -53,14 +51,49 @@
53 51 * Constructor
54 52 */
55 53 public function __construct() {
56 54 global $wpdb;
57 - $this->wpdb = $wpdb;
58 - $this->old_prefix = $wpdb->prefix;
55 + $this->wpdb = $wpdb;
56 + // Always the network-wide base prefix. On a single site it is identical to
57 + // $wpdb->prefix; on multisite $wpdb->prefix is the prefix of the *current*
58 + // blog (wp_3_), and using it would rename only that subsite's tables while
59 + // rewriting the $table_prefix shared by the whole network.
60 + $this->old_prefix = $wpdb->base_prefix;
59 61 $this->wpconfig_path = $this->find_wpconfig_path();
60 62 }
61 63
62 64 /**
65 + * Whether the prefix may be changed from the current context
66 + *
67 + * The prefix lives in wp-config.php, which a multisite network shares across
68 + * every site, so changing it is a network-wide operation: only a network
69 + * administrator working from the main site may run it.
70 + *
71 + * @return true|WP_Error
72 + */
73 + public function can_change_prefix() {
74 + if ( ! is_multisite() ) {
75 + return true;
76 + }
77 +
78 + if ( ! is_main_site() || ! is_main_network() ) {
79 + return new WP_Error(
80 + 'multisite_not_main_site',
81 + __( 'The database prefix is shared by the whole network. Change it from the main site of the network.', 'vigilante' )
82 + );
83 + }
84 +
85 + if ( ! Vigilante_Settings::can_write_shared_files() ) {
86 + return new WP_Error(
87 + 'multisite_not_network_admin',
88 + __( 'Only a network administrator can change the database prefix of a multisite network.', 'vigilante' )
89 + );
90 + }
91 +
92 + return true;
93 + }
94 +
95 + /**
63 96 * Get the current database prefix
64 97 *
65 98 * @return string
66 99 */
@@ -159,15 +192,21 @@
159 192 */
160 193 public function change_prefix( $new_prefix ) {
161 194 $this->new_prefix = $new_prefix;
162 195
163 - // Step 1: Validate
196 + // Step 1: Check the context is allowed to change a network-wide setting
197 + $allowed = $this->can_change_prefix();
198 + if ( is_wp_error( $allowed ) ) {
199 + return $allowed;
200 + }
201 +
202 + // Step 2: Validate
164 203 $valid = $this->validate_prefix( $new_prefix );
165 204 if ( is_wp_error( $valid ) ) {
166 205 return $valid;
167 206 }
168 207
169 - // Step 2: Check wp-config.php is writable
208 + // Step 3: Check wp-config.php is writable
170 209 if ( ! $this->wpconfig_path ) {
171 210 return new WP_Error( 'wpconfig_not_found', __( 'Cannot locate wp-config.php file.', 'vigilante' ) );
172 211 }
173 212
@@ -175,43 +214,116 @@
175 214 if ( ! is_writable( $this->wpconfig_path ) ) {
176 215 return new WP_Error( 'wpconfig_not_writable', __( 'wp-config.php is not writable. Check file permissions.', 'vigilante' ) );
177 216 }
178 217
179 - // Step 3: Get all tables with current prefix
218 + // Step 4: Map every site of the install to its old and new prefix.
219 + // Has to happen before the rename: once the blogs table moves, neither
220 + // get_sites() nor $wpdb->blogs can resolve the list any more.
221 + $sites = $this->get_site_prefix_map();
222 + if ( is_wp_error( $sites ) ) {
223 + return $sites;
224 + }
225 +
226 + // Step 5: Get all tables with current prefix
180 227 $tables = $this->get_prefixed_tables();
181 228 if ( empty( $tables ) ) {
182 229 return new WP_Error( 'no_tables', __( 'No tables found with the current prefix.', 'vigilante' ) );
183 230 }
184 231
185 - // Step 4: Rename all tables
232 + // Step 6: Rename all tables
186 233 $rename_result = $this->rename_tables( $tables );
187 234 if ( is_wp_error( $rename_result ) ) {
188 235 return $rename_result;
189 236 }
190 237
191 - // Step 5: Update wp-config.php
238 + // Step 7: Point wpdb at the new table names, so the remainder of this
239 + // request (option rewrites, activity log entry) still has a database.
240 + $this->wpdb->set_prefix( $this->new_prefix );
241 +
242 + // Step 8: Update wp-config.php
192 243 $config_result = $this->update_wpconfig();
193 244 if ( is_wp_error( $config_result ) ) {
194 245 // Rollback table renames
195 246 $this->rollback_tables( $tables );
247 + $this->wpdb->set_prefix( $this->old_prefix );
196 248 return $config_result;
197 249 }
198 250
199 - // Step 6: Update options table references (user_roles, etc.)
200 - $this->update_options_prefix();
251 + // Step 9: Update the option names WordPress derives from the prefix,
252 + // in the options table of every site of the install
253 + $this->update_options_prefix( $sites );
201 254
202 - // Step 7: Update usermeta prefix keys
203 - $this->update_usermeta_prefix();
255 + // Step 10: Update usermeta prefix keys
256 + $this->update_usermeta_prefix( $sites );
204 257
258 + // Step 11: Drop cached copies of everything that was renamed
259 + $this->flush_caches();
260 +
205 261 return true;
206 262 }
207 263
208 264 /**
265 + * Build the old/new prefix map for every site of the install
266 + *
267 + * A single site returns one entry with no blog segment. A network returns one
268 + * entry per row of the blogs table: blog 1 uses the bare base prefix and every
269 + * other blog the {base}{id}_ form, matching wpdb::get_blog_prefix().
270 + *
271 + * Must run before the tables are renamed.
272 + *
273 + * @return array|WP_Error
274 + */
275 + private function get_site_prefix_map() {
276 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name comes from the validated prefix map and all values use placeholders.
277 + if ( ! is_multisite() ) {
278 + return array(
279 + array(
280 + 'blog_id' => 1,
281 + 'is_main' => true,
282 + 'old_prefix' => $this->old_prefix,
283 + 'new_prefix' => $this->new_prefix,
284 + ),
285 + );
286 + }
287 +
288 + $blogs_table = $this->old_prefix . 'blogs';
289 +
290 + if ( ! $this->table_exists( $blogs_table ) ) {
291 + return new WP_Error( 'no_blogs_table', __( 'Cannot find the network sites table.', 'vigilante' ) );
292 + }
293 +
294 + $blog_ids = $this->wpdb->get_col( "SELECT blog_id FROM `{$blogs_table}` ORDER BY blog_id ASC" );
295 +
296 + if ( empty( $blog_ids ) ) {
297 + return new WP_Error( 'no_sites', __( 'Cannot read the list of sites of the network.', 'vigilante' ) );
298 + }
299 +
300 + $map = array();
301 +
302 + foreach ( $blog_ids as $blog_id ) {
303 + $blog_id = (int) $blog_id;
304 + // wpdb::get_blog_prefix() treats blog 1 (and 0) as the base prefix.
305 + $segment = ( $blog_id > 1 ) ? $blog_id . '_' : '';
306 +
307 + $map[] = array(
308 + 'blog_id' => $blog_id,
309 + 'is_main' => ( '' === $segment ),
310 + 'old_prefix' => $this->old_prefix . $segment,
311 + 'new_prefix' => $this->new_prefix . $segment,
312 + );
313 + }
314 +
315 + return $map;
316 + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
317 + }
318 +
319 + /**
209 320 * Get all tables with the current prefix
210 321 *
211 322 * @return array Table names.
212 323 */
213 324 private function get_prefixed_tables() {
325 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
214 326 return $this->wpdb->get_col(
215 327 $this->wpdb->prepare(
216 328 'SHOW TABLES LIKE %s',
217 329 $this->wpdb->esc_like( $this->old_prefix ) . '%'
@@ -216,8 +328,9 @@
216 328 'SHOW TABLES LIKE %s',
217 329 $this->wpdb->esc_like( $this->old_prefix ) . '%'
218 330 )
219 331 );
332 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
220 333 }
221 334
222 335 /**
223 336 * Check if tables exist with a given prefix
@@ -225,8 +338,9 @@
225 338 * @param string $prefix Prefix to check.
226 339 * @return bool
227 340 */
228 341 private function prefix_tables_exist( $prefix ) {
342 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
229 343 $result = $this->wpdb->get_var(
230 344 $this->wpdb->prepare(
231 345 'SHOW TABLES LIKE %s',
232 346 $this->wpdb->esc_like( $prefix ) . '%'
@@ -233,8 +347,9 @@
233 347 )
234 348 );
235 349
236 350 return ! empty( $result );
351 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
237 352 }
238 353
239 354 /**
240 355 * Rename all tables from old prefix to new prefix
@@ -242,8 +357,9 @@
242 357 * @param array $tables List of table names.
243 358 * @return true|WP_Error
244 359 */
245 360 private function rename_tables( $tables ) {
361 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
246 362 $renamed = array();
247 363
248 364 foreach ( $tables as $old_name ) {
249 365 $new_name = $this->new_prefix . substr( $old_name, strlen( $this->old_prefix ) );
@@ -282,8 +398,9 @@
282 398 $renamed[ $new_name ] = $old_name;
283 399 }
284 400
285 401 return true;
402 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
286 403 }
287 404
288 405 /**
289 406 * Rollback table renames
@@ -290,15 +407,14 @@
290 407 *
291 408 * @param array $original_tables Original table names.
292 409 */
293 410 private function rollback_tables( $original_tables ) {
411 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
294 412 foreach ( $original_tables as $old_name ) {
295 413 $new_name = $this->new_prefix . substr( $old_name, strlen( $this->old_prefix ) );
296 414
297 415 // Check if new name exists (it was renamed)
298 - $exists = $this->wpdb->get_var(
299 - $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $new_name )
300 - );
416 + $exists = $this->table_exists( $new_name );
301 417
302 418 if ( $exists ) {
303 419 $this->wpdb->query(
304 420 $this->wpdb->prepare(
@@ -308,8 +424,9 @@
308 424 )
309 425 );
310 426 }
311 427 }
428 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
312 429 }
313 430
314 431 /**
315 432 * Update $table_prefix in wp-config.php
@@ -323,14 +440,24 @@
323 440 if ( false === $content ) {
324 441 return new WP_Error( 'read_error', __( 'Cannot read wp-config.php.', 'vigilante' ) );
325 442 }
326 443
327 - // Back up the original file
328 - $backup_path = $this->wpconfig_path . '.vigilante-backup-' . gmdate( 'YmdHis' );
329 - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
330 - if ( ! file_put_contents( $backup_path, $content ) ) {
331 - return new WP_Error( 'backup_error', __( 'Cannot create wp-config.php backup.', 'vigilante' ) );
332 - }
444 + /*
445 + * The original file is held in $content, in memory, and that is the
446 + * whole backup this needs.
447 + *
448 + * Until 2.11.3 it also wrote a copy next to wp-config.php, named
449 + * wp-config.php.vigilante-backup-<timestamp>. On a standard install
450 + * that is the site root, the name is a predictable timestamp and it
451 + * carries no .php extension, so a server hands it over as plain text
452 + * with the database credentials and the eight salts inside. It was
453 + * deleted straight after, but a request that died in between left it
454 + * there for good, and that is exactly the moment when the owner is busy
455 + * with a site that will not load. Present since 1.2.0.
456 + *
457 + * Nothing is lost by removing it: that file was never read back. The
458 + * restore below, the only path that undoes anything, uses $content.
459 + */
333 460
334 461 // Match the $table_prefix line (handles single and double quotes, with/without spaces)
335 462 $pattern = '/(\$table_prefix\s*=\s*)([\'"]).+?\\2(\s*;)/';
336 463 $replacement = '${1}\'' . $this->new_prefix . '\'${3}';
@@ -337,10 +464,8 @@
337 464
338 465 $new_content = preg_replace( $pattern, $replacement, $content, 1, $count );
339 466
340 467 if ( 0 === $count || null === $new_content ) {
341 - // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
342 - unlink( $backup_path );
343 468 return new WP_Error( 'replace_error', __( 'Cannot find $table_prefix in wp-config.php.', 'vigilante' ) );
344 469 }
345 470
346 471 // Write updated content
@@ -347,99 +472,364 @@
347 472 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
348 473 $result = file_put_contents( $this->wpconfig_path, $new_content );
349 474
350 475 if ( false === $result ) {
351 - // Restore backup
476 + // Restore from memory, which is where the original has been all along.
352 477 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
353 478 file_put_contents( $this->wpconfig_path, $content );
354 - // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
355 - unlink( $backup_path );
356 479 return new WP_Error( 'write_error', __( 'Cannot write to wp-config.php.', 'vigilante' ) );
357 480 }
358 481
359 - // Clean up backup after successful write
360 - // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
361 - unlink( $backup_path );
482 + $this->invalidate_wpconfig_opcode_cache();
362 483
363 484 return true;
364 485 }
365 486
366 487 /**
367 - * Update option names that contain the old prefix
488 + * Drop the compiled copy of wp-config.php from the opcode cache
368 489 *
369 - * WordPress stores some options with the prefix in their name:
370 - * - {prefix}user_roles
490 + * wp-config.php is PHP, so OPcache keeps serving the compiled old
491 + * $table_prefix for up to opcache.revalidate_freq seconds after the file is
492 + * rewritten. Any request landing in that window boots WordPress against
493 + * tables that no longer exist: it cannot read siteurl, decides the site is
494 + * not installed and redirects to install.php, and the missing users table
495 + * makes the auth cookie fail validation, which core answers by clearing it.
496 + * The visitor is thrown out of the session and offered the installer, and it
497 + * fixes itself a couple of seconds later, which makes it look like a ghost.
498 + *
499 + * With PHP-FPM the opcode cache is shared by the whole pool, so invalidating
500 + * it here covers every worker.
371 501 */
372 - private function update_options_prefix() {
373 - $options_table = $this->new_prefix . 'options';
502 + private function invalidate_wpconfig_opcode_cache() {
503 + if ( ! $this->wpconfig_path ) {
504 + return;
505 + }
374 506
375 - // Find and update options that start with old prefix
376 - $old_like = $this->wpdb->esc_like( $this->old_prefix ) . '%';
507 + clearstatcache( true, $this->wpconfig_path );
377 508
378 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
379 - $options = $this->wpdb->get_results(
380 - $this->wpdb->prepare(
381 - "SELECT option_id, option_name FROM `{$options_table}` WHERE option_name LIKE %s",
382 - $old_like
509 + if ( ! function_exists( 'opcache_invalidate' ) ) {
510 + return;
511 + }
512 +
513 + if ( ! filter_var( ini_get( 'opcache.enable' ), FILTER_VALIDATE_BOOLEAN ) ) {
514 + return;
515 + }
516 +
517 + // Silenced on purpose: with opcache.restrict_api set to a path this file
518 + // is not under, the call is refused with a warning and there is nothing
519 + // to do about it. Losing the invalidation is survivable, a warning in
520 + // the log of every site that hardens the API is not.
521 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
522 + @opcache_invalidate( $this->wpconfig_path, true );
523 + }
524 +
525 + /**
526 + * Rename the option names WordPress derives from the table prefix
527 + *
528 + * WordPress builds exactly one option name out of the prefix:
529 + * {$blog_prefix}user_roles (see WP_Roles::for_site()). Every other option
530 + * that merely starts with the same letters is a literal name owned by core
531 + * (wp_page_for_privacy_policy, wp_notes_notify, wp_attachment_pages_enabled,
532 + * wp_force_deactivated_plugins) or by a plugin (wp_rocket_settings,
533 + * wp_installer_settings) and renaming it silently destroys that setting.
534 + *
535 + * On multisite each site keeps its roles in its own options table, so every
536 + * site of the network is visited, not just the main one. A subsite whose
537 + * {prefix}{id}_user_roles is left behind ends up with zero roles: an empty
538 + * role dropdown and fatals in plugins that assume a role exists.
539 + *
540 + * @param array $sites Prefix map from get_site_prefix_map().
541 + */
542 + private function update_options_prefix( $sites ) {
543 + /**
544 + * Filters the option name suffixes that are derived from the table prefix.
545 + *
546 + * Only add suffixes for options a plugin stores as
547 + * $wpdb->get_blog_prefix() . 'suffix'. Anything whose name merely starts
548 + * with the prefix letters must NOT be listed here.
549 + *
550 + * @since 2.9.8
551 + *
552 + * @param string[] $suffixes Suffixes appended to the blog prefix.
553 + */
554 + $suffixes = apply_filters( 'vigilante_prefixed_option_suffixes', array( 'user_roles' ) );
555 +
556 + foreach ( $sites as $site ) {
557 + $table = $site['new_prefix'] . 'options';
558 +
559 + if ( ! $this->table_exists( $table ) ) {
560 + continue;
561 + }
562 +
563 + // In a subsite's own options table anything named {base}{id}_* is
564 + // prefix-derived by construction: no plugin calls an option "wp_3_...".
565 + if ( empty( $site['is_main'] ) ) {
566 + $this->bulk_rename_options( $table, $site['old_prefix'], $site['new_prefix'] );
567 + }
568 +
569 + foreach ( $suffixes as $suffix ) {
570 + $this->rename_option( $table, $site['old_prefix'] . $suffix, $site['new_prefix'] . $suffix );
571 + }
572 + }
573 +
574 + $this->repair_subsite_user_roles( $sites );
575 + }
576 +
577 + /**
578 + * Last-resort repair for subsites whose roles option has an unexpected name
579 + *
580 + * A subsite that was cloned from another install, or migrated by a tool that
581 + * did not rewrite the option, can hold its roles under the bare base prefix
582 + * ({base}user_roles) inside its own options table. WordPress looks for
583 + * {base}{id}_user_roles, finds nothing and the site is left with no roles at
584 + * all. Runs only when the correct name is missing, so it never overwrites
585 + * roles that are already in place.
586 + *
587 + * @param array $sites Prefix map from get_site_prefix_map().
588 + */
589 + private function repair_subsite_user_roles( $sites ) {
590 + foreach ( $sites as $site ) {
591 + if ( ! empty( $site['is_main'] ) ) {
592 + continue;
593 + }
594 +
595 + $table = $site['new_prefix'] . 'options';
596 +
597 + if ( ! $this->table_exists( $table ) ) {
598 + continue;
599 + }
600 +
601 + $correct = $site['new_prefix'] . 'user_roles';
602 +
603 + if ( $this->option_exists( $table, $correct ) ) {
604 + continue;
605 + }
606 +
607 + $candidates = array(
608 + $this->old_prefix . 'user_roles',
609 + $this->new_prefix . 'user_roles',
610 + );
611 +
612 + foreach ( $candidates as $candidate ) {
613 + if ( $this->rename_option( $table, $candidate, $correct ) ) {
614 + break;
615 + }
616 + }
617 + }
618 + }
619 +
620 + /**
621 + * Update usermeta keys that contain the old prefix
622 + *
623 + * WordPress stores per-site user meta with the blog prefix in the key
624 + * ({prefix}capabilities, {prefix}user_level, and everything written through
625 + * update_user_option()). Keys that merely start with the same letters
626 + * (wp_sensei_*, wp_language_pairs) belong to a plugin and are left alone.
627 + *
628 + * For a subsite the {base}{id}_ form cannot collide with a literal key, so
629 + * everything carrying it is renamed. For the main site, where the prefix is
630 + * bare, only the known core keys are touched.
631 + *
632 + * @param array $sites Prefix map from get_site_prefix_map().
633 + */
634 + private function update_usermeta_prefix( $sites ) {
635 + $usermeta_table = $this->new_prefix . 'usermeta';
636 +
637 + if ( ! $this->table_exists( $usermeta_table ) ) {
638 + return;
639 + }
640 +
641 + /**
642 + * Filters the usermeta key suffixes that are derived from the table prefix.
643 + *
644 + * These are the keys core stores as $wpdb->get_blog_prefix() . 'suffix'.
645 + * Add a suffix here for a plugin that stores per-site user meta through
646 + * update_user_option() and needs it carried over on the main site.
647 + *
648 + * @since 2.9.8
649 + *
650 + * @param string[] $suffixes Suffixes appended to the blog prefix.
651 + */
652 + $suffixes = apply_filters(
653 + 'vigilante_prefixed_usermeta_suffixes',
654 + array(
655 + 'capabilities',
656 + 'user_level',
657 + 'user-settings',
658 + 'user-settings-time',
659 + 'dashboard_quick_press_last_post_id',
660 + 'media_library_mode',
661 + 'persisted_preferences',
383 662 )
384 663 );
385 664
386 - if ( $options ) {
387 - foreach ( $options as $option ) {
388 - $new_option_name = $this->new_prefix . substr( $option->option_name, strlen( $this->old_prefix ) );
665 + foreach ( $sites as $site ) {
666 + if ( empty( $site['is_main'] ) ) {
667 + $this->bulk_rename_usermeta( $usermeta_table, $site['old_prefix'], $site['new_prefix'] );
668 + continue;
669 + }
389 670
390 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
391 - $this->wpdb->update(
392 - $options_table,
393 - array( 'option_name' => $new_option_name ),
394 - array( 'option_id' => $option->option_id ),
395 - array( '%s' ),
396 - array( '%d' )
397 - );
671 + foreach ( $suffixes as $suffix ) {
672 + $this->rename_usermeta( $usermeta_table, $site['old_prefix'] . $suffix, $site['new_prefix'] . $suffix );
398 673 }
399 674 }
400 675 }
401 676
402 677 /**
403 - * Update usermeta keys that contain the old prefix
678 + * Rename every option in a table whose name starts with a given prefix
404 679 *
405 - * WordPress stores some usermeta with the prefix in the key:
406 - * - {prefix}capabilities
407 - * - {prefix}user_level
408 - * - {prefix}dashboard_quick_press_last_post_id
409 - * - {prefix}user-settings
410 - * - {prefix}user-settings-time
680 + * Only ever called with a subsite prefix ({base}{id}_), where the prefix
681 + * cannot appear at the start of a literal option name.
682 + *
683 + * @param string $table Options table name.
684 + * @param string $old_prefix Prefix to strip.
685 + * @param string $new_prefix Prefix to write.
411 686 */
412 - private function update_usermeta_prefix() {
413 - $usermeta_table = $this->new_prefix . 'usermeta';
687 + private function bulk_rename_options( $table, $old_prefix, $new_prefix ) {
688 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare(), and the table name comes from the validated prefix map and all values use placeholders.
689 + $this->wpdb->query(
690 + $this->wpdb->prepare(
691 + "UPDATE `{$table}` SET option_name = CONCAT( %s, SUBSTRING( option_name, %d ) ) WHERE option_name LIKE %s",
692 + $new_prefix,
693 + strlen( $old_prefix ) + 1,
694 + $this->wpdb->esc_like( $old_prefix ) . '%'
695 + )
696 + );
697 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
698 + }
414 699
415 - $old_like = $this->wpdb->esc_like( $this->old_prefix ) . '%';
700 + /**
701 + * Rename every usermeta key that starts with a given prefix
702 + *
703 + * Only ever called with a subsite prefix ({base}{id}_), where the prefix
704 + * cannot appear at the start of a literal meta key.
705 + *
706 + * @param string $table Usermeta table name.
707 + * @param string $old_prefix Prefix to strip.
708 + * @param string $new_prefix Prefix to write.
709 + */
710 + private function bulk_rename_usermeta( $table, $old_prefix, $new_prefix ) {
711 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare(), and the table name comes from the validated prefix map and all values use placeholders.
712 + // Prefix migration has to rewrite meta_key values by definition, so the
713 + // slow-query rule does not apply here.
714 + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
715 + $this->wpdb->query(
716 + $this->wpdb->prepare(
717 + "UPDATE `{$table}` SET meta_key = CONCAT( %s, SUBSTRING( meta_key, %d ) ) WHERE meta_key LIKE %s",
718 + $new_prefix,
719 + strlen( $old_prefix ) + 1,
720 + $this->wpdb->esc_like( $old_prefix ) . '%'
721 + )
722 + );
723 + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
724 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
725 + }
416 726
417 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
418 - $metas = $this->wpdb->get_results(
727 + /**
728 + * Rename a single option, without ever overwriting an existing one
729 + *
730 + * @param string $table Options table name.
731 + * @param string $old_name Current option name.
732 + * @param string $new_name Wanted option name.
733 + * @return bool Whether the option was renamed.
734 + */
735 + private function rename_option( $table, $old_name, $new_name ) {
736 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare(), and the table name comes from the validated prefix map and all values use placeholders.
737 + if ( $old_name === $new_name || $this->option_exists( $table, $new_name ) ) {
738 + return false;
739 + }
740 +
741 + $option_id = $this->wpdb->get_var(
742 + $this->wpdb->prepare( "SELECT option_id FROM `{$table}` WHERE option_name = %s LIMIT 1", $old_name )
743 + );
744 +
745 + if ( ! $option_id ) {
746 + return false;
747 + }
748 +
749 + return (bool) $this->wpdb->update(
750 + $table,
751 + array( 'option_name' => $new_name ),
752 + array( 'option_id' => (int) $option_id ),
753 + array( '%s' ),
754 + array( '%d' )
755 + );
756 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
757 + }
758 +
759 + /**
760 + * Rename every row of a usermeta key
761 + *
762 + * @param string $table Usermeta table name.
763 + * @param string $old_key Current meta key.
764 + * @param string $new_key Wanted meta key.
765 + */
766 + private function rename_usermeta( $table, $old_key, $new_key ) {
767 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare(), and the table name comes from the validated prefix map and all values use placeholders.
768 + if ( $old_key === $new_key ) {
769 + return;
770 + }
771 +
772 + // Prefix migration has to rewrite meta_key values by definition, so the
773 + // slow-query rule does not apply here.
774 + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
775 + $this->wpdb->query(
419 776 $this->wpdb->prepare(
420 - "SELECT umeta_id, meta_key FROM `{$usermeta_table}` WHERE meta_key LIKE %s",
421 - $old_like
777 + "UPDATE `{$table}` SET meta_key = %s WHERE meta_key = %s",
778 + $new_key,
779 + $old_key
422 780 )
423 781 );
782 + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
783 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
784 + }
424 785
425 - if ( $metas ) {
426 - foreach ( $metas as $meta ) {
427 - $new_meta_key = $this->new_prefix . substr( $meta->meta_key, strlen( $this->old_prefix ) );
786 + /**
787 + * Whether an option name exists in a given options table
788 + *
789 + * @param string $table Options table name.
790 + * @param string $option_name Option name.
791 + * @return bool
792 + */
793 + private function option_exists( $table, $option_name ) {
794 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare(), and the table name comes from the validated prefix map and all values use placeholders.
795 + $found = $this->wpdb->get_var(
796 + $this->wpdb->prepare( "SELECT option_id FROM `{$table}` WHERE option_name = %s LIMIT 1", $option_name )
797 + );
428 798
429 - // Prefix migration has to rewrite meta_key values by definition — the slow-query
430 - // rule does not apply here. Disable around the whole statement so the sniff
431 - // catches both the call and the 'meta_key' array literal inside it.
432 - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
433 - $this->wpdb->update(
434 - $usermeta_table,
435 - array( 'meta_key' => $new_meta_key ),
436 - array( 'umeta_id' => $meta->umeta_id ),
437 - array( '%s' ),
438 - array( '%d' )
439 - );
440 - // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
441 - }
799 + return ! empty( $found );
800 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
801 + }
802 +
803 + /**
804 + * Whether a table exists
805 + *
806 + * @param string $table Table name.
807 + * @return bool
808 + */
809 + private function table_exists( $table ) {
810 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
811 + $found = $this->wpdb->get_var(
812 + $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $this->wpdb->esc_like( $table ) )
813 + );
814 +
815 + return ! empty( $found );
816 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
817 + }
818 +
819 + /**
820 + * Drop cached copies of everything the rename touched
821 + *
822 + * Options are served from the object cache, and the rows were renamed behind
823 + * WordPress's back, so the cached alloptions/notoptions arrays still describe
824 + * the old names. With a persistent object cache (Memcached, Redis) that stale
825 + * copy outlives the request and the site keeps behaving as if nothing changed.
826 + */
827 + private function flush_caches() {
828 + wp_cache_flush();
829 +
830 + if ( function_exists( 'wp_cache_flush_runtime' ) ) {
831 + wp_cache_flush_runtime();
442 832 }
443 833 }
444 834
445 835 /**