From ff9c3741d7b849367946c5ba926572fd3bb77f23 Mon Sep 17 00:00:00 2001
From: Konstantin Obenland
Date: Thu, 4 Dec 2025 14:27:17 -0600
Subject: [PATCH 1/8] Add domain blocklist importer
---
includes/class-moderation.php | 32 ++
includes/wp-admin/import/class-blocklist.php | 385 ++++++++++++++++++
includes/wp-admin/import/load.php | 7 +
.../wp-admin/import/class-test-blocklist.php | 355 ++++++++++++++++
4 files changed, 779 insertions(+)
create mode 100644 includes/wp-admin/import/class-blocklist.php
create mode 100644 tests/phpunit/tests/includes/wp-admin/import/class-test-blocklist.php
diff --git a/includes/class-moderation.php b/includes/class-moderation.php
index c17f8cd412..b28164b01d 100644
--- a/includes/class-moderation.php
+++ b/includes/class-moderation.php
@@ -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.
*
diff --git a/includes/wp-admin/import/class-blocklist.php b/includes/wp-admin/import/class-blocklist.php
new file mode 100644
index 0000000000..81fb740a8f
--- /dev/null
+++ b/includes/wp-admin/import/class-blocklist.php
@@ -0,0 +1,385 @@
+';
+ echo '' . \esc_html__( 'Import Domain Blocklist', 'activitypub' ) . ' ';
+ }
+
+ /**
+ * Display the importer footer.
+ */
+ private static function footer() {
+ echo '';
+ }
+
+ /**
+ * Display the greeting/intro screen.
+ */
+ private static function greet() {
+ echo '';
+ echo '
' . \esc_html__( 'Import a domain blocklist to block multiple ActivityPub instances at once. Supported formats:', 'activitypub' ) . '
';
+ echo '
';
+ echo '' . \esc_html__( 'Mastodon CSV export (with #domain header)', 'activitypub' ) . ' ';
+ echo '' . \esc_html__( 'Simple text file with one domain per line', 'activitypub' ) . ' ';
+ echo ' ';
+
+ // File upload option.
+ \printf( '
%s ', \esc_html__( 'Option 1: Upload a File', 'activitypub' ) );
+ \wp_import_upload_form( 'admin.php?import=blocklist&step=1' );
+
+ // URL import option.
+ \printf( '
%s ', \esc_html__( 'Option 2: Import from URL', 'activitypub' ) );
+ ?>
+
+
+
+
+
+
+ ';
+ }
+
+ /**
+ * Handle file upload and import.
+ */
+ private static function handle_upload() {
+ $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in dispatch().
+ if ( ! isset( $_FILES['import']['name'] ) ) {
+ echo '
' . \esc_html( $error_message ) . ' ';
+ \printf(
+ /* translators: 1: php.ini, 2: post_max_size, 3: upload_max_filesize */
+ \esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your %1$s file or by %2$s being defined as smaller than %3$s in %1$s.', 'activitypub' ),
+ 'php.ini',
+ 'post_max_size',
+ 'upload_max_filesize'
+ );
+ echo '
';
+ return;
+ }
+
+ // Allow CSV and TXT files.
+ $allowed_types = array(
+ 'csv' => 'text/csv',
+ 'txt' => 'text/plain',
+ );
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in dispatch().
+ $file_info = \wp_check_filetype( \sanitize_file_name( $_FILES['import']['name'] ), $allowed_types );
+
+ if ( ! $file_info['type'] ) {
+ \printf(
+ '
%s %s
',
+ \esc_html( $error_message ),
+ \esc_html__( 'The uploaded file must be a CSV or TXT file. Please try again with the correct file format.', 'activitypub' )
+ );
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- Nonce verified in dispatch(), tmp_name is a server path.
+ $file_path = $_FILES['import']['tmp_name'] ?? '';
+
+ if ( empty( $file_path ) ) {
+ \printf( '
%s %s
', \esc_html( $error_message ), \esc_html__( 'Upload failed. Please try again.', 'activitypub' ) );
+ return;
+ }
+
+ $domains = self::parse_csv( $file_path );
+
+ if ( empty( $domains ) ) {
+ \printf( '
%s %s
', \esc_html( $error_message ), \esc_html__( 'No valid domains found in the file.', 'activitypub' ) );
+ return;
+ }
+
+ self::import( $domains );
+ }
+
+ /**
+ * Handle URL import.
+ */
+ private static function handle_url_import() {
+ $error_message = \__( 'Sorry, there has been an error.', 'activitypub' );
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in dispatch().
+ $url = \sanitize_url( \wp_unslash( $_POST['import_url'] ?? '' ) );
+
+ if ( empty( $url ) ) {
+ \printf( '
%s %s
', \esc_html( $error_message ), \esc_html__( 'Please provide a valid URL.', 'activitypub' ) );
+ return;
+ }
+
+ if ( ! \filter_var( $url, FILTER_VALIDATE_URL ) ) {
+ \printf( '
%s %s
', \esc_html( $error_message ), \esc_html__( 'The provided URL is not valid.', 'activitypub' ) );
+ return;
+ }
+
+ // Fetch the URL content.
+ $response = \wp_remote_get(
+ $url,
+ array(
+ 'timeout' => 30,
+ 'redirection' => 5,
+ )
+ );
+
+ if ( \is_wp_error( $response ) ) {
+ \printf( '
%s %s
', \esc_html( $error_message ), \esc_html( $response->get_error_message() ) );
+ return;
+ }
+
+ $response_code = \wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $response_code ) {
+ \printf(
+ '
%s %s
',
+ \esc_html( $error_message ),
+ /* translators: %d: HTTP response code */
+ \esc_html( \sprintf( \__( 'Failed to fetch URL. HTTP response code: %d', 'activitypub' ), $response_code ) )
+ );
+ return;
+ }
+
+ $body = \wp_remote_retrieve_body( $response );
+ if ( empty( $body ) ) {
+ \printf( '
%s %s
', \esc_html( $error_message ), \esc_html__( 'The URL returned empty content.', 'activitypub' ) );
+ return;
+ }
+
+ $domains = self::parse_csv_string( $body );
+
+ if ( empty( $domains ) ) {
+ \printf( '
%s %s
', \esc_html( $error_message ), \esc_html__( 'No valid domains found at the URL.', 'activitypub' ) );
+ return;
+ }
+
+ self::import( $domains );
+ }
+
+ /**
+ * Execute the import.
+ *
+ * @param array $domains Array of domains to import.
+ */
+ private static function import( $domains ) {
+ \set_time_limit( 0 );
+
+ /**
+ * Fires when the blocklist import starts.
+ */
+ \do_action( 'import_start' );
+
+ $existing = Moderation::get_site_blocks()[ Moderation::TYPE_DOMAIN ] ?? array();
+ $new_domains = \array_diff( $domains, $existing );
+ $imported = \count( $new_domains );
+ $skipped = \count( $domains ) - $imported;
+
+ Moderation::add_site_blocks( Moderation::TYPE_DOMAIN, $new_domains );
+
+ /**
+ * Fires when the blocklist import ends.
+ */
+ \do_action( 'import_end' );
+
+ echo '
' . \esc_html__( 'Import Complete', 'activitypub' ) . ' ';
+
+ \printf(
+ '
%s
',
+ \esc_html(
+ \sprintf(
+ /* translators: %s: Number of domains */
+ \_n( 'Imported %s domain.', 'Imported %s domains.', $imported, 'activitypub' ),
+ \number_format_i18n( $imported )
+ )
+ )
+ );
+
+ if ( $skipped > 0 ) {
+ \printf(
+ '
%s
',
+ \esc_html(
+ \sprintf(
+ /* translators: %s: Number of domains */
+ \_n( 'Skipped %s domain (already blocked).', 'Skipped %s domains (already blocked).', $skipped, 'activitypub' ),
+ \number_format_i18n( $skipped )
+ )
+ )
+ );
+ }
+
+ \printf(
+ '
%s
',
+ \esc_url( \admin_url( 'options-general.php?page=activitypub&tab=settings' ) ),
+ \esc_html__( 'View blocked domains in settings', 'activitypub' )
+ );
+ }
+
+ /**
+ * Parse a CSV file and extract domain names.
+ *
+ * Supports Mastodon CSV format (with #domain header) and simple
+ * one-domain-per-line format.
+ *
+ * @param string $file_path Path to the CSV file.
+ * @return array Array of unique, valid domain names.
+ */
+ public static function parse_csv( $file_path ) {
+ if ( ! \file_exists( $file_path ) || ! \is_readable( $file_path ) ) {
+ return array();
+ }
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading local file.
+ $content = \file_get_contents( $file_path );
+ if ( false === $content ) {
+ return array();
+ }
+
+ return self::parse_csv_string( $content );
+ }
+
+ /**
+ * 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.
+ */
+ private 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/wp-admin/import/load.php b/includes/wp-admin/import/load.php
index cc1aba7f45..7352174224 100644
--- a/includes/wp-admin/import/load.php
+++ b/includes/wp-admin/import/load.php
@@ -27,6 +27,13 @@ function load() {
array( __NAMESPACE__ . '\Mastodon', 'dispatch' )
);
+ \register_importer(
+ 'blocklist',
+ \__( 'Domain Blocklist', 'activitypub' ),
+ \__( 'Import a domain blocklist in CSV format (Mastodon, IFTAS DNI, etc.)', 'activitypub' ),
+ array( __NAMESPACE__ . '\Blocklist', 'dispatch' )
+ );
+
if ( '1' === \get_option( 'activitypub_following_ui', '0' ) ) {
\register_importer(
'starter-kit',
diff --git a/tests/phpunit/tests/includes/wp-admin/import/class-test-blocklist.php b/tests/phpunit/tests/includes/wp-admin/import/class-test-blocklist.php
new file mode 100644
index 0000000000..0246ec8e9b
--- /dev/null
+++ b/tests/phpunit/tests/includes/wp-admin/import/class-test-blocklist.php
@@ -0,0 +1,355 @@
+temp_files as $file ) {
+ if ( \file_exists( $file ) ) {
+ \wp_delete_file( $file );
+ }
+ }
+ $this->temp_files = array();
+
+ parent::tear_down();
+ }
+
+ /**
+ * Create a temporary CSV file with given content.
+ *
+ * @param string $content The file content.
+ * @return string The path to the temporary file.
+ */
+ private function create_temp_csv( $content ) {
+ $file = \wp_tempnam( 'blocklist-test-' );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ \file_put_contents( $file, $content );
+ $this->temp_files[] = $file;
+
+ return $file;
+ }
+
+ /**
+ * Test parsing Mastodon CSV format with #domain header.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_mastodon_format() {
+ $csv_content = "#domain,#severity,#public_comment,#private_comment\n";
+ $csv_content .= "example.com,suspend,\"Spam\",\"\"\n";
+ $csv_content .= "bad.org,silence,\"Abuse\",\"Internal note\"\n";
+ $csv_content .= "spam.net,suspend,\"\",\"\"\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 3, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ $this->assertContains( 'spam.net', $domains );
+ }
+
+ /**
+ * Test parsing Mastodon CSV format with domain column not in first position.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_mastodon_format_domain_not_first() {
+ $csv_content = "#severity,#domain,#public_comment\n";
+ $csv_content .= "suspend,example.com,\"Spam\"\n";
+ $csv_content .= "silence,bad.org,\"Abuse\"\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 2, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ }
+
+ /**
+ * Test parsing simple domain-per-line format.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_simple_format() {
+ $csv_content = "example.com\n";
+ $csv_content .= "bad.org\n";
+ $csv_content .= "spam.net\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 3, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ $this->assertContains( 'spam.net', $domains );
+ }
+
+ /**
+ * Test parsing CSV with 'domain' header (without #).
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_domain_header_without_hash() {
+ $csv_content = "domain,comment\n";
+ $csv_content .= "example.com,\"Test domain\"\n";
+ $csv_content .= "bad.org,\"Another domain\"\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 2, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ }
+
+ /**
+ * Test that duplicate domains are removed.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_removes_duplicates() {
+ $csv_content = "example.com\n";
+ $csv_content .= "bad.org\n";
+ $csv_content .= "example.com\n";
+ $csv_content .= "Example.Com\n"; // Should be treated as duplicate (case-insensitive).
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 2, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ }
+
+ /**
+ * Test that domains are normalized to lowercase.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_normalizes_lowercase() {
+ $csv_content = "Example.COM\n";
+ $csv_content .= "BAD.org\n";
+ $csv_content .= "Spam.NET\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 3, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ $this->assertContains( 'spam.net', $domains );
+ $this->assertNotContains( 'Example.COM', $domains );
+ }
+
+ /**
+ * Test that comment lines are skipped.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_skips_comments() {
+ $csv_content = "# This is a comment\n";
+ $csv_content .= "example.com\n";
+ $csv_content .= "# Another comment\n";
+ $csv_content .= "bad.org\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 2, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ }
+
+ /**
+ * Test that empty lines are skipped.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_skips_empty_lines() {
+ $csv_content = "example.com\n";
+ $csv_content .= "\n";
+ $csv_content .= " \n";
+ $csv_content .= "bad.org\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 2, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ }
+
+ /**
+ * Test that invalid domains are skipped.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_skips_invalid_domains() {
+ $csv_content = "example.com\n";
+ $csv_content .= "notadomain\n"; // No dot.
+ $csv_content .= "invalid domain.com\n"; // Space.
+ $csv_content .= "bad.org\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 2, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ $this->assertNotContains( 'notadomain', $domains );
+ }
+
+ /**
+ * Test parsing empty file.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_empty_file() {
+ $file = $this->create_temp_csv( '' );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertEmpty( $domains );
+ }
+
+ /**
+ * Test parsing non-existent file.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_nonexistent_file() {
+ $domains = Blocklist::parse_csv( '/nonexistent/path/to/file.csv' );
+
+ $this->assertEmpty( $domains );
+ }
+
+ /**
+ * Test parsing file with only header.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_only_header() {
+ $csv_content = "#domain,#severity,#public_comment\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertEmpty( $domains );
+ }
+
+ /**
+ * Test parsing file with whitespace around domains.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_trims_whitespace() {
+ $csv_content = " example.com \n";
+ $csv_content .= " bad.org \n";
+ $csv_content .= " spam.net\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 3, $domains );
+ $this->assertContains( 'example.com', $domains );
+ $this->assertContains( 'bad.org', $domains );
+ $this->assertContains( 'spam.net', $domains );
+ }
+
+ /**
+ * Test parsing subdomain.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_with_subdomains() {
+ $csv_content = "sub.example.com\n";
+ $csv_content .= "deep.sub.example.org\n";
+ $csv_content .= "www.test.net\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 3, $domains );
+ $this->assertContains( 'sub.example.com', $domains );
+ $this->assertContains( 'deep.sub.example.org', $domains );
+ $this->assertContains( 'www.test.net', $domains );
+ }
+
+ /**
+ * Test parsing large file with many domains.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_large_file() {
+ $csv_content = "#domain\n";
+ for ( $i = 0; $i < 1000; $i++ ) {
+ $csv_content .= "domain{$i}.example.com\n";
+ }
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 1000, $domains );
+ $this->assertContains( 'domain0.example.com', $domains );
+ $this->assertContains( 'domain999.example.com', $domains );
+ }
+
+ /**
+ * Test parsing domains with hyphens.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_domains_with_hyphens() {
+ $csv_content = "my-example.com\n";
+ $csv_content .= "another-test-domain.org\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 2, $domains );
+ $this->assertContains( 'my-example.com', $domains );
+ $this->assertContains( 'another-test-domain.org', $domains );
+ }
+
+ /**
+ * Test that domain starting with hyphen is rejected.
+ *
+ * @covers ::parse_csv
+ */
+ public function test_parse_csv_rejects_domain_starting_with_hyphen() {
+ $csv_content = "-invalid.com\n";
+ $csv_content .= "valid.com\n";
+
+ $file = $this->create_temp_csv( $csv_content );
+ $domains = Blocklist::parse_csv( $file );
+
+ $this->assertCount( 1, $domains );
+ $this->assertContains( 'valid.com', $domains );
+ $this->assertNotContains( '-invalid.com', $domains );
+ }
+}
From 1876c2f4165450ae608960e7d15cdc19a0ff4fc8 Mon Sep 17 00:00:00 2001
From: Konstantin Obenland
Date: Thu, 4 Dec 2025 15:35:03 -0600
Subject: [PATCH 2/8] Add blocklist subscriptions for automatic weekly sync
- Add Blocklist_Subscriptions class to manage remote blocklist URLs
- Move CSV parsing logic from importer to Blocklist_Subscriptions
- Add settings UI to manage subscriptions with hostname display
- Add AJAX handlers for add/remove subscription operations
- Schedule weekly cron job to sync all subscriptions
- Hide IFTAS button when already subscribed
- Add unit tests for subscription management
---
assets/js/activitypub-moderation-admin.js | 87 +++++++
includes/class-blocklist-subscriptions.php | 222 ++++++++++++++++++
includes/class-scheduler.php | 6 +
includes/wp-admin/class-admin.php | 49 ++++
includes/wp-admin/class-settings-fields.php | 70 ++++++
includes/wp-admin/import/class-blocklist.php | 173 +++++---------
.../class-test-blocklist-subscriptions.php | 129 ++++++++++
7 files changed, 618 insertions(+), 118 deletions(-)
create mode 100644 includes/class-blocklist-subscriptions.php
create mode 100644 tests/phpunit/tests/includes/class-test-blocklist-subscriptions.php
diff --git a/assets/js/activitypub-moderation-admin.js b/assets/js/activitypub-moderation-admin.js
index 95cef0f7b3..d37bd30301 100644
--- a/assets/js/activitypub-moderation-admin.js
+++ b/assets/js/activitypub-moderation-admin.js
@@ -83,6 +83,9 @@
// Site moderation management.
initSiteModeration();
+
+ // Blocklist subscriptions management.
+ initBlocklistSubscriptions();
}
/**
@@ -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 );
diff --git a/includes/class-blocklist-subscriptions.php b/includes/class-blocklist-subscriptions.php
new file mode 100644
index 0000000000..190090e4cc
--- /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();
+
+ // 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 );
+ }
+}
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 e3b4ca4cfd..9a784ba8f8 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' ) );
}
/**
@@ -1069,4 +1071,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 contain invalid data.', '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 24cd4adb56..39bbcd0209 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;
@@ -159,6 +160,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'
+ );
}
/**
@@ -491,4 +500,65 @@ public static function render_site_blocked_keywords_field() {
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $timestamp ) : ?>
+
+
+
+
+
+ 0 ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -98,7 +100,13 @@ private static function greet() {