PluginProbe
ActivityPub / 7.7.0
ActivityPub v7.7.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-posts.php

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

456 lines 12.9 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\Attachments;
11 use Activitypub\Sanitize;
12
13 use function Activitypub\generate_post_summary;
14 use function Activitypub\object_to_uri;
15
16 /**
17 * Posts collection.
18 *
19 * Provides methods to retrieve, create, update, and manage ActivityPub posts (articles, notes, media, etc.).
20 */
21 class Posts {
22 /**
23 * The post type for the posts.
24 *
25 * @var string
26 */
27 const POST_TYPE = 'ap_post';
28
29 /**
30 * Add an object to the collection.
31 *
32 * @param array $activity The activity object data.
33 * @param int|int[] $recipients The id(s) of the local blog-user(s).
34 *
35 * @return \WP_Post|\WP_Error The object post or WP_Error on failure.
36 */
37 public static function add( $activity, $recipients ) {
38 $recipients = (array) $recipients;
39 $activity_object = $activity['object'];
40
41 $existing = self::get_by_guid( $activity_object['id'] );
42 // If post exists, call update instead.
43 if ( ! \is_wp_error( $existing ) ) {
44 return self::update( $activity, $recipients );
45 }
46
47 // Post doesn't exist, create new post.
48 $actor = Remote_Actors::fetch_by_uri( object_to_uri( $activity_object['attributedTo'] ) );
49
50 if ( \is_wp_error( $actor ) ) {
51 return $actor;
52 }
53
54 $post_array = self::activity_to_post( $activity_object );
55 $post_id = \wp_insert_post( $post_array, true );
56
57 if ( \is_wp_error( $post_id ) ) {
58 return $post_id;
59 }
60
61 \add_post_meta( $post_id, '_activitypub_remote_actor_id', $actor->ID );
62
63 // Add recipients as separate meta entries after post is created.
64 foreach ( $recipients as $user_id ) {
65 self::add_recipient( $post_id, $user_id );
66 }
67
68 self::add_taxonomies( $post_id, $activity_object );
69 self::maybe_import_attachments( $activity_object, $post_id );
70
71 return \get_post( $post_id );
72 }
73
74 /**
75 * Get an object from the collection.
76 *
77 * @param int $id The object ID.
78 *
79 * @return \WP_Post|null The post object or null on failure.
80 */
81 public static function get( $id ) {
82 return \get_post( $id );
83 }
84
85 /**
86 * Get an object by its GUID.
87 *
88 * @param string $guid The object GUID.
89 *
90 * @return \WP_Post|\WP_Error The object post or WP_Error on failure.
91 */
92 public static function get_by_guid( $guid ) {
93 global $wpdb;
94 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
95 $post_id = $wpdb->get_var(
96 $wpdb->prepare(
97 "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
98 \esc_url( $guid ),
99 self::POST_TYPE
100 )
101 );
102
103 if ( ! $post_id ) {
104 return new \WP_Error(
105 'activitypub_post_not_found',
106 \__( 'Post not found', 'activitypub' ),
107 array( 'status' => 404 )
108 );
109 }
110
111 return \get_post( $post_id );
112 }
113
114 /**
115 * Update an object in the collection.
116 *
117 * @param array $activity The activity object data.
118 * @param int|int[] $recipients The id(s) of the local blog-user(s).
119 *
120 * @return \WP_Post|\WP_Error The updated object post or WP_Error on failure.
121 */
122 public static function update( $activity, $recipients ) {
123 $recipients = (array) $recipients;
124
125 $post = self::get_by_guid( $activity['object']['id'] );
126 if ( \is_wp_error( $post ) ) {
127 return $post;
128 }
129
130 $post_array = self::activity_to_post( $activity['object'] );
131 $post_array['ID'] = $post->ID;
132 $post_id = \wp_update_post( $post_array, true );
133
134 if ( \is_wp_error( $post_id ) ) {
135 return $post_id;
136 }
137
138 // Add new recipients using add_recipient (handles deduplication).
139 foreach ( $recipients as $user_id ) {
140 self::add_recipient( $post_id, $user_id );
141 }
142
143 self::add_taxonomies( $post_id, $activity['object'] );
144
145 // Always delete existing attachments on update in case filter value changed.
146 Attachments::delete_ap_posts_directory( $post_id );
147 self::maybe_import_attachments( $activity['object'], $post_id );
148
149 return \get_post( $post_id );
150 }
151
152 /**
153 * Delete an object from the collection.
154 *
155 * @param int $id The object ID.
156 *
157 * @return \WP_Post|false|null Post data on success, false or null on failure.
158 */
159 public static function delete( $id ) {
160 return \wp_delete_post( $id, true );
161 }
162
163 /**
164 * Delete an object from the collection by its GUID.
165 *
166 * @param string $guid The object GUID.
167 *
168 * @return \WP_Post|\WP_Error|false|null Post data on success, false or null on failure, or WP_Error if no post to delete.
169 */
170 public static function delete_by_guid( $guid ) {
171 $post = self::get_by_guid( $guid );
172 if ( \is_wp_error( $post ) ) {
173 return $post;
174 }
175
176 return self::delete( $post->ID );
177 }
178
179 /**
180 * Extract hashtag names from ActivityPub tag array.
181 *
182 * @param array $tags Array of ActivityPub tags.
183 *
184 * @return array Array of normalized hashtag names (without # prefix, trimmed, sanitized).
185 */
186 public static function extract_hashtags( $tags ) {
187 $hashtags = array();
188
189 if ( empty( $tags ) || ! \is_array( $tags ) ) {
190 return $hashtags;
191 }
192
193 foreach ( $tags as $tag ) {
194 if ( isset( $tag['type'] ) && 'Hashtag' === $tag['type'] && isset( $tag['name'] ) ) {
195 // Strip # prefix, trim whitespace, and sanitize.
196 $normalized = \trim( \ltrim( $tag['name'], '#' ) );
197 $normalized = \wp_strip_all_tags( $normalized );
198
199 if ( ! empty( $normalized ) ) {
200 $hashtags[] = $normalized;
201 }
202 }
203 }
204
205 return $hashtags;
206 }
207
208 /**
209 * Remove hashtags from content.
210 *
211 * Removes hashtags that appear at the end of the content.
212 * Handles both plain text and HTML content, including hashtags within anchor tags.
213 *
214 * @param string $content The content to process.
215 * @param array $tags Array of tag objects from activity (with 'type' and 'name' keys).
216 *
217 * @return string The content with trailing hashtags removed.
218 */
219 public static function remove_hashtags( $content, $tags ) {
220 if ( empty( $content ) || empty( $tags ) || ! \is_array( $tags ) ) {
221 return $content;
222 }
223
224 // Extract and normalize hashtags from tag objects.
225 $normalized_tags = self::extract_hashtags( $tags );
226
227 if ( empty( $normalized_tags ) ) {
228 return $content;
229 }
230
231 // Build pattern to match trailing hashtags (at end of content or before closing tags).
232 $tag_patterns = array();
233 foreach ( $normalized_tags as $tag ) {
234 $escaped_tag = \preg_quote( $tag, '/' );
235 $tag_patterns[] = '(?:<a[^>]*>\s*)?#' . $escaped_tag . '(?=\s|<|$)(?:\s*<\/a>)?';
236 }
237
238 /*
239 * Pattern explanation:
240 * Match one or more hashtags (plain or in anchor tags) at the end of content.
241 * The pattern matches trailing hashtags before closing HTML tags or at end of string.
242 */
243 $pattern = '/(?:\s+(?:' . \implode( '|', $tag_patterns ) . '))+(?=\s*(?:<\/[^>]+>)*\s*$)/i';
244 $content = \preg_replace( $pattern, '', $content );
245
246 // Clean up any extra whitespace at end of paragraphs.
247 $content = \preg_replace( '/<p>\s*<\/p>/', '', $content );
248 $content = \preg_replace( '/\s+<\/p>/', '</p>', $content );
249 $content = \preg_replace( '/\s+<\/strong>/', '</strong>', $content );
250
251 return \trim( $content );
252 }
253
254 /**
255 * Convert an activity to a post array.
256 *
257 * @param array $activity The activity array.
258 *
259 * @return array|\WP_Error The post array or WP_Error on failure.
260 */
261 private static function activity_to_post( $activity ) {
262 if ( ! is_array( $activity ) ) {
263 return new \WP_Error( 'invalid_activity', __( 'Invalid activity format', 'activitypub' ) );
264 }
265
266 $gm_date = \gmdate( 'Y-m-d H:i:s', \strtotime( $activity['published'] ?? 'now' ) );
267
268 // Sanitize content and remove hashtags.
269 $content = isset( $activity['content'] ) ? Sanitize::content( $activity['content'] ) : '';
270 $content = self::remove_hashtags( $content, $activity['tag'] ?? array() );
271
272 return array(
273 'post_title' => isset( $activity['name'] ) ? \wp_strip_all_tags( $activity['name'] ) : '',
274 'post_content' => $content,
275 'post_excerpt' => isset( $activity['summary'] ) ? \wp_strip_all_tags( $activity['summary'] ) : generate_post_summary( $activity['content'] ?? '' ),
276 'post_status' => 'publish',
277 'post_type' => self::POST_TYPE,
278 'post_date_gmt' => $gm_date,
279 'post_date' => \get_date_from_gmt( $gm_date ),
280 'guid' => isset( $activity['id'] ) ? \esc_url_raw( $activity['id'] ) : '',
281 );
282 }
283
284 /**
285 * Add taxonomies to the object post.
286 *
287 * @param int $post_id The post ID.
288 * @param array $activity_object The activity object data.
289 */
290 private static function add_taxonomies( $post_id, $activity_object ) {
291 // Save Object Type as Taxonomy item.
292 \wp_set_post_terms( $post_id, array( $activity_object['type'] ), 'ap_object_type' );
293
294 // Save the Hashtags as Taxonomy items.
295 $tags = self::extract_hashtags( $activity_object['tag'] ?? array() );
296
297 \wp_set_post_terms( $post_id, $tags, 'ap_tag' );
298 }
299
300 /**
301 * Maybe import attachments for an activity object.
302 *
303 * Checks if attachments should be stored locally via filter and imports them if enabled.
304 *
305 * @param array $activity_object The activity object data.
306 * @param int $post_id The post ID.
307 */
308 private static function maybe_import_attachments( $activity_object, $post_id ) {
309 // Process attachments if present.
310 if ( empty( $activity_object['attachment'] ) ) {
311 return;
312 }
313
314 /**
315 * Filters whether to store attachments locally for incoming ActivityPub posts.
316 *
317 * Allows plugins or users to disable local storage of attachments from
318 * incoming ActivityPub posts. When disabled, attachments won't be downloaded
319 * and stored locally, which can be useful for users with limited webspace.
320 *
321 * @param bool $store_locally Whether to store attachments locally. Default true.
322 * @param array $activity_object The ActivityPub activity object.
323 * @param int $post_id The post ID.
324 */
325 $store_locally = \apply_filters( 'activitypub_store_attachments_locally', true, $activity_object, $post_id );
326
327 if ( $store_locally ) {
328 Attachments::import_post_files( $activity_object['attachment'], $post_id );
329 }
330 }
331
332 /**
333 * Get posts by remote actor.
334 *
335 * @param string $actor The remote actor URI.
336 *
337 * @return array Array of WP_Post objects.
338 */
339 public static function get_by_remote_actor( $actor ) {
340 $remote_actor = Remote_Actors::fetch_by_uri( $actor );
341
342 if ( \is_wp_error( $remote_actor ) ) {
343 return array();
344 }
345
346 return self::get_by_remote_actor_id( $remote_actor->ID );
347 }
348
349 /**
350 * Get posts by remote actor ID.
351 *
352 * @param int $actor_id The remote actor post ID.
353 *
354 * @return array Array of WP_Post objects.
355 */
356 public static function get_by_remote_actor_id( $actor_id ) {
357 $query = new \WP_Query(
358 array(
359 'post_type' => self::POST_TYPE,
360 'posts_per_page' => -1,
361 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
362 'meta_key' => '_activitypub_remote_actor_id',
363 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
364 'meta_value' => $actor_id,
365 )
366 );
367
368 return $query->posts;
369 }
370
371 /**
372 * Get all recipients for a post.
373 *
374 * @param int $post_id The post ID.
375 *
376 * @return int[] Array of user IDs who are recipients.
377 */
378 public static function get_recipients( $post_id ) {
379 // Get all meta values with key '_activitypub_user_id' (single => false).
380 $recipients = \get_post_meta( $post_id, '_activitypub_user_id', false );
381 $recipients = \array_map( 'intval', $recipients );
382
383 return $recipients;
384 }
385
386 /**
387 * Check if a user is a recipient of a post.
388 *
389 * @param int $post_id The post ID.
390 * @param int $user_id The user ID to check.
391 *
392 * @return bool True if user is a recipient, false otherwise.
393 */
394 public static function has_recipient( $post_id, $user_id ) {
395 $recipients = self::get_recipients( $post_id );
396
397 return \in_array( (int) $user_id, $recipients, true );
398 }
399
400 /**
401 * Add a recipient to an existing post.
402 *
403 * @param int $post_id The post ID.
404 * @param int $user_id The user ID to add.
405 *
406 * @return bool True on success, false on failure.
407 */
408 public static function add_recipient( $post_id, $user_id ) {
409 $user_id = (int) $user_id;
410 // Allow 0 for blog user, but reject negative values.
411 if ( $user_id < 0 ) {
412 return false;
413 }
414
415 // Check if already a recipient.
416 if ( self::has_recipient( $post_id, $user_id ) ) {
417 return true;
418 }
419
420 // Add new recipient as separate meta entry.
421 return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
422 }
423
424 /**
425 * Add multiple recipients to an existing post.
426 *
427 * @param int $post_id The post ID.
428 * @param int[] $user_ids The user ID or array of user IDs to add.
429 */
430 public static function add_recipients( $post_id, $user_ids ) {
431 foreach ( $user_ids as $user_id ) {
432 self::add_recipient( $post_id, $user_id );
433 }
434 }
435
436 /**
437 * Remove a recipient from a post.
438 *
439 * @param int $post_id The post ID.
440 * @param int $user_id The user ID to remove.
441 *
442 * @return bool True on success, false on failure.
443 */
444 public static function remove_recipient( $post_id, $user_id ) {
445 $user_id = (int) $user_id;
446
447 // Allow 0 for blog user, but reject negative values.
448 if ( $user_id < 0 ) {
449 return false;
450 }
451
452 // Delete the specific meta entry with this value.
453 return \delete_post_meta( $post_id, '_activitypub_user_id', $user_id );
454 }
455 }
456