PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.1
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 9.0.1, at includes/functions.php

419 lines 12.5 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 * Check whether a blog is public based on the `blog_public` option.
107 *
108 * @return bool True if public, false if not
109 */
110 function is_blog_public() {
111 /**
112 * Filter whether the blog is public.
113 *
114 * @param bool $public Whether the blog is public.
115 */
116 return (bool) apply_filters( 'activitypub_is_blog_public', \get_option( 'blog_public', 1 ) );
117 }
118
119 /**
120 * Get the masked WordPress version to only show the major and minor version.
121 *
122 * @return string The masked version.
123 */
124 function get_masked_wp_version() {
125 // Only show the major and minor version.
126 $version = get_bloginfo( 'version' );
127 // Strip the RC or beta part.
128 $version = preg_replace( '/-.*$/', '', $version );
129 $version = explode( '.', $version );
130 $version = array_slice( $version, 0, 2 );
131
132 return implode( '.', $version );
133 }
134
135 /**
136 * Check if a plugin is active, loading plugin.php if necessary.
137 *
138 * This is a wrapper around the core is_plugin_active() function that ensures
139 * the function is available by loading wp-admin/includes/plugin.php if needed.
140 * This is useful when checking plugin status outside of the admin context.
141 *
142 * @param string $plugin Plugin basename (e.g., 'plugin-folder/plugin-file.php').
143 *
144 * @return bool True if the plugin is active, false otherwise.
145 */
146 function is_plugin_active( $plugin ) {
147 // Include plugin.php if not already loaded (needed for core is_plugin_active).
148 if ( ! \function_exists( 'is_plugin_active' ) ) {
149 require_once ABSPATH . 'wp-admin/includes/plugin.php';
150 }
151
152 return \is_plugin_active( $plugin );
153 }
154
155 /**
156 * Returns the website hosts allowed to credit this blog.
157 *
158 * @return array|null The attribution domains or null if not found.
159 */
160 function get_attribution_domains() {
161 if ( '1' !== \get_option( 'activitypub_use_opengraph', '1' ) ) {
162 return null;
163 }
164
165 $domains = \get_option( 'activitypub_attribution_domains', home_host() );
166 $domains = explode( PHP_EOL, $domains );
167
168 if ( ! $domains ) {
169 $domains = null;
170 }
171
172 return $domains;
173 }
174
175 /**
176 * Change the display of large numbers on the site.
177 *
178 * @author Jeremy Herve
179 *
180 * @see https://wordpress.org/support/topic/abbreviate-numbers-with-k/
181 *
182 * @param string $formatted Converted number in string format.
183 * @param float $number The number to convert based on locale.
184 *
185 * @return string Converted number in string format.
186 */
187 function custom_large_numbers( $formatted, $number ) {
188 global $wp_locale;
189
190 $decimals = 0;
191 $decimal_point = '.';
192 $thousands_sep = ',';
193
194 if ( isset( $wp_locale ) ) {
195 $decimals = (int) $wp_locale->number_format['decimal_point'];
196 $decimal_point = $wp_locale->number_format['decimal_point'];
197 $thousands_sep = $wp_locale->number_format['thousands_sep'];
198 }
199
200 if ( $number < 1000 ) { // Any number less than a Thousand.
201 return \number_format( $number, $decimals, $decimal_point, $thousands_sep );
202 } elseif ( $number < 1000000 ) { // Any number less than a million.
203 return \number_format( $number / 1000, $decimals, $decimal_point, $thousands_sep ) . 'K';
204 } elseif ( $number < 1000000000 ) { // Any number less than a billion.
205 return \number_format( $number / 1000000, $decimals, $decimal_point, $thousands_sep ) . 'M';
206 } else { // At least a billion.
207 return \number_format( $number / 1000000000, $decimals, $decimal_point, $thousands_sep ) . 'B';
208 }
209 }
210
211 /**
212 * Escapes a Tag, to be used as a hashtag.
213 *
214 * @param string $input The string to escape.
215 *
216 * @return string The escaped hashtag.
217 */
218 function esc_hashtag( $input ) {
219 $hashtag = \wp_specialchars_decode( $input, ENT_QUOTES );
220 // Remove all characters that are not letters, numbers, or hyphens.
221 $hashtag = \preg_replace( '/[^\p{L}\p{Nd}-]+/u', '-', $hashtag );
222
223 // Capitalize every letter that is preceded by a hyphen.
224 $hashtag = preg_replace_callback(
225 '/-+(.)/',
226 static function ( $matches ) {
227 return strtoupper( $matches[1] );
228 },
229 $hashtag
230 );
231
232 // Add a hashtag to the beginning of the string.
233 $hashtag = ltrim( $hashtag, '#' );
234 $hashtag = trim( $hashtag, '-' );
235 $hashtag = '#' . $hashtag;
236
237 /**
238 * Allow defining your own custom hashtag generation rules.
239 *
240 * @param string $hashtag The hashtag to be returned.
241 * @param string $input The original string.
242 */
243 $hashtag = apply_filters( 'activitypub_esc_hashtag', $hashtag, $input );
244
245 return esc_html( $hashtag );
246 }
247
248 /**
249 * Replace content with links, mentions or hashtags by Regex callback and not affect protected tags.
250 *
251 * @param string $content The content that should be changed.
252 * @param string $regex The regex to use.
253 * @param callable $regex_callback Callback for replacement logic.
254 *
255 * @return string The content with links, mentions, hashtags, etc.
256 */
257 function enrich_content_data( $content, $regex, $regex_callback ) {
258 // Small protection against execution timeouts: limit to 1 MB.
259 if ( mb_strlen( $content ) > MB_IN_BYTES ) {
260 return $content;
261 }
262 $tag_stack = array();
263 $protected_tags = array(
264 'pre',
265 'code',
266 'textarea',
267 'style',
268 'a',
269 );
270 $content_with_links = '';
271 $in_protected_tag = false;
272 foreach ( wp_html_split( $content ) as $chunk ) {
273 if ( preg_match( '#^<!--[\s\S]*-->$#i', $chunk, $m ) ) {
274 $content_with_links .= $chunk;
275 continue;
276 }
277
278 if ( preg_match( '#^<(/)?([a-z-]+)\b[^>]*>$#i', $chunk, $m ) ) {
279 $tag = strtolower( $m[2] );
280 if ( '/' === $m[1] ) {
281 // Closing tag.
282 $i = array_search( $tag, $tag_stack, true );
283 // We can only remove the tag from the stack if it is in the stack.
284 if ( false !== $i ) {
285 $tag_stack = array_slice( $tag_stack, 0, $i );
286 }
287 } else {
288 // Opening tag, add it to the stack.
289 $tag_stack[] = $tag;
290 }
291
292 // If we're in a protected tag, the tag_stack contains at least one protected tag string.
293 // The protected tag state can only change when we encounter a start or end tag.
294 $in_protected_tag = array_intersect( $tag_stack, $protected_tags );
295
296 // Never inspect tags.
297 $content_with_links .= $chunk;
298 continue;
299 }
300
301 if ( $in_protected_tag ) {
302 // Don't inspect a chunk inside an inspected tag.
303 $content_with_links .= $chunk;
304 continue;
305 }
306
307 // Only reachable when there is no protected tag in the stack.
308 $content_with_links .= \preg_replace_callback( $regex, $regex_callback, $chunk );
309 }
310
311 return $content_with_links;
312 }
313
314 /**
315 * Get an ActivityPub embed HTML for a URL.
316 *
317 * @param string $url The URL to get the embed for.
318 * @param boolean $inline_css Whether to inline CSS. Default true.
319 *
320 * @return string|false The embed HTML or false if not found.
321 */
322 function get_embed_html( $url, $inline_css = true ) {
323 return Embed::get_html( $url, $inline_css );
324 }
325
326 /**
327 * Get the client IP address for rate-limiting purposes.
328 *
329 * Walks the ordered list of $_SERVER keys returned by the
330 * `activitypub_client_ip_sources` filter (default: `['REMOTE_ADDR']`) and
331 * returns the first value that parses as a valid IP literal, validated via
332 * `filter_var( ..., FILTER_VALIDATE_IP )`. The result can be overridden
333 * outright via the `activitypub_client_ip` filter; that filter's output is
334 * also validated and replaced with `''` when it isn't a valid IP, so a
335 * misbehaving filter can't collide all callers into the same rate-limit
336 * bucket.
337 *
338 * Trusting any source other than `REMOTE_ADDR` is only safe behind a
339 * reverse proxy that sets and overwrites the corresponding header — see
340 * the `activitypub_client_ip_sources` filter docblock for guidance.
341 *
342 * Callers using the return value as a rate-limit key should treat an
343 * empty return as "client unidentifiable" and fail closed rather than
344 * share a single bucket across every such request.
345 *
346 * @since 8.1.0
347 *
348 * @return string A valid IP address, or '' when no IP could be determined.
349 */
350 function get_client_ip() {
351 // phpcs:disable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
352 $ip = '';
353
354 /**
355 * Filter the ordered list of $_SERVER keys to consult as a source for the
356 * client IP. The first key whose value parses as a valid IP wins.
357 *
358 * Default: array( 'REMOTE_ADDR' ) — the actual TCP peer, the only value
359 * that an HTTP client cannot spoof. Trusting any other $_SERVER key is
360 * only safe when a reverse proxy in front of the site sets that key and
361 * overwrites any client-supplied version; otherwise an attacker can spoof
362 * the value and bypass the per-IP rate limits that depend on it.
363 *
364 * Common operator overrides:
365 * array( 'HTTP_CF_CONNECTING_IP' ) on Cloudflare.
366 * array( 'HTTP_TRUE_CLIENT_IP', 'REMOTE_ADDR' ) Akamai with a fallback.
367 * array( 'HTTP_X_REAL_IP' ) nginx that strips the client copy.
368 *
369 * X-Forwarded-For pitfall: even with a trusted proxy, an attacker can
370 * prepend their own value before the proxy appends the real client IP.
371 * This helper takes the leftmost entry, which is correct only when the
372 * trusted proxy fully overwrites the header. If you trust X-Forwarded-For
373 * end-to-end, prefer to resolve from the right by your known proxy count
374 * via the activitypub_client_ip filter.
375 *
376 * @since 8.2.0
377 *
378 * @param string[] $sources $_SERVER keys to consult, in priority order.
379 */
380 $sources = \apply_filters( 'activitypub_client_ip_sources', array( 'REMOTE_ADDR' ) );
381
382 if ( ! \is_array( $sources ) ) {
383 $sources = array( 'REMOTE_ADDR' );
384 }
385
386 foreach ( $sources as $source ) {
387 if ( ! \is_string( $source ) || empty( $_SERVER[ $source ] ) ) {
388 continue;
389 }
390
391 // Some headers (e.g. X-Forwarded-For) may contain a comma-separated list; use the first IP.
392 $ip_list = \sanitize_text_field( \wp_unslash( $_SERVER[ $source ] ) );
393 $candidate = \trim( \explode( ',', $ip_list )[0] );
394
395 if ( \filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
396 $ip = $candidate;
397 break;
398 }
399 }
400 // phpcs:enable WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders
401
402 /**
403 * Filter the client IP address used for rate limiting.
404 *
405 * @since 8.1.0
406 *
407 * @param string $ip The detected client IP address (empty when none could be determined).
408 */
409 $ip = \apply_filters( 'activitypub_client_ip', $ip );
410
411 // Tolerate surrounding whitespace from filter callbacks; FILTER_VALIDATE_IP would otherwise reject it.
412 if ( \is_string( $ip ) ) {
413 $ip = \trim( $ip );
414 }
415
416 // Re-validate so a misbehaving filter can't return a sentinel string that would collapse all callers into one bucket.
417 return \is_string( $ip ) && \filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
418 }
419