PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.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.2.2, at includes/collection/class-remote-posts.php

672 lines 19.0 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 // Store the GUID the way get_by_guid() looks it up, which is with esc_url(): an
352 // ampersand becomes `&#038;`. Passing it unescaped instead lets `pre_post_guid`
353 // store it as `&amp;`, and the two spellings never match.
354 'guid' => isset( $activity['id'] ) ? \esc_url( $activity['id'] ) : '',
355 );
356 }
357
358 /**
359 * Add taxonomies to the object post.
360 *
361 * @param int $post_id The post ID.
362 * @param array $activity_object The activity object data.
363 */
364 private static function add_taxonomies( $post_id, $activity_object ) {
365 // Save Object Type as Taxonomy item.
366 \wp_set_post_terms( $post_id, array( $activity_object['type'] ), 'ap_object_type' );
367
368 // Save the Hashtags as Taxonomy items.
369 $tags = self::extract_hashtags( $activity_object['tag'] ?? array() );
370
371 \wp_set_post_terms( $post_id, $tags, 'ap_tag' );
372 }
373
374 /**
375 * Extract media attachments from an activity object.
376 *
377 * Extracts attachments with URL, alt text, and media type for appending to content.
378 *
379 * @param array $activity_object The activity object data.
380 *
381 * @return array Array of attachments with 'url', 'alt', and 'type' keys.
382 */
383 private static function extract_attachments( $activity_object ) {
384 if ( empty( $activity_object['attachment'] ) || ! \is_array( $activity_object['attachment'] ) ) {
385 return array();
386 }
387
388 $attachments = array();
389 foreach ( $activity_object['attachment'] as $attachment ) {
390 if ( \is_object( $attachment ) ) {
391 $attachment = \get_object_vars( $attachment );
392 }
393
394 if ( empty( $attachment['url'] ) ) {
395 continue;
396 }
397
398 $mime_type = $attachment['mediaType'] ?? '';
399
400 if ( \str_starts_with( $mime_type, 'video/' ) ) {
401 $type = 'video';
402 } elseif ( \str_starts_with( $mime_type, 'audio/' ) ) {
403 $type = 'audio';
404 } else {
405 $type = 'image';
406 }
407
408 $attachments[] = array(
409 'url' => $attachment['url'],
410 'alt' => $attachment['name'] ?? '',
411 'type' => $type,
412 );
413 }
414
415 return $attachments;
416 }
417
418 /**
419 * Get posts by remote actor.
420 *
421 * @param string $actor The remote actor URI.
422 *
423 * @return array Array of WP_Post objects.
424 */
425 public static function get_by_remote_actor( $actor ) {
426 $remote_actor = Remote_Actors::fetch_by_uri( $actor );
427
428 if ( \is_wp_error( $remote_actor ) ) {
429 return array();
430 }
431
432 return self::get_by_remote_actor_id( $remote_actor->ID );
433 }
434
435 /**
436 * Get posts by remote actor ID.
437 *
438 * @param int $actor_id The remote actor post ID.
439 *
440 * @return array Array of WP_Post objects.
441 */
442 public static function get_by_remote_actor_id( $actor_id ) {
443 $query = new \WP_Query(
444 array(
445 'post_type' => self::POST_TYPE,
446 'posts_per_page' => -1,
447 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
448 'meta_key' => '_activitypub_remote_actor_id',
449 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
450 'meta_value' => $actor_id,
451 )
452 );
453
454 return $query->posts;
455 }
456
457 /**
458 * Get all recipients for a post.
459 *
460 * @param int $post_id The post ID.
461 *
462 * @return int[] Array of user IDs who are recipients.
463 */
464 public static function get_recipients( $post_id ) {
465 // Get all meta values with key '_activitypub_user_id' (single => false).
466 $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
467 $recipients = \array_map( 'intval', $recipients );
468
469 return $recipients;
470 }
471
472 /**
473 * Check if a user is a recipient of a post.
474 *
475 * @param int $post_id The post ID.
476 * @param int $user_id The user ID to check.
477 *
478 * @return bool True if user is a recipient, false otherwise.
479 */
480 public static function has_recipient( $post_id, $user_id ) {
481 $recipients = self::get_recipients( $post_id );
482
483 return \in_array( (int) $user_id, $recipients, true );
484 }
485
486 /**
487 * Add a recipient to an existing post.
488 *
489 * @param int $post_id The post ID.
490 * @param int $user_id The user ID to add.
491 *
492 * @return bool True on success, false on failure.
493 */
494 public static function add_recipient( $post_id, $user_id ) {
495 $user_id = (int) $user_id;
496 // Allow 0 for blog user, but reject negative values.
497 if ( $user_id < 0 ) {
498 return false;
499 }
500
501 // Check if already a recipient.
502 if ( self::has_recipient( $post_id, $user_id ) ) {
503 return true;
504 }
505
506 // Add new recipient as separate meta entry.
507 return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
508 }
509
510 /**
511 * Add multiple recipients to an existing post.
512 *
513 * @param int $post_id The post ID.
514 * @param int[] $user_ids The user ID or array of user IDs to add.
515 */
516 public static function add_recipients( $post_id, $user_ids ) {
517 foreach ( $user_ids as $user_id ) {
518 self::add_recipient( $post_id, $user_id );
519 }
520 }
521
522 /**
523 * Remove a recipient from a post.
524 *
525 * @param int $post_id The post ID.
526 * @param int $user_id The user ID to remove.
527 *
528 * @return bool True on success, false on failure.
529 */
530 public static function remove_recipient( $post_id, $user_id ) {
531 $user_id = (int) $user_id;
532
533 // Allow 0 for blog user, but reject negative values.
534 if ( $user_id < 0 ) {
535 return false;
536 }
537
538 // Delete the specific meta entry with this value.
539 return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
540 }
541
542 /**
543 * Delete all posts.
544 *
545 * Used during plugin uninstall to clean up all remote posts.
546 *
547 * @return int The number of posts deleted.
548 */
549 public static function delete_all() {
550 $post_ids = \get_posts(
551 array(
552 'post_type' => self::POST_TYPE,
553 'post_status' => array( 'any', 'trash', 'auto-draft' ),
554 'fields' => 'ids',
555 'numberposts' => -1,
556 )
557 );
558
559 foreach ( $post_ids as $post_id ) {
560 \wp_delete_post( $post_id, true );
561 }
562
563 return \count( $post_ids );
564 }
565
566 /**
567 * Purge old remote posts.
568 *
569 * Deletes remote posts older than the specified number of days,
570 * but preserves posts that have comments from local users
571 * as these indicate meaningful local interactions.
572 *
573 * @param int $days Number of days to keep items. Items older than this will be deleted.
574 *
575 * @return int The number of items deleted.
576 */
577 public static function purge( $days ) {
578 if ( $days <= 0 ) {
579 return 0;
580 }
581
582 $counts = \wp_count_posts( self::POST_TYPE );
583 $total = 0;
584 foreach ( $counts as $count ) {
585 $total += (int) $count;
586 }
587
588 if ( $total <= 200 ) {
589 return 0;
590 }
591
592 global $wpdb;
593
594 $deleted = 0;
595 $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) );
596 $start_time = \time();
597 $exclude = array();
598
599 // If total exceeds the hard cap, drop the date filter to purge oldest items first.
600 $overflow = $total > self::MAX_ITEMS;
601 $date_query = array(
602 array(
603 'before' => $cutoff,
604 ),
605 );
606
607 $query_args = array(
608 'post_type' => self::POST_TYPE,
609 'post_status' => 'any',
610 'fields' => 'ids',
611 'numberposts' => self::PURGE_BATCH_SIZE,
612 'orderby' => 'date',
613 'order' => 'ASC',
614 );
615
616 if ( ! $overflow ) {
617 $query_args['date_query'] = $date_query;
618 }
619
620 do {
621 $query_args['exclude'] = $exclude;
622 $post_ids = \get_posts( $query_args );
623
624 if ( empty( $post_ids ) ) {
625 break;
626 }
627
628 // Batch-fetch post IDs that have local user comments (single query per batch).
629 $placeholders = \implode( ',', \array_fill( 0, \count( $post_ids ), '%d' ) );
630
631 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
632 $commented_post_ids = $wpdb->get_col(
633 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders
634 $wpdb->prepare( "SELECT DISTINCT comment_post_ID FROM $wpdb->comments WHERE comment_post_ID IN ($placeholders) AND user_id > 0", $post_ids )
635 );
636 $commented_post_ids = \array_flip( $commented_post_ids );
637
638 foreach ( $post_ids as $post_id ) {
639 /**
640 * Filter whether to preserve a specific ap_post from being purged.
641 *
642 * @param bool $preserve Whether to preserve this post. Default false.
643 * @param int $post_id The ap_post ID being considered for deletion.
644 *
645 * @return bool Whether to preserve this post from deletion.
646 */
647 if ( \apply_filters( 'activitypub_preserve_ap_post', false, $post_id ) ) {
648 $exclude[] = $post_id;
649 continue;
650 }
651
652 // Preserve posts with comments from local users.
653 if ( isset( $commented_post_ids[ $post_id ] ) ) {
654 $exclude[] = $post_id;
655 continue;
656 }
657
658 \wp_delete_post( $post_id, true );
659 ++$deleted;
660 }
661
662 // Once we're back under the cap, re-apply the date filter.
663 if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) {
664 $overflow = false;
665 $query_args['date_query'] = $date_query;
666 }
667 } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT );
668
669 return $deleted;
670 }
671 }
672