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

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

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