PluginProbe
ActivityPub / 9.0.2
ActivityPub v9.0.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
activitypub / includes / collection / class-remote-posts.php

class-remote-posts.php in ActivityPub 9.0.2, at includes/collection/class-remote-posts.php

652 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Remote Posts collection file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Collection;
9
10 use Activitypub\Emoji;
11 use Activitypub\Sanitize;
12
13 use function Activitypub\generate_post_summary;
14 use function Activitypub\object_to_uri;
15 use function Activitypub\process_remote_media;
16
17 /**
18 * Remote Posts collection.
19 *
20 * Provides methods to retrieve, create, update, and manage remote
21 * ActivityPub posts (articles, notes, media, etc.) received via
22 * Server-to-Server (S2S) federation.
23 *
24 * @see Posts for local posts created via Client-to-Server (C2S) outbox.
25 */
26 class Remote_Posts {
27 /**
28 * The post type for the posts.
29 *
30 * @var string
31 */
32 const POST_TYPE = 'ap_post';
33
34 /**
35 * Maximum number of remote post items to keep.
36 *
37 * @var int
38 */
39 const MAX_ITEMS = 5000;
40
41 /**
42 * Number of items to process per batch during purge.
43 *
44 * @var int
45 */
46 const PURGE_BATCH_SIZE = 100;
47
48 /**
49 * Maximum seconds a purge run may take before yielding.
50 *
51 * @var int
52 */
53 const PURGE_TIMEOUT = 30;
54
55 /**
56 * Add an object to the collection.
57 *
58 * @param array $activity The activity object data.
59 * @param int|int[] $recipients The id(s) of the local blog-user(s).
60 *
61 * @return \WP_Post|\WP_Error The object post or WP_Error on failure.
62 */
63 public static function add( $activity, $recipients ) {
64 $recipients = (array) $recipients;
65 $activity_object = $activity['object'];
66
67 $existing = self::get_by_guid( $activity_object['id'] );
68 // If post exists, call update instead.
69 if ( ! \is_wp_error( $existing ) ) {
70 return self::update( $activity, $recipients );
71 }
72
73 // An actor may only create posts attributed to itself; only the actor is signature-bound, not attributedTo.
74 if ( object_to_uri( $activity['actor'] ?? '' ) !== object_to_uri( $activity_object['attributedTo'] ?? '' ) ) {
75 return new \WP_Error(
76 'activitypub_create_unauthorized',
77 \__( 'The Create actor does not match the object attributedTo.', 'activitypub' ),
78 array( 'status' => 403 )
79 );
80 }
81
82 // Post doesn't exist, create new post.
83 $actor = Remote_Actors::fetch_by_uri( object_to_uri( $activity_object['attributedTo'] ) );
84
85 if ( \is_wp_error( $actor ) ) {
86 return $actor;
87 }
88
89 $post_array = self::activity_to_post( $activity_object );
90 $post_id = \wp_insert_post( $post_array, true );
91
92 if ( \is_wp_error( $post_id ) ) {
93 return $post_id;
94 }
95
96 \add_post_meta( $post_id, '_activitypub_remote_actor_id', $actor->ID );
97
98 // Add recipients as separate meta entries after post is created.
99 foreach ( $recipients as $user_id ) {
100 self::add_recipient( $post_id, $user_id );
101 }
102
103 self::add_taxonomies( $post_id, $activity_object );
104
105 return \get_post( $post_id );
106 }
107
108 /**
109 * Get an object from the collection.
110 *
111 * @param int $id The object ID.
112 *
113 * @return \WP_Post|null The post object or null on failure.
114 */
115 public static function get( $id ) {
116 return \get_post( $id );
117 }
118
119 /**
120 * Get an object by its GUID.
121 *
122 * @param string $guid The object GUID.
123 *
124 * @return \WP_Post|\WP_Error The object post or WP_Error on failure.
125 */
126 public static function get_by_guid( $guid ) {
127 global $wpdb;
128 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
129 $post_id = $wpdb->get_var(
130 $wpdb->prepare(
131 "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
132 \esc_url( $guid ),
133 self::POST_TYPE
134 )
135 );
136
137 if ( ! $post_id ) {
138 return new \WP_Error(
139 'activitypub_post_not_found',
140 \__( 'Post not found', 'activitypub' ),
141 array( 'status' => 404 )
142 );
143 }
144
145 return \get_post( $post_id );
146 }
147
148 /**
149 * Update an object in the collection.
150 *
151 * @param array $activity The activity object data.
152 * @param int|int[] $recipients The id(s) of the local blog-user(s).
153 *
154 * @return \WP_Post|\WP_Error The updated object post or WP_Error on failure.
155 */
156 public static function update( $activity, $recipients ) {
157 $recipients = (array) $recipients;
158
159 $post = self::get_by_guid( $activity['object']['id'] );
160 if ( \is_wp_error( $post ) ) {
161 return $post;
162 }
163
164 /*
165 * Only the post's author may update it. When the activity carries an actor (every
166 * signature-verified inbound activity does), compare it against the remote actor
167 * stored when the post was first cached (its guid is the actor URI), so a remote
168 * server cannot overwrite another host's cached post by sending an Update whose
169 * object.id points at a post it does not own. The actor must be used here, not the
170 * payload's attributedTo, because only the actor is bound to the HTTP signature.
171 */
172 if ( isset( $activity['actor'] ) ) {
173 $owner = \get_post( (int) \get_post_meta( $post->ID, '_activitypub_remote_actor_id', true ) );
174 if ( ! $owner instanceof \WP_Post || object_to_uri( $activity['actor'] ) !== $owner->guid ) {
175 return new \WP_Error(
176 'activitypub_update_forbidden',
177 \__( 'Update failed: the actor does not own this post.', 'activitypub' ),
178 array( 'status' => 403 )
179 );
180 }
181 }
182
183 $post_array = self::activity_to_post( $activity['object'] );
184 $post_array['ID'] = $post->ID;
185 $post_id = \wp_update_post( $post_array, true );
186
187 if ( \is_wp_error( $post_id ) ) {
188 return $post_id;
189 }
190
191 // Add new recipients using add_recipient (handles deduplication).
192 foreach ( $recipients as $user_id ) {
193 self::add_recipient( $post_id, $user_id );
194 }
195
196 self::add_taxonomies( $post_id, $activity['object'] );
197
198 return \get_post( $post_id );
199 }
200
201 /**
202 * Delete an object from the collection.
203 *
204 * @param int $id The object ID.
205 *
206 * @return \WP_Post|false|null Post data on success, false or null on failure.
207 */
208 public static function delete( $id ) {
209 return \wp_delete_post( $id, true );
210 }
211
212 /**
213 * Delete an object from the collection by its GUID.
214 *
215 * @param string $guid The object GUID.
216 *
217 * @return \WP_Post|\WP_Error|false|null Post data on success, false or null on failure, or WP_Error if no post to delete.
218 */
219 public static function delete_by_guid( $guid ) {
220 $post = self::get_by_guid( $guid );
221 if ( \is_wp_error( $post ) ) {
222 return $post;
223 }
224
225 return self::delete( $post->ID );
226 }
227
228 /**
229 * Extract hashtag names from ActivityPub tag array.
230 *
231 * @param array $tags Array of ActivityPub tags.
232 *
233 * @return array Array of normalized hashtag names (without # prefix, trimmed, sanitized).
234 */
235 public static function extract_hashtags( $tags ) {
236 $hashtags = array();
237
238 if ( empty( $tags ) || ! \is_array( $tags ) ) {
239 return $hashtags;
240 }
241
242 foreach ( $tags as $tag ) {
243 if ( isset( $tag['type'] ) && 'Hashtag' === $tag['type'] && isset( $tag['name'] ) ) {
244 // Strip # prefix, trim whitespace, and sanitize.
245 $normalized = \trim( \ltrim( $tag['name'], '#' ) );
246 $normalized = \wp_strip_all_tags( $normalized );
247
248 if ( ! empty( $normalized ) ) {
249 $hashtags[] = $normalized;
250 }
251 }
252 }
253
254 return $hashtags;
255 }
256
257 /**
258 * Remove hashtags from content.
259 *
260 * Removes hashtags that appear at the end of the content.
261 * Handles both plain text and HTML content, including hashtags within anchor tags.
262 *
263 * @param string $content The content to process.
264 * @param array $tags Array of tag objects from activity (with 'type' and 'name' keys).
265 *
266 * @return string The content with trailing hashtags removed.
267 */
268 public static function remove_hashtags( $content, $tags ) {
269 if ( empty( $content ) || empty( $tags ) || ! \is_array( $tags ) ) {
270 return $content;
271 }
272
273 // Extract and normalize hashtags from tag objects.
274 $normalized_tags = self::extract_hashtags( $tags );
275
276 if ( empty( $normalized_tags ) ) {
277 return $content;
278 }
279
280 // Build pattern to match trailing hashtags (at end of content or before closing tags).
281 $tag_patterns = array();
282 foreach ( $normalized_tags as $tag ) {
283 $escaped_tag = \preg_quote( $tag, '/' );
284 $tag_patterns[] = '(?:<a[^>]*>\s*)?#' . $escaped_tag . '(?=\s|<|$)(?:\s*<\/a>)?';
285 }
286
287 /*
288 * Pattern explanation:
289 * Match one or more hashtags (plain or in anchor tags) at the end of content.
290 * The pattern matches trailing hashtags before closing HTML tags or at end of string.
291 */
292 $pattern = '/(?:\s+(?:' . \implode( '|', $tag_patterns ) . '))+(?=\s*(?:<\/[^>]+>)*\s*$)/i';
293 $content = \preg_replace( $pattern, '', $content );
294
295 // Clean up any extra whitespace at end of paragraphs.
296 $content = \preg_replace( '/<p>\s*<\/p>/', '', $content );
297 $content = \preg_replace( '/\s+<\/p>/', '</p>', $content );
298 $content = \preg_replace( '/\s+<\/strong>/', '</strong>', $content );
299
300 return \trim( $content );
301 }
302
303 /**
304 * Convert an activity to a post array.
305 *
306 * @param array $activity The activity array.
307 *
308 * @return array|\WP_Error The post array or WP_Error on failure.
309 */
310 private static function activity_to_post( $activity ) {
311 if ( ! \is_array( $activity ) ) {
312 return new \WP_Error( 'invalid_activity', \__( 'Invalid activity format', 'activitypub' ) );
313 }
314
315 $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $activity['published'] ?? 'now' ) );
316
317 // Sanitize content and remove hashtags.
318 $content = isset( $activity['content'] ) ? Sanitize::content( $activity['content'] ) : '';
319 $content = self::remove_hashtags( $content, $activity['tag'] ?? array() );
320 $content = Emoji::wrap_in_content( $content, $activity );
321
322 // Process remote media: wrap inline images and append attachments.
323 $attachments = self::extract_attachments( $activity );
324 $content = process_remote_media( $content, $attachments );
325
326 return array(
327 'post_title' => isset( $activity['name'] ) ? \wp_strip_all_tags( $activity['name'] ) : '',
328 'post_content' => $content,
329 'post_excerpt' => isset( $activity['summary'] ) ? \wp_strip_all_tags( $activity['summary'] ) : generate_post_summary( $activity['content'] ?? '' ),
330 'post_status' => 'publish',
331 'post_type' => self::POST_TYPE,
332 'post_date_gmt' => $gm_date,
333 'post_date' => \get_date_from_gmt( $gm_date ),
334 'guid' => isset( $activity['id'] ) ? \esc_url_raw( $activity['id'] ) : '',
335 );
336 }
337
338 /**
339 * Add taxonomies to the object post.
340 *
341 * @param int $post_id The post ID.
342 * @param array $activity_object The activity object data.
343 */
344 private static function add_taxonomies( $post_id, $activity_object ) {
345 // Save Object Type as Taxonomy item.
346 \wp_set_post_terms( $post_id, array( $activity_object['type'] ), 'ap_object_type' );
347
348 // Save the Hashtags as Taxonomy items.
349 $tags = self::extract_hashtags( $activity_object['tag'] ?? array() );
350
351 \wp_set_post_terms( $post_id, $tags, 'ap_tag' );
352 }
353
354 /**
355 * Extract media attachments from an activity object.
356 *
357 * Extracts attachments with URL, alt text, and media type for appending to content.
358 *
359 * @param array $activity_object The activity object data.
360 *
361 * @return array Array of attachments with 'url', 'alt', and 'type' keys.
362 */
363 private static function extract_attachments( $activity_object ) {
364 if ( empty( $activity_object['attachment'] ) || ! \is_array( $activity_object['attachment'] ) ) {
365 return array();
366 }
367
368 $attachments = array();
369 foreach ( $activity_object['attachment'] as $attachment ) {
370 if ( \is_object( $attachment ) ) {
371 $attachment = \get_object_vars( $attachment );
372 }
373
374 if ( empty( $attachment['url'] ) ) {
375 continue;
376 }
377
378 $mime_type = $attachment['mediaType'] ?? '';
379
380 if ( \str_starts_with( $mime_type, 'video/' ) ) {
381 $type = 'video';
382 } elseif ( \str_starts_with( $mime_type, 'audio/' ) ) {
383 $type = 'audio';
384 } else {
385 $type = 'image';
386 }
387
388 $attachments[] = array(
389 'url' => $attachment['url'],
390 'alt' => $attachment['name'] ?? '',
391 'type' => $type,
392 );
393 }
394
395 return $attachments;
396 }
397
398 /**
399 * Get posts by remote actor.
400 *
401 * @param string $actor The remote actor URI.
402 *
403 * @return array Array of WP_Post objects.
404 */
405 public static function get_by_remote_actor( $actor ) {
406 $remote_actor = Remote_Actors::fetch_by_uri( $actor );
407
408 if ( \is_wp_error( $remote_actor ) ) {
409 return array();
410 }
411
412 return self::get_by_remote_actor_id( $remote_actor->ID );
413 }
414
415 /**
416 * Get posts by remote actor ID.
417 *
418 * @param int $actor_id The remote actor post ID.
419 *
420 * @return array Array of WP_Post objects.
421 */
422 public static function get_by_remote_actor_id( $actor_id ) {
423 $query = new \WP_Query(
424 array(
425 'post_type' => self::POST_TYPE,
426 'posts_per_page' => -1,
427 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
428 'meta_key' => '_activitypub_remote_actor_id',
429 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
430 'meta_value' => $actor_id,
431 )
432 );
433
434 return $query->posts;
435 }
436
437 /**
438 * Get all recipients for a post.
439 *
440 * @param int $post_id The post ID.
441 *
442 * @return int[] Array of user IDs who are recipients.
443 */
444 public static function get_recipients( $post_id ) {
445 // Get all meta values with key '_activitypub_user_id' (single => false).
446 $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
447 $recipients = \array_map( 'intval', $recipients );
448
449 return $recipients;
450 }
451
452 /**
453 * Check if a user is a recipient of a post.
454 *
455 * @param int $post_id The post ID.
456 * @param int $user_id The user ID to check.
457 *
458 * @return bool True if user is a recipient, false otherwise.
459 */
460 public static function has_recipient( $post_id, $user_id ) {
461 $recipients = self::get_recipients( $post_id );
462
463 return \in_array( (int) $user_id, $recipients, true );
464 }
465
466 /**
467 * Add a recipient to an existing post.
468 *
469 * @param int $post_id The post ID.
470 * @param int $user_id The user ID to add.
471 *
472 * @return bool True on success, false on failure.
473 */
474 public static function add_recipient( $post_id, $user_id ) {
475 $user_id = (int) $user_id;
476 // Allow 0 for blog user, but reject negative values.
477 if ( $user_id < 0 ) {
478 return false;
479 }
480
481 // Check if already a recipient.
482 if ( self::has_recipient( $post_id, $user_id ) ) {
483 return true;
484 }
485
486 // Add new recipient as separate meta entry.
487 return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
488 }
489
490 /**
491 * Add multiple recipients to an existing post.
492 *
493 * @param int $post_id The post ID.
494 * @param int[] $user_ids The user ID or array of user IDs to add.
495 */
496 public static function add_recipients( $post_id, $user_ids ) {
497 foreach ( $user_ids as $user_id ) {
498 self::add_recipient( $post_id, $user_id );
499 }
500 }
501
502 /**
503 * Remove a recipient from a post.
504 *
505 * @param int $post_id The post ID.
506 * @param int $user_id The user ID to remove.
507 *
508 * @return bool True on success, false on failure.
509 */
510 public static function remove_recipient( $post_id, $user_id ) {
511 $user_id = (int) $user_id;
512
513 // Allow 0 for blog user, but reject negative values.
514 if ( $user_id < 0 ) {
515 return false;
516 }
517
518 // Delete the specific meta entry with this value.
519 return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
520 }
521
522 /**
523 * Delete all posts.
524 *
525 * Used during plugin uninstall to clean up all remote posts.
526 *
527 * @return int The number of posts deleted.
528 */
529 public static function delete_all() {
530 $post_ids = \get_posts(
531 array(
532 'post_type' => self::POST_TYPE,
533 'post_status' => array( 'any', 'trash', 'auto-draft' ),
534 'fields' => 'ids',
535 'numberposts' => -1,
536 )
537 );
538
539 foreach ( $post_ids as $post_id ) {
540 \wp_delete_post( $post_id, true );
541 }
542
543 return count( $post_ids );
544 }
545
546 /**
547 * Purge old remote posts.
548 *
549 * Deletes remote posts older than the specified number of days,
550 * but preserves posts that have comments from local users
551 * as these indicate meaningful local interactions.
552 *
553 * @param int $days Number of days to keep items. Items older than this will be deleted.
554 *
555 * @return int The number of items deleted.
556 */
557 public static function purge( $days ) {
558 if ( $days <= 0 ) {
559 return 0;
560 }
561
562 $counts = \wp_count_posts( self::POST_TYPE );
563 $total = 0;
564 foreach ( $counts as $count ) {
565 $total += (int) $count;
566 }
567
568 if ( $total <= 200 ) {
569 return 0;
570 }
571
572 global $wpdb;
573
574 $deleted = 0;
575 $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
576 $start_time = \time();
577 $exclude = array();
578
579 // If total exceeds the hard cap, drop the date filter to purge oldest items first.
580 $overflow = $total > self::MAX_ITEMS;
581 $date_query = array(
582 array(
583 'before' => $cutoff,
584 ),
585 );
586
587 $query_args = array(
588 'post_type' => self::POST_TYPE,
589 'post_status' => 'any',
590 'fields' => 'ids',
591 'numberposts' => self::PURGE_BATCH_SIZE,
592 'orderby' => 'date',
593 'order' => 'ASC',
594 );
595
596 if ( ! $overflow ) {
597 $query_args['date_query'] = $date_query;
598 }
599
600 do {
601 $query_args['exclude'] = $exclude;
602 $post_ids = \get_posts( $query_args );
603
604 if ( empty( $post_ids ) ) {
605 break;
606 }
607
608 // Batch-fetch post IDs that have local user comments (single query per batch).
609 $placeholders = \implode( ',', \array_fill( 0, \count( $post_ids ), '%d' ) );
610
611 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
612 $commented_post_ids = $wpdb->get_col(
613 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders
614 $wpdb->prepare( "SELECT DISTINCT comment_post_ID FROM $wpdb->comments WHERE comment_post_ID IN ($placeholders) AND user_id > 0", $post_ids )
615 );
616 $commented_post_ids = \array_flip( $commented_post_ids );
617
618 foreach ( $post_ids as $post_id ) {
619 /**
620 * Filter whether to preserve a specific ap_post from being purged.
621 *
622 * @param bool $preserve Whether to preserve this post. Default false.
623 * @param int $post_id The ap_post ID being considered for deletion.
624 *
625 * @return bool Whether to preserve this post from deletion.
626 */
627 if ( \apply_filters( 'activitypub_preserve_ap_post', false, $post_id ) ) {
628 $exclude[] = $post_id;
629 continue;
630 }
631
632 // Preserve posts with comments from local users.
633 if ( isset( $commented_post_ids[ $post_id ] ) ) {
634 $exclude[] = $post_id;
635 continue;
636 }
637
638 \wp_delete_post( $post_id, true );
639 ++$deleted;
640 }
641
642 // Once we're back under the cap, re-apply the date filter.
643 if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
644 $overflow = false;
645 $query_args['date_query'] = $date_query;
646 }
647 } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
648
649 return $deleted;
650 }
651 }
652