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

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