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 +351 -36 6.1 → 6.2.2 View file →
@@ -47,52 +47,51 @@
47 47 return static::$instance;
48 48 }
49 49
50 50 /**
51 - * Method get_last_sync_status()
51 + * Get the last sync status.
52 52 *
53 - * Get last sync status.
53 + * @param string $is_staging Wether query on staging sites.
54 54 *
55 - * @return string $return all_synced|not_synced|last_sync
55 + * @return array{
56 + * sync_status: string|false,
57 + * last_sync: int
58 + * }
56 59 */
57 - public function get_last_sync_status() {
58 - $wpsite_fields = array( 'id' );
59 - $sync_fields = array( 'sync_errors', 'dtsSync' );
60 - $websites = $this->query(
61 - $this->get_sql_websites_for_current_user_by_params(
62 - array(
63 - 'select_wp_fields' => $wpsite_fields,
64 - 'select_sync_fields' => $sync_fields,
65 - )
66 - )
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
67 78 );
68 79
80 + $stats = $this->wpdb->get_row( $sql );
81 +
69 82 $return = array(
70 83 'sync_status' => false,
71 84 'last_sync' => 0,
72 85 );
73 86
74 - if ( ! $websites ) {
87 + if ( empty( $stats ) || 0 === (int) $stats->total_sites ) {
75 88 $return['sync_status'] = 'all_synced';
76 89 return $return;
77 90 }
78 91
79 - $total_sites = 0;
80 - $synced_sites = 0;
81 - $last_sync = 0;
82 - static::data_seek( $websites, 0 );
83 - while ( $websites && ( $website = static::fetch_object( $websites ) ) ) {
84 - if ( empty( $website ) || '' !== $website->sync_errors ) {
85 - continue;
86 - }
87 - ++$total_sites;
88 - if ( 60 * 60 * 24 > time() - $website->dtsSync ) {
89 - ++$synced_sites;
90 - }
91 - if ( $last_sync < $website->dtsSync ) {
92 - $last_sync = $website->dtsSync;
93 - }
94 - }
92 + $total_sites = (int) $stats->total_sites;
93 + $synced_sites = (int) $stats->synced_sites;
95 94
96 95 if ( $total_sites === $synced_sites ) {
97 96 $return['sync_status'] = 'all_synced';
98 97 } elseif ( 0 === $synced_sites ) {
@@ -97,9 +96,11 @@
97 96 $return['sync_status'] = 'all_synced';
98 97 } elseif ( 0 === $synced_sites ) {
99 98 $return['sync_status'] = 'not_synced';
100 99 }
101 - $return['last_sync'] = $last_sync;
100 +
101 + $return['last_sync'] = (int) $stats->last_sync;
102 +
102 103 return $return;
103 104 }
104 105
105 106 /**
@@ -1056,10 +1057,34 @@
1056 1057 $website = MainWP_DB::instance()->get_website_by_id( $websiteid );
1057 1058 if ( empty( $website ) ) {
1058 1059 return false;
1059 1060 }
1061 +
1060 1062 $success = false;
1061 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 +
1062 1087 $map_fields = array(
1063 1088 'http_user' => 'http_user',
1064 1089 'http_pass' => 'http_pass',
1065 1090 'name' => 'name',
@@ -1072,18 +1097,22 @@
1072 1097
1073 1098 foreach ( $map_fields as $field => $name ) {
1074 1099 if ( isset( $data[ $name ] ) && empty( ! $data[ $name ] ) ) {
1075 1100 $value = $data[ $name ];
1101 +
1076 1102 // MWP-1548: encrypt http_user / http_pass at rest before
1077 1103 // they hit the raw SQL UPDATE. Same fail-closed contract
1078 1104 // as the other write paths in MainWP_DB.
1079 1105 if ( 'http_user' === $field || 'http_pass' === $field ) {
1080 1106 $encrypted = MainWP_Credential_Storage::encrypt_credential( $value, $field );
1107 +
1081 1108 if ( false === $encrypted ) {
1082 1109 return false;
1083 1110 }
1111 +
1084 1112 $value = $encrypted;
1085 1113 }
1114 +
1086 1115 $update_fields[ $field ] = $value;
1087 1116 }
1088 1117 }
1089 1118
@@ -1114,12 +1143,19 @@
1114 1143 $this->table_name( 'wp' ),
1115 1144 $update_fields,
1116 1145 array( 'id' => $websiteid )
1117 1146 );
1147 +
1118 1148 if ( false === $updated ) {
1119 1149 return false;
1120 1150 }
1121 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 + }
1122 1158 }
1123 1159
1124 1160 $groupids = array();
1125 1161 if ( isset( $data['groupids'] ) && ! empty( $data['groupids'] ) ) {
@@ -1151,13 +1187,16 @@
1151 1187
1152 1188 if ( isset( $data['backup_before_upgrade'] ) ) {
1153 1189 $newValues['backup_before_upgrade'] = $data['backup_before_upgrade'] ? 1 : 0;
1154 1190 }
1191 +
1155 1192 if ( isset( $data['force_use_ipv4'] ) ) {
1156 1193 $forceuseipv4 = intval( $data['force_use_ipv4'] );
1194 +
1157 1195 if ( 2 < $forceuseipv4 ) {
1158 1196 $forceuseipv4 = 0;
1159 1197 }
1198 +
1160 1199 $newValues['force_use_ipv4'] = $forceuseipv4;
1161 1200 }
1162 1201
1163 1202 if ( isset( $data['ignore_core_updates'] ) ) {
@@ -1182,15 +1221,291 @@
1182 1221 }
1183 1222
1184 1223 if ( isset( $data['monitoring_emails'] ) ) {
1185 1224 $monitoring_emails = MainWP_Utility::valid_input_emails( $data['monitoring_emails'] );
1186 - MainWP_DB::instance()->update_website_option( $website, 'monitoring_notification_emails', ( $monitoring_emails ) );
1187 1225
1226 + MainWP_DB::instance()->update_website_option(
1227 + $website,
1228 + 'monitoring_notification_emails',
1229 + $monitoring_emails
1230 + );
1188 1231 }
1189 1232
1190 - return array(
1191 - 'message' => 'Site updated successfully.',
1192 - 'site' => $website->url,
1193 - '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,
1194 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;
1195 1510 }
1196 1511 }