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 +365 -43 5.1.1 → 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.8'; // 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 .= ',
@@ -223,8 +251,9 @@
223 251 ignored_plugins longtext NOT NULL DEFAULT '',
224 252 trusted_plugins longtext NOT NULL DEFAULT '',
225 253 trusted_plugins_notes longtext NOT NULL DEFAULT '',
226 254 ignored_themes longtext NOT NULL DEFAULT '',
255 + ignored_wp_upgrades longtext NOT NULL DEFAULT '',
227 256 trusted_themes longtext NOT NULL DEFAULT '',
228 257 trusted_themes_notes longtext NOT NULL DEFAULT '',
229 258 site_view tinyint(1) NOT NULL DEFAULT '0',
230 259 pluginDir text NOT NULL DEFAULT '',
@@ -267,14 +296,12 @@
267 296 wp_group_id int(11) NOT NULL auto_increment,
268 297 wpid int(11) NOT NULL,
269 298 groupid int(11) NOT NULL,
270 299 KEY idx_wpid (wpid),
271 - KEY idx_groupid (groupid)';
272 - if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.57', '<=' ) ) {
273 - $tbl .= ',
274 - PRIMARY KEY (wp_group_id) ';
275 - }
276 - $tbl .= ') ' . $charset_collate;
300 + KEY idx_groupid (groupid),
301 + PRIMARY KEY (wp_group_id)
302 + ) ' . $charset_collate;
303 +
277 304 $sql[] = $tbl;
278 305
279 306 $tbl = 'CREATE TABLE ' . $this->table_name( 'lookup_item_objects' ) . ' (
280 307 lookup_id bigint(20) unsigned NOT NULL auto_increment,
@@ -284,9 +311,9 @@
284 311 object_name varchar(32) NOT NULL,
285 312 KEY item_id (item_id),
286 313 KEY object_id (object_id)';
287 314
288 - if ( empty( $currentVersion ) || version_compare( $currentVersion, '9.0.0.5', '<' ) ) { // NOSONAR - no IP.
315 + if ( empty( $currentVersion ) || version_compare( $currentVersion, '9.0.0.5', '<' ) ) { // NOSONAR - none IP.
289 316 $tbl .= ',
290 317 PRIMARY KEY (lookup_id) ';
291 318 }
292 319 $tbl .= ') ' . $charset_collate . ';';
@@ -291,9 +318,9 @@
291 318 }
292 319 $tbl .= ') ' . $charset_collate . ';';
293 320 $sql[] = $tbl;
294 321
295 - $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup_progress' ) . " (
322 + $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup_progress' ) . " (
296 323 task_id int(11) NOT NULL,
297 324 wp_id int(11) NOT NULL,
298 325 dtsFetched int(11) NOT NULL DEFAULT 0,
299 326 fetchResult text NOT NULL DEFAULT '',
@@ -305,12 +332,8 @@
305 332 attempts int(11) NOT NULL DEFAULT 0,
306 333 last_error text NOT NULL DEFAULT '',
307 334 pid int(11) NOT NULL DEFAULT 0,
308 335 KEY idx_task_id (task_id)";
309 - if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.53', '<=' ) ) {
310 - $tbl .= ',
311 - UNIQUE (task_id)';
312 - }
313 336 $tbl .= ') ' . $charset_collate;
314 337 $sql[] = $tbl;
315 338
316 339 $tbl = 'CREATE TABLE ' . $this->table_name( 'wp_backup' ) . ' (
@@ -348,8 +371,29 @@
348 371 }
349 372 $tbl .= ') ' . $charset_collate;
350 373 $sql[] = $tbl;
351 374
375 + $tbl = 'CREATE TABLE ' . $this->table_name( 'api_keys' ) . ' (
376 + key_id bigint(20) unsigned NOT NULL auto_increment,
377 + user_id bigint(20) unsigned NOT NULL,
378 + description varchar(200) NULL,
379 + permissions varchar(10) NOT NULL,
380 + consumer_key char(64) NOT NULL,
381 + consumer_secret varchar(255) NOT NULL,
382 + nonces longtext NULL,
383 + truncated_key char(7) NOT NULL,
384 + key_pass char(64) NOT NULL DEFAULT "",
385 + key_type tinyint(1) NOT NULL DEFAULT 0,
386 + `enabled` tinyint(1) DEFAULT 0,
387 + last_access datetime NULL default null,
388 + KEY consumer_key (consumer_key)';
389 + if ( empty( $currentVersion ) || version_compare( $currentVersion, '9.0.0.9', '<=' ) ) { // NOSONAR - none IP.
390 + $tbl .= ',
391 + PRIMARY KEY (key_id) ';
392 + }
393 + $tbl .= ') ' . $charset_collate . ';';
394 + $sql[] = $tbl;
395 +
352 396 $tbl = 'CREATE TABLE ' . $this->table_name( 'action_log' ) . " (
353 397 id int(11) NOT NULL auto_increment,
354 398 log_content mediumtext NOT NULL DEFAULT '',
355 399 log_type tinyint(1) DEFAULT 0,
@@ -359,10 +403,10 @@
359 403 if ( empty( $currentVersion ) || version_compare( $currentVersion, '8.50', '<=' ) ) {
360 404 $tbl .= ',
361 405 PRIMARY KEY (id) ';
362 406 }
363 - $tbl .= ') ' . $charset_collate . ';';
364 - $sql[] = $tbl;
407 + $tbl .= ') ' . $charset_collate . ';';
408 + $sql[] = $tbl;
365 409
366 410 $tbl = 'CREATE TABLE ' . $this->table_name( 'request_log' ) . " (
367 411 id int(11) NOT NULL auto_increment,
368 412 wpid int(11) NOT NULL,
@@ -376,10 +420,32 @@
376 420 }
377 421 $tbl .= ') ' . $charset_collate . ';';
378 422 $sql[] = $tbl;
379 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 +
380 444 $sql = apply_filters( 'mainwp_db_install_tables', $sql, $currentVersion, $charset_collate );
381 445
446 + MainWP_DB_Uptime_Monitoring::instance()->get_db_schema( $sql, $currentVersion );
447 +
382 448 require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // NOSONAR - WP compatible.
383 449
384 450 global $wpdb;
385 451
@@ -394,8 +460,10 @@
394 460 $wpdb->suppress_errors( $suppress );
395 461
396 462 $this->post_update();
397 463
464 + do_action( 'mainwp_db_after_update', $currentVersion, $this->mainwp_db_version ); // new version: $this->mainwp_db_version.
465 +
398 466 if ( ! is_multisite() ) {
399 467 MainWP_Utility::update_option( $this->option_db_key, $this->mainwp_db_version );
400 468 } else {
401 469 update_site_option( $this->option_db_key, $this->mainwp_db_version );
@@ -435,10 +503,13 @@
435 503 $sslColumns = array(
436 504 'nossl',
437 505 'nosslkey',
438 506 );
507 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
439 508 foreach ( $sslColumns as $col ) {
440 - $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}" );
441 512 }
442 513 }
443 514
444 515 // delete old columns.
@@ -453,14 +524,19 @@
453 524 'last_db_backup_size',
454 525 );
455 526
456 527 foreach ( $rankColumns as $rankColumn ) {
457 - $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}" );
458 531 }
459 532
460 - $syncColumns = array( 'uptodate' );
533 + $syncColumns = array( 'uptodate' );
534 + $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
461 535 foreach ( $syncColumns as $column ) {
462 - $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}" );
463 539 }
464 540 }
465 541
466 542 // delete old columns.
@@ -466,41 +542,274 @@
466 542 // delete old columns.
467 543 if ( version_compare( $currentVersion, '8.35', '<' ) ) {
468 544 $delColumns = array( 'offline_checks' );
469 545 foreach ( $delColumns as $column ) {
470 - $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}" );
471 549 }
472 - $delColumns = array( 'heatMap' );
550 + $delColumns = array( 'heatMap' );
551 + $users_table = esc_sql( $this->table_name( 'users' ) );
473 552 foreach ( $delColumns as $column ) {
474 - $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}" );
475 556 }
476 557 }
477 558
478 - // change columns.
479 - if ( version_compare( $currentVersion, '8.40', '<' ) && version_compare( $currentVersion, '8.30', '>' ) ) {
480 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_status' ) . ' CHANGE COLUMN `timestamp_status` `event_timestamp` int(11) NOT NULL' );
481 - }
482 -
483 559 // delete columns.
484 560 if ( version_compare( $currentVersion, '8.42', '<' ) ) {
485 561 $delColumns = array( 'offlineChecksOnlineNotification' );
486 562 foreach ( $delColumns as $column ) {
487 - $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}" );
488 566 }
489 567 }
490 568
491 569 // fix missing PRIMARY keys.
492 570 if ( version_compare( $currentVersion, '8.53', '<=' ) ) {
493 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_options' ) . ' ADD opt_id int NOT NULL AUTO_INCREMENT PRIMARY KEY' );
494 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp_settings_backup' ) . ' ADD set_id int NOT NULL AUTO_INCREMENT PRIMARY KEY' );
495 - $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" );
496 579 }
497 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 +
498 592 $this->wpdb->suppress_errors( $suppress );
499 593 MainWP_DB_Client::instance()->check_to_updates_reports_data_861( $currentVersion );
500 594 }
501 595
502 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 + /**
503 812 * Method pre_update_tables()
504 813 *
505 814 * Handle pre update tables.
506 815 *
@@ -516,11 +825,15 @@
516 825
517 826 $suppress = $this->wpdb->suppress_errors();
518 827
519 828 if ( version_compare( $currentVersion, '8.98', '<=' ) ) {
520 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN backups' );
521 - $this->wpdb->query( 'ALTER TABLE ' . $this->table_name( 'wp' ) . ' DROP COLUMN note_lastupdate' );
522 - $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 + }
523 836 }
524 837
525 838 $this->wpdb->suppress_errors( $suppress );
526 839 }
@@ -550,16 +863,21 @@
550 863 'dtsSyncStart',
551 864 'dtsAutomaticSync',
552 865 'dtsAutomaticSyncStart',
553 866 );
867 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
868 + $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
554 869 foreach ( $wpSyncColumns as $wpSyncColumn ) {
555 - $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 );
556 873 if ( empty( $rslts ) ) {
557 874 continue;
558 875 }
559 876
560 877 foreach ( $rslts as $rslt ) {
561 - $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 );
562 880 if ( empty( $exists ) ) {
563 881 $this->wpdb->insert(
564 882 $this->table_name( 'wp_sync' ),
565 883 array(
@@ -572,9 +890,10 @@
572 890 }
573 891 }
574 892
575 893 $suppress = $this->wpdb->suppress_errors();
576 - $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}" );
577 896 $this->wpdb->suppress_errors( $suppress );
578 897 }
579 898
580 899 $optionColumns = array(
@@ -586,9 +905,11 @@
586 905 'recent_posts',
587 906 'recent_pages',
588 907 );
589 908 foreach ( $optionColumns as $optionColumn ) {
590 - $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 );
591 912 if ( empty( $rslts ) ) {
592 913 continue;
593 914 }
594 915
@@ -596,9 +917,10 @@
596 917 static::update_website_option( (object) $rslt, $optionColumn, $rslt[ $optionColumn ] );
597 918 }
598 919
599 920 $suppress = $this->wpdb->suppress_errors();
600 - $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}" );
601 923 $this->wpdb->suppress_errors( $suppress );
602 924 }
603 925 }
604 926 }