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 / collection / class-remote-posts.php

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

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