PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-blocklist-subscriptions.php

class-blocklist-subscriptions.php in ActivityPub trunk, at includes/class-blocklist-subscriptions.php

223 lines 5.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
223