PluginProbe
ActivityPub / 1.0.0
ActivityPub v1.0.0
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 +323 -297 8.3.01.0.0 View file →
@@ -1,246 +1,256 @@
1 1 <?php
2 -/**
3 - * Functions file.
4 - *
5 - * General utility functions for the ActivityPub plugin.
6 - *
7 - * @package Activitypub
8 - */
2 +namespace Activitypub;
9 3
10 -namespace Activitypub;
4 +use WP_Error;
5 +use Activitypub\Http;
6 +use Activitypub\Activity\Activity;
7 +use Activitypub\Collection\Followers;
11 8
12 9 /**
13 - * Get the ActivityPub ID for a WordPress object.
10 + * Returns the ActivityPub default JSON-context
14 11 *
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.
12 + * @return array the activitypub context
20 13 */
21 -function get_object_id( $wp_object ) {
22 - if ( $wp_object instanceof \WP_Post ) {
23 - return get_post_id( $wp_object->ID );
24 - }
14 +function get_context() {
15 + $context = Activity::CONTEXT;
25 16
26 - if ( $wp_object instanceof \WP_Comment ) {
27 - return get_comment_id( $wp_object );
28 - }
17 + return \apply_filters( 'activitypub_json_context', $context );
18 +}
29 19
30 - return null;
20 +function safe_remote_post( $url, $body, $user_id ) {
21 + return Http::post( $url, $body, $user_id );
31 22 }
32 23
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 ) );
24 +function safe_remote_get( $url ) {
25 + return Http::get( $url );
42 26 }
43 27
44 28 /**
45 - * Convert a string from snake_case to camelCase.
29 + * Returns a users WebFinger "resource"
46 30 *
47 - * @param string $input The string to convert.
31 + * @param int $user_id The User-ID.
48 32 *
49 - * @return string The converted string.
33 + * @return string The User-Resource.
50 34 */
51 -function snake_to_camel_case( $input ) {
52 - return lcfirst( str_replace( '_', '', ucwords( $input, '_' ) ) );
35 +function get_webfinger_resource( $user_id ) {
36 + return Webfinger::get_user_resource( $user_id );
53 37 }
54 38
55 39 /**
56 - * Convert seconds to ISO 8601 duration format.
40 + * Requests the Meta-Data from the Actors profile
57 41 *
58 - * @param int $seconds The duration in seconds.
42 + * @param string $actor The Actor URL.
43 + * @param bool $cached If the result should be cached.
59 44 *
60 - * @return string The duration in ISO 8601 format (e.g., "PT1H23M45S").
45 + * @return array The Actor profile as array
61 46 */
62 -function seconds_to_iso8601( $seconds ) {
63 - $seconds = (int) $seconds;
47 +function get_remote_metadata_by_actor( $actor, $cached = true ) {
48 + $pre = apply_filters( 'pre_get_remote_metadata_by_actor', false, $actor );
49 + if ( $pre ) {
50 + return $pre;
51 + }
52 + if ( preg_match( '/^@?' . ACTIVITYPUB_USERNAME_REGEXP . '$/i', $actor ) ) {
53 + $actor = Webfinger::resolve( $actor );
54 + }
64 55
65 - if ( $seconds <= 0 ) {
66 - return 'PT0S';
56 + if ( ! $actor ) {
57 + return new WP_Error( 'activitypub_no_valid_actor_identifier', \__( 'The "actor" identifier is not valid', 'activitypub' ), $actor );
67 58 }
68 59
69 - $hours = floor( $seconds / 3600 );
70 - $minutes = floor( ( $seconds % 3600 ) / 60 );
71 - $secs = $seconds % 60;
60 + if ( is_wp_error( $actor ) ) {
61 + return $actor;
62 + }
72 63
73 - $duration = 'PT';
64 + $transient_key = 'activitypub_' . $actor;
74 65
75 - if ( $hours > 0 ) {
76 - $duration .= $hours . 'H';
66 + // only check the cache if needed.
67 + if ( $cached ) {
68 + $metadata = \get_transient( $transient_key );
69 +
70 + if ( $metadata ) {
71 + return $metadata;
72 + }
77 73 }
78 74
79 - if ( $minutes > 0 ) {
80 - $duration .= $minutes . 'M';
75 + if ( ! \wp_http_validate_url( $actor ) ) {
76 + $metadata = new WP_Error( 'activitypub_no_valid_actor_url', \__( 'The "actor" is no valid URL', 'activitypub' ), $actor );
77 + \set_transient( $transient_key, $metadata, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
78 + return $metadata;
81 79 }
82 80
83 - if ( $secs > 0 || ( 0 === $hours && 0 === $minutes ) ) {
84 - $duration .= $secs . 'S';
81 + $short_timeout = function() {
82 + return 3;
83 + };
84 + add_filter( 'activitypub_remote_get_timeout', $short_timeout );
85 + $response = Http::get( $actor );
86 + remove_filter( 'activitypub_remote_get_timeout', $short_timeout );
87 + if ( \is_wp_error( $response ) ) {
88 + \set_transient( $transient_key, $response, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
89 + return $response;
85 90 }
86 91
87 - return $duration;
92 + $metadata = \wp_remote_retrieve_body( $response );
93 + $metadata = \json_decode( $metadata, true );
94 +
95 + \set_transient( $transient_key, $metadata, WEEK_IN_SECONDS );
96 +
97 + if ( ! $metadata ) {
98 + $metadata = new WP_Error( 'activitypub_invalid_json', \__( 'No valid JSON data', 'activitypub' ), $actor );
99 + \set_transient( $transient_key, $metadata, HOUR_IN_SECONDS ); // Cache the error for a shorter period.
100 + return $metadata;
101 + }
102 +
103 + return $metadata;
88 104 }
89 105
90 106 /**
91 - * Check if a site supports the block editor.
107 + * Returns the followers of a given user.
92 108 *
93 - * @return boolean True if the site supports the block editor, false otherwise.
109 + * @param int $user_id The User-ID.
110 + *
111 + * @return array The followers.
94 112 */
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 );
113 +function get_followers( $user_id ) {
114 + return Followers::get_followers( $user_id );
103 115 }
104 116
105 117 /**
106 - * Check if data is valid JSON.
118 + * Count the number of followers for a given user.
107 119 *
108 - * @deprecated 7.1.0 Use {@see \json_decode}.
120 + * @param int $user_id The User-ID.
109 121 *
110 - * @param string $data The data to check.
111 - *
112 - * @return boolean True if the data is JSON, false otherwise.
122 + * @return int The number of followers.
113 123 */
114 -function is_json( $data ) {
115 - \_deprecated_function( __FUNCTION__, '7.1.0', 'json_decode' );
116 -
117 - return \is_array( \json_decode( $data, true ) );
124 +function count_followers( $user_id ) {
125 + return Followers::count_followers( $user_id );
118 126 }
119 127
120 128 /**
121 - * Check whether a blog is public based on the `blog_public` option.
129 + * Examine a url and try to determine the author ID it represents.
122 130 *
123 - * @return bool True if public, false if not
131 + * Checks are supposedly from the hosted site blog.
132 + *
133 + * @param string $url Permalink to check.
134 + *
135 + * @return int User ID, or 0 on failure.
124 136 */
125 -function is_blog_public() {
126 - /**
127 - * Filter whether the blog is public.
128 - *
129 - * @param bool $public Whether the blog is public.
130 - */
131 - return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
132 -}
137 +function url_to_authorid( $url ) {
138 + global $wp_rewrite;
133 139
134 -/**
135 - * Get the masked WordPress version to only show the major and minor version.
136 - *
137 - * @return string The masked version.
138 - */
139 -function get_masked_wp_version() {
140 - // Only show the major and minor version.
141 - $version = get_bloginfo( 'version' );
142 - // Strip the RC or beta part.
143 - $version = preg_replace( '/-.*$/', '', $version );
144 - $version = explode( '.', $version );
145 - $version = array_slice( $version, 0, 2 );
140 + // check if url hase the same host
141 + if ( \wp_parse_url( \site_url(), \PHP_URL_HOST ) !== \wp_parse_url( $url, \PHP_URL_HOST ) ) {
142 + return 0;
143 + }
146 144
147 - return implode( '.', $version );
145 + // first, check to see if there is a 'author=N' to match against
146 + if ( \preg_match( '/[?&]author=(\d+)/i', $url, $values ) ) {
147 + $id = \absint( $values[1] );
148 + if ( $id ) {
149 + return $id;
150 + }
151 + }
152 +
153 + // check to see if we are using rewrite rules
154 + $rewrite = $wp_rewrite->wp_rewrite_rules();
155 +
156 + // not using rewrite rules, and 'author=N' method failed, so we're out of options
157 + if ( empty( $rewrite ) ) {
158 + return 0;
159 + }
160 +
161 + // generate rewrite rule for the author url
162 + $author_rewrite = $wp_rewrite->get_author_permastruct();
163 + $author_regexp = \str_replace( '%author%', '', $author_rewrite );
164 +
165 + // match the rewrite rule with the passed url
166 + if ( \preg_match( '/https?:\/\/(.+)' . \preg_quote( $author_regexp, '/' ) . '([^\/]+)/i', $url, $match ) ) {
167 + $user = \get_user_by( 'slug', $match[2] );
168 + if ( $user ) {
169 + return $user->ID;
170 + }
171 + }
172 +
173 + return 0;
148 174 }
149 175
150 176 /**
151 - * Check if a plugin is active, loading plugin.php if necessary.
177 + * Check for Tombstone Objects
152 178 *
153 - * This is a wrapper around the core is_plugin_active() function that ensures
154 - * the function is available by loading wp-admin/includes/plugin.php if needed.
155 - * This is useful when checking plugin status outside of the admin context.
179 + * @see https://www.w3.org/TR/activitypub/#delete-activity-outbox
156 180 *
157 - * @param string $plugin Plugin basename (e.g., 'plugin-folder/plugin-file.php').
181 + * @param WP_Error $wp_error A WP_Error-Response of an HTTP-Request
158 182 *
159 - * @return bool True if the plugin is active, false otherwise.
183 + * @return boolean true if HTTP-Code is 410 or 404
160 184 */
161 -function is_plugin_active( $plugin ) {
162 - // Include plugin.php if not already loaded (needed for core is_plugin_active).
163 - if ( ! \function_exists( 'is_plugin_active' ) ) {
164 - require_once ABSPATH . 'wp-admin/includes/plugin.php';
185 +function is_tombstone( $wp_error ) {
186 + if ( ! is_wp_error( $wp_error ) ) {
187 + return false;
165 188 }
166 189
167 - return \is_plugin_active( $plugin );
190 + if ( in_array( (int) $wp_error->get_error_code(), array( 404, 410 ), true ) ) {
191 + return true;
192 + }
193 +
194 + return false;
168 195 }
169 196
170 197 /**
171 - * Returns the website hosts allowed to credit this blog.
198 + * Get the REST URL relative to this plugin's namespace.
172 199 *
173 - * @return array|null The attribution domains or null if not found.
200 + * @param string $path Optional. REST route path. Otherwise this plugin's namespaced root.
201 + *
202 + * @return string REST URL relative to this plugin's namespace.
174 203 */
175 -function get_attribution_domains() {
176 - if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
177 - return null;
178 - }
179 -
180 - $domains = \get_option( 'activitypub_attribution_domains', home_host() );
181 - $domains = explode( PHP_EOL, $domains );
182 -
183 - if ( ! $domains ) {
184 - $domains = null;
185 - }
186 -
187 - return $domains;
204 +function get_rest_url_by_path( $path = '' ) {
205 + // we'll handle the leading slash.
206 + $path = ltrim( $path, '/' );
207 + $namespaced_path = sprintf( '/%s/%s', ACTIVITYPUB_REST_NAMESPACE, $path );
208 + return \get_rest_url( null, $namespaced_path );
188 209 }
189 210
190 211 /**
191 - * Change the display of large numbers on the site.
212 + * Convert a string from camelCase to snake_case.
192 213 *
193 - * @author Jeremy Herve
214 + * @param string $string The string to convert.
194 215 *
195 - * @see https://wordpress.org/support/topic/abbreviate-numbers-with-k/
216 + * @return string The converted string.
217 + */
218 +// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
219 +function camel_to_snake_case( $string ) {
220 + return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $string ) );
221 +}
222 +
223 +/**
224 + * Convert a string from snake_case to camelCase.
196 225 *
197 - * @param string $formatted Converted number in string format.
198 - * @param float $number The number to convert based on locale.
226 + * @param string $string The string to convert.
199 227 *
200 - * @return string Converted number in string format.
228 + * @return string The converted string.
201 229 */
202 -function custom_large_numbers( $formatted, $number ) {
203 - global $wp_locale;
204 -
205 - $decimals = 0;
206 - $decimal_point = '.';
207 - $thousands_sep = ',';
208 -
209 - if ( isset( $wp_locale ) ) {
210 - $decimals = (int) $wp_locale->number_format['decimal_point'];
211 - $decimal_point = $wp_locale->number_format['decimal_point'];
212 - $thousands_sep = $wp_locale->number_format['thousands_sep'];
213 - }
214 -
215 - if ( $number < 1000 ) { // Any number less than a Thousand.
216 - return \number_format( $number, $decimals, $decimal_point, $thousands_sep );
217 - } elseif ( $number < 1000000 ) { // Any number less than a million.
218 - return \number_format( $number / 1000, $decimals, $decimal_point, $thousands_sep ) . 'K';
219 - } elseif ( $number < 1000000000 ) { // Any number less than a billion.
220 - return \number_format( $number / 1000000, $decimals, $decimal_point, $thousands_sep ) . 'M';
221 - } else { // At least a billion.
222 - return \number_format( $number / 1000000000, $decimals, $decimal_point, $thousands_sep ) . 'B';
223 - }
230 +// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
231 +function snake_to_camel_case( $string ) {
232 + return lcfirst( str_replace( '_', '', ucwords( $string, '_' ) ) );
224 233 }
225 234
226 235 /**
227 236 * Escapes a Tag, to be used as a hashtag.
228 237 *
229 - * @param string $input The string to escape.
238 + * @param string $string The string to escape.
230 239 *
231 - * @return string The escaped hashtag.
240 + * @return string The escaped hastag.
232 241 */
233 -function esc_hashtag( $input ) {
234 - $hashtag = \wp_specialchars_decode( $input, ENT_QUOTES );
235 - // Remove all characters that are not letters, numbers, or hyphens.
236 - $hashtag = \preg_replace( '/[^\p{L}\p{Nd}-]+/u', '-', $hashtag );
242 +function esc_hashtag( $string ) {
237 243
238 - // Capitalize every letter that is preceded by a hyphen.
244 + $hashtag = \wp_specialchars_decode( $string, ENT_QUOTES );
245 + // Remove all characters that are not letters, numbers, or underscores.
246 + $hashtag = \preg_replace( '/emoji-regex(*SKIP)(?!)|[^\p{L}\p{Nd}_]+/u', '_', $hashtag );
247 +
248 + // Capitalize every letter that is preceded by an underscore.
239 249 $hashtag = preg_replace_callback(
240 - '/-+(.)/',
241 - static function ( $matches ) {
242 - return strtoupper( $matches[1] );
250 + '/_(.)/',
251 + function ( $matches ) {
252 + return '' . strtoupper( $matches[1] );
243 253 },
244 254 $hashtag
245 255 );
246 256
@@ -245,9 +255,8 @@
245 255 );
246 256
247 257 // Add a hashtag to the beginning of the string.
248 258 $hashtag = ltrim( $hashtag, '#' );
249 - $hashtag = trim( $hashtag, '-' );
250 259 $hashtag = '#' . $hashtag;
251 260
252 261 /**
253 262 * Allow defining your own custom hashtag generation rules.
@@ -252,182 +261,199 @@
252 261 /**
253 262 * Allow defining your own custom hashtag generation rules.
254 263 *
255 264 * @param string $hashtag The hashtag to be returned.
256 - * @param string $input The original string.
265 + * @param string $string The original string.
257 266 */
258 - $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
267 + $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $string );
259 268
260 269 return esc_html( $hashtag );
261 270 }
262 271
263 272 /**
264 - * Replace content with links, mentions or hashtags by Regex callback and not affect protected tags.
273 + * Check if a request is for an ActivityPub request.
265 274 *
266 - * @param string $content The content that should be changed.
267 - * @param string $regex The regex to use.
268 - * @param callable $regex_callback Callback for replacement logic.
269 - *
270 - * @return string The content with links, mentions, hashtags, etc.
275 + * @return bool False by default.
271 276 */
272 -function enrich_content_data( $content, $regex, $regex_callback ) {
273 - // Small protection against execution timeouts: limit to 1 MB.
274 - if ( mb_strlen( $content ) > MB_IN_BYTES ) {
275 - return $content;
277 +function is_activitypub_request() {
278 + global $wp_query;
279 +
280 + /*
281 + * ActivityPub requests are currently only made for
282 + * author archives, singular posts, and the homepage.
283 + */
284 + if ( ! \is_author() && ! \is_singular() && ! \is_home() && ! defined( '\REST_REQUEST' ) ) {
285 + return false;
276 286 }
277 - $tag_stack = array();
278 - $protected_tags = array(
279 - 'pre',
280 - 'code',
281 - 'textarea',
282 - 'style',
283 - 'a',
284 - );
285 - $content_with_links = '';
286 - $in_protected_tag = false;
287 - foreach ( wp_html_split( $content ) as $chunk ) {
288 - if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
289 - $content_with_links .= $chunk;
290 - continue;
291 - }
292 287
293 - if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
294 - $tag = strtolower( $m[2] );
295 - if ( '/' === $m[1] ) {
296 - // Closing tag.
297 - $i = array_search( $tag, $tag_stack, true );
298 - // We can only remove the tag from the stack if it is in the stack.
299 - if ( false !== $i ) {
300 - $tag_stack = array_slice( $tag_stack, 0, $i );
301 - }
302 - } else {
303 - // Opening tag, add it to the stack.
304 - $tag_stack[] = $tag;
305 - }
288 + // One can trigger an ActivityPub request by adding ?activitypub to the URL.
289 + // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.VariableRedeclaration
290 + global $wp_query;
291 + if ( isset( $wp_query->query_vars['activitypub'] ) ) {
292 + return true;
293 + }
306 294
307 - // If we're in a protected tag, the tag_stack contains at least one protected tag string.
308 - // The protected tag state can only change when we encounter a start or end tag.
309 - $in_protected_tag = array_intersect( $tag_stack, $protected_tags );
295 + /*
296 + * The other (more common) option to make an ActivityPub request
297 + * is to send an Accept header.
298 + */
299 + if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
300 + $accept = sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
310 301
311 - // Never inspect tags.
312 - $content_with_links .= $chunk;
313 - continue;
302 + /*
303 + * $accept can be a single value, or a comma separated list of values.
304 + * We want to support both scenarios,
305 + * and return true when the header includes at least one of the following:
306 + * - application/activity+json
307 + * - application/ld+json
308 + * - application/json
309 + */
310 + if ( preg_match( '/(application\/(ld\+json|activity\+json|json))/i', $accept ) ) {
311 + return true;
314 312 }
315 -
316 - if ( $in_protected_tag ) {
317 - // Don't inspect a chunk inside an inspected tag.
318 - $content_with_links .= $chunk;
319 - continue;
320 - }
321 -
322 - // Only reachable when there is no protected tag in the stack.
323 - $content_with_links .= \preg_replace_callback( $regex, $regex_callback, $chunk );
324 313 }
325 314
326 - return $content_with_links;
315 + return false;
327 316 }
328 317
329 318 /**
330 - * Get an ActivityPub embed HTML for a URL.
319 + * This function checks if a user is disabled for ActivityPub.
331 320 *
332 - * @param string $url The URL to get the embed for.
333 - * @param boolean $inline_css Whether to inline CSS. Default true.
321 + * @param int $user_id The User-ID.
334 322 *
335 - * @return string|false The embed HTML or false if not found.
323 + * @return boolean True if the user is disabled, false otherwise.
336 324 */
337 -function get_embed_html( $url, $inline_css = true ) {
338 - return Embed::get_html( $url, $inline_css );
325 +function is_user_disabled( $user_id ) {
326 + $return = false;
327 +
328 + switch ( $user_id ) {
329 + // if the user is the application user, it's always enabled.
330 + case \Activitypub\Collection\Users::APPLICATION_USER_ID:
331 + $return = false;
332 + break;
333 + // if the user is the blog user, it's only enabled in single-user mode.
334 + case \Activitypub\Collection\Users::BLOG_USER_ID:
335 + if ( is_user_type_disabled( 'blog' ) ) {
336 + $return = true;
337 + break;
338 + }
339 +
340 + $return = false;
341 + break;
342 + // if the user is any other user, it's enabled if it can publish posts.
343 + default:
344 + if ( ! \get_user_by( 'id', $user_id ) ) {
345 + $return = true;
346 + break;
347 + }
348 +
349 + if ( is_user_type_disabled( 'user' ) ) {
350 + $return = true;
351 + break;
352 + }
353 +
354 + if ( ! \user_can( $user_id, 'publish_posts' ) ) {
355 + $return = true;
356 + break;
357 + }
358 +
359 + $return = false;
360 + break;
361 + }
362 +
363 + return apply_filters( 'activitypub_is_user_disabled', $return, $user_id );
339 364 }
340 365
341 366 /**
342 - * Get the client IP address for rate-limiting purposes.
367 + * Checks if a User-Type is disabled for ActivityPub.
343 368 *
344 - * Walks the ordered list of $_SERVER keys returned by the
345 - * `activitypub_client_ip_sources` filter (default: `['REMOTE_ADDR']`) and
346 - * returns the first value that parses as a valid IP literal, validated via
347 - * `filter_var( ..., FILTER_VALIDATE_IP )`. The result can be overridden
348 - * outright via the `activitypub_client_ip` filter; that filter's output is
349 - * also validated and replaced with `''` when it isn't a valid IP, so a
350 - * misbehaving filter can't collide all callers into the same rate-limit
351 - * bucket.
369 + * This function is used to check if the 'blog' or 'user'
370 + * type is disabled for ActivityPub.
352 371 *
353 - * Trusting any source other than `REMOTE_ADDR` is only safe behind a
354 - * reverse proxy that sets and overwrites the corresponding header — see
355 - * the `activitypub_client_ip_sources` filter docblock for guidance.
372 + * @param enum $type Can be 'blog' or 'user'.
356 373 *
357 - * Callers using the return value as a rate-limit key should treat an
358 - * empty return as "client unidentifiable" and fail closed rather than
359 - * share a single bucket across every such request.
360 - *
361 - * @since 8.1.0
362 - *
363 - * @return string A valid IP address, or '' when no IP could be determined.
374 + * @return boolean True if the user type is disabled, false otherwise.
364 375 */
365 -function get_client_ip() {
366 - // phpcs:disable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
367 - $ip = '';
376 +function is_user_type_disabled( $type ) {
377 + switch ( $type ) {
378 + case 'blog':
379 + if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
380 + if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
381 + $return = false;
382 + break;
383 + }
384 + }
368 385
369 - /**
370 - * Filter the ordered list of $_SERVER keys to consult as a source for the
371 - * client IP. The first key whose value parses as a valid IP wins.
372 - *
373 - * Default: array( 'REMOTE_ADDR' ) — the actual TCP peer, the only value
374 - * that an HTTP client cannot spoof. Trusting any other $_SERVER key is
375 - * only safe when a reverse proxy in front of the site sets that key and
376 - * overwrites any client-supplied version; otherwise an attacker can spoof
377 - * the value and bypass the per-IP rate limits that depend on it.
378 - *
379 - * Common operator overrides:
380 - * array( 'HTTP_CF_CONNECTING_IP' ) on Cloudflare.
381 - * array( 'HTTP_TRUE_CLIENT_IP', 'REMOTE_ADDR' ) Akamai with a fallback.
382 - * array( 'HTTP_X_REAL_IP' ) nginx that strips the client copy.
383 - *
384 - * X-Forwarded-For pitfall: even with a trusted proxy, an attacker can
385 - * prepend their own value before the proxy appends the real client IP.
386 - * This helper takes the leftmost entry, which is correct only when the
387 - * trusted proxy fully overwrites the header. If you trust X-Forwarded-For
388 - * end-to-end, prefer to resolve from the right by your known proxy count
389 - * via the activitypub_client_ip filter.
390 - *
391 - * @since 8.2.0
392 - *
393 - * @param string[] $sources $_SERVER keys to consult, in priority order.
394 - */
395 - $sources = \apply_filters( 'activitypub_client_ip_sources', array( 'REMOTE_ADDR' ) );
386 + if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) ) {
387 + $return = ACTIVITYPUB_DISABLE_BLOG_USER;
388 + break;
389 + }
396 390
397 - if ( ! \is_array( $sources ) ) {
398 - $sources = array( 'REMOTE_ADDR' );
391 + if ( '1' !== \get_option( 'activitypub_enable_blog_user', '0' ) ) {
392 + $return = true;
393 + break;
394 + }
395 +
396 + $return = false;
397 + break;
398 + case 'user':
399 + if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
400 + if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
401 + $return = true;
402 + break;
403 + }
404 + }
405 +
406 + if ( \defined( 'ACTIVITYPUB_DISABLE_USER' ) ) {
407 + $return = ACTIVITYPUB_DISABLE_USER;
408 + break;
409 + }
410 +
411 + if ( '1' !== \get_option( 'activitypub_enable_users', '1' ) ) {
412 + $return = true;
413 + break;
414 + }
415 +
416 + $return = false;
417 + break;
418 + default:
419 + $return = new WP_Error( 'activitypub_wrong_user_type', __( 'Wrong user type', 'activitypub' ) );
420 + break;
399 421 }
400 422
401 - foreach ( $sources as $source ) {
402 - if ( ! \is_string( $source ) || empty( $_SERVER[ $source ] ) ) {
403 - continue;
404 - }
423 + return apply_filters( 'activitypub_is_user_type_disabled', $return, $type );
424 +}
405 425
406 - // Some headers (e.g. X-Forwarded-For) may contain a comma-separated list; use the first IP.
407 - $ip_list = \sanitize_text_field( \wp_unslash( $_SERVER[ $source ] ) );
408 - $candidate = \trim( \explode( ',', $ip_list )[0] );
426 +/**
427 + * Check if the blog is in single-user mode.
428 + *
429 + * @return boolean True if the blog is in single-user mode, false otherwise.
430 + */
431 +function is_single_user() {
432 + $return = false;
409 433
410 - if ( \filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
411 - $ip = $candidate;
412 - break;
434 + if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
435 + if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
436 + $return = true;
413 437 }
438 + } elseif (
439 + false === is_user_type_disabled( 'blog' ) &&
440 + true === is_user_type_disabled( 'user' )
441 + ) {
442 + $return = true;
414 443 }
415 - // phpcs:enable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
416 444
445 + return $return;
446 +}
447 +
448 +if ( ! function_exists( 'get_self_link' ) ) {
417 449 /**
418 - * Filter the client IP address used for rate limiting.
450 + * Returns the link for the currently displayed feed.
419 451 *
420 - * @since 8.1.0
421 - *
422 - * @param string $ip The detected client IP address (empty when none could be determined).
452 + * @return string Correct link for the atom:self element.
423 453 */
424 - $ip = \apply_filters( 'activitypub_client_ip', $ip );
425 -
426 - // Tolerate surrounding whitespace from filter callbacks; FILTER_VALIDATE_IP would otherwise reject it.
427 - if ( \is_string( $ip ) ) {
428 - $ip = \trim( $ip );
454 + function get_self_link() {
455 + $host = wp_parse_url( home_url() );
456 + $path = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
457 + return esc_url( apply_filters( 'self_link', set_url_scheme( 'http://' . $host['host'] . $path ) ) );
429 458 }
430 -
431 - // Re-validate so a misbehaving filter can't return a sentinel string that would collapse all callers into one bucket.
432 - return \is_string( $ip ) && \filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
433 459 }