Skip to content

Commit 2177671

Browse files
authored
Add blocklist subscriptions for automatic weekly sync (#2590)
1 parent cc01649 commit 2177671

File tree

8 files changed

+646
-119
lines changed

8 files changed

+646
-119
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Add blocklist subscriptions for automatic weekly synchronization of remote blocklists.

assets/js/activitypub-moderation-admin.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@
196196

197197
// Site moderation management.
198198
initSiteModeration();
199+
200+
// Blocklist subscriptions management.
201+
initBlocklistSubscriptions();
199202
}
200203

201204
/**
@@ -345,6 +348,92 @@
345348
});
346349
}
347350

351+
/**
352+
* Initialize blocklist subscriptions management
353+
*/
354+
function initBlocklistSubscriptions() {
355+
// Function to add a blocklist subscription.
356+
function addBlocklistSubscription( url ) {
357+
if ( ! url ) {
358+
var message = activitypubModerationL10n.enterUrl || 'Please enter a URL.';
359+
if ( wp.a11y && wp.a11y.speak ) {
360+
wp.a11y.speak( message, 'assertive' );
361+
}
362+
alert( message );
363+
return;
364+
}
365+
366+
// Disable the button while processing.
367+
var button = $( '.add-blocklist-subscription-btn' );
368+
button.prop( 'disabled', true );
369+
370+
wp.ajax.post( 'activitypub_blocklist_subscription', {
371+
operation: 'add',
372+
url: url,
373+
_wpnonce: activitypubModerationL10n.nonce
374+
}).done( function() {
375+
// Reload the page to show the updated list.
376+
window.location.reload();
377+
}).fail( function( response ) {
378+
var message = response && response.message ? response.message : activitypubModerationL10n.subscriptionFailed || 'Failed to add subscription.';
379+
if ( wp.a11y && wp.a11y.speak ) {
380+
wp.a11y.speak( message, 'assertive' );
381+
}
382+
alert( message );
383+
button.prop( 'disabled', false );
384+
});
385+
}
386+
387+
// Function to remove a blocklist subscription.
388+
function removeBlocklistSubscription( url ) {
389+
wp.ajax.post( 'activitypub_blocklist_subscription', {
390+
operation: 'remove',
391+
url: url,
392+
_wpnonce: activitypubModerationL10n.nonce
393+
}).done( function() {
394+
// Remove the row from the UI.
395+
$( '.remove-blocklist-subscription-btn' ).filter( function() {
396+
return $( this ).data( 'url' ) === url;
397+
}).closest( 'tr' ).remove();
398+
399+
// If no more subscriptions, remove the table.
400+
var table = $( '.activitypub-blocklist-subscriptions table' );
401+
if ( table.find( 'tbody tr' ).length === 0 ) {
402+
table.remove();
403+
}
404+
}).fail( function( response ) {
405+
var message = response && response.message ? response.message : activitypubModerationL10n.removeSubscriptionFailed || 'Failed to remove subscription.';
406+
if ( wp.a11y && wp.a11y.speak ) {
407+
wp.a11y.speak( message, 'assertive' );
408+
}
409+
alert( message );
410+
});
411+
}
412+
413+
// Add subscription functionality (button click).
414+
$( document ).on( 'click', '.add-blocklist-subscription-btn', function( e ) {
415+
e.preventDefault();
416+
var url = $( this ).data( 'url' ) || $( '#new_blocklist_subscription_url' ).val().trim();
417+
addBlocklistSubscription( url );
418+
});
419+
420+
// Add subscription functionality (Enter key).
421+
$( document ).on( 'keypress', '#new_blocklist_subscription_url', function( e ) {
422+
if ( e.which === 13 ) { // Enter key.
423+
e.preventDefault();
424+
var url = $( this ).val().trim();
425+
addBlocklistSubscription( url );
426+
}
427+
});
428+
429+
// Remove subscription functionality.
430+
$( document ).on( 'click', '.remove-blocklist-subscription-btn', function( e ) {
431+
e.preventDefault();
432+
var url = $( this ).data( 'url' );
433+
removeBlocklistSubscription( url );
434+
});
435+
}
436+
348437
// Initialize when document is ready.
349438
$( document ).ready( init );
350439

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
<?php
2+
/**
3+
* Blocklist Subscriptions class file.
4+
*
5+
* @package Activitypub
6+
*/
7+
8+
namespace Activitypub;
9+
10+
/**
11+
* Blocklist Subscriptions class.
12+
*
13+
* Manages subscriptions to remote blocklists for automatic updates.
14+
* Owns all remote blocklist logic: fetching, parsing, and importing.
15+
*/
16+
class Blocklist_Subscriptions {
17+
18+
/**
19+
* Option key for storing subscriptions.
20+
*/
21+
const OPTION_KEY = 'activitypub_blocklist_subscriptions';
22+
23+
/**
24+
* IFTAS DNI list URL.
25+
*/
26+
const IFTAS_DNI_URL = 'https://about.iftas.org/wp-content/uploads/2025/10/iftas-dni-latest.csv';
27+
28+
/**
29+
* Get all subscriptions.
30+
*
31+
* @return array Array of URL => timestamp pairs.
32+
*/
33+
public static function get_all() {
34+
return \get_option( self::OPTION_KEY, array() );
35+
}
36+
37+
/**
38+
* Add a subscription.
39+
*
40+
* Only adds the URL to the subscription list. Does not sync.
41+
* Call sync() separately to fetch and import domains.
42+
*
43+
* @param string $url The blocklist URL to subscribe to.
44+
* @return bool True on success, false on failure.
45+
*/
46+
public static function add( $url ) {
47+
$url = \sanitize_url( $url );
48+
49+
if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) {
50+
return false;
51+
}
52+
53+
$subscriptions = self::get_all();
54+
55+
// Not already subscribed.
56+
if ( ! isset( $subscriptions[ $url ] ) ) {
57+
// Add subscription with timestamp 0 (never synced).
58+
$subscriptions[ $url ] = 0;
59+
\update_option( self::OPTION_KEY, $subscriptions );
60+
}
61+
62+
return true;
63+
}
64+
65+
/**
66+
* Remove a subscription.
67+
*
68+
* @param string $url The blocklist URL to unsubscribe from.
69+
* @return bool True on success, false if not found.
70+
*/
71+
public static function remove( $url ) {
72+
$subscriptions = self::get_all();
73+
74+
if ( ! isset( $subscriptions[ $url ] ) ) {
75+
return false;
76+
}
77+
78+
unset( $subscriptions[ $url ] );
79+
\update_option( self::OPTION_KEY, $subscriptions );
80+
81+
return true;
82+
}
83+
84+
/**
85+
* Sync a single subscription.
86+
*
87+
* Fetches the blocklist URL, parses domains, and adds new ones to the blocklist.
88+
* Updates the subscription timestamp on success.
89+
*
90+
* @param string $url The blocklist URL to sync.
91+
* @return int|false Number of domains added, or false on failure.
92+
*/
93+
public static function sync( $url ) {
94+
$response = \wp_safe_remote_get(
95+
$url,
96+
array(
97+
'timeout' => 30,
98+
'redirection' => 5,
99+
)
100+
);
101+
102+
if ( \is_wp_error( $response ) ) {
103+
return false;
104+
}
105+
106+
$response_code = \wp_remote_retrieve_response_code( $response );
107+
if ( 200 !== $response_code ) {
108+
return false;
109+
}
110+
111+
$body = \wp_remote_retrieve_body( $response );
112+
if ( empty( $body ) ) {
113+
return false;
114+
}
115+
116+
$domains = self::parse_csv_string( $body );
117+
118+
if ( empty( $domains ) ) {
119+
return false;
120+
}
121+
122+
// Get existing blocks and find new ones.
123+
$existing = Moderation::get_site_blocks()[ Moderation::TYPE_DOMAIN ] ?? array();
124+
$new_domains = \array_diff( $domains, $existing );
125+
126+
if ( ! empty( $new_domains ) ) {
127+
Moderation::add_site_blocks( Moderation::TYPE_DOMAIN, $new_domains );
128+
}
129+
130+
// Update timestamp if this is a subscription.
131+
$subscriptions = self::get_all();
132+
if ( isset( $subscriptions[ $url ] ) ) {
133+
$subscriptions[ $url ] = \time();
134+
\update_option( self::OPTION_KEY, $subscriptions );
135+
}
136+
137+
return \count( $new_domains );
138+
}
139+
140+
/**
141+
* Sync all subscriptions.
142+
*
143+
* Called by cron job.
144+
*/
145+
public static function sync_all() {
146+
\array_map( array( __CLASS__, 'sync' ), \array_keys( self::get_all() ) );
147+
}
148+
149+
/**
150+
* Parse CSV content from a string and extract domain names.
151+
*
152+
* Supports Mastodon CSV format (with #domain header) and simple
153+
* one-domain-per-line format.
154+
*
155+
* @param string $content CSV content as a string.
156+
* @return array Array of unique, valid domain names.
157+
*/
158+
public static function parse_csv_string( $content ) {
159+
$domains = array();
160+
161+
if ( empty( $content ) ) {
162+
return $domains;
163+
}
164+
165+
// Split into lines.
166+
$lines = \preg_split( '/\r\n|\r|\n/', $content );
167+
if ( empty( $lines ) ) {
168+
return $domains;
169+
}
170+
171+
// Parse first line to detect format.
172+
$first_line = \str_getcsv( $lines[0] );
173+
$first_cell = \trim( $first_line[0] ?? '' );
174+
$has_header = \str_starts_with( $first_cell, '#' ) || 'domain' === \strtolower( $first_cell );
175+
176+
// Find domain column index.
177+
$domain_index = 0;
178+
if ( $has_header ) {
179+
foreach ( $first_line as $i => $col ) {
180+
$col = \ltrim( \strtolower( \trim( $col ) ), '#' );
181+
if ( 'domain' === $col ) {
182+
$domain_index = $i;
183+
break;
184+
}
185+
}
186+
// Remove header from lines.
187+
\array_shift( $lines );
188+
}
189+
190+
// Process each line.
191+
foreach ( $lines as $line ) {
192+
$row = \str_getcsv( $line );
193+
$domain = \trim( $row[ $domain_index ] ?? '' );
194+
195+
// Skip empty lines and comments.
196+
if ( empty( $domain ) || \str_starts_with( $domain, '#' ) ) {
197+
continue;
198+
}
199+
200+
if ( self::is_valid_domain( $domain ) ) {
201+
$domains[] = \strtolower( $domain );
202+
}
203+
}
204+
205+
return \array_unique( $domains );
206+
}
207+
208+
/**
209+
* Validate a domain name.
210+
*
211+
* @param string $domain The domain to validate.
212+
* @return bool True if valid, false otherwise.
213+
*/
214+
public static function is_valid_domain( $domain ) {
215+
// Must contain at least one dot (filter_var would accept "localhost").
216+
if ( ! \str_contains( $domain, '.' ) ) {
217+
return false;
218+
}
219+
220+
return (bool) \filter_var( $domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME );
221+
}
222+
}

