| @@ -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 | } |