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-db.php +262 -52 6.1.2 → 6.2.2 View file →
@@ -83,8 +83,37 @@
83 83 return static::$instance;
84 84 }
85 85
86 86 /**
87 + * Keep only option names that are safe to splice into SQL as an identifier.
88 + *
89 + * Every wp_options view builder below puts each name in identifier position
90 + * (column and table aliases), where escape()/esc_sql() is not a defense: it
91 + * escapes quotes but leaves backticks alone, so a name like "a`, (SELECT ...)
92 + * AS `b" breaks out of the alias. Callers reach these through ?custom_fields on
93 + * the REST sites endpoints and through the view_fields param of
94 + * get_sql_website_by_params(). Anything outside [A-Za-z0-9_] is dropped rather
95 + * than escaped, which also makes the surviving escape() calls no-ops.
96 + *
97 + * @param array $fields Option names.
98 + *
99 + * @return array Option names safe for identifier position.
100 + */
101 + protected function filter_safe_option_names( $fields ) {
102 + if ( ! is_array( $fields ) ) {
103 + return array();
104 + }
105 + return array_values(
106 + array_filter(
107 + $fields,
108 + function ( $name ) {
109 + return is_string( $name ) && preg_match( '/^[A-Za-z0-9_]+$/', $name );
110 + }
111 + )
112 + );
113 + }
114 +
115 + /**
87 116 * Get wp_options database table view.
88 117 *
89 118 * @compatible function.
90 119 *
@@ -124,8 +153,10 @@
124 153 if ( ! in_array( 'cust_site_icon_info', $fields, true ) ) {
125 154 $fields[] = 'cust_site_icon_info';
126 155 }
127 156
157 + $fields = $this->filter_safe_option_names( $fields );
158 +
128 159 if ( is_array( $fields ) ) {
129 160 foreach ( $fields as $field ) {
130 161 if ( empty( $field ) ) {
131 162 continue;
@@ -133,9 +164,9 @@
133 164 if ( in_array( $field, $included_opts ) ) {
134 165 continue;
135 166 }
136 167 $view .= ', ';
137 - $view .= '(SELECT ' . $this->escape( $field ) . '.value FROM ' . $this->table_name( 'wp_options' ) . ' ' . $this->escape( $field ) . ' WHERE ' . $this->escape( $field ) . '.wpid = intwp.id AND ' . $this->escape( $field ) . '.name = "' . $this->escape( $field ) . '" LIMIT 1) AS ' . $this->escape( $field );
168 + $view .= '(SELECT `' . $this->escape( $field ) . '`.value FROM ' . $this->table_name( 'wp_options' ) . ' `' . $this->escape( $field ) . '` WHERE `' . $this->escape( $field ) . '`.wpid = intwp.id AND `' . $this->escape( $field ) . '`.name = "' . $this->escape( $field ) . '" LIMIT 1) AS `' . $this->escape( $field ) . '`';
138 169 }
139 170 }
140 171
141 172 $view .= ' FROM ' . $this->table_name( 'wp' ) . ' intwp)';
@@ -145,11 +176,11 @@
145 176
146 177 /**
147 178 * Method get_wp_options_join().
148 179 *
149 - * @param array $fields Extra option fields.
150 - * @param string $view_query view query.
151 - * @param array $params Additional parameters.
180 + * @param array $fields Extra option fields.
181 + * @param string $view_query view query.
182 + * @param array<string, mixed> $params Additional parameters.
152 183
153 184 *
154 185 * NOTE: This method is used to improve the performance of wp_options view, as the old view with subquery for each field will cause performance issue when there are many sites, and this method will generate the SQL with LEFT JOIN which will be much faster than subquery.
155 186 * The alias of wp table must be 'wp' to make sure the LEFT JOIN works, and the alias of wp_options table will be 'owp' + key of field in fields array, and the join condition is owp.wpid = wp.id AND owp.name = field name, and the select field is owp.value AS field name.
@@ -184,9 +215,9 @@
184 215 if ( ! in_array( 'cust_site_icon_info', $fields, true ) ) {
185 216 $fields[] = 'cust_site_icon_info';
186 217 }
187 218
188 - $fields = array_values( array_unique( array_filter( $fields ) ) );
219 + $fields = array_values( array_unique( array_filter( $this->filter_safe_option_names( $fields ) ) ) );
189 220
190 221 $tbl_wp_options = $this->table_name( 'wp_options' );
191 222
192 223 $selects = array();
@@ -257,8 +288,10 @@
257 288 if ( ! in_array( 'cust_site_icon_info', $fields, true ) ) {
258 289 $fields[] = 'cust_site_icon_info';
259 290 }
260 291
292 + $fields = $this->filter_safe_option_names( $fields );
293 +
261 294 if ( is_array( $fields ) ) {
262 295 foreach ( $fields as $field ) {
263 296 if ( empty( $field ) ) {
264 297 continue;
@@ -266,9 +299,9 @@
266 299 if ( in_array( $field, $included_opts ) ) {
267 300 continue;
268 301 }
269 302 $view .= ', ';
270 - $view .= 'MAX(CASE WHEN name = "' . $this->escape( $field ) . '" THEN value END) AS ' . $this->escape( $field );
303 + $view .= 'MAX(CASE WHEN name = "' . $this->escape( $field ) . '" THEN value END) AS `' . $this->escape( $field ) . '`';
271 304
272 305 $included_opts[] = $this->escape( $field );
273 306 }
274 307 }
@@ -309,9 +342,10 @@
309 342 // legacy support.
310 343 $selectgroups = isset( $params['with_tags'] ) && $params['with_tags'] ? true : false;
311 344 $orderBy = isset( $params['orderby'] ) ? $params['orderby'] : 'wp.url';
312 345 $offset = isset( $params['offset'] ) ? intval( $params['offset'] ) : false;
313 - $rowcount = isset( $params['rowcount'] ) && $params['rowcount'] ? true : false;
346 + $rowcount = isset( $params['rowcount'] ) ? (int) $params['rowcount'] : false;
347 + $count_sql = isset( $params['count_sql'] ) && $params['count_sql'] ? true : false;
314 348 $extraWhere = isset( $params['where'] ) ? $params['where'] : null; // NOTE: without 'AND' at begining and ending of 'where'.
315 349 $for_manager = isset( $params['for_manager'] ) && $params['for_manager'] ? true : false;
316 350 $others_fields = isset( $params['others_fields'] ) && is_array( $params['others_fields'] ) ? $params['others_fields'] : array( 'favi_icon' );
317 351 $is_staging = isset( $params['is_staging'] ) && in_array( $params['is_staging'], array( 'yes', 'no' ) ) ? $params['is_staging'] : 'no';
@@ -395,9 +429,9 @@
395 429 $status_conds = array();
396 430 if ( in_array( 'available_update', $status ) ) {
397 431 $available_sql = " ( wp.plugin_upgrades <> '' && wp.plugin_upgrades <> '[]' ) OR ( wp.theme_upgrades <> '' && wp.theme_upgrades <> '[]' ) OR ( wp.translation_upgrades <> '' && wp.translation_upgrades <> '[]' ) OR ( wp.premium_upgrades <> '' && wp.premium_upgrades <> '[]' ) ";
398 432 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
399 - $results = $this->wpdb->get_results( "SELECT wpid FROM {$table_name} WHERE name = 'wp_upgrades' AND value <> '' AND value <> '[]'" );
433 + $results = $this->wpdb->get_results( "SELECT wpid FROM {$table_name} WHERE name = 'wp_upgrades' AND value <> '' AND value <> '[]'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; the rest of the query is a literal.
400 434 if ( $results ) {
401 435 $wp_ids = array();
402 436 foreach ( $results as $item ) {
403 437 if ( ! empty( $item->wpid ) ) {
@@ -474,8 +508,9 @@
474 508 'wp.userid',
475 509 'wp.plugins',
476 510 'wp.themes',
477 511 'wp.offline_check_result', // 1 - online, -1 offline.
512 + 'wp.automatic_update',
478 513 );
479 514
480 515 $select = ' wp.*,wp_sync.* ';
481 516 if ( 'base_view' === $view ) {
@@ -507,18 +542,19 @@
507 542 }
508 543
509 544 // wpgroups to fix issue for mysql 8.0, as groups will generate error syntax.
510 545 if ( $selectgroups ) {
511 - $qry = 'SELECT ' . $select . $view_selects . ', GROUP_CONCAT(gr.name ORDER BY gr.name SEPARATOR ",") as wpgroups, GROUP_CONCAT(gr.id ORDER BY gr.name SEPARATOR ",") as wpgroupids, GROUP_CONCAT(gr.color ORDER BY gr.name SEPARATOR ",") as wpgroups_colors ' .
512 - $select_clients . '
546 + $select_qry = 'SELECT ' . $select . $view_selects . ', GROUP_CONCAT(gr.name ORDER BY gr.name SEPARATOR ",") as wpgroups, GROUP_CONCAT(gr.id ORDER BY gr.name SEPARATOR ",") as wpgroupids, GROUP_CONCAT(gr.color ORDER BY gr.name SEPARATOR ",") as wpgroups_colors ' .
547 + $select_clients;
513 548
514 - FROM ' . $this->table_name( 'wp' ) . ' wp
549 + $qry = ' FROM ' . $this->table_name( 'wp' ) . ' wp
515 550 LEFT JOIN ' . $this->table_name( 'wp_group' ) . ' wpgr ON wp.id = wpgr.wpid
516 551 LEFT JOIN ' . $this->table_name( 'group' ) . ' gr ON wpgr.groupid = gr.id
517 552 ' . $join_clients . '
518 553 JOIN ' . $this->table_name( 'wp_sync' ) . ' wp_sync ON wp.id = wp_sync.wpid
519 554 ' . $view_joins . '
520 - WHERE 1 ' . $where . $connected_sql . '
555 + WHERE 1 ' . $where . $connected_sql;
556 + $group_qry = '
521 557 GROUP BY wp.id, wp_sync.sync_id
522 558 ORDER BY ' . $orderBy;
523 559 } elseif ( ! empty( $specific_wp_fields ) ) { // Optimize select sites data.
524 560 $select = $specific_wp_fields;
@@ -529,25 +565,33 @@
529 565 $select .= ',' . $specific_sync_fields;
530 566 $join_sync = ' JOIN ' . $this->table_name( 'wp_sync' ) . ' wp_sync ON wp.id = wp_sync.wpid';
531 567 $group_by .= ', wp_sync.sync_id';
532 568 }
533 - $qry = 'SELECT ' . $select . $view_selects . '
534 - FROM ' . $this->table_name( 'wp' ) . ' wp
569 + $select_qry = 'SELECT ' . $select . $view_selects;
570 + $qry = ' FROM ' . $this->table_name( 'wp' ) . ' wp
535 571 ' . $join_sync . ' ' . $view_joins . '
536 - WHERE 1 ' . $where . $connected_sql . '
572 + WHERE 1 ' . $where . $connected_sql;
573 + $group_qry = '
537 574 GROUP BY ' . $group_by . '
538 575 ORDER BY ' . $orderBy;
539 576 } else {
540 - $qry = 'SELECT ' . $select . $view_selects . $select_clients . '
541 - FROM ' . $this->table_name( 'wp' ) . ' wp
577 + $select_qry = 'SELECT ' . $select . $view_selects . $select_clients;
578 + $qry = ' FROM ' . $this->table_name( 'wp' ) . ' wp
542 579 ' . $join_clients . '
543 580 JOIN ' . $this->table_name( 'wp_sync' ) . ' wp_sync ON wp.id = wp_sync.wpid
544 581 ' . $view_joins . '
545 - WHERE 1 ' . $where . $connected_sql . '
582 + WHERE 1 ' . $where . $connected_sql;
583 + $group_qry = '
546 584 GROUP BY wp.id, wp_sync.sync_id
547 585 ORDER BY ' . $orderBy;
548 586 }
549 587
588 + if ( $count_sql ) {
589 + return 'SELECT COUNT(DISTINCT wp.id) ' . $qry;
590 + }
591 +
592 + $qry = $select_qry . $qry . $group_qry;
593 +
550 594 if ( ( false !== $offset ) && ( false !== $rowcount ) ) {
551 595 $qry .= ' LIMIT ' . $offset . ', ' . $rowcount;
552 596 } elseif ( false !== $rowcount ) {
553 597 $qry .= ' LIMIT ' . $rowcount;
@@ -599,10 +643,11 @@
599 643 'ignored_trans_updates',
600 644 );
601 645 } elseif ( in_array( $view, array( 'simple_view', 'base_view', 'monitor_view', 'ping_view', 'uptime_notification' ) ) ) {
602 646 $fields = array();
603 - if ( 'monitor_view' === $view ) {
647 + if ( 'monitor_view' === $view || 'ping_view' === $view ) {
604 648 $fields[] = 'health_site_status';
649 + $fields[] = 'bypass_cache';
605 650 }
606 651 } elseif ( 'custom_view' !== $view ) {
607 652 $fields = $default;
608 653 }
@@ -620,9 +665,9 @@
620 665 $fields[] = 'verify_method';
621 666 }
622 667 }
623 668
624 - $fields = array_values( array_filter( $fields ) );
669 + $fields = array_values( array_filter( $this->filter_safe_option_names( $fields ) ) );
625 670
626 671 $tbl_wp_options = $this->table_name( 'wp_options' );
627 672
628 673 $selects = array();
@@ -676,10 +721,11 @@
676 721 'ignored_trans_updates',
677 722 );
678 723 } elseif ( in_array( $view, array( 'simple_view', 'base_view', 'monitor_view', 'ping_view', 'uptime_notification' ) ) ) {
679 724 $fields = array();
680 - if ( 'monitor_view' === $view ) {
725 + if ( 'monitor_view' === $view || 'ping_view' === $view ) {
681 726 $fields[] = 'health_site_status';
727 + $fields[] = 'bypass_cache';
682 728 }
683 729 } elseif ( 'custom_view' !== $view ) {
684 730 $fields = $default;
685 731 }
@@ -699,8 +745,10 @@
699 745 $fields[] = 'verify_method';
700 746 }
701 747 }
702 748
749 + $fields = $this->filter_safe_option_names( $fields );
750 +
703 751 foreach ( $fields as $field ) {
704 752
705 753 if ( empty( $field ) ) {
706 754 continue;
@@ -706,9 +754,9 @@
706 754 continue;
707 755 }
708 756
709 757 $view_query .= ', ';
710 - $view_query .= 'MAX(CASE WHEN name = "' . $this->escape( $field ) . '" THEN value END) AS ' . $this->escape( $field );
758 + $view_query .= 'MAX(CASE WHEN name = "' . $this->escape( $field ) . '" THEN value END) AS `' . $this->escape( $field ) . '`';
711 759 }
712 760
713 761 $view_query .= ' FROM ' . $this->table_name( 'wp_options' ) .
714 762 " WHERE name IN ('" . implode( "','", $fields ) . "')
@@ -926,9 +974,9 @@
926 974 return false;
927 975 }
928 976
929 977 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
930 - $value = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT value FROM {$table_name} WHERE wpid = %d AND name = %s", $site_id, $option ) );
978 + $value = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT value FROM {$table_name} WHERE wpid = %d AND name = %s", $site_id, $option ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
931 979
932 980 if ( null === $value && null !== $default_value ) {
933 981 return $default_value;
934 982 }
@@ -1006,9 +1054,9 @@
1006 1054 }
1007 1055
1008 1056 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1009 1057 $placeholders = implode( ',', array_fill( 0, count( $get_options ), '%s' ) );
1010 - $options_db = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name, value FROM {$table_name} WHERE wpid = %d AND name IN ({$placeholders})", array_merge( array( $site_id ), $get_options ) ) );
1058 + $options_db = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name, value FROM {$table_name} WHERE wpid = %d AND name IN ({$placeholders})", array_merge( array( $site_id ), $get_options ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; $placeholders is a generated list of %s.
1011 1059
1012 1060 $fill_options = array(
1013 1061 'primary_lasttime_backup',
1014 1062 );
@@ -1045,9 +1093,9 @@
1045 1093 $site_id = $website->id;
1046 1094 }
1047 1095
1048 1096 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1049 - $rslt = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name FROM {$table_name} WHERE wpid = %d AND name = %s", $site_id, $option ) );
1097 + $rslt = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name FROM {$table_name} WHERE wpid = %d AND name = %s", $site_id, $option ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
1050 1098 if ( empty( $rslt ) ) {
1051 1099 $this->wpdb->insert(
1052 1100 $this->table_name( 'wp_options' ),
1053 1101 array(
@@ -1092,9 +1140,9 @@
1092 1140 }
1093 1141
1094 1142 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1095 1143 foreach ( $options as $opt ) {
1096 - $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$table_name} WHERE wpid=%d AND name=%s", $site_id, $opt ) );
1144 + $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$table_name} WHERE wpid=%d AND name=%s", $site_id, $opt ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
1097 1145 }
1098 1146 }
1099 1147
1100 1148
@@ -1115,9 +1163,9 @@
1115 1163 static::$general_options[] = array();
1116 1164 }
1117 1165
1118 1166 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1119 - $val = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT value FROM {$table_name} WHERE wpid = %d AND name = %s", 0, $option ) );
1167 + $val = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT value FROM {$table_name} WHERE wpid = %d AND name = %s", 0, $option ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
1120 1168
1121 1169 static::$general_options[ $option ] = $val;
1122 1170 return $val;
1123 1171 }
@@ -1158,9 +1206,9 @@
1158 1206 }
1159 1207
1160 1208 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1161 1209 $placeholders = implode( ',', array_fill( 0, count( $diff_options ), '%s' ) );
1162 - $options_db = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name, value FROM {$table_name} WHERE wpid = %d AND name IN ({$placeholders})", array_merge( array( 0 ), $diff_options ) ) );
1210 + $options_db = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name, value FROM {$table_name} WHERE wpid = %d AND name IN ({$placeholders})", array_merge( array( 0 ), $diff_options ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; $placeholders is a generated list of %s.
1163 1211
1164 1212 foreach ( (array) $options_db as $o ) {
1165 1213 $return_options[ $o->name ] = $o->value;
1166 1214 static::$general_options[ $o->name ] = $o->value;
@@ -1191,9 +1239,9 @@
1191 1239 }
1192 1240 static::$general_options[ $option ] = $value;
1193 1241
1194 1242 $table_name = esc_sql( $this->table_name( 'wp_options' ) );
1195 - $rslt = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name FROM {$table_name} WHERE wpid = %d AND name = %s", 0, $option ) );
1243 + $rslt = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT name FROM {$table_name} WHERE wpid = %d AND name = %s", 0, $option ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
1196 1244
1197 1245 if ( empty( $rslt ) ) {
1198 1246 $this->wpdb->insert(
1199 1247 $this->table_name( 'wp_options' ),
@@ -1462,9 +1510,9 @@
1462 1510 $status_conds = array();
1463 1511 if ( in_array( 'available_update', $status ) ) {
1464 1512 $available_sql = " ( wp.plugin_upgrades <> '' && wp.plugin_upgrades <> '[]' ) OR ( wp.theme_upgrades <> '' && wp.theme_upgrades <> '[]' ) OR ( wp.translation_upgrades <> '' && wp.translation_upgrades <> '[]' ) OR ( wp.premium_upgrades <> '' && wp.premium_upgrades <> '[]' ) ";
1465 1513 $options_table = esc_sql( $this->table_name( 'wp_options' ) );
1466 - $results = $this->wpdb->get_results( "SELECT wpid FROM {$options_table} WHERE name = 'wp_upgrades' AND value <> '' AND value <> '[]'" );
1514 + $results = $this->wpdb->get_results( "SELECT wpid FROM {$options_table} WHERE name = 'wp_upgrades' AND value <> '' AND value <> '[]'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; the rest of the query is a literal.
1467 1515 if ( $results ) {
1468 1516 $wp_ids = array();
1469 1517 foreach ( $results as $item ) {
1470 1518 if ( ! empty( $item->wpid ) ) {
@@ -1924,9 +1972,12 @@
1924 1972 $obj_data = MainWP_Utility::map_site( $website, $data );
1925 1973
1926 1974 if ( $full_data ) {
1927 1975 $sum_upgrades = 0;
1928 - if ( '' !== $obj_data->plugin_upgrades ) {
1976 + // The option-backed columns are SQL NULL until a first sync writes them, and
1977 + // map_site() copies a null property through, so an '' check alone leaves
1978 + // json_decode() a null on PHP 8.1+.
1979 + if ( ! empty( $obj_data->plugin_upgrades ) ) {
1929 1980 $plugin_upgrades = json_decode( $obj_data->plugin_upgrades, true );
1930 1981 if ( is_array( $plugin_upgrades ) ) {
1931 1982 $sum_upgrades += count( $plugin_upgrades );
1932 1983 }
@@ -1931,9 +1982,9 @@
1931 1982 $sum_upgrades += count( $plugin_upgrades );
1932 1983 }
1933 1984 }
1934 1985
1935 - if ( '' !== $obj_data->theme_upgrades ) {
1986 + if ( ! empty( $obj_data->theme_upgrades ) ) {
1936 1987 $theme_upgrades = json_decode( $obj_data->theme_upgrades, true );
1937 1988 if ( is_array( $theme_upgrades ) ) {
1938 1989 $sum_upgrades += count( $theme_upgrades );
1939 1990 }
@@ -1938,9 +1989,9 @@
1938 1989 $sum_upgrades += count( $theme_upgrades );
1939 1990 }
1940 1991 }
1941 1992
1942 - if ( '' !== $obj_data->wp_upgrades ) {
1993 + if ( ! empty( $obj_data->wp_upgrades ) ) {
1943 1994 $wp_upgrades = json_decode( $obj_data->wp_upgrades, true );
1944 1995 if ( is_array( $wp_upgrades ) ) {
1945 1996 $sum_upgrades += count( $wp_upgrades );
1946 1997 }
@@ -2914,9 +2965,36 @@
2914 2965
2915 2966 return (int) $this->wpdb->get_var( $qry ); //phpcs:ignore PluginCheck.Security.DirectDB.Unprepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where_allowed is from validated get_sql_where_access_sites() with numeric IDs.
2916 2967 }
2917 2968
2969 +
2918 2970 /**
2971 + * Get child site connection status counts.
2972 + *
2973 + * @return array|null {
2974 + * Connection status counts.
2975 + *
2976 + * @type int $total_sites Total number of sites.
2977 + * @type int $connected_sites Number of connected sites.
2978 + * @type int $disconnected_sites Number of disconnected sites.
2979 + * }
2980 + */
2981 + public function get_sites_connections_status() {
2982 +
2983 + $is_staging = 'no';
2984 +
2985 + $sites_table = $this->table_name( 'wp' );
2986 + $sync_table = $this->table_name( 'wp_sync' );
2987 +
2988 + return $this->wpdb->get_row(
2989 + "SELECT COUNT(wp.id) AS total_sites, SUM(CASE WHEN s.sync_errors = '' THEN 1 ELSE 0 END) AS connected_sites, SUM(CASE WHEN s.sync_errors <> '' THEN 1 ELSE 0 END) AS disconnected_sites FROM {$sites_table} wp JOIN {$sync_table} s ON s.wpid = wp.id " . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names come from table_name(), which is the DB prefix plus a hardcoded suffix.
2990 + $this->get_sql_where_allow_access_sites( 'wp', $is_staging ),
2991 + ARRAY_A
2992 + );
2993 + }
2994 +
2995 +
2996 + /**
2919 2997 * Get child sites by group id via SQL.
2920 2998 *
2921 2999 * @param int $id Group ID.
2922 3000 * @param bool $selectgroups Selected groups. Default: false.
@@ -3083,9 +3161,9 @@
3083 3161 * @return string|null Child site IP address or null on failure.
3084 3162 */
3085 3163 public function get_wp_ip( $wpid ) {
3086 3164 $table_name = esc_sql( $this->table_name( 'request_log' ) );
3087 - return $this->wpdb->get_var( $this->wpdb->prepare( "SELECT ip FROM {$table_name} WHERE wpid = %d", $wpid ) );
3165 + return $this->wpdb->get_var( $this->wpdb->prepare( "SELECT ip FROM {$table_name} WHERE wpid = %d", $wpid ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3088 3166 }
3089 3167
3090 3168 /**
3091 3169 * Add website to the MainWP Dashboard.
@@ -3124,8 +3202,12 @@
3124 3202 $http_pass = isset( $params['http_pass'] ) ? $params['http_pass'] : null;
3125 3203 $sslVersion = isset( $params['sslVersion'] ) ? $params['sslVersion'] : 0;
3126 3204 $wpe = isset( $params['wpe'] ) ? $params['wpe'] : 0;
3127 3205 $isStaging = isset( $params['isStaging'] ) ? $params['isStaging'] : 0;
3206 + // 2 means "use the global mainwp_forceUseIPv4 option" and mainwp_string_to_bool()
3207 + // collapses it to 0, so it is kept before the boolean conversion. Null or absent
3208 + // stays null and leaves the NOT NULL column at its default.
3209 + $force_use_ipv4 = isset( $params['force_use_ipv4'] ) ? ( 2 === (int) $params['force_use_ipv4'] ? 2 : (int) mainwp_string_to_bool( $params['force_use_ipv4'] ) ) : null;
3128 3210
3129 3211 // MWP-1548: encrypt http_user / http_pass at rest. Empty / null
3130 3212 // values pass through unchanged (encrypt_credential is a no-op
3131 3213 // for those). A non-empty value that fails to encrypt produces
@@ -3184,8 +3266,12 @@
3184 3266 'wpe' => $wpe,
3185 3267 'is_staging' => $isStaging,
3186 3268 );
3187 3269
3270 + if ( null !== $force_use_ipv4 ) {
3271 + $values['force_use_ipv4'] = $force_use_ipv4;
3272 + }
3273 +
3188 3274 $syncValues = array(
3189 3275 'dtsSync' => 0,
3190 3276 'dtsSyncStart' => 0,
3191 3277 'dtsAutomaticSync' => 0,
@@ -3230,8 +3316,26 @@
3230 3316 'groupid' => $groupid,
3231 3317 )
3232 3318 );
3233 3319 }
3320 +
3321 + // Add primary monitor, use global settings.
3322 + $success = MainWP_DB_Uptime_Monitoring::instance()->update_wp_monitor(
3323 + array(
3324 + 'wpid' => $websiteid,
3325 + 'suburl' => '',
3326 + )
3327 + );
3328 +
3329 + $opt_name = 'mainwp_primary_monitor_repair_attempts_for_wp_' . $websiteid;
3330 +
3331 + if ( ! $success ) {
3332 + update_option( $opt_name, 1 );
3333 + MainWP_Utility::unhide_mainwp_message( 'failed-attempt-create-primary-monitor-' . $websiteid );
3334 + } elseif ( get_option( $opt_name ) ) {
3335 + delete_option( $opt_name );
3336 + }
3337 +
3234 3338 MainWP_Manage_Sites_List_Table::invalidate_manage_sites_cache();
3235 3339 return $websiteid;
3236 3340 }
3237 3341 }
@@ -3254,9 +3358,9 @@
3254 3358 $this->wpdb->delete( $this->table_name( 'wp_group' ), array( 'wpid' => $websiteid ) );
3255 3359 $this->wpdb->delete( $this->table_name( 'wp_sync' ), array( 'wpid' => $websiteid ) );
3256 3360 $this->wpdb->delete( $this->table_name( 'wp_options' ), array( 'wpid' => $websiteid ) );
3257 3361 MainWP_Encrypt_Data_Lib::remove_key_file( $websiteid );
3258 - MainWP_DB_Uptime_Monitoring::instance()->delete_monitor( array( 'wpid' => $websiteid ) );
3362 + MainWP_DB_Uptime_Monitoring::instance()->delete_site_monitors( $websiteid );
3259 3363 MainWP_Manage_Sites_List_Table::invalidate_manage_sites_cache();
3260 3364 return $nr;
3261 3365 }
3262 3366
@@ -3294,9 +3398,9 @@
3294 3398 }
3295 3399 // Lock the data stream to prevent other processes from updating at the same time.
3296 3400 $table_name = esc_sql( $this->table_name( 'wp' ) );
3297 3401 $sql = $this->wpdb->prepare(
3298 - "SELECT * FROM {$table_name} WHERE id = %d FOR UPDATE",
3402 + "SELECT * FROM {$table_name} WHERE id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3299 3403 $websiteid
3300 3404 );
3301 3405 $this->wpdb->get_row( $sql );
3302 3406
@@ -3518,9 +3622,9 @@
3518 3622 $url .= '/';
3519 3623 }
3520 3624 $wp_table = esc_sql( $this->table_name( 'wp' ) );
3521 3625 $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
3522 - $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.url = %s", $url ), OBJECT );
3626 + $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.url = %s", $url ), OBJECT ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd.
3523 3627 if ( $results ) {
3524 3628 return $results;
3525 3629 }
3526 3630
@@ -3532,9 +3636,9 @@
3532 3636 $url = str_replace( 'https://', 'https://www.', $url );
3533 3637 $url = str_replace( 'http://', 'http://www.', $url );
3534 3638 }
3535 3639
3536 - $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.url = %s", $url ), OBJECT );
3640 + $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.url = %s", $url ), OBJECT ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd.
3537 3641 if ( $results ) {
3538 3642 return $results;
3539 3643 }
3540 3644
@@ -3539,12 +3643,68 @@
3539 3643 }
3540 3644
3541 3645 $url = str_replace( array( 'https://www.', 'http://www.', 'https://', 'http://', 'www.' ), array( '', '', '', '', '' ), $url );
3542 3646
3543 - return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE replace(replace(replace(replace(replace(wp.url, 'https://www.',''), 'http://www.',''), 'https://', ''), 'http://', ''), 'www.', '') = %s", $url ), OBJECT );
3647 + return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE replace(replace(replace(replace(replace(wp.url, 'https://www.',''), 'http://www.',''), 'https://', ''), 'http://', ''), 'www.', '') = %s", $url ), OBJECT ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd.
3544 3648 }
3545 3649
3546 3650 /**
3651 + * Get recently-synced child sites whose stored url differs from the
3652 + * child-reported siteurl.
3653 + *
3654 + * The trailing slash must be normalized in SQL: stored urls always end
3655 + * with a slash while child-reported siteurl values never do, so a plain
3656 + * inequality would match nearly every site.
3657 + *
3658 + * @since 6.2
3659 + *
3660 + * @param int $days Freshness gate: only sites synced within this many days.
3661 + *
3662 + * @return array|object|null Rows with id, url, siteurl or null on failure.
3663 + */
3664 + public function get_websites_with_url_siteurl_mismatch( $days = 30 ) {
3665 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
3666 + $wp_sync_table = esc_sql( $this->table_name( 'wp_sync' ) );
3667 + $minimum_sync = time() - intval( $days ) * DAY_IN_SECONDS;
3668 +
3669 + return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT wp.id, wp.url, wp.siteurl FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid WHERE wp.siteurl <> '' AND TRIM(TRAILING '/' FROM wp.url) <> TRIM(TRAILING '/' FROM wp.siteurl) AND wp_sync.dtsSync >= %d", $minimum_sync ), OBJECT ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd.
3670 + }
3671 +
3672 + /**
3673 + * Get IDs of child sites that have a non-empty value for a website option.
3674 + *
3675 + * @since 6.2
3676 + *
3677 + * @param string $option_name Website option name.
3678 + * @param int $limit Maximum number of IDs to return.
3679 + *
3680 + * @return array Site IDs.
3681 + */
3682 + public function get_website_ids_with_nonempty_option( $option_name, $limit = 3 ) {
3683 + $options_table = esc_sql( $this->table_name( 'wp_options' ) );
3684 +
3685 + return $this->wpdb->get_col( $this->wpdb->prepare( "SELECT wpid FROM {$options_table} WHERE name = %s AND value <> '' ORDER BY wpid ASC LIMIT %d", $option_name, max( 1, intval( $limit ) ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3686 + }
3687 +
3688 + /**
3689 + * Get one child site whose stored url exactly matches the given URL,
3690 + * excluding a site ID. Used as the duplicate guard before a URL correction.
3691 + *
3692 + * @since 6.2
3693 + *
3694 + * @param string $url URL to match ( trailing slash enforced ).
3695 + * @param int $exclude_id Site ID to exclude.
3696 + *
3697 + * @return object|null Matching row ( id, url ) or null.
3698 + */
3699 + public function get_website_by_exact_url_excluding_id( $url, $exclude_id ) {
3700 + $url = rtrim( (string) $url, '/' );
3701 + $wp_table = esc_sql( $this->table_name( 'wp' ) );
3702 +
3703 + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT id, url FROM {$wp_table} WHERE TRIM(TRAILING '/' FROM url) = %s AND id <> %d", $url, intval( $exclude_id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
3704 + }
3705 +
3706 + /**
3547 3707 * Method get_websites_to_notice_health_threshold()
3548 3708 *
3549 3709 * Get websites to notice site health.
3550 3710 *
@@ -3578,12 +3738,9 @@
3578 3738 $view_selects = ',' . $opts_view['selects'];
3579 3739 $view_joins = $opts_view['joins'];
3580 3740 }
3581 3741 return $this->wpdb->get_results( // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $option_view is a validated SQL subquery
3582 - "SELECT wp.*,wp_sync.* {$view_selects} FROM {$wp_table} wp
3583 - JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid
3584 - {$view_joins}
3585 - WHERE wp.disable_health_check <> 1 AND wp.offline_check_result = 1 AND ( {$where_global_threshold} OR{$where_site_threshold} ) AND wp_sync.health_site_noticed = 0 " .
3742 + "SELECT wp.*,wp_sync.* {$view_selects} FROM {$wp_table} wp JOIN {$wp_sync_table} wp_sync ON wp.id = wp_sync.wpid {$view_joins} WHERE wp.disable_health_check <> 1 AND wp.offline_check_result = 1 AND ( {$where_global_threshold} OR{$where_site_threshold} ) AND wp_sync.health_site_noticed = 0 " . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names are esc_sql'd, the view fragments come from get_wp_options_join(), which allowlists option names, and both threshold fragments are literals picked above.
3586 3743 $where . ' GROUP BY wp.id ',
3587 3744 OBJECT
3588 3745 );
3589 3746 }
@@ -3609,10 +3766,9 @@
3609 3766 }
3610 3767
3611 3768 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- is a validated SQL subquery.
3612 3769 return $this->wpdb->get_results(
3613 - "SELECT wp.*{$view_selects} FROM {$wp_table} wp
3614 - {$view_joins}" . ' WHERE wp.suspended = 0 AND wp.http_code_noticed = 0 AND wp.offline_check_result = -1 ' .
3770 + "SELECT wp.*{$view_selects} FROM {$wp_table} wp {$view_joins}" . ' WHERE wp.suspended = 0 AND wp.http_code_noticed = 0 AND wp.offline_check_result = -1 ' . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd; the view fragments come from get_wp_options_join(), which allowlists option names.
3615 3771 $where . ' GROUP BY wp.id ',
3616 3772 OBJECT
3617 3773 );
3618 3774 }
@@ -3861,18 +4017,28 @@
3861 4017 *
3862 4018 * @return mixed Result
3863 4019 */
3864 4020 public function insert_lookup_item( $item_name, $item_id, $obj_name, $obj_id ) {
4021 + // get_lookup_items() and delete_lookup_items() match item_name as given, so a
4022 + // name that sanitizing would alter is refused instead of being stored under a
4023 + // value the caller can never read back or delete.
4024 + if ( ! is_string( $item_name ) || sanitize_text_field( $item_name ) !== $item_name ) {
4025 + return false;
4026 + }
3865 4027 if ( empty( $item_name ) || empty( $item_id ) || empty( $obj_name ) || empty( $obj_id ) ) {
3866 4028 return false;
3867 4029 }
3868 4030 $data = array(
3869 - 'item_name' => 'cost',
4031 + 'item_name' => $item_name,
3870 4032 'item_id' => $item_id,
3871 4033 'object_name' => $obj_name,
3872 4034 'object_id' => $obj_id,
3873 4035 );
3874 - $this->wpdb->insert( $this->table_name( 'lookup_item_objects' ), $data );
4036 + // wpdb refuses a value past the column length before any SQL runs, and
4037 + // insert_id would still hold the previous insert's id.
4038 + if ( false === $this->wpdb->insert( $this->table_name( 'lookup_item_objects' ), $data ) ) {
4039 + return false;
4040 + }
3875 4041 return $this->wpdb->insert_id; // must return lookup id.
3876 4042 }
3877 4043
3878 4044 /**
@@ -4047,9 +4213,9 @@
4047 4213 * @return bool result.
4048 4214 */
4049 4215 public function is_existed_enabled_rest_key() {
4050 4216 $table_name = esc_sql( $this->table_name( 'api_keys' ) );
4051 - $enabled = $this->wpdb->get_row( "SELECT * FROM {$table_name} WHERE enabled = 1 LIMIT 1" );
4217 + $enabled = $this->wpdb->get_row( "SELECT * FROM {$table_name} WHERE enabled = 1 LIMIT 1" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4052 4218 return $enabled ? true : false;
4053 4219 }
4054 4220
4055 4221 /**
@@ -4060,9 +4226,9 @@
4060 4226 * @return array
4061 4227 */
4062 4228 public function get_rest_api_key_by( $id ) {
4063 4229 $table_name = esc_sql( $this->table_name( 'api_keys' ) );
4064 - return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$table_name} WHERE key_id = %d", $id ) );
4230 + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$table_name} WHERE key_id = %d", $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4065 4231 }
4066 4232
4067 4233 /**
4068 4234 * Method remove_rest_api_key().
@@ -4072,9 +4238,9 @@
4072 4238 * @return array
4073 4239 */
4074 4240 public function remove_rest_api_key( $id ) {
4075 4241 $table_name = esc_sql( $this->table_name( 'api_keys' ) );
4076 - return $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$table_name} WHERE key_id = %s", $id ) );
4242 + return $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM {$table_name} WHERE key_id = %s", $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4077 4243 }
4078 4244
4079 4245 /**
4080 4246 * Method get_rest_api_keys().
@@ -4082,9 +4248,9 @@
4082 4248 * @return array
4083 4249 */
4084 4250 public function get_rest_api_keys() {
4085 4251 $table_name = esc_sql( $this->table_name( 'api_keys' ) );
4086 - return $this->wpdb->get_results( "SELECT * FROM {$table_name} ORDER BY key_id DESC" );
4252 + return $this->wpdb->get_results( "SELECT * FROM {$table_name} ORDER BY key_id DESC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4087 4253 }
4088 4254
4089 4255
4090 4256 /**
@@ -4154,9 +4320,9 @@
4154 4320 * @return mixed result
4155 4321 */
4156 4322 public function get_regular_process_by_item_id_type_slug( $item_id, $type, $process_slug ) {
4157 4323 $table_name = esc_sql( $this->table_name( 'schedule_processes' ) );
4158 - return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT pr.* FROM {$table_name} pr WHERE pr.item_id = %d AND pr.type = %s AND pr.process_slug = %s", $item_id, $type, $process_slug ) );
4324 + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT pr.* FROM {$table_name} pr WHERE pr.item_id = %d AND pr.type = %s AND pr.process_slug = %s", $item_id, $type, $process_slug ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is esc_sql'd.
4159 4325 }
4160 4326
4161 4327 /**
4162 4328 * Log SQL queries for debugging via hook.
@@ -4186,6 +4352,50 @@
4186 4352 $params = apply_filters( 'mainwp_log_system_query_params', $params, $sql, $caller );
4187 4353 if ( is_array( $params ) && ! empty( $params['dev_log_query'] ) && ! empty( $sql ) ) {
4188 4354 do_action( 'mainwp_log_system_query', $params, $sql, $caller );
4189 4355 }
4356 + }
4357 +
4358 + /**
4359 + * Method get_core_tables().
4360 + *
4361 + * Returns the list of core tables used by MainWP for validation data integrity and other purposes.
4362 + *
4363 + * @return array Core tables info
4364 + */
4365 + public function get_core_tables() {
4366 +
4367 + $core_tables = array(
4368 + $this->table_name( 'wp_clients' ),
4369 + $this->table_name( 'wp_clients_fields' ),
4370 + $this->table_name( 'wp_clients_field_values' ),
4371 + $this->table_name( 'wp_clients_contacts' ),
4372 + $this->table_name( 'monitors' ),
4373 + $this->table_name( 'monitor_heartbeat' ),
4374 + $this->table_name( 'monitor_stat_hourly' ),
4375 + $this->table_name( 'wp' ),
4376 + $this->table_name( 'wp_sync' ),
4377 + $this->table_name( 'wp_options' ),
4378 + $this->table_name( 'wp_settings_backup' ),
4379 + $this->table_name( 'users' ),
4380 + $this->table_name( 'wp_status' ),
4381 + $this->table_name( 'group' ),
4382 + $this->table_name( 'wp_group' ),
4383 + $this->table_name( 'lookup_item_objects' ),
4384 + $this->table_name( 'wp_backup_progress' ),
4385 + $this->table_name( 'wp_backup' ),
4386 + $this->table_name( 'api_keys' ),
4387 + $this->table_name( 'action_log' ),
4388 + $this->table_name( 'request_log' ),
4389 + $this->table_name( 'schedule_processes' ),
4390 + $this->table_name( 'cost_tracker' ),
4391 + );
4392 +
4393 + if ( ! defined( 'MAINWP_MODULE_LOG_ENABLED' ) || MAINWP_MODULE_LOG_ENABLED ) {
4394 + $core_tables[] = $this->table_name( 'wp_logs' );
4395 + $core_tables[] = $this->table_name( 'wp_logs_meta' );
4396 + $core_tables[] = $this->table_name( 'wp_logs_meta_archive' );
4397 + }
4398 +
4399 + return apply_filters( 'mainwp_database_core_tables', $core_tables );
4190 4400 }
4191 4401 }