← All changes
|
jetpack_vendor/automattic/jetpack-connection/src/class-rest-connector.php
+200
-14
16.2
→
16.3
View file →
| @@ -9,8 +9,9 @@ | ||
| 9 | 9 | |
| 10 | 10 | use Automattic\Jetpack\Connection\Webhooks\Authorize_Redirect; |
| 11 | 11 | use Automattic\Jetpack\Constants; |
| 12 | 12 | use Automattic\Jetpack\Redirect; |
| 13 | +use Automattic\Jetpack\Roles; | |
| 13 | 14 | use Automattic\Jetpack\Status; |
| 14 | 15 | use Jetpack_XMLRPC_Server; |
| 15 | 16 | use WP_Error; |
| 16 | 17 | use WP_REST_Request; |
| @@ -22,9 +23,22 @@ | ||
| 22 | 23 | * |
| 23 | 24 | * @phan-constructor-used-for-side-effects |
| 24 | 25 | */ |
| 25 | 26 | class REST_Connector { |
| 27 | + | |
| 26 | 28 | /** |
| 29 | + * Site record options left out of the site data REST response. | |
| 30 | + * | |
| 31 | + * @since 9.9.0.1 | |
| 32 | + * | |
| 33 | + * @var string[] | |
| 34 | + */ | |
| 35 | + const EXCLUDED_SITE_OPTIONS = array( | |
| 36 | + 'frame_nonce', | |
| 37 | + 'jetpack_frame_nonce', | |
| 38 | + ); | |
| 39 | + | |
| 40 | + /** | |
| 27 | 41 | * The Connection Manager. |
| 28 | 42 | * |
| 29 | 43 | * @var Manager |
| 30 | 44 | */ |
| @@ -343,8 +357,30 @@ | ||
| 343 | 357 | ), |
| 344 | 358 | ), |
| 345 | 359 | ) |
| 346 | 360 | ); |
| 361 | + | |
| 362 | + // Confirm the current user as the protected owner. Not the connection-owner change above. | |
| 363 | + register_rest_route( | |
| 364 | + 'jetpack/v4', | |
| 365 | + '/connection/owner/protect', | |
| 366 | + array( | |
| 367 | + 'methods' => WP_REST_Server::EDITABLE, | |
| 368 | + 'callback' => array( static::class, 'protect_connection_owner' ), | |
| 369 | + 'permission_callback' => array( static::class, 'protect_connection_owner_permission_check' ), | |
| 370 | + ) | |
| 371 | + ); | |
| 372 | + | |
| 373 | + // Release the protected owner, leaving ownership open to any connected administrator. | |
| 374 | + register_rest_route( | |
| 375 | + 'jetpack/v4', | |
| 376 | + '/connection/owner/release', | |
| 377 | + array( | |
| 378 | + 'methods' => WP_REST_Server::EDITABLE, | |
| 379 | + 'callback' => array( static::class, 'release_connection_owner' ), | |
| 380 | + 'permission_callback' => array( static::class, 'release_connection_owner_permission_check' ), | |
| 381 | + ) | |
| 382 | + ); | |
| 347 | 383 | } |
| 348 | 384 | |
| 349 | 385 | /** |
| 350 | 386 | * Handles verification that a site is registered. |
| @@ -743,16 +779,20 @@ | ||
| 743 | 779 | return $response; |
| 744 | 780 | } |
| 745 | 781 | |
| 746 | 782 | /** |
| 747 | - * Verify that user is allowed to disconnect Jetpack. | |
| 783 | + * Verify that user is allowed to restore the connection. | |
| 748 | 784 | * |
| 785 | + * Users with only 'jetpack_connect_user' get through, but connection_reconnect() | |
| 786 | + * limits them to refreshing their own user token. | |
| 787 | + * | |
| 749 | 788 | * @since 1.15.0 |
| 789 | + * @since 9.8.0 Also allows 'jetpack_connect_user'. | |
| 750 | 790 | * |
| 751 | - * @return bool|WP_Error Whether user has the capability 'jetpack_disconnect'. | |
| 791 | + * @return bool|WP_Error Whether user has the capability 'jetpack_reconnect' or 'jetpack_connect_user'. | |
| 752 | 792 | */ |
| 753 | 793 | public static function jetpack_reconnect_permission_check() { |
| 754 | - if ( current_user_can( 'jetpack_reconnect' ) ) { | |
| 794 | + if ( current_user_can( 'jetpack_reconnect' ) || current_user_can( 'jetpack_connect_user' ) ) { | |
| 755 | 795 | return true; |
| 756 | 796 | } |
| 757 | 797 | |
| 758 | 798 | return new WP_Error( 'invalid_user_permission_jetpack_disconnect', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) ); |
| @@ -770,8 +810,9 @@ | ||
| 770 | 810 | /** |
| 771 | 811 | * The endpoint tried to partially or fully reconnect the website to WP.com. |
| 772 | 812 | * |
| 773 | 813 | * @since 1.15.0 |
| 814 | + * @since 9.8.0 Users without 'jetpack_reconnect' only refresh their own user token. | |
| 774 | 815 | * |
| 775 | 816 | * @return \WP_REST_Response|WP_Error |
| 776 | 817 | */ |
| 777 | 818 | public function connection_reconnect() { |
| @@ -778,9 +819,11 @@ | ||
| 778 | 819 | $response = array(); |
| 779 | 820 | |
| 780 | 821 | $next = null; |
| 781 | 822 | |
| 782 | - $result = $this->connection->restore(); | |
| 823 | + $result = current_user_can( 'jetpack_reconnect' ) | |
| 824 | + ? $this->connection->restore() | |
| 825 | + : $this->connection->refresh_user_token( false ); | |
| 783 | 826 | |
| 784 | 827 | if ( is_wp_error( $result ) ) { |
| 785 | 828 | $response = $result; |
| 786 | 829 | } elseif ( is_string( $result ) ) { |
| @@ -933,20 +976,11 @@ | ||
| 933 | 976 | $is_connection_owner = isset( $request['is_connection_owner'] ) |
| 934 | 977 | ? (bool) $request['is_connection_owner'] |
| 935 | 978 | : ( new Manager() )->get_connection_owner_id() === $user_id; |
| 936 | 979 | |
| 980 | + // Tokens::update_user_token() fires jetpack_updated_user_token itself. | |
| 937 | 981 | ( new Tokens() )->update_user_token( $user_id, $request['user_token'], $is_connection_owner ); |
| 938 | 982 | |
| 939 | - /** | |
| 940 | - * Fires when the user token gets successfully replaced. | |
| 941 | - * | |
| 942 | - * @since 1.29.0 | |
| 943 | - * | |
| 944 | - * @param int $user_id User ID. | |
| 945 | - * @param string $token New user token. | |
| 946 | - */ | |
| 947 | - do_action( 'jetpack_updated_user_token', $user_id, $request['user_token'] ); | |
| 948 | - | |
| 949 | 983 | return rest_ensure_response( |
| 950 | 984 | array( |
| 951 | 985 | 'success' => true, |
| 952 | 986 | ) |
| @@ -1075,8 +1109,132 @@ | ||
| 1075 | 1109 | return new WP_Error( 'invalid_user_permission_set_connection_owner', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) ); |
| 1076 | 1110 | } |
| 1077 | 1111 | |
| 1078 | 1112 | /** |
| 1113 | + * Confirm the current user as the protected owner. | |
| 1114 | + * | |
| 1115 | + * The claim is always for the signed-in user. A caller cannot name someone else. | |
| 1116 | + * | |
| 1117 | + * @since 9.9.0 | |
| 1118 | + * | |
| 1119 | + * @return WP_REST_Response|WP_Error | |
| 1120 | + */ | |
| 1121 | + public static function protect_connection_owner() { | |
| 1122 | + $result = ( new Manager() )->set_protected_owner( get_current_user_id() ); | |
| 1123 | + | |
| 1124 | + if ( is_wp_error( $result ) ) { | |
| 1125 | + return $result; | |
| 1126 | + } | |
| 1127 | + | |
| 1128 | + return rest_ensure_response( | |
| 1129 | + array( | |
| 1130 | + 'code' => 'success', | |
| 1131 | + ) | |
| 1132 | + ); | |
| 1133 | + } | |
| 1134 | + | |
| 1135 | + /** | |
| 1136 | + * Whether the current user may confirm a protected owner. | |
| 1137 | + * | |
| 1138 | + * A connected administrator qualifies, and only while a consumer is requesting a protected | |
| 1139 | + * owner. Holding the connection owner slot does not matter. | |
| 1140 | + * | |
| 1141 | + * `requires_protected_owner()` is documented as a momentary answer, but it is the only opt-in | |
| 1142 | + * signal there is, so a consumer that surfaces a confirmation must keep answering true for as | |
| 1143 | + * long as it is on screen. One that flips to false between render and submit turns its own | |
| 1144 | + * link into a 403. | |
| 1145 | + * | |
| 1146 | + * @since 9.9.0 | |
| 1147 | + * | |
| 1148 | + * @return true|WP_Error | |
| 1149 | + */ | |
| 1150 | + public static function protect_connection_owner_permission_check() { | |
| 1151 | + $user_id = get_current_user_id(); | |
| 1152 | + $admin_cap = ( new Roles() )->translate_role_to_cap( 'administrator' ); | |
| 1153 | + $manager = new Manager(); | |
| 1154 | + | |
| 1155 | + if ( | |
| 1156 | + $user_id | |
| 1157 | + && current_user_can( 'jetpack_connect' ) | |
| 1158 | + && $admin_cap | |
| 1159 | + && current_user_can( $admin_cap ) | |
| 1160 | + && $manager->is_user_connected( $user_id ) | |
| 1161 | + && $manager->requires_protected_owner() | |
| 1162 | + ) { | |
| 1163 | + return true; | |
| 1164 | + } | |
| 1165 | + | |
| 1166 | + return new WP_Error( | |
| 1167 | + 'invalid_user_permission_protect_owner', | |
| 1168 | + self::get_user_permissions_error_msg(), | |
| 1169 | + array( 'status' => rest_authorization_required_code() ) | |
| 1170 | + ); | |
| 1171 | + } | |
| 1172 | + | |
| 1173 | + /** | |
| 1174 | + * Release the protected owner for this site. | |
| 1175 | + * | |
| 1176 | + * @since 9.9.0 | |
| 1177 | + * | |
| 1178 | + * @return WP_REST_Response|WP_Error | |
| 1179 | + */ | |
| 1180 | + public static function release_connection_owner() { | |
| 1181 | + $result = ( new Manager() )->release_protected_owner(); | |
| 1182 | + | |
| 1183 | + if ( is_wp_error( $result ) ) { | |
| 1184 | + return $result; | |
| 1185 | + } | |
| 1186 | + | |
| 1187 | + return rest_ensure_response( | |
| 1188 | + array( | |
| 1189 | + 'code' => 'success', | |
| 1190 | + ) | |
| 1191 | + ); | |
| 1192 | + } | |
| 1193 | + | |
| 1194 | + /** | |
| 1195 | + * Whether the current user may release the protected owner. | |
| 1196 | + * | |
| 1197 | + * Only the confirmed owner qualifies. WordPress.com is asked again before anything is cleared, | |
| 1198 | + * and its answer is the one that decides. | |
| 1199 | + * | |
| 1200 | + * Deliberately not gated on `requires_protected_owner()`, unlike confirming: a consumer that | |
| 1201 | + * has stopped asking must not strand a site holding a lock it can no longer release. | |
| 1202 | + * | |
| 1203 | + * @since 9.9.0 | |
| 1204 | + * | |
| 1205 | + * @return true|WP_Error | |
| 1206 | + */ | |
| 1207 | + public static function release_connection_owner_permission_check() { | |
| 1208 | + $user_id = get_current_user_id(); | |
| 1209 | + $admin_cap = ( new Roles() )->translate_role_to_cap( 'administrator' ); | |
| 1210 | + $manager = new Manager(); | |
| 1211 | + | |
| 1212 | + if ( | |
| 1213 | + $user_id | |
| 1214 | + && current_user_can( 'jetpack_connect' ) | |
| 1215 | + && $admin_cap | |
| 1216 | + && current_user_can( $admin_cap ) | |
| 1217 | + && $manager->is_user_connected( $user_id ) | |
| 1218 | + ) { | |
| 1219 | + // `RE_EVALUATE` settles that the connection owner matches the anchor, so pinning this | |
| 1220 | + // user to that owner is what makes it their identity. A matching binding would not: | |
| 1221 | + // Premium Content writes the same key directly, so the IDs are not unique site-wide. | |
| 1222 | + $state = $manager->resolve_protected_owner_state(); | |
| 1223 | + | |
| 1224 | + if ( Manager::PO_STATE_RE_EVALUATE === $state['status'] && $user_id === (int) $manager->get_connection_owner_id() ) { | |
| 1225 | + return true; | |
| 1226 | + } | |
| 1227 | + } | |
| 1228 | + | |
| 1229 | + return new WP_Error( | |
| 1230 | + 'invalid_user_permission_release_owner', | |
| 1231 | + self::get_user_permissions_error_msg(), | |
| 1232 | + array( 'status' => rest_authorization_required_code() ) | |
| 1233 | + ); | |
| 1234 | + } | |
| 1235 | + | |
| 1236 | + /** | |
| 1079 | 1237 | * The endpoint verifies blog connection and blog token validity. |
| 1080 | 1238 | * |
| 1081 | 1239 | * @since 2.7.0 |
| 1082 | 1240 | * |
| @@ -1187,8 +1345,10 @@ | ||
| 1187 | 1345 | public static function site_data_response( ?Manager $connection = null ) { |
| 1188 | 1346 | $site_data = ( $connection ?? new Manager() )->get_connected_site_data(); |
| 1189 | 1347 | |
| 1190 | 1348 | if ( ! is_wp_error( $site_data ) ) { |
| 1349 | + $site_data = self::exclude_site_options( $site_data ); | |
| 1350 | + | |
| 1191 | 1351 | /** |
| 1192 | 1352 | * Fires when the site data was successfully returned from the /sites/%d wpcom endpoint. |
| 1193 | 1353 | * |
| 1194 | 1354 | * @since 8.10.0 |
| @@ -1222,8 +1382,34 @@ | ||
| 1222 | 1382 | 'api_error_code' => empty( $error_data['api_error_code'] ) ? null : $error_data['api_error_code'], |
| 1223 | 1383 | 'api_http_code' => empty( $error_data['api_http_code'] ) ? null : $error_data['api_http_code'], |
| 1224 | 1384 | ) |
| 1225 | 1385 | ); |
| 1386 | + } | |
| 1387 | + | |
| 1388 | + /** | |
| 1389 | + * Removes EXCLUDED_SITE_OPTIONS from the site record before it is served to a REST caller. | |
| 1390 | + * | |
| 1391 | + * Works on a copy: a listener on 'jetpack_site_data_fetched', or any other internal | |
| 1392 | + * consumer holding the record, keeps seeing it whole. | |
| 1393 | + * | |
| 1394 | + * @since 9.9.0.1 | |
| 1395 | + * | |
| 1396 | + * @param object $site_data The decoded site record. | |
| 1397 | + * @return object The record to serve, with EXCLUDED_SITE_OPTIONS removed. | |
| 1398 | + */ | |
| 1399 | + private static function exclude_site_options( $site_data ) { | |
| 1400 | + if ( ! is_object( $site_data ) || ! isset( $site_data->options ) || ! is_object( $site_data->options ) ) { | |
| 1401 | + return $site_data; | |
| 1402 | + } | |
| 1403 | + | |
| 1404 | + $site_data = clone $site_data; | |
| 1405 | + $site_data->options = clone $site_data->options; | |
| 1406 | + | |
| 1407 | + foreach ( self::EXCLUDED_SITE_OPTIONS as $option ) { | |
| 1408 | + unset( $site_data->options->$option ); | |
| 1409 | + } | |
| 1410 | + | |
| 1411 | + return $site_data; | |
| 1226 | 1412 | } |
| 1227 | 1413 | |
| 1228 | 1414 | /** |
| 1229 | 1415 | * Run all connection health tests and return the result. |