id === 'settings_page_activitypub' ) { $this->user_id = Actors::BLOG_USER_ID; $this->follow_url = \admin_url( 'options-general.php?page=activitypub&tab=following' ); } else { $this->user_id = \get_current_user_id(); $this->follow_url = \admin_url( 'users.php?page=activitypub-following-list' ); \add_action( 'admin_notices', array( $this, 'process_admin_notices' ) ); } parent::__construct( array( 'singular' => \__( 'Follower', 'activitypub' ), 'plural' => \__( 'Followers', 'activitypub' ), 'ajax' => false, ) ); \add_action( 'load-' . \get_current_screen()->id, array( $this, 'process_action' ), 20 ); } /** * Process action. * * @return void */ public function process_action() { if ( ! \current_user_can( 'edit_user', $this->user_id ) ) { return; } if ( ! $this->current_action() ) { return; } $redirect_to = \add_query_arg( array( 'settings-updated' => true, // Tell WordPress to load settings errors transient. 'action' => false, // Remove action parameter to prevent redirect loop. ) ); switch ( $this->current_action() ) { case 'delete': $redirect_to = \remove_query_arg( array( 'follower', 'followers' ), $redirect_to ); // Handle single follower deletion. if ( isset( $_GET['follower'], $_GET['_wpnonce'] ) ) { $follower = \absint( $_GET['follower'] ); $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) ); if ( \wp_verify_nonce( $nonce, 'delete-follower_' . $follower ) ) { Follower_Collection::remove( $follower, $this->user_id ); \add_settings_error( 'activitypub', 'follower_deleted', \__( 'Follower deleted.', 'activitypub' ), 'success' ); } } // Handle bulk actions. if ( isset( $_REQUEST['followers'], $_REQUEST['_wpnonce'] ) ) { $nonce = \sanitize_text_field( \wp_unslash( $_REQUEST['_wpnonce'] ) ); if ( \wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) { $followers = \array_map( 'absint', \wp_unslash( $_REQUEST['followers'] ) ); foreach ( $followers as $follower ) { Follower_Collection::remove( $follower, $this->user_id ); } $count = \count( $followers ); /* translators: %d: Number of followers deleted. */ $message = \_n( '%d follower deleted.', '%d followers deleted.', $count, 'activitypub' ); $message = \sprintf( $message, \number_format_i18n( $count ) ); \add_settings_error( 'activitypub', 'followers_deleted', $message, 'success' ); } } break; case 'follow': $redirect_to = \remove_query_arg( array( 'follower', 'followers' ), $redirect_to ); if ( isset( $_GET['follower'], $_GET['_wpnonce'] ) ) { $follower = \absint( $_GET['follower'] ); $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) ); if ( \wp_verify_nonce( $nonce, 'follow-follower_' . $follower ) ) { Following::follow( $follower, $this->user_id ); \add_settings_error( 'activitypub', 'followed', \__( 'Account followed.', 'activitypub' ), 'success' ); } } break; case 'block': $redirect_to = \remove_query_arg( array( 'follower', 'followers', 'confirm' ), $redirect_to ); // Handle single follower block. if ( isset( $_GET['follower'], $_GET['_wpnonce'] ) ) { $follower = \absint( $_GET['follower'] ); $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) ); if ( \wp_verify_nonce( $nonce, 'block-follower_' . $follower ) ) { // If confirm is not set, show confirmation screen. if ( ! isset( $_GET['confirm'] ) || 'true' !== $_GET['confirm'] ) { $args = array( 'actor_id' => $follower, 'user_id' => $this->user_id, ); \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/block-confirmation.php', false, $args ); exit; } $blocked = $this->block_followers( array( $follower ) ); if ( $blocked['success'] > 0 ) { \add_settings_error( 'activitypub', 'account_blocked', \__( 'Account blocked.', 'activitypub' ), 'success' ); } else { \add_settings_error( 'activitypub', 'block_error', \__( 'Invalid account.', 'activitypub' ) ); } } } // Handle bulk block actions. if ( isset( $_REQUEST['followers'], $_REQUEST['_wpnonce'] ) ) { $nonce = \sanitize_text_field( \wp_unslash( $_REQUEST['_wpnonce'] ) ); if ( \wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) { // If confirm is not set, show confirmation screen. if ( ! isset( $_GET['confirm'] ) || 'true' !== $_GET['confirm'] ) { $followers = \array_map( 'absint', \wp_unslash( $_REQUEST['followers'] ) ); $args = array( 'followers' => $followers, 'user_id' => $this->user_id, 'plural_args' => $this->_args['plural'], ); \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/bulk-block-confirmation.php', false, $args ); exit; } $followers = \array_map( 'absint', \wp_unslash( $_REQUEST['followers'] ) ); $blocked = $this->block_followers( $followers ); if ( $blocked['success'] > 0 ) { /* translators: %d: Number of followers blocked. */ $message = \_n( '%d account blocked.', '%d accounts blocked.', $blocked['success'], 'activitypub' ); $message = \sprintf( $message, \number_format_i18n( $blocked['success'] ) ); \add_settings_error( 'activitypub', 'accounts_blocked', $message, 'success' ); } } } break; default: break; } \set_transient( 'settings_errors', \get_settings_errors(), 30 ); // 30 seconds. \wp_safe_redirect( $redirect_to ); exit; } /** * Process admin notices based on query parameters. * * @return void */ public function process_admin_notices() { \settings_errors( 'activitypub' ); } /** * Prepare items. * * @return void */ public function prepare_items() { $page_num = $this->get_pagenum(); $per_page = $this->get_items_per_page( 'activitypub_followers_per_page' ); $args = array(); // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['orderby'] ) ) { $args['orderby'] = \sanitize_text_field( \wp_unslash( $_GET['orderby'] ) ); } if ( isset( $_GET['order'] ) ) { $args['order'] = \sanitize_text_field( \wp_unslash( $_GET['order'] ) ); } if ( ! empty( $_GET['s'] ) ) { $args['s'] = $this->normalize_search_term( \wp_unslash( $_GET['s'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } $followers_with_count = Follower_Collection::query( $this->user_id, $per_page, $page_num, $args ); $followers = $followers_with_count['followers']; $counter = $followers_with_count['total']; $this->items = array(); $this->set_pagination_args( array( 'total_items' => (int) $counter, 'total_pages' => (int) \ceil( $counter / $per_page ), 'per_page' => $per_page, ) ); foreach ( $followers as $follower ) { $actor = Remote_Actors::get_actor( $follower ); if ( \is_wp_error( $actor ) ) { continue; } $this->items[] = array( 'id' => $follower->ID, 'icon' => object_to_uri( $actor->get_icon() ?? ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg' ), 'post_title' => $actor->get_name() ?: $actor->get_preferred_username(), 'username' => $actor->get_preferred_username(), 'url' => object_to_uri( $actor->get_url() ?: $actor->get_id() ), 'webfinger' => $actor->get_webfinger(), 'identifier' => $actor->get_id(), 'modified' => $follower->post_modified_gmt, ); } } /** * Returns views. * * @return string[] */ public function get_views() { $count = Follower_Collection::count( $this->user_id ); $path = 'users.php?page=activitypub-followers-list'; if ( Actors::BLOG_USER_ID === $this->user_id ) { $path = 'options-general.php?page=activitypub&tab=followers'; } $links = array( 'all' => array( 'url' => \admin_url( $path ), 'label' => \sprintf( /* translators: %s: Number of users. */ \_nx( 'All (%s)', 'All (%s)', $count, 'users', 'activitypub' ), \number_format_i18n( $count ) ), 'current' => true, ), ); return $this->get_views_links( $links ); } /** * Returns bulk actions. * * @return array */ public function get_bulk_actions() { return array( 'delete' => \__( 'Delete', 'activitypub' ), 'block' => \__( 'Block', 'activitypub' ), ); } /** * Column cb. * * @param array $item Item. * @return string */ public function column_cb( $item ) { return \sprintf( '', \esc_attr( $item['id'] ) ); } /** * Column username. * * @param array $item Item. * @return string */ public function column_username( $item ) { return \sprintf( '%2$s %4$s
', \esc_url( $item['icon'] ), \esc_attr( $item['username'] ), \esc_url( $item['url'] ), \esc_html( $item['username'] ) ); } /** * Column webfinger. * * @param array $item Item. * @return string */ public function column_webfinger( $item ) { $webfinger = Sanitize::webfinger( $item['webfinger'] ); return \sprintf( '@%2$s', \esc_url( $item['url'] ), \esc_html( $webfinger ) ); } /** * Column modified. * * @param array $item Item. * @return string */ public function column_modified( $item ) { $modified = \strtotime( $item['modified'] ); return \sprintf( '', \esc_attr( \gmdate( 'c', $modified ) ), \esc_html( \gmdate( \get_option( 'date_format' ), $modified ) ) ); } /** * Message to be displayed when there are no followers. * * @return void */ public function no_items() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $search = \sanitize_text_field( \wp_unslash( $_GET['s'] ?? '' ) ); $actor_or_false = $this->is_followable( $search ); if ( $actor_or_false ) { \printf( /* translators: 1: Actor name, 2: Follow link */ \esc_html__( '%1$s is not following you, would you like to %2$s instead?', 'activitypub' ), \esc_html( $actor_or_false->post_title ), \sprintf( '%s', \esc_url( \add_query_arg( 'resource', $search, $this->follow_url ) ), \esc_html__( 'follow them', 'activitypub' ) ) ); } else { \esc_html_e( 'No followers found.', 'activitypub' ); } } /** * Handles the row actions for each follower item. * * @param array $item The current follower item. * @param string $column_name The current column name. * @param string $primary The primary column name. * @return string HTML for the row actions. */ protected function handle_row_actions( $item, $column_name, $primary ) { if ( $column_name !== $primary ) { return ''; } $actions = array( 'delete' => \sprintf( '%s', $this->get_action_url( 'delete', $item['id'] ), /* translators: %s: username. */ \esc_attr( \sprintf( \__( 'Delete %s', 'activitypub' ), $item['username'] ) ), \esc_html__( 'Delete', 'activitypub' ) ), 'block' => \sprintf( '%s', $this->get_action_url( 'block', $item['id'] ), /* translators: %s: username. */ \esc_attr( \sprintf( \__( 'Block %s', 'activitypub' ), $item['username'] ) ), \esc_html__( 'Block', 'activitypub' ) ), ); if ( \boolval( \get_option( 'activitypub_following_ui', '0' ) ) ) { if ( ! Following::check_status( $this->user_id, $item['id'] ) ) { $actions['follow'] = \sprintf( '%s', $this->get_action_url( 'follow', $item['id'] ), /* translators: %s: username. */ \esc_attr( \sprintf( \__( 'Follow %s', 'activitypub' ), $item['username'] ) ), \esc_html__( 'Follow back', 'activitypub' ) ); } } /** * Filters the array of row action links for each follower in the Followers list table. * * This filter allows you to modify the available row actions (such as Delete, Block, or Follow back) * for each follower item displayed in the table. * * @since 7.5.0 * * @param string[] $actions An array of row action links. Defaults are * 'Delete', 'Block', and optionally 'Follow back'. * @param array $item The current follower item. */ $actions = \apply_filters( 'activitypub_followers_row_actions', $actions, $item ); return $this->row_actions( $actions ); } /** * Block one or more followers. * * @param array $follower_ids Array of follower IDs to block. * @return array Array with counts of success and failure. */ private function block_followers( $follower_ids ) { $success_count = 0; $fail_count = 0; foreach ( $follower_ids as $follower ) { $actor = Remote_Actors::get_actor( $follower ); if ( \is_wp_error( $actor ) ) { ++$fail_count; continue; } $actor_id = $actor->get_id(); // Add user-specific block. $user_block_success = Moderation::add_user_block( $this->user_id, 'actor', $actor_id ); // Add site-wide block only if user is admin and explicitly requested. $site_block_success = true; if ( \user_can( $this->user_id, 'manage_options' ) && isset( $_REQUEST['site_wide'] ) && '1' === $_REQUEST['site_wide'] ) { $site_block_success = Moderation::add_site_block( 'actor', $actor_id ); } // Check if blocking was successful. if ( $user_block_success && $site_block_success ) { ++$success_count; } else { ++$fail_count; } } return array( 'success' => $success_count, 'failure' => $fail_count, ); } /** * Checks if the searched actor can be followed. * * @param string $search The search string. * * @return \WP_Post|false The actor post or false. */ private function is_followable( $search ) { if ( '1' !== \get_option( 'activitypub_following_ui', '0' ) ) { return false; } if ( empty( $search ) ) { return false; } $search = Sanitize::webfinger( $search ); if ( ! \filter_var( $search, FILTER_VALIDATE_EMAIL ) ) { return false; } $search = Webfinger::resolve( $search ); if ( \is_wp_error( $search ) || ! \filter_var( $search, FILTER_VALIDATE_URL ) ) { return false; } $actor = Remote_Actors::fetch_by_uri( $search ); if ( \is_wp_error( $actor ) ) { return false; } $does_follow = Following::check_status( $this->user_id, $actor->ID ); if ( $does_follow ) { return false; } return $actor; } }