PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.2
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.2
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
← All changes | class/class-mainwp-install.php +348 -47 5.2 → 6.2.2 View file →
@@ -8,8 +8,14 @@
8 8 */
9 9
10 10 namespace MainWP\Dashboard;
11 11
12 +// Exit if accessed directly.
13 +if ( ! defined( 'ABSPATH' ) ) {
14 + exit;
15 +}
16 +
17 +
12 18 /**
13 19 * Class MainWP_Install
14 20 *
15 21 * @package MainWP\Dashboard
@@ -24,9 +30,9 @@
24 30 * Private variable to hold the database version info.
25 31 *
26 32 * @var string DB version info.
27 33 */
28 - protected $mainwp_db_version = '9.0.0.12'; // NOSONAR - no IP.
34 + protected $mainwp_db_version = '9.0.2.5'; // NOSONAR - no IP. 9.0.2.4 drops the stray unique index on backup progress task_id. Bumped for MWP-1566 sibling-dir chmod migration (MWP-1558 follow-up). Original 9.0.2.0 bump for MWP-1557/1558.
29 35
30 36 /**
31 37 * Protected variable to hold the database option name.
32 38 *
@@ -34,8 +40,18 @@
34 40 */
35 41 protected $option_db_key = 'mainwp_db_version';
36 42
37 43 /**
44 + * Network-scoped like mainwp_db_version, so every blog on a multisite sees the pending repair.
45 + */
46 + const BACKUP_PROGRESS_INDEX_REPAIR_PENDING = 'mainwp_backup_progress_index_repair_pending';
47 +
48 + /**
49 + * Network-scoped like mainwp_db_version, so every blog on a multisite sees the pending repair.
50 + */
51 + const WP_GROUP_MISSING_PRIMARY_KEY_REPAIR_PENDING = 'mainwp_wp_group_missing_primary_key_repair_pending';
52 +
53 + /**
38 54 * Private static variable to hold the single instance of the class.
39 55 *
40 56 * @static
41 57 *
@@ -84,14 +100,22 @@
84 100 if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.8', '<' ) ) {
85 101 MainWP_Utility::update_option( 'mainwp_selected_theme', 'default' );
86 102 }
87 103
88 - $rslt = static::instance()->query( "SHOW TABLES LIKE '" . $this->table_name( 'wp' ) . "'" );
104 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
105 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql.
106 + $rslt = static::instance()->query( "SHOW TABLES LIKE '{$wp_table}'" );
89 107 if ( empty( static::num_rows( $rslt ) ) ) {
90 108 $currentVersion = false;
91 109 }
92 110
93 111 if ( $currentVersion === $this->mainwp_db_version ) {
112 + // The index repair keeps its own marker so a failed DROP is retried at
113 + // most hourly, without holding the DB version back and re-running every
114 + // older migration on each load.
115 + $this->maybe_retry_backup_progress_index_repair();
116 +
117 + $this->maybe_retry_repair_wp_group_primary_key();
94 118 return;
95 119 }
96 120
97 121 $this->pre_update_tables();
@@ -113,11 +137,9 @@
113 137 offline_checks_last int(11) NOT NULL,
114 138 offline_check_result int(11) NOT NULL,
115 139 http_response_code int(11) NOT NULL DEFAULT 0,
116 140 http_code_noticed tinyint(1) NOT NULL DEFAULT 1,
117 - disable_status_check tinyint(1) NOT NULL DEFAULT 0,
118 141 disable_health_check tinyint(1) NOT NULL DEFAULT 0,
119 - status_check_interval tinyint(1) NOT NULL DEFAULT 0,
120 142 health_threshold int(11) NOT NULL DEFAULT 0,
121 143 note text NOT NULL,
122 144 statsUpdate int(11) NOT NULL,
123 145 directories longtext NOT NULL,
@@ -147,8 +169,9 @@
147 169 uniqueId text NOT NULL,
148 170 maximumFileDescriptorsOverride tinyint(1) NOT NULL DEFAULT 0,
149 171 maximumFileDescriptorsAuto tinyint(1) NOT NULL DEFAULT 1,
150 172 maximumFileDescriptors int(11) NOT NULL DEFAULT 150,
173 + primary_backup_method varchar(64) NOT NULL DEFAULT '',
151 174 http_user text NOT NULL DEFAULT '',
152 175 http_pass text NOT NULL DEFAULT '',
153 176 wpe tinyint(1) NOT NULL,
154 177 is_staging tinyint(1) NOT NULL DEFAULT 0,
@@ -153,9 +176,13 @@
153 176 wpe tinyint(1) NOT NULL,
154 177 is_staging tinyint(1) NOT NULL DEFAULT 0,
155 178 client_id int(11) NOT NULL DEFAULT 0,
156 179 `suspended` tinyint(1) NOT NULL DEFAULT 0,
157 - KEY idx_userid (userid)";
180 + KEY idx_wp_staging_name_id (is_staging, name(191), id),
181 + KEY idx_userid (userid),
182 + KEY idx_client_id (client_id),
183 + KEY idx_url (url(191))";
184 +
158 185 if ( empty( $currentVersion ) ) {
159 186 $tbl .= ',
160 187 PRIMARY KEY (id) ';
161 188 }
@@ -194,8 +221,9 @@
194 221 opt_id int(11) NOT NULL auto_increment,
195 222 wpid int(11) NOT NULL,
196 223 name text NOT NULL DEFAULT '',
197 224 value longtext NOT NULL DEFAULT '',
225 + KEY idx_options_wpid_name (wpid, name(191)),
198 226 KEY idx_wpid (wpid)";
199 227
200 228 if ( empty( $currentVersion ) ) {
201 229 $tbl .= ',
@@ -268,14 +296,12 @@
268 296 wp_group_id int(11) NOT NULL auto_increment,
269 297 wpid int(11) NOT NULL,
270 298 groupid int(11) NOT NULL,
271 299 KEY idx_wpid (wpid),
272 - KEY idx_groupid (groupid)';
273 - if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.57', '<=' ) ) {
274 - $tbl .= ',
275 - PRIMARY KEY (wp_group_id) ';
276 - }
277 - $tbl .= ') ' . $charset_collate;
300 + KEY idx_groupid (groupid),
301 + PRIMARY KEY (wp_group_id)
302 + ) ' . $charset_collate;
303 +
278 304 $sql[] = $tbl;
279 305
280 306 $tbl = 'CREATE TABLE ' . $this->table_name( 'lookup_item_objects' ) . ' (
281 307 lookup_id bigint(20) unsigned NOT NULL auto_increment,
@@ -292,9 +318,9 @@
292 318 }
293 319 $tbl .= ') ' . $charset_collate . ';';
294 320 $sql[] = $tbl;
295 321
296 - $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup_progress' ) . " (
322 + $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup_progress' ) . " (
297 323 task_id int(11) NOT NULL,
298 324 wp_id int(11) NOT NULL,
299 325 dtsFetched int(11) NOT NULL DEFAULT 0,
300 326 fetchResult text NOT NULL DEFAULT '',
@@ -306,12 +332,8 @@
306 332 attempts int(11) NOT NULL DEFAULT 0,
307 333 last_error text NOT NULL DEFAULT '',
308 334 pid int(11) NOT NULL DEFAULT 0,
309 335 KEY idx_task_id (task_id)";
310 - if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.53', '<=' ) ) {
311 - $tbl .= ',
312 - UNIQUE (task_id)';
313 - }
314 336 $tbl .= ') ' . $charset_collate;
315 337 $sql[] = $tbl;
316 338
317 339 $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup' ) . ' (
@@ -355,21 +377,22 @@
355 377 user_id bigint(20) unsigned NOT NULL,
356 378 description varchar(200) NULL,
357 379 permissions varchar(10) NOT NULL,
358 380 consumer_key char(64) NOT NULL,
359 - consumer_secret char(43) NOT NULL,
381 + consumer_secret varchar(255) NOT NULL,
360 382 nonces longtext NULL,
361 383 truncated_key char(7) NOT NULL,
384 + key_pass char(64) NOT NULL DEFAULT "",
385 + key_type tinyint(1) NOT NULL DEFAULT 0,
362 386 `enabled` tinyint(1) DEFAULT 0,
363 387 last_access datetime NULL default null,
364 - KEY consumer_key (consumer_key),
365 - KEY consumer_secret (consumer_secret)';
388 + KEY consumer_key (consumer_key)';
366 389 if ( empty( $currentVersion ) || version_compare( $currentVersion, '9.0.0.9', '<=' ) ) { // NOSONAR - none IP.
367 390 $tbl .= ',
368 391 PRIMARY KEY (key_id) ';
369 392 }
370 - $tbl .= ') ' . $charset_collate . ';';
371 - $sql[] = $tbl;
393 + $tbl .= ') ' . $charset_collate . ';';
394 + $sql[] = $tbl;
372 395
373 396 $tbl = 'CREATE TABLE ' . $this->table_name( 'action_log' ) . " (
374 397 id int(11) NOT NULL auto_increment,
375 398 log_content mediumtext NOT NULL DEFAULT '',
@@ -380,10 +403,10 @@
380 403 if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.50', '<=' ) ) {
381 404 $tbl .= ',
382 405 PRIMARY KEY (id) ';
383 406 }
384 - $tbl .= ') ' . $charset_collate . ';';
385 - $sql[] = $tbl;
407 + $tbl .= ') ' . $charset_collate . ';';
408 + $sql[] = $tbl;
386 409
387 410 $tbl = 'CREATE TABLE ' . $this->table_name( 'request_log' ) . " (
388 411 id int(11) NOT NULL auto_increment,
389 412 wpid int(11) NOT NULL,
@@ -397,10 +420,32 @@
397 420 }
398 421 $tbl .= ') ' . $charset_collate . ';';
399 422 $sql[] = $tbl;
400 423
424 + $tbl = 'CREATE TABLE ' . $this->table_name( 'schedule_processes' ) . " (
425 + process_id int(11) NOT NULL auto_increment,
426 + item_id int(11) NOT NULL,
427 + `type` varchar(32) NOT NULL,
428 + `process_slug` varchar(64) NOT NULL,
429 + `status` varchar(32) NOT NULL DEFAULT '',
430 + dts_process_start int(11) NOT NULL DEFAULT 0,
431 + dts_process_init_time int(11) NOT NULL DEFAULT 0,
432 + dts_process_stop int(11) NOT NULL DEFAULT 0";
433 +
434 + if ( empty( $currentVersion ) || version_compare( $currentVersion, '9.0.0.45', '<' ) ) { //phpcs:ignore -- NOSONAR - no ip.
435 + $tbl .= ',
436 + PRIMARY KEY (process_id) ';
437 + }
438 +
439 + $tbl .= ') ' . $charset_collate . ';';
440 + $sql[] = $tbl;
441 +
442 + // End of tables.
443 +
401 444 $sql = apply_filters( 'mainwp_db_install_tables', $sql, $currentVersion, $charset_collate );
402 445
446 + MainWP_DB_Uptime_Monitoring::instance()->get_db_schema( $sql, $currentVersion );
447 +
403 448 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // NOSONAR - WP compatible.
404 449
405 450 global $wpdb;
406 451
@@ -415,8 +460,10 @@
415 460 $wpdb->suppress_errors( $suppress );
416 461
417 462 $this->post_update();
418 463
464 + do_action( 'mainwp_db_after_update', $currentVersion, $this->mainwp_db_version ); // new version: $this->mainwp_db_version.
465 +
419 466 if ( ! is_multisite() ) {
420 467 MainWP_Utility::update_option( $this->option_db_key, $this->mainwp_db_version );
421 468 } else {
422 469 update_site_option( $this->option_db_key, $this->mainwp_db_version );
@@ -456,10 +503,13 @@
456 503 $sslColumns = array(
457 504 'nossl',
458 505 'nosslkey',
459 506 );
507 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
460 508 foreach ( $sslColumns as $col ) {
461 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $col );
509 + $col = esc_sql( $col );
510 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
511 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$col}" );
462 512 }
463 513 }
464 514
465 515 // delete old columns.
@@ -474,14 +524,19 @@
474 524 'last_db_backup_size',
475 525 );
476 526
477 527 foreach ( $rankColumns as $rankColumn ) {
478 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $rankColumn );
528 + $rankColumn = esc_sql( $rankColumn );
529 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
530 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$rankColumn}" );
479 531 }
480 532
481 - $syncColumns = array( 'uptodate' );
533 + $syncColumns = array( 'uptodate' );
534 + $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
482 535 foreach ( $syncColumns as $column ) {
483 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_sync' ) . ' DROP COLUMN ' . $column );
536 + $column = esc_sql( $column );
537 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
538 + $this->wpdb->query( "ALTER TABLE {$wp_sync_table} DROP COLUMN {$column}" );
484 539 }
485 540 }
486 541
487 542 // delete old columns.
@@ -487,41 +542,274 @@
487 542 // delete old columns.
488 543 if ( version_compare( $currentVersion, '8.35', '<' ) ) {
489 544 $delColumns = array( 'offline_checks' );
490 545 foreach ( $delColumns as $column ) {
491 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $column );
546 + $column = esc_sql( $column );
547 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
548 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$column}" );
492 549 }
493 - $delColumns = array( 'heatMap' );
550 + $delColumns = array( 'heatMap' );
551 + $users_table = esc_sql( $this->table_name( 'users' ) );
494 552 foreach ( $delColumns as $column ) {
495 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'users' ) . ' DROP COLUMN ' . $column );
553 + $column = esc_sql( $column );
554 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
555 + $this->wpdb->query( "ALTER TABLE {$users_table} DROP COLUMN {$column}" );
496 556 }
497 557 }
498 558
499 - // change columns.
500 - if ( version_compare( $currentVersion, '8.40', '<' ) && version_compare( $currentVersion, '8.30', '>' ) ) {
501 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_status' ) . ' CHANGE COLUMN `timestamp_status` `event_timestamp` int(11) NOT NULL' );
502 - }
503 -
504 559 // delete columns.
505 560 if ( version_compare( $currentVersion, '8.42', '<' ) ) {
506 561 $delColumns = array( 'offlineChecksOnlineNotification' );
507 562 foreach ( $delColumns as $column ) {
508 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'users' ) . ' DROP COLUMN ' . $column );
563 + $column = esc_sql( $column );
564 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
565 + $this->wpdb->query( "ALTER TABLE {$users_table} DROP COLUMN {$column}" );
509 566 }
510 567 }
511 568
512 569 // fix missing PRIMARY keys.
513 570 if ( version_compare( $currentVersion, '8.53', '<=' ) ) {
514 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_options' ) . ' ADD opt_id int NOT NULL AUTO_INCREMENT PRIMARY KEY' );
515 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_settings_backup' ) . ' ADD set_id int NOT NULL AUTO_INCREMENT PRIMARY KEY' );
516 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_sync' ) . ' ADD sync_id int NOT NULL AUTO_INCREMENT PRIMARY KEY' );
571 + $wp_options_table = esc_sql( $this->table_name( 'wp_options' ) );
572 + $wp_settings_backup_table = esc_sql( $this->table_name( 'wp_settings_backup' ) );
573 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql.
574 + $this->wpdb->query( "ALTER TABLE {$wp_options_table} ADD opt_id int NOT NULL AUTO_INCREMENT PRIMARY KEY" );
575 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql.
576 + $this->wpdb->query( "ALTER TABLE {$wp_settings_backup_table} ADD set_id int NOT NULL AUTO_INCREMENT PRIMARY KEY" );
577 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql.
578 + $this->wpdb->query( "ALTER TABLE {$wp_sync_table} ADD sync_id int NOT NULL AUTO_INCREMENT PRIMARY KEY" );
517 579 }
518 580
581 + $this->update_optimize_indexes_55( $currentVersion );
582 +
583 + // dbDelta never drops an index, so the stray unique key needs an explicit migration.
584 + if ( version_compare( $currentVersion, '9.0.2.4', '<' ) ) { // NOSONAR - no ip.
585 + $this->repair_backup_progress_index();
586 + }
587 +
588 + if ( ! empty( $currentVersion ) && version_compare( $currentVersion, '9.0.2.5', '<' ) ) { // NOSONAR - no ip.
589 + $this->repair_wp_group_primary_key();
590 + }
591 +
519 592 $this->wpdb->suppress_errors( $suppress );
520 593 MainWP_DB_Client::instance()->check_to_updates_reports_data_861( $currentVersion );
521 594 }
522 595
523 596 /**
597 + * Handle optimize tables indexes.
598 + *
599 + * @param string $current_ver Current DB version.
600 + *
601 + * @return void
602 + */
603 + public function update_optimize_indexes_55( $current_ver ) {
604 + if ( ! empty( $current_ver ) && version_compare( $current_ver, '9.0.1.1', '<' ) ) { // NOSONAR - no ip.
605 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' ADD INDEX idx_wp_staging_name_id (is_staging, name(191), id)' ); //phpcs:ignore -- ok.
606 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_options' ) . ' ADD INDEX KEY idx_options_wpid_name (wpid, name(191))' ); //phpcs:ignore -- ok.
607 + }
608 + if ( ! empty( $current_ver ) && version_compare( $current_ver, '9.0.1.3', '<' ) ) { // NOSONAR - no ip.
609 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' ADD INDEX idx_userid (userid)' ); //phpcs:ignore -- ok.
610 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' ADD INDEX idx_client_id (client_id)' ); //phpcs:ignore -- ok.
611 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' ADD INDEX idx_url (url(191))' ); //phpcs:ignore -- ok.
612 + }
613 + // MWP-1540: widen consumer_secret so wp_hash_password output (variable length,
614 + // typically 34 chars for $P$ but up to ~150 for argon2) fits without truncation,
615 + // and drop the unused KEY consumer_secret index (lookups are by HMAC'd consumer_key,
616 + // never by consumer_secret). dbDelta does not reliably MODIFY existing column types
617 + // or DROP indexes, so both changes are applied explicitly here.
618 + if ( ! empty( $current_ver ) && version_compare( $current_ver, '9.0.1.5', '<' ) ) { // NOSONAR - no ip.
619 + $api_keys_table = $this->table_name( 'api_keys' );
620 + // Drop the unused index FIRST so the column type change is unambiguous.
621 + $existing_indexes = $this->wpdb->get_col( "SHOW INDEX FROM {$api_keys_table} WHERE Key_name = 'consumer_secret'", 2 ); // phpcs:ignore -- table name is internal.
622 + if ( ! empty( $existing_indexes ) ) {
623 + $this->wpdb->query( "ALTER TABLE {$api_keys_table} DROP INDEX consumer_secret" ); // phpcs:ignore -- table name is internal.
624 + }
625 + $this->wpdb->query( "ALTER TABLE {$api_keys_table} MODIFY COLUMN consumer_secret varchar(255) NOT NULL" ); // phpcs:ignore -- table name is internal.
626 +
627 + // Confirm the MODIFY actually took effect before we trust this migration.
628 + // WP 6.5+ produces bcrypt hashes around 60 characters, so a silent failure
629 + // would leave the column at char(43) and truncate every freshly hashed
630 + // secret on insert, quietly breaking auth on any newly created key. On a
631 + // width mismatch we set a flag option that the dashboard surfaces as an
632 + // admin notice; mainwp_notice_wp_mail_failed in class-mainwp-system.php
633 + // uses the same shape.
634 + $col_def = $this->wpdb->get_row( "SHOW COLUMNS FROM {$api_keys_table} LIKE 'consumer_secret'" ); // phpcs:ignore -- table name is internal.
635 + if ( empty( $col_def ) || false === stripos( (string) $col_def->Type, 'varchar(255)' ) ) {
636 + update_option( 'mainwp_notice_consumer_secret_migration_failed', current_time( 'mysql' ) );
637 + } else {
638 + delete_option( 'mainwp_notice_consumer_secret_migration_failed' );
639 + }
640 + }
641 + }
642 +
643 + /**
644 + * Retry a pending backup progress index repair, no more than once an hour.
645 + *
646 + * A DB user without ALTER never clears the marker, so an unthrottled retry
647 + * would run two SHOW INDEX plus a failing ALTER on every single request.
648 + *
649 + * @return bool True when a repair attempt was made.
650 + */
651 + public function maybe_retry_repair_wp_group_primary_key() {
652 + $pending = (int) get_site_option( self::WP_GROUP_MISSING_PRIMARY_KEY_REPAIR_PENDING );
653 + if ( empty( $pending ) || time() - $pending < HOUR_IN_SECONDS ) {
654 + return false;
655 + }
656 +
657 + $suppress = $this->wpdb->suppress_errors();
658 + $this->repair_wp_group_primary_key();
659 + $this->wpdb->suppress_errors( $suppress );
660 +
661 + return true;
662 + }
663 +
664 + /**
665 + * Repair the wp_group table primary key for legacy installations.
666 + *
667 + * To fix MWP-1794.
668 + *
669 + * @return void
670 + */
671 + private function repair_wp_group_primary_key() {
672 + $wp_group_table = esc_sql( $this->table_name( 'wp_group' ) );
673 +
674 + $column_exists = $this->wpdb->get_var(
675 + "SHOW COLUMNS FROM {$wp_group_table} LIKE 'wp_group_id'" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is escaped and query contains no user input.
676 + );
677 +
678 + if ( empty( $column_exists ) ) {
679 + $this->wpdb->query(
680 + "ALTER TABLE {$wp_group_table}
681 + ADD COLUMN wp_group_id int(11) NOT NULL auto_increment PRIMARY KEY FIRST" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is escaped and query contains no user input.
682 + );
683 + } else {
684 + $primary_key = $this->wpdb->get_var(
685 + "SHOW INDEX FROM {$wp_group_table}
686 + WHERE Key_name = 'PRIMARY'
687 + AND Column_name = 'wp_group_id'" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is escaped and query contains no user input.
688 + );
689 +
690 + if ( empty( $primary_key ) ) {
691 + $this->wpdb->query(
692 + "ALTER TABLE {$wp_group_table}
693 + ADD PRIMARY KEY (wp_group_id)" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is escaped and query contains no user input.
694 + );
695 + }
696 + }
697 +
698 + $primary_key = $this->wpdb->get_var(
699 + "SHOW INDEX FROM {$wp_group_table}
700 + WHERE Key_name = 'PRIMARY'
701 + AND Column_name = 'wp_group_id'" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is escaped and query contains no user input.
702 + );
703 +
704 + if ( ! empty( $primary_key ) ) {
705 + delete_site_option( self::WP_GROUP_MISSING_PRIMARY_KEY_REPAIR_PENDING );
706 + } else {
707 + // A timestamp, not a formatted date: the retry throttle compares it against time().
708 + update_site_option( self::WP_GROUP_MISSING_PRIMARY_KEY_REPAIR_PENDING, time() );
709 + }
710 + }
711 +
712 + /**
713 + * Retry a pending backup progress index repair, no more than once an hour.
714 + *
715 + * A DB user without ALTER never clears the marker, so an unthrottled retry
716 + * would run two SHOW INDEX plus a failing ALTER on every single request.
717 + *
718 + * @return bool True when a repair attempt was made.
719 + */
720 + public function maybe_retry_backup_progress_index_repair() {
721 + $pending = (int) get_site_option( self::BACKUP_PROGRESS_INDEX_REPAIR_PENDING );
722 + if ( empty( $pending ) || time() - $pending < HOUR_IN_SECONDS ) {
723 + return false;
724 + }
725 +
726 + $this->repair_backup_progress_index();
727 +
728 + return true;
729 + }
730 +
731 + /**
732 + * Run the backup progress index repair and remember whether it still needs a retry.
733 + *
734 + * @return bool True when the repair is confirmed complete.
735 + */
736 + public function repair_backup_progress_index() {
737 + // The retry from install() runs outside post_update()'s suppression; a
738 + // persistently failing ALTER must not log or print on every request.
739 + $suppress = $this->wpdb->suppress_errors();
740 + $repaired = $this->drop_backup_progress_unique_index();
741 + $this->wpdb->suppress_errors( $suppress );
742 + if ( $repaired ) {
743 + delete_site_option( self::BACKUP_PROGRESS_INDEX_REPAIR_PENDING );
744 + } else {
745 + // A timestamp, not a formatted date: the retry throttle compares it against time().
746 + update_site_option( self::BACKUP_PROGRESS_INDEX_REPAIR_PENDING, time() );
747 + }
748 + return $repaired;
749 + }
750 +
751 + /**
752 + * Drop a stray unique index on the backup progress task_id column.
753 + *
754 + * Fresh installs made between DB 8.53 and the schema fix created
755 + * wp_backup_progress with UNIQUE (task_id). Progress rows are per task and
756 + * per site, so a task covering two or more sites could never insert its
757 + * second row. dbDelta never drops an index, so the stray key has to go here.
758 + *
759 + * @return bool True when no such key remains, false when one survived a failed DROP.
760 + */
761 + public function drop_backup_progress_unique_index() {
762 + $table = $this->table_name( 'wp_backup_progress' );
763 + $keys = $this->find_backup_progress_task_id_unique_keys();
764 + if ( null === $keys ) {
765 + return false;
766 + }
767 +
768 + foreach ( $keys as $key_name ) {
769 + $this->wpdb->query( "ALTER TABLE {$table} DROP INDEX `{$key_name}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL statement; table name is a hardcoded internal identifier, key name comes from SHOW INDEX and is allowlisted below.
770 + }
771 +
772 + // suppress_errors() hides a failed DROP, so the table is the only source of truth.
773 + return array() === $this->find_backup_progress_task_id_unique_keys();
774 + }
775 +
776 + /**
777 + * Names of the unique keys on the backup progress table that cover task_id alone.
778 + *
779 + * A composite unique key over task_id and wp_id is legitimate and is left out.
780 + *
781 + * @return array|null Key names, or null when the lookup itself failed and nothing can be concluded.
782 + */
783 + protected function find_backup_progress_task_id_unique_keys() {
784 + $table = $this->table_name( 'wp_backup_progress' );
785 +
786 + // suppress_errors() is on in post_update(), so a failed SHOW INDEX looks like an empty index list; last_error is the only tell.
787 + $this->wpdb->last_error = '';
788 + $indexes = $this->wpdb->get_results( "SHOW INDEX FROM {$table} WHERE Non_unique = 0", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL introspection; table name is a hardcoded internal identifier, no user input involved.
789 + if ( '' !== $this->wpdb->last_error ) {
790 + return null;
791 + }
792 +
793 + $key_columns = array();
794 + foreach ( (array) $indexes as $index ) {
795 + if ( 'PRIMARY' === $index['Key_name'] ) {
796 + continue;
797 + }
798 + $key_columns[ $index['Key_name'] ][] = $index['Column_name'];
799 + }
800 +
801 + $keys = array();
802 + foreach ( $key_columns as $key_name => $columns ) {
803 + if ( array( 'task_id' ) === $columns && preg_match( '/^[A-Za-z0-9_]+$/', $key_name ) ) {
804 + $keys[] = $key_name;
805 + }
806 + }
807 +
808 + return $keys;
809 + }
810 +
811 + /**
524 812 * Method pre_update_tables()
525 813 *
526 814 * Handle pre update tables.
527 815 *
@@ -537,11 +825,15 @@
537 825
538 826 $suppress = $this->wpdb->suppress_errors();
539 827
540 828 if ( version_compare( $currentVersion, '8.98', '<=' ) ) {
541 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN backups' );
542 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN note_lastupdate' );
543 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN pages' );
829 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
830 + $existing_columns = $this->wpdb->get_col( "SHOW COLUMNS FROM {$wp_table}", 0 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL introspection; table name is a hardcoded internal identifier escaped via esc_sql(), no user input involved.
831 + foreach ( array( 'backups', 'note_lastupdate', 'pages' ) as $column ) {
832 + if ( in_array( $column, $existing_columns, true ) ) {
833 + $this->wpdb->query( 'ALTER TABLE ' . $wp_table . ' DROP COLUMN ' . esc_sql( $column ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL statement; table and column names are hardcoded internal identifiers escaped via esc_sql(), no user input involved.
834 + }
835 + }
544 836 }
545 837
546 838 $this->wpdb->suppress_errors( $suppress );
547 839 }
@@ -571,16 +863,21 @@
571 863 'dtsSyncStart',
572 864 'dtsAutomaticSync',
573 865 'dtsAutomaticSyncStart',
574 866 );
867 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
868 + $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
575 869 foreach ( $wpSyncColumns as $wpSyncColumn ) {
576 - $rslts = $this->wpdb->get_results( 'SELECT id,' . $wpSyncColumn . ' FROM ' . $this->table_name( 'wp' ), ARRAY_A );
870 + $wpSyncColumn = esc_sql( $wpSyncColumn );
871 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
872 + $rslts = $this->wpdb->get_results( "SELECT id,{$wpSyncColumn} FROM {$wp_table}", ARRAY_A );
577 873 if ( empty( $rslts ) ) {
578 874 continue;
579 875 }
580 876
581 877 foreach ( $rslts as $rslt ) {
582 - $exists = $this->wpdb->get_results( $this->wpdb->prepare( 'SELECT wpid FROM ' . $this->table_name( 'wp_sync' ) . ' WHERE wpid = %d', $rslt['id'] ), ARRAY_A );
878 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql and query uses proper prepare.
879 + $exists = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT wpid FROM {$wp_sync_table} WHERE wpid = %d", $rslt['id'] ), ARRAY_A );
583 880 if ( empty( $exists ) ) {
584 881 $this->wpdb->insert(
585 882 $this->table_name( 'wp_sync' ),
586 883 array(
@@ -593,9 +890,10 @@
593 890 }
594 891 }
595 892
596 893 $suppress = $this->wpdb->suppress_errors();
597 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $wpSyncColumn );
894 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
895 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$wpSyncColumn}" );
598 896 $this->wpdb->suppress_errors( $suppress );
599 897 }
600 898
601 899 $optionColumns = array(
@@ -607,9 +905,11 @@
607 905 'recent_posts',
608 906 'recent_pages',
609 907 );
610 908 foreach ( $optionColumns as $optionColumn ) {
611 - $rslts = $this->wpdb->get_results( 'SELECT id,' . $optionColumn . ' FROM ' . $this->table_name( 'wp' ), ARRAY_A );
909 + $optionColumn = esc_sql( $optionColumn );
910 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
911 + $rslts = $this->wpdb->get_results( "SELECT id,{$optionColumn} FROM {$wp_table}", ARRAY_A );
612 912 if ( empty( $rslts ) ) {
613 913 continue;
614 914 }
615 915
@@ -617,9 +917,10 @@
617 917 static::update_website_option( (object) $rslt, $optionColumn, $rslt[ $optionColumn ] );
618 918 }
619 919
620 920 $suppress = $this->wpdb->suppress_errors();
621 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $optionColumn );
921 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
922 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$optionColumn}" );
622 923 $this->wpdb->suppress_errors( $suppress );
623 924 }
624 925 }
625 926 }