PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / wp-admin / table / class-followers.php

class-followers.php in ActivityPub trunk, at includes/wp-admin/table/class-followers.php

535 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Followers Table-Class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\WP_Admin\Table;
9
10 use Activitypub\Collection\Actors;
11 use Activitypub\Collection\Followers as Follower_Collection;
12 use Activitypub\Collection\Following;
13 use Activitypub\Collection\Remote_Actors;
14 use Activitypub\Moderation;
15 use Activitypub\Sanitize;
16 use Activitypub\Webfinger;
17
18 use function Activitypub\object_to_uri;
19
20 if ( ! \class_exists( '\WP_List_Table' ) ) {
21 require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
22 }
23
24 /**
25 * Followers Table-Class.
26 */
27 class Followers extends \WP_List_Table {
28 use Actor_List_Table;
29
30 /**
31 * User ID.
32 *
33 * @var int
34 */
35 private $user_id;
36
37 /**
38 * Follow URL.
39 *
40 * @var string
41 */
42 public $follow_url;
43
44 /**
45 * Constructor.
46 */
47 public function __construct() {
48 if ( \get_current_screen()->id === 'settings_page_activitypub' ) {
49 $this->user_id = Actors::BLOG_USER_ID;
50 $this->follow_url = \admin_url( 'options-general.php?page=activitypub&tab=following' );
51 } else {
52 $this->user_id = \get_current_user_id();
53 $this->follow_url = \admin_url( 'users.php?page=activitypub-following-list' );
54
55 \add_action( 'admin_notices', array( $this, 'process_admin_notices' ) );
56 }
57
58 parent::__construct(
59 array(
60 'singular' => \__( 'Follower', 'activitypub' ),
61 'plural' => \__( 'Followers', 'activitypub' ),
62 'ajax' => false,
63 )
64 );
65
66 \add_action( 'load-' . \get_current_screen()->id, array( $this, 'process_action' ), 20 );
67 }
68
69 /**
70 * Process action.
71 */
72 public function process_action() {
73 if ( ! \current_user_can( 'edit_user', $this->user_id ) ) {
74 return;
75 }
76
77 if ( ! $this->current_action() ) {
78 return;
79 }
80
81 $redirect_to = \add_query_arg(
82 array(
83 'settings-updated' => true, // Tell WordPress to load settings errors transient.
84 'action' => false, // Remove action parameter to prevent redirect loop.
85 )
86 );
87
88 switch ( $this->current_action() ) {
89 case 'delete':
90 $redirect_to = \remove_query_arg( array( 'follower', 'followers' ), $redirect_to );
91
92 // Handle single follower deletion.
93 if ( isset( $_GET['follower'], $_GET['_wpnonce'] ) ) {
94 $follower = \absint( $_GET['follower'] );
95 $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) );
96
97 if ( \wp_verify_nonce( $nonce, 'delete-follower_' . $follower ) ) {
98 Follower_Collection::remove( $follower, $this->user_id );
99
100 \add_settings_error( 'activitypub', 'follower_deleted', \__( 'Follower deleted.', 'activitypub' ), 'success' );
101 }
102 }
103
104 // Handle bulk actions.
105 if ( isset( $_REQUEST['followers'], $_REQUEST['_wpnonce'] ) ) {
106 $nonce = \sanitize_text_field( \wp_unslash( $_REQUEST['_wpnonce'] ) );
107
108 if ( \wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
109 $followers = \array_map( 'absint', \wp_unslash( $_REQUEST['followers'] ) );
110 foreach ( $followers as $follower ) {
111 Follower_Collection::remove( $follower, $this->user_id );
112 }
113
114 $count = \count( $followers );
115 /* translators: %d: Number of followers deleted. */
116 $message = \_n( '%d follower deleted.', '%d followers deleted.', $count, 'activitypub' );
117 $message = \sprintf( $message, \number_format_i18n( $count ) );
118
119 \add_settings_error( 'activitypub', 'followers_deleted', $message, 'success' );
120 }
121 }
122 break;
123
124 case 'follow':
125 $redirect_to = \remove_query_arg( array( 'follower', 'followers' ), $redirect_to );
126
127 if ( isset( $_GET['follower'], $_GET['_wpnonce'] ) ) {
128 $follower = \absint( $_GET['follower'] );
129 $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) );
130
131 if ( \wp_verify_nonce( $nonce, 'follow-follower_' . $follower ) ) {
132 Following::follow( $follower, $this->user_id );
133
134 \add_settings_error( 'activitypub', 'followed', \__( 'Account followed.', 'activitypub' ), 'success' );
135
136 }
137 }
138 break;
139
140 case 'block':
141 $redirect_to = \remove_query_arg( array( 'follower', 'followers', 'confirm' ), $redirect_to );
142
143 // Handle single follower block.
144 if ( isset( $_GET['follower'], $_GET['_wpnonce'] ) ) {
145 $follower = \absint( $_GET['follower'] );
146 $nonce = \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ) );
147
148 if ( \wp_verify_nonce( $nonce, 'block-follower_' . $follower ) ) {
149 // If confirm is not set, show confirmation screen.
150 if ( ! isset( $_GET['confirm'] ) || 'true' !== $_GET['confirm'] ) {
151 $args = array(
152 'actor_id' => $follower,
153 'user_id' => $this->user_id,
154 );
155 \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/block-confirmation.php', false, $args );
156 exit;
157 }
158
159 $blocked = $this->block_followers( array( $follower ) );
160 if ( $blocked['success'] > 0 ) {
161 \add_settings_error( 'activitypub', 'account_blocked', \__( 'Account blocked.', 'activitypub' ), 'success' );
162 } else {
163 \add_settings_error( 'activitypub', 'block_error', \__( 'Invalid account.', 'activitypub' ) );
164 }
165 }
166 }
167
168 // Handle bulk block actions.
169 if ( isset( $_REQUEST['followers'], $_REQUEST['_wpnonce'] ) ) {
170 $nonce = \sanitize_text_field( \wp_unslash( $_REQUEST['_wpnonce'] ) );
171
172 if ( \wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
173 // If confirm is not set, show confirmation screen.
174 if ( ! isset( $_GET['confirm'] ) || 'true' !== $_GET['confirm'] ) {
175 $followers = \array_map( 'absint', \wp_unslash( $_REQUEST['followers'] ) );
176 $args = array(
177 'followers' => $followers,
178 'user_id' => $this->user_id,
179 'plural_args' => $this->_args['plural'],
180 );
181 \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/bulk-block-confirmation.php', false, $args );
182 exit;
183 }
184
185 $followers = \array_map( 'absint', \wp_unslash( $_REQUEST['followers'] ) );
186 $blocked = $this->block_followers( $followers );
187
188 if ( $blocked['success'] > 0 ) {
189 /* translators: %d: Number of followers blocked. */
190 $message = \_n( '%d account blocked.', '%d accounts blocked.', $blocked['success'], 'activitypub' );
191 $message = \sprintf( $message, \number_format_i18n( $blocked['success'] ) );
192 \add_settings_error( 'activitypub', 'accounts_blocked', $message, 'success' );
193 }
194 }
195 }
196 break;
197
198 default:
199 break;
200 }
201
202 \set_transient( 'settings_errors', \get_settings_errors(), 30 ); // 30 seconds.
203
204 \wp_safe_redirect( $redirect_to );
205 exit;
206 }
207
208 /**
209 * Process admin notices based on query parameters.
210 */
211 public function process_admin_notices() {
212 \settings_errors( 'activitypub' );
213 }
214
215 /**
216 * Prepare items.
217 */
218 public function prepare_items() {
219 $page_num = $this->get_pagenum();
220 $per_page = $this->get_items_per_page( 'activitypub_followers_per_page' );
221 $args = array();
222
223 // phpcs:disable WordPress.Security.NonceVerification.Recommended
224 if ( isset( $_GET['orderby'] ) ) {
225 $args['orderby'] = \sanitize_text_field( \wp_unslash( $_GET['orderby'] ) );
226 }
227
228 if ( isset( $_GET['order'] ) ) {
229 $args['order'] = \sanitize_text_field( \wp_unslash( $_GET['order'] ) );
230 }
231
232 if ( ! empty( $_GET['s'] ) ) {
233 $args['s'] = $this->normalize_search_term( \wp_unslash( $_GET['s'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
234 }
235
236 $followers_with_count = Follower_Collection::query( $this->user_id, $per_page, $page_num, $args );
237 $followers = $followers_with_count['followers'];
238 $counter = $followers_with_count['total'];
239
240 $this->items = array();
241 $this->set_pagination_args(
242 array(
243 'total_items' => $counter,
244 'total_pages' => \ceil( $counter / $per_page ),
245 'per_page' => $per_page,
246 )
247 );
248
249 foreach ( $followers as $follower ) {
250 $actor = Remote_Actors::get_actor( $follower );
251 if ( \is_wp_error( $actor ) ) {
252 continue;
253 }
254
255 $this->items[] = array(
256 'id' => $follower->ID,
257 'icon' => object_to_uri( $actor->get_icon() ?? ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg' ),
258 'post_title' => $actor->get_name() ?: $actor->get_preferred_username(),
259 'username' => $actor->get_preferred_username(),
260 'url' => object_to_uri( $actor->get_url() ?: $actor->get_id() ),
261 'webfinger' => $actor->get_webfinger(),
262 'identifier' => $actor->get_id(),
263 'modified' => $follower->post_modified_gmt,
264 );
265 }
266 }
267
268 /**
269 * Returns views.
270 *
271 * @return string[]
272 */
273 public function get_views() {
274 $count = Follower_Collection::count( $this->user_id );
275
276 $path = 'users.php?page=activitypub-followers-list';
277 if ( Actors::BLOG_USER_ID === $this->user_id ) {
278 $path = 'options-general.php?page=activitypub&tab=followers';
279 }
280
281 $links = array(
282 'all' => array(
283 'url' => \admin_url( $path ),
284 'label' => \sprintf(
285 /* translators: %s: Number of users. */
286 \_nx(
287 'All <span class="count">(%s)</span>',
288 'All <span class="count">(%s)</span>',
289 $count,
290 'users',
291 'activitypub'
292 ),
293 \number_format_i18n( $count )
294 ),
295 'current' => true,
296 ),
297 );
298
299 return $this->get_views_links( $links );
300 }
301
302 /**
303 * Returns bulk actions.
304 *
305 * @return array
306 */
307 public function get_bulk_actions() {
308 return array(
309 'delete' => \__( 'Delete', 'activitypub' ),
310 'block' => \__( 'Block', 'activitypub' ),
311 );
312 }
313
314 /**
315 * Column cb.
316 *
317 * @param array $item Item.
318 * @return string
319 */
320 public function column_cb( $item ) {
321 return \sprintf( '<input type="checkbox" name="followers[]" value="%s" />', \esc_attr( $item['id'] ) );
322 }
323
324 /**
325 * Column username.
326 *
327 * @param array $item Item.
328 * @return string
329 */
330 public function column_username( $item ) {
331 return \sprintf(
332 '<img src="%1$s" width="32" height="32" alt="%2$s" loading="lazy"/> <strong><a href="%3$s" target="_blank">%4$s</a></strong><br />',
333 \esc_url( $item['icon'] ),
334 \esc_attr( $item['username'] ),
335 \esc_url( $item['url'] ),
336 \esc_html( $item['username'] )
337 );
338 }
339
340 /**
341 * Column webfinger.
342 *
343 * @param array $item Item.
344 * @return string
345 */
346 public function column_webfinger( $item ) {
347 $webfinger = Sanitize::webfinger( $item['webfinger'] );
348
349 return \sprintf(
350 '<a href="%1$s" target="_blank" title="%1$s">@%2$s</a>',
351 \esc_url( $item['url'] ),
352 \esc_html( $webfinger )
353 );
354 }
355
356 /**
357 * Column modified.
358 *
359 * @param array $item Item.
360 * @return string
361 */
362 public function column_modified( $item ) {
363 $modified = \strtotime( $item['modified'] );
364 return \sprintf(
365 '<time datetime="%1$s">%2$s</time>',
366 \esc_attr( \gmdate( 'c', $modified ) ),
367 \esc_html( \gmdate( \get_option( 'date_format' ), $modified ) )
368 );
369 }
370
371 /**
372 * Message to be displayed when there are no followers.
373 */
374 public function no_items() {
375 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
376 $search = \sanitize_text_field( \wp_unslash( $_GET['s'] ?? '' ) );
377 $actor_or_false = $this->is_followable( $search );
378
379 if ( $actor_or_false ) {
380 \printf(
381 /* translators: 1: Actor name, 2: Follow link */
382 \esc_html__( '%1$s is not following you, would you like to %2$s instead?', 'activitypub' ),
383 \esc_html( $actor_or_false->post_title ),
384 \sprintf(
385 '<a href="%s">%s</a>',
386 \esc_url( \add_query_arg( 'resource', $search, $this->follow_url ) ),
387 \esc_html__( 'follow them', 'activitypub' )
388 )
389 );
390 } else {
391 \esc_html_e( 'No followers found.', 'activitypub' );
392 }
393 }
394
395 /**
396 * Handles the row actions for each follower item.
397 *
398 * @param array $item The current follower item.
399 * @param string $column_name The current column name.
400 * @param string $primary The primary column name.
401 * @return string HTML for the row actions.
402 */
403 protected function handle_row_actions( $item, $column_name, $primary ) {
404 if ( $column_name !== $primary ) {
405 return '';
406 }
407
408 $actions = array(
409 'delete' => \sprintf(
410 '<a href="%s" aria-label="%s">%s</a>',
411 $this->get_action_url( 'delete', $item['id'] ),
412 /* translators: %s: username. */
413 \esc_attr( \sprintf( \__( 'Delete %s', 'activitypub' ), $item['username'] ) ),
414 \esc_html__( 'Delete', 'activitypub' )
415 ),
416 'block' => \sprintf(
417 '<a href="%s" aria-label="%s" class="activitypub-block-follower">%s</a>',
418 $this->get_action_url( 'block', $item['id'] ),
419 /* translators: %s: username. */
420 \esc_attr( \sprintf( \__( 'Block %s', 'activitypub' ), $item['username'] ) ),
421 \esc_html__( 'Block', 'activitypub' )
422 ),
423 );
424
425 if ( \boolval( \get_option( 'activitypub_following_ui', '0' ) ) ) {
426 if ( ! Following::check_status( $this->user_id, $item['id'] ) ) {
427 $actions['follow'] = \sprintf(
428 '<a href="%s" aria-label="%s">%s</a>',
429 $this->get_action_url( 'follow', $item['id'] ),
430 /* translators: %s: username. */
431 \esc_attr( \sprintf( \__( 'Follow %s', 'activitypub' ), $item['username'] ) ),
432 \esc_html__( 'Follow back', 'activitypub' )
433 );
434 }
435 }
436
437 /**
438 * Filters the array of row action links for each follower in the Followers list table.
439 *
440 * This filter allows you to modify the available row actions (such as Delete, Block, or Follow back)
441 * for each follower item displayed in the table.
442 *
443 * @since 7.5.0
444 *
445 * @param string[] $actions An array of row action links. Defaults are
446 * 'Delete', 'Block', and optionally 'Follow back'.
447 * @param array $item The current follower item.
448 */
449 $actions = \apply_filters( 'activitypub_followers_row_actions', $actions, $item );
450
451 return $this->row_actions( $actions );
452 }
453
454 /**
455 * Block one or more followers.
456 *
457 * @param array $follower_ids Array of follower IDs to block.
458 * @return array Array with counts of success and failure.
459 */
460 private function block_followers( $follower_ids ) {
461 $success_count = 0;
462 $fail_count = 0;
463
464 foreach ( $follower_ids as $follower ) {
465 $actor = Remote_Actors::get_actor( $follower );
466 if ( \is_wp_error( $actor ) ) {
467 ++$fail_count;
468 continue;
469 }
470
471 $actor_id = $actor->get_id();
472
473 // Add user-specific block.
474 $user_block_success = Moderation::add_user_block( $this->user_id, 'actor', $actor_id );
475
476 // Add site-wide block only if user is admin and explicitly requested.
477 $site_block_success = true;
478 if ( \user_can( $this->user_id, 'manage_options' ) && isset( $_REQUEST['site_wide'] ) && '1' === $_REQUEST['site_wide'] ) {
479 $site_block_success = Moderation::add_site_block( 'actor', $actor_id );
480 }
481
482 // Check if blocking was successful.
483 if ( $user_block_success && $site_block_success ) {
484 ++$success_count;
485 } else {
486 ++$fail_count;
487 }
488 }
489
490 return array(
491 'success' => $success_count,
492 'failure' => $fail_count,
493 );
494 }
495
496 /**
497 * Checks if the searched actor can be followed.
498 *
499 * @param string $search The search string.
500 *
501 * @return \WP_Post|false The actor post or false.
502 */
503 private function is_followable( $search ) {
504 if ( '1' !== \get_option( 'activitypub_following_ui', '0' ) ) {
505 return false;
506 }
507
508 if ( empty( $search ) ) {
509 return false;
510 }
511
512 $search = Sanitize::webfinger( $search );
513 if ( ! \filter_var( $search, FILTER_VALIDATE_EMAIL ) ) {
514 return false;
515 }
516
517 $search = Webfinger::resolve( $search );
518 if ( \is_wp_error( $search ) || ! \filter_var( $search, FILTER_VALIDATE_URL ) ) {
519 return false;
520 }
521
522 $actor = Remote_Actors::fetch_by_uri( $search );
523 if ( \is_wp_error( $actor ) ) {
524 return false;
525 }
526
527 $does_follow = Following::check_status( $this->user_id, $actor->ID );
528 if ( $does_follow ) {
529 return false;
530 }
531
532 return $actor;
533 }
534 }
535