includes/class-scheduler.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public static function init() {
6262
\add_action( 'activitypub_outbox_purge', array( self::class, 'purge_outbox' ) );
6363
\add_action( 'activitypub_inbox_purge', array( self::class, 'purge_inbox' ) );
6464
\add_action( 'activitypub_inbox_create_item', array( self::class, 'process_inbox_activity' ) );
65+
\add_action( 'activitypub_sync_blocklist_subscriptions', array( Blocklist_Subscriptions::class, 'sync_all' ) );
6566

6667
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) );
6768
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_announce_activity' ), 10, 4 );
@@ -132,6 +133,10 @@ public static function register_schedules() {
132133
if ( ! \wp_next_scheduled( 'activitypub_inbox_purge' ) ) {
133134
\wp_schedule_event( time(), 'daily', 'activitypub_inbox_purge' );
134135
}
136+
137+
if ( ! \wp_next_scheduled( 'activitypub_sync_blocklist_subscriptions' ) ) {
138+
\wp_schedule_event( time(), 'weekly', 'activitypub_sync_blocklist_subscriptions' );
139+
}
135140
}
136141

137142
/**
@@ -145,6 +150,7 @@ public static function deregister_schedules() {
145150
\wp_unschedule_hook( 'activitypub_reprocess_outbox' );
146151
\wp_unschedule_hook( 'activitypub_outbox_purge' );
147152
\wp_unschedule_hook( 'activitypub_inbox_purge' );
153+
\wp_unschedule_hook( 'activitypub_sync_blocklist_subscriptions' );
148154
}
149155

150156
/**

0 commit comments

Comments
 (0)