PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.10
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.10
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 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-database-prefix.php

class-database-prefix.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.10, at includes/class-database-prefix.php

857 lines 32.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Database Prefix Changer Class
4 *
5 * Safely changes the WordPress database table prefix
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Database_Prefix
17 *
18 * Changes the WordPress database prefix safely
19 */
20 class Vigilante_Database_Prefix {
21
22 /**
23 * WordPress database instance
24 *
25 * @var wpdb
26 */
27 private $wpdb;
28
29 /**
30 * Current prefix
31 *
32 * @var string
33 */
34 private $old_prefix;
35
36 /**
37 * New prefix to apply
38 *
39 * @var string
40 */
41 private $new_prefix;
42
43 /**
44 * Path to wp-config.php
45 *
46 * @var string
47 */
48 private $wpconfig_path;
49
50 /**
51 * Constructor
52 */
53 public function __construct() {
54 global $wpdb;
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;
61 $this->wpconfig_path = $this->find_wpconfig_path();
62 }
63
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 /**
96 * Get the current database prefix
97 *
98 * @return string
99 */
100 public function get_current_prefix() {
101 return $this->old_prefix;
102 }
103
104 /**
105 * Check if current prefix is the insecure default
106 *
107 * @return bool
108 */
109 public function is_default_prefix() {
110 return 'wp_' === $this->old_prefix;
111 }
112
113 /**
114 * Generate a random secure prefix
115 *
116 * Format: 2-3 lowercase letters + 2-3 digits + underscore (e.g., vg72_ or kx391_)
117 *
118 * @return string
119 */
120 public function generate_prefix() {
121 $letters = 'abcdefghijklmnopqrstuvwxyz';
122 $prefix = '';
123
124 // 2-3 random letters
125 $letter_count = wp_rand( 2, 3 );
126 for ( $i = 0; $i < $letter_count; $i++ ) {
127 $prefix .= $letters[ wp_rand( 0, strlen( $letters ) - 1 ) ];
128 }
129
130 // 2-3 random digits
131 $digit_count = wp_rand( 2, 3 );
132 for ( $i = 0; $i < $digit_count; $i++ ) {
133 $prefix .= wp_rand( 0, 9 );
134 }
135
136 $prefix .= '_';
137
138 // Verify no tables exist with this prefix
139 if ( $this->prefix_tables_exist( $prefix ) ) {
140 return $this->generate_prefix(); // Regenerate if collision
141 }
142
143 return $prefix;
144 }
145
146 /**
147 * Validate a prefix string
148 *
149 * @param string $prefix Prefix to validate.
150 * @return true|WP_Error
151 */
152 public function validate_prefix( $prefix ) {
153 // Must end with underscore
154 if ( substr( $prefix, -1 ) !== '_' ) {
155 return new WP_Error( 'no_underscore', __( 'Prefix must end with an underscore.', 'vigilante' ) );
156 }
157
158 // Length check (including underscore): 3-16 characters
159 $len = strlen( $prefix );
160 if ( $len < 3 || $len > 16 ) {
161 return new WP_Error( 'invalid_length', __( 'Prefix must be between 3 and 16 characters (including underscore).', 'vigilante' ) );
162 }
163
164 // Only lowercase letters, digits, and underscore
165 if ( ! preg_match( '/^[a-z0-9_]+$/', $prefix ) ) {
166 return new WP_Error( 'invalid_chars', __( 'Prefix must contain only lowercase letters, digits, and underscores.', 'vigilante' ) );
167 }
168
169 // Must start with a letter
170 if ( ! preg_match( '/^[a-z]/', $prefix ) ) {
171 return new WP_Error( 'must_start_letter', __( 'Prefix must start with a letter.', 'vigilante' ) );
172 }
173
174 // Cannot be the same as current
175 if ( $prefix === $this->old_prefix ) {
176 return new WP_Error( 'same_prefix', __( 'New prefix is the same as the current one.', 'vigilante' ) );
177 }
178
179 // Check for existing tables with this prefix
180 if ( $this->prefix_tables_exist( $prefix ) ) {
181 return new WP_Error( 'prefix_exists', __( 'Tables with this prefix already exist in the database.', 'vigilante' ) );
182 }
183
184 return true;
185 }
186
187 /**
188 * Execute the full prefix change operation
189 *
190 * @param string $new_prefix New prefix to apply.
191 * @return true|WP_Error
192 */
193 public function change_prefix( $new_prefix ) {
194 $this->new_prefix = $new_prefix;
195
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
203 $valid = $this->validate_prefix( $new_prefix );
204 if ( is_wp_error( $valid ) ) {
205 return $valid;
206 }
207
208 // Step 3: Check wp-config.php is writable
209 if ( ! $this->wpconfig_path ) {
210 return new WP_Error( 'wpconfig_not_found', __( 'Cannot locate wp-config.php file.', 'vigilante' ) );
211 }
212
213 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
214 if ( ! is_writable( $this->wpconfig_path ) ) {
215 return new WP_Error( 'wpconfig_not_writable', __( 'wp-config.php is not writable. Check file permissions.', 'vigilante' ) );
216 }
217
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
227 $tables = $this->get_prefixed_tables();
228 if ( empty( $tables ) ) {
229 return new WP_Error( 'no_tables', __( 'No tables found with the current prefix.', 'vigilante' ) );
230 }
231
232 // Step 6: Rename all tables
233 $rename_result = $this->rename_tables( $tables );
234 if ( is_wp_error( $rename_result ) ) {
235 return $rename_result;
236 }
237
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
243 $config_result = $this->update_wpconfig();
244 if ( is_wp_error( $config_result ) ) {
245 // Rollback table renames
246 $this->rollback_tables( $tables );
247 $this->wpdb->set_prefix( $this->old_prefix );
248 return $config_result;
249 }
250
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 );
254
255 // Step 10: Update usermeta prefix keys
256 $this->update_usermeta_prefix( $sites );
257
258 // Step 11: Drop cached copies of everything that was renamed
259 $this->flush_caches();
260
261 return true;
262 }
263
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 /**
320 * Get all tables with the current prefix
321 *
322 * @return array Table names.
323 */
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().
326 return $this->wpdb->get_col(
327 $this->wpdb->prepare(
328 'SHOW TABLES LIKE %s',
329 $this->wpdb->esc_like( $this->old_prefix ) . '%'
330 )
331 );
332 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
333 }
334
335 /**
336 * Check if tables exist with a given prefix
337 *
338 * @param string $prefix Prefix to check.
339 * @return bool
340 */
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().
343 $result = $this->wpdb->get_var(
344 $this->wpdb->prepare(
345 'SHOW TABLES LIKE %s',
346 $this->wpdb->esc_like( $prefix ) . '%'
347 )
348 );
349
350 return ! empty( $result );
351 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
352 }
353
354 /**
355 * Rename all tables from old prefix to new prefix
356 *
357 * @param array $tables List of table names.
358 * @return true|WP_Error
359 */
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().
362 $renamed = array();
363
364 foreach ( $tables as $old_name ) {
365 $new_name = $this->new_prefix . substr( $old_name, strlen( $this->old_prefix ) );
366
367 // Use RENAME TABLE (atomic operation, works within same database)
368 $result = $this->wpdb->query(
369 $this->wpdb->prepare(
370 'RENAME TABLE %i TO %i',
371 $old_name,
372 $new_name
373 )
374 );
375
376 if ( false === $result ) {
377 // Rollback already renamed tables
378 foreach ( $renamed as $rollback_new => $rollback_old ) {
379 $this->wpdb->query(
380 $this->wpdb->prepare(
381 'RENAME TABLE %i TO %i',
382 $rollback_new,
383 $rollback_old
384 )
385 );
386 }
387
388 return new WP_Error(
389 'rename_failed',
390 sprintf(
391 /* translators: %s: Table name */
392 __( 'Failed to rename table: %s. All changes have been rolled back.', 'vigilante' ),
393 $old_name
394 )
395 );
396 }
397
398 $renamed[ $new_name ] = $old_name;
399 }
400
401 return true;
402 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
403 }
404
405 /**
406 * Rollback table renames
407 *
408 * @param array $original_tables Original table names.
409 */
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().
412 foreach ( $original_tables as $old_name ) {
413 $new_name = $this->new_prefix . substr( $old_name, strlen( $this->old_prefix ) );
414
415 // Check if new name exists (it was renamed)
416 $exists = $this->table_exists( $new_name );
417
418 if ( $exists ) {
419 $this->wpdb->query(
420 $this->wpdb->prepare(
421 'RENAME TABLE %i TO %i',
422 $new_name,
423 $old_name
424 )
425 );
426 }
427 }
428 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
429 }
430
431 /**
432 * Update $table_prefix in wp-config.php
433 *
434 * @return true|WP_Error
435 */
436 private function update_wpconfig() {
437 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
438 $content = file_get_contents( $this->wpconfig_path );
439
440 if ( false === $content ) {
441 return new WP_Error( 'read_error', __( 'Cannot read wp-config.php.', 'vigilante' ) );
442 }
443
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 */
460
461 // Match the $table_prefix line (handles single and double quotes, with/without spaces)
462 $pattern = '/(\$table_prefix\s*=\s*)([\'"]).+?\\2(\s*;)/';
463 $replacement = '${1}\'' . $this->new_prefix . '\'${3}';
464
465 $new_content = preg_replace( $pattern, $replacement, $content, 1, $count );
466
467 if ( 0 === $count || null === $new_content ) {
468 return new WP_Error( 'replace_error', __( 'Cannot find $table_prefix in wp-config.php.', 'vigilante' ) );
469 }
470
471 // Write updated content
472 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
473 $result = file_put_contents( $this->wpconfig_path, $new_content );
474
475 if ( false === $result ) {
476 // Restore from memory, which is where the original has been all along.
477 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
478 file_put_contents( $this->wpconfig_path, $content );
479 return new WP_Error( 'write_error', __( 'Cannot write to wp-config.php.', 'vigilante' ) );
480 }
481
482 $this->invalidate_wpconfig_opcode_cache();
483
484 return true;
485 }
486
487 /**
488 * Drop the compiled copy of wp-config.php from the opcode cache
489 *
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.
501 */
502 private function invalidate_wpconfig_opcode_cache() {
503 if ( ! $this->wpconfig_path ) {
504 return;
505 }
506
507 clearstatcache( true, $this->wpconfig_path );
508
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',
662 )
663 );
664
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 }
670
671 foreach ( $suffixes as $suffix ) {
672 $this->rename_usermeta( $usermeta_table, $site['old_prefix'] . $suffix, $site['new_prefix'] . $suffix );
673 }
674 }
675 }
676
677 /**
678 * Rename every option in a table whose name starts with a given prefix
679 *
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.
686 */
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 }
699
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 }
726
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(
776 $this->wpdb->prepare(
777 "UPDATE `{$table}` SET meta_key = %s WHERE meta_key = %s",
778 $new_key,
779 $old_key
780 )
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 }
785
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 );
798
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();
832 }
833 }
834
835 /**
836 * Find wp-config.php path
837 *
838 * Checks standard location and one level up (common setup)
839 *
840 * @return string|false Path or false if not found.
841 */
842 private function find_wpconfig_path() {
843 // Standard location
844 $path = ABSPATH . 'wp-config.php';
845 if ( file_exists( $path ) ) {
846 return $path;
847 }
848
849 // One directory up
850 $path = dirname( ABSPATH ) . '/wp-config.php';
851 if ( file_exists( $path ) && ! file_exists( dirname( ABSPATH ) . '/wp-settings.php' ) ) {
852 return $path;
853 }
854
855 return false;
856 }
857 }