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
← All changes | includes/collection/class-posts.php +369 -135 9.3.07.7.0 View file →
@@ -6,216 +6,450 @@
6 6 */
7 7
8 8 namespace Activitypub\Collection;
9 9
10 -use Activitypub\Blocks;
11 -use Activitypub\Hashtag;
12 -use Activitypub\Link;
10 +use Activitypub\Attachments;
11 +use Activitypub\Sanitize;
13 12
14 -use function Activitypub\get_content_visibility;
15 -use function Activitypub\user_can_act_as_blog;
13 +use function Activitypub\generate_post_summary;
14 +use function Activitypub\object_to_uri;
16 15
17 16 /**
18 17 * Posts collection.
19 18 *
20 - * Provides CRUD methods for local WordPress posts created
21 - * via ActivityPub Client-to-Server (C2S) outbox.
22 - *
23 - * @see Remote_Posts for federated posts received via Server-to-Server (S2S).
19 + * Provides methods to retrieve, create, update, and manage ActivityPub posts (articles, notes, media, etc.).
24 20 */
25 21 class Posts {
26 22 /**
27 - * Create a WordPress post from an ActivityPub activity.
23 + * The post type for the posts.
28 24 *
29 - * @since 8.1.0
25 + * @var string
26 + */
27 + const POST_TYPE = 'ap_post';
28 +
29 + /**
30 + * Add an object to the collection.
30 31 *
31 - * @param array $activity The activity data.
32 - * @param int $user_id The local user ID.
33 - * @param string|null $visibility Content visibility.
32 + * @param array $activity The activity object data.
33 + * @param int|int[] $recipients The id(s) of the local blog-user(s).
34 34 *
35 - * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure.
35 + * @return \WP_Post|\WP_Error The object post or WP_Error on failure.
36 36 */
37 - public static function create( $activity, $user_id, $visibility = null ) {
38 - // Resolve the post author. Blog actor falls back to the current user for a real byline.
39 - $post_author = $user_id > 0 ? $user_id : \get_current_user_id();
37 + public static function add( $activity, $recipients ) {
38 + $recipients = (array) $recipients;
39 + $activity_object = $activity['object'];
40 40
41 - /*
42 - * Authorize the request:
43 - * - Per-user path: require `publish_posts` on the URL-specified user.
44 - * - Blog actor path (post_author falls back to current user): require
45 - * the act-as-blog grant. `publish_posts` is implicit because the
46 - * helper defaults to `manage_options` (administrators).
47 - * - Cron/CLI path keeps `post_author = 0` and bypasses both checks.
48 - */
49 - if ( $post_author > 0 ) {
50 - $authorized = $post_author === (int) $user_id
51 - ? \user_can( $user_id, 'publish_posts' )
52 - : user_can_act_as_blog();
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 + }
53 46
54 - if ( ! $authorized ) {
55 - return new \WP_Error(
56 - 'activitypub_forbidden',
57 - \__( 'You do not have permission to create posts.', 'activitypub' ),
58 - array( 'status' => 403 )
59 - );
60 - }
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;
61 52 }
62 53
63 - $object = $activity['object'] ?? array();
54 + $post_array = self::activity_to_post( $activity_object );
55 + $post_id = \wp_insert_post( $post_array, true );
64 56
65 - $object_type = $object['type'] ?? '';
66 - $content = \wp_kses_post( $object['content'] ?? '' );
67 - $name = \sanitize_text_field( $object['name'] ?? '' );
68 - $summary = \wp_kses_post( $object['summary'] ?? '' );
69 - $plain_summary = \sanitize_text_field( $summary );
57 + if ( \is_wp_error( $post_id ) ) {
58 + return $post_id;
59 + }
70 60
71 - // A summary marked sensitive is a content warning (plain text); otherwise it's a regular excerpt.
72 - // Route on the sanitized summary so whitespace-only values don't pollute either field.
73 - $content_warning = ! empty( $object['sensitive'] ) && '' !== $plain_summary ? $plain_summary : '';
74 - $post_excerpt = '' === $content_warning && '' !== $plain_summary ? $summary : '';
61 + \add_post_meta( $post_id, '_activitypub_remote_actor_id', $actor->ID );
75 62
76 - // Process content: autop, autolink, hashtags, and convert to blocks.
77 - $content = self::prepare_content( $content );
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 + }
78 67
79 - // Use name as title for Articles, or generate from content for Notes.
80 - $title = $name;
81 - if ( empty( $title ) && ! empty( $content ) ) {
82 - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
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 + );
83 109 }
84 110
85 - // Determine visibility if not provided.
86 - if ( null === $visibility ) {
87 - $visibility = get_content_visibility( $activity );
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;
88 128 }
89 129
90 - $post_data = array(
91 - 'post_author' => $post_author,
92 - 'post_title' => $title,
93 - 'post_content' => $content,
94 - 'post_excerpt' => $post_excerpt,
95 - 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish',
96 - 'post_type' => 'post',
97 - 'meta_input' => array(
98 - 'activitypub_content_visibility' => $visibility,
99 - 'activitypub_content_warning' => $content_warning,
100 - ),
101 - );
130 + $post_array = self::activity_to_post( $activity['object'] );
131 + $post_array['ID'] = $post->ID;
132 + $post_id = \wp_update_post( $post_array, true );
102 133
103 - $post_id = \wp_insert_post( $post_data, true );
104 -
105 134 if ( \is_wp_error( $post_id ) ) {
106 135 return $post_id;
107 136 }
108 137
109 - // Set post format to 'status' for Notes so the transformer maps it back correctly.
110 - if ( 'Note' === $object_type ) {
111 - \set_post_format( $post_id, 'status' );
138 + // Add new recipients using add_recipient (handles deduplication).
139 + foreach ( $recipients as $user_id ) {
140 + self::add_recipient( $post_id, $user_id );
112 141 }
113 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 +
114 149 return \get_post( $post_id );
115 150 }
116 151
117 152 /**
118 - * Update a WordPress post from an ActivityPub activity.
153 + * Delete an object from the collection.
119 154 *
120 - * @since 8.1.0
155 + * @param int $id The object ID.
121 156 *
122 - * @param \WP_Post $post The post to update.
123 - * @param array $activity The activity data.
124 - * @param string|null $visibility Content visibility.
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.
125 165 *
126 - * @return \WP_Post|\WP_Error The updated post on success, WP_Error on failure.
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.
127 169 */
128 - public static function update( $post, $activity, $visibility = null ) {
129 - $object = $activity['object'] ?? array();
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 + }
130 175
131 - $content = \wp_kses_post( $object['content'] ?? '' );
132 - $name = \sanitize_text_field( $object['name'] ?? '' );
133 - $summary = \wp_kses_post( $object['summary'] ?? '' );
134 - $plain_summary = \sanitize_text_field( $summary );
176 + return self::delete( $post->ID );
177 + }
135 178
136 - // A summary marked sensitive is a content warning (plain text); otherwise it's a regular excerpt.
137 - // Route on the sanitized summary so whitespace-only values don't pollute either field.
138 - $content_warning = ! empty( $object['sensitive'] ) && '' !== $plain_summary ? $plain_summary : '';
139 - $post_excerpt = '' === $content_warning && '' !== $plain_summary ? $summary : '';
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();
140 188
141 - // Process content: autop, autolink, hashtags, and convert to blocks.
142 - $content = self::prepare_content( $content );
189 + if ( empty( $tags ) || ! \is_array( $tags ) ) {
190 + return $hashtags;
191 + }
143 192
144 - // Use name as title for Articles, or generate from content for Notes.
145 - $title = $name;
146 - if ( empty( $title ) && ! empty( $content ) ) {
147 - $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
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 + }
148 203 }
149 204
150 - // Determine visibility if not provided.
151 - if ( null === $visibility ) {
152 - $visibility = get_content_visibility( $activity );
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;
153 222 }
154 223
155 - $post_data = array(
156 - 'ID' => $post->ID,
157 - 'post_title' => $title,
158 - 'post_content' => $content,
159 - 'post_excerpt' => $post_excerpt,
160 - 'meta_input' => array(
161 - 'activitypub_content_visibility' => $visibility,
162 - 'activitypub_content_warning' => $content_warning,
163 - ),
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'] ) : '',
164 281 );
282 + }
165 283
166 - $post_id = \wp_update_post( $post_data, true );
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' );
167 293
168 - if ( \is_wp_error( $post_id ) ) {
169 - return $post_id;
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;
170 312 }
171 313
172 - return \get_post( $post_id );
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 + }
173 330 }
174 331
175 332 /**
176 - * Delete (trash) a WordPress post.
333 + * Get posts by remote actor.
177 334 *
178 - * @since 8.1.0
335 + * @param string $actor The remote actor URI.
179 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 + *
180 374 * @param int $post_id The post ID.
181 375 *
182 - * @return \WP_Post|false|null Post data on success, false or null on failure.
376 + * @return int[] Array of user IDs who are recipients.
183 377 */
184 - public static function delete( $post_id ) {
185 - return \wp_trash_post( $post_id );
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;
186 384 }
187 385
188 386 /**
189 - * Prepare content for storage as a WordPress post.
387 + * Check if a user is a recipient of a post.
190 388 *
191 - * Applies wpautop (for plain text), autolinks bare URLs,
192 - * converts hashtags to links, and wraps in block markup.
389 + * @param int $post_id The post ID.
390 + * @param int $user_id The user ID to check.
193 391 *
194 - * @since 8.1.0
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.
195 402 *
196 - * @param string $content The HTML or plain-text content.
403 + * @param int $post_id The post ID.
404 + * @param int $user_id The user ID to add.
197 405 *
198 - * @return string The processed content with block markup.
406 + * @return bool True on success, false on failure.
199 407 */
200 - public static function prepare_content( $content ) {
201 - if ( empty( $content ) ) {
202 - return '';
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;
203 413 }
204 414
205 - // Wrap plain text in paragraphs if it has no block-level HTML.
206 - if ( ! \preg_match( '/<(p|h[1-6]|ul|ol|blockquote|figure|hr|img|div|pre|table)\b/i', $content ) ) {
207 - $content = \wpautop( $content );
415 + // Check if already a recipient.
416 + if ( self::has_recipient( $post_id, $user_id ) ) {
417 + return true;
208 418 }
209 419
210 - // Convert bare URLs to links.
211 - $content = Link::the_content( $content );
420 + // Add new recipient as separate meta entry.
421 + return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
422 + }
212 423
213 - // Convert #hashtags to links.
214 - $content = Hashtag::the_content( $content );
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 + }
215 435
216 - // Convert HTML to block markup.
217 - $content = Blocks::convert_from_html( $content );
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;
218 446
219 - return $content;
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 );
220 454 }
221 455 }