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
← All changes | includes/collection/class-posts.php +532 -134 9.3.08.0.2 View file →
@@ -6,216 +6,614 @@
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\Emoji;
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;
15 +use function Activitypub\process_remote_media;
16 16
17 17 /**
18 18 * Posts collection.
19 19 *
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).
20 + * Provides methods to retrieve, create, update, and manage ActivityPub posts (articles, notes, media, etc.).
24 21 */
25 22 class Posts {
26 23 /**
27 - * Create a WordPress post from an ActivityPub activity.
24 + * The post type for the posts.
28 25 *
29 - * @since 8.1.0
26 + * @var string
27 + */
28 + const POST_TYPE = 'ap_post';
29 +
30 + /**
31 + * Maximum number of remote post items to keep.
30 32 *
31 - * @param array $activity The activity data.
32 - * @param int $user_id The local user ID.
33 - * @param string|null $visibility Content visibility.
33 + * @var int
34 + */
35 + const MAX_ITEMS = 5000;
36 +
37 + /**
38 + * Number of items to process per batch during purge.
34 39 *
35 - * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure.
40 + * @var int
36 41 */
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();
42 + const PURGE_BATCH_SIZE = 100;
40 43
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();
44 + /**
45 + * Maximum seconds a purge run may take before yielding.
46 + *
47 + * @var int
48 + */
49 + const PURGE_TIMEOUT = 30;
53 50
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 - }
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 );
61 67 }
62 68
63 - $object = $activity['object'] ?? array();
69 + // Post doesn't exist, create new post.
70 + $actor = Remote_Actors::fetch_by_uri( object_to_uri( $activity_object['attributedTo'] ) );
64 71
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 );
72 + if ( \is_wp_error( $actor ) ) {
73 + return $actor;
74 + }
70 75
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 : '';
76 + $post_array = self::activity_to_post( $activity_object );
77 + $post_id = \wp_insert_post( $post_array, true );
75 78
76 - // Process content: autop, autolink, hashtags, and convert to blocks.
77 - $content = self::prepare_content( $content );
79 + if ( \is_wp_error( $post_id ) ) {
80 + return $post_id;
81 + }
78 82
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, '...' );
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 );
83 88 }
84 89
85 - // Determine visibility if not provided.
86 - if ( null === $visibility ) {
87 - $visibility = get_content_visibility( $activity );
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 + );
88 130 }
89 131
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 - );
132 + return \get_post( $post_id );
133 + }
102 134
103 - $post_id = \wp_insert_post( $post_data, true );
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;
104 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 +
105 155 if ( \is_wp_error( $post_id ) ) {
106 156 return $post_id;
107 157 }
108 158
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' );
159 + // Add new recipients using add_recipient (handles deduplication).
160 + foreach ( $recipients as $user_id ) {
161 + self::add_recipient( $post_id, $user_id );
112 162 }
113 163
164 + self::add_taxonomies( $post_id, $activity['object'] );
165 +
114 166 return \get_post( $post_id );
115 167 }
116 168
117 169 /**
118 - * Update a WordPress post from an ActivityPub activity.
170 + * Delete an object from the collection.
119 171 *
120 - * @since 8.1.0
172 + * @param int $id The object ID.
121 173 *
122 - * @param \WP_Post $post The post to update.
123 - * @param array $activity The activity data.
124 - * @param string|null $visibility Content visibility.
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.
125 182 *
126 - * @return \WP_Post|\WP_Error The updated post on success, WP_Error on failure.
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.
127 186 */
128 - public static function update( $post, $activity, $visibility = null ) {
129 - $object = $activity['object'] ?? array();
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 + }
130 192
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 );
193 + return self::delete( $post->ID );
194 + }
135 195
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 : '';
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();
140 205
141 - // Process content: autop, autolink, hashtags, and convert to blocks.
142 - $content = self::prepare_content( $content );
206 + if ( empty( $tags ) || ! \is_array( $tags ) ) {
207 + return $hashtags;
208 + }
143 209
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, '...' );
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 + }
148 220 }
149 221
150 - // Determine visibility if not provided.
151 - if ( null === $visibility ) {
152 - $visibility = get_content_visibility( $activity );
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;
153 239 }
154 240
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 - ),
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'] ) : '',
164 303 );
304 + }
165 305
166 - $post_id = \wp_update_post( $post_data, true );
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' );
167 315
168 - if ( \is_wp_error( $post_id ) ) {
169 - return $post_id;
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();
170 334 }
171 335
172 - return \get_post( $post_id );
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;
173 364 }
174 365
175 366 /**
176 - * Delete (trash) a WordPress post.
367 + * Get posts by remote actor.
177 368 *
178 - * @since 8.1.0
369 + * @param string $actor The remote actor URI.
179 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 + *
180 408 * @param int $post_id The post ID.
181 409 *
182 - * @return \WP_Post|false|null Post data on success, false or null on failure.
410 + * @return int[] Array of user IDs who are recipients.
183 411 */
184 - public static function delete( $post_id ) {
185 - return \wp_trash_post( $post_id );
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;
186 418 }
187 419
188 420 /**
189 - * Prepare content for storage as a WordPress post.
421 + * Check if a user is a recipient of a post.
190 422 *
191 - * Applies wpautop (for plain text), autolinks bare URLs,
192 - * converts hashtags to links, and wraps in block markup.
423 + * @param int $post_id The post ID.
424 + * @param int $user_id The user ID to check.
193 425 *
194 - * @since 8.1.0
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.
195 436 *
196 - * @param string $content The HTML or plain-text content.
437 + * @param int $post_id The post ID.
438 + * @param int $user_id The user ID to add.
197 439 *
198 - * @return string The processed content with block markup.
440 + * @return bool True on success, false on failure.
199 441 */
200 - public static function prepare_content( $content ) {
201 - if ( empty( $content ) ) {
202 - return '';
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;
203 447 }
204 448
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 );
449 + // Check if already a recipient.
450 + if ( self::has_recipient( $post_id, $user_id ) ) {
451 + return true;
208 452 }
209 453
210 - // Convert bare URLs to links.
211 - $content = Link::the_content( $content );
454 + // Add new recipient as separate meta entry.
455 + return (bool) \add_post_meta( $post_id, '_activitypub_user_id', $user_id, false );
456 + }
212 457
213 - // Convert #hashtags to links.
214 - $content = Hashtag::the_content( $content );
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 + }
215 469
216 - // Convert HTML to block markup.
217 - $content = Blocks::convert_from_html( $content );
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;
218 480
219 - return $content;
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;
220 618 }
221 619 }