Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d1348fd
Add comprehensive ActivityPub moderation system
obenland Jul 28, 2025
d640adf
Add comprehensive PHPUnit tests for Moderation API
obenland Jul 28, 2025
202047e
Apply PHPCBF code formatting fixes
obenland Jul 28, 2025
2892d71
Improve moderation JavaScript UX and fix terminology
obenland Jul 29, 2025
cc9cf01
Add code coverage annotations and refactor blocking logic
obenland Jul 29, 2025
db625c1
Add changelog entry for blocking and moderation system
obenland Jul 29, 2025
72f919e
Fix AJAX nonce verification order for better security
obenland Jul 29, 2025
3c8d6f0
Consolidate moderation admin functionality and fix AJAX parameter con…
obenland Jul 29, 2025
ecaacac
Add moderation meta and settings for ActivityPub
obenland Jul 29, 2025
2084082
Align variable assignments in ajax_moderation_settings
obenland Jul 29, 2025
c4b48e6
Fix test_add_user_block_valid_types by mocking remote actor response
obenland Jul 29, 2025
b4f9c68
Remove explicit cap check
obenland Jul 29, 2025
42bb423
Backslashit
obenland Jul 29, 2025
52b0423
Remove actor block functionality from moderation settings
obenland Jul 29, 2025
11dd52a
Merge branch 'trunk' into add/block-lists
obenland Jul 29, 2025
d225429
Moderation: Refine UI with singular class names and table-only handling
obenland Jul 29, 2025
1d94cf3
Use namespaced functions and improve moderation labels
obenland Jul 29, 2025
15679ca
Refactor moderation to remove actor-level blocking
obenland Jul 29, 2025
4976174
Add actor blocking functionality with list table interface
obenland Jul 29, 2025
9495d26
Merge branch 'trunk' into feature/new-actor-blocks
obenland Aug 1, 2025
087d22c
Fix merge stuff
obenland Aug 1, 2025
f403d4b
Backslashit
obenland Aug 1, 2025
84602eb
Merge branch 'trunk' into feature/new-actor-blocks
obenland Aug 21, 2025
8e93ef1
Add periods to admin method docblocks and fix translation call
obenland Aug 21, 2025
9c46998
Add hooks for user and site block/unblock actions
obenland Aug 21, 2025
a608f5e
Remove blocked actors from following list on block
obenland Aug 21, 2025
8f2cd8c
Add changelog
matticbot Aug 21, 2025
7320518
Refactor actor blocking and icon handling
obenland Aug 22, 2025
c48e16a
Update label for profile input for accessibility
obenland Aug 22, 2025
2a8c607
Clean post cache after block/unblock actions
obenland Aug 22, 2025
c0a66eb
Refactor actor blocking logic in Blocked_Actors table
obenland Aug 22, 2025
d990f45
Refactor unfollow to use actor ID instead of post
obenland Aug 22, 2025
3b10797
Merge branch 'trunk' into feature/new-actor-blocks
obenland Aug 25, 2025
fcfc8fe
Merge branch 'trunk' into feature/new-actor-blocks
pfefferle Aug 25, 2025
b909d5e
Update templates/blocked-actors-list.php
obenland Aug 26, 2025
48d3211
Align Following input label with Block input
obenland Aug 26, 2025
3c43505
Resolve non-URL actors via Webfinger before blocking
obenland Aug 26, 2025
625e23f
Refactor blocked actors into dedicated collection class
obenland Aug 26, 2025
df08a79
Move actor blocking management to Blocked_Actors collection
obenland Aug 26, 2025
b074be0
Fix PHP coding standards violations
obenland Aug 26, 2025
d2736af
Replace magic strings with TYPE constants in moderation system
obenland Aug 26, 2025
fa63f80
Merge branch 'trunk' into feature/new-actor-blocks
obenland Aug 26, 2025
b1c49ef
Followers: First pass at blocking accounts (#1935)
obenland Aug 26, 2025
6d0db54
Preserve original profile in follow error redirects
obenland Aug 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/2027-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add actor blocking functionality with list table interface for managing blocked users and site-wide blocks
4 changes: 4 additions & 0 deletions .github/changelog/add-block-action
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Follower lists now include the option to block individual accounts.
3 changes: 3 additions & 0 deletions includes/class-activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Activitypub\Collection\Actors;
use Activitypub\Collection\Extra_Fields;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Following;
use Activitypub\Collection\Inbox;
use Activitypub\Collection\Outbox;

Expand Down Expand Up @@ -47,6 +48,8 @@ public static function init() {
\add_filter( 'default_post_metadata', array( self::class, 'default_post_metadata' ), 10, 3 );

\add_filter( 'activitypub_get_actor_extra_fields', array( Extra_Fields::class, 'default_actor_extra_fields' ), 10, 2 );
\add_action( 'activitypub_add_user_block', array( Followers::class, 'remove_blocked_actors' ), 10, 3 );
\add_action( 'activitypub_add_user_block', array( Following::class, 'remove_blocked_actors' ), 10, 3 );

// Add support for ActivityPub to custom post types.
foreach ( \get_option( 'activitypub_support_post_types', array( 'post' ) ) as $post_type ) {
Expand Down
2 changes: 0 additions & 2 deletions includes/class-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

namespace Activitypub;

use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Outbox;

/**
Expand Down
125 changes: 99 additions & 26 deletions includes/class-moderation.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,42 @@
namespace Activitypub;

use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Blocked_Actors;

/**
* ActivityPub Moderation class.
*
* Handles user-specific blocking and site-wide moderation.
*/
class Moderation {

/**
* Block type constants.
*/
const TYPE_ACTOR = 'actor';
const TYPE_DOMAIN = 'domain';
const TYPE_KEYWORD = 'keyword';

/**
* Post meta key for blocked actors.
*/
const BLOCKED_ACTORS_META_KEY = '_activitypub_blocked_by';

/**
* User meta key for blocked keywords.
*/
const USER_META_KEYS = array(
'domain' => 'activitypub_blocked_domains',
'keyword' => 'activitypub_blocked_keywords',
self::TYPE_DOMAIN => 'activitypub_blocked_domains',
self::TYPE_KEYWORD => 'activitypub_blocked_keywords',
);

/**
* Option key for site-wide blocked keywords.
*/
const OPTION_KEYS = array(
'domain' => 'activitypub_site_blocked_domains',
'keyword' => 'activitypub_site_blocked_keywords',
self::TYPE_DOMAIN => 'activitypub_site_blocked_domains',
self::TYPE_KEYWORD => 'activitypub_site_blocked_keywords',
);

/**
Expand Down Expand Up @@ -95,11 +110,23 @@ public static function activity_is_blocked_for_user( $activity, $user_id ) {
*/
public static function add_user_block( $user_id, $type, $value ) {
switch ( $type ) {
case 'domain':
case 'keyword':
case self::TYPE_ACTOR:
return Blocked_Actors::add_block( $user_id, $value );

case self::TYPE_DOMAIN:
case self::TYPE_KEYWORD:
$blocks = \get_user_meta( $user_id, self::USER_META_KEYS[ $type ], true ) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found

if ( ! in_array( $value, $blocks, true ) ) {
if ( ! \in_array( $value, $blocks, true ) ) {
/**
* Fired when a domain or keyword is blocked.
*
* @param string $value The blocked domain or keyword.
* @param string $type The block type (actor, domain, keyword).
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_add_user_block', $value, $type, $user_id );

$blocks[] = $value;
return (bool) \update_user_meta( $user_id, self::USER_META_KEYS[ $type ], $blocks );
}
Expand All @@ -119,14 +146,26 @@ public static function add_user_block( $user_id, $type, $value ) {
*/
public static function remove_user_block( $user_id, $type, $value ) {
switch ( $type ) {
case 'domain':
case 'keyword':
case self::TYPE_ACTOR:
return Blocked_Actors::remove_block( $user_id, $value );

case self::TYPE_DOMAIN:
case self::TYPE_KEYWORD:
$blocks = \get_user_meta( $user_id, self::USER_META_KEYS[ $type ], true ) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$key = array_search( $value, $blocks, true );
$key = \array_search( $value, $blocks, true );

if ( false !== $key ) {
/**
* Fired when a domain or keyword is unblocked.
*
* @param string $value The unblocked domain or keyword.
* @param string $type The block type (actor, domain, keyword).
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_remove_user_block', $value, $type, $user_id );

unset( $blocks[ $key ] );
return \update_user_meta( $user_id, self::USER_META_KEYS[ $type ], array_values( $blocks ) );
return \update_user_meta( $user_id, self::USER_META_KEYS[ $type ], \array_values( $blocks ) );
}
break;
}
Expand All @@ -142,9 +181,9 @@ public static function remove_user_block( $user_id, $type, $value ) {
*/
public static function get_user_blocks( $user_id ) {
return array(
'actors' => array(),
'domains' => \get_user_meta( $user_id, self::USER_META_KEYS['domain'], true ) ?: array(), // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'keywords' => \get_user_meta( $user_id, self::USER_META_KEYS['keyword'], true ) ?: array(), // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'actors' => \wp_list_pluck( Blocked_Actors::get_blocked_actors( $user_id ), 'guid' ),
'domains' => \get_user_meta( $user_id, self::USER_META_KEYS[ self::TYPE_DOMAIN ], true ) ?: array(), // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
'keywords' => \get_user_meta( $user_id, self::USER_META_KEYS[ self::TYPE_KEYWORD ], true ) ?: array(), // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
);
}

Expand All @@ -157,11 +196,23 @@ public static function get_user_blocks( $user_id ) {
*/
public static function add_site_block( $type, $value ) {
switch ( $type ) {
case 'domain':
case 'keyword':
case self::TYPE_ACTOR:
// Site-wide actor blocking uses the BLOG_USER_ID.
return self::add_user_block( Actors::BLOG_USER_ID, self::TYPE_ACTOR, $value );

case self::TYPE_DOMAIN:
case self::TYPE_KEYWORD:
$blocks = \get_option( self::OPTION_KEYS[ $type ], array() );

if ( ! in_array( $value, $blocks, true ) ) {
if ( ! \in_array( $value, $blocks, true ) ) {
/**
* Fired when a domain or keyword is blocked site-wide.
*
* @param string $value The blocked domain or keyword.
* @param string $type The block type (actor, domain, keyword).
*/
\do_action( 'activitypub_add_site_block', $value, $type );

$blocks[] = $value;
return \update_option( self::OPTION_KEYS[ $type ], $blocks );
}
Expand All @@ -180,14 +231,26 @@ public static function add_site_block( $type, $value ) {
*/
public static function remove_site_block( $type, $value ) {
switch ( $type ) {
case 'domain':
case 'keyword':
case self::TYPE_ACTOR:
// Site-wide actor unblocking uses the BLOG_USER_ID.
return self::remove_user_block( Actors::BLOG_USER_ID, self::TYPE_ACTOR, $value );

case self::TYPE_DOMAIN:
case self::TYPE_KEYWORD:
$blocks = \get_option( self::OPTION_KEYS[ $type ], array() );
$key = array_search( $value, $blocks, true );
$key = \array_search( $value, $blocks, true );

if ( false !== $key ) {
/**
* Fired when a domain or keyword is unblocked site-wide.
*
* @param string $value The unblocked domain or keyword.
* @param string $type The block type (actor, domain, keyword).
*/
\do_action( 'activitypub_remove_site_block', $value, $type );

unset( $blocks[ $key ] );
return \update_option( self::OPTION_KEYS[ $type ], array_values( $blocks ) );
return \update_option( self::OPTION_KEYS[ $type ], \array_values( $blocks ) );
}
break;
}
Expand All @@ -202,9 +265,9 @@ public static function remove_site_block( $type, $value ) {
*/
public static function get_site_blocks() {
return array(
'actors' => array(),
'domains' => \get_option( self::OPTION_KEYS['domain'], array() ),
'keywords' => \get_option( self::OPTION_KEYS['keyword'], array() ),
'actors' => \wp_list_pluck( Blocked_Actors::get_blocked_actors( Actors::BLOG_USER_ID ), 'guid' ),
'domains' => \get_option( self::OPTION_KEYS[ self::TYPE_DOMAIN ], array() ),
'keywords' => \get_option( self::OPTION_KEYS[ self::TYPE_KEYWORD ], array() ),
);
}

Expand All @@ -224,8 +287,18 @@ private static function check_activity_against_blocks( $activity, $blocked_actor
$actor_id = object_to_uri( $activity->get_actor() );

// Check blocked actors.
if ( $actor_id && \in_array( $actor_id, $blocked_actors, true ) ) {
return true;
if ( $actor_id ) {
// If actor_id is not a URL, resolve it via webfinger.
if ( ! \str_starts_with( $actor_id, 'http' ) ) {
$resolved_url = Webfinger::resolve( $actor_id );
if ( ! \is_wp_error( $resolved_url ) ) {
$actor_id = $resolved_url;
}
}

if ( \in_array( $actor_id, $blocked_actors, true ) ) {
return true;
}
}

// Check blocked domains.
Expand Down
138 changes: 138 additions & 0 deletions includes/collection/class-blocked-actors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php
/**
* Blocked Actors collection file.
*
* @package Activitypub
*/

namespace Activitypub\Collection;

use Activitypub\Moderation;

/**
* ActivityPub Blocked Actors Collection.
*/
class Blocked_Actors {

/**
* Add an actor block for a user.
*
* @param int $user_id The user ID.
* @param string $value The actor URI to block.
* @return bool True on success, false on failure.
*/
public static function add_block( $user_id, $value ) {
// Find or create actor post.
$actor_post = Actors::fetch_remote_by_uri( $value );
if ( \is_wp_error( $actor_post ) ) {
return false;
}

$blocked = \get_post_meta( $actor_post->ID, Moderation::BLOCKED_ACTORS_META_KEY, false );
if ( ! \in_array( (string) $user_id, $blocked, true ) ) {
/**
* Fired when an actor is blocked.
*
* @param string $value The blocked actor URI.
* @param string $type The block type (actor, domain, keyword).
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_add_user_block', $value, Moderation::TYPE_ACTOR, $user_id );

$result = (bool) \add_post_meta( $actor_post->ID, Moderation::BLOCKED_ACTORS_META_KEY, (string) $user_id );
\clean_post_cache( $actor_post->ID );

return $result;
}

return true; // Already blocked.
}

/**
* Remove an actor block for a user.
*
* @param int $user_id The user ID.
* @param string|int $value The actor URI or post ID to unblock.
* @return bool True on success, false on failure.
*/
public static function remove_block( $user_id, $value ) {
// Handle both post ID and URI formats.
if ( \is_numeric( $value ) ) {
$post_id = (int) $value;
} else {
// Otherwise, find the actor post by actor ID.
$actor_post = Actors::fetch_remote_by_uri( $value );
if ( \is_wp_error( $actor_post ) ) {
return false;
}
$post_id = $actor_post->ID;
}

/**
* Fired when an actor is unblocked.
*
* @param string $value The unblocked actor URI.
* @param string $type The block type (actor, domain, keyword).
* @param int $user_id The user ID.
*/
\do_action( 'activitypub_remove_user_block', $value, Moderation::TYPE_ACTOR, $user_id );

$result = \delete_post_meta( $post_id, Moderation::BLOCKED_ACTORS_META_KEY, $user_id );
\clean_post_cache( $post_id );

return $result;
}

/**
* Get the blocked actors of a given user, along with a total count for pagination purposes.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the blocked actors.
*
* @type \WP_Post[] $blocked_actors List of blocked Actor WP_Post objects.
* @type int $total Total number of blocked actors.
* }
*/
public static function get_blocked_actors_with_count( $user_id, $number = -1, $page = null, $args = array() ) {
$defaults = array(
'post_type' => Actors::POST_TYPE,
'posts_per_page' => $number,
'paged' => $page,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => Moderation::BLOCKED_ACTORS_META_KEY,
'value' => $user_id,
),
),
);

$args = \wp_parse_args( $args, $defaults );
$query = new \WP_Query( $args );
$total = $query->found_posts;
$blocked_actors = \array_filter( $query->posts );

return \compact( 'blocked_actors', 'total' );
}

/**
* Get the blocked actors of a given user.
*
* @param int|null $user_id The ID of the WordPress User.
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return \WP_Post[] List of blocked Actors.
*/
public static function get_blocked_actors( $user_id, $number = -1, $page = null, $args = array() ) {
return self::get_blocked_actors_with_count( $user_id, $number, $page, $args )['blocked_actors'];
}
}
Loading
Loading