Skip to content
Merged
87 changes: 87 additions & 0 deletions assets/js/activitypub-moderation-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@

// Site moderation management.
initSiteModeration();

// Blocklist subscriptions management.
initBlocklistSubscriptions();
}

/**
Expand Down Expand Up @@ -299,6 +302,90 @@
});
}

/**
* Initialize blocklist subscriptions management
*/
function initBlocklistSubscriptions() {
// Function to add a blocklist subscription.
function addBlocklistSubscription( url ) {
if ( ! url ) {
var message = activitypubModerationL10n.enterUrl || 'Please enter a URL.';
if ( wp.a11y && wp.a11y.speak ) {
wp.a11y.speak( message, 'assertive' );
}
alert( message );
return;
}

// Disable the button while processing.
var button = $( '.add-blocklist-subscription-btn' );
button.prop( 'disabled', true );

wp.ajax.post( 'activitypub_blocklist_subscription', {
operation: 'add',
url: url,
_wpnonce: activitypubModerationL10n.nonce
}).done( function() {
// Reload the page to show the updated list.
window.location.reload();
}).fail( function( response ) {
var message = response && response.message ? response.message : activitypubModerationL10n.subscriptionFailed || 'Failed to add subscription.';
if ( wp.a11y && wp.a11y.speak ) {
wp.a11y.speak( message, 'assertive' );
}
alert( message );
button.prop( 'disabled', false );
});
}

// Function to remove a blocklist subscription.
function removeBlocklistSubscription( url ) {
wp.ajax.post( 'activitypub_blocklist_subscription', {
operation: 'remove',
url: url,
_wpnonce: activitypubModerationL10n.nonce
}).done( function() {
// Remove the row from the UI.
$( '.remove-blocklist-subscription-btn[data-url="' + url + '"]' ).closest( 'tr' ).remove();

// If no more subscriptions, remove the table.
var table = $( '.activitypub-blocklist-subscriptions table' );
if ( table.find( 'tbody tr' ).length === 0 ) {
table.remove();
}
}).fail( function( response ) {
var message = response && response.message ? response.message : activitypubModerationL10n.removeSubscriptionFailed || 'Failed to remove subscription.';
if ( wp.a11y && wp.a11y.speak ) {
wp.a11y.speak( message, 'assertive' );
}
alert( message );
});
}

// Add subscription functionality (button click).
$( document ).on( 'click', '.add-blocklist-subscription-btn', function( e ) {
e.preventDefault();
var url = $( this ).data( 'url' ) || $( '#new_blocklist_subscription_url' ).val().trim();
addBlocklistSubscription( url );
});

// Add subscription functionality (Enter key).
$( document ).on( 'keypress', '#new_blocklist_subscription_url', function( e ) {
if ( e.which === 13 ) { // Enter key.
e.preventDefault();
var url = $( this ).val().trim();
addBlocklistSubscription( url );
}
});

// Remove subscription functionality.
$( document ).on( 'click', '.remove-blocklist-subscription-btn', function( e ) {
e.preventDefault();
var url = $( this ).data( 'url' );
removeBlocklistSubscription( url );
});
}

// Initialize when document is ready.
$( document ).ready( init );

Expand Down
222 changes: 222 additions & 0 deletions includes/class-blocklist-subscriptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php
/**
* Blocklist Subscriptions class file.
*
* @package Activitypub
*/

namespace Activitypub;

/**
* Blocklist Subscriptions class.
*
* Manages subscriptions to remote blocklists for automatic updates.
* Owns all remote blocklist logic: fetching, parsing, and importing.
*/
class Blocklist_Subscriptions {

/**
* Option key for storing subscriptions.
*/
const OPTION_KEY = 'activitypub_blocklist_subscriptions';

/**
* IFTAS DNI list URL.
*/
const IFTAS_DNI_URL = 'https://about.iftas.org/wp-content/uploads/2025/10/iftas-dni-latest.csv';

/**
* Get all subscriptions.
*
* @return array Array of URL => timestamp pairs.
*/
public static function get_all() {
return \get_option( self::OPTION_KEY, array() );
}

/**
* Add a subscription.
*
* Only adds the URL to the subscription list. Does not sync.
* Call sync() separately to fetch and import domains.
*
* @param string $url The blocklist URL to subscribe to.
* @return bool True on success, false on failure.
*/
public static function add( $url ) {
$url = \sanitize_url( $url );

if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) {
return false;
}

$subscriptions = self::get_all();

// Already subscribed.
if ( ! isset( $subscriptions[ $url ] ) ) {
// Add subscription with timestamp 0 (never synced).
$subscriptions[ $url ] = 0;
\update_option( self::OPTION_KEY, $subscriptions );
}

return true;
}

/**
* Remove a subscription.
*
* @param string $url The blocklist URL to unsubscribe from.
* @return bool True on success, false if not found.
*/
public static function remove( $url ) {
$subscriptions = self::get_all();

if ( ! isset( $subscriptions[ $url ] ) ) {
return false;
}

unset( $subscriptions[ $url ] );
\update_option( self::OPTION_KEY, $subscriptions );

return true;
}

