route_namespace, $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'index' ), 'permission_callback' => array( $this, 'authorize_as_from_wpcom' ), 'args' => array( 'page' => array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'per_page' => array( 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'user_id' => array( 'description' => __( 'Limit results to tokens belonging to this user.', 'woocommerce' ), 'type' => 'integer', 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'device_uuid' => array( 'description' => __( 'Limit results to tokens registered by this device.', 'woocommerce' ), 'type' => 'string', 'maxLength' => PushTokenValidator::DEVICE_UUID_MAXIMUM_LENGTH, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), ), ), 'schema' => fn () => array_merge( $this->get_base_schema(), array( 'title' => 'push_tokens', 'properties' => array( 'tokens' => array( 'description' => __( 'The push tokens registered on this store.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view' ), 'readonly' => true, 'items' => $this->get_schema(), ), ), ) ), ) ); register_rest_route( $this->route_namespace, $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'delete' ), 'args' => $this->get_args( 'delete' ), 'permission_callback' => array( $this, 'authorize_wpcom_always_or_push_user_when_enabled' ), ), 'schema' => array( $this, 'get_schema' ), ) ); if ( ! wc_get_container()->get( PushNotifications::class )->should_be_enabled() ) { return; } register_rest_route( $this->route_namespace, $this->rest_base, array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'create' ), 'args' => $this->get_args( 'create' ), 'permission_callback' => array( $this, 'authorize_as_authenticated' ), ), ) ); } /** * Returns push tokens for roles that can receive push notifications, * along with when each token was registered, when the app last confirmed * it, and the username and email of the account it belongs to. Optionally * limited to one user, one device, or both. * * @since 10.8.0 * * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @return WP_REST_Response|WP_Error */ public function index( WP_REST_Request $request ) { $page = (int) $request->get_param( 'page' ); $per_page = (int) $request->get_param( 'per_page' ); $filters = array( 'user_id' => $request->get_param( 'user_id' ), 'device_uuid' => $request->get_param( 'device_uuid' ), ); try { /** * Paginated result from get_tokens_for_roles. * * @var array{tokens: PushToken[], total: int, total_pages: int} $result */ $result = wc_get_container() ->get( PushTokensDataStore::class ) ->get_tokens_for_roles( PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED, $page, $per_page, $filters ); } catch ( Exception $e ) { return $this->convert_exception_to_wp_error( $e ); } $response = new WP_REST_Response( array( 'tokens' => $this->prepare_tokens_for_response( $result['tokens'] ) ), WP_Http::OK ); $response->header( 'X-WP-Total', (string) $result['total'] ); $response->header( 'X-WP-TotalPages', (string) $result['total_pages'] ); return $response; } /** * Formats tokens for the index response. * * The account fields are added here rather than in * {@see PushToken::to_rest_format()}, so the users for a whole page are * loaded in one query rather than one per token. * * Requesting three named columns rather than user objects skips loading * usermeta, and keeps WP_User_Query's result cache on. * * @param PushToken[] $tokens The tokens to format. * @return array[] */ private function prepare_tokens_for_response( array $tokens ): array { $user_ids = array_values( array_unique( array_filter( array_map( fn ( PushToken $token ) => $token->get_user_id(), $tokens ) ) ) ); if ( ! $user_ids ) { return array(); } $users = array_column( get_users( array( 'include' => $user_ids, 'fields' => array( 'ID', 'user_login', 'user_email' ), ) ), null, 'ID' ); return array_map( function ( PushToken $token ) use ( $users ) { $user = $users[ $token->get_user_id() ] ?? null; return array_merge( $token->to_rest_format(), array( 'user_login' => $user ? $user->user_login : null, 'user_email' => $user ? $user->user_email : null, ) ); }, $tokens ); } /** * Creates a push token record. * * @since 10.6.0 * * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @return WP_REST_Response|WP_Error */ public function create( WP_REST_Request $request ) { try { $data = array( 'user_id' => get_current_user_id(), 'token' => $request->get_param( 'token' ), 'platform' => $request->get_param( 'platform' ), 'device_uuid' => $request->get_param( 'device_uuid' ), 'origin' => $request->get_param( 'origin' ), 'device_locale' => $request->get_param( 'device_locale' ), 'metadata' => $request->get_param( 'metadata' ) ?? array(), ); $data_store = wc_get_container()->get( PushTokensDataStore::class ); $push_token = $data_store->get_by_token_or_device_id( $data ); if ( $push_token ) { $push_token->set_token( $data['token'] ); $push_token->set_device_uuid( $data['device_uuid'] ); $push_token->set_device_locale( $data['device_locale'] ); $push_token->set_metadata( $data['metadata'] ); $data_store->update( $push_token ); } else { $push_token = $data_store->create( $data ); } } catch ( Exception $e ) { return $this->convert_exception_to_wp_error( $e ); } return new WP_REST_Response( array( 'id' => $push_token->get_id() ), WP_Http::CREATED ); } /** * Deletes a push token record. * * Users can only delete their own tokens. WPCOM can delete any token, so * support can stop notifications to a device its owner can no longer reach * from the app. * * @since 10.6.0 * * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @throws PushTokenNotFoundException If token does not belong to authenticated user. * @throws WC_Data_Exception If token wasn't deleted. * @return WP_REST_Response|WP_Error */ public function delete( WP_REST_Request $request ) { try { $id = (int) $request->get_param( 'id' ); $from_wpcom = $this->is_signed_with_blog_token(); $data_store = wc_get_container()->get( PushTokensDataStore::class ); $push_token = $data_store->read( $id ); if ( ! $from_wpcom && $push_token->get_user_id() !== get_current_user_id() ) { throw new PushTokenNotFoundException(); } $deleted = $data_store->delete( $id ); if ( ! $deleted ) { throw new WC_Data_Exception( 'woocommerce_push_token_not_deleted', 'The push token could not be deleted.', WP_Http::INTERNAL_SERVER_ERROR ); } if ( $from_wpcom ) { wc_get_container() ->get( LegacyProxy::class ) ->call_function( 'wc_get_logger' ) ->info( 'Push token deleted by WordPress.com support. The device it was registered from will no longer receive push notifications from this store.', array( 'source' => PushNotifications::FEATURE_NAME, 'token_id' => $id, 'user_id' => $push_token->get_user_id(), 'platform' => $push_token->get_platform(), ) ); } } catch ( Exception $e ) { return $this->convert_exception_to_wp_error( $e ); } return new WP_REST_Response( null, WP_Http::NO_CONTENT ); } /** * Validates the arguments from the request via PushTokenValidator. * * @since 10.6.0 * * @param mixed $value The value being validated. * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @param string $param The name of the parameter being validated. * @return bool|WP_Error */ public function validate_argument( $value, WP_REST_Request $request, string $param ) { return PushTokenValidator::validate( $request->get_params(), array( $param ) ); } /** * Get the schema for a single push token. * * Describes the token as the index returns it. The fields a client sends * when registering one are published separately, through the route `args` * that OPTIONS reports per endpoint. * * @since 10.6.0 * * @return array */ public function get_schema(): array { return array_merge( $this->get_base_schema(), array( 'title' => PushToken::POST_TYPE, 'properties' => array( 'id' => array( 'description' => __( 'Unique identifier for the token.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view' ), 'readonly' => true, ), 'user_id' => array( 'description' => __( 'The user the token belongs to.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view' ), 'readonly' => true, ), 'user_login' => array( 'description' => __( 'The username of the account the token belongs to. Null when the user no longer exists.', 'woocommerce' ), 'type' => array( 'string', 'null' ), 'context' => array( 'view' ), 'readonly' => true, ), 'user_email' => array( 'description' => __( 'The email address of the account the token belongs to. Null when the user no longer exists.', 'woocommerce' ), 'type' => array( 'string', 'null' ), 'context' => array( 'view' ), 'readonly' => true, ), 'token' => array( 'description' => __( 'The push token issued by Apple or Google.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'platform' => array( 'description' => __( 'The platform the token was issued for.', 'woocommerce' ), 'type' => 'string', 'enum' => PushToken::PLATFORMS, 'context' => array( 'view' ), 'readonly' => true, ), 'origin' => array( 'description' => __( 'The app the token was registered from.', 'woocommerce' ), 'type' => 'string', 'enum' => PushToken::ORIGINS, 'context' => array( 'view' ), 'readonly' => true, ), 'device_uuid' => array( 'description' => __( 'An identifier the app generates for its own install, so a re-registration matches the existing record after the OS issues a new token. Null for browser tokens.', 'woocommerce' ), 'type' => array( 'string', 'null' ), 'context' => array( 'view' ), 'readonly' => true, ), 'device_locale' => array( 'description' => __( 'The locale the device is set to.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'metadata' => array( 'description' => __( 'Values the app supplies to describe itself and the device, such as the app and OS version.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view' ), 'readonly' => true, ), 'created_at_gmt' => array( 'description' => __( 'The date the token was registered, as GMT. Null when the date is unknown.', 'woocommerce' ), 'type' => array( 'string', 'null' ), 'format' => 'date-time', 'context' => array( 'view' ), 'readonly' => true, ), 'last_confirmed_at_gmt' => array( 'description' => __( 'The date the app last registered this token, as GMT. The app re-sends the token periodically, not only when the token value changes, so this shows how recently the app read the token from the device. Null when the date is unknown.', 'woocommerce' ), 'type' => array( 'string', 'null' ), 'format' => 'date-time', 'context' => array( 'view' ), 'readonly' => true, ), 'last_sent_at_gmt' => array( 'description' => __( 'The date a notification for this token was last sent to WordPress.com, as GMT. This records that WordPress.com accepted the payload, not that the device received it. Null when no send has been recorded, which also covers a send whose record failed.', 'woocommerce' ), 'type' => array( 'string', 'null' ), 'format' => 'date-time', 'context' => array( 'view' ), 'readonly' => true, ), ), ) ); } /** * Get the accepted arguments for the POST request. * * @since 10.6.0 * * @param string $context The context to return args for. * @return array */ private function get_args( ?string $context = null ): array { $args = array( 'id' => array( 'description' => __( 'Push Token ID', 'woocommerce' ), 'type' => 'integer', 'required' => true, 'context' => array( 'delete' ), 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => array( $this, 'validate_argument' ), ), 'origin' => array( 'description' => __( 'Origin', 'woocommerce' ), 'type' => 'string', 'required' => true, 'context' => array( 'create' ), 'enum' => PushToken::ORIGINS, 'validate_callback' => array( $this, 'validate_argument' ), ), 'device_uuid' => array( 'description' => __( 'Device UUID', 'woocommerce' ), 'default' => '', 'type' => 'string', 'context' => array( 'create' ), 'validate_callback' => array( $this, 'validate_argument' ), 'sanitize_callback' => 'sanitize_text_field', ), 'device_locale' => array( 'description' => __( 'Device Locale', 'woocommerce' ), 'type' => 'string', 'required' => true, 'context' => array( 'create' ), 'validate_callback' => array( $this, 'validate_argument' ), 'sanitize_callback' => 'sanitize_text_field', ), 'platform' => array( 'description' => __( 'Platform', 'woocommerce' ), 'type' => 'string', 'required' => true, 'context' => array( 'create' ), 'enum' => PushToken::PLATFORMS, 'validate_callback' => array( $this, 'validate_argument' ), ), 'token' => array( 'description' => __( 'Push Token', 'woocommerce' ), 'type' => 'string', 'required' => true, 'context' => array( 'create' ), 'validate_callback' => array( $this, 'validate_argument' ), 'sanitize_callback' => 'wp_unslash', ), 'metadata' => array( 'description' => __( 'Metadata', 'woocommerce' ), 'type' => 'object', 'context' => array( 'create' ), 'validate_callback' => array( $this, 'validate_argument' ), 'sanitize_callback' => 'wp_unslash', ), ); if ( $context ) { $args = array_filter( $args, fn ( $arg ) => in_array( $context, $arg['context'], true ) ); } return $args; } }