PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.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-posts.php

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

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