PluginProbe
ActivityPub / 0.7.3
ActivityPub v0.7.3
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
← All changes | includes/functions.php +163 -383 9.3.10.7.3 View file →
@@ -1,457 +1,237 @@
1 1 <?php
2 +namespace Activitypub;
3 +
2 4 /**
3 - * Functions file.
5 + * Returns the ActivityPub default JSON-context
4 6 *
5 - * General utility functions for the ActivityPub plugin.
6 - *
7 - * @package Activitypub
7 + * @return array the activitypub context
8 8 */
9 +function get_context() {
10 + $context = array(
11 + 'https://www.w3.org/ns/activitystreams',
12 + 'https://w3id.org/security/v1',
13 + array(
14 + 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
15 + 'sensitive' => 'as:sensitive',
16 + 'movedTo' => array(
17 + '@id' => 'as:movedTo',
18 + '@type' => '@id',
19 + ),
20 + 'Hashtag' => 'as:Hashtag',
21 + 'ostatus' => 'http://ostatus.org#',
22 + 'atomUri' => 'ostatus:atomUri',
23 + 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri',
24 + 'conversation' => 'ostatus:conversation',
25 + 'toot' => 'http://joinmastodon.org/ns#',
26 + 'Emoji' => 'toot:Emoji',
27 + 'focalPoint' => array(
28 + '@container' => '@list',
29 + '@id' => 'toot:focalPoint',
30 + ),
31 + 'featured' => array(
32 + '@id' => 'toot:featured',
33 + '@type' => '@id',
34 + ),
35 + 'schema' => 'http://schema.org#',
36 + 'PropertyValue' => 'schema:PropertyValue',
37 + 'value' => 'schema:value',
38 + ),
39 + );
9 40
10 -namespace Activitypub;
41 + return apply_filters( 'activitypub_json_context', $context );
42 +}
11 43
12 -/**
13 - * Get the ActivityPub ID for a WordPress object.
14 - *
15 - * Returns the canonical ActivityPub URI for a WP_Post or WP_Comment.
16 - *
17 - * @param \WP_Post|\WP_Comment $wp_object The WordPress post or comment.
18 - *
19 - * @return string|null The ActivityPub ID (a URL), or null if unsupported type.
20 - */
21 -function get_object_id( $wp_object ) {
22 - if ( $wp_object instanceof \WP_Post ) {
23 - return get_post_id( $wp_object->ID );
24 - }
44 +function safe_remote_post( $url, $body, $user_id ) {
45 + $date = gmdate( 'D, d M Y H:i:s T' );
46 + $signature = \Activitypub\Signature::generate_signature( $user_id, $url, $date );
25 47
26 - if ( $wp_object instanceof \WP_Comment ) {
27 - return get_comment_id( $wp_object );
28 - }
48 + $wp_version = get_bloginfo( 'version' );
49 + $user_agent = apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . get_bloginfo( 'url' ) );
50 + $args = array(
51 + 'timeout' => 100,
52 + 'limit_response_size' => 1048576,
53 + 'redirection' => 3,
54 + 'user-agent' => "$user_agent; ActivityPub",
55 + 'headers' => array(
56 + 'Accept' => 'application/activity+json',
57 + 'Content-Type' => 'application/activity+json',
58 + 'Signature' => $signature,
59 + 'Date' => $date,
60 + ),
61 + 'body' => $body,
62 + );
29 63
30 - return null;
31 -}
64 + $response = wp_safe_remote_post( $url, $args );
32 65
33 -/**
34 - * Convert a string from camelCase to snake_case.
35 - *
36 - * @param string $input The string to convert.
37 - *
38 - * @return string The converted string.
39 - */
40 -function camel_to_snake_case( $input ) {
41 - return \strtolower( \preg_replace( '/(?<!^)[A-Z]/', '_$0', $input ) );
42 -}
66 + do_action( 'activitypub_safe_remote_post_response', $response, $url, $body, $user_id );
43 67
44 -/**
45 - * Convert a string from snake_case to camelCase.
46 - *
47 - * @param string $input The string to convert.
48 - *
49 - * @return string The converted string.
50 - */
51 -function snake_to_camel_case( $input ) {
52 - return \lcfirst( \str_replace( '_', '', \ucwords( $input, '_' ) ) );
68 + return $response;
53 69 }
54 70
55 71 /**
56 - * Convert seconds to ISO 8601 duration format.
72 + * Returns a users WebFinger "resource"
57 73 *
58 - * @param int $seconds The duration in seconds.
74 + * @param int $user_id
59 75 *
60 - * @return string The duration in ISO 8601 format (e.g., "PT1H23M45S").
76 + * @return string The user-resource
61 77 */
62 -function seconds_to_iso8601( $seconds ) {
63 - $seconds = (int) $seconds;
64 -
65 - if ( $seconds <= 0 ) {
66 - return 'PT0S';
78 +function get_webfinger_resource( $user_id ) {
79 + // use WebFinger plugin if installed
80 + if ( function_exists( '\get_webfinger_resource' ) ) {
81 + return \get_webfinger_resource( $user_id, false );
67 82 }
68 83
69 - $hours = \floor( $seconds / 3600 );
70 - $minutes = \floor( ( $seconds % 3600 ) / 60 );
71 - $secs = $seconds % 60;
84 + $user = get_user_by( 'id', $user_id );
72 85
73 - $duration = 'PT';
74 -
75 - if ( $hours > 0 ) {
76 - $duration .= $hours . 'H';
77 - }
78 -
79 - if ( $minutes > 0 ) {
80 - $duration .= $minutes . 'M';
81 - }
82 -
83 - if ( $secs > 0 || ( 0 === $hours && 0 === $minutes ) ) {
84 - $duration .= $secs . 'S';
85 - }
86 -
87 - return $duration;
86 + return $user->user_login . '@' . wp_parse_url( home_url(), PHP_URL_HOST );
88 87 }
89 88
90 89 /**
91 - * Check if a site supports the block editor.
90 + * [get_metadata_by_actor description]
92 91 *
93 - * @return boolean True if the site supports the block editor, false otherwise.
94 - */
95 -function site_supports_blocks() {
96 - /**
97 - * Allow plugins to disable block editor support,
98 - * thus disabling blocks registered by the ActivityPub plugin.
99 - *
100 - * @param boolean $supports_blocks True if the site supports the block editor, false otherwise.
101 - */
102 - return \apply_filters( 'activitypub_site_supports_blocks', true );
103 -}
104 -
105 -/**
106 - * Get the icon Image object for site-wide ActivityPub actors.
92 + * @param sting $actor
107 93 *
108 - * Tries the site icon first, then the custom logo, and falls back to the
109 - * bundled WordPress logo.
110 - *
111 - * @since 9.1.0
112 - *
113 - * @return array The icon array with 'type' and 'url'.
94 + * @return array
114 95 */
115 -function site_icon() {
116 - // Try site icon first.
117 - $icon_id = \get_option( 'site_icon' );
96 +function get_remote_metadata_by_actor( $actor ) {
97 + $metadata = get_transient( 'activitypub_' . $actor );
118 98
119 - // Try custom logo second.
120 - if ( ! $icon_id ) {
121 - $icon_id = \get_theme_mod( 'custom_logo' );
99 + if ( $metadata ) {
100 + return $metadata;
122 101 }
123 102
124 - $icon_url = false;
125 -
126 - if ( $icon_id ) {
127 - $icon = \wp_get_attachment_image_src( $icon_id, 'full' );
128 - if ( $icon ) {
129 - $icon_url = $icon[0];
130 - }
103 + if ( ! wp_http_validate_url( $actor ) ) {
104 + return new \WP_Error( 'activitypub_no_valid_actor_url', __( 'The "actor" is no valid URL', 'activitypub' ), $actor );
131 105 }
132 106
133 - if ( ! $icon_url ) {
134 - // Fallback to default icon.
135 - $icon_url = \plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE );
136 - }
107 + $wp_version = get_bloginfo( 'version' );
137 108
138 - return array(
139 - 'type' => 'Image',
140 - 'url' => \esc_url_raw( $icon_url ),
109 + $user_agent = apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . get_bloginfo( 'url' ) );
110 + $args = array(
111 + 'timeout' => 100,
112 + 'limit_response_size' => 1048576,
113 + 'redirection' => 3,
114 + 'user-agent' => "$user_agent; ActivityPub",
115 + 'headers' => array( 'accept' => 'application/activity+json' ),
141 116 );
142 -}
143 117
144 -/**
145 - * Check whether a blog is public based on the `blog_public` option.
146 - *
147 - * @return bool True if public, false if not
148 - */
149 -function is_blog_public() {
150 - /**
151 - * Filter whether the blog is public.
152 - *
153 - * @param bool $public Whether the blog is public.
154 - */
155 - return (bool) \apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
156 -}
118 + $response = wp_safe_remote_get( $actor, $args );
157 119
158 -/**
159 - * Get the masked WordPress version to only show the major and minor version.
160 - *
161 - * @return string The masked version.
162 - */
163 -function get_masked_wp_version() {
164 - // Only show the major and minor version.
165 - $version = \get_bloginfo( 'version' );
166 - // Strip the RC or beta part.
167 - $version = \preg_replace( '/-.*$/', '', $version );
168 - $version = \explode( '.', $version );
169 - $version = \array_slice( $version, 0, 2 );
120 + if ( is_wp_error( $response ) ) {
121 + return $response;
122 + }
170 123
171 - return \implode( '.', $version );
172 -}
124 + $metadata = wp_remote_retrieve_body( $response );
125 + $metadata = json_decode( $metadata, true );
173 126
174 -/**
175 - * Check if a plugin is active, loading plugin.php if necessary.
176 - *
177 - * This is a wrapper around the core is_plugin_active() function that ensures
178 - * the function is available by loading wp-admin/includes/plugin.php if needed.
179 - * This is useful when checking plugin status outside of the admin context.
180 - *
181 - * @param string $plugin Plugin basename (e.g., 'plugin-folder/plugin-file.php').
182 - *
183 - * @return bool True if the plugin is active, false otherwise.
184 - */
185 -function is_plugin_active( $plugin ) {
186 - // Include plugin.php if not already loaded (needed for core is_plugin_active).
187 - if ( ! \function_exists( 'is_plugin_active' ) ) {
188 - require_once ABSPATH . 'wp-admin/includes/plugin.php';
127 + if ( ! $metadata ) {
128 + return new \WP_Error( 'activitypub_invalid_json', __( 'No valid JSON data', 'activitypub' ), $actor );
189 129 }
190 130
191 - return \is_plugin_active( $plugin );
131 + set_transient( 'activitypub_' . $actor, $metadata, WEEK_IN_SECONDS );
132 +
133 + return $metadata;
192 134 }
193 135
194 136 /**
195 - * Returns the website hosts allowed to credit this blog.
196 - *
197 - * @return array|null The attribution domains or null if not found.
137 + * [get_inbox_by_actor description]
138 + * @param [type] $actor [description]
139 + * @return [type] [description]
198 140 */
199 -function get_attribution_domains() {
200 - if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
201 - return null;
141 +function get_inbox_by_actor( $actor ) {
142 + $metadata = \Activitypub\get_remote_metadata_by_actor( $actor );
143 +
144 + if ( is_wp_error( $metadata ) ) {
145 + return $metadata;
202 146 }
203 147
204 - $domains = \get_option( 'activitypub_attribution_domains', home_host() );
205 - $domains = \explode( PHP_EOL, $domains );
148 + if ( isset( $metadata['endpoints'] ) && isset( $metadata['endpoints']['sharedInbox'] ) ) {
149 + return $metadata['endpoints']['sharedInbox'];
150 + }
206 151
207 - if ( ! $domains ) {
208 - $domains = null;
152 + if ( array_key_exists( 'inbox', $metadata ) ) {
153 + return $metadata['inbox'];
209 154 }
210 155
211 - return $domains;
156 + return new \WP_Error( 'activitypub_no_inbox', __( 'No "Inbox" found', 'activitypub' ), $metadata );
212 157 }
213 158
214 159 /**
215 - * Change the display of large numbers on the site.
216 - *
217 - * @author Jeremy Herve
218 - *
219 - * @see https://wordpress.org/support/topic/abbreviate-numbers-with-k/
220 - *
221 - * @param string $formatted Converted number in string format.
222 - * @param float $number The number to convert based on locale.
223 - *
224 - * @return string Converted number in string format.
160 + * [get_inbox_by_actor description]
161 + * @param [type] $actor [description]
162 + * @return [type] [description]
225 163 */
226 -function custom_large_numbers( $formatted, $number ) {
227 - global $wp_locale;
164 +function get_publickey_by_actor( $actor, $key_id ) {
165 + $metadata = \Activitypub\get_remote_metadata_by_actor( $actor );
228 166
229 - $decimals = 0;
230 - $decimal_point = '.';
231 - $thousands_sep = ',';
232 -
233 - if ( isset( $wp_locale ) ) {
234 - $decimals = (int) $wp_locale->number_format['decimal_point'];
235 - $decimal_point = $wp_locale->number_format['decimal_point'];
236 - $thousands_sep = $wp_locale->number_format['thousands_sep'];
167 + if ( is_wp_error( $metadata ) ) {
168 + return $metadata;
237 169 }
238 170
239 - if ( $number < 1000 ) { // Any number less than a Thousand.
240 - return \number_format( $number, $decimals, $decimal_point, $thousands_sep );
241 - } elseif ( $number < 1000000 ) { // Any number less than a million.
242 - return \number_format( $number / 1000, $decimals, $decimal_point, $thousands_sep ) . 'K';
243 - } elseif ( $number < 1000000000 ) { // Any number less than a billion.
244 - return \number_format( $number / 1000000, $decimals, $decimal_point, $thousands_sep ) . 'M';
245 - } else { // At least a billion.
246 - return \number_format( $number / 1000000000, $decimals, $decimal_point, $thousands_sep ) . 'B';
171 + if (
172 + isset( $metadata['publicKey'] ) &&
173 + isset( $metadata['publicKey']['id'] ) &&
174 + isset( $metadata['publicKey']['owner'] ) &&
175 + isset( $metadata['publicKey']['publicKeyPem'] ) &&
176 + $key_id === $metadata['publicKey']['id'] &&
177 + $actor === $metadata['publicKey']['owner']
178 + ) {
179 + return $metadata['publicKey']['publicKeyPem'];
247 180 }
248 -}
249 181
250 -/**
251 - * Escapes a Tag, to be used as a hashtag.
252 - *
253 - * @param string $input The string to escape.
254 - *
255 - * @return string The escaped hashtag.
256 - */
257 -function esc_hashtag( $input ) {
258 - $hashtag = \wp_specialchars_decode( $input, ENT_QUOTES );
259 - // Remove all characters that are not letters, numbers, or hyphens.
260 - $hashtag = \preg_replace( '/[^\p{L}\p{Nd}-]+/u', '-', $hashtag );
261 -
262 - // Capitalize every letter that is preceded by a hyphen.
263 - $hashtag = \preg_replace_callback(
264 - '/-+(.)/',
265 - static function ( $matches ) {
266 - return \strtoupper( $matches[1] );
267 - },
268 - $hashtag
269 - );
270 -
271 - // Add a hashtag to the beginning of the string.
272 - $hashtag = \ltrim( $hashtag, '#' );
273 - $hashtag = \trim( $hashtag, '-' );
274 - $hashtag = '#' . $hashtag;
275 -
276 - /**
277 - * Allow defining your own custom hashtag generation rules.
278 - *
279 - * @param string $hashtag The hashtag to be returned.
280 - * @param string $input The original string.
281 - */
282 - $hashtag = \apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
283 -
284 - return \esc_html( $hashtag );
182 + return new \WP_Error( 'activitypub_no_public_key', __( 'No "Public-Key" found', 'activitypub' ), $metadata );
285 183 }
286 184
287 -/**
288 - * Replace content with links, mentions or hashtags by Regex callback and not affect protected tags.
289 - *
290 - * @param string $content The content that should be changed.
291 - * @param string $regex The regex to use.
292 - * @param callable $regex_callback Callback for replacement logic.
293 - *
294 - * @return string The content with links, mentions, hashtags, etc.
295 - */
296 -function enrich_content_data( $content, $regex, $regex_callback ) {
297 - // Small protection against execution timeouts: limit to 1 MB.
298 - if ( \mb_strlen( $content ) > MB_IN_BYTES ) {
299 - return $content;
300 - }
301 - $tag_stack = array();
302 - $protected_tags = array(
303 - 'pre',
304 - 'code',
305 - 'textarea',
306 - 'style',
307 - 'a',
308 - );
309 - $content_with_links = '';
310 - $in_protected_tag = false;
311 - foreach ( \wp_html_split( $content ) as $chunk ) {
312 - if ( \preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
313 - $content_with_links .= $chunk;
314 - continue;
315 - }
185 +function get_follower_inboxes( $user_id ) {
186 + $followers = \Activitypub\Db\Followers::get_followers( $user_id );
187 + $inboxes = array();
316 188
317 - if ( \preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
318 - $tag = \strtolower( $m[2] );
319 - if ( '/' === $m[1] ) {
320 - // Closing tag.
321 - $i = \array_search( $tag, $tag_stack, true );
322 - // We can only remove the tag from the stack if it is in the stack.
323 - if ( false !== $i ) {
324 - $tag_stack = \array_slice( $tag_stack, 0, $i );
325 - }
326 - } else {
327 - // Opening tag, add it to the stack.
328 - $tag_stack[] = $tag;
329 - }
330 -
331 - // If we're in a protected tag, the tag_stack contains at least one protected tag string.
332 - // The protected tag state can only change when we encounter a start or end tag.
333 - $in_protected_tag = \array_intersect( $tag_stack, $protected_tags );
334 -
335 - // Never inspect tags.
336 - $content_with_links .= $chunk;
189 + foreach ( $followers as $follower ) {
190 + $inbox = \Activitypub\get_inbox_by_actor( $follower );
191 + if ( ! $inbox ) {
337 192 continue;
338 193 }
339 -
340 - if ( $in_protected_tag ) {
341 - // Don't inspect a chunk inside an inspected tag.
342 - $content_with_links .= $chunk;
343 - continue;
194 + // init array if empty
195 + if ( ! isset( $inboxes[ $inbox ] ) ) {
196 + $inboxes[ $inbox ] = array();
344 197 }
345 -
346 - // Only reachable when there is no protected tag in the stack.
347 - $content_with_links .= \preg_replace_callback( $regex, $regex_callback, $chunk );
198 + $inboxes[ $inbox ][] = $follower;
348 199 }
349 200
350 - return $content_with_links;
201 + return $inboxes;
351 202 }
352 203
353 -/**
354 - * Get an ActivityPub embed HTML for a URL.
355 - *
356 - * @param string $url The URL to get the embed for.
357 - * @param boolean $inline_css Whether to inline CSS. Default true.
358 - *
359 - * @return string|false The embed HTML or false if not found.
360 - */
361 -function get_embed_html( $url, $inline_css = true ) {
362 - return Embed::get_html( $url, $inline_css );
204 +function get_identifier_settings( $user_id ) {
205 + ?>
206 +<table class="form-table">
207 + <tbody>
208 + <tr>
209 + <th scope="row">
210 + <label><?php esc_html_e( 'Profile identifier', 'activitypub' ); ?></label>
211 + </th>
212 + <td>
213 + <p><code><?php echo esc_html( \Activitypub\get_webfinger_resource( $user_id ) ); ?></code> or <code><?php echo esc_url( get_author_posts_url( $user_id ) ); ?></code></p>
214 + <?php // translators: the webfinger resource ?>
215 + <p class="description"><?php printf( esc_html__( 'Try to follow "@%s" in the Mastodon/Friendica search field.', 'activitypub' ), esc_html( \Activitypub\get_webfinger_resource( $user_id ) ) ); ?></p>
216 + </td>
217 + </tr>
218 + </tbody>
219 +</table>
220 + <?php
363 221 }
364 222
365 -/**
366 - * Get the client IP address for rate-limiting purposes.
367 - *
368 - * Walks the ordered list of $_SERVER keys returned by the
369 - * `activitypub_client_ip_sources` filter (default: `['REMOTE_ADDR']`) and
370 - * returns the first value that parses as a valid IP literal, validated via
371 - * `filter_var( ..., FILTER_VALIDATE_IP )`. The result can be overridden
372 - * outright via the `activitypub_client_ip` filter; that filter's output is
373 - * also validated and replaced with `''` when it isn't a valid IP, so a
374 - * misbehaving filter can't collide all callers into the same rate-limit
375 - * bucket.
376 - *
377 - * Trusting any source other than `REMOTE_ADDR` is only safe behind a
378 - * reverse proxy that sets and overwrites the corresponding header — see
379 - * the `activitypub_client_ip_sources` filter docblock for guidance.
380 - *
381 - * Callers using the return value as a rate-limit key should treat an
382 - * empty return as "client unidentifiable" and fail closed rather than
383 - * share a single bucket across every such request.
384 - *
385 - * @since 8.1.0
386 - *
387 - * @return string A valid IP address, or '' when no IP could be determined.
388 - */
389 -function get_client_ip() {
390 - // phpcs:disable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
391 - $ip = '';
223 +function get_followers( $user_id ) {
224 + $followers = \Activitypub\Db\Followers::get_followers( $user_id );
392 225
393 - /**
394 - * Filter the ordered list of $_SERVER keys to consult as a source for the
395 - * client IP. The first key whose value parses as a valid IP wins.
396 - *
397 - * Default: array( 'REMOTE_ADDR' ) — the actual TCP peer, the only value
398 - * that an HTTP client cannot spoof. Trusting any other $_SERVER key is
399 - * only safe when a reverse proxy in front of the site sets that key and
400 - * overwrites any client-supplied version; otherwise an attacker can spoof
401 - * the value and bypass the per-IP rate limits that depend on it.
402 - *
403 - * Common operator overrides:
404 - * array( 'HTTP_CF_CONNECTING_IP' ) on Cloudflare.
405 - * array( 'HTTP_TRUE_CLIENT_IP', 'REMOTE_ADDR' ) Akamai with a fallback.
406 - * array( 'HTTP_X_REAL_IP' ) nginx that strips the client copy.
407 - *
408 - * X-Forwarded-For pitfall: even with a trusted proxy, an attacker can
409 - * prepend their own value before the proxy appends the real client IP.
410 - * This helper takes the leftmost entry, which is correct only when the
411 - * trusted proxy fully overwrites the header. If you trust X-Forwarded-For
412 - * end-to-end, prefer to resolve from the right by your known proxy count
413 - * via the activitypub_client_ip filter.
414 - *
415 - * @since 8.2.0
416 - *
417 - * @param string[] $sources $_SERVER keys to consult, in priority order.
418 - */
419 - $sources = \apply_filters( 'activitypub_client_ip_sources', array( 'REMOTE_ADDR' ) );
420 -
421 - if ( ! \is_array( $sources ) ) {
422 - $sources = array( 'REMOTE_ADDR' );
226 + if ( ! $followers ) {
227 + return array();
423 228 }
424 229
425 - foreach ( $sources as $source ) {
426 - if ( ! \is_string( $source ) || empty( $_SERVER[ $source ] ) ) {
427 - continue;
428 - }
230 + return $followers;
231 +}
429 232
430 - // Some headers (e.g. X-Forwarded-For) may contain a comma-separated list; use the first IP.
431 - $ip_list = \sanitize_text_field( \wp_unslash( $_SERVER[ $source ] ) );
432 - $candidate = \trim( \explode( ',', $ip_list )[0] );
233 +function count_followers( $user_id ) {
234 + $followers = \Activitypub\get_followers( $user_id );
433 235
434 - if ( \filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
435 - $ip = $candidate;
436 - break;
437 - }
438 - }
439 - // phpcs:enable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
440 -
441 - /**
442 - * Filter the client IP address used for rate limiting.
443 - *
444 - * @since 8.1.0
445 - *
446 - * @param string $ip The detected client IP address (empty when none could be determined).
447 - */
448 - $ip = \apply_filters( 'activitypub_client_ip', $ip );
449 -
450 - // Tolerate surrounding whitespace from filter callbacks; FILTER_VALIDATE_IP would otherwise reject it.
451 - if ( \is_string( $ip ) ) {
452 - $ip = \trim( $ip );
453 - }
454 -
455 - // Re-validate so a misbehaving filter can't return a sentinel string that would collapse all callers into one bucket.
456 - return \is_string( $ip ) && \filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
236 + return count( $followers );
457 237 }