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 / class-moderation.php

class-moderation.php in ActivityPub trunk, at includes/class-moderation.php

628 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Moderation class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Activity\Activity;
11 use Activitypub\Activity\Actor;
12 use Activitypub\Collection\Actors;
13 use Activitypub\Collection\Blocked_Actors;
14
15 /**
16 * ActivityPub Moderation class.
17 *
18 * Handles user-specific blocking and site-wide moderation.
19 */
20 class Moderation {
21
22 /**
23 * Block type constants.
24 */
25 const TYPE_ACTOR = 'actor';
26 const TYPE_DOMAIN = 'domain';
27 const TYPE_KEYWORD = 'keyword';
28
29 /**
30 * Post meta key for blocked actors.
31 */
32 const BLOCKED_ACTORS_META_KEY = '_activitypub_blocked_by';
33
34 /**
35 * User meta key for blocked keywords.
36 */
37 const USER_META_KEYS = array(
38 self::TYPE_DOMAIN => 'activitypub_blocked_domains',
39 self::TYPE_KEYWORD => 'activitypub_blocked_keywords',
40 );
41
42 /**
43 * Option key for site-wide blocked keywords.
44 */
45 const OPTION_KEYS = array(
46 self::TYPE_DOMAIN => 'activitypub_site_blocked_domains',
47 self::TYPE_KEYWORD => 'activitypub_site_blocked_keywords',
48 );
49
50 /**
51 * Check if an activity should be blocked for a specific user.
52 *
53 * @param Activity $activity The activity.
54 * @param int|null $user_id The user ID to check blocks for.
55 * @return bool True if blocked, false otherwise.
56 */
57 public static function activity_is_blocked( $activity, $user_id = null ) {
58 if ( ! $activity instanceof Activity ) {
59 return false;
60 }
61
62 // First check site-wide blocks (admin moderation).
63 if ( self::activity_is_blocked_site_wide( $activity ) ) {
64 return true;
65 }
66
67 // Then check user-specific blocks.
68 if ( $user_id && self::activity_is_blocked_for_user( $activity, $user_id ) ) {
69 return true;
70 }
71
72 $remote_addr = \sanitize_text_field( \wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
73 $user_agent = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
74
75 // Fall back to WordPress comment disallowed list.
76 return \wp_check_comment_disallowed_list( $activity->to_json( false ), '', '', $activity->get_content(), $remote_addr, $user_agent );
77 }
78
79 /**
80 * Check if an activity is blocked site-wide.
81 *
82 * @param Activity $activity The activity.
83 * @return bool True if blocked, false otherwise.
84 */
85 public static function activity_is_blocked_site_wide( $activity ) {
86 $blocks = self::get_site_blocks();
87
88 if ( ! self::has_blocks( $blocks ) ) {
89 return false;
90 }
91
92 return self::check_activity_against_blocks( $activity, $blocks );
93 }
94
95 /**
96 * Check if an activity is blocked for a specific user.
97 *
98 * @param Activity $activity The activity.
99 * @param int $user_id The user ID.
100 * @return bool True if blocked, false otherwise.
101 */
102 public static function activity_is_blocked_for_user( $activity, $user_id ) {
103 $blocks = self::get_user_blocks( $user_id );
104
105 if ( ! self::has_blocks( $blocks ) ) {
106 return false;
107 }
108
109 return self::check_activity_against_blocks( $activity, $blocks );
110 }
111
112 /**
113 * Check whether a set of blocks has anything in it.
114 *
115 * Worth asking before the checks run: they parse the activity and can go to the network for a
116 * handle, and this runs once per local recipient of every delivery. Most sites block nothing.
117 *
118 * @since 9.3.0
119 *
120 * @param array $blocks Blocks organized by type, as returned by get_site_blocks().
121 *
122 * @return bool True if any list has an entry, false otherwise.
123 */
124 public static function has_blocks( $blocks ) {
125 return self::has_actor_blocks( $blocks ) || self::has_domain_blocks( $blocks ) || self::has_keyword_blocks( $blocks );
126 }
127
128 /**
129 * Check whether a set of blocks names any actor.
130 *
131 * @since 9.3.0
132 *
133 * @param array $blocks Blocks organized by type.
134 *
135 * @return bool True if an actor is blocked, false otherwise.
136 */
137 public static function has_actor_blocks( $blocks ) {
138 return ! empty( $blocks['actors'] );
139 }
140
141 /**
142 * Check whether a set of blocks names any domain.
143 *
144 * @since 9.3.0
145 *
146 * @param array $blocks Blocks organized by type.
147 *
148 * @return bool True if a domain is blocked, false otherwise.
149 */
150 public static function has_domain_blocks( $blocks ) {
151 return ! empty( $blocks['domains'] );
152 }
153
154 /**
155 * Check whether a set of blocks names any keyword.
156 *
157 * @since 9.3.0
158 *
159 * @param array $blocks Blocks organized by type.
160 *
161 * @return bool True if a keyword is blocked, false otherwise.
162 */
163 public static function has_keyword_blocks( $blocks ) {
164 return ! empty( $blocks['keywords'] );
165 }
166
167 /**
168 * Add a block for a user.
169 *
170 * @param int $user_id The user ID.
171 * @param string $type The block type (actor, domain, keyword).
172 * @param string $value The value to block.
173 * @return bool True on success, false on failure.
174 */
175 public static function add_user_block( $user_id, $type, $value ) {
176 switch ( $type ) {
177 case self::TYPE_ACTOR:
178 return Blocked_Actors::add( $user_id, $value );
179
180 case self::TYPE_DOMAIN:
181 case self::TYPE_KEYWORD:
182 $blocks = \get_user_meta( $user_id, self::USER_META_KEYS[ $type ], true ) ?: array();
183
184 if ( ! \in_array( $value, $blocks, true ) ) {
185 /**
186 * Fired when a domain or keyword is blocked.
187 *
188 * @param string $value The blocked domain or keyword.
189 * @param string $type The block type (actor, domain, keyword).
190 * @param int $user_id The user ID.
191 */
192 \do_action( 'activitypub_add_user_block', $value, $type, $user_id );
193
194 $blocks[] = $value;
195 return (bool) \update_user_meta( $user_id, self::USER_META_KEYS[ $type ], $blocks );
196 }
197 break;
198 }
199
200 return true; // Already blocked.
201 }
202
203 /**
204 * Remove a block for a user.
205 *
206 * @param int $user_id The user ID.
207 * @param string $type The block type (actor, domain, keyword).
208 * @param string $value The value to unblock.
209 * @return bool True on success, false on failure.
210 */
211 public static function remove_user_block( $user_id, $type, $value ) {
212 switch ( $type ) {
213 case self::TYPE_ACTOR:
214 return Blocked_Actors::remove( $user_id, $value );
215
216 case self::TYPE_DOMAIN:
217 case self::TYPE_KEYWORD:
218 $blocks = \get_user_meta( $user_id, self::USER_META_KEYS[ $type ], true ) ?: array();
219 $key = \array_search( $value, $blocks, true );
220
221 if ( false !== $key ) {
222 /**
223 * Fired when a domain or keyword is unblocked.
224 *
225 * @param string $value The unblocked domain or keyword.
226 * @param string $type The block type (actor, domain, keyword).
227 * @param int $user_id The user ID.
228 */
229 \do_action( 'activitypub_remove_user_block', $value, $type, $user_id );
230
231 unset( $blocks[ $key ] );
232 return \update_user_meta( $user_id, self::USER_META_KEYS[ $type ], \array_values( $blocks ) );
233 }
234 break;
235 }
236
237 return true; // Not blocked anyway.
238 }
239
240 /**
241 * Get all blocks for a user.
242 *
243 * @param int $user_id The user ID.
244 * @return array Array of blocks organized by type.
245 */
246 public static function get_user_blocks( $user_id ) {
247 return array(
248 'actors' => \wp_list_pluck( Blocked_Actors::get_many( $user_id ), 'guid' ),
249 'domains' => \get_user_meta( $user_id, self::USER_META_KEYS[ self::TYPE_DOMAIN ], true ) ?: array(),
250 'keywords' => \get_user_meta( $user_id, self::USER_META_KEYS[ self::TYPE_KEYWORD ], true ) ?: array(),
251 );
252 }
253
254 /**
255 * Add a site-wide block.
256 *
257 * @param string $type The block type (actor, domain, keyword).
258 * @param string $value The value to block.
259 * @return bool True on success, false on failure.
260 */
261 public static function add_site_block( $type, $value ) {
262 switch ( $type ) {
263 case self::TYPE_ACTOR:
264 // Site-wide actor blocking uses the BLOG_USER_ID.
265 return self::add_user_block( Actors::BLOG_USER_ID, self::TYPE_ACTOR, $value );
266
267 case self::TYPE_DOMAIN:
268 case self::TYPE_KEYWORD:
269 $blocks = \get_option( self::OPTION_KEYS[ $type ], array() );
270
271 if ( ! \in_array( $value, $blocks, true ) ) {
272 /**
273 * Fired when a domain or keyword is blocked site-wide.
274 *
275 * @param string $value The blocked domain or keyword.
276 * @param string $type The block type (actor, domain, keyword).
277 */
278 \do_action( 'activitypub_add_site_block', $value, $type );
279
280 $blocks[] = $value;
281 return \update_option( self::OPTION_KEYS[ $type ], $blocks );
282 }
283 break;
284 }
285
286 return true; // Already blocked.
287 }
288
289 /**
290 * Add multiple site-wide blocks at once.
291 *
292 * More efficient than calling add_site_block() in a loop as it
293 * performs a single database update.
294 *
295 * @param string $type The block type (domain or keyword only).
296 * @param array $values Array of values to block.
297 */
298 public static function add_site_blocks( $type, $values ) {
299 if ( ! \in_array( $type, array( self::TYPE_DOMAIN, self::TYPE_KEYWORD ), true ) ) {
300 return;
301 }
302
303 if ( empty( $values ) ) {
304 return;
305 }
306
307 foreach ( $values as $value ) {
308 /**
309 * Fired when a domain or keyword is blocked site-wide.
310 *
311 * @param string $value The blocked domain or keyword.
312 * @param string $type The block type (actor, domain, keyword).
313 */
314 \do_action( 'activitypub_add_site_block', $value, $type );
315 }
316
317 $existing = \get_option( self::OPTION_KEYS[ $type ], array() );
318 \update_option( self::OPTION_KEYS[ $type ], \array_unique( \array_merge( $existing, $values ) ) );
319 }
320
321 /**
322 * Remove a site-wide block.
323 *
324 * @param string $type The block type (actor, domain, keyword).
325 * @param string $value The value to unblock.
326 * @return bool True on success, false on failure.
327 */
328 public static function remove_site_block( $type, $value ) {
329 switch ( $type ) {
330 case self::TYPE_ACTOR:
331 // Site-wide actor unblocking uses the BLOG_USER_ID.
332 return self::remove_user_block( Actors::BLOG_USER_ID, self::TYPE_ACTOR, $value );
333
334 case self::TYPE_DOMAIN:
335 case self::TYPE_KEYWORD:
336 $blocks = \get_option( self::OPTION_KEYS[ $type ], array() );
337 $key = \array_search( $value, $blocks, true );
338
339 if ( false !== $key ) {
340 /**
341 * Fired when a domain or keyword is unblocked site-wide.
342 *
343 * @param string $value The unblocked domain or keyword.
344 * @param string $type The block type (actor, domain, keyword).
345 */
346 \do_action( 'activitypub_remove_site_block', $value, $type );
347
348 unset( $blocks[ $key ] );
349 return \update_option( self::OPTION_KEYS[ $type ], \array_values( $blocks ) );
350 }
351 break;
352 }
353
354 return true; // Not blocked anyway.
355 }
356
357 /**
358 * Get all site-wide blocks.
359 *
360 * @return array Array of blocks organized by type.
361 */
362 public static function get_site_blocks() {
363 return array(
364 'actors' => \wp_list_pluck( Blocked_Actors::get_many( Actors::BLOG_USER_ID ), 'guid' ),
365 'domains' => \get_option( self::OPTION_KEYS[ self::TYPE_DOMAIN ], array() ),
366 'keywords' => \get_option( self::OPTION_KEYS[ self::TYPE_KEYWORD ], array() ),
367 );
368 }
369
370 /**
371 * Check if an actor is blocked by user or site-wide.
372 *
373 * @param string $actor_uri Actor URI to check.
374 * @param int $user_id Optional. User ID to check user blocks for. Defaults to 0 (site-wide only).
375 * @return bool True if blocked, false otherwise.
376 */
377 public static function is_actor_blocked( $actor_uri, $user_id = 0 ) {
378 if ( ! $actor_uri ) {
379 return false;
380 }
381
382 $hosts = array( Webfinger::get_host( $actor_uri ) );
383
384 // Check site-wide blocks.
385 $site_blocks = self::get_site_blocks();
386 if ( self::uri_matches_actors( $actor_uri, $site_blocks['actors'] ) ) {
387 return true;
388 }
389
390 // Check site-wide domain blocks.
391 if ( self::hosts_are_blocked( $hosts, $site_blocks['domains'] ) ) {
392 return true;
393 }
394
395 // Check user-specific blocks if user_id is provided.
396 if ( $user_id > 0 ) {
397 $user_blocks = self::get_user_blocks( $user_id );
398 if ( self::uri_matches_actors( $actor_uri, $user_blocks['actors'] ) ) {
399 return true;
400 }
401
402 // Check user-specific domain blocks.
403 if ( self::hosts_are_blocked( $hosts, $user_blocks['domains'] ) ) {
404 return true;
405 }
406 }
407
408 return false;
409 }
410
411 /**
412 * Check a set of hosts against the blocked domains.
413 *
414 * @param string[] $hosts The folded hosts to check.
415 * @param array $blocked_domains The blocked domains.
416 *
417 * @return bool True if any host is blocked, false otherwise.
418 */
419 private static function hosts_are_blocked( $hosts, $blocked_domains ) {
420 /*
421 * Cast because a stored list is not guaranteed to be one: array_map() fatals on a scalar
422 * where the foreach this replaced only warned, and this runs on every delivery.
423 * array_filter drops the empty hosts, so an empty stored entry can never match one.
424 */
425 return (bool) \array_intersect( \array_filter( $hosts ), \array_map( __NAMESPACE__ . '\\fold_host', (array) $blocked_domains ) );
426 }
427
428 /**
429 * Check an actor URI against a list of blocked actors.
430 *
431 * Compared entry by entry rather than by normalizing the whole list first, so a match
432 * returns without touching the rest of it. This runs on every delivery, and a site can
433 * block a lot of accounts.
434 *
435 * @param string $uri The actor URI to look for, in any spelling.
436 * @param string[] $blocked_actors The blocked actor URIs.
437 *
438 * @return bool True if the URI is blocked, false otherwise.
439 */
440 private static function uri_matches_actors( $uri, $blocked_actors ) {
441 $normalized = normalize_actor_uri( $uri );
442
443 if ( '' === $normalized ) {
444 return false;
445 }
446
447 foreach ( $blocked_actors as $blocked ) {
448 if ( normalize_actor_uri( $blocked ) === $normalized ) {
449 return true;
450 }
451 }
452
453 return false;
454 }
455
456 /**
457 * Check a delivered actor URI against the blocked actors.
458 *
459 * The delivered `actor` is only bound to a host by the signature, not to an exact string, so a
460 * spelling that normalizes differently still has to be resolved to be ruled out.
461 *
462 * Two steps. The delivered string is compared normalized, which settles the ordinary case
463 * without leaving the site. A delivery that gets past that is resolved over the network, so a
464 * spelling the normalizer cannot fold still gets ruled out. A failed fetch leaves the delivery
465 * unblocked, as before: a host that will not answer cannot be confirmed as blocked, and the
466 * host owns the actor being claimed, so refusing to answer is a way out of a block. The
467 * domain list is the tool for a host behaving that way.
468 *
469 * @param string $actor_id The actor URI from the delivered activity.
470 * @param string[] $blocked_actors The blocked actor URIs.
471 *
472 * @return bool True if the actor is blocked, false otherwise.
473 */
474 private static function actor_matches_blocklist( $actor_id, $blocked_actors ) {
475 if ( empty( $blocked_actors ) ) {
476 return false;
477 }
478
479 /*
480 * Narrow by host first, so only a delivery that could plausibly be blocked pays for a
481 * resolution. Most deliveries reach a list with nothing on their host and leave here. The
482 * host comes from the delivered actor only: taking it from the key id would let a forged
483 * header choose which entries get compared.
484 *
485 * This is a cost gate, not a completeness one. An actor whose own host carries no entry is
486 * never resolved, so a document served elsewhere that declares a blocked id is not caught
487 * here even though the resolved comparison below would match it. Resolving every delivery
488 * to close that would mean an outbound request per delivery whenever any actor is blocked.
489 * Block the host to cover it.
490 */
491 $host = Webfinger::get_host( $actor_id );
492
493 if ( '' === $host ) {
494 return false;
495 }
496
497 $on_host = \array_filter(
498 $blocked_actors,
499 static function ( $blocked ) use ( $host ) {
500 return Webfinger::get_host( $blocked ) === $host;
501 }
502 );
503
504 if ( empty( $on_host ) ) {
505 return false;
506 }
507
508 if ( self::uri_matches_actors( $actor_id, $on_host ) ) {
509 return true;
510 }
511
512 /*
513 * Resolved rather than read from a locally stored actor: a `guid` is the id the actor
514 * declared, and `Update` stores an embedded actor object bound only to the sender's
515 * host, so a remote server can store itself under any same-host id it likes.
516 */
517 $object = Http::get_remote_object( $actor_id );
518
519 /*
520 * Compared against the whole list, not the host-narrowed one: that narrowing decides
521 * whether resolving is worth a request, but the id it resolves to can be on another host
522 * and still be blocked.
523 */
524 return ! \is_wp_error( $object )
525 && isset( $object['id'] )
526 && \is_string( $object['id'] )
527 && self::uri_matches_actors( $object['id'], $blocked_actors );
528 }
529
530 /**
531 * Check activity against blocklists.
532 *
533 * @param Activity $activity The activity.
534 * @param array $blocks Blocks organized by type, as returned by get_site_blocks().
535 * @return bool True if blocked, false otherwise.
536 */
537 private static function check_activity_against_blocks( $activity, $blocks ) {
538 // Extract actor information.
539 $actor_id = object_to_uri( $activity->get_actor() );
540
541 /*
542 * Domains are checked before anything that goes to the network: the webfinger lookup
543 * below and the actor check both issue requests, and a blocked domain must not be
544 * contacted to find out that it is blocked.
545 */
546 if ( self::has_domain_blocks( $blocks ) ) {
547 $hosts = array(
548 Webfinger::get_host( $actor_id ),
549 Webfinger::get_host( $activity->get_id() ),
550 Webfinger::get_host( object_to_uri( $activity->get_object() ) ),
551 );
552
553 if ( self::hosts_are_blocked( $hosts, $blocks['domains'] ) ) {
554 return true;
555 }
556 }
557
558 /*
559 * `is_acct()` is too strict to decide this. Its host grammar rejects a trailing-dot FQDN
560 * and any non-ASCII host, both of which `Webfinger::resolve()` resolves fine, and a
561 * delivery that skips resolution over a spelling is a delivery that skips the block. All
562 * that matters here is whether this is a handle rather than a URL already worth comparing.
563 */
564 $is_handle = false === \strpos( $actor_id, '://' ) && false !== \strpos( $actor_id, '@' );
565
566 /*
567 * Resolve a handle to its URL, but only for a list that could match it: a keyword-only
568 * blocklist has no use for the actor's URL and should not pay a lookup for one. Its own
569 * host is not blocked, or we would have returned above.
570 */
571
572 if ( ( self::has_domain_blocks( $blocks ) || self::has_actor_blocks( $blocks ) ) && $is_handle ) {
573 $resolved_url = Webfinger::resolve( $actor_id );
574 $actor_id = \is_wp_error( $resolved_url ) ? $actor_id : $resolved_url;
575
576 /*
577 * Checked again: webfinger returns whatever `href` the remote document names, and
578 * that can be on a different host than the handle it was looked up under.
579 */
580 if ( self::hosts_are_blocked( array( Webfinger::get_host( $actor_id ) ), $blocks['domains'] ) ) {
581 return true;
582 }
583 }
584
585 // Check blocked actors.
586 if ( self::actor_matches_blocklist( $actor_id, $blocks['actors'] ) ) {
587 return true;
588 }
589
590 // Check blocked keywords in activity content.
591 if ( self::has_keyword_blocks( $blocks ) && \is_object( $activity->get_object() ) ) {
592 $object = $activity->get_object();
593 $content_map = array();
594 $content_map[] = $object->get_content();
595 $content_map[] = $object->get_summary();
596 $content_map[] = $object->get_name();
597
598 if ( is_actor( $object ) ) {
599 /* @var Actor $object Actor object */
600 $content_map[] = $object->get_preferred_username();
601 }
602
603 if ( \is_array( $object->get_content_map() ) ) {
604 $content_map = \array_merge( $content_map, \array_values( $object->get_content_map() ) );
605 }
606
607 if ( \is_array( $object->get_summary_map() ) ) {
608 $content_map = \array_merge( $content_map, \array_values( $object->get_summary_map() ) );
609 }
610
611 if ( \is_array( $object->get_name_map() ) ) {
612 $content_map = \array_merge( $content_map, \array_values( $object->get_name_map() ) );
613 }
614
615 $content_map = \array_filter( $content_map );
616 $content = \implode( ' ', $content_map );
617
618 foreach ( (array) $blocks['keywords'] as $keyword ) {
619 if ( \stripos( $content, $keyword ) !== false ) {
620 return true;
621 }
622 }
623 }
624
625 return false;
626 }
627 }
628