| @@ -1057,10 +1057,34 @@ | ||
| 1057 | 1057 | $website = MainWP_DB::instance()->get_website_by_id( $websiteid ); |
| 1058 | 1058 | if ( empty( $website ) ) { |
| 1059 | 1059 | return false; |
| 1060 | 1060 | } |
| 1061 | + | |
| 1061 | 1062 | $success = false; |
| 1062 | 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 | + | |
| 1063 | 1087 | $map_fields = array( |
| 1064 | 1088 | 'http_user' => 'http_user', |
| 1065 | 1089 | 'http_pass' => 'http_pass', |
| 1066 | 1090 | 'name' => 'name', |
| @@ -1073,18 +1097,22 @@ | ||
| 1073 | 1097 | |
| 1074 | 1098 | foreach ( $map_fields as $field => $name ) { |
| 1075 | 1099 | if ( isset( $data[ $name ] ) && empty( ! $data[ $name ] ) ) { |
| 1076 | 1100 | $value = $data[ $name ]; |
| 1101 | + | |
| 1077 | 1102 | // MWP-1548: encrypt http_user / http_pass at rest before |
| 1078 | 1103 | // they hit the raw SQL UPDATE. Same fail-closed contract |
| 1079 | 1104 | // as the other write paths in MainWP_DB. |
| 1080 | 1105 | if ( 'http_user' === $field || 'http_pass' === $field ) { |
| 1081 | 1106 | $encrypted = MainWP_Credential_Storage::encrypt_credential( $value, $field ); |
| 1107 | + | |
| 1082 | 1108 | if ( false === $encrypted ) { |
| 1083 | 1109 | return false; |
| 1084 | 1110 | } |
| 1111 | + | |
| 1085 | 1112 | $value = $encrypted; |
| 1086 | 1113 | } |
| 1114 | + | |
| 1087 | 1115 | $update_fields[ $field ] = $value; |
| 1088 | 1116 | } |
| 1089 | 1117 | } |
| 1090 | 1118 | |
| @@ -1115,8 +1143,9 @@ | ||
| 1115 | 1143 | $this->table_name( 'wp' ), |
| 1116 | 1144 | $update_fields, |
| 1117 | 1145 | array( 'id' => $websiteid ) |
| 1118 | 1146 | ); |
| 1147 | + | |
| 1119 | 1148 | if ( false === $updated ) { |
| 1120 | 1149 | return false; |
| 1121 | 1150 | } |
| 1122 | 1151 | $success = true; |
| @@ -1158,13 +1187,16 @@ | ||
| 1158 | 1187 | |
| 1159 | 1188 | if ( isset( $data['backup_before_upgrade'] ) ) { |
| 1160 | 1189 | $newValues['backup_before_upgrade'] = $data['backup_before_upgrade'] ? 1 : 0; |
| 1161 | 1190 | } |
| 1191 | + | |
| 1162 | 1192 | if ( isset( $data['force_use_ipv4'] ) ) { |
| 1163 | 1193 | $forceuseipv4 = intval( $data['force_use_ipv4'] ); |
| 1194 | + | |
| 1164 | 1195 | if ( 2 < $forceuseipv4 ) { |
| 1165 | 1196 | $forceuseipv4 = 0; |
| 1166 | 1197 | } |
| 1198 | + | |
| 1167 | 1199 | $newValues['force_use_ipv4'] = $forceuseipv4; |
| 1168 | 1200 | } |
| 1169 | 1201 | |
| 1170 | 1202 | if ( isset( $data['ignore_core_updates'] ) ) { |
| @@ -1189,15 +1221,291 @@ | ||
| 1189 | 1221 | } |
| 1190 | 1222 | |
| 1191 | 1223 | if ( isset( $data['monitoring_emails'] ) ) { |
| 1192 | 1224 | $monitoring_emails = MainWP_Utility::valid_input_emails( $data['monitoring_emails'] ); |
| 1193 | - MainWP_DB::instance()->update_website_option( $website, 'monitoring_notification_emails', ( $monitoring_emails ) ); | |
| 1194 | 1225 | |
| 1226 | + MainWP_DB::instance()->update_website_option( | |
| 1227 | + $website, | |
| 1228 | + 'monitoring_notification_emails', | |
| 1229 | + $monitoring_emails | |
| 1230 | + ); | |
| 1195 | 1231 | } |
| 1196 | 1232 | |
| 1197 | - return array( | |
| 1198 | - 'message' => 'Site updated successfully.', | |
| 1199 | - 'site' => $website->url, | |
| 1200 | - '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, | |
| 1201 | 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; | |
| 1202 | 1510 | } |
| 1203 | 1511 | } |