-
Notifications
You must be signed in to change notification settings - Fork 83
Add blocklist subscriptions for automatic weekly sync #2590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
ff9c374
Add domain blocklist importer
obenland 1876c2f
Add blocklist subscriptions for automatic weekly sync
obenland 9db8790
Wrap blocked domains and keywords tables in collapsible details element
obenland c5f4cdc
Revert "Wrap blocked domains and keywords tables in collapsible detai…
obenland d62b441
Update includes/class-blocklist-subscriptions.php
obenland eb68801
Use filter() instead of attribute selector to avoid selector injection
obenland b2afba8
Add changelog
matticbot afe0037
Merge remote-tracking branch 'origin/trunk' into add/blocklist-subscr…
obenland 8af7493
Validate blocklist URLs contain valid domains before subscribing
obenland 82698b2
Merge branch 'trunk' into add/blocklist-subscriptions
obenland File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ) { | ||
obenland marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| $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() ) ); | ||
obenland marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /** | ||
| * 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 ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.