PluginProbe
ActivityPub / 0.3.2
ActivityPub v0.3.2
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 +153 -367 8.2.10.3.2 View file →
@@ -1,433 +1,219 @@
1 1 <?php
2 2 /**
3 - * Functions file.
3 + * Returns the ActivityPub default JSON-context
4 4 *
5 - * General utility functions for the ActivityPub plugin.
6 - *
7 - * @package Activitypub
5 + * @return array the activitypub context
8 6 */
7 +function get_activitypub_context() {
8 + $context = array(
9 + 'https://www.w3.org/ns/activitystreams',
10 + 'https://w3id.org/security/v1',
11 + array(
12 + 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
13 + 'sensitive' => 'as:sensitive',
14 + 'movedTo' => array(
15 + '@id' => 'as:movedTo',
16 + '@type' => '@id',
17 + ),
18 + 'Hashtag' => 'as:Hashtag',
19 + 'ostatus' => 'http://ostatus.org#',
20 + 'atomUri' => 'ostatus:atomUri',
21 + 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri',
22 + 'conversation' => 'ostatus:conversation',
23 + 'toot' => 'http://joinmastodon.org/ns#',
24 + 'Emoji' => 'toot:Emoji',
25 + 'focalPoint' => array(
26 + '@container' => '@list',
27 + '@id' => 'toot:focalPoint',
28 + ),
29 + 'featured' => array(
30 + '@id' => 'toot:featured',
31 + '@type' => '@id',
32 + ),
33 + 'schema' => 'http://schema.org#',
34 + 'PropertyValue' => 'schema:PropertyValue',
35 + 'value' => 'schema:value',
36 + ),
37 + );
9 38
10 -namespace Activitypub;
39 + return apply_filters( 'activitypub_json_context', $context );
40 +}
11 41
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 - }
42 +function activitypub_safe_remote_post( $url, $body, $user_id ) {
43 + $date = gmdate( 'D, d M Y H:i:s T' );
44 + $signature = Activitypub_Signature::generate_signature( $user_id, $url, $date );
25 45
26 - if ( $wp_object instanceof \WP_Comment ) {
27 - return get_comment_id( $wp_object );
28 - }
46 + $wp_version = get_bloginfo( 'version' );
47 + $user_agent = apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . get_bloginfo( 'url' ) );
48 + $args = array(
49 + 'timeout' => 100,
50 + 'limit_response_size' => 1048576,
51 + 'redirection' => 3,
52 + 'user-agent' => "$user_agent; ActivityPub",
53 + 'headers' => array(
54 + 'Accept' => 'application/activity+json',
55 + 'Content-Type' => 'application/activity+json',
56 + 'Signature' => $signature,
57 + 'Date' => $date,
58 + ),
59 + 'body' => $body,
60 + );
29 61
30 - return null;
62 + return wp_safe_remote_post( $url, $args );
31 63 }
32 64
33 65 /**
34 - * Convert a string from camelCase to snake_case.
66 + * Returns a users WebFinger "resource"
35 67 *
36 - * @param string $input The string to convert.
68 + * @param int $user_id
37 69 *
38 - * @return string The converted string.
70 + * @return string The user-resource
39 71 */
40 -function camel_to_snake_case( $input ) {
41 - return strtolower( preg_replace( '/(?<!^)[A-Z]/', '_$0', $input ) );
42 -}
72 +function activitypub_get_webfinger_resource( $user_id ) {
73 + // use WebFinger plugin if installed
74 + if ( function_exists( 'get_webfinger_resource' ) ) {
75 + return get_webfinger_resource( $user_id, false );
76 + }
43 77
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, '_' ) ) );
78 + $user = get_user_by( 'id', $user_id );
79 +
80 + return $user->user_login . '@' . wp_parse_url( home_url(), PHP_URL_HOST );
53 81 }
54 82
55 83 /**
56 - * Convert seconds to ISO 8601 duration format.
84 + * [get_metadata_by_actor description]
57 85 *
58 - * @param int $seconds The duration in seconds.
59 - *
60 - * @return string The duration in ISO 8601 format (e.g., "PT1H23M45S").
86 + * @param [type] $actor [description]
87 + * @return [type] [description]
61 88 */
62 -function seconds_to_iso8601( $seconds ) {
63 - $seconds = (int) $seconds;
89 +function activitypub_get_remote_metadata_by_actor( $actor ) {
90 + $metadata = get_transient( 'activitypub_' . $actor );
64 91
65 - if ( $seconds <= 0 ) {
66 - return 'PT0S';
92 + if ( $metadata ) {
93 + return $metadata;
67 94 }
68 95
69 - $hours = floor( $seconds / 3600 );
70 - $minutes = floor( ( $seconds % 3600 ) / 60 );
71 - $secs = $seconds % 60;
96 + if ( ! wp_http_validate_url( $actor ) ) {
97 + return new WP_Error( 'activitypub_no_valid_actor_url', __( 'The "actor" is no valid URL', 'activitypub' ), $actor );
98 + }
72 99
73 - $duration = 'PT';
100 + $wp_version = get_bloginfo( 'version' );
74 101
75 - if ( $hours > 0 ) {
76 - $duration .= $hours . 'H';
77 - }
102 + $user_agent = apply_filters( 'http_headers_useragent', 'WordPress/' . $wp_version . '; ' . get_bloginfo( 'url' ) );
103 + $args = array(
104 + 'timeout' => 100,
105 + 'limit_response_size' => 1048576,
106 + 'redirection' => 3,
107 + 'user-agent' => "$user_agent; ActivityPub",
108 + 'headers' => array( 'accept' => 'application/activity+json' ),
109 + );
78 110
79 - if ( $minutes > 0 ) {
80 - $duration .= $minutes . 'M';
81 - }
111 + $response = wp_safe_remote_get( $actor, $args );
82 112
83 - if ( $secs > 0 || ( 0 === $hours && 0 === $minutes ) ) {
84 - $duration .= $secs . 'S';
113 + if ( is_wp_error( $response ) ) {
114 + return $response;
85 115 }
86 116
87 - return $duration;
88 -}
117 + $metadata = wp_remote_retrieve_body( $response );
118 + $metadata = json_decode( $metadata, true );
89 119
90 -/**
91 - * Check if a site supports the block editor.
92 - *
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 -}
120 + if ( ! $metadata ) {
121 + return new WP_Error( 'activitypub_invalid_json', __( 'No valid JSON data', 'activitypub' ), $actor );
122 + }
104 123
105 -/**
106 - * Check if data is valid JSON.
107 - *
108 - * @deprecated 7.1.0 Use {@see \json_decode}.
109 - *
110 - * @param string $data The data to check.
111 - *
112 - * @return boolean True if the data is JSON, false otherwise.
113 - */
114 -function is_json( $data ) {
115 - \_deprecated_function( __FUNCTION__, '7.1.0', 'json_decode' );
124 + set_transient( 'activitypub_' . $actor, $metadata, WEEK_IN_SECONDS );
116 125
117 - return \is_array( \json_decode( $data, true ) );
126 + return $metadata;
118 127 }
119 128
120 129 /**
121 - * Check whether a blog is public based on the `blog_public` option.
122 - *
123 - * @return bool True if public, false if not
130 + * [get_inbox_by_actor description]
131 + * @param [type] $actor [description]
132 + * @return [type] [description]
124 133 */
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 -}
134 +function activitypub_get_inbox_by_actor( $actor ) {
135 + $metadata = activitypub_get_remote_metadata_by_actor( $actor );
133 136
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 );
146 -
147 - return implode( '.', $version );
148 -}
149 -
150 -/**
151 - * Check if a plugin is active, loading plugin.php if necessary.
152 - *
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.
156 - *
157 - * @param string $plugin Plugin basename (e.g., 'plugin-folder/plugin-file.php').
158 - *
159 - * @return bool True if the plugin is active, false otherwise.
160 - */
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';
137 + if ( is_wp_error( $metadata ) ) {
138 + return $metadata;
165 139 }
166 140
167 - return \is_plugin_active( $plugin );
168 -}
169 -
170 -/**
171 - * Returns the website hosts allowed to credit this blog.
172 - *
173 - * @return array|null The attribution domains or null if not found.
174 - */
175 -function get_attribution_domains() {
176 - if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
177 - return null;
141 + if ( isset( $metadata['endpoints'] ) && isset( $metadata['endpoints']['sharedInbox'] ) ) {
142 + return $metadata['endpoints']['sharedInbox'];
178 143 }
179 144
180 - $domains = \get_option( 'activitypub_attribution_domains', home_host() );
181 - $domains = explode( PHP_EOL, $domains );
182 -
183 - if ( ! $domains ) {
184 - $domains = null;
145 + if ( array_key_exists( 'inbox', $metadata ) ) {
146 + return $metadata['inbox'];
185 147 }
186 148
187 - return $domains;
149 + return new WP_Error( 'activitypub_no_inbox', __( 'No "Inbox" found', 'activitypub' ), $metadata );
188 150 }
189 151
190 152 /**
191 - * Change the display of large numbers on the site.
192 - *
193 - * @author Jeremy Herve
194 - *
195 - * @see https://wordpress.org/support/topic/abbreviate-numbers-with-k/
196 - *
197 - * @param string $formatted Converted number in string format.
198 - * @param float $number The number to convert based on locale.
199 - *
200 - * @return string Converted number in string format.
153 + * [get_inbox_by_actor description]
154 + * @param [type] $actor [description]
155 + * @return [type] [description]
201 156 */
202 -function custom_large_numbers( $formatted, $number ) {
203 - global $wp_locale;
157 +function activitypub_get_publickey_by_actor( $actor, $key_id ) {
158 + $metadata = activitypub_get_remote_metadata_by_actor( $actor );
204 159
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'];
160 + if ( is_wp_error( $metadata ) ) {
161 + return $metadata;
213 162 }
214 163
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';
164 + if (
165 + isset( $metadata['publicKey'] ) &&
166 + isset( $metadata['publicKey']['id'] ) &&
167 + isset( $metadata['publicKey']['owner'] ) &&
168 + isset( $metadata['publicKey']['publicKeyPem'] ) &&
169 + $key_id === $metadata['publicKey']['id'] &&
170 + $actor === $metadata['publicKey']['owner']
171 + ) {
172 + return $metadata['publicKey']['publicKeyPem'];
223 173 }
224 -}
225 174
226 -/**
227 - * Escapes a Tag, to be used as a hashtag.
228 - *
229 - * @param string $input The string to escape.
230 - *
231 - * @return string The escaped hashtag.
232 - */
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 );
237 -
238 - // Capitalize every letter that is preceded by a hyphen.
239 - $hashtag = preg_replace_callback(
240 - '/-+(.)/',
241 - static function ( $matches ) {
242 - return strtoupper( $matches[1] );
243 - },
244 - $hashtag
245 - );
246 -
247 - // Add a hashtag to the beginning of the string.
248 - $hashtag = ltrim( $hashtag, '#' );
249 - $hashtag = trim( $hashtag, '-' );
250 - $hashtag = '#' . $hashtag;
251 -
252 - /**
253 - * Allow defining your own custom hashtag generation rules.
254 - *
255 - * @param string $hashtag The hashtag to be returned.
256 - * @param string $input The original string.
257 - */
258 - $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
259 -
260 - return esc_html( $hashtag );
175 + return new WP_Error( 'activitypub_no_public_key', __( 'No "Public-Key" found', 'activitypub' ), $metadata );
261 176 }
262 177
263 -/**
264 - * Replace content with links, mentions or hashtags by Regex callback and not affect protected tags.
265 - *
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.
271 - */
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;
178 +function activitypub_get_follower_inboxes( $user_id, $followers ) {
179 + $inboxes = array();
180 + foreach ( $followers as $follower ) {
181 + $inboxes[] = activitypub_get_inbox_by_actor( $follower );
276 182 }
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 183
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 - }
306 -
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 );
310 -
311 - // Never inspect tags.
312 - $content_with_links .= $chunk;
313 - continue;
314 - }
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 - }
325 -
326 - return $content_with_links;
184 + return array_unique( $inboxes );
327 185 }
328 186
329 -/**
330 - * Get an ActivityPub embed HTML for a URL.
331 - *
332 - * @param string $url The URL to get the embed for.
333 - * @param boolean $inline_css Whether to inline CSS. Default true.
334 - *
335 - * @return string|false The embed HTML or false if not found.
336 - */
337 -function get_embed_html( $url, $inline_css = true ) {
338 - return Embed::get_html( $url, $inline_css );
187 +function activitypub_get_identifier_settings( $user_id ) {
188 + ?>
189 +<table class="form-table">
190 + <tbody>
191 + <tr>
192 + <th scope="row">
193 + <label><?php esc_html_e( 'Profile identifier', 'activitypub' ); ?></label>
194 + </th>
195 + <td>
196 + <p><code><?php echo activitypub_get_webfinger_resource( $user_id ); ?></code> or <code><?php echo get_author_posts_url( $user_id ); ?></code></p>
197 + <p class="description"><?php printf( __( 'Try to follow "@%s" in the mastodon/friendi.ca search field.', 'activitypub' ), activitypub_get_webfinger_resource( $user_id ) ); ?></p>
198 + </td>
199 + </tr>
200 + </tbody>
201 +</table>
202 + <?php
339 203 }
340 204
341 -/**
342 - * Get the client IP address for rate-limiting purposes.
343 - *
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.
352 - *
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.
356 - *
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.
364 - */
365 -function get_client_ip() {
366 - // phpcs:disable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
367 - $ip = '';
205 +function activitypub_get_followers( $user_id ) {
206 + $followers = Db_Activitypub_Followers::get_followers( $user_id );
368 207
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' ) );
396 -
397 - if ( ! \is_array( $sources ) ) {
398 - $sources = array( 'REMOTE_ADDR' );
208 + if ( ! $followers ) {
209 + return array();
399 210 }
400 211
401 - foreach ( $sources as $source ) {
402 - if ( ! \is_string( $source ) || empty( $_SERVER[ $source ] ) ) {
403 - continue;
404 - }
212 + return $followers;
213 +}
405 214
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] );
215 +function activitypub_count_followers( $user_id ) {
216 + $followers = activitypub_get_followers( $user_id );
409 217
410 - if ( \filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
411 - $ip = $candidate;
412 - break;
413 - }
414 - }
415 - // phpcs:enable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
416 -
417 - /**
418 - * Filter the client IP address used for rate limiting.
419 - *
420 - * @since 8.1.0
421 - *
422 - * @param string $ip The detected client IP address (empty when none could be determined).
423 - */
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 );
429 - }
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 : '';
218 + return count( $followers );
433 219 }