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

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

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