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
← All changes | class/class-mainwp-db-common.php +380 -49 6.0.11 → 6.2.1 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',
@@ -1067,43 +1092,70 @@
1067 1092 'ssl_version' => 'sslversion',
1068 1093 'uniqueId' => 'uniqueid',
1069 1094 );
1070 1095
1071 - $sql_set = '';
1096 + $update_fields = array();
1072 1097
1073 1098 foreach ( $map_fields as $field => $name ) {
1074 1099 if ( isset( $data[ $name ] ) && empty( ! $data[ $name ] ) ) {
1075 - $sql_set .= ' `' . $this->escape( $field ) . '` = "' . $this->escape( $data[ $name ] ) . '",';
1100 + $value = $data[ $name ];
1101 +
1102 + // MWP-1548: encrypt http_user / http_pass at rest before
1103 + // they hit the raw SQL UPDATE. Same fail-closed contract
1104 + // as the other write paths in MainWP_DB.
1105 + if ( 'http_user' === $field || 'http_pass' === $field ) {
1106 + $encrypted = MainWP_Credential_Storage::encrypt_credential( $value, $field );
1107 +
1108 + if ( false === $encrypted ) {
1109 + return false;
1110 + }
1111 +
1112 + $value = $encrypted;
1113 + }
1114 +
1115 + $update_fields[ $field ] = $value;
1076 1116 }
1077 1117 }
1078 1118
1079 1119 if ( isset( $data['verify'] ) ) {
1080 - $verify = intval( $data['verify'] );
1081 - $sql_set .= ' verify_certificate = "' . $this->escape( $verify ) . '",';
1120 + $verify = intval( $data['verify'] );
1121 + $update_fields['verify_certificate'] = $verify;
1082 1122 }
1083 1123
1084 1124 if ( isset( $data['protocol'] ) && ( 'http' === $data['protocol'] || 'https' === $data['protocol'] ) ) {
1085 - $url = $data['protocol'] . '://' . MainWP_Utility::remove_http_prefix( $website->url, true );
1086 - $sql_set .= ' url = "' . $this->escape( $url ) . '",';
1125 + $url = $data['protocol'] . '://' . MainWP_Utility::remove_http_prefix( $website->url, true );
1126 + $update_fields['url'] = $this->escape( $url );
1087 1127 }
1088 1128
1089 1129 if ( isset( $data['disablehealthchecking'] ) ) {
1090 - $sql_set .= ' disable_health_check = "' . ( $data['disablehealthchecking'] ? 1 : 0 ) . '",';
1130 + $update_fields['disable_health_check'] = $data['disablehealthchecking'] ? 1 : 0;
1091 1131 }
1092 1132
1093 1133 if ( isset( $data['healththreshold'] ) ) {
1094 - $sql_set .= ' health_threshold = "' . intval( $data['healththreshold'] ) . '",';
1134 + $update_fields['health_threshold'] = intval( $data['healththreshold'] );
1095 1135 }
1096 1136
1097 1137 if ( isset( $data['suspended'] ) ) {
1098 - $sql_set .= ' suspended = "' . ( 1 === intval( $data['suspended'] ) ? 1 : 0 ) . '",';
1138 + $update_fields['suspended'] = 1 === intval( $data['suspended'] ) ? 1 : 0;
1099 1139 }
1100 1140
1101 - if ( ! empty( $sql_set ) ) {
1102 - $sql_set = rtrim( $sql_set, ',' );
1103 - $table_wp = esc_sql( $this->table_name( 'wp' ) );
1104 - $this->wpdb->query( $this->wpdb->prepare( "UPDATE `{$table_wp}` SET " . $sql_set . ' WHERE id=%d', $websiteid ) ); // phpcs:ignore -- NOSONAR -- $sql_set are escaped.
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 + }
1105 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 + }
1106 1158 }
1107 1159
1108 1160 $groupids = array();
1109 1161 if ( isset( $data['groupids'] ) && ! empty( $data['groupids'] ) ) {
@@ -1135,13 +1187,16 @@
1135 1187
1136 1188 if ( isset( $data['backup_before_upgrade'] ) ) {
1137 1189 $newValues['backup_before_upgrade'] = $data['backup_before_upgrade'] ? 1 : 0;
1138 1190 }
1191 +
1139 1192 if ( isset( $data['force_use_ipv4'] ) ) {
1140 1193 $forceuseipv4 = intval( $data['force_use_ipv4'] );
1194 +
1141 1195 if ( 2 < $forceuseipv4 ) {
1142 1196 $forceuseipv4 = 0;
1143 1197 }
1198 +
1144 1199 $newValues['force_use_ipv4'] = $forceuseipv4;
1145 1200 }
1146 1201
1147 1202 if ( isset( $data['ignore_core_updates'] ) ) {
@@ -1166,15 +1221,291 @@
1166 1221 }
1167 1222
1168 1223 if ( isset( $data['monitoring_emails'] ) ) {
1169 1224 $monitoring_emails = MainWP_Utility::valid_input_emails( $data['monitoring_emails'] );
1170 - MainWP_DB::instance()->update_website_option( $website, 'monitoring_notification_emails', ( $monitoring_emails ) );
1171 1225
1226 + MainWP_DB::instance()->update_website_option(
1227 + $website,
1228 + 'monitoring_notification_emails',
1229 + $monitoring_emails
1230 + );
1172 1231 }
1173 1232
1174 - return array(
1175 - 'message' => 'Site updated successfully.',
1176 - 'site' => $website->url,
1177 - '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,
1178 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;
1179 1510 }
1180 1511 }