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-common.php +609 -106 5.2 → 6.2.2 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
@@ -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,22 +316,33 @@
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
265 326
266 327 /**
267 - * Medthod get_groups_and_count()
328 + * Method get_tags()
268 329 *
269 - * Get groups and count.
330 + * Get tags (groups) with optional filtering and pagination.
270 331 *
271 332 * @since 5.1.1
272 333 *
273 - * @param array $params params.
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.
274 342 *
275 - * @return object|null Database query result for groups and count or null on failure.
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.
276 345 */
277 346 public function get_tags( $params = array() ) { //phpcs:ignore -- NOSONAR - complex.
278 347
279 348 $s = '';
@@ -290,8 +359,9 @@
290 359 $include = isset( $params['include'] ) ? wp_parse_id_list( $params['include'] ) : array();
291 360 $page = isset( $params['page'] ) ? intval( $params['page'] ) : false;
292 361 $per_page = isset( $params['per_page'] ) ? intval( $params['per_page'] ) : false;
293 362 $with_sites_ids = isset( $params['with_sites_ids'] ) && $params['with_sites_ids'] ? true : false;
363 + $count_only = ! empty( $params['count'] );
294 364
295 365 if ( $with_sites_ids ) {
296 366 $select .= ', wp_tagview.* ';
297 367 }
@@ -296,9 +366,15 @@
296 366 $select .= ', wp_tagview.* ';
297 367 }
298 368
299 369 if ( ! empty( $s ) ) {
300 - $where .= ' AND ( gr.name LIKE "%' . $this->escape( $s ) . '%" OR gr.id LIKE "%' . $this->escape( $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 + );
301 377 }
302 378
303 379 if ( ! empty( $exclude ) ) {
304 380 $where .= ' AND gr.id NOT IN (' . implode( ',', $exclude ) . ') ';
@@ -307,10 +383,17 @@
307 383 if ( ! empty( $include ) ) {
308 384 $where .= ' AND gr.id IN (' . implode( ',', $include ) . ') ';
309 385 }
310 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 +
311 394 if ( ! empty( $page ) && ! empty( $per_page ) ) {
312 - $limit = ' LIMIT ' . ( $page - 1 ) * $per_page . ',' . $per_page;
395 + $limit = $this->wpdb->prepare( ' LIMIT %d, %d', ( $page - 1 ) * $per_page, $per_page );
313 396 }
314 397
315 398 $join = '';
316 399
@@ -317,9 +400,16 @@
317 400 if ( $with_sites_ids ) {
318 401 $join = ' JOIN ' . $this->get_tag_view() . ' wp_tagview ON gr.id = wp_tagview.id ';
319 402 }
320 403 }
321 - return $this->wpdb->get_results( 'SELECT gr.* ' . $select . ', COUNT(DISTINCT(wpgr.wpid)) as count_sites FROM ' . $this->table_name( 'group' ) . ' gr LEFT JOIN ' . $this->table_name( 'wp_group' ) . ' wpgr ON gr.id = wpgr.groupid ' . $join . ' WHERE 1 ' . $where . ' GROUP BY gr.id ORDER BY gr.name ' . $limit, OBJECT_K );
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.
322 412 }
323 413
324 414 /**
325 415 * Method get_tag_view().
@@ -326,11 +416,11 @@
326 416 *
327 417 * @return string tag view.
328 418 */
329 419 public function get_tag_view() {
330 - $view = "( SELECT intgr.id, ( SELECT GROUP_CONCAT(wp.id ORDER BY wp.id SEPARATOR ',') FROM " . $this->table_name( 'wp' ) . ' wp ';
331 - $view .= ' LEFT JOIN ' . $this->table_name( 'wp_group' ) . ' wpgr ON wp.id = wpgr.wpid WHERE wpgr.groupid = intgr.id ) as sites_ids ';
332 - $view .= ' FROM ' . $this->table_name( 'group' ) . ' intgr )';
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 )';
333 423 return $view;
334 424 }
335 425
336 426
@@ -338,16 +428,16 @@
338 428 * Method get_not_empty_groups()
339 429 *
340 430 * Get non-empty groups.
341 431 *
342 - * @param null $userid Current user ID.
343 - * @param bool $enableOfflineSites Include offline sites? Default: true.
432 + * @param mixed $userid Current user ID.
433 + * @param bool $enableOfflineSites Include offline sites? Default: true.
344 434 *
345 435 * @return object|null Database query result for non-empty groups or null on failure.
346 436 *
347 437 * @uses \MainWP\Dashboard\MainWP_System::is_multi_user()
348 438 */
349 - 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.
350 440 if ( ( null === $userid ) && MainWP_System::instance()->is_multi_user() ) {
351 441
352 442 /**
353 443 * Current user global.
@@ -358,19 +448,58 @@
358 448
359 449 $userid = $current_user->ID;
360 450 }
361 451
362 - $where = ' WHERE 1 ';
363 - $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' ) );
364 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 +
365 486 if ( null !== $userid ) {
366 - $where .= ' AND g.userid = ' . intval( $userid );
487 + $sql .= ' AND g.userid = %d';
488 + $params[] = intval( $userid );
367 489 }
490 +
368 491 if ( ! $enableOfflineSites ) {
369 - $where .= ' AND wp_sync.sync_errors = ""';
492 + $sql .= " AND wp_sync.sync_errors = ''";
370 493 }
371 494
372 - 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.
373 502 }
374 503
375 504 /**
376 505 * Method get_sql_log()
@@ -376,14 +505,44 @@
376 505 * Method get_sql_log()
377 506 *
378 507 * Get sql log.
379 508 *
509 + * @param int $paged paged.
510 + * @param int $order order.
511 + * @param array $params params.
512 + *
380 513 * @return string sql query.
381 514 */
382 - 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 +
383 541 return 'SELECT log.*
384 - FROM ' . $this->table_name( 'action_log' ) . ' log
385 - 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 );
386 545 }
387 546
388 547 /**
389 548 * Method insert_action_log()
@@ -411,9 +570,11 @@
411 570 $where = '';
412 571 if ( ! empty( $days ) ) {
413 572 $where .= ' AND log_timestamp < ' . ( time() - $days * DAY_IN_SECONDS );
414 573 }
415 - $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}" );
416 577 }
417 578
418 579 /**
419 580 * Method insert_or_update_request_log()
@@ -438,9 +599,10 @@
438 599 if ( ! empty( $stop ) ) {
439 600 $updateValues['micro_timestamp_stop'] = $stop;
440 601 }
441 602
442 - $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 ) );
443 605 if ( null !== $var ) {
444 606 $this->wpdb->update( $this->table_name( 'request_log' ), $updateValues, array( 'wpid' => $wpid ) );
445 607 } else {
446 608 $updateValues['wpid'] = $wpid;
@@ -455,10 +617,11 @@
455 617 *
456 618 * @return void
457 619 */
458 620 public function close_open_requests() {
459 - // Close requests open longer then 7 seconds.. something is wrong here.
460 - $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" );
461 624 }
462 625
463 626 /**
464 627 * Method get_nrof_open_requests()
@@ -469,13 +632,19 @@
469 632 *
470 633 * @return (string|null) Database query result for number of requests or null on failure.
471 634 */
472 635 public function get_nrof_open_requests( $ip = null ) {
636 + $table_request_log = esc_sql( $this->table_name( 'request_log' ) );
473 637 if ( null === $ip ) {
474 - 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.
475 639 }
476 640
477 - 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 + );
478 647 }
479 648
480 649 /**
481 650 * Method get_last_request_timestamp()
@@ -486,13 +655,14 @@
486 655 *
487 656 * @return (int|null) Database query result for timestamp of last request sent or null on failure.
488 657 */
489 658 public function get_last_request_timestamp( $ip = null ) {
659 + $table_request_log = esc_sql( $this->table_name( 'request_log' ) );
490 660 if ( null === $ip ) {
491 - 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" );
492 662 }
493 663
494 - 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 ) ) );
495 665 }
496 666
497 667 /**
498 668 * Method update_group_site()
@@ -517,11 +687,12 @@
517 687 *
518 688 * Clear sites in group.
519 689 *
520 690 * @param mixed $groupId ID of group.
691 + * @param mixed $exclude_wpids Empty or array of wp ids to exclude.
521 692 */
522 - public function clear_group( $groupId ) {
523 - $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 ) ) . ')' : '' ) );
524 695 }
525 696
526 697
527 698 /**
@@ -590,14 +761,14 @@
590 761 }
591 762 //phpcs:enable WordPress.Security.NonceVerification.Missing
592 763
593 764 if ( ! empty( $groupId ) ) {
594 - $color_update = '';
765 + $table_group = esc_sql( $this->table_name( 'group' ) );
595 766 if ( null !== $newColor ) {
596 - $color_update = ", color='" . $this->escape( $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 ) );
597 770 }
598 - // update groupname.
599 - $this->wpdb->query( $this->wpdb->prepare( 'UPDATE ' . $this->table_name( 'group' ) . ' SET name=%s ' . $color_update . ' WHERE id=%d', $this->escape( $newName ), $groupId ) );
600 771 return $this->get_group_by_id( $groupId );
601 772 } elseif ( ! empty( $newName ) ) {
602 773 $groupId = $this->add_group( $current_user->ID, MainWP_Manage_Groups::check_group_name( $newName ), $newColor );
603 774
@@ -626,11 +797,13 @@
626 797 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
627 798 */
628 799 public function remove_group( $groupid ) {
629 800 if ( MainWP_Utility::ctype_digit( $groupid ) ) {
630 - $group = $this->get_group_by_id( $groupid );
631 - $nr = $this->wpdb->query( $this->wpdb->prepare( 'DELETE FROM ' . $this->table_name( 'group' ) . ' WHERE id=%d', $groupid ) );
632 - $this->wpdb->query( $this->wpdb->prepare( 'DELETE FROM ' . $this->table_name( 'wp_group' ) . ' WHERE groupid=%d', $groupid ) );
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 ) );
633 806 if ( $nr ) {
634 807 /**
635 808 * Fires after a tag has been deleted.
636 809 *
@@ -655,9 +828,10 @@
655 828 *
656 829 * @return void
657 830 */
658 831 public function update_note( $websiteid, $note ) {
659 - $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 ) );
660 834 }
661 835
662 836 /**
663 837 * Method update_group()
@@ -673,10 +847,10 @@
673 847 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
674 848 */
675 849 public function update_group( $groupid, $groupname, $groupcolor ) {
676 850 if ( MainWP_Utility::ctype_digit( $groupid ) ) {
677 - // update groupname.
678 - $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 ) );
679 853
680 854 return true;
681 855 }
682 856
@@ -698,12 +872,13 @@
698 872 $theUserId = $userid;
699 873 if ( MainWP_System::instance()->is_single_user() ) {
700 874 $theUserId = 0;
701 875 }
702 - $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 ) );
703 878
704 879 if ( null === $user_email || empty( $user_email ) ) {
705 - $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 ) );
706 881 }
707 882
708 883 return $user_email;
709 884 }
@@ -754,12 +929,13 @@
754 929 if ( MainWP_System::instance()->is_single_user() ) {
755 930 $userid = 0;
756 931 }
757 932
758 - $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 );
759 935 if ( null === $row ) {
760 936 $this->create_user_extension( $userid );
761 - $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 );
762 938 }
763 939
764 940 return $row;
765 941 }
@@ -825,9 +1001,10 @@
825 1001
826 1002 $userid = $current_user->ID;
827 1003 }
828 1004 }
829 - $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 );
830 1007 if ( null === $row ) {
831 1008 $this->create_user_extension( $userid );
832 1009 }
833 1010
@@ -841,9 +1018,9 @@
841 1018 if ( ! empty( $fields ) ) {
842 1019 $this->wpdb->update( $this->table_name( 'users' ), $fields, array( 'userid' => $userid ) );
843 1020 }
844 1021
845 - 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 );
846 1023 }
847 1024
848 1025
849 1026 /**
@@ -860,9 +1037,8 @@
860 1037 * 'sslversion'.
861 1038 * 'uniqueid'.
862 1039 * 'verify'.
863 1040 * 'protocol'.
864 - * 'disablechecking'.
865 1041 * 'checkinterval'.
866 1042 * 'disablehealthchecking'.
867 1043 * 'healththreshold'.
868 1044 * 'groupids'.
@@ -873,9 +1049,9 @@
873 1049 * 'ignore_plugin_updates'.
874 1050 * 'ignore_theme_updates'.
875 1051 * 'monitoring_emails'.
876 1052 *
877 - * @return bool true|false.
1053 + * @return mixed array|true|false.
878 1054 */
879 1055 public function rest_api_update_website( $websiteid, $data ) { // phpcs:ignore -- NOSONAR - complex function.
880 1056
881 1057 $website = MainWP_DB::instance()->get_website_by_id( $websiteid );
@@ -881,10 +1057,34 @@
881 1057 $website = MainWP_DB::instance()->get_website_by_id( $websiteid );
882 1058 if ( empty( $website ) ) {
883 1059 return false;
884 1060 }
1061 +
885 1062 $success = false;
886 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 +
887 1087 $map_fields = array(
888 1088 'http_user' => 'http_user',
889 1089 'http_pass' => 'http_pass',
890 1090 'name' => 'name',
@@ -892,50 +1092,70 @@
892 1092 'ssl_version' => 'sslversion',
893 1093 'uniqueId' => 'uniqueid',
894 1094 );
895 1095
896 - $sql_set = '';
1096 + $update_fields = array();
897 1097
898 1098 foreach ( $map_fields as $field => $name ) {
899 1099 if ( isset( $data[ $name ] ) && empty( ! $data[ $name ] ) ) {
900 - $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;
901 1116 }
902 1117 }
903 1118
904 1119 if ( isset( $data['verify'] ) ) {
905 - $verify = intval( $data['verify'] );
906 - $sql_set .= ' verify_certificate = "' . $this->escape( $verify ) . '",';
1120 + $verify = intval( $data['verify'] );
1121 + $update_fields['verify_certificate'] = $verify;
907 1122 }
908 1123
909 1124 if ( isset( $data['protocol'] ) && ( 'http' === $data['protocol'] || 'https' === $data['protocol'] ) ) {
910 - $url = $data['protocol'] . '://' . MainWP_Utility::remove_http_prefix( $website->url, true );
911 - $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 );
912 1127 }
913 1128
914 - if ( isset( $data['disablechecking'] ) ) {
915 - $sql_set .= ' disable_status_check= "' . ( $data['disablechecking'] ? 1 : 0 ) . '",';
916 - }
917 -
918 - if ( isset( $data['checkinterval'] ) ) {
919 - $sql_set .= ' status_check_interval= "' . intval( $data['checkinterval'] ) . '",';
920 - }
921 -
922 1129 if ( isset( $data['disablehealthchecking'] ) ) {
923 - $sql_set .= ' disable_health_check = "' . ( $data['disablehealthchecking'] ? 1 : 0 ) . '",';
1130 + $update_fields['disable_health_check'] = $data['disablehealthchecking'] ? 1 : 0;
924 1131 }
925 1132
926 1133 if ( isset( $data['healththreshold'] ) ) {
927 - $sql_set .= ' health_threshold = "' . intval( $data['healththreshold'] ) . '",';
1134 + $update_fields['health_threshold'] = intval( $data['healththreshold'] );
928 1135 }
929 1136
930 1137 if ( isset( $data['suspended'] ) ) {
931 - $sql_set .= ' suspended = "' . ( 1 === intval( $data['suspended'] ) ? 1 : 0 ) . '",';
1138 + $update_fields['suspended'] = 1 === intval( $data['suspended'] ) ? 1 : 0;
932 1139 }
933 1140
934 - if ( ! empty( $sql_set ) ) {
935 - $sql_set = rtrim( $sql_set, ',' );
936 - $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 + }
937 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 + }
938 1158 }
939 1159
940 1160 $groupids = array();
941 1161 if ( isset( $data['groupids'] ) && ! empty( $data['groupids'] ) ) {
@@ -942,10 +1162,10 @@
942 1162 $groupids = explode( ',', sanitize_text_field( wp_unslash( $data['groupids'] ) ) );
943 1163 }
944 1164
945 1165 if ( ! empty( $groupids ) ) {
946 - // remove groups.
947 - $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 ) );
948 1168
949 1169 // update groups.
950 1170 foreach ( $groupids as $groupid ) {
951 1171 $this->wpdb->insert(
@@ -967,13 +1187,16 @@
967 1187
968 1188 if ( isset( $data['backup_before_upgrade'] ) ) {
969 1189 $newValues['backup_before_upgrade'] = $data['backup_before_upgrade'] ? 1 : 0;
970 1190 }
1191 +
971 1192 if ( isset( $data['force_use_ipv4'] ) ) {
972 1193 $forceuseipv4 = intval( $data['force_use_ipv4'] );
1194 +
973 1195 if ( 2 < $forceuseipv4 ) {
974 1196 $forceuseipv4 = 0;
975 1197 }
1198 +
976 1199 $newValues['force_use_ipv4'] = $forceuseipv4;
977 1200 }
978 1201
979 1202 if ( isset( $data['ignore_core_updates'] ) ) {
@@ -987,8 +1210,12 @@
987 1210 if ( isset( $data['ignore_theme_updates'] ) ) {
988 1211 $newValues['is_ignoreThemeUpdates'] = $data['ignore_theme_updates'] ? 1 : 0;
989 1212 }
990 1213
1214 + if ( isset( $data['client_id'] ) ) {
1215 + $newValues['client_id'] = max( 0, intval( $data['client_id'] ) );
1216 + }
1217 +
991 1218 if ( ! empty( $newValues ) ) {
992 1219 MainWP_DB::instance()->update_website_values( $website->id, $newValues );
993 1220 $success = true;
994 1221 }
@@ -994,15 +1221,291 @@
994 1221 }
995 1222
996 1223 if ( isset( $data['monitoring_emails'] ) ) {
997 1224 $monitoring_emails = MainWP_Utility::valid_input_emails( $data['monitoring_emails'] );
998 - MainWP_DB::instance()->update_website_option( $website, 'monitoring_notification_emails', $monitoring_emails );
999 1225
1226 + MainWP_DB::instance()->update_website_option(
1227 + $website,
1228 + 'monitoring_notification_emails',
1229 + $monitoring_emails
1230 + );
1000 1231 }
1001 1232
1002 - return array(
1003 - 'message' => 'Site updated successfully.',
1004 - 'site' => $website->url,
1005 - 'success' => $success,
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,
1006 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;
1007 1510 }
1008 1511 }