array( 'type' => 'integer', 'required' => true, ), ); register_rest_route( $this->namespace, '/feed', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'permission_community' ), 'args' => array_merge( $this->paging_args( 50 ), array( 'space_id' => array( 'type' => 'integer', 'default' => 0, ), 'user_id' => array( 'type' => 'integer', 'default' => 0, ), 'topic' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array( $this, 'sanitize_slug' ), ), 'hashtag' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array( Terms::class, 'hashtag_slug' ), ), 'bookmarked' => array( 'type' => 'boolean', 'default' => false, ), 'orderby' => array( 'type' => 'string', 'enum' => array( 'latest', 'activity' ), 'default' => 'latest', ), // False skips counting every post: X-WP-Total then only says whether more pages follow. 'count_total' => array( 'type' => 'boolean', 'default' => true, ), ) ), ), ) ); register_rest_route( $this->namespace, '/posts', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'permission_logged_in' ), 'args' => $this->content_args( true ), ), ) ); register_rest_route( $this->namespace, '/posts/(?P\d+)', array( 'args' => $id_arg, array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'permission_community' ), ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'permission_logged_in' ), 'args' => $this->content_args( false ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'permission_logged_in' ), ), ) ); foreach ( array( 'pin', 'like', 'bookmark' ) as $action ) { register_rest_route( $this->namespace, '/posts/(?P\d+)/' . $action, array( 'args' => $id_arg, array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'toggle_' . $action ), 'permission_callback' => array( $this, 'permission_logged_in' ), 'args' => array( 'on' => array( 'type' => 'boolean', 'required' => true, ), ), ), ) ); } } /** * Sanitizes a slug argument. A wrapper is needed because the REST API * passes the request as the second argument, which sanitize_title() * would treat as a fallback title. * * @param mixed $value Raw value. * @return string */ public function sanitize_slug( $value ) { return Slug::make( rawurldecode( (string) $value ) ); } /** * Lists posts. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_items( $request ) { $viewer_id = get_current_user_id(); $args = array( 'space_id' => (int) $request['space_id'], 'user_id' => (int) $request['user_id'], 'search' => $request['search'], 'orderby' => $request['orderby'], 'page' => $request['page'], 'per_page' => $request['per_page'], 'count' => (bool) $request['count_total'], ); if ( $args['space_id'] ) { $space = $this->visible_space( $args['space_id'] ); if ( is_wp_error( $space ) ) { return $space; } } if ( '' !== $request['topic'] ) { $topic = Terms::topic_by_slug( $request['topic'] ); if ( ! $topic ) { return $this->paged_response( array(), 0, $args['per_page'] ); } $args['topic_id'] = $topic['id']; } if ( '' !== $request['hashtag'] ) { $args['hashtag'] = $request['hashtag']; } if ( $request['bookmarked'] ) { if ( ! $viewer_id ) { return $this->permission_logged_in(); } $args['bookmarked'] = true; } $result = Posts::query( $args, $viewer_id ); return $this->paged_response( Formatter::posts( $result['items'], $viewer_id ), $result['total'], $args['per_page'] ); } /** * Returns one post. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_item( $request ) { $found = $this->readable_post( $request['id'] ); if ( is_wp_error( $found ) ) { return $found; } return rest_ensure_response( Formatter::post( $found['post'], get_current_user_id() ) ); } /** * Publishes a post. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_item( $request ) { $post = Posts::create( $this->content_input( $request ), get_current_user_id() ); if ( is_wp_error( $post ) ) { return $post; } $response = rest_ensure_response( Formatter::post( $post, get_current_user_id() ) ); $response->set_status( 201 ); return $response; } /** * Edits a post. Authors and space moderators only. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_item( $request ) { $found = $this->changeable_post( $request['id'] ); if ( is_wp_error( $found ) ) { return $found; } $post = Posts::update( $found['post'], $this->content_input( $request ), get_current_user_id() ); if ( is_wp_error( $post ) ) { return $post; } return rest_ensure_response( Formatter::post( $post, get_current_user_id() ) ); } /** * Deletes a post. Authors and space moderators only. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function delete_item( $request ) { $found = $this->changeable_post( $request['id'] ); if ( is_wp_error( $found ) ) { return $found; } Posts::delete( array( $found['post']['id'] ) ); return rest_ensure_response( array( 'deleted' => true, 'id' => $found['post']['id'], ) ); } /** * Pins or unpins a post. Space moderators only. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function toggle_pin( $request ) { $found = $this->readable_post( $request['id'] ); if ( is_wp_error( $found ) ) { return $found; } if ( ! SpaceAccess::can_moderate( $found['space'], get_current_user_id() ) ) { return $this->forbidden(); } return rest_ensure_response( Formatter::post( Posts::set_pinned( $found['post'], (bool) $request['on'] ), get_current_user_id() ) ); } /** * Likes or unlikes a post. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function toggle_like( $request ) { return $this->react( $request, 'like' ); } /** * Saves or unsaves a post. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function toggle_bookmark( $request ) { return $this->react( $request, 'bookmark' ); } /** * Applies a reaction. * * @param WP_REST_Request $request Request. * @param string $type Reaction type. * @return WP_REST_Response|WP_Error */ private function react( $request, $type ) { $limited = RateLimit::hit( 'react', get_current_user_id(), 60 ); if ( is_wp_error( $limited ) ) { return $limited; } $found = $this->readable_post( $request['id'] ); if ( is_wp_error( $found ) ) { return $found; } $user_id = get_current_user_id(); if ( 'like' === $type && ! PostAccess::can_comment( $found['space'], $user_id ) ) { return new WP_Error( 'spacebay_cannot_react', __( 'Join this space to like posts.', 'spacebay' ), array( 'status' => 403 ) ); } Reactions::set( 'post', $found['post']['id'], $user_id, $type, (bool) $request['on'] ); Posts::forget( $found['post']['id'] ); return rest_ensure_response( Formatter::post( Posts::find( $found['post']['id'] ), $user_id ) ); } /** * Returns a post and its space when the viewer may read it. * * @param int $id Post ID. * @return array{post: array, space: array}|WP_Error */ protected function readable_post( $id ) { $post = Posts::find( $id ); $space = $post ? SpaceRepository::find( $post['space_id'] ) : null; if ( ! $post || ! $space || 'published' !== $post['status'] || ! SpaceAccess::can_read( $space, get_current_user_id() ) ) { return new WP_Error( 'spacebay_post_not_found', __( 'Post not found.', 'spacebay' ), array( 'status' => 404 ) ); } return array( 'post' => $post, 'space' => $space, ); } /** * Returns a post the viewer may edit or delete. * * @param int $id Post ID. * @return array{post: array, space: array}|WP_Error */ private function changeable_post( $id ) { $found = $this->readable_post( $id ); if ( is_wp_error( $found ) ) { return $found; } if ( ! PostAccess::can_change( $found['space'], $found['post']['user_id'], get_current_user_id() ) ) { return $this->forbidden(); } return $found; } /** * Picks content fields from the request. * * @param WP_REST_Request $request Request. * @return array */ private function content_input( $request ) { $input = array(); foreach ( array( 'space_id', 'title', 'message', 'media_ids', 'topic_ids' ) as $field ) { if ( $request->has_param( $field ) ) { $input[ $field ] = $request->get_param( $field ); } } return $input; } /** * Argument schema for creating and editing posts. * * @param bool $is_new Whether a space is required. * @return array */ private function content_args( $is_new ) { $args = array( 'title' => array( 'type' => 'string' ), 'message' => array( 'type' => 'string' ), 'media_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'maxItems' => 10, ), 'topic_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'maxItems' => Terms::MAX_TOPICS, ), ); if ( $is_new ) { $args['space_id'] = array( 'type' => 'integer', 'required' => true, ); } return $args; } }