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

functions.php in ActivityPub trunk, at includes/functions.php

458 lines 13.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Functions file.
4 *
5 * General utility functions for the ActivityPub plugin.
6 *
7 * @package Activitypub
8 */
9
10 namespace Activitypub;
11
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 }
25
26 if ( $wp_object instanceof \WP_Comment ) {
27 return get_comment_id( $wp_object );
28 }
29
30 return null;
31 }
32
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 }
43
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, '_' ) ) );
53 }
54
55 /**
56 * Convert seconds to ISO 8601 duration format.
57 *
58 * @param int $seconds The duration in seconds.
59 *
60 * @return string The duration in ISO 8601 format (e.g., "PT1H23M45S").
61 */
62 function seconds_to_iso8601( $seconds ) {
63 $seconds = (int) $seconds;
64
65 if ( $seconds <= 0 ) {
66 return 'PT0S';
67 }
68
69 $hours = \floor( $seconds / 3600 );
70 $minutes = \floor( ( $seconds % 3600 ) / 60 );
71 $secs = $seconds % 60;
72
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;
88 }
89
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 }
104
105 /**
106 * Get the icon Image object for site-wide ActivityPub actors.
107 *
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'.
114 */
115 function site_icon() {
116 // Try site icon first.
117 $icon_id = \get_option( 'site_icon' );
118
119 // Try custom logo second.
120 if ( ! $icon_id ) {
121 $icon_id = \get_theme_mod( 'custom_logo' );
122 }
123
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 }
131 }
132
133 if ( ! $icon_url ) {
134 // Fallback to default icon.
135 $icon_url = \plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE );
136 }
137
138 return array(
139 'type' => 'Image',
140 'url' => \esc_url_raw( $icon_url ),
141 );
142 }
143
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 }
157
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 );
170
171 return \implode( '.', $version );
172 }
173
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';
189 }
190
191 return \is_plugin_active( $plugin );
192 }
193
194 /**
195 * Returns the website hosts allowed to credit this blog.
196 *
197 * @return array|null The attribution domains or null if not found.
198 */
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;
212 }
213
214 /**
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.
225 */
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 }
248 }
249
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 );
285 }
286
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 }
316
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;
337 continue;
338 }
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 }
349
350 return $content_with_links;
351 }
352
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 );
363 }
364
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 = '';
392
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' );
423 }
424
425 foreach ( $sources as $source ) {
426 if ( ! \is_string( $source ) || empty( $_SERVER[ $source ] ) ) {
427 continue;
428 }
429
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] );
433
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 : '';
457 }
458