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; } $now = \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339 ); if ( ! $activity->get_published() ) { $activity->set_published( $now ); } if ( 'Update' === $activity->get_type() ) { // The queue time overrides what the transformer read off the post, which is its last content change. $activity->set_updated( $now ); } // Save activity in the context of an activitypub request. \add_filter( 'activitypub_is_activitypub_request', '__return_true' ); $date = \current_time( 'mysql' ); $outbox_item = array( 'post_type' => self::POST_TYPE, 'post_date' => $date, /* * A `pending` row would otherwise keep the `0000-00-00 00:00:00` sentinel here, because * WordPress only derives this column for statuses that do not float and wp_publish_post() * never repairs it, leaving every reader to compare a local date against a GMT one. */ 'post_date_gmt' => \get_gmt_from_date( $date ), '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, Reject, and QuoteRequest activities, as * those are independent per-request items 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, Reject, or QuoteRequest 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. * A QuoteRequest's object ID is the quoted URI, so two local posts quoting * the same object must not cancel each other's request. */ if ( \in_array( $activity_type, array( 'Follow', 'Announce', 'Accept', 'Reject', 'QuoteRequest' ), true ) ) { return; } $meta_query = array( array( 'key' => '_activitypub_object_id', 'value' => $object_id, ), ); /* * Same-type pending items are always superseded. A confirmed * re-publish (Create) additionally invalidates a pending Delete so * we do not send both Delete and Create for the same object. * * Update is intentionally NOT in this list: it must not cancel a * pending Delete, or an unrelated edit could flip a hidden object back * to federated. An Update for an already-deleted object is rejected * upstream in `add_to_outbox()`, and the scheduler re-publish path emits * a Create (not an Update), so that legitimate path still cancels Delete. */ if ( 'Delete' !== $activity_type ) { $types = 'Create' === $activity_type ? array( 'Create', 'Delete' ) : array( $activity_type ); $meta_query[] = array( 'key' => '_activitypub_activity_type', 'value' => $types, 'compare' => 'IN', ); } /* * Delete wipes the entire outbox history for the object — any * already-sent Create/Update/etc. is now stale and a redelivery * retry would resurrect content we are tearing down. Other * activity types only invalidate pending peers. */ $status_filter = 'Delete' === $activity_type ? 'any' : 'pending'; $existing_items = \get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => $status_filter, 'exclude' => array( $exclude_id ), 'numberposts' => -1, // 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 ); /* * The retraction is its own activity, and pre_fill_activity_from_object() copies dates from the * object it wraps, so the activity being retracted would otherwise lend it its publication date. */ $activity->set_published( null ); $activity->set_updated( null ); return add_to_outbox( $activity, $type, (int) $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 ); $date = \current_time( 'mysql' ); \wp_update_post( array( 'ID' => $outbox_item->ID, 'post_status' => 'pending', 'post_date' => $date, // Both columns move together, because readers take the date from whichever one they use. 'post_date_gmt' => \get_gmt_from_date( $date ), // Without this, WordPress reads an undated edit of a `pending` row that still holds the `0000-00-00 00:00:00` sentinel as a request to keep floating, and drops the dates passed here. 'edit_date' => true, ) ); 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 = Activity::init_from_json( $outbox_item->post_content ); if ( \is_wp_error( $activity ) ) { return $activity; } // A row written before 5.6.0 holds the object, so it hydrates with an object type. It cannot be dispatched as it stands, and rebuilding it on every read is not worth carrying. if ( ! is_activity( $activity ) ) { return new \WP_Error( 'activitypub_outbox_item_invalid', \__( 'Outbox item does not hold an activity.', 'activitypub' ), array( 'status' => 500 ) ); } /** * Filters the Activity object before it is returned. * * The row deliberately keeps more than it sends, a Delete keeps its Tombstone and a hand-built * Like its object, and this is the last point before any consumer sees it. * * @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 '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() ) { 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. * * An OAuth caller additionally needs `read`, the scope the paged outbox asks for. */ if ( \is_user_logged_in() && Server::permits_scope( Scope::READ ) ) { $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 || \is_array( $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 and * QuoteRequest activities, which are always preserved because a later Accept or Reject * looks them up by their original outbox GUID. * 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' => array( 'Follow', 'QuoteRequest' ), 'compare' => 'NOT IN', ), ), ); 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; } }