diff --git a/.github/changelog/2590-from-description b/.github/changelog/2590-from-description new file mode 100644 index 0000000000..33ee27ffaa --- /dev/null +++ b/.github/changelog/2590-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add blocklist subscriptions for automatic weekly synchronization of remote blocklists. diff --git a/assets/js/activitypub-moderation-admin.js b/assets/js/activitypub-moderation-admin.js index 207d41c16a..204253dbf8 100644 --- a/assets/js/activitypub-moderation-admin.js +++ b/assets/js/activitypub-moderation-admin.js @@ -196,6 +196,9 @@ // Site moderation management. initSiteModeration(); + + // Blocklist subscriptions management. + initBlocklistSubscriptions(); } /** @@ -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 ); diff --git a/includes/class-blocklist-subscriptions.php b/includes/class-blocklist-subscriptions.php new file mode 100644 index 0000000000..faf342f551 --- /dev/null +++ b/includes/class-blocklist-subscriptions.php @@ -0,0 +1,222 @@ + 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 ); + } +} diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index b29e0d08af..331c82849b 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -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 ); @@ -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' ); + } } /** @@ -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' ); } /** diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index af99fc2032..a90408407b 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -7,6 +7,7 @@ namespace Activitypub\WP_Admin; +use Activitypub\Blocklist_Subscriptions; use Activitypub\Collection\Actors; use Activitypub\Collection\Extra_Fields; use Activitypub\Comment; @@ -75,6 +76,7 @@ public static function init() { \add_action( 'wp_dashboard_setup', array( self::class, 'add_dashboard_widgets' ) ); \add_action( 'wp_ajax_activitypub_moderation_settings', array( self::class, 'ajax_moderation_settings' ) ); + \add_action( 'wp_ajax_activitypub_blocklist_subscription', array( self::class, 'ajax_blocklist_subscription' ) ); } /** @@ -1070,4 +1072,51 @@ public static function ajax_moderation_settings() { \wp_send_json_error( array( 'message' => $error_message ) ); } } + + /** + * AJAX handler for blocklist subscriptions (add/remove). + */ + public static function ajax_blocklist_subscription() { + $operation = \sanitize_text_field( \wp_unslash( $_POST['operation'] ?? '' ) ); + $url = \sanitize_url( \wp_unslash( $_POST['url'] ?? '' ) ); + + // Validate required parameters. + if ( ! \in_array( $operation, array( 'add', 'remove' ), true ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid operation.', 'activitypub' ) ) ); + } + + if ( empty( $url ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid URL.', 'activitypub' ) ) ); + } + + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub_moderation_settings' ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid nonce.', 'activitypub' ) ) ); + } + + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( array( 'message' => \__( 'You do not have permission to perform this action.', 'activitypub' ) ) ); + } + + if ( 'add' === $operation ) { + // First add the subscription (validates URL format). + if ( ! Blocklist_Subscriptions::add( $url ) ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid URL.', 'activitypub' ) ) ); + } + + // Then sync to validate it works and import domains. + $result = Blocklist_Subscriptions::sync( $url ); + if ( false === $result ) { + // Remove the subscription since sync failed. + Blocklist_Subscriptions::remove( $url ); + \wp_send_json_error( array( 'message' => \__( 'Failed to fetch blocklist. The URL may be unreachable or not contain valid domains.', 'activitypub' ) ) ); + } + + \wp_send_json_success(); + } elseif ( Blocklist_Subscriptions::remove( $url ) ) { + \wp_send_json_success(); + } else { + \wp_send_json_error( array( 'message' => \__( 'Failed to remove subscription.', 'activitypub' ) ) ); + } + } } diff --git a/includes/wp-admin/class-settings-fields.php b/includes/wp-admin/class-settings-fields.php index 090a9586a1..eda17b445a 100644 --- a/includes/wp-admin/class-settings-fields.php +++ b/includes/wp-admin/class-settings-fields.php @@ -7,6 +7,7 @@ namespace Activitypub\WP_Admin; +use Activitypub\Blocklist_Subscriptions; use Activitypub\Moderation; use function Activitypub\home_host; @@ -163,6 +164,14 @@ public static function register_settings_fields() { 'activitypub_settings', 'activitypub_moderation' ); + + add_settings_field( + 'activitypub_blocklist_subscriptions', + \esc_html__( 'Blocklist Subscriptions', 'activitypub' ), + array( self::class, 'render_blocklist_subscriptions_field' ), + 'activitypub_settings', + 'activitypub_moderation' + ); } /** @@ -535,4 +544,65 @@ public static function render_site_blocked_keywords_field() { +

+ +
+ + + + + + + + + + + $timestamp ) : ?> + + + + + + + + + + +
+ + +
+ + +

+ + +

+ +
+

+

+ +

@@ -98,7 +100,13 @@ private static function greet() {

- + +

+ +