PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / trunk
MainWP Dashboard: Self-hosted WordPress Management for Agencies vtrunk
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 5.2.2 5.3 All 153 releases
← All changes | class/class-mainwp-install.php +264 -41 5.2trunk 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.4'; // 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,13 @@
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 + /**
38 49 * Private static variable to hold the single instance of the class.
39 50 *
40 51 * @static
41 52 *
@@ -84,14 +95,20 @@
84 95 if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.8', '<' ) ) {
85 96 MainWP_Utility::update_option( 'mainwp_selected_theme', 'default' );
86 97 }
87 98
88 - $rslt = static::instance()->query( "SHOW TABLES LIKE '" . $this->table_name( 'wp' ) . "'" );
99 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
100 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql.
101 + $rslt = static::instance()->query( "SHOW TABLES LIKE '{$wp_table}'" );
89 102 if ( empty( static::num_rows( $rslt ) ) ) {
90 103 $currentVersion = false;
91 104 }
92 105
93 106 if ( $currentVersion === $this->mainwp_db_version ) {
107 + // The index repair keeps its own marker so a failed DROP is retried at
108 + // most hourly, without holding the DB version back and re-running every
109 + // older migration on each load.
110 + $this->maybe_retry_backup_progress_index_repair();
94 111 return;
95 112 }
96 113
97 114 $this->pre_update_tables();
@@ -113,11 +130,9 @@
113 130 offline_checks_last int(11) NOT NULL,
114 131 offline_check_result int(11) NOT NULL,
115 132 http_response_code int(11) NOT NULL DEFAULT 0,
116 133 http_code_noticed tinyint(1) NOT NULL DEFAULT 1,
117 - disable_status_check tinyint(1) NOT NULL DEFAULT 0,
118 134 disable_health_check tinyint(1) NOT NULL DEFAULT 0,
119 - status_check_interval tinyint(1) NOT NULL DEFAULT 0,
120 135 health_threshold int(11) NOT NULL DEFAULT 0,
121 136 note text NOT NULL,
122 137 statsUpdate int(11) NOT NULL,
123 138 directories longtext NOT NULL,
@@ -147,8 +162,9 @@
147 162 uniqueId text NOT NULL,
148 163 maximumFileDescriptorsOverride tinyint(1) NOT NULL DEFAULT 0,
149 164 maximumFileDescriptorsAuto tinyint(1) NOT NULL DEFAULT 1,
150 165 maximumFileDescriptors int(11) NOT NULL DEFAULT 150,
166 + primary_backup_method varchar(64) NOT NULL DEFAULT '',
151 167 http_user text NOT NULL DEFAULT '',
152 168 http_pass text NOT NULL DEFAULT '',
153 169 wpe tinyint(1) NOT NULL,
154 170 is_staging tinyint(1) NOT NULL DEFAULT 0,
@@ -153,9 +169,13 @@
153 169 wpe tinyint(1) NOT NULL,
154 170 is_staging tinyint(1) NOT NULL DEFAULT 0,
155 171 client_id int(11) NOT NULL DEFAULT 0,
156 172 `suspended` tinyint(1) NOT NULL DEFAULT 0,
157 - KEY idx_userid (userid)";
173 + KEY idx_wp_staging_name_id (is_staging, name(191), id),
174 + KEY idx_userid (userid),
175 + KEY idx_client_id (client_id),
176 + KEY idx_url (url(191))";
177 +
158 178 if ( empty( $currentVersion ) ) {
159 179 $tbl .= ',
160 180 PRIMARY KEY (id) ';
161 181 }
@@ -194,8 +214,9 @@
194 214 opt_id int(11) NOT NULL auto_increment,
195 215 wpid int(11) NOT NULL,
196 216 name text NOT NULL DEFAULT '',
197 217 value longtext NOT NULL DEFAULT '',
218 + KEY idx_options_wpid_name (wpid, name(191)),
198 219 KEY idx_wpid (wpid)";
199 220
200 221 if ( empty( $currentVersion ) ) {
201 222 $tbl .= ',
@@ -292,9 +313,9 @@
292 313 }
293 314 $tbl .= ') ' . $charset_collate . ';';
294 315 $sql[] = $tbl;
295 316
296 - $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup_progress' ) . " (
317 + $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup_progress' ) . " (
297 318 task_id int(11) NOT NULL,
298 319 wp_id int(11) NOT NULL,
299 320 dtsFetched int(11) NOT NULL DEFAULT 0,
300 321 fetchResult text NOT NULL DEFAULT '',
@@ -306,12 +327,8 @@
306 327 attempts int(11) NOT NULL DEFAULT 0,
307 328 last_error text NOT NULL DEFAULT '',
308 329 pid int(11) NOT NULL DEFAULT 0,
309 330 KEY idx_task_id (task_id)";
310 - if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.53', '<=' ) ) {
311 - $tbl .= ',
312 - UNIQUE (task_id)';
313 - }
314 331 $tbl .= ') ' . $charset_collate;
315 332 $sql[] = $tbl;
316 333
317 334 $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup' ) . ' (
@@ -355,21 +372,22 @@
355 372 user_id bigint(20) unsigned NOT NULL,
356 373 description varchar(200) NULL,
357 374 permissions varchar(10) NOT NULL,
358 375 consumer_key char(64) NOT NULL,
359 - consumer_secret char(43) NOT NULL,
376 + consumer_secret varchar(255) NOT NULL,
360 377 nonces longtext NULL,
361 378 truncated_key char(7) NOT NULL,
379 + key_pass char(64) NOT NULL DEFAULT "",
380 + key_type tinyint(1) NOT NULL DEFAULT 0,
362 381 `enabled` tinyint(1) DEFAULT 0,
363 382 last_access datetime NULL default null,
364 - KEY consumer_key (consumer_key),
365 - KEY consumer_secret (consumer_secret)';
383 + KEY consumer_key (consumer_key)';
366 384 if ( empty( $currentVersion ) || version_compare( $currentVersion, '9.0.0.9', '<=' ) ) { // NOSONAR - none IP.
367 385 $tbl .= ',
368 386 PRIMARY KEY (key_id) ';
369 387 }
370 - $tbl .= ') ' . $charset_collate . ';';
371 - $sql[] = $tbl;
388 + $tbl .= ') ' . $charset_collate . ';';
389 + $sql[] = $tbl;
372 390
373 391 $tbl = 'CREATE TABLE ' . $this->table_name( 'action_log' ) . " (
374 392 id int(11) NOT NULL auto_increment,
375 393 log_content mediumtext NOT NULL DEFAULT '',
@@ -380,10 +398,10 @@
380 398 if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.50', '<=' ) ) {
381 399 $tbl .= ',
382 400 PRIMARY KEY (id) ';
383 401 }
384 - $tbl .= ') ' . $charset_collate . ';';
385 - $sql[] = $tbl;
402 + $tbl .= ') ' . $charset_collate . ';';
403 + $sql[] = $tbl;
386 404
387 405 $tbl = 'CREATE TABLE ' . $this->table_name( 'request_log' ) . " (
388 406 id int(11) NOT NULL auto_increment,
389 407 wpid int(11) NOT NULL,
@@ -397,10 +415,32 @@
397 415 }
398 416 $tbl .= ') ' . $charset_collate . ';';
399 417 $sql[] = $tbl;
400 418
419 + $tbl = 'CREATE TABLE ' . $this->table_name( 'schedule_processes' ) . " (
420 + process_id int(11) NOT NULL auto_increment,
421 + item_id int(11) NOT NULL,
422 + `type` varchar(32) NOT NULL,
423 + `process_slug` varchar(64) NOT NULL,
424 + `status` varchar(32) NOT NULL DEFAULT '',
425 + dts_process_start int(11) NOT NULL DEFAULT 0,
426 + dts_process_init_time int(11) NOT NULL DEFAULT 0,
427 + dts_process_stop int(11) NOT NULL DEFAULT 0";
428 +
429 + if ( empty( $currentVersion ) || version_compare( $currentVersion, '9.0.0.45', '<' ) ) { //phpcs:ignore -- NOSONAR - no ip.
430 + $tbl .= ',
431 + PRIMARY KEY (process_id) ';
432 + }
433 +
434 + $tbl .= ') ' . $charset_collate . ';';
435 + $sql[] = $tbl;
436 +
437 + // End of tables.
438 +
401 439 $sql = apply_filters( 'mainwp_db_install_tables', $sql, $currentVersion, $charset_collate );
402 440
441 + MainWP_DB_Uptime_Monitoring::instance()->get_db_schema( $sql, $currentVersion );
442 +
403 443 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // NOSONAR - WP compatible.
404 444
405 445 global $wpdb;
406 446
@@ -415,8 +455,10 @@
415 455 $wpdb->suppress_errors( $suppress );
416 456
417 457 $this->post_update();
418 458
459 + do_action( 'mainwp_db_after_update', $currentVersion, $this->mainwp_db_version ); // new version: $this->mainwp_db_version.
460 +
419 461 if ( ! is_multisite() ) {
420 462 MainWP_Utility::update_option( $this->option_db_key, $this->mainwp_db_version );
421 463 } else {
422 464 update_site_option( $this->option_db_key, $this->mainwp_db_version );
@@ -456,10 +498,13 @@
456 498 $sslColumns = array(
457 499 'nossl',
458 500 'nosslkey',
459 501 );
502 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
460 503 foreach ( $sslColumns as $col ) {
461 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $col );
504 + $col = esc_sql( $col );
505 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
506 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$col}" );
462 507 }
463 508 }
464 509
465 510 // delete old columns.
@@ -474,14 +519,19 @@
474 519 'last_db_backup_size',
475 520 );
476 521
477 522 foreach ( $rankColumns as $rankColumn ) {
478 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $rankColumn );
523 + $rankColumn = esc_sql( $rankColumn );
524 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
525 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$rankColumn}" );
479 526 }
480 527
481 - $syncColumns = array( 'uptodate' );
528 + $syncColumns = array( 'uptodate' );
529 + $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
482 530 foreach ( $syncColumns as $column ) {
483 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_sync' ) . ' DROP COLUMN ' . $column );
531 + $column = esc_sql( $column );
532 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
533 + $this->wpdb->query( "ALTER TABLE {$wp_sync_table} DROP COLUMN {$column}" );
484 534 }
485 535 }
486 536
487 537 // delete old columns.
@@ -487,41 +537,201 @@
487 537 // delete old columns.
488 538 if ( version_compare( $currentVersion, '8.35', '<' ) ) {
489 539 $delColumns = array( 'offline_checks' );
490 540 foreach ( $delColumns as $column ) {
491 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $column );
541 + $column = esc_sql( $column );
542 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
543 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$column}" );
492 544 }
493 - $delColumns = array( 'heatMap' );
545 + $delColumns = array( 'heatMap' );
546 + $users_table = esc_sql( $this->table_name( 'users' ) );
494 547 foreach ( $delColumns as $column ) {
495 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'users' ) . ' DROP COLUMN ' . $column );
548 + $column = esc_sql( $column );
549 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
550 + $this->wpdb->query( "ALTER TABLE {$users_table} DROP COLUMN {$column}" );
496 551 }
497 552 }
498 553
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 554 // delete columns.
505 555 if ( version_compare( $currentVersion, '8.42', '<' ) ) {
506 556 $delColumns = array( 'offlineChecksOnlineNotification' );
507 557 foreach ( $delColumns as $column ) {
508 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'users' ) . ' DROP COLUMN ' . $column );
558 + $column = esc_sql( $column );
559 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
560 + $this->wpdb->query( "ALTER TABLE {$users_table} DROP COLUMN {$column}" );
509 561 }
510 562 }
511 563
512 564 // fix missing PRIMARY keys.
513 565 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' );
566 + $wp_options_table = esc_sql( $this->table_name( 'wp_options' ) );
567 + $wp_settings_backup_table = esc_sql( $this->table_name( 'wp_settings_backup' ) );
568 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql.
569 + $this->wpdb->query( "ALTER TABLE {$wp_options_table} ADD opt_id int NOT NULL AUTO_INCREMENT PRIMARY KEY" );
570 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql.
571 + $this->wpdb->query( "ALTER TABLE {$wp_settings_backup_table} ADD set_id int NOT NULL AUTO_INCREMENT PRIMARY KEY" );
572 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql.
573 + $this->wpdb->query( "ALTER TABLE {$wp_sync_table} ADD sync_id int NOT NULL AUTO_INCREMENT PRIMARY KEY" );
517 574 }
518 575
576 + $this->update_optimize_indexes_55( $currentVersion );
577 +
578 + // dbDelta never drops an index, so the stray unique key needs an explicit migration.
579 + if ( version_compare( $currentVersion, '9.0.2.4', '<' ) ) { // NOSONAR - no ip.
580 + $this->repair_backup_progress_index();
581 + }
582 +
519 583 $this->wpdb->suppress_errors( $suppress );
520 584 MainWP_DB_Client::instance()->check_to_updates_reports_data_861( $currentVersion );
521 585 }
522 586
523 587 /**
588 + * Handle optimize tables indexes.
589 + *
590 + * @param string $current_ver Current DB version.
591 + *
592 + * @return void
593 + */
594 + public function update_optimize_indexes_55( $current_ver ) {
595 + if ( ! empty( $current_ver ) && version_compare( $current_ver, '9.0.1.1', '<' ) ) { // NOSONAR - no ip.
596 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' ADD INDEX idx_wp_staging_name_id (is_staging, name(191), id)' ); //phpcs:ignore -- ok.
597 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_options' ) . ' ADD INDEX KEY idx_options_wpid_name (wpid, name(191))' ); //phpcs:ignore -- ok.
598 + }
599 + if ( ! empty( $current_ver ) && version_compare( $current_ver, '9.0.1.3', '<' ) ) { // NOSONAR - no ip.
600 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' ADD INDEX idx_userid (userid)' ); //phpcs:ignore -- ok.
601 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' ADD INDEX idx_client_id (client_id)' ); //phpcs:ignore -- ok.
602 + $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' ADD INDEX idx_url (url(191))' ); //phpcs:ignore -- ok.
603 + }
604 + // MWP-1540: widen consumer_secret so wp_hash_password output (variable length,
605 + // typically 34 chars for $P$ but up to ~150 for argon2) fits without truncation,
606 + // and drop the unused KEY consumer_secret index (lookups are by HMAC'd consumer_key,
607 + // never by consumer_secret). dbDelta does not reliably MODIFY existing column types
608 + // or DROP indexes, so both changes are applied explicitly here.
609 + if ( ! empty( $current_ver ) && version_compare( $current_ver, '9.0.1.5', '<' ) ) { // NOSONAR - no ip.
610 + $api_keys_table = $this->table_name( 'api_keys' );
611 + // Drop the unused index FIRST so the column type change is unambiguous.
612 + $existing_indexes = $this->wpdb->get_col( "SHOW INDEX FROM {$api_keys_table} WHERE Key_name = 'consumer_secret'", 2 ); // phpcs:ignore -- table name is internal.
613 + if ( ! empty( $existing_indexes ) ) {
614 + $this->wpdb->query( "ALTER TABLE {$api_keys_table} DROP INDEX consumer_secret" ); // phpcs:ignore -- table name is internal.
615 + }
616 + $this->wpdb->query( "ALTER TABLE {$api_keys_table} MODIFY COLUMN consumer_secret varchar(255) NOT NULL" ); // phpcs:ignore -- table name is internal.
617 +
618 + // Confirm the MODIFY actually took effect before we trust this migration.
619 + // WP 6.5+ produces bcrypt hashes around 60 characters, so a silent failure
620 + // would leave the column at char(43) and truncate every freshly hashed
621 + // secret on insert, quietly breaking auth on any newly created key. On a
622 + // width mismatch we set a flag option that the dashboard surfaces as an
623 + // admin notice; mainwp_notice_wp_mail_failed in class-mainwp-system.php
624 + // uses the same shape.
625 + $col_def = $this->wpdb->get_row( "SHOW COLUMNS FROM {$api_keys_table} LIKE 'consumer_secret'" ); // phpcs:ignore -- table name is internal.
626 + if ( empty( $col_def ) || false === stripos( (string) $col_def->Type, 'varchar(255)' ) ) {
627 + update_option( 'mainwp_notice_consumer_secret_migration_failed', current_time( 'mysql' ) );
628 + } else {
629 + delete_option( 'mainwp_notice_consumer_secret_migration_failed' );
630 + }
631 + }
632 + }
633 +
634 + /**
635 + * Retry a pending backup progress index repair, no more than once an hour.
636 + *
637 + * A DB user without ALTER never clears the marker, so an unthrottled retry
638 + * would run two SHOW INDEX plus a failing ALTER on every single request.
639 + *
640 + * @return bool True when a repair attempt was made.
641 + */
642 + public function maybe_retry_backup_progress_index_repair() {
643 + $pending = (int) get_site_option( self::BACKUP_PROGRESS_INDEX_REPAIR_PENDING );
644 + if ( empty( $pending ) || time() - $pending < HOUR_IN_SECONDS ) {
645 + return false;
646 + }
647 +
648 + $this->repair_backup_progress_index();
649 +
650 + return true;
651 + }
652 +
653 + /**
654 + * Run the backup progress index repair and remember whether it still needs a retry.
655 + *
656 + * @return bool True when the repair is confirmed complete.
657 + */
658 + public function repair_backup_progress_index() {
659 + // The retry from install() runs outside post_update()'s suppression; a
660 + // persistently failing ALTER must not log or print on every request.
661 + $suppress = $this->wpdb->suppress_errors();
662 + $repaired = $this->drop_backup_progress_unique_index();
663 + $this->wpdb->suppress_errors( $suppress );
664 + if ( $repaired ) {
665 + delete_site_option( self::BACKUP_PROGRESS_INDEX_REPAIR_PENDING );
666 + } else {
667 + // A timestamp, not a formatted date: the retry throttle compares it against time().
668 + update_site_option( self::BACKUP_PROGRESS_INDEX_REPAIR_PENDING, time() );
669 + }
670 + return $repaired;
671 + }
672 +
673 + /**
674 + * Drop a stray unique index on the backup progress task_id column.
675 + *
676 + * Fresh installs made between DB 8.53 and the schema fix created
677 + * wp_backup_progress with UNIQUE (task_id). Progress rows are per task and
678 + * per site, so a task covering two or more sites could never insert its
679 + * second row. dbDelta never drops an index, so the stray key has to go here.
680 + *
681 + * @return bool True when no such key remains, false when one survived a failed DROP.
682 + */
683 + public function drop_backup_progress_unique_index() {
684 + $table = $this->table_name( 'wp_backup_progress' );
685 + $keys = $this->find_backup_progress_task_id_unique_keys();
686 + if ( null === $keys ) {
687 + return false;
688 + }
689 +
690 + foreach ( $keys as $key_name ) {
691 + $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.
692 + }
693 +
694 + // suppress_errors() hides a failed DROP, so the table is the only source of truth.
695 + return array() === $this->find_backup_progress_task_id_unique_keys();
696 + }
697 +
698 + /**
699 + * Names of the unique keys on the backup progress table that cover task_id alone.
700 + *
701 + * A composite unique key over task_id and wp_id is legitimate and is left out.
702 + *
703 + * @return array|null Key names, or null when the lookup itself failed and nothing can be concluded.
704 + */
705 + protected function find_backup_progress_task_id_unique_keys() {
706 + $table = $this->table_name( 'wp_backup_progress' );
707 +
708 + // suppress_errors() is on in post_update(), so a failed SHOW INDEX looks like an empty index list; last_error is the only tell.
709 + $this->wpdb->last_error = '';
710 + $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.
711 + if ( '' !== $this->wpdb->last_error ) {
712 + return null;
713 + }
714 +
715 + $key_columns = array();
716 + foreach ( (array) $indexes as $index ) {
717 + if ( 'PRIMARY' === $index['Key_name'] ) {
718 + continue;
719 + }
720 + $key_columns[ $index['Key_name'] ][] = $index['Column_name'];
721 + }
722 +
723 + $keys = array();
724 + foreach ( $key_columns as $key_name => $columns ) {
725 + if ( array( 'task_id' ) === $columns && preg_match( '/^[A-Za-z0-9_]+$/', $key_name ) ) {
726 + $keys[] = $key_name;
727 + }
728 + }
729 +
730 + return $keys;
731 + }
732 +
733 + /**
524 734 * Method pre_update_tables()
525 735 *
526 736 * Handle pre update tables.
527 737 *
@@ -537,11 +747,15 @@
537 747
538 748 $suppress = $this->wpdb->suppress_errors();
539 749
540 750 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' );
751 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
752 + $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.
753 + foreach ( array( 'backups', 'note_lastupdate', 'pages' ) as $column ) {
754 + if ( in_array( $column, $existing_columns, true ) ) {
755 + $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.
756 + }
757 + }
544 758 }
545 759
546 760 $this->wpdb->suppress_errors( $suppress );
547 761 }
@@ -571,16 +785,21 @@
571 785 'dtsSyncStart',
572 786 'dtsAutomaticSync',
573 787 'dtsAutomaticSyncStart',
574 788 );
789 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
790 + $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
575 791 foreach ( $wpSyncColumns as $wpSyncColumn ) {
576 - $rslts = $this->wpdb->get_results( 'SELECT id,' . $wpSyncColumn . ' FROM ' . $this->table_name( 'wp' ), ARRAY_A );
792 + $wpSyncColumn = esc_sql( $wpSyncColumn );
793 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
794 + $rslts = $this->wpdb->get_results( "SELECT id,{$wpSyncColumn} FROM {$wp_table}", ARRAY_A );
577 795 if ( empty( $rslts ) ) {
578 796 continue;
579 797 }
580 798
581 799 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 );
800 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name escaped via esc_sql and query uses proper prepare.
801 + $exists = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT wpid FROM {$wp_sync_table} WHERE wpid = %d", $rslt['id'] ), ARRAY_A );
583 802 if ( empty( $exists ) ) {
584 803 $this->wpdb->insert(
585 804 $this->table_name( 'wp_sync' ),
586 805 array(
@@ -593,9 +812,10 @@
593 812 }
594 813 }
595 814
596 815 $suppress = $this->wpdb->suppress_errors();
597 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $wpSyncColumn );
816 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
817 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$wpSyncColumn}" );
598 818 $this->wpdb->suppress_errors( $suppress );
599 819 }
600 820
601 821 $optionColumns = array(
@@ -607,9 +827,11 @@
607 827 'recent_posts',
608 828 'recent_pages',
609 829 );
610 830 foreach ( $optionColumns as $optionColumn ) {
611 - $rslts = $this->wpdb->get_results( 'SELECT id,' . $optionColumn . ' FROM ' . $this->table_name( 'wp' ), ARRAY_A );
831 + $optionColumn = esc_sql( $optionColumn );
832 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
833 + $rslts = $this->wpdb->get_results( "SELECT id,{$optionColumn} FROM {$wp_table}", ARRAY_A );
612 834 if ( empty( $rslts ) ) {
613 835 continue;
614 836 }
615 837
@@ -617,9 +839,10 @@
617 839 static::update_website_option( (object) $rslt, $optionColumn, $rslt[ $optionColumn ] );
618 840 }
619 841
620 842 $suppress = $this->wpdb->suppress_errors();
621 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN ' . $optionColumn );
843 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Column name escaped via esc_sql, table name escaped separately.
844 + $this->wpdb->query( "ALTER TABLE {$wp_table} DROP COLUMN {$optionColumn}" );
622 845 $this->wpdb->suppress_errors( $suppress );
623 846 }
624 847 }
625 848 }