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 +322 -320 9.2.21.0.0 View file →
@@ -1,277 +1,262 @@
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 - * Get the icon Image object for site-wide ActivityPub actors.
118 + * Count the number of followers for a given user.
107 119 *
108 - * Tries the site icon first, then the custom logo, and falls back to the
109 - * bundled WordPress logo.
120 + * @param int $user_id The User-ID.
110 121 *
111 - * @since 9.1.0
122 + * @return int The number of followers.
123 + */
124 +function count_followers( $user_id ) {
125 + return Followers::count_followers( $user_id );
126 +}
127 +
128 +/**
129 + * Examine a url and try to determine the author ID it represents.
112 130 *
113 - * @return array The icon array with 'type' and 'url'.
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.
114 136 */
115 -function site_icon() {
116 - // Try site icon first.
117 - $icon_id = \get_option( 'site_icon' );
137 +function url_to_authorid( $url ) {
138 + global $wp_rewrite;
118 139
119 - // Try custom logo second.
120 - if ( ! $icon_id ) {
121 - $icon_id = \get_theme_mod( 'custom_logo' );
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;
122 143 }
123 144
124 - $icon_url = false;
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 + }
125 152
126 - if ( $icon_id ) {
127 - $icon = \wp_get_attachment_image_src( $icon_id, 'full' );
128 - if ( $icon ) {
129 - $icon_url = $icon[0];
130 - }
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;
131 159 }
132 160
133 - if ( ! $icon_url ) {
134 - // Fallback to default icon.
135 - $icon_url = \plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE );
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 + }
136 171 }
137 172
138 - return array(
139 - 'type' => 'Image',
140 - 'url' => \esc_url_raw( $icon_url ),
141 - );
173 + return 0;
142 174 }
143 175
144 176 /**
145 - * Check whether a blog is public based on the `blog_public` option.
177 + * Check for Tombstone Objects
146 178 *
147 - * @return bool True if public, false if not
179 + * @see https://www.w3.org/TR/activitypub/#delete-activity-outbox
180 + *
181 + * @param WP_Error $wp_error A WP_Error-Response of an HTTP-Request
182 + *
183 + * @return boolean true if HTTP-Code is 410 or 404
148 184 */
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 -}
185 +function is_tombstone( $wp_error ) {
186 + if ( ! is_wp_error( $wp_error ) ) {
187 + return false;
188 + }
157 189
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 );
190 + if ( in_array( (int) $wp_error->get_error_code(), array( 404, 410 ), true ) ) {
191 + return true;
192 + }
170 193
171 - return \implode( '.', $version );
194 + return false;
172 195 }
173 196
174 197 /**
175 - * Check if a plugin is active, loading plugin.php if necessary.
198 + * Get the REST URL relative to this plugin's namespace.
176 199 *
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.
200 + * @param string $path Optional. REST route path. Otherwise this plugin's namespaced root.
180 201 *
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.
202 + * @return string REST URL relative to this plugin's namespace.
184 203 */
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';
189 - }
190 -
191 - return \is_plugin_active( $plugin );
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 );
192 209 }
193 210
194 211 /**
195 - * Returns the website hosts allowed to credit this blog.
212 + * Convert a string from camelCase to snake_case.
196 213 *
197 - * @return array|null The attribution domains or null if not found.
214 + * @param string $string The string to convert.
215 + *
216 + * @return string The converted string.
198 217 */
199 -function get_attribution_domains() {
200 - if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
201 - return null;
202 - }
203 -
204 - $domains = \get_option( 'activitypub_attribution_domains', home_host() );
205 - $domains = \explode( PHP_EOL, $domains );
206 -
207 - if ( ! $domains ) {
208 - $domains = null;
209 - }
210 -
211 - return $domains;
218 +// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
219 +function camel_to_snake_case( $string ) {
220 + return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $string ) );
212 221 }
213 222
214 223 /**
215 - * Change the display of large numbers on the site.
224 + * Convert a string from snake_case to camelCase.
216 225 *
217 - * @author Jeremy Herve
226 + * @param string $string The string to convert.
218 227 *
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.
228 + * @return string The converted string.
225 229 */
226 -function custom_large_numbers( $formatted, $number ) {
227 - global $wp_locale;
228 -
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'];
237 - }
238 -
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';
247 - }
230 +// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound
231 +function snake_to_camel_case( $string ) {
232 + return lcfirst( str_replace( '_', '', ucwords( $string, '_' ) ) );
248 233 }
249 234
250 235 /**
251 236 * Escapes a Tag, to be used as a hashtag.
252 237 *
253 - * @param string $input The string to escape.
238 + * @param string $string The string to escape.
254 239 *
255 - * @return string The escaped hashtag.
240 + * @return string The escaped hastag.
256 241 */
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 );
242 +function esc_hashtag( $string ) {
261 243
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] );
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.
249 + $hashtag = preg_replace_callback(
250 + '/_(.)/',
251 + function ( $matches ) {
252 + return '' . strtoupper( $matches[1] );
267 253 },
268 254 $hashtag
269 255 );
270 256
271 257 // Add a hashtag to the beginning of the string.
272 - $hashtag = \ltrim( $hashtag, '#' );
273 - $hashtag = \trim( $hashtag, '-' );
258 + $hashtag = ltrim( $hashtag, '#' );
274 259 $hashtag = '#' . $hashtag;
275 260
276 261 /**
277 262 * Allow defining your own custom hashtag generation rules.
@@ -276,182 +261,199 @@
276 261 /**
277 262 * Allow defining your own custom hashtag generation rules.
278 263 *
279 264 * @param string $hashtag The hashtag to be returned.
280 - * @param string $input The original string.
265 + * @param string $string The original string.
281 266 */
282 - $hashtag = \apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
267 + $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $string );
283 268
284 - return \esc_html( $hashtag );
269 + return esc_html( $hashtag );
285 270 }
286 271
287 272 /**
288 - * 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.
289 274 *
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.
275 + * @return bool False by default.
295 276 */
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;
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;
300 286 }
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 - }
316 287
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 - }
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 + }
330 294
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 );
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'] ) );
334 301
335 - // Never inspect tags.
336 - $content_with_links .= $chunk;
337 - 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;
338 312 }
339 -
340 - if ( $in_protected_tag ) {
341 - // Don't inspect a chunk inside an inspected tag.
342 - $content_with_links .= $chunk;
343 - continue;
344 - }
345 -
346 - // Only reachable when there is no protected tag in the stack.
347 - $content_with_links .= \preg_replace_callback( $regex, $regex_callback, $chunk );
348 313 }
349 314
350 - return $content_with_links;
315 + return false;
351 316 }
352 317
353 318 /**
354 - * Get an ActivityPub embed HTML for a URL.
319 + * This function checks if a user is disabled for ActivityPub.
355 320 *
356 - * @param string $url The URL to get the embed for.
357 - * @param boolean $inline_css Whether to inline CSS. Default true.
321 + * @param int $user_id The User-ID.
358 322 *
359 - * @return string|false The embed HTML or false if not found.
323 + * @return boolean True if the user is disabled, false otherwise.
360 324 */
361 -function get_embed_html( $url, $inline_css = true ) {
362 - 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 );
363 364 }
364 365
365 366 /**
366 - * Get the client IP address for rate-limiting purposes.
367 + * Checks if a User-Type is disabled for ActivityPub.
367 368 *
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.
369 + * This function is used to check if the 'blog' or 'user'
370 + * type is disabled for ActivityPub.
376 371 *
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.
372 + * @param enum $type Can be 'blog' or 'user'.
380 373 *
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.
374 + * @return boolean True if the user type is disabled, false otherwise.
388 375 */
389 -function get_client_ip() {
390 - // phpcs:disable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
391 - $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 + }
392 385
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' ) );
386 + if ( \defined( 'ACTIVITYPUB_DISABLE_BLOG_USER' ) ) {
387 + $return = ACTIVITYPUB_DISABLE_BLOG_USER;
388 + break;
389 + }
420 390
421 - if ( ! \is_array( $sources ) ) {
422 - $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;
423 421 }
424 422
425 - foreach ( $sources as $source ) {
426 - if ( ! \is_string( $source ) || empty( $_SERVER[ $source ] ) ) {
427 - continue;
428 - }
423 + return apply_filters( 'activitypub_is_user_type_disabled', $return, $type );
424 +}
429 425
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] );
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;
433 433
434 - if ( \filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
435 - $ip = $candidate;
436 - break;
434 + if ( \defined( 'ACTIVITYPUB_SINGLE_USER_MODE' ) ) {
435 + if ( ACTIVITYPUB_SINGLE_USER_MODE ) {
436 + $return = true;
437 437 }
438 + } elseif (
439 + false === is_user_type_disabled( 'blog' ) &&
440 + true === is_user_type_disabled( 'user' )
441 + ) {
442 + $return = true;
438 443 }
439 - // phpcs:enable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
440 444
445 + return $return;
446 +}
447 +
448 +if ( ! function_exists( 'get_self_link' ) ) {
441 449 /**
442 - * Filter the client IP address used for rate limiting.
450 + * Returns the link for the currently displayed feed.
443 451 *
444 - * @since 8.1.0
445 - *
446 - * @param string $ip The detected client IP address (empty when none could be determined).
452 + * @return string Correct link for the atom:self element.
447 453 */
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 );
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 ) ) );
453 458 }
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 : '';
457 459 }