Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/2590-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add blocklist subscriptions for automatic weekly synchronization of remote blocklists.
89 changes: 89 additions & 0 deletions assets/js/activitypub-moderation-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@

// Site moderation management.
initSiteModeration();

// Blocklist subscriptions management.
initBlocklistSubscriptions();
}

/**
Expand Down Expand Up @@ -345,6 +348,92 @@
});
}

/**
* 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' ).filter( function() {
return $( this ).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();

// Not 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 false;
}

// 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 );
}
}
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