get_actor() ) { $activity->set_actor( Actors::get_by_id( $user_id )->get_id() ); } $object_id = object_to_uri( self::get_object_id( $activity ) ); $title = self::get_object_title( $activity->get_object() ); if ( ! $object_id || ! \is_string( $object_id ) ) { return new \WP_Error( 'activitypub_outbox_invalid_object_id', \__( 'Unable to determine an object ID for this activity.', 'activitypub' ), array( 'status' => 400 ) ); } if ( ! \filter_var( $object_id, FILTER_VALIDATE_URL ) ) { $object_id = Webfinger::resolve( $object_id ); } if ( \is_wp_error( $object_id ) ) { return $object_id; } // Save activity in the context of an activitypub request. \add_filter( 'activitypub_is_activitypub_request', '__return_true' ); $outbox_item = array( 'post_type' => self::POST_TYPE, 'post_title' => sprintf( /* translators: 1. Activity type, 2. Object Title or Excerpt */ __( '[%1$s] %2$s', 'activitypub' ), $activity->get_type(), \wp_trim_words( $title, 5 ) ), // Persist the blind audience so later dispatch can compute recipients from `bto`/`bcc`. 'post_content' => wp_slash( $activity->to_json( true, true ) ), // ensure that user ID is not below 0. 'post_author' => \max( $user_id, 0 ), 'post_status' => 'pending', 'meta_input' => array( '_activitypub_object_id' => $object_id, '_activitypub_activity_type' => $activity->get_type(), '_activitypub_activity_actor' => $actor_type, 'activitypub_content_visibility' => $visibility, ), ); \remove_filter( 'activitypub_is_activitypub_request', '__return_true' ); $has_kses = false !== \has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $has_kses ) { // Prevent KSES from corrupting JSON in post_content. \kses_remove_filters(); } $id = \wp_insert_post( $outbox_item, true ); // Update the activity ID if the post was inserted successfully. if ( $id && ! \is_wp_error( $id ) ) { $activity->set_id( \get_the_guid( $id ) ); \wp_update_post( array( 'ID' => $id, 'post_content' => \wp_slash( $activity->to_json( true, true ) ), ) ); } if ( $has_kses ) { \kses_init_filters(); } if ( \is_wp_error( $id ) ) { return $id; } if ( ! $id ) { return false; } self::delete_superseded_items( $object_id, $activity->get_type(), $id ); return $id; } /** * Delete pending outbox items that have been superseded by a newer item. * * For most activity types, only items with the same type and object ID are * deleted. Delete activities are a special case: they supersede all pending * items for the same object regardless of type. * * Unschedules all federation events before deleting each item. * Skips Follow, Announce, Accept, and Reject activities, as those are * independent per-request responses that must not cancel each other. * * @param string $object_id The ActivityPub object ID (URL). * @param string $activity_type The activity type (e.g. 'Create', 'Update', 'Delete'). * @param int $exclude_id The ID of the newly added outbox item to keep. * * @return void */ private static function delete_superseded_items( $object_id, $activity_type, $exclude_id ) { /* * Do not delete items for Follow, Announce, Accept, or Reject activities. * Follow activities from different users share the same object ID but are * independent and must survive until their Accept is received. * Accept/Reject are per-request responses (e.g. to individual incoming * QuoteRequests) and must not cancel each other even when they share * the same object ID. */ if ( in_array( $activity_type, array( 'Follow', 'Announce', 'Accept', 'Reject' ), true ) ) { return; } $meta_query = array( array( 'key' => '_activitypub_object_id', 'value' => $object_id, ), ); // For non-Delete activities, only delete items of the same type. // Delete activities supersede all pending items for the same object. if ( 'Delete' !== $activity_type ) { $meta_query[] = array( 'key' => '_activitypub_activity_type', 'value' => $activity_type, ); } $existing_items = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'pending', 'exclude' => array( $exclude_id ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => $meta_query, 'fields' => 'ids', ) ); foreach ( $existing_items as $existing_item_id ) { Scheduler::unschedule_events_for_item( $existing_item_id ); \wp_delete_post( $existing_item_id, true ); } } /** * Creates an Undo activity. * * @param int|\WP_Post $outbox_item The Outbox post or post ID. * * @return int|bool|\WP_Error The ID of the outbox item or false on failure. */ public static function undo( $outbox_item ) { $outbox_item = \get_post( $outbox_item ); $activity = self::get_activity( $outbox_item ); if ( \is_wp_error( $activity ) ) { return $activity; } $type = 'Undo'; if ( 'Create' === $activity->get_type() ) { $type = 'Delete'; } elseif ( 'Add' === $activity->get_type() ) { $type = 'Remove'; } $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true ); return add_to_outbox( $activity, $type, $outbox_item->post_author, $visibility ); } /** * Get an outbox item by object ID and activity type. * * @param string $object_id The ActivityPub object ID. * @param string $activity_type The activity type (Create, Update, etc.). * * @return \WP_Post|null The outbox item or null if not found. */ public static function get_by_object_id( $object_id, $activity_type ) { $outbox_items = \get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'any', 'posts_per_page' => 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => '_activitypub_object_id', 'value' => $object_id, ), array( 'key' => '_activitypub_activity_type', 'value' => $activity_type, ), ), ) ); return ! empty( $outbox_items ) ? $outbox_items[0] : null; } /** * Get an outbox item by its GUID. * * @param string $guid The GUID of the outbox item. * * @return \WP_Post|\WP_Error The outbox item or WP_Error. */ public static function get_by_guid( $guid ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $post_id = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s", \esc_url( $guid ), self::POST_TYPE ) ); if ( ! $post_id ) { return new \WP_Error( 'activitypub_outbox_item_not_found', \__( 'Outbox item not found', 'activitypub' ), array( 'status' => 404 ) ); } return \get_post( $post_id ); } /** * Reschedule an activity. * * @param int|\WP_Post $outbox_item The Outbox post or post ID. * * @return bool True if the activity was rescheduled, false otherwise. */ public static function reschedule( $outbox_item ) { $outbox_item = get_post( $outbox_item ); $outbox_item->post_status = 'pending'; $outbox_item->post_date = current_time( 'mysql' ); wp_update_post( $outbox_item ); Scheduler::schedule_outbox_activity_for_federation( $outbox_item->ID ); return true; } /** * Get the Activity object from the Outbox item. * * @param int|\WP_Post $outbox_item The Outbox post or post ID. * @return Activity|\WP_Error The Activity object or WP_Error. */ public static function get_activity( $outbox_item ) { $outbox_item = \get_post( $outbox_item ); if ( ! $outbox_item ) { return new \WP_Error( 'activitypub_outbox_item_not_found', \__( 'Outbox item not found.', 'activitypub' ), array( 'status' => 404 ) ); } $activity_object = \json_decode( $outbox_item->post_content, true ); $type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); if ( $activity_object['type'] === $type ) { $activity = Activity::init_from_array( $activity_object ); if ( ! $activity->get_actor() ) { $actor = self::get_actor( $outbox_item ); if ( \is_wp_error( $actor ) ) { return $actor; } $activity->set_actor( $actor->get_id() ); } } else { $actor = self::get_actor( $outbox_item ); if ( \is_wp_error( $actor ) ) { return $actor; } $activity = new Activity(); $activity->set_type( $type ); $activity->set_id( $outbox_item->guid ); $activity->set_actor( $actor->get_id() ); // Pre-fill the Activity with data (for example cc and to). $activity->set_object( $activity_object ); } if ( 'Update' === $type ) { $activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified ) ) ); } /** * Filters the Activity object before it is returned. * * @param Activity $activity The Activity object. * @param \WP_Post $outbox_item The outbox item post object. */ return apply_filters( 'activitypub_get_outbox_activity', $activity, $outbox_item ); } /** * Get the Actor object from the Outbox item. * * @param \WP_Post $outbox_item The Outbox post. * * @return \Activitypub\Model\User|\Activitypub\Model\Blog|\WP_Error The Actor object or WP_Error. */ public static function get_actor( $outbox_item ) { $actor_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_actor', true ); switch ( $actor_type ) { case 'blog': $actor_id = Actors::BLOG_USER_ID; break; case 'application': $actor_id = Actors::APPLICATION_USER_ID; break; case 'user': default: $actor_id = $outbox_item->post_author; break; } return Actors::get_by_id( $actor_id ); } /** * Get the Activity object from the Outbox item. * * @param \WP_Post $outbox_item The Outbox post. * * @return Activity|\WP_Error The Activity object or WP_Error. */ public static function maybe_get_activity( $outbox_item ) { if ( ! $outbox_item instanceof \WP_Post ) { return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' ); } if ( 'ap_outbox' !== $outbox_item->post_type ) { return new \WP_Error( 'invalid_outbox_item', 'Invalid Outbox item.' ); } // Authenticate via Bearer token for non-REST requests (e.g. permalink access). if ( \get_option( 'activitypub_api', false ) && ! \is_user_logged_in() && ! \wp_is_serving_rest_request() ) { \Activitypub\OAuth\Server::authenticate_oauth( null ); } /* * Allow the author to view their own outbox items regardless of visibility. * The `is_user_logged_in()` guard prevents anonymous visitors from matching * the blog actor's items (where both `get_current_user_id()` and `post_author` * are `0`), which would otherwise expose private activities at their permalink. * * Users authorized to act as the blog actor are treated as the author of * blog-actor items so they can read the same private outbox they can post to. */ if ( \is_user_logged_in() ) { $author = (int) $outbox_item->post_author; if ( \get_current_user_id() === $author ) { return self::get_activity( $outbox_item ); } if ( Actors::BLOG_USER_ID === $author && user_can_act_as_blog() ) { return self::get_activity( $outbox_item ); } } // Check if Outbox Activity is public. $visibility = \get_post_meta( $outbox_item->ID, 'activitypub_content_visibility', true ); if ( ! in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC ), true ) ) { return new \WP_Error( 'private_outbox_item', 'Not a public Outbox item.' ); } $activity_types = \apply_filters( 'rest_activitypub_outbox_activity_types', self::ACTIVITY_TYPES ); $activity_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true ); if ( ! in_array( $activity_type, $activity_types, true ) ) { return new \WP_Error( 'private_outbox_item', 'Not public Outbox item type.' ); } return self::get_activity( $outbox_item ); } /** * Get the object ID of an activity. * * @param Activity|Base_Object|string $data The activity object. * * @return string|null The object ID. */ private static function get_object_id( $data ) { $object = $data->get_object(); if ( is_object( $object ) ) { return self::get_object_id( $object ); } if ( is_string( $object ) ) { return $object; } if ( $data->get_id() ) { return $data->get_id(); } return object_to_uri( $data->get_actor() ); } /** * Get the title of an activity recursively. * * @param Activity|Base_Object $activity_object The activity object. * * @return string The title. */ private static function get_object_title( $activity_object ) { if ( ! $activity_object ) { return ''; } if ( is_string( $activity_object ) ) { $post_id = url_to_postid( $activity_object ); return $post_id ? get_the_title( $post_id ) : ''; } $title = $activity_object->get_name() ?: $activity_object->get_content(); if ( ! $title && $activity_object->get_object() instanceof Base_Object ) { $title = $activity_object->get_object()->get_name() ?: $activity_object->get_object()->get_content(); } return $title; } /** * Purge old outbox items. * * Deletes outbox items older than the specified number of days, * except for Follow activities which are always preserved. * Also enforces a hard cap on total items via MAX_ITEMS. * * @param int $days Number of days to keep items. Items older than this will be deleted. * * @return int The number of items deleted. */ public static function purge( $days ) { if ( $days <= 0 ) { return 0; } $counts = \wp_count_posts( self::POST_TYPE ); $total = 0; foreach ( $counts as $count ) { $total += (int) $count; } if ( $total <= 20 ) { return 0; } $deleted = 0; $cutoff = \gmdate( 'Y-m-d', \time() - ( $days * DAY_IN_SECONDS ) ); $start_time = \time(); // If total exceeds the hard cap, drop the date filter to purge oldest items first. $overflow = $total > self::MAX_ITEMS; $date_query = array( array( 'before' => $cutoff, ), ); $query_args = array( 'post_type' => self::POST_TYPE, 'post_status' => 'any', 'fields' => 'ids', 'numberposts' => self::PURGE_BATCH_SIZE, 'orderby' => 'date', 'order' => 'ASC', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => '_activitypub_activity_type', 'value' => 'Follow', 'compare' => '!=', ), ), ); if ( ! $overflow ) { $query_args['date_query'] = $date_query; } do { $post_ids = \get_posts( $query_args ); foreach ( $post_ids as $post_id ) { \wp_delete_post( $post_id, true ); ++$deleted; } // Once we're back under the cap, re-apply the date filter. if ( $overflow && ( $total - $deleted ) <= self::MAX_ITEMS ) { $overflow = false; $query_args['date_query'] = $date_query; } } while ( ! empty( $post_ids ) && ( \time() - $start_time ) < self::PURGE_TIMEOUT ); return $deleted; } }