@@ -987,6 +987,11 @@ protected function prepare_item_for_database( $request ) {
987987 $ prepared_site ['domain ' ] = $ request ['domain ' ];
988988 }
989989
990+ $ domain_check = $ this ->check_domain_is_available ( $ prepared_site , $ request );
991+ if ( is_wp_error ( $ domain_check ) ) {
992+ return $ domain_check ;
993+ }
994+
990995 /**
991996 * Filters a site after it is prepared for the database.
992997 *
@@ -1000,6 +1005,209 @@ protected function prepare_item_for_database( $request ) {
10001005 return apply_filters ( 'rest_preprocess_site ' , $ prepared_site , $ request );
10011006 }
10021007
1008+ /**
1009+ * Validates that the `domain` parameter is a valid hostname or IP address.
1010+ *
1011+ * @since 7.2.0
1012+ *
1013+ * @param mixed $value Value of the `domain` parameter.
1014+ * @param WP_REST_Request $request The current request.
1015+ * @param string $param The parameter name.
1016+ * @return true|WP_Error True if the value is valid, otherwise a WP_Error object.
1017+ */
1018+ public function validate_domain ( $ value , $ request , $ param ) {
1019+ $ valid = rest_validate_request_arg ( $ value , $ request , $ param );
1020+ if ( is_wp_error ( $ valid ) ) {
1021+ return $ valid ;
1022+ }
1023+
1024+ if ( $ this ->is_valid_domain ( $ value ) ) {
1025+ return true ;
1026+ }
1027+
1028+ return new WP_Error (
1029+ 'rest_invalid_param ' ,
1030+ __ ( 'domain must be a valid hostname or IP address (IPv4 or IPv6), optionally followed by a port. Bracketed IPv6 addresses are not supported. ' ),
1031+ array (
1032+ 'status ' => 400 ,
1033+ 'param ' => $ param ,
1034+ )
1035+ );
1036+ }
1037+
1038+ /**
1039+ * Checks whether a string is a valid domain: a hostname or bare IP address
1040+ * (IPv4 or IPv6, unbracketed), optionally followed by a port number.
1041+ * Brackets are not supported, so an IPv6 address can't carry a port.
1042+ *
1043+ * @since 7.2.0
1044+ *
1045+ * @param string $domain The domain to check.
1046+ * @return bool True if `$domain` is a valid domain, otherwise false.
1047+ */
1048+ private function is_valid_domain ( $ domain ) {
1049+ if ( ! is_string ( $ domain ) || '' === $ domain ) {
1050+ return false ;
1051+ }
1052+
1053+ // Bare IPv4 or IPv6 address, no port.
1054+ if ( $ this ->is_ip_address_candidate ( $ domain ) && rest_is_ip_address ( $ domain ) ) {
1055+ return true ;
1056+ }
1057+
1058+ // hostname[:port] or ipv4[:port]. A second colon means this isn't a
1059+ // single port suffix but an (invalid, since it failed the check above)
1060+ // unbracketed IPv6 address, so only split off a port when there's exactly one colon.
1061+ $ host = $ domain ;
1062+ $ port = null ;
1063+
1064+ if ( 1 === substr_count ( $ domain , ': ' ) ) {
1065+ $ colon_pos = strpos ( $ domain , ': ' );
1066+ $ host = substr ( $ domain , 0 , $ colon_pos );
1067+ $ port = substr ( $ domain , $ colon_pos + 1 );
1068+ }
1069+
1070+ if ( null !== $ port && ! $ this ->is_valid_port ( $ port ) ) {
1071+ return false ;
1072+ }
1073+
1074+ if ( $ this ->is_ip_address_candidate ( $ host ) && rest_is_ip_address ( $ host ) ) {
1075+ return true ;
1076+ }
1077+
1078+ return 1 === preg_match (
1079+ '/^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/ ' ,
1080+ $ host
1081+ ) && strlen ( $ host ) <= 253 ;
1082+ }
1083+
1084+ /**
1085+ * Checks whether a string is a valid TCP/UDP port number (1-65535).
1086+ *
1087+ * @since 7.2.0
1088+ *
1089+ * @param string $port The port to check.
1090+ * @return bool True if `$port` is a valid port number, otherwise false.
1091+ */
1092+ private function is_valid_port ( $ port ) {
1093+ return 1 === preg_match ( '/^[0-9]{1,5}$/ ' , $ port ) && (int ) $ port >= 1 && (int ) $ port <= 65535 ;
1094+ }
1095+
1096+ /**
1097+ * Checks whether a string is made up only of characters that can appear
1098+ * in an IPv4 or IPv6 (including IPv4-mapped) address, i.e. it's safe to
1099+ * pass to rest_is_ip_address(). That underlying check parses its input
1100+ * as hexadecimal, and PHP emits a warning for any other character, so
1101+ * this keeps arbitrary domain input from tripping that on the way in.
1102+ *
1103+ * @since 7.2.0
1104+ *
1105+ * @param string $value The value to check.
1106+ * @return bool True if `$value` only contains valid IP address characters, otherwise false.
1107+ */
1108+ private function is_ip_address_candidate ( $ value ) {
1109+ return 1 === preg_match ( '/^[0-9A-Fa-f:.]+$/ ' , $ value );
1110+ }
1111+
1112+ /**
1113+ * Validates that the `path` parameter is a valid site path.
1114+ *
1115+ * @since 7.2.0
1116+ *
1117+ * @param mixed $value Value of the `path` parameter.
1118+ * @param WP_REST_Request $request The current request.
1119+ * @param string $param The parameter name.
1120+ * @return true|WP_Error True if the value is valid, otherwise a WP_Error object.
1121+ */
1122+ public function validate_path ( $ value , $ request , $ param ) {
1123+ $ valid = rest_validate_request_arg ( $ value , $ request , $ param );
1124+ if ( is_wp_error ( $ valid ) ) {
1125+ return $ valid ;
1126+ }
1127+
1128+ if ( $ this ->is_valid_path ( $ value ) ) {
1129+ return true ;
1130+ }
1131+
1132+ return new WP_Error (
1133+ 'rest_invalid_param ' ,
1134+ __ ( 'path must be a valid site path, starting and ending with a forward slash. ' ),
1135+ array (
1136+ 'status ' => 400 ,
1137+ 'param ' => $ param ,
1138+ )
1139+ );
1140+ }
1141+
1142+ /**
1143+ * Checks whether a string is a valid site path: a forward slash, or a
1144+ * sequence of `/`-delimited segments each starting and ending with a
1145+ * forward slash, made up of characters allowed in a URL path segment
1146+ * (RFC 3986 `pchar`).
1147+ *
1148+ * @since 7.2.0
1149+ *
1150+ * @param string $path The path to check.
1151+ * @return bool True if `$path` is a valid site path, otherwise false.
1152+ */
1153+ private function is_valid_path ( $ path ) {
1154+ if ( ! is_string ( $ path ) || '' === $ path ) {
1155+ return false ;
1156+ }
1157+
1158+ return 1 === preg_match (
1159+ '#^/(?:(?:[A-Za-z0-9._~!$& \'()*+,;=:@-]|%[0-9A-Fa-f]{2})+/)*$# ' ,
1160+ $ path
1161+ );
1162+ }
1163+
1164+ /**
1165+ * Checks that the domain and path for a prepared site are not already in use.
1166+ *
1167+ * When creating a site, the domain and path must not belong to any existing
1168+ * site. When updating a site, they may only belong to the site being updated.
1169+ *
1170+ * @since 7.2.0
1171+ *
1172+ * @param array $prepared_site The prepared site data for `wp_insert_site()`.
1173+ * @param WP_REST_Request $request The current request.
1174+ * @return true|WP_Error True if the domain and path are available, WP_Error otherwise.
1175+ */
1176+ protected function check_domain_is_available ( $ prepared_site , $ request ) {
1177+ $ id = (int ) $ request ['id ' ];
1178+ $ domain = isset ( $ prepared_site ['domain ' ] ) ? $ prepared_site ['domain ' ] : '' ;
1179+ $ path = isset ( $ prepared_site ['path ' ] ) ? $ prepared_site ['path ' ] : '/ ' ;
1180+ $ network_id = isset ( $ prepared_site ['network_id ' ] ) ? $ prepared_site ['network_id ' ] : get_current_network_id ();
1181+
1182+ if ( ! empty ( $ id ) ) {
1183+ // Updating a site: fall back to the current values for anything the request left out.
1184+ $ current_site = $ this ->get_site ( $ id );
1185+
1186+ if ( is_wp_error ( $ current_site ) ) {
1187+ return $ current_site ;
1188+ }
1189+
1190+ if ( ! isset ( $ prepared_site ['domain ' ] ) ) {
1191+ $ domain = $ current_site ->domain ;
1192+ }
1193+ if ( ! isset ( $ prepared_site ['path ' ] ) ) {
1194+ $ path = $ current_site ->path ;
1195+ }
1196+ if ( ! isset ( $ prepared_site ['network_id ' ] ) ) {
1197+ $ network_id = (int ) $ current_site ->network_id ;
1198+ }
1199+ }
1200+
1201+ $ existing_site_id = domain_exists ( $ domain , $ path , $ network_id );
1202+
1203+ // The domain and path may only belong to the site being updated.
1204+ if ( $ existing_site_id && (int ) $ existing_site_id !== $ id ) {
1205+ return new WP_Error ( 'rest_site_taken ' , __ ( 'Sorry, that site already exists! ' ), array ( 'status ' => 400 ) );
1206+ }
1207+
1208+ return true ;
1209+ }
1210+
10031211 /**
10041212 * Retrieves the site's schema, conforming to JSON Schema.
10051213 *
@@ -1027,18 +1235,25 @@ public function get_item_schema() {
10271235 'description ' => __ ( 'The site \'s network ID. Default is the current network ID. ' ),
10281236 'type ' => 'integer ' ,
10291237 'context ' => array ( 'view ' , 'edit ' , 'embed ' ),
1238+ 'default ' => get_current_network_id (),
10301239 ),
10311240 'domain ' => array (
10321241 'description ' => __ ( 'Site domain. ' ),
10331242 'type ' => 'string ' ,
10341243 'context ' => array ( 'view ' , 'edit ' , 'embed ' ),
10351244 'default ' => '' ,
1245+ 'arg_options ' => array (
1246+ 'validate_callback ' => array ( $ this , 'validate_domain ' ),
1247+ ),
10361248 ),
10371249 'path ' => array (
10381250 'description ' => __ ( 'Site path. ' ),
10391251 'type ' => 'string ' ,
10401252 'context ' => array ( 'view ' , 'edit ' , 'embed ' ),
10411253 'default ' => '/ ' ,
1254+ 'arg_options ' => array (
1255+ 'validate_callback ' => array ( $ this , 'validate_path ' ),
1256+ ),
10421257 ),
10431258 'registered ' => array (
10441259 'description ' => __ ( 'When the site was registered, in the site \'s timezone. ' ),
0 commit comments