namespace, '/reports', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_report' ), 'permission_callback' => array( $this, 'permission_logged_in' ), 'args' => array( 'object_type' => array( 'type' => 'string', 'enum' => Reports::TYPES, 'required' => true, ), 'object_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), 'reason' => array( 'type' => 'string', 'enum' => Reports::REASONS, 'required' => true, ), 'note' => array( 'type' => 'string', 'default' => '', 'maxLength' => 500, ), ), ), ) ); register_rest_route( $this->namespace, '/moderation/counts', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_counts' ), 'permission_callback' => array( $this, 'permission_moderate' ), ), ) ); register_rest_route( $this->namespace, '/moderation/queue', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_queue' ), 'permission_callback' => array( $this, 'permission_moderate' ), 'args' => array_merge( $this->paging_args( 50 ), array( 'type' => array( 'type' => 'string', 'enum' => array_merge( array( 'all' ), Reports::TYPES ), 'default' => 'all', ), ) ), ), ) ); register_rest_route( $this->namespace, '/moderation/queue/(?Ppost|comment|member|message)/(?P\d+)', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'decide' ), 'permission_callback' => array( $this, 'permission_moderate' ), 'args' => array( 'action' => array( 'type' => 'string', 'enum' => array( 'approve', 'remove' ), 'required' => true, ), ), ), ) ); register_rest_route( $this->namespace, '/moderation/members', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_members' ), 'permission_callback' => array( $this, 'permission_moderate' ), 'args' => array_merge( $this->paging_args( 50 ), array( 'status' => array( 'type' => 'string', 'enum' => array( 'pending', 'blocked' ), 'default' => 'pending', ), ) ), ), ) ); register_rest_route( $this->namespace, '/moderation/members/(?P\d+)', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'update_member' ), 'permission_callback' => array( $this, 'permission_moderate' ), 'args' => array( 'status' => array( 'type' => 'string', 'enum' => array( 'active', 'blocked' ), 'required' => true, ), ), ), ) ); register_rest_route( $this->namespace, '/moderators', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_moderators' ), 'permission_callback' => array( $this, 'permission_manage' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'add_moderator' ), 'permission_callback' => array( $this, 'permission_manage' ), 'args' => array( 'user_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), ), ), ) ); register_rest_route( $this->namespace, '/moderators/(?P\d+)', array( array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'remove_moderator' ), 'permission_callback' => array( $this, 'permission_manage' ), ), ) ); } /** * Saves a report from a member. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_report( $request ) { $user_id = get_current_user_id(); $type = $request['object_type']; $id = absint( $request['object_id'] ); $author = $this->reportable_author( $type, $id, $user_id ); if ( is_wp_error( $author ) ) { return $author; } if ( $author === $user_id ) { return new WP_Error( 'spacebay_report_self', __( 'You cannot report yourself.', 'spacebay' ), array( 'status' => 400 ) ); } $limited = RateLimit::hit( 'report', $user_id, 10 ); if ( is_wp_error( $limited ) ) { return $limited; } $result = Reports::add( $type, $id, $user_id, $request['reason'], $request['note'] ); if ( ! $result['duplicate'] ) { /** * Fires after a member reports a post, comment or member. * * @since 1.0.0 * * @param string $type "post", "comment" or "member". * @param int $id Object ID. * @param int $reporter_id Reporting member. * @param string $reason Reason key. */ do_action( 'spacebay_content_reported', $type, $id, $user_id, $request['reason'] ); Moderation::after_report( $type, $id ); } return rest_ensure_response( array( 'reported' => true ) ); } /** * Numbers shown as badges. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_counts( $request ) { unset( $request ); return rest_ensure_response( array( 'queue' => Reports::open_count(), 'members' => count( Profiles::ids_with_status( 'pending' ) ), ) ); } /** * Lists reported and held items. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_queue( $request ) { $result = Reports::queue( $request['type'], $request['page'], $request['per_page'] ); $items = array(); $total = $result['total']; foreach ( $result['items'] as $item ) { $formatted = $this->format_item( $item ); // Content deleted elsewhere, for example with its space, leaves the queue. if ( ! $formatted['exists'] ) { Reports::resolve( $item['object_type'], $item['object_id'], 'removed', 0 ); --$total; continue; } $items[] = $formatted; } return $this->paged_response( $items, max( 0, $total ), $request['per_page'] ); } /** * Applies a moderator decision. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function decide( $request ) { $type = $request['type']; $id = absint( $request['id'] ); $result = 'approve' === $request['action'] ? Moderation::approve( $type, $id, get_current_user_id() ) : Moderation::remove( $type, $id, get_current_user_id() ); if ( is_wp_error( $result ) && 'spacebay_content_gone' !== $result->get_error_code() ) { return $result; } return rest_ensure_response( array( 'done' => true, 'queue' => Reports::open_count(), ) ); } /** * Lists members who wait for approval or are suspended. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_members( $request ) { $ids = Profiles::ids_with_status( $request['status'] ); $per_page = (int) $request['per_page']; $page_ids = array_slice( array_reverse( $ids ), ( max( 1, (int) $request['page'] ) - 1 ) * $per_page, $per_page ); $authors = Formatter::authors( $page_ids ); $show = current_user_can( Capabilities::MANAGE ); $items = array(); foreach ( $page_ids as $user_id ) { $user = get_userdata( $user_id ); if ( ! $user || ! isset( $authors[ $user_id ] ) ) { continue; } $items[] = array_merge( $authors[ $user_id ], array( 'email' => $show ? $user->user_email : '', 'registered_at' => Time::iso( $user->user_registered ), 'status' => $request['status'], ) ); } return $this->paged_response( $items, count( $ids ), $per_page ); } /** * Approves, suspends or restores a member. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_member( $request ) { $id = absint( $request['id'] ); $result = Moderation::set_member_status( $id, $request['status'], get_current_user_id() ); if ( is_wp_error( $result ) ) { return $result; } if ( 'active' === $request['status'] ) { Reports::resolve( 'member', $id, 'dismissed', get_current_user_id() ); } return rest_ensure_response( array( 'id' => $id, 'status' => $request['status'], 'members' => count( Profiles::ids_with_status( 'pending' ) ), ) ); } /** * Lists community moderators and managers. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_moderators( $request ) { unset( $request ); $ids = Moderation::moderator_ids(); $authors = Formatter::authors( $ids ); $items = array(); foreach ( $ids as $user_id ) { $user = get_userdata( $user_id ); if ( $user && isset( $authors[ $user_id ] ) ) { $items[] = array_merge( $authors[ $user_id ], array( 'is_manager' => user_can( $user, Capabilities::MANAGE ), 'removable' => ! user_can( $user, Capabilities::MANAGE ) && ! empty( $user->caps[ Capabilities::MODERATE ] ), ) ); } } return rest_ensure_response( $items ); } /** * Makes a member a community moderator. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function add_moderator( $request ) { $user = get_userdata( absint( $request['user_id'] ) ); if ( ! $user instanceof WP_User || ( is_multisite() && ! is_user_member_of_blog( $user->ID ) ) ) { return new WP_Error( 'spacebay_member_not_found', __( 'Member not found.', 'spacebay' ), array( 'status' => 404 ) ); } $user->add_cap( Capabilities::MODERATE ); Profiles::set_status( $user->ID, 'active' ); return $this->get_moderators( $request ); } /** * Removes the moderator role from a member. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function remove_moderator( $request ) { $user = get_userdata( absint( $request['id'] ) ); if ( ! $user instanceof WP_User ) { return new WP_Error( 'spacebay_member_not_found', __( 'Member not found.', 'spacebay' ), array( 'status' => 404 ) ); } if ( user_can( $user, Capabilities::MANAGE ) ) { return new WP_Error( 'spacebay_cannot_remove', __( 'Administrators always moderate the community.', 'spacebay' ), array( 'status' => 400 ) ); } $user->remove_cap( Capabilities::MODERATE ); return $this->get_moderators( $request ); } /** * Author of something the member may report, or an error. * * @param string $type Object type. * @param int $id Object ID. * @param int $user_id Reporter. * @return int|WP_Error Author ID. */ private function reportable_author( $type, $id, $user_id ) { $missing = new WP_Error( 'spacebay_report_not_found', __( 'This no longer exists.', 'spacebay' ), array( 'status' => 404 ) ); if ( 'member' === $type ) { $user = get_userdata( $id ); $profile = $user ? Profiles::get( $id ) : null; return $user && ( ! $profile || 'active' === $profile['status'] ) ? (int) $user->ID : $missing; } if ( 'message' === $type ) { $message = \SpaceBay\Messages\Messages::find( $id ); // Only someone in the conversation can report a message, and only a real one. if ( ! $message || 'text' !== $message['type'] || 'published' !== $message['status'] || ! \SpaceBay\Messages\Messages::visible_to( $message, $user_id ) ) { return $missing; } return (int) $message['user_id']; } $found = Moderation::content( $type, $id ); if ( ! $found || 'published' !== $found['row']['status'] || 'published' !== $found['post']['status'] || ! SpaceAccess::can_read( $found['space'], $user_id ) ) { return $missing; } return (int) $found['row']['user_id']; } /** * Formats one queue item for moderators. * * @param array $item Queue item from Reports::queue(). * @return array */ private function format_item( array $item ) { $type = $item['object_type']; $id = $item['object_id']; $reporters = Formatter::authors( array_filter( wp_list_pluck( $item['reports'], 'reporter_id' ) ) ); $reports = array(); foreach ( $item['reports'] as $report ) { $reports[] = array( 'reason' => $report['reason'], 'label' => $this->reason_label( $report['reason'], $report['note'] ), 'note' => $report['reporter_id'] ? $report['note'] : '', 'reporter' => $report['reporter_id'] && isset( $reporters[ $report['reporter_id'] ] ) ? $reporters[ $report['reporter_id'] ]['name'] : '', 'created_at' => Time::iso( $report['created_at'] ), ); } $data = array( 'key' => $type . '-' . $id, 'type' => $type, 'id' => $id, 'reports' => $reports, 'first_at' => Time::iso( $item['first_at'] ), 'exists' => false, 'status' => '', 'title' => '', 'content' => '', 'author' => Formatter::deleted_author(), 'space' => null, 'route' => '', ); if ( 'member' === $type ) { $authors = Formatter::authors( array( $id ) ); if ( isset( $authors[ $id ] ) ) { $profile = Profiles::get( $id ); $data['exists'] = true; $data['author'] = $authors[ $id ]; $data['status'] = $profile ? $profile['status'] : 'active'; $data['route'] = 'members/' . $id; } return $data; } if ( 'message' === $type ) { $message = \SpaceBay\Messages\Messages::find( $id ); if ( ! $message || 'published' !== $message['status'] ) { return $data; } $authors = Formatter::authors( array( $message['user_id'] ) ); $data['exists'] = true; $data['status'] = 'published'; $data['content'] = $message['message_rendered']; $data['author'] = $authors[ $message['user_id'] ] ?? Formatter::deleted_author(); $data['media'] = \SpaceBay\Content\Media::for_objects( 'message', array( $id ) )[ $id ] ?? array(); return $data; } $found = Moderation::content( $type, $id ); if ( ! $found ) { return $data; } $authors = Formatter::authors( array( $found['row']['user_id'] ) ); $data['exists'] = true; $data['status'] = $found['row']['status']; $data['title'] = 'post' === $type ? $found['row']['title'] : $found['post']['title']; $data['content'] = $found['row']['message_rendered']; $data['author'] = $authors[ $found['row']['user_id'] ] ?? Formatter::deleted_author(); $data['space'] = array( 'title' => $found['space']['title'], 'slug' => $found['space']['slug'], ); $data['route'] = 'published' === $found['post']['status'] ? 'posts/' . $found['post']['id'] : ''; if ( 'post' === $type ) { $data['media'] = \SpaceBay\Content\Media::for_objects( 'post', array( $id ) )[ $id ] ?? array(); } return $data; } /** * Human label of a report reason. * * @param string $reason Reason key. * @param string $note System note. * @return string */ private function reason_label( $reason, $note ) { switch ( $reason ) { case 'spam': return __( 'Spam', 'spacebay' ); case 'abuse': return __( 'Harassment or hate', 'spacebay' ); case 'inappropriate': return __( 'Inappropriate content', 'spacebay' ); case 'words': /* translators: %s: Blocked words found in the text. */ return sprintf( __( 'Contains blocked words: %s', 'spacebay' ), $note ); case 'first_post': return __( 'First post from a new member', 'spacebay' ); case 'all_posts': return __( 'All new posts are reviewed', 'spacebay' ); case 'edited': return __( 'Edited post; all posts are reviewed', 'spacebay' ); case 'reports': return __( 'Hidden automatically after several reports', 'spacebay' ); } return __( 'Other', 'spacebay' ); } }