diff --git a/CHANGELOG.md b/CHANGELOG.md index 790b48dea..acc2f42f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Batch Outbox-Processing. * Outbox processed events get logged in Stream and show any errors returned from inboxes. +* Outbox items older than 6 months will be purged to avoid performance issues. * REST API endpoints for likes and shares. ### Changed @@ -25,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `object_id_to_comment` returns a commment now, even if there are more than one matching comment in the DB. * Integration of content-visibility setup in the block editor. * Update CLI commands to the new scheduler refactorings. +* `Activity::set_object` falsely overwrites the Activity-ID with a default. ## [5.1.0] - 2025-02-06 diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index 3fa8669f1..6e85ae9ef 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -20,6 +20,38 @@ * * @see https://www.w3.org/TR/activitystreams-core/#activities * @see https://www.w3.org/TR/activitystreams-core/#intransitiveactivities + * + * @method string|array|null get_actor() Gets one or more entities that performed or are expected to perform the activity. + * @method string|null get_id() Gets the object's unique global identifier. + * @method string get_type() Gets the type of the object. + * @method string|null get_name() Gets the natural language name of the object. + * @method string|null get_url() Gets the URL of the object. + * @method string|null get_summary() Gets the natural language summary of the object. + * @method string|null get_published() Gets the date and time the object was published in ISO 8601 format. + * @method string|null get_updated() Gets the date and time the object was updated in ISO 8601 format. + * @method string|null get_attributed_to() Gets the entity attributed as the original author. + * @method array|string|null get_cc() Gets the secondary recipients of the object. + * @method array|string|null get_to() Gets the primary recipients of the object. + * @method array|null get_attachment() Gets the attachment property of the object. + * @method array|null get_icon() Gets the icon property of the object. + * @method array|null get_image() Gets the image property of the object. + * @method Base_Object|string|null get_object() Gets the direct object of the activity. + * @method array|string|null get_in_reply_to() Gets the objects this object is in reply to. + * + * @method Activity set_actor( string|array $actor ) Sets one or more entities that performed the activity. + * @method Activity set_id( string $id ) Sets the object's unique global identifier. + * @method Activity set_type( string $type ) Sets the type of the object. + * @method Activity set_name( string $name ) Sets the natural language name of the object. + * @method Activity set_url( string $url ) Sets the URL of the object. + * @method Activity set_summary( string $summary ) Sets the natural language summary of the object. + * @method Activity set_published( string $published ) Sets the date and time the object was published in ISO 8601 format. + * @method Activity set_updated( string $updated ) Sets the date and time the object was updated in ISO 8601 format. + * @method Activity set_attributed_to( string $attributed_to ) Sets the entity attributed as the original author. + * @method Activity set_cc( array|string $cc ) Sets the secondary recipients of the object. + * @method Activity set_to( array|string $to ) Sets the primary recipients of the object. + * @method Activity set_attachment( array $attachment ) Sets the attachment property of the object. + * @method Activity set_icon( array $icon ) Sets the icon property of the object. + * @method Activity set_image( array $image ) Sets the image property of the object. */ class Activity extends Base_Object { const JSON_LD_CONTEXT = array( @@ -40,10 +72,7 @@ class Activity extends Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object-term * - * @var string - * | Base_Object - * | Link - * | null + * @var string|Base_Object|null */ protected $object; @@ -55,11 +84,7 @@ class Activity extends Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-actor * - * @var string - * | \ActivityPhp\Type\Extended\AbstractActor - * | array - * | array - * | Link + * @var string|array */ protected $actor; @@ -74,11 +99,7 @@ class Activity extends Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-target * - * @var string - * | ObjectType - * | array - * | Link - * | array + * @var string|array */ protected $target; @@ -90,10 +111,7 @@ class Activity extends Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-result * - * @var string - * | ObjectType - * | Link - * | null + * @var string|Base_Object */ protected $result; @@ -106,9 +124,6 @@ class Activity extends Base_Object { * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies * * @var array - * | ObjectType - * | Link - * | null */ protected $replies; @@ -122,10 +137,7 @@ class Activity extends Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-origin * - * @var string - * | ObjectType - * | Link - * | null + * @var string|array */ protected $origin; @@ -135,10 +147,7 @@ class Activity extends Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-instrument * - * @var string - * | ObjectType - * | Link - * | null + * @var string|array */ protected $instrument; @@ -151,8 +160,6 @@ class Activity extends Base_Object { * @see https://www.w3.org/TR/activitypub/#object-without-create * * @param array|string|Base_Object|Link|null $data Activity object. - * - * @return void */ public function set_object( $data ) { // Convert array to object. @@ -171,7 +178,7 @@ public function set_object( $data ) { $this->set( 'object', $data ); // Check if `$data` is a URL and use it to generate an ID then. - if ( is_string( $data ) && filter_var( $data, FILTER_VALIDATE_URL ) ) { + if ( is_string( $data ) && filter_var( $data, FILTER_VALIDATE_URL ) && ! $this->get_id() ) { $this->set( 'id', $data . '#activity-' . strtolower( $this->get_type() ) . '-' . time() ); return; diff --git a/includes/activity/class-actor.php b/includes/activity/class-actor.php index 654ff7f4e..b9876990e 100644 --- a/includes/activity/class-actor.php +++ b/includes/activity/class-actor.php @@ -66,8 +66,7 @@ class Actor extends Base_Object { * * @see https://www.w3.org/TR/activitypub/#inbox * - * @var string - * | null + * @var string|null */ protected $inbox; @@ -77,8 +76,7 @@ class Actor extends Base_Object { * * @see https://www.w3.org/TR/activitypub/#outbox * - * @var string - * | null + * @var string|null */ protected $outbox; diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index 18725d0b2..4e6f12fb6 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -61,12 +61,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attachment * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|null */ protected $attachment; @@ -77,12 +72,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-attributedto * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|null */ protected $attributed_to; @@ -92,12 +82,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audience * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|null */ protected $audience; @@ -127,10 +112,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context * - * @var string - * | ObjectType - * | Link - * | null + * @var string|null */ protected $context; @@ -191,12 +173,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon * - * @var string - * | Image - * | Link - * | array - * | array - * | null + * @var string|array|null */ protected $icon; @@ -207,12 +184,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image-term * - * @var string - * | Image - * | Link - * | array - * | array - * | null + * @var string|array|null */ protected $image; @@ -222,12 +194,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|null */ protected $in_reply_to; @@ -237,12 +204,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-location * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|null */ protected $location; @@ -251,10 +213,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-preview * - * @var string - * | ObjectType - * | Link - * | null + * @var string|null */ protected $preview; @@ -286,10 +245,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary * - * @var string - * | ObjectType - * | Link - * | null + * @var string|null */ protected $summary; @@ -299,7 +255,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-summary * - * @var array|null + * @var string[]|null */ protected $summary_map; @@ -312,12 +268,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|null */ protected $tag; @@ -333,11 +284,7 @@ class Base_Object { /** * One or more links to representations of the object. * - * @var string - * | array - * | Link - * | array - * | null + * @var string|null */ protected $url; @@ -347,12 +294,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-to * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|array|null */ protected $to; @@ -362,12 +304,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-bto * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|array|null */ protected $bto; @@ -377,12 +314,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-cc * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|array|null */ protected $cc; @@ -392,12 +324,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-bcc * - * @var string - * | ObjectType - * | Link - * | array - * | array - * | null + * @var string|array|null */ protected $bcc; @@ -443,10 +370,7 @@ class Base_Object { * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies * - * @var string - * | Collection - * | Link - * | null + * @var string|array|null */ protected $replies; diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index 853fed415..26968cae4 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -244,9 +244,9 @@ function ( $actor ) { /** * Default filter to add Inboxes of Posts that are set as `in-reply-to` * - * @param array $inboxes The list of Inboxes. - * @param int $actor_id The WordPress Actor-ID. - * @param array $activity The ActivityPub Activity. + * @param array $inboxes The list of Inboxes. + * @param int $actor_id The WordPress Actor-ID. + * @param Activity $activity The ActivityPub Activity. * * @return array The filtered Inboxes */ diff --git a/includes/class-migration.php b/includes/class-migration.php index 1b16b6a92..929de7348 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -179,6 +179,9 @@ public static function maybe_migrate() { \wp_schedule_single_event( \time() + 15, 'activitypub_upgrade', array( 'create_comment_outbox_items' ) ); add_action( 'init', 'flush_rewrite_rules', 20 ); } + if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + Scheduler::register_schedules(); + } /* * Add new update routines above this comment. ^ diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 024a94d46..93a27eae2 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -33,6 +33,7 @@ public static function init() { \add_action( 'activitypub_async_batch', array( self::class, 'async_batch' ), 10, 99 ); \add_action( 'activitypub_reprocess_outbox', array( self::class, 'reprocess_outbox' ) ); + \add_action( 'activitypub_outbox_purge', array( self::class, 'purge_outbox' ) ); \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_outbox_activity_for_federation' ) ); \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'schedule_announce_activity' ), 10, 4 ); @@ -69,6 +70,10 @@ public static function register_schedules() { if ( ! \wp_next_scheduled( 'activitypub_reprocess_outbox' ) ) { \wp_schedule_event( time(), 'hourly', 'activitypub_reprocess_outbox' ); } + + if ( ! wp_next_scheduled( 'activitypub_outbox_purge' ) ) { + wp_schedule_event( time(), 'daily', 'activitypub_outbox_purge' ); + } } /** @@ -80,6 +85,7 @@ public static function deregister_schedules() { wp_unschedule_hook( 'activitypub_update_followers' ); wp_unschedule_hook( 'activitypub_cleanup_followers' ); wp_unschedule_hook( 'activitypub_reprocess_outbox' ); + wp_unschedule_hook( 'activitypub_outbox_purge' ); } /** @@ -199,6 +205,39 @@ public static function reprocess_outbox() { } } + /** + * Purge outbox items based on a schedule. + */ + public static function purge_outbox() { + $total_posts = (int) wp_count_posts( Outbox::POST_TYPE )->publish; + if ( $total_posts <= 20 ) { + return; + } + + $days = 180; // TODO: Replace with a setting. + $timezone = new \DateTimeZone( 'UTC' ); + $date = new \DateTime( 'now', $timezone ); + + $date->sub( \DateInterval::createFromDateString( "$days days" ) ); + + $post_ids = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'any', + 'fields' => 'ids', + 'date_query' => array( + array( + 'before' => $date->format( 'Y-m-d' ), + ), + ), + ) + ); + + foreach ( $post_ids as $post_id ) { + \wp_delete_post( $post_id, true ); + } + } + /** * Asynchronously runs batch processing routines. * diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 9c3593ee0..36f7c7851 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -114,6 +114,7 @@ public static function handle_delete( $activity ) { * @param array $activity The delete activity. */ public static function maybe_delete_follower( $activity ) { + /* @var \Activitypub\Model\Follower $follower Follower object. */ $follower = Followers::get_follower_by_actor( $activity['actor'] ); // Verify that Actor is deleted. @@ -142,7 +143,7 @@ public static function maybe_delete_interactions( $activity ) { /** * Delete comments from an Actor. * - * @param array $actor The actor whose comments to delete. + * @param string $actor The URL of the actor whose comments to delete. */ public static function delete_interactions( $actor ) { $comments = Interactions::get_interactions_by_actor( $actor ); diff --git a/includes/model/class-application.php b/includes/model/class-application.php index fbcd1bfc7..0c488dcfa 100644 --- a/includes/model/class-application.php +++ b/includes/model/class-application.php @@ -16,6 +16,8 @@ /** * Application class. + * + * @method int get__id() Gets the internal user ID for the application (always returns APPLICATION_USER_ID). */ class Application extends Actor { /** diff --git a/includes/model/class-blog.php b/includes/model/class-blog.php index e5f860522..e9b383f25 100644 --- a/includes/model/class-blog.php +++ b/includes/model/class-blog.php @@ -22,6 +22,8 @@ /** * Blog class. + * + * @method int get__id() Gets the internal user ID for the blog (always returns BLOG_USER_ID). */ class Blog extends Actor { /** diff --git a/includes/model/class-follower.php b/includes/model/class-follower.php index 477a71886..1c944255c 100644 --- a/includes/model/class-follower.php +++ b/includes/model/class-follower.php @@ -21,6 +21,18 @@ * @author Matthias Pfefferle * * @see https://www.w3.org/TR/activitypub/#follow-activity-inbox + * + * @method int get__id() Gets the post ID of the follower record. + * @method array|null get_image() Gets the follower's profile image data. + * @method string|null get_inbox() Gets the follower's ActivityPub inbox URL. + * @method array|null get_endpoints() Gets the follower's ActivityPub endpoints. + * + * @method Follower set__id( int $id ) Sets the post ID of the follower record. + * @method Follower set_id( string $guid ) Sets the follower's GUID. + * @method Follower set_name( string $name ) Sets the follower's display name. + * @method Follower set_summary( string $summary ) Sets the follower's bio/summary. + * @method Follower set_published( string $datetime ) Sets the follower's published datetime in ISO 8601 format. + * @method Follower set_updated( string $datetime ) Sets the follower's last updated datetime in ISO 8601 format. */ class Follower extends Actor { /** @@ -335,7 +347,9 @@ public function get_shared_inbox() { */ public static function init_from_cpt( $post ) { $actor_json = get_post_meta( $post->ID, '_activitypub_actor_json', true ); - $object = self::init_from_json( $actor_json ); + + /* @var Follower $object Follower object. */ + $object = self::init_from_json( $actor_json ); if ( is_wp_error( $object ) ) { return false; diff --git a/includes/model/class-user.php b/includes/model/class-user.php index 6e6a2432d..581e496c7 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -19,6 +19,8 @@ /** * User class. + * + * @method int get__id() Gets the WordPress user ID. */ class User extends Actor { /** diff --git a/readme.txt b/readme.txt index 84cbd7ebe..976e41fa3 100644 --- a/readme.txt +++ b/readme.txt @@ -133,6 +133,7 @@ For reasons of data protection, it is not possible to see the followers of other * Added: Batch Outbox-Processing. * Added: Outbox processed events get logged in Stream and show any errors returned from inboxes. +* Added: Outbox items older than 6 months will be purged to avoid performance issues. * Added: REST API endpoints for likes and shares. * Changed: Increased probability of Outbox items being processed with the correct author. * Changed: Enabled querying of Outbox posts through the REST API to improve troubleshooting and debugging. @@ -141,6 +142,7 @@ For reasons of data protection, it is not possible to see the followers of other * Fixed: `object_id_to_comment` returns a commment now, even if there are more than one matching comment in the DB. * Fixed: Integration of content-visibility setup in the block editor. * Fixed: Update CLI commands to the new scheduler refactorings. +* Fixed: `Activity::set_object` falsely overwrites the Activity-ID with a default. = 5.1.0 = diff --git a/tests/includes/activity/class-test-activity.php b/tests/includes/activity/class-test-activity.php index c8b894aee..10b5a6194 100644 --- a/tests/includes/activity/class-test-activity.php +++ b/tests/includes/activity/class-test-activity.php @@ -72,6 +72,8 @@ public function test_object_transformation() { /** * Test activity object. + * + * @covers ::init_from_array */ public function test_activity_object() { $test_array = array( @@ -92,8 +94,34 @@ public function test_activity_object() { /** * Test activity object. + * + * @covers ::init_from_array */ public function test_activity_object_url() { + $test_array = array( + 'id' => 'https://example.com/id/123', + 'type' => 'Follow', + 'object' => 'https://example.com/post/123', + ); + + $activity = Activity::init_from_array( $test_array ); + + $this->assertEquals( 'https://example.com/id/123', $activity->get_id() ); + + $test_array2 = array( + 'type' => 'Follow', + 'object' => 'https://example.com/post/123', + ); + + $activity2 = Activity::init_from_array( $test_array2 ); + + $this->assertTrue( str_starts_with( $activity2->get_id(), 'https://example.com/post/123#activity-follow-' ) ); + } + + /** + * Test activity object. + */ + public function test_activity_object_id() { $id = 'https://example.com/author/123'; // Build the update. diff --git a/tests/includes/class-test-scheduler.php b/tests/includes/class-test-scheduler.php index 7bc023737..13ec2cd73 100644 --- a/tests/includes/class-test-scheduler.php +++ b/tests/includes/class-test-scheduler.php @@ -194,4 +194,58 @@ function ( $event ) use ( &$scheduled_time ) { wp_delete_post( $pending_id, true ); remove_all_filters( 'schedule_event' ); } + + /** + * Test purge_outbox method with more than 20 posts. + * + * @covers ::purge_outbox + */ + public function test_purge_outbox_more_than_20_posts() { + // Create 25 posts, 5 older than 6 months. + self::factory()->post->create_many( + 25, + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( '-1 month' ) ), + ) + ); + self::factory()->post->create_many( + 5, + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( '-7 months' ) ), + ) + ); + + Scheduler::purge_outbox(); + wp_cache_delete( _count_posts_cache_key( Outbox::POST_TYPE ), 'counts' ); + + // Assert that 5 posts were deleted, leaving 25. + $this->assertEquals( 25, wp_count_posts( Outbox::POST_TYPE )->publish ); + } + + /** + * Test purge_outbox method with 20 or fewer posts. + * + * @covers ::purge_outbox + */ + public function test_purge_outbox_20_or_fewer_posts() { + // Create 20 posts, all older than 6 months. + self::factory()->post->create_many( + 20, + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_date' => gmdate( 'Y-m-d H:i:s', strtotime( '-7 months' ) ), + ) + ); + + Scheduler::purge_outbox(); + wp_cache_delete( _count_posts_cache_key( Outbox::POST_TYPE ), 'counts' ); + + // Assert that no posts were deleted. + $this->assertEquals( 20, wp_count_posts( Outbox::POST_TYPE )->publish ); + } }