PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.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
vigilante / includes / class-database-prefix.php

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

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