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
mainwp / class / class-mainwp-db-common.php

class-mainwp-db-common.php in MainWP Dashboard: Self-hosted WordPress Management for Agencies 6.2.1, at class/class-mainwp-db-common.php

1,512 lines 51.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MainWP Database Controller
4 *
5 * This file handles all interactions with the DB.
6 *
7 * @package MainWP/Dashboard
8 */
9
10 namespace MainWP\Dashboard;
11
12 // Exit if accessed directly.
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Class MainWP_DB_Common
19 *
20 * @package MainWP\Dashboard
21 */
22 class MainWP_DB_Common extends MainWP_DB { // phpcs:ignore Generic.Classes.OpeningBraceSameLine.ContentAfterBrace -- NOSONAR.
23
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.
25
26 /**
27 * Private static variable to hold the single instance of the class.
28 *
29 * @static
30 *
31 * @var mixed Default null
32 */
33 private static $instance = null;
34
35 /**
36 * Method instance()
37 *
38 * Create public static instance.
39 *
40 * @static
41 * @return MainWP_DB_Common
42 */
43 public static function instance() {
44 if ( null === static::$instance ) {
45 static::$instance = new self();
46 }
47 return static::$instance;
48 }
49
50 /**
51 * Get the last sync status.
52 *
53 * @param string $is_staging Wether query on staging sites.
54 *
55 * @return array{
56 * sync_status: string|false,
57 * last_sync: int
58 * }
59 */
60 public function get_last_sync_status( $is_staging = 'no' ) {
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
82 $return = array(
83 'sync_status' => false,
84 'last_sync' => 0,
85 );
86
87 if ( empty( $stats ) || 0 === (int) $stats->total_sites ) {
88 $return['sync_status'] = 'all_synced';
89 return $return;
90 }
91
92 $total_sites = (int) $stats->total_sites;
93 $synced_sites = (int) $stats->synced_sites;
94
95 if ( $total_sites === $synced_sites ) {
96 $return['sync_status'] = 'all_synced';
97 } elseif ( 0 === $synced_sites ) {
98 $return['sync_status'] = 'not_synced';
99 }
100
101 $return['last_sync'] = (int) $stats->last_sync;
102
103 return $return;
104 }
105
106 /**
107 * Method get_group_by_name()
108 *
109 * Get group by name.
110 *
111 * @param mixed $name Group name.
112 * @param null $userid user ID.
113 *
114 * @return object|null Database query result for chosen group name or null on failure
115 *
116 * @uses \MainWP\Dashboard\MainWP_System::is_multi_user()
117 */
118 public function get_group_by_name( $name, $userid = null ) {
119 if ( ( null === $userid ) && MainWP_System::instance()->is_multi_user() ) {
120
121 /**
122 * Current user global.
123 *
124 * @global string
125 */
126 global $current_user;
127
128 $userid = $current_user->ID;
129 }
130 $where = ( null !== $userid ) ? ' AND userid=' . intval( $userid ) : '';
131 $where .= $this->get_sql_where_allow_groups();
132
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 ) ) );
135 }
136
137 /**
138 * Method get_group_by_id()
139 *
140 * Get group by ID.
141 *
142 * @param mixed $id Group ID.
143 *
144 * @return object|null Database query result for chosen Group ID or null on failure.
145 *
146 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
147 */
148 public function get_group_by_id( $id ) {
149 if ( MainWP_Utility::ctype_digit( $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 ) );
152 }
153
154 return null;
155 }
156
157 /**
158 * Method get_groups_for_manage_sites()
159 *
160 * Get groups for mananged sites.
161 *
162 * @return object|null Database query result for Managed Sites Groups or null on failure.
163 *
164 * @uses \MainWP\Dashboard\MainWP_System::is_multi_user()
165 */
166 public function get_groups_for_manage_sites() {
167 $where = ' 1 ';
168 if ( MainWP_System::instance()->is_multi_user() ) {
169
170 /**
171 * Current user global.
172 *
173 * @global string
174 */
175 global $current_user;
176
177 $where = ' userid = ' . $current_user->ID . ' ';
178 }
179 $with_staging = 'yes';
180 $staging_enabled = is_plugin_active( 'mainwp-staging-extension/mainwp-staging-extension.php' ) || is_plugin_active( 'mainwp-timecapsule-extension/mainwp-timecapsule-extension.php' );
181
182 if ( ! $staging_enabled ) {
183 $with_staging = 'no';
184 }
185
186 $where .= $this->get_sql_where_allow_groups( '', $with_staging );
187
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 );
190 }
191
192
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 /**
231 * Method get_groups_for_current_user()
232 *
233 * Get groups for current user.
234 *
235 * @return object|null Database query result for Current User Groups or null on failure.
236 *
237 * @uses \MainWP\Dashboard\MainWP_System::is_multi_user()
238 */
239 public function get_groups_for_current_user() {
240 $where = ' 1 ';
241 if ( MainWP_System::instance()->is_multi_user() ) {
242
243 /**
244 * Current user global.
245 *
246 * @global string
247 */
248 global $current_user;
249
250 $where = ' userid = ' . $current_user->ID . ' ';
251 }
252 $where .= $this->get_sql_where_allow_groups();
253
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 );
256 }
257
258 /**
259 * Method get_groups_by_website_id()
260 *
261 * Get groups by website ID.
262 *
263 * @param mixed $websiteid Child Site ID.
264 *
265 * @return object|null Database query result for groups by website ID or null on failure.
266 *
267 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
268 */
269 public function get_groups_by_website_id( $websiteid ) {
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' ) );
273 return $this->wpdb->get_results(
274 $this->wpdb->prepare(
275 "SELECT * FROM `{$table_group}` gr JOIN `{$table_wp_group}` wpgr ON gr.id = wpgr.groupid WHERE wpgr.wpid = %d ORDER BY name",
276 $websiteid
277 ),
278 OBJECT_K
279 );
280 }
281
282 return null;
283 }
284
285 /**
286 * Medthod get_groups_and_count()
287 *
288 * Get groups and count.
289 *
290 * @param null $userid Current user ID.
291 * @param bool $for_manager Default: false.
292 *
293 * @return object|null Database query result for groups and count or null on failure.
294 *
295 * @uses \MainWP\Dashboard\MainWP_System::is_multi_user()
296 */
297 public function get_groups_and_count( $userid = null, $for_manager = false ) {
298 if ( ( null === $userid ) && MainWP_System::instance()->is_multi_user() ) {
299
300 /**
301 * Current user global.
302 *
303 * @global string
304 */
305 global $current_user;
306
307 $userid = $current_user->ID;
308 }
309
310 $where = '';
311
312 if ( ! empty( $userid ) ) {
313 $where = ' AND gr.userid = ' . intval( $userid );
314 }
315
316 if ( ! $for_manager ) {
317 $where .= $this->get_sql_where_allow_groups( 'gr' );
318 }
319
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 );
324 }
325
326
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 /**
428 * Method get_not_empty_groups()
429 *
430 * Get non-empty groups.
431 *
432 * @param mixed $userid Current user ID.
433 * @param bool $enableOfflineSites Include offline sites? Default: true.
434 *
435 * @return object|null Database query result for non-empty groups or null on failure.
436 *
437 * @uses \MainWP\Dashboard\MainWP_System::is_multi_user()
438 */
439 public function get_not_empty_groups( $userid = null, $enableOfflineSites = true ) { //phpcs:ignore --NOSONAR -- complex.
440 if ( ( null === $userid ) && MainWP_System::instance()->is_multi_user() ) {
441
442 /**
443 * Current user global.
444 *
445 * @global string
446 */
447 global $current_user;
448
449 $userid = $current_user->ID;
450 }
451
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' ) );
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
486 if ( null !== $userid ) {
487 $sql .= ' AND g.userid = %d';
488 $params[] = intval( $userid );
489 }
490
491 if ( ! $enableOfflineSites ) {
492 $sql .= " AND wp_sync.sync_errors = ''";
493 }
494
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.
502 }
503
504 /**
505 * Method get_sql_log()
506 *
507 * Get sql log.
508 *
509 * @param int $paged paged.
510 * @param int $order order.
511 * @param array $params params.
512 *
513 * @return string sql query.
514 */
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
541 return 'SELECT log.*
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 );
545 }
546
547 /**
548 * Method insert_action_log()
549 *
550 * Insert action log.
551 *
552 * @param array $data log data.
553 *
554 * @return void
555 */
556 public function insert_action_log( $data ) {
557 $this->wpdb->insert( $this->table_name( 'action_log' ), $data );
558 }
559
560 /**
561 * Method delete_action_log()
562 *
563 * Delete action log.
564 *
565 * @param int $days number days.
566 *
567 * @return void
568 */
569 public function delete_action_log( $days = false ) {
570 $where = '';
571 if ( ! empty( $days ) ) {
572 $where .= ' AND log_timestamp < ' . ( time() - $days * DAY_IN_SECONDS );
573 }
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}" );
577 }
578
579 /**
580 * Method insert_or_update_request_log()
581 *
582 * Insert or update request log.
583 *
584 * @param mixed $wpid WordPress ID.
585 * @param mixed $ip IP address.
586 * @param mixed $start Start time.
587 * @param mixed $stop Stop Time.
588 *
589 * @return void
590 */
591 public function insert_or_update_request_log( $wpid, $ip, $start, $stop ) {
592 $updateValues = array();
593 if ( ! empty( $ip ) ) {
594 $updateValues['ip'] = $ip;
595 }
596 if ( ! empty( $start ) ) {
597 $updateValues['micro_timestamp_start'] = $start;
598 }
599 if ( ! empty( $stop ) ) {
600 $updateValues['micro_timestamp_stop'] = $stop;
601 }
602
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 ) );
605 if ( null !== $var ) {
606 $this->wpdb->update( $this->table_name( 'request_log' ), $updateValues, array( 'wpid' => $wpid ) );
607 } else {
608 $updateValues['wpid'] = $wpid;
609 $this->wpdb->insert( $this->table_name( 'request_log' ), $updateValues );
610 }
611 }
612
613 /**
614 * Method close_open_requests()
615 *
616 * Close open request.
617 *
618 * @return void
619 */
620 public function close_open_requests() {
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" );
624 }
625
626 /**
627 * Method get_nrof_open_requests()
628 *
629 * Get number of requests.
630 *
631 * @param null $ip IP Address.
632 *
633 * @return (string|null) Database query result for number of requests or null on failure.
634 */
635 public function get_nrof_open_requests( $ip = null ) {
636 $table_request_log = esc_sql( $this->table_name( 'request_log' ) );
637 if ( null === $ip ) {
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.
639 }
640
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 );
647 }
648
649 /**
650 * Method get_last_request_timestamp()
651 *
652 * Get timestamp of last request sent.
653 *
654 * @param null $ip Child Site IP address, default: null.
655 *
656 * @return (int|null) Database query result for timestamp of last request sent or null on failure.
657 */
658 public function get_last_request_timestamp( $ip = null ) {
659 $table_request_log = esc_sql( $this->table_name( 'request_log' ) );
660 if ( null === $ip ) {
661 return $this->wpdb->get_var( "select micro_timestamp_start from `{$table_request_log}` order by micro_timestamp_start desc limit 1" );
662 }
663
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 ) ) );
665 }
666
667 /**
668 * Method update_group_site()
669 *
670 * @param mixed $groupId Group ID.
671 * @param mixed $websiteId Child Site ID.
672 *
673 * @return void
674 */
675 public function update_group_site( $groupId, $websiteId ) {
676 $this->wpdb->insert(
677 $this->table_name( 'wp_group' ),
678 array(
679 'wpid' => $websiteId,
680 'groupid' => $groupId,
681 )
682 );
683 }
684
685 /**
686 * Method clear_group()
687 *
688 * Clear sites in group.
689 *
690 * @param mixed $groupId ID of group.
691 * @param mixed $exclude_wpids Empty or array of wp ids to exclude.
692 */
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 ) ) . ')' : '' ) );
695 }
696
697
698 /**
699 * Method add_group()
700 *
701 * Add group.
702 *
703 * @param mixed $userid Current User ID.
704 * @param mixed $name Name of group to add.
705 * @param mixed $color Color of group to add.
706 *
707 * @return boolean true
708 *
709 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
710 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
711 */
712 public function add_group( $userid, $name, $color = '' ) {
713 if ( MainWP_Utility::ctype_digit( $userid ) && $this->wpdb->insert(
714 $this->table_name( 'group' ),
715 array(
716 'userid' => $userid,
717 'name' => $this->escape( $name ),
718 'color' => $this->escape( $color ),
719 )
720 ) ) {
721
722 $groupId = $this->wpdb->insert_id;
723
724 $group = $this->get_group_by_id( $groupId );
725
726 /**
727 * Fires after a new sites tag has been created.
728 *
729 * @param object $group group created.
730 * @param string group action.
731 */
732 do_action( 'mainwp_site_tag_action', $group, 'created' );
733
734 return $groupId;
735 }
736
737 return false;
738 }
739
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 /**
789 * Method remove_group()
790 *
791 * Remove group.
792 *
793 * @param mixed $groupid Group ID.
794 *
795 * @return int|boolean Group that was deleted or false on failure.
796 *
797 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
798 */
799 public function remove_group( $groupid ) {
800 if ( MainWP_Utility::ctype_digit( $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 ) );
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 }
815 return $nr;
816 }
817
818 return false;
819 }
820
821 /**
822 * Method update_note()
823 *
824 * Update Note.
825 *
826 * @param mixed $websiteid Child Site ID.
827 * @param mixed $note Note data.
828 *
829 * @return void
830 */
831 public function update_note( $websiteid, $note ) {
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 ) );
834 }
835
836 /**
837 * Method update_group()
838 *
839 * Update group.
840 *
841 * @param mixed $groupid Group ID.
842 * @param mixed $groupname Group Name.
843 * @param string $groupcolor Group Color.
844 *
845 * @return boolean true|false.
846 *
847 * @uses \MainWP\Dashboard\MainWP_Utility::ctype_digit()
848 */
849 public function update_group( $groupid, $groupname, $groupcolor ) {
850 if ( MainWP_Utility::ctype_digit( $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 ) );
853
854 return true;
855 }
856
857 return false;
858 }
859
860 /**
861 * Method get_user_notification_email()
862 *
863 * Get user notification email.
864 *
865 * @param mixed $userid Current user ID.
866 *
867 * @return string $user_email User email address.
868 *
869 * @uses \MainWP\Dashboard\MainWP_System::is_single_user()
870 */
871 public function get_user_notification_email( $userid = 0 ) {
872 $theUserId = $userid;
873 if ( MainWP_System::instance()->is_single_user() ) {
874 $theUserId = 0;
875 }
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 ) );
878
879 if ( null === $user_email || empty( $user_email ) ) {
880 $user_email = $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT user_email FROM `' . $this->wpdb->prefix . 'users` WHERE id = %d', $userid ) );
881 }
882
883 return $user_email;
884 }
885
886 /**
887 * Method get_user_extension()
888 *
889 * Get user extension.
890 *
891 * @return boolean|int false|get_user_extension_by_user_id()
892 *
893 * @uses \MainWP\Dashboard\MainWP_System::is_single_user()
894 */
895 public function get_user_extension() {
896
897 /**
898 * Current user global.
899 *
900 * @global string
901 */
902 global $current_user;
903
904 if ( empty( $current_user ) ) {
905 if ( MainWP_System::instance()->is_single_user() ) {
906 $userid = 0;
907 } else {
908 return false;
909 }
910 } else {
911 $userid = $current_user->ID;
912 }
913
914 return $this->get_user_extension_by_user_id( $userid );
915 }
916
917 /**
918 * Method get_user_extension_by_user_id()
919 *
920 * Get user extension by user id.
921 *
922 * @param mixed $userid Current user ID.
923 *
924 * @return object $row User extension.
925 *
926 * @uses \MainWP\Dashboard\MainWP_System::is_single_user()
927 */
928 public function get_user_extension_by_user_id( $userid = 0 ) {
929 if ( MainWP_System::instance()->is_single_user() ) {
930 $userid = 0;
931 }
932
933 $table_users = esc_sql( $this->table_name( 'users' ) );
934 $row = $this->wpdb->get_row( "SELECT * FROM `{$table_users}` WHERE userid= " . intval( $userid ), OBJECT );
935 if ( null === $row ) {
936 $this->create_user_extension( $userid );
937 $row = $this->wpdb->get_row( "SELECT * FROM `{$table_users}` WHERE userid= " . intval( $userid ), OBJECT );
938 }
939
940 return $row;
941 }
942
943 /**
944 * Method create_user_extension()
945 *
946 * Create user extension
947 *
948 * @param mixed $userId Current user ID.
949 *
950 * @return void
951 */
952 protected function create_user_extension( $userId ) {
953 $fields = array(
954 'userid' => $userId,
955 'user_email' => '',
956 'ignored_plugins' => '',
957 'trusted_plugins' => '',
958 'trusted_plugins_notes' => '',
959 'ignored_themes' => '',
960 'trusted_themes' => '',
961 'trusted_themes_notes' => '',
962 'pluginDir' => '',
963 'ignored_wp_upgrades' => '',
964 );
965
966 $this->wpdb->insert( $this->table_name( 'users' ), $fields );
967 }
968
969 /**
970 * Method update_user_extension()
971 *
972 * Update user extension.
973 *
974 * @param mixed $userExtension User extention to update.
975 *
976 * @return object $row User extension.
977 *
978 * @uses \MainWP\Dashboard\MainWP_System::is_single_user()
979 */
980 public function update_user_extension( $userExtension ) {
981
982 if ( is_object( $userExtension ) ) {
983 $userid = $userExtension->userid;
984 } elseif ( is_array( $userExtension ) ) {
985 $userid = $userExtension['userid'];
986 } else {
987 $userid = null;
988 }
989
990 if ( null === $userid ) {
991 if ( MainWP_System::instance()->is_single_user() ) {
992 $userid = '0';
993 } else {
994
995 /**
996 * Current user global.
997 *
998 * @global string
999 */
1000 global $current_user;
1001
1002 $userid = $current_user->ID;
1003 }
1004 }
1005 $table_users = esc_sql( $this->table_name( 'users' ) );
1006 $row = $this->wpdb->get_row( "SELECT * FROM `{$table_users}` WHERE userid= " . intval( $userid ), OBJECT );
1007 if ( null === $row ) {
1008 $this->create_user_extension( $userid );
1009 }
1010
1011 $fields = array();
1012 foreach ( $userExtension as $field => $value ) {
1013 if ( $value != $row->$field ) { //phpcs:ignore -- to valid.
1014 $fields[ $field ] = $value;
1015 }
1016 }
1017
1018 if ( ! empty( $fields ) ) {
1019 $this->wpdb->update( $this->table_name( 'users' ), $fields, array( 'userid' => $userid ) );
1020 }
1021
1022 return $this->wpdb->get_row( "SELECT * FROM `{$table_users}` WHERE userid= " . intval( $userid ), OBJECT );
1023 }
1024
1025
1026 /**
1027 * Method rest_api_update_website().
1028 *
1029 * Rest API update website.
1030 *
1031 * @param int $websiteid website ID.
1032 * @param array $data Update fields array.
1033 * 'http_user'.
1034 * 'http_pass'.
1035 * 'name'.
1036 * 'admin'.
1037 * 'sslversion'.
1038 * 'uniqueid'.
1039 * 'verify'.
1040 * 'protocol'.
1041 * 'checkinterval'.
1042 * 'disablehealthchecking'.
1043 * 'healththreshold'.
1044 * 'groupids'.
1045 * 'automatic_update'.
1046 * 'backup_before_upgrade'.
1047 * 'force_use_ipv4'.
1048 * 'ignore_core_updates'.
1049 * 'ignore_plugin_updates'.
1050 * 'ignore_theme_updates'.
1051 * 'monitoring_emails'.
1052 *
1053 * @return mixed array|true|false.
1054 */
1055 public function rest_api_update_website( $websiteid, $data ) { // phpcs:ignore -- NOSONAR - complex function.
1056
1057 $website = MainWP_DB::instance()->get_website_by_id( $websiteid );
1058 if ( empty( $website ) ) {
1059 return false;
1060 }
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
1087 $map_fields = array(
1088 'http_user' => 'http_user',
1089 'http_pass' => 'http_pass',
1090 'name' => 'name',
1091 'adminname' => 'admin',
1092 'ssl_version' => 'sslversion',
1093 'uniqueId' => 'uniqueid',
1094 );
1095
1096 $update_fields = array();
1097
1098 foreach ( $map_fields as $field => $name ) {
1099 if ( isset( $data[ $name ] ) && empty( ! $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;
1116 }
1117 }
1118
1119 if ( isset( $data['verify'] ) ) {
1120 $verify = intval( $data['verify'] );
1121 $update_fields['verify_certificate'] = $verify;
1122 }
1123
1124 if ( isset( $data['protocol'] ) && ( 'http' === $data['protocol'] || 'https' === $data['protocol'] ) ) {
1125 $url = $data['protocol'] . '://' . MainWP_Utility::remove_http_prefix( $website->url, true );
1126 $update_fields['url'] = $this->escape( $url );
1127 }
1128
1129 if ( isset( $data['disablehealthchecking'] ) ) {
1130 $update_fields['disable_health_check'] = $data['disablehealthchecking'] ? 1 : 0;
1131 }
1132
1133 if ( isset( $data['healththreshold'] ) ) {
1134 $update_fields['health_threshold'] = intval( $data['healththreshold'] );
1135 }
1136
1137 if ( isset( $data['suspended'] ) ) {
1138 $update_fields['suspended'] = 1 === intval( $data['suspended'] ) ? 1 : 0;
1139 }
1140
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 }
1158 }
1159
1160 $groupids = array();
1161 if ( isset( $data['groupids'] ) && ! empty( $data['groupids'] ) ) {
1162 $groupids = explode( ',', sanitize_text_field( wp_unslash( $data['groupids'] ) ) );
1163 }
1164
1165 if ( ! empty( $groupids ) ) {
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 ) );
1168
1169 // update groups.
1170 foreach ( $groupids as $groupid ) {
1171 $this->wpdb->insert(
1172 $this->table_name( 'wp_group' ),
1173 array(
1174 'wpid' => $websiteid,
1175 'groupid' => $groupid,
1176 )
1177 );
1178 }
1179 $success = true;
1180 }
1181
1182 $newValues = array();
1183
1184 if ( isset( $data['automatic_update'] ) ) {
1185 $newValues['automatic_update'] = $data['automatic_update'] ? 1 : 0;
1186 }
1187
1188 if ( isset( $data['backup_before_upgrade'] ) ) {
1189 $newValues['backup_before_upgrade'] = $data['backup_before_upgrade'] ? 1 : 0;
1190 }
1191
1192 if ( isset( $data['force_use_ipv4'] ) ) {
1193 $forceuseipv4 = intval( $data['force_use_ipv4'] );
1194
1195 if ( 2 < $forceuseipv4 ) {
1196 $forceuseipv4 = 0;
1197 }
1198
1199 $newValues['force_use_ipv4'] = $forceuseipv4;
1200 }
1201
1202 if ( isset( $data['ignore_core_updates'] ) ) {
1203 $newValues['is_ignoreCoreUpdates'] = $data['ignore_core_updates'] ? 1 : 0;
1204 }
1205
1206 if ( isset( $data['ignore_plugin_updates'] ) ) {
1207 $newValues['is_ignorePluginUpdates'] = $data['ignore_plugin_updates'] ? 1 : 0;
1208 }
1209
1210 if ( isset( $data['ignore_theme_updates'] ) ) {
1211 $newValues['is_ignoreThemeUpdates'] = $data['ignore_theme_updates'] ? 1 : 0;
1212 }
1213
1214 if ( isset( $data['client_id'] ) ) {
1215 $newValues['client_id'] = max( 0, intval( $data['client_id'] ) );
1216 }
1217
1218 if ( ! empty( $newValues ) ) {
1219 MainWP_DB::instance()->update_website_values( $website->id, $newValues );
1220 $success = true;
1221 }
1222
1223 if ( isset( $data['monitoring_emails'] ) ) {
1224 $monitoring_emails = MainWP_Utility::valid_input_emails( $data['monitoring_emails'] );
1225
1226 MainWP_DB::instance()->update_website_option(
1227 $website,
1228 'monitoring_notification_emails',
1229 $monitoring_emails
1230 );
1231 }
1232
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,
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;
1510 }
1511 }
1512