PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
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-common.php +732 -93 5.1.1 → 6.2.1 View file →
@@ -8,8 +8,13 @@
8 8 */
9 9
10 10 namespace MainWP\Dashboard;
11 11
12 +// Exit if accessed directly.
13 +if ( ! defined( 'ABSPATH' ) ) {
14 + exit;
15 +}
16 +
12 17 /**
13 18 * Class MainWP_DB_Common
14 19 *
15 20 * @package MainWP\Dashboard
@@ -15,9 +20,9 @@
15 20 * @package MainWP\Dashboard
16 21 */
17 22 class MainWP_DB_Common extends MainWP_DB { // phpcs:ignore Generic.Classes.OpeningBraceSameLine.ContentAfterBrace -- NOSONAR.
18 23
19 - // phpcs:disable WordPress.DB.RestrictedFunctions, WordPress.DB.PreparedSQL.NotPrepared -- unprepared SQL ok, accessing the database directly to custom database functions.
24 + // phpcs:disable WordPress.DB.RestrictedFunctions,WordPress.DB.PreparedSQL.NotPrepared,Generic.Metrics.CyclomaticComplexity -- This is the only way to achieve desired results, pull request solutions appreciated.
20 25
21 26 /**
22 27 * Private static variable to hold the single instance of the class.
23 28 *
@@ -42,44 +47,51 @@
42 47 return static::$instance;
43 48 }
44 49
45 50 /**
46 - * Method get_last_sync_status()
51 + * Get the last sync status.
47 52 *
48 - * Get last sync status.
53 + * @param string $is_staging Wether query on staging sites.
49 54 *
50 - * @return string $return all_synced|not_synced|last_sync
55 + * @return array{
56 + * sync_status: string|false,
57 + * last_sync: int
58 + * }
51 59 */
52 - public function get_last_sync_status() {
53 - $sql = $this->get_sql_websites_for_current_user();
54 - $websites = $this->query( $sql );
60 + public function get_last_sync_status( $is_staging = 'no' ) {
55 61
62 + $cutoff = time() - DAY_IN_SECONDS;
63 +
64 + $site_table = $this->table_name( 'wp' );
65 + $sync_table = $this->table_name( 'wp_sync' );
66 +
67 + $sql = $this->wpdb->prepare(
68 + "
69 + SELECT
70 + COUNT(*) AS total_sites,
71 + SUM(CASE WHEN s.dtsSync >= %d THEN 1 ELSE 0 END) AS synced_sites,
72 + MAX(s.dtsSync) AS last_sync
73 + FROM {$site_table} w
74 + INNER JOIN {$sync_table} s ON s.wpid = w.id
75 + WHERE s.sync_errors = ''
76 + " . $this->get_sql_where_allow_access_sites( 'w', $is_staging ),
77 + $cutoff
78 + );
79 +
80 + $stats = $this->wpdb->get_row( $sql );
81 +
56 82 $return = array(
57 83 'sync_status' => false,
58 84 'last_sync' => 0,
59 85 );
60 86
61 - if ( ! $websites ) {
87 + if ( empty( $stats ) || 0 === (int) $stats->total_sites ) {
62 88 $return['sync_status'] = 'all_synced';
63 89 return $return;
64 90 }
65 91
66 - $total_sites = 0;
67 - $synced_sites = 0;
68 - $last_sync = 0;
69 - static::data_seek( $websites, 0 );
70 - while ( $websites && ( $website = static::fetch_object( $websites ) ) ) {
71 - if ( empty( $website ) || '' !== $website->sync_errors ) {
72 - continue;
73 - }
74 - ++$total_sites;
75 - if ( 60 * 60 * 24 > time() - $website->dtsSync ) {
76 - ++$synced_sites;
77 - }
78 - if ( $last_sync < $website->dtsSync ) {
79 - $last_sync = $website->dtsSync;
80 - }
81 - }
92 + $total_sites = (int) $stats->total_sites;
93 + $synced_sites = (int) $stats->synced_sites;
82 94
83 95 if ( $total_sites === $synced_sites ) {
84 96 $return['sync_status'] = 'all_synced';
85 97 } elseif ( 0 === $synced_sites ) {
@@ -84,9 +96,11 @@
84 96 $return['sync_status'] = 'all_synced';
85 97 } elseif ( 0 === $synced_sites ) {
86 98 $return['sync_status'] = 'not_synced';
87 99 }
88 - $return['last_sync'] = $last_sync;
100 +
101 + $return['last_sync'] = (int) $stats->last_sync;
102 +
89 103 return $return;
90 104 }
91 105
92 106 /**
@@ -115,9 +129,10 @@
115 129 }
116 130 $where = ( null !== $userid ) ? ' AND userid=' . intval( $userid ) : '';
117 131 $where .= $this->get_sql_where_allow_groups();
118 132
119 - return $this->wpdb->get_row( $this->wpdb->prepare( 'SELECT * FROM ' . $this->table_name( 'group' ) . ' WHERE 1 ' . $where . ' AND name= %s', $this->escape( $name ) ) );
133 + $table_group = esc_sql( $this->table_name( 'group' ) );
134 + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM `{$table_group}` WHERE 1 " . esc_sql( $where ) . ' AND name= %s', $this->escape( $name ) ) );
120 135 }
121 136
122 137 /**
123 138 * Method get_group_by_id()
@@ -131,9 +146,10 @@
131 146 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
132 147 */
133 148 public function get_group_by_id( $id ) {
134 149 if ( MainWP_Utility::ctype_digit( $id ) ) {
135 - return $this->wpdb->get_row( $this->wpdb->prepare( 'SELECT * FROM ' . $this->table_name( 'group' ) . ' WHERE id= %d', $id ) );
150 + $table_group = esc_sql( $this->table_name( 'group' ) );
151 + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM `{$table_group}` WHERE id= %d", $id ) );
136 152 }
137 153
138 154 return null;
139 155 }
@@ -168,12 +184,51 @@
168 184 }
169 185
170 186 $where .= $this->get_sql_where_allow_groups( '', $with_staging );
171 187
172 - return $this->wpdb->get_results( 'SELECT * FROM ' . $this->table_name( 'group' ) . ' WHERE ' . $where . ' ORDER BY name', OBJECT_K );
188 + $table_group = esc_sql( $this->table_name( 'group' ) );
189 + return $this->wpdb->get_results( "SELECT * FROM `{$table_group}` WHERE " . esc_sql( $where ) . ' ORDER BY name', OBJECT_K );
173 190 }
174 191
192 +
175 193 /**
194 + * Method get_sql_where_wpopt_phpversion().
195 + *
196 + * @param string $coln Column compare.
197 + * @param string $operator Operator compare.
198 + * @param string $ver_str Version compare.
199 + *
200 + * @return string Sql version compare.
201 + */
202 + public function get_sql_where_wpopt_phpversion( $coln, $operator, $ver_str ) {
203 + $allowed_columns = array( 'phpversion' );
204 + $allowed_operators = array( '>', '>=', '<', '<=', '=', '!=' );
205 + if ( ! in_array( $coln, $allowed_columns, true ) || ! in_array( $operator, $allowed_operators, true ) ) {
206 + return ' 0 = 1 ';
207 + }
208 + $alias = 'owp_' . $coln;
209 + return $this->wpdb->prepare(
210 + " ( {$alias}.value IS NOT NULL AND INET_ATON( SUBSTRING_INDEX( CONCAT(SUBSTRING_INDEX( {$alias}.value, '-', 1), '.0.0.0.0'), '.', 4 ) ) {$operator} INET_ATON(%s) ) ", //phpcs:ignore -- NOSONAR escaped variables.
211 + $ver_str
212 + );
213 + }
214 +
215 + /**
216 + * Method get_sql_version_compare().
217 + *
218 + * @param string $coln Column compare.
219 + * @param string $operator Operator compare.
220 + * @param string $ver_str Version compare.
221 + *
222 + * @return string Sql version compare.
223 + */
224 + public function get_sql_version_compare( $coln, $operator, $ver_str ) {
225 + // It's safe since it's not user input, but the AI still suggests escaping it.
226 + return ' INET_ATON( SUBSTRING_INDEX( CONCAT( SUBSTRING_INDEX(' . $this->escape( $coln ) . ", '-', 1), '.0.0.0.0' ), '.', 4) ) " .
227 + $this->escape( $operator ) . " INET_ATON('" . $this->escape( $ver_str ) . "') ";
228 + }
229 +
230 + /**
176 231 * Method get_groups_for_current_user()
177 232 *
178 233 * Get groups for current user.
179 234 *
@@ -195,9 +250,10 @@
195 250 $where = ' userid = ' . $current_user->ID . ' ';
196 251 }
197 252 $where .= $this->get_sql_where_allow_groups();
198 253
199 - return $this->wpdb->get_results( 'SELECT * FROM ' . $this->table_name( 'group' ) . ' WHERE ' . $where . ' ORDER BY name', OBJECT_K );
254 + $table_group = esc_sql( $this->table_name( 'group' ) );
255 + return $this->wpdb->get_results( "SELECT * FROM `{$table_group}` WHERE " . esc_sql( $where ) . ' ORDER BY name', OBJECT_K );
200 256 }
201 257
202 258 /**
203 259 * Method get_groups_by_website_id()
@@ -211,11 +267,13 @@
211 267 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
212 268 */
213 269 public function get_groups_by_website_id( $websiteid ) {
214 270 if ( MainWP_Utility::ctype_digit( $websiteid ) ) {
271 + $table_group = esc_sql( $this->table_name( 'group' ) );
272 + $table_wp_group = esc_sql( $this->table_name( 'wp_group' ) );
215 273 return $this->wpdb->get_results(
216 274 $this->wpdb->prepare(
217 - 'SELECT * FROM ' . $this->table_name( 'group' ) . ' gr JOIN ' . $this->table_name( 'wp_group' ) . ' wpgr ON gr.id = wpgr.groupid WHERE wpgr.wpid = %d ORDER BY name',
275 + "SELECT * FROM `{$table_group}` gr JOIN `{$table_wp_group}` wpgr ON gr.id = wpgr.groupid WHERE wpgr.wpid = %d ORDER BY name",
218 276 $websiteid
219 277 ),
220 278 OBJECT_K
221 279 );
@@ -258,24 +316,128 @@
258 316 if ( ! $for_manager ) {
259 317 $where .= $this->get_sql_where_allow_groups( 'gr' );
260 318 }
261 319
262 - return $this->wpdb->get_results( 'SELECT gr.*, COUNT(DISTINCT(wpgr.wpid)) as nrsites FROM ' . $this->table_name( 'group' ) . ' gr LEFT JOIN ' . $this->table_name( 'wp_group' ) . ' wpgr ON gr.id = wpgr.groupid WHERE 1 ' . $where . ' GROUP BY gr.id ORDER BY gr.name', OBJECT_K );
320 + $table_group = esc_sql( $this->table_name( 'group' ) );
321 + $table_wp_group = esc_sql( $this->table_name( 'wp_group' ) );
322 + $where = esc_sql( $where );
323 + return $this->wpdb->get_results( "SELECT gr.*, COUNT(DISTINCT(wpgr.wpid)) as nrsites FROM `{$table_group}` gr LEFT JOIN `{$table_wp_group}` wpgr ON gr.id = wpgr.groupid WHERE 1 {$where} GROUP BY gr.id ORDER BY gr.name", OBJECT_K );
263 324 }
264 325
326 +
265 327 /**
328 + * Method get_tags()
329 + *
330 + * Get tags (groups) with optional filtering and pagination.
331 + *
332 + * @since 5.1.1
333 + *
334 + * @param array $params Optional parameters for filtering and pagination.
335 + * - 's' (string) Search term for tag name or ID.
336 + * - 'exclude' (array) Tag IDs to exclude.
337 + * - 'include' (array) Tag IDs to include.
338 + * - 'page' (int) Page number for pagination.
339 + * - 'per_page' (int) Items per page for pagination.
340 + * - 'with_sites_ids' (bool) Include associated site IDs.
341 + * - 'count' (bool) Return count only instead of results.
342 + *
343 + * @return object[]|int When $params['count'] is true, returns an integer count of matching tags.
344 + * Otherwise, returns an array of tag objects keyed by ID, or empty array on failure.
345 + */
346 + public function get_tags( $params = array() ) { //phpcs:ignore -- NOSONAR - complex.
347 +
348 + $s = '';
349 + $exclude = array();
350 + $include = array();
351 + $limit = '';
352 +
353 + $where = '';
354 + $select = '';
355 +
356 + if ( $params && is_array( $params ) ) {
357 + $s = isset( $params['s'] ) ? $params['s'] : '';
358 + $exclude = isset( $params['exclude'] ) ? wp_parse_id_list( $params['exclude'] ) : array();
359 + $include = isset( $params['include'] ) ? wp_parse_id_list( $params['include'] ) : array();
360 + $page = isset( $params['page'] ) ? intval( $params['page'] ) : false;
361 + $per_page = isset( $params['per_page'] ) ? intval( $params['per_page'] ) : false;
362 + $with_sites_ids = isset( $params['with_sites_ids'] ) && $params['with_sites_ids'] ? true : false;
363 + $count_only = ! empty( $params['count'] );
364 +
365 + if ( $with_sites_ids ) {
366 + $select .= ', wp_tagview.* ';
367 + }
368 +
369 + if ( ! empty( $s ) ) {
370 + $s = trim( $s );
371 + $like_pattern = '%' . $this->wpdb->esc_like( $s ) . '%';
372 + $where .= $this->wpdb->prepare(
373 + ' AND ( gr.name LIKE %s OR gr.id LIKE %s ) ',
374 + $like_pattern,
375 + $like_pattern
376 + );
377 + }
378 +
379 + if ( ! empty( $exclude ) ) {
380 + $where .= ' AND gr.id NOT IN (' . implode( ',', $exclude ) . ') ';
381 + }
382 +
383 + if ( ! empty( $include ) ) {
384 + $where .= ' AND gr.id IN (' . implode( ',', $include ) . ') ';
385 + }
386 +
387 + $gr_table = esc_sql( $this->table_name( 'group' ) );
388 +
389 + // Return count only if requested.
390 + if ( $count_only ) {
391 + return (int) $this->wpdb->get_var( 'SELECT COUNT(*) FROM ' . $gr_table . ' gr WHERE 1 ' . $where ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is built with wpdb->prepare() and escaped table name, safe to use directly.
392 + }
393 +
394 + if ( ! empty( $page ) && ! empty( $per_page ) ) {
395 + $limit = $this->wpdb->prepare( ' LIMIT %d, %d', ( $page - 1 ) * $per_page, $per_page );
396 + }
397 +
398 + $join = '';
399 +
400 + if ( $with_sites_ids ) {
401 + $join = ' JOIN ' . $this->get_tag_view() . ' wp_tagview ON gr.id = wp_tagview.id ';
402 + }
403 + }
404 + $table_group = esc_sql( $this->table_name( 'group' ) );
405 + $table_wp_group = esc_sql( $this->table_name( 'wp_group' ) );
406 + $select_sql = $select ? $select : '';
407 + $join_sql = $join ? $join : '';
408 + $where_sql = $where ? $where : '';
409 + $limit_sql = $limit ? $limit : '';
410 + $query = "SELECT gr.* {$select_sql}, COUNT(DISTINCT(wpgr.wpid)) as count_sites FROM `{$table_group}` gr LEFT JOIN `{$table_wp_group}` wpgr ON gr.id = wpgr.groupid {$join_sql} WHERE 1 {$where_sql} GROUP BY gr.id ORDER BY gr.name {$limit_sql}";
411 + return $this->wpdb->get_results( $query, OBJECT_K ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query is built with proper escaping and parameterization above.
412 + }
413 +
414 + /**
415 + * Method get_tag_view().
416 + *
417 + * @return string tag view.
418 + */
419 + public function get_tag_view() {
420 + $view = "( SELECT intgr.id, ( SELECT GROUP_CONCAT(wp.id ORDER BY wp.id SEPARATOR ',') FROM `" . $this->table_name( 'wp' ) . '` wp ';
421 + $view .= ' LEFT JOIN `' . $this->table_name( 'wp_group' ) . '` wpgr ON wp.id = wpgr.wpid WHERE wpgr.groupid = intgr.id ) as sites_ids ';
422 + $view .= ' FROM `' . $this->table_name( 'group' ) . '` intgr )';
423 + return $view;
424 + }
425 +
426 +
427 + /**
266 428 * Method get_not_empty_groups()
267 429 *
268 430 * Get non-empty groups.
269 431 *
270 - * @param null $userid Current user ID.
271 - * @param bool $enableOfflineSites Include offline sites? Default: true.
432 + * @param mixed $userid Current user ID.
433 + * @param bool $enableOfflineSites Include offline sites? Default: true.
272 434 *
273 435 * @return object|null Database query result for non-empty groups or null on failure.
274 436 *
275 437 * @uses \MainWP\Dashboard\MainWP_System::is_multi_user()
276 438 */
277 - public function get_not_empty_groups( $userid = null, $enableOfflineSites = true ) {
439 + public function get_not_empty_groups( $userid = null, $enableOfflineSites = true ) { //phpcs:ignore --NOSONAR -- complex.
278 440 if ( ( null === $userid ) && MainWP_System::instance()->is_multi_user() ) {
279 441
280 442 /**
281 443 * Current user global.
@@ -286,19 +448,58 @@
286 448
287 449 $userid = $current_user->ID;
288 450 }
289 451
290 - $where = ' WHERE 1 ';
291 - $where .= $this->get_sql_where_allow_groups( 'g' );
452 + $table_group = esc_sql( $this->table_name( 'group' ) );
453 + $table_wp_group = esc_sql( $this->table_name( 'wp_group' ) );
454 + $table_wp = esc_sql( $this->table_name( 'wp' ) );
455 + $table_wp_sync = esc_sql( $this->table_name( 'wp_sync' ) );
292 456
457 + $sql = "SELECT DISTINCT(g.id), g.name, count(wp.wpid) FROM `{$table_group}` g JOIN `{$table_wp_group}` wp ON g.id = wp.groupid JOIN `{$table_wp}` wpsite ON wp.wpid = wpsite.id JOIN `{$table_wp_sync}` wp_sync ON wp.wpid = wp_sync.wpid WHERE 1 = 1";
458 + $params = array();
459 +
460 + $staging_group = get_option( 'mainwp_stagingsites_group_id' );
461 + if ( $staging_group ) {
462 + $sql .= ' AND g.id <> %d';
463 + $params[] = absint( $staging_group );
464 + }
465 +
466 + $allowed_groups = apply_filters( 'mainwp_currentuserallowedaccessgroups', 'all' );
467 + if ( 'all' !== $allowed_groups ) {
468 + if ( is_array( $allowed_groups ) && ! empty( $allowed_groups ) ) {
469 + $allowed_groups = array_filter(
470 + $allowed_groups,
471 + function ( $e ) {
472 + return is_numeric( $e ) ? true : false;
473 + }
474 + );
475 +
476 + if ( ! empty( $allowed_groups ) ) {
477 + $placeholders = implode( ',', array_fill( 0, count( $allowed_groups ), '%d' ) );
478 + $sql .= ' AND g.id IN (' . $placeholders . ')';
479 + $params = array_merge( $params, array_map( 'intval', $allowed_groups ) );
480 + }
481 + } else {
482 + $sql .= ' AND 0';
483 + }
484 + }
485 +
293 486 if ( null !== $userid ) {
294 - $where .= ' AND g.userid = ' . intval( $userid );
487 + $sql .= ' AND g.userid = %d';
488 + $params[] = intval( $userid );
295 489 }
490 +
296 491 if ( ! $enableOfflineSites ) {
297 - $where .= ' AND wp_sync.sync_errors = ""';
492 + $sql .= " AND wp_sync.sync_errors = ''";
298 493 }
299 494
300 - return $this->wpdb->get_results( 'SELECT DISTINCT(g.id), g.name, count(wp.wpid) FROM ' . $this->table_name( 'group' ) . ' g JOIN ' . $this->table_name( 'wp_group' ) . ' wp ON g.id = wp.groupid JOIN ' . $this->table_name( 'wp' ) . ' wpsite ON wp.wpid = wpsite.id JOIN ' . $this->table_name( 'wp_sync' ) . ' wp_sync ON wp.wpid = wp_sync.wpid ' . $where . ' GROUP BY g.id HAVING count(wp.wpid) > 0 ORDER BY g.name', OBJECT_K );
495 + $sql .= ' GROUP BY g.id HAVING count(wp.wpid) > 0 ORDER BY g.name';
496 +
497 + if ( ! empty( $params ) ) {
498 + $sql = $this->wpdb->prepare( $sql, ...$params );
499 + }
500 +
501 + return $this->wpdb->get_results( $sql, OBJECT_K ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is prepared via $wpdb->prepare() with all dynamic values properly parameterized.
301 502 }
302 503
303 504 /**
304 505 * Method get_sql_log()
@@ -304,14 +505,44 @@
304 505 * Method get_sql_log()
305 506 *
306 507 * Get sql log.
307 508 *
509 + * @param int $paged paged.
510 + * @param int $order order.
511 + * @param array $params params.
512 + *
308 513 * @return string sql query.
309 514 */
310 - public function get_sql_log() {
515 + public function get_sql_log( $paged = 0, $order = '', $params = array() ) {
516 +
517 + $count_only = ! empty( $params['count'] ) ? true : false;
518 + $limit = ! empty( $params['limit'] ) ? intval( $params['limit'] ) : 500;
519 +
520 + $last_hours = ! empty( $params['hour'] ) ? intval( $params['hour'] ) : 0;
521 +
522 + $order = strtoupper( $order );
523 +
524 + $order = 'DESC' === $order || 'ASC' === $order ? $order : 'DESC';
525 +
526 + $start = ! empty( $paged ) ? absint( $paged * $limit ) : 0;
527 +
528 + if ( $count_only ) {
529 + return 'SELECT count(*)
530 + FROM `' . $this->table_name( 'action_log' ) . '` log
531 + WHERE 1 ';
532 + }
533 +
534 + if ( ! empty( $last_hours ) ) {
535 + return 'SELECT log.*
536 + FROM `' . $this->table_name( 'action_log' ) . '` log
537 + WHERE ' . $this->wpdb->prepare( ' log_timestamp > %d ', time() - $last_hours * HOUR_IN_SECONDS ) .
538 + ' ORDER BY log_timestamp ' . $this->escape( $order );
539 + }
540 +
311 541 return 'SELECT log.*
312 - FROM ' . $this->table_name( 'action_log' ) . ' log
313 - WHERE 1 LIMIT 0, 300 ';
542 + FROM `' . $this->table_name( 'action_log' ) . '` log
543 + WHERE 1 ORDER BY ' .
544 + $this->wpdb->prepare( 'log_timestamp ' . $this->escape( $order ) . ' LIMIT %d, %d', $start, $limit );
314 545 }
315 546
316 547 /**
317 548 * Method insert_action_log()
@@ -339,9 +570,11 @@
339 570 $where = '';
340 571 if ( ! empty( $days ) ) {
341 572 $where .= ' AND log_timestamp < ' . ( time() - $days * DAY_IN_SECONDS );
342 573 }
343 - $this->wpdb->query( 'DELETE FROM `' . $this->table_name( 'action_log' ) . '` WHERE 1 ' . $where );
574 + $table_action_log = esc_sql( $this->table_name( 'action_log' ) );
575 + $where = esc_sql( $where );
576 + $this->wpdb->query( "DELETE FROM `{$table_action_log}` WHERE 1 {$where}" );
344 577 }
345 578
346 579 /**
347 580 * Method insert_or_update_request_log()
@@ -366,9 +599,10 @@
366 599 if ( ! empty( $stop ) ) {
367 600 $updateValues['micro_timestamp_stop'] = $stop;
368 601 }
369 602
370 - $var = $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT id FROM ' . $this->table_name( 'request_log' ) . ' WHERE wpid = %d ', $wpid ) );
603 + $table_request_log = esc_sql( $this->table_name( 'request_log' ) );
604 + $var = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT id FROM `{$table_request_log}` WHERE wpid = %d ", $wpid ) );
371 605 if ( null !== $var ) {
372 606 $this->wpdb->update( $this->table_name( 'request_log' ), $updateValues, array( 'wpid' => $wpid ) );
373 607 } else {
374 608 $updateValues['wpid'] = $wpid;
@@ -383,10 +617,11 @@
383 617 *
384 618 * @return void
385 619 */
386 620 public function close_open_requests() {
387 - // Close requests open longer then 7 seconds.. something is wrong here.
388 - $this->wpdb->query( 'UPDATE ' . $this->table_name( 'request_log' ) . ' SET micro_timestamp_stop = micro_timestamp_start WHERE micro_timestamp_stop < micro_timestamp_start and ' . microtime( true ) . ' - micro_timestamp_start > 7' );
621 + $table_request_log = esc_sql( $this->table_name( 'request_log' ) );
622 + $microtime_value = esc_sql( microtime( true ) );
623 + $this->wpdb->query( "UPDATE `{$table_request_log}` SET micro_timestamp_stop = micro_timestamp_start WHERE micro_timestamp_stop < micro_timestamp_start and {$microtime_value} - micro_timestamp_start > 7" );
389 624 }
390 625
391 626 /**
392 627 * Method get_nrof_open_requests()
@@ -397,13 +632,19 @@
397 632 *
398 633 * @return (string|null) Database query result for number of requests or null on failure.
399 634 */
400 635 public function get_nrof_open_requests( $ip = null ) {
636 + $table_request_log = esc_sql( $this->table_name( 'request_log' ) );
401 637 if ( null === $ip ) {
402 - return $this->wpdb->get_var( 'select count(id) from ' . $this->table_name( 'request_log' ) . ' where micro_timestamp_stop < micro_timestamp_start' );
638 + return $this->wpdb->get_var( "select count(id) from `{$table_request_log}` where micro_timestamp_stop < micro_timestamp_start" ); // phpcs:ignore -- NOSONAR - table name escaped.
403 639 }
404 640
405 - return $this->wpdb->get_var( 'select count(id) from ' . $this->table_name( 'request_log' ) . ' where micro_timestamp_stop < micro_timestamp_start and ip = "' . esc_sql( $ip ) . '"' );
641 + return $this->wpdb->get_var(
642 + $this->wpdb->prepare(
643 + "SELECT COUNT(id) FROM `{$table_request_log}` WHERE micro_timestamp_stop < micro_timestamp_start AND ip = %s", // phpcs:ignore -- NOSONAR - table name escaped.
644 + $ip
645 + )
646 + );
406 647 }
407 648
408 649 /**
409 650 * Method get_last_request_timestamp()
@@ -414,13 +655,14 @@
414 655 *
415 656 * @return (int|null) Database query result for timestamp of last request sent or null on failure.
416 657 */
417 658 public function get_last_request_timestamp( $ip = null ) {
659 + $table_request_log = esc_sql( $this->table_name( 'request_log' ) );
418 660 if ( null === $ip ) {
419 - return $this->wpdb->get_var( 'select micro_timestamp_start from ' . $this->table_name( 'request_log' ) . ' order by micro_timestamp_start desc limit 1' );
661 + return $this->wpdb->get_var( "select micro_timestamp_start from `{$table_request_log}` order by micro_timestamp_start desc limit 1" );
420 662 }
421 663
422 - return $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT micro_timestamp_start FROM ' . $this->table_name( 'request_log' ) . ' WHERE ip = %s order by micro_timestamp_start desc limit 1', esc_sql( $ip ) ) );
664 + return $this->wpdb->get_var( $this->wpdb->prepare( "SELECT micro_timestamp_start FROM `{$table_request_log}` WHERE ip = %s order by micro_timestamp_start desc limit 1", esc_sql( $ip ) ) );
423 665 }
424 666
425 667 /**
426 668 * Method update_group_site()
@@ -445,11 +687,12 @@
445 687 *
446 688 * Clear sites in group.
447 689 *
448 690 * @param mixed $groupId ID of group.
691 + * @param mixed $exclude_wpids Empty or array of wp ids to exclude.
449 692 */
450 - public function clear_group( $groupId ) {
451 - $this->wpdb->query( 'DELETE FROM ' . $this->table_name( 'wp_group' ) . ' WHERE groupid=' . $groupId );
693 + public function clear_group( $groupId, $exclude_wpids = array() ) {
694 + $this->wpdb->query( 'DELETE FROM ' . $this->table_name( 'wp_group' ) . ' WHERE groupid=' . $groupId . ( ! empty( $exclude_wpids ) && is_array( $exclude_wpids ) ? ' AND wpid NOT IN (' . implode( ',', array_map( 'intval', $exclude_wpids ) ) . ')' : '' ) );
452 695 }
453 696
454 697
455 698 /**
@@ -494,8 +737,56 @@
494 737 return false;
495 738 }
496 739
497 740 /**
741 + * Method add_tag()
742 + *
743 + * Add Group.
744 + *
745 + * @param array $params params data.
746 + */
747 + public function add_tag( $params = array() ) {
748 + /**
749 + * Current user global.
750 + *
751 + * @global string
752 + */
753 + global $current_user;
754 + //phpcs:disable WordPress.Security.NonceVerification.Missing
755 + $groupId = isset( $params['id'] ) ? intval( $params['id'] ) : 0;
756 + $newName = isset( $params['name'] ) ? sanitize_text_field( wp_unslash( $params['name'] ) ) : '';
757 + $newColor = null;
758 +
759 + if ( isset( $params['color'] ) ) {
760 + $newColor = sanitize_hex_color( wp_unslash( $params['color'] ) );
761 + }
762 + //phpcs:enable WordPress.Security.NonceVerification.Missing
763 +
764 + if ( ! empty( $groupId ) ) {
765 + $table_group = esc_sql( $this->table_name( 'group' ) );
766 + if ( null !== $newColor ) {
767 + $this->wpdb->query( $this->wpdb->prepare( "UPDATE `{$table_group}` SET name=%s, color=%s WHERE id=%d", $newName, $newColor, $groupId ) );
768 + } else {
769 + $this->wpdb->query( $this->wpdb->prepare( "UPDATE `{$table_group}` SET name=%s WHERE id=%d", $newName, $groupId ) );
770 + }
771 + return $this->get_group_by_id( $groupId );
772 + } elseif ( ! empty( $newName ) ) {
773 + $groupId = $this->add_group( $current_user->ID, MainWP_Manage_Groups::check_group_name( $newName ), $newColor );
774 +
775 + /**
776 + * New Group Added
777 + *
778 + * Fires after a new sites group has been created.
779 + *
780 + * @param int $groupId Group ID.
781 + */
782 + do_action( 'mainwp_added_new_group', $groupId );
783 + return $this->get_group_by_id( $groupId );
784 + }
785 + return false;
786 + }
787 +
788 + /**
498 789 * Method remove_group()
499 790 *
500 791 * Remove group.
501 792 *
@@ -506,11 +797,22 @@
506 797 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
507 798 */
508 799 public function remove_group( $groupid ) {
509 800 if ( MainWP_Utility::ctype_digit( $groupid ) ) {
510 - $nr = $this->wpdb->query( $this->wpdb->prepare( 'DELETE FROM ' . $this->table_name( 'group' ) . ' WHERE id=%d', $groupid ) );
511 - $this->wpdb->query( $this->wpdb->prepare( 'DELETE FROM ' . $this->table_name( 'wp_group' ) . ' WHERE groupid=%d', $groupid ) );
512 -
801 + $group = $this->get_group_by_id( $groupid );
802 + $table_group = esc_sql( $this->table_name( 'group' ) );
803 + $table_wp_group = esc_sql( $this->table_name( 'wp_group' ) );
804 + $nr = $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM `{$table_group}` WHERE id=%d", $groupid ) );
805 + $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM `{$table_wp_group}` WHERE groupid=%d", $groupid ) );
806 + if ( $nr ) {
807 + /**
808 + * Fires after a tag has been deleted.
809 + *
810 + * @param object $group group created.
811 + * @param string group action.
812 + */
813 + do_action( 'mainwp_site_tag_action', $group, 'deleted' );
814 + }
513 815 return $nr;
514 816 }
515 817
516 818 return false;
@@ -526,9 +828,10 @@
526 828 *
527 829 * @return void
528 830 */
529 831 public function update_note( $websiteid, $note ) {
530 - $this->wpdb->query( $this->wpdb->prepare( 'UPDATE ' . $this->table_name( 'wp' ) . ' SET note= %s WHERE id=%d', $this->escape( $note ), $websiteid ) );
832 + $table_wp = esc_sql( $this->table_name( 'wp' ) );
833 + $this->wpdb->query( $this->wpdb->prepare( "UPDATE `{$table_wp}` SET note= %s WHERE id=%d", $this->escape( $note ), $websiteid ) );
531 834 }
532 835
533 836 /**
534 837 * Method update_group()
@@ -544,10 +847,10 @@
544 847 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
545 848 */
546 849 public function update_group( $groupid, $groupname, $groupcolor ) {
547 850 if ( MainWP_Utility::ctype_digit( $groupid ) ) {
548 - // update groupname.
549 - $this->wpdb->query( $this->wpdb->prepare( 'UPDATE ' . $this->table_name( 'group' ) . ' SET name=%s, color=%s WHERE id=%d', $this->escape( $groupname ), $this->escape( $groupcolor ), $groupid ) );
851 + $table_group = esc_sql( $this->table_name( 'group' ) );
852 + $this->wpdb->query( $this->wpdb->prepare( "UPDATE `{$table_group}` SET name=%s, color=%s WHERE id=%d", $this->escape( $groupname ), $this->escape( $groupcolor ), $groupid ) );
550 853
551 854 return true;
552 855 }
553 856
@@ -569,12 +872,13 @@
569 872 $theUserId = $userid;
570 873 if ( MainWP_System::instance()->is_single_user() ) {
571 874 $theUserId = 0;
572 875 }
573 - $user_email = $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT user_email FROM ' . $this->table_name( 'users' ) . ' WHERE userid = %d', $theUserId ) );
876 + $table_users = esc_sql( $this->table_name( 'users' ) );
877 + $user_email = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT user_email FROM `{$table_users}` WHERE userid = %d", $theUserId ) );
574 878
575 879 if ( null === $user_email || empty( $user_email ) ) {
576 - $user_email = $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT user_email FROM ' . $this->wpdb->prefix . 'users WHERE id = %d', $userid ) );
880 + $user_email = $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT user_email FROM `' . $this->wpdb->prefix . 'users` WHERE id = %d', $userid ) );
577 881 }
578 882
579 883 return $user_email;
580 884 }
@@ -620,17 +924,18 @@
620 924 * @return object $row User extension.
621 925 *
622 926 * @uses \MainWP\Dashboard\MainWP_System::is_single_user()
623 927 */
624 - public function get_user_extension_by_user_id( $userid ) {
928 + public function get_user_extension_by_user_id( $userid = 0 ) {
625 929 if ( MainWP_System::instance()->is_single_user() ) {
626 930 $userid = 0;
627 931 }
628 932
629 - $row = $this->wpdb->get_row( 'SELECT * FROM ' . $this->table_name( 'users' ) . ' WHERE userid= ' . $userid, OBJECT );
933 + $table_users = esc_sql( $this->table_name( 'users' ) );
934 + $row = $this->wpdb->get_row( "SELECT * FROM `{$table_users}` WHERE userid= " . intval( $userid ), OBJECT );
630 935 if ( null === $row ) {
631 936 $this->create_user_extension( $userid );
632 - $row = $this->wpdb->get_row( 'SELECT * FROM ' . $this->table_name( 'users' ) . ' WHERE userid= ' . $userid, OBJECT );
937 + $row = $this->wpdb->get_row( "SELECT * FROM `{$table_users}` WHERE userid= " . intval( $userid ), OBJECT );
633 938 }
634 939
635 940 return $row;
636 941 }
@@ -654,8 +959,9 @@
654 959 'ignored_themes' => '',
655 960 'trusted_themes' => '',
656 961 'trusted_themes_notes' => '',
657 962 'pluginDir' => '',
963 + 'ignored_wp_upgrades' => '',
658 964 );
659 965
660 966 $this->wpdb->insert( $this->table_name( 'users' ), $fields );
661 967 }
@@ -695,9 +1001,10 @@
695 1001
696 1002 $userid = $current_user->ID;
697 1003 }
698 1004 }
699 - $row = $this->wpdb->get_row( 'SELECT * FROM ' . $this->table_name( 'users' ) . ' WHERE userid= ' . $userid, OBJECT );
1005 + $table_users = esc_sql( $this->table_name( 'users' ) );
1006 + $row = $this->wpdb->get_row( "SELECT * FROM `{$table_users}` WHERE userid= " . intval( $userid ), OBJECT );
700 1007 if ( null === $row ) {
701 1008 $this->create_user_extension( $userid );
702 1009 }
703 1010
@@ -711,9 +1018,9 @@
711 1018 if ( ! empty( $fields ) ) {
712 1019 $this->wpdb->update( $this->table_name( 'users' ), $fields, array( 'userid' => $userid ) );
713 1020 }
714 1021
715 - return $this->wpdb->get_row( 'SELECT * FROM ' . $this->table_name( 'users' ) . ' WHERE userid= ' . $userid, OBJECT );
1022 + return $this->wpdb->get_row( "SELECT * FROM `{$table_users}` WHERE userid= " . intval( $userid ), OBJECT );
716 1023 }
717 1024
718 1025
719 1026 /**
@@ -730,9 +1037,8 @@
730 1037 * 'sslversion'.
731 1038 * 'uniqueid'.
732 1039 * 'verify'.
733 1040 * 'protocol'.
734 - * 'disablechecking'.
735 1041 * 'checkinterval'.
736 1042 * 'disablehealthchecking'.
737 1043 * 'healththreshold'.
738 1044 * 'groupids'.
@@ -743,9 +1049,9 @@
743 1049 * 'ignore_plugin_updates'.
744 1050 * 'ignore_theme_updates'.
745 1051 * 'monitoring_emails'.
746 1052 *
747 - * @return bool true|false.
1053 + * @return mixed array|true|false.
748 1054 */
749 1055 public function rest_api_update_website( $websiteid, $data ) { // phpcs:ignore -- NOSONAR - complex function.
750 1056
751 1057 $website = MainWP_DB::instance()->get_website_by_id( $websiteid );
@@ -752,8 +1058,33 @@
752 1058 if ( empty( $website ) ) {
753 1059 return false;
754 1060 }
755 1061
1062 + $success = false;
1063 +
1064 + $websiteid = (int) $websiteid;
1065 +
1066 + $group_update_success = null;
1067 + $new_groupids = array();
1068 +
1069 + // Validate group IDs before updating any website data.
1070 + if ( isset( $data['groupids'] ) && is_string( $data['groupids'] ) ) {
1071 + $group_update_success = true;
1072 + $groupids_string = trim( wp_unslash( $data['groupids'] ) );
1073 + $new_groupids = $this->validate_site_group_ids( $groupids_string );
1074 + if ( false === $new_groupids ) {
1075 + $group_update_success = false;
1076 + }
1077 + }
1078 +
1079 + // Do not modify any group associations until all group IDs are validated.
1080 + if ( false === $group_update_success ) {
1081 + return array(
1082 + 'group_updated' => false,
1083 + 'group_message' => 'Failed to update tags or invalid tag IDs.',
1084 + );
1085 + }
1086 +
756 1087 $map_fields = array(
757 1088 'http_user' => 'http_user',
758 1089 'http_pass' => 'http_pass',
759 1090 'name' => 'name',
@@ -761,49 +1092,70 @@
761 1092 'ssl_version' => 'sslversion',
762 1093 'uniqueId' => 'uniqueid',
763 1094 );
764 1095
765 - $sql_set = '';
1096 + $update_fields = array();
766 1097
767 1098 foreach ( $map_fields as $field => $name ) {
768 1099 if ( isset( $data[ $name ] ) && empty( ! $data[ $name ] ) ) {
769 - $sql_set .= ' `' . $this->escape( $field ) . '` = "' . $this->escape( $data[ $name ] ) . '",';
1100 + $value = $data[ $name ];
1101 +
1102 + // MWP-1548: encrypt http_user / http_pass at rest before
1103 + // they hit the raw SQL UPDATE. Same fail-closed contract
1104 + // as the other write paths in MainWP_DB.
1105 + if ( 'http_user' === $field || 'http_pass' === $field ) {
1106 + $encrypted = MainWP_Credential_Storage::encrypt_credential( $value, $field );
1107 +
1108 + if ( false === $encrypted ) {
1109 + return false;
1110 + }
1111 +
1112 + $value = $encrypted;
1113 + }
1114 +
1115 + $update_fields[ $field ] = $value;
770 1116 }
771 1117 }
772 1118
773 1119 if ( isset( $data['verify'] ) ) {
774 - $verify = intval( $data['verify'] );
775 - $sql_set .= ' verify_certificate = "' . $this->escape( $verify ) . '",';
1120 + $verify = intval( $data['verify'] );
1121 + $update_fields['verify_certificate'] = $verify;
776 1122 }
777 1123
778 1124 if ( isset( $data['protocol'] ) && ( 'http' === $data['protocol'] || 'https' === $data['protocol'] ) ) {
779 - $url = $data['protocol'] . '://' . MainWP_Utility::remove_http_prefix( $website->url, true );
780 - $sql_set .= ' url = "' . $this->escape( $url ) . '",';
1125 + $url = $data['protocol'] . '://' . MainWP_Utility::remove_http_prefix( $website->url, true );
1126 + $update_fields['url'] = $this->escape( $url );
781 1127 }
782 1128
783 - if ( isset( $data['disablechecking'] ) ) {
784 - $sql_set .= ' disable_status_check= "' . ( $data['disablechecking'] ? 1 : 0 ) . '",';
785 - }
786 -
787 - if ( isset( $data['checkinterval'] ) ) {
788 - $sql_set .= ' status_check_interval= "' . intval( $data['checkinterval'] ) . '",';
789 - }
790 -
791 1129 if ( isset( $data['disablehealthchecking'] ) ) {
792 - $sql_set .= ' disable_health_check = "' . ( $data['disablehealthchecking'] ? 1 : 0 ) . '",';
1130 + $update_fields['disable_health_check'] = $data['disablehealthchecking'] ? 1 : 0;
793 1131 }
794 1132
795 1133 if ( isset( $data['healththreshold'] ) ) {
796 - $sql_set .= ' health_threshold = "' . intval( $data['healththreshold'] ) . '",';
1134 + $update_fields['health_threshold'] = intval( $data['healththreshold'] );
797 1135 }
798 1136
799 1137 if ( isset( $data['suspended'] ) ) {
800 - $sql_set .= ' suspended = "' . ( 1 === intval( $data['suspended'] ) ? 1 : 0 ) . '",';
1138 + $update_fields['suspended'] = 1 === intval( $data['suspended'] ) ? 1 : 0;
801 1139 }
802 1140
803 - if ( ! empty( $sql_set ) ) {
804 - $sql_set = rtrim( $sql_set, ',' );
805 - $this->wpdb->query( $this->wpdb->prepare( 'UPDATE ' . $this->table_name( 'wp' ) . ' SET ' . $sql_set . ' WHERE id=%d', $websiteid ) );
1141 + if ( ! empty( $update_fields ) ) {
1142 + $updated = $this->wpdb->update(
1143 + $this->table_name( 'wp' ),
1144 + $update_fields,
1145 + array( 'id' => $websiteid )
1146 + );
1147 +
1148 + if ( false === $updated ) {
1149 + return false;
1150 + }
1151 + $success = true;
1152 +
1153 + if ( isset( $update_fields['url'] ) ) {
1154 + // A user-set URL ( REST API / WP-CLI ) that diverges from the
1155 + // child-reported address locks auto-correction for the site.
1156 + MainWP_Site_Url_Corrector::after_user_set_url( $website, $update_fields['url'] );
1157 + }
806 1158 }
807 1159
808 1160 $groupids = array();
809 1161 if ( isset( $data['groupids'] ) && ! empty( $data['groupids'] ) ) {
@@ -810,10 +1162,10 @@
810 1162 $groupids = explode( ',', sanitize_text_field( wp_unslash( $data['groupids'] ) ) );
811 1163 }
812 1164
813 1165 if ( ! empty( $groupids ) ) {
814 - // remove groups.
815 - $this->wpdb->query( $this->wpdb->prepare( 'DELETE FROM ' . $this->table_name( 'wp_group' ) . ' WHERE wpid=%d', $websiteid ) );
1166 + $table_wp_group = esc_sql( $this->table_name( 'wp_group' ) );
1167 + $this->wpdb->query( $this->wpdb->prepare( "DELETE FROM `{$table_wp_group}` WHERE wpid=%d", $websiteid ) );
816 1168
817 1169 // update groups.
818 1170 foreach ( $groupids as $groupid ) {
819 1171 $this->wpdb->insert(
@@ -823,8 +1175,9 @@
823 1175 'groupid' => $groupid,
824 1176 )
825 1177 );
826 1178 }
1179 + $success = true;
827 1180 }
828 1181
829 1182 $newValues = array();
830 1183
@@ -834,13 +1187,16 @@
834 1187
835 1188 if ( isset( $data['backup_before_upgrade'] ) ) {
836 1189 $newValues['backup_before_upgrade'] = $data['backup_before_upgrade'] ? 1 : 0;
837 1190 }
1191 +
838 1192 if ( isset( $data['force_use_ipv4'] ) ) {
839 1193 $forceuseipv4 = intval( $data['force_use_ipv4'] );
1194 +
840 1195 if ( 2 < $forceuseipv4 ) {
841 1196 $forceuseipv4 = 0;
842 1197 }
1198 +
843 1199 $newValues['force_use_ipv4'] = $forceuseipv4;
844 1200 }
845 1201
846 1202 if ( isset( $data['ignore_core_updates'] ) ) {
@@ -854,19 +1210,302 @@
854 1210 if ( isset( $data['ignore_theme_updates'] ) ) {
855 1211 $newValues['is_ignoreThemeUpdates'] = $data['ignore_theme_updates'] ? 1 : 0;
856 1212 }
857 1213
1214 + if ( isset( $data['client_id'] ) ) {
1215 + $newValues['client_id'] = max( 0, intval( $data['client_id'] ) );
1216 + }
1217 +
858 1218 if ( ! empty( $newValues ) ) {
859 1219 MainWP_DB::instance()->update_website_values( $website->id, $newValues );
1220 + $success = true;
860 1221 }
861 1222
862 1223 if ( isset( $data['monitoring_emails'] ) ) {
863 1224 $monitoring_emails = MainWP_Utility::valid_input_emails( $data['monitoring_emails'] );
864 - MainWP_DB::instance()->update_website_option( $website, 'monitoring_notification_emails', $monitoring_emails );
1225 +
1226 + MainWP_DB::instance()->update_website_option(
1227 + $website,
1228 + 'monitoring_notification_emails',
1229 + $monitoring_emails
1230 + );
865 1231 }
866 1232
867 - return array(
868 - 'message' => 'Site updated successfully.',
869 - 'site' => $website->url,
1233 + // Update group associations last so that credential encryption and
1234 + // website updates cannot fail after the group changes are committed.
1235 + if ( null !== $group_update_success ) {
1236 + $group_update_success = $this->safe_update_site_group_ids(
1237 + $websiteid,
1238 + $new_groupids
1239 + );
1240 +
1241 + if ( false === $group_update_success ) {
1242 + return array(
1243 + 'group_updated' => false,
1244 + 'group_message' => 'Failed to update tags or invalid tag IDs.',
1245 + 'message' => 'Group associations could not be updated, but the other requested fields were saved.',
1246 + 'success' => false,
1247 + 'site' => $website->url,
1248 + );
1249 + }
1250 +
1251 + $success = true;
1252 + }
1253 +
1254 + $response = array(
1255 + 'site' => $website->url,
870 1256 );
1257 +
1258 + if ( null !== $group_update_success ) {
1259 + $response['group_updated'] = true;
1260 + $response['group_message'] = 'Tags updated successfully.';
1261 + }
1262 +
1263 + $response['message'] = $success ? 'Site updated successfully.' : 'Site update failed.';
1264 + $response['success'] = $success;
1265 +
1266 + return $response;
1267 + }
1268 +
1269 + /**
1270 + * Validate site group IDs.
1271 + *
1272 + * Accepts a comma-separated string or an array of group IDs,
1273 + * validates that all IDs are positive integers, removes duplicates,
1274 + * and ensures the current user has access to the requested groups.
1275 + *
1276 + * @param string|array $groupids Group IDs to validate.
1277 + *
1278 + * @return array|false Validated group IDs or false on validation failure.
1279 + */
1280 + public function validate_site_group_ids( $groupids ) {
1281 + $tokens = array();
1282 +
1283 + // MWP-1741 - The add-site handler treats 1,abc as group ID 1 plus a new group named abc,
1284 + // while 0 can create a groupid = 0 association. It should enforce the same positive,
1285 + // permitted-ID validation as updates.
1286 + if ( is_string( $groupids ) ) {
1287 + $groupids_string = trim( wp_unslash( $groupids ) );
1288 + if ( '' !== $groupids_string ) {
1289 + $tokens = array_map( 'trim', explode( ',', $groupids_string ) );
1290 + }
1291 + } elseif ( is_array( $groupids ) ) {
1292 + foreach ( $groupids as $groupid ) {
1293 + if ( ! is_string( $groupid ) && ! is_int( $groupid ) ) {
1294 + return false;
1295 + }
1296 + }
1297 + $tokens = array_map( 'trim', $groupids );
1298 + } else {
1299 + return false;
1300 + }
1301 +
1302 + foreach ( $tokens as $token ) {
1303 + if ( '' === $token || ! ctype_digit( $token ) || 0 === (int) $token ) {
1304 + return false;
1305 + }
1306 + }
1307 +
1308 + $groupids = array_values(
1309 + array_unique(
1310 + array_map( 'absint', $tokens )
1311 + )
1312 + );
1313 +
1314 + if ( empty( $groupids ) ) {
1315 + return array();
1316 + }
1317 +
1318 + $groups = $this->get_groups_for_current_user();
1319 +
1320 + if ( empty( $groups ) ) {
1321 + return false;
1322 + }
1323 +
1324 + $allowed_groupids = array_map(
1325 + static function ( $group ) {
1326 + return (int) $group->id;
1327 + },
1328 + $groups
1329 + );
1330 +
1331 + if ( count( $groupids ) !== count( array_intersect( $groupids, $allowed_groupids ) ) ) {
1332 + return false;
1333 + }
1334 +
1335 + return $groupids;
1336 + }
1337 +
1338 + /**
1339 + * Safely update the group associations for a site.
1340 + *
1341 + * New group associations are inserted before obsolete associations are removed.
1342 + * If an insert fails, only the associations created during this operation are
1343 + * removed, preserving the site's existing group associations.
1344 + *
1345 + * @param int $site_id Site ID.
1346 + * @param array $new_group_ids New group IDs.
1347 + *
1348 + * @return bool True on success, false if validation or an insert fails.
1349 + */
1350 + private function safe_update_site_group_ids( $site_id, $new_group_ids ) { // phpcs:ignore -- NOSONAR -complex.
1351 + if ( empty( $site_id ) || ! is_array( $new_group_ids ) ) {
1352 + return false;
1353 + }
1354 +
1355 + global $wpdb;
1356 +
1357 + $context = DB_HOST . ':' . DB_NAME . ':' . $wpdb->prefix . ':' . (int) $site_id;
1358 + $lock_name = 'mainwp_primary_monitor_' . md5( $context ); // phpcs:ignore -- NOSONAR - Broader transaction/locking across all group access paths is outside the scope of this change.
1359 +
1360 + $locked = $this->wpdb->get_var(
1361 + $this->wpdb->prepare(
1362 + 'SELECT GET_LOCK( %s, 10 )',
1363 + $lock_name
1364 + )
1365 + );
1366 +
1367 + if ( '1' !== (string) $locked ) {
1368 + return false;
1369 + }
1370 +
1371 + try {
1372 + $current_groups = $this->get_groups_by_website_id( $site_id );
1373 +
1374 + $old_group_ids = $current_groups
1375 + ? array_map( 'intval', wp_list_pluck( $current_groups, 'id' ) )
1376 + : array();
1377 +
1378 + $table_name = esc_sql( $this->table_name( 'wp_group' ) );
1379 + $inserted_group_ids = array();
1380 + $deleted_group_ids = array();
1381 +
1382 + // Insert new associations first.
1383 + foreach ( array_diff( $new_group_ids, $old_group_ids ) as $group_id ) {
1384 + $result = $this->wpdb->insert(
1385 + $table_name,
1386 + array(
1387 + 'wpid' => $site_id,
1388 + 'groupid' => $group_id,
1389 + ),
1390 + array( '%d', '%d' )
1391 + );
1392 +
1393 + if ( false === $result ) {
1394 + $rollback_success = $this->rollback_site_group_ids(
1395 + $table_name,
1396 + $site_id,
1397 + $inserted_group_ids,
1398 + $deleted_group_ids
1399 + );
1400 +
1401 + if ( ! $rollback_success ) {
1402 + MainWP_Logger::instance()->warning(
1403 + sprintf(
1404 + 'Failed to fully rollback group associations for site ID %d after a group update failure.',
1405 + $site_id
1406 + )
1407 + );
1408 + }
1409 +
1410 + return false;
1411 + }
1412 +
1413 + $inserted_group_ids[] = $group_id;
1414 + }
1415 +
1416 + // Remove obsolete associations.
1417 + foreach ( array_diff( $old_group_ids, $new_group_ids ) as $old_group_id ) {
1418 + $result = $this->wpdb->delete(
1419 + $table_name,
1420 + array(
1421 + 'wpid' => $site_id,
1422 + 'groupid' => $old_group_id,
1423 + ),
1424 + array( '%d', '%d' )
1425 + );
1426 +
1427 + if ( false === $result ) {
1428 + $rollback_success = $this->rollback_site_group_ids(
1429 + $table_name,
1430 + $site_id,
1431 + $inserted_group_ids,
1432 + $deleted_group_ids
1433 + );
1434 +
1435 + if ( ! $rollback_success ) {
1436 + MainWP_Logger::instance()->warning(
1437 + sprintf(
1438 + 'Failed to fully rollback group associations for site ID %d after a group update failure.',
1439 + $site_id
1440 + )
1441 + );
1442 + }
1443 +
1444 + return false;
1445 + }
1446 +
1447 + $deleted_group_ids[] = $old_group_id;
1448 + }
1449 +
1450 + return true;
1451 + } finally {
1452 + $this->wpdb->get_var(
1453 + $this->wpdb->prepare(
1454 + 'SELECT RELEASE_LOCK( %s )',
1455 + $lock_name
1456 + )
1457 + );
1458 + }
1459 + }
1460 +
1461 + /**
1462 + * Roll back site group ID associations.
1463 + *
1464 + * Removes associations created by the current update and restores
1465 + * associations removed by the current update.
1466 + *
1467 + * @param string $table_name Database table name.
1468 + * @param int $site_id Website ID.
1469 + * @param array $inserted_group_ids Group IDs inserted by the update.
1470 + * @param array $deleted_group_ids Group IDs deleted by the update.
1471 + *
1472 + * @return bool True if all rollback operations succeed, false otherwise.
1473 + */
1474 + private function rollback_site_group_ids( $table_name, $site_id, $inserted_group_ids, $deleted_group_ids ) {
1475 + $rollback_success = true;
1476 +
1477 + // Remove associations created by this operation.
1478 + foreach ( $inserted_group_ids as $group_id ) {
1479 + $result = $this->wpdb->delete(
1480 + $table_name,
1481 + array(
1482 + 'wpid' => $site_id,
1483 + 'groupid' => $group_id,
1484 + ),
1485 + array( '%d', '%d' )
1486 + );
1487 +
1488 + if ( false === $result ) {
1489 + $rollback_success = false;
1490 + }
1491 + }
1492 +
1493 + // Restore associations removed by this operation.
1494 + foreach ( $deleted_group_ids as $group_id ) {
1495 + $result = $this->wpdb->insert(
1496 + $table_name,
1497 + array(
1498 + 'wpid' => $site_id,
1499 + 'groupid' => $group_id,
1500 + ),
1501 + array( '%d', '%d' )
1502 + );
1503 +
1504 + if ( false === $result ) {
1505 + $rollback_success = false;
1506 + }
1507 + }
1508 +
1509 + return $rollback_success;
871 1510 }
872 1511 }