/**
* Sync a single subscription.
*
* Fetches the blocklist URL, parses domains, and adds new ones to the blocklist.
* Updates the subscription timestamp on success.
*
* @param string $url The blocklist URL to sync.
* @return int|false Number of domains added, or false on failure.
*/
public static function sync( $url ) {
$response = \wp_safe_remote_get(
$url,
array(
'timeout' => 30,
'redirection' => 5,
)
);

if ( \is_wp_error( $response ) ) {
return false;
}

$response_code = \wp_remote_retrieve_response_code( $response );
if ( 200 !== $response_code ) {
return false;
}

$body = \wp_remote_retrieve_body( $response );
if ( empty( $body ) ) {
return false;
}

$domains = self::parse_csv_string( $body );

if ( empty( $domains ) ) {
return 0;
}

// Get existing blocks and find new ones.
$existing = Moderation::get_site_blocks()[ Moderation::TYPE_DOMAIN ] ?? array();
$new_domains = \array_diff( $domains, $existing );

if ( ! empty( $new_domains ) ) {
Moderation::add_site_blocks( Moderation::TYPE_DOMAIN, $new_domains );
}

// Update timestamp if this is a subscription.
$subscriptions = self::get_all();
if ( isset( $subscriptions[ $url ] ) ) {
$subscriptions[ $url ] = \time();
\update_option( self::OPTION_KEY, $subscriptions );
}

return \count( $new_domains );
}

/**
* Sync all subscriptions.
*
* Called by cron job.
*/
public static function sync_all() {
\array_map( array( __CLASS__, 'sync' ), \array_keys( self::get_all() ) );
}

/**
* Parse CSV content from a string and extract domain names.
*
* Supports Mastodon CSV format (with #domain header) and simple
* one-domain-per-line format.
*
* @param string $content CSV content as a string.
* @return array Array of unique, valid domain names.
*/
public static function parse_csv_string( $content ) {
$domains = array();

if ( empty( $content ) ) {
return $domains;
}

// Split into lines.
$lines = \preg_split( '/\r\n|\r|\n/', $content );
if ( empty( $lines ) ) {
return $domains;
}

// Parse first line to detect format.
$first_line = \str_getcsv( $lines[0] );
$first_cell = \trim( $first_line[0] ?? '' );
$has_header = \str_starts_with( $first_cell, '#' ) || 'domain' === \strtolower( $first_cell );

// Find domain column index.
$domain_index = 0;
if ( $has_header ) {
foreach ( $first_line as $i => $col ) {
$col = \ltrim( \strtolower( \trim( $col ) ), '#' );
if ( 'domain' === $col ) {
$domain_index = $i;
break;
}
}
// Remove header from lines.
\array_shift( $lines );
}

// Process each line.
foreach ( $lines as $line ) {
$row = \str_getcsv( $line );
$domain = \trim( $row[ $domain_index ] ?? '' );

// Skip empty lines and comments.
if ( empty( $domain ) || \str_starts_with( $domain, '#' ) ) {
continue;
}

if ( self::is_valid_domain( $domain ) ) {
$domains[] = \strtolower( $domain );
}
}

return \array_unique( $domains );
}

/**
* Validate a domain name.
*
* @param string $domain The domain to validate.
* @return bool True if valid, false otherwise.
*/
public static function is_valid_domain( $domain ) {
// Must contain at least one dot (filter_var would accept "localhost").
if ( ! \str_contains( $domain, '.' ) ) {
return false;
}

return (bool) \filter_var( $domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME );
}
}
32 changes: 32 additions & 0 deletions includes/class-moderation.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,38 @@ public static function add_site_block( $type, $value ) {
return true; // Already blocked.
}

/**
* Add multiple site-wide blocks at once.
*
* More efficient than calling add_site_block() in a loop as it
* performs a single database update.
*
* @param string $type The block type (domain or keyword only).
* @param array $values Array of values to block.
*/
public static function add_site_blocks( $type, $values ) {
if ( ! in_array( $type, array( self::TYPE_DOMAIN, self::TYPE_KEYWORD ), true ) ) {
return;
}

if ( empty( $values ) ) {
return;
}

foreach ( $values as $value ) {
/**
* 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 );
}

$existing = \get_option( self::OPTION_KEYS[ $type ], array() );
\update_option( self::OPTION_KEYS[ $type ], array_unique( array_merge( $existing, $values ) ) );
}

/**
* Remove a site-wide block.
*
Expand Down
6 changes: 6 additions & 0 deletions includes/class-scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static function init() {
\add_action( 'activitypub_outbox_purge', array( self::class, 'purge_outbox' ) );
\add_action( 'activitypub_inbox_purge', array( self::class, 'purge_inbox' ) );
\add_action( 'activitypub_inbox_create_item', array( self::class, 'process_inbox_activity' ) );
\add_action( 'activitypub_sync_blocklist_subscriptions', array( Blocklist_Subscriptions::class, 'sync_all' ) );

\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) );
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_announce_activity' ), 10, 4 );
Expand Down Expand Up @@ -132,6 +133,10 @@ public static function register_schedules() {
if ( ! \wp_next_scheduled( 'activitypub_inbox_purge' ) ) {
\wp_schedule_event( time(), 'daily', 'activitypub_inbox_purge' );
}

if ( ! \wp_next_scheduled( 'activitypub_sync_blocklist_subscriptions' ) ) {
\wp_schedule_event( time(), 'weekly', 'activitypub_sync_blocklist_subscriptions' );
}
}

/**
Expand All @@ -145,6 +150,7 @@ public static function deregister_schedules() {
\wp_unschedule_hook( 'activitypub_reprocess_outbox' );
\wp_unschedule_hook( 'activitypub_outbox_purge' );
\wp_unschedule_hook( 'activitypub_inbox_purge' );
\wp_unschedule_hook( 'activitypub_sync_blocklist_subscriptions' );
}

/**
Expand Down
Loading