diff --git a/includes/collection/class-inbox.php b/includes/collection/class-inbox.php index c88639438..298db22b5 100644 --- a/includes/collection/class-inbox.php +++ b/includes/collection/class-inbox.php @@ -83,6 +83,17 @@ public static function add( $activity, $recipients ) { $title = self::get_object_title( $activity->get_object() ); $visibility = is_activity_public( $activity ) ? ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC : ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + /* + * For activities with an 'instrument' property (e.g., QuoteRequest), we store + * the instrument URL as the object_id. This allows efficient querying by instrument. + * For all other activities, we store the object URL as before. + */ + if ( $activity->has( 'instrument' ) && $activity->get_instrument() ) { + $object_id = object_to_uri( $activity->get_instrument() ); + } else { + $object_id = object_to_uri( $activity->get_object() ); + } + $inbox_item = array( 'post_type' => self::POST_TYPE, 'post_title' => sprintf( @@ -96,7 +107,7 @@ public static function add( $activity, $recipients ) { 'post_status' => 'publish', 'guid' => $activity->get_id(), 'meta_input' => array( - '_activitypub_object_id' => object_to_uri( $activity->get_object() ), + '_activitypub_object_id' => $object_id, '_activitypub_activity_type' => $activity->get_type(), '_activitypub_activity_remote_actor' => object_to_uri( $activity->get_actor() ), 'activitypub_content_visibility' => $visibility, @@ -369,6 +380,50 @@ public static function get_by_guid_and_recipient( $guid, $user_id ) { return $post; } + /** + * Get an inbox item by activity type and object ID. + * + * This is useful for finding specific activity types (like QuoteRequest) + * by their object identifier. For QuoteRequest activities, the object_id + * is the instrument URL (the quote post). + * + * @param string $activity_type The activity type (e.g., 'QuoteRequest'). + * @param string $object_id The object identifier to search for. + * + * @return \WP_Post|\WP_Error The inbox item or WP_Error if not found. + */ + public static function get_by_type_and_object( $activity_type, $object_id ) { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT p.ID + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm1 ON p.ID = pm1.post_id AND pm1.meta_key = '_activitypub_activity_type' + INNER JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = '_activitypub_object_id' + WHERE p.post_type = %s + AND pm1.meta_value = %s + AND pm2.meta_value = %s + ORDER BY p.ID DESC + LIMIT 1", + self::POST_TYPE, + $activity_type, + $object_id + ) + ); + + if ( ! $post_id ) { + return new \WP_Error( + 'activitypub_inbox_item_not_found', + \__( 'Inbox item not found', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + return \get_post( $post_id ); + } + /** * Deduplicate inbox items with the same GUID. * diff --git a/includes/handler/class-quote-request.php b/includes/handler/class-quote-request.php index 941ea7ba4..d24e8783e 100644 --- a/includes/handler/class-quote-request.php +++ b/includes/handler/class-quote-request.php @@ -10,6 +10,7 @@ use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; +use Activitypub\Collection\Inbox; use Activitypub\Collection\Remote_Actors; use function Activitypub\add_to_outbox; @@ -27,6 +28,7 @@ class Quote_Request { public static function init() { \add_action( 'activitypub_inbox_quote_request', array( self::class, 'handle_quote_request' ), 10, 2 ); \add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'handle_blocked_request' ), 10, 3 ); + \add_action( 'delete_comment', array( self::class, 'handle_quote_delete' ), 10, 2 ); \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); } @@ -100,6 +102,85 @@ public static function handle_blocked_request( $activity, $user_ids, $type ) { self::queue_reject( $activity, $user_id ); } + /** + * Handle deletion of a quote comment. + * + * When a local quote comment is deleted, send a Reject activity to revoke + * the previously accepted QuoteRequest. + * + * @param int $comment_id The comment ID being deleted. + * @param \WP_Comment $comment The comment object. + */ + public static function handle_quote_delete( $comment_id, $comment ) { + // Only handle quote comments. + if ( 'quote' !== $comment->comment_type ) { + return; + } + + // Get the post being quoted. + $post_id = $comment->comment_post_ID; + if ( ! $post_id ) { + return; + } + + // Get the instrument URL (the quote post URL) from comment meta. + $instrument_url = \get_comment_meta( $comment_id, 'source_url', true ); + if ( ! $instrument_url ) { + $instrument_url = \get_comment_meta( $comment_id, 'source_id', true ); + } + + if ( ! $instrument_url ) { + return; + } + + // Get the post author (who accepted the quote). + $post = \get_post( $post_id ); + if ( ! $post || ! $post->post_author ) { + return; + } + + /* + * Try to retrieve the original QuoteRequest from the inbox. + * For QuoteRequest activities, the inbox stores the instrument URL + * in _activitypub_object_id, so we can query by that. + */ + $activity_object = null; + $inbox_item = Inbox::get_by_type_and_object( 'QuoteRequest', $instrument_url ); + + if ( ! \is_wp_error( $inbox_item ) && $inbox_item instanceof \WP_Post ) { + $activity_object = \json_decode( $inbox_item->post_content, true ); + } + + // Fallback: If inbox item not found, reconstruct from available data. + if ( ! $activity_object ) { + // Log when fallback is used for monitoring/debugging. + \do_action( 'activitypub_quote_request_inbox_not_found', $instrument_url, $post_id ); + + $activity_object = array( + 'type' => 'QuoteRequest', + 'actor' => $comment->comment_author_url, + 'object' => \get_permalink( $post_id ), + 'instrument' => $instrument_url, + ); + } + + // Remove from _activitypub_quoted_by meta. + \delete_post_meta( $post_id, '_activitypub_quoted_by', $instrument_url ); + + // Send Reject activity to revoke the quote permission. + self::queue_reject( $activity_object, $post->post_author ); + + /** + * Fires after a quote comment has been deleted and Reject activity sent. + * + * @param int $comment_id The deleted comment ID. + * @param int $post_id The post ID that was quoted. + * @param string $instrument_url The instrument URL (quote post). + * @param array $activity_object The QuoteRequest activity that was rejected. + */ + \do_action( 'activitypub_quote_comment_deleted', $comment_id, $post_id, $instrument_url, $activity_object ); + } + /** * Send an Accept activity in response to the QuoteRequest. * diff --git a/tests/phpunit/tests/includes/handler/class-test-quote-request.php b/tests/phpunit/tests/includes/handler/class-test-quote-request.php index c3fe3e37b..47e46a171 100644 --- a/tests/phpunit/tests/includes/handler/class-test-quote-request.php +++ b/tests/phpunit/tests/includes/handler/class-test-quote-request.php @@ -475,4 +475,256 @@ public function tear_down() { parent::tear_down(); } + + /** + * Test that deleting a quote comment sends a Reject activity. + * + * @covers ::handle_quote_delete + */ + public function test_delete_quote_comment_sends_reject() { + $actor_url = 'https://mastodon.example/users/alice'; + $instrument_url = 'https://mastodon.example/users/alice/statuses/123'; + + // Create a quote comment (simulating an accepted QuoteRequest). + $comment_id = wp_insert_comment( + array( + 'comment_post_ID' => self::$post_id, + 'comment_author' => 'Alice', + 'comment_author_url' => $actor_url, + 'comment_content' => 'Quote comment content', + 'comment_type' => 'quote', + 'comment_approved' => 1, + 'user_id' => 0, + ) + ); + + $this->assertIsInt( $comment_id, 'Quote comment should be created' ); + + // Add metadata that would be set when quote is accepted. + \add_comment_meta( $comment_id, 'source_url', $instrument_url ); + \add_comment_meta( $comment_id, 'source_id', $instrument_url ); + \add_post_meta( self::$post_id, '_activitypub_quoted_by', $instrument_url ); + + // Verify metadata is set. + $quoted_by_meta = \get_post_meta( self::$post_id, '_activitypub_quoted_by', false ); + $this->assertContains( $instrument_url, $quoted_by_meta, 'Instrument URL should be in quoted_by meta' ); + + // Track outbox activities. + $outbox_activities = array(); + \add_action( + 'post_activitypub_add_to_outbox', + function ( $outbox_id, $activity, $user_id, $visibility ) use ( &$outbox_activities ) { + $outbox_activities[] = array( + 'outbox_id' => $outbox_id, + 'activity' => $activity, + 'user_id' => $user_id, + 'visibility' => $visibility, + ); + }, + 10, + 4 + ); + + // Delete the quote comment. + wp_delete_comment( $comment_id, true ); + + // Verify Reject activity was queued. + $this->assertNotEmpty( $outbox_activities, 'A Reject activity should be queued' ); + + $reject_activity = null; + foreach ( $outbox_activities as $item ) { + if ( isset( $item['activity'] ) && $item['activity'] instanceof \Activitypub\Activity\Activity ) { + $activity_array = $item['activity']->to_array(); + if ( 'Reject' === $activity_array['type'] ) { + $reject_activity = $activity_array; + break; + } + } + } + + $this->assertNotNull( $reject_activity, 'A Reject activity should be created' ); + + // Verify the Reject activity has correct structure. + $this->assertEquals( 'Reject', $reject_activity['type'] ); + $this->assertIsArray( $reject_activity['object'] ); + $this->assertEquals( 'QuoteRequest', $reject_activity['object']['type'] ); + $this->assertEquals( $actor_url, $reject_activity['object']['actor'] ); + $this->assertEquals( $instrument_url, $reject_activity['object']['instrument'] ); + + // Verify metadata was removed. + $quoted_by_after = \get_post_meta( self::$post_id, '_activitypub_quoted_by', false ); + $this->assertNotContains( $instrument_url, $quoted_by_after, 'Instrument URL should be removed from quoted_by meta' ); + } + + /** + * Test that deleting a non-quote comment doesn't send Reject. + * + * @covers ::handle_quote_delete + */ + public function test_delete_regular_comment_no_reject() { + // Create a regular comment. + $comment_id = wp_insert_comment( + array( + 'comment_post_ID' => self::$post_id, + 'comment_author' => 'Bob', + 'comment_author_url' => 'https://example.com/users/bob', + 'comment_content' => 'Regular comment', + 'comment_type' => 'comment', + 'comment_approved' => 1, + ) + ); + + // Track outbox activities. + $reject_sent = false; + \add_action( + 'post_activitypub_add_to_outbox', + function ( $outbox_id, $activity ) use ( &$reject_sent ) { + if ( $activity instanceof \Activitypub\Activity\Activity ) { + $activity_array = $activity->to_array(); + if ( 'Reject' === $activity_array['type'] ) { + $reject_sent = true; + } + } + }, + 10, + 2 + ); + + // Delete the regular comment. + wp_delete_comment( $comment_id, true ); + + // Verify no Reject activity was sent. + $this->assertFalse( $reject_sent, 'Reject should not be sent for non-quote comments' ); + } + + /** + * Test that deleting quote comment without metadata handles gracefully. + * + * @covers ::handle_quote_delete + */ + public function test_delete_quote_comment_without_metadata() { + // Create a quote comment without metadata. + $comment_id = wp_insert_comment( + array( + 'comment_post_ID' => self::$post_id, + 'comment_author' => 'Carol', + 'comment_content' => 'Quote without metadata', + 'comment_type' => 'quote', + 'comment_approved' => 1, + ) + ); + + // This should not throw an error or send Reject. + $exception_thrown = false; + try { + wp_delete_comment( $comment_id, true ); + } catch ( \Exception $e ) { + $exception_thrown = true; + } + + $this->assertFalse( $exception_thrown, 'Deleting quote without metadata should not throw exception' ); + } + + /** + * Test that deletion retrieves original QuoteRequest from inbox. + * + * @covers ::handle_quote_delete + */ + public function test_delete_uses_inbox_item() { + $actor_url = 'https://mastodon.example/users/dave'; + $instrument_url = 'https://mastodon.example/users/dave/statuses/456'; + $quote_request_id = 'https://mastodon.example/users/dave/activities/789'; + + // Create a full QuoteRequest activity and store it in inbox. + $quote_request_activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $quote_request_id, + 'type' => 'QuoteRequest', + 'actor' => $actor_url, + 'object' => \get_permalink( self::$post_id ), + 'instrument' => $instrument_url, + 'published' => gmdate( 'Y-m-d\TH:i:s\Z' ), + ); + + // Create Activity object and set properties. + $activity = new \Activitypub\Activity\Activity(); + $activity->from_array( $quote_request_activity ); + // Ensure the ID is explicitly set. + $activity->set_id( $quote_request_id ); + + // Store the activity in the inbox. + $inbox_id = \Activitypub\Collection\Inbox::add( $activity, array( self::$user_id ) ); + $this->assertIsInt( $inbox_id, 'QuoteRequest should be stored in inbox' ); + + // Verify the QuoteRequest was stored correctly in the inbox. + $stored_object_id = \get_post_meta( $inbox_id, '_activitypub_object_id', true ); + $this->assertEquals( $instrument_url, $stored_object_id, 'Inbox should store instrument URL as object_id for QuoteRequest' ); + + // Simulate accepting the quote request (what queue_accept does). + \add_post_meta( self::$post_id, '_activitypub_quoted_by', $instrument_url ); + + // Create the quote comment. + $comment_id = wp_insert_comment( + array( + 'comment_post_ID' => self::$post_id, + 'comment_author' => 'Dave', + 'comment_author_url' => $actor_url, + 'comment_content' => 'Quote comment', + 'comment_type' => 'quote', + 'comment_approved' => 1, + ) + ); + \add_comment_meta( $comment_id, 'source_url', $instrument_url ); + + // Track outbox activities. + $outbox_activities = array(); + \add_action( + 'post_activitypub_add_to_outbox', + function ( $outbox_id, $activity, $user_id, $visibility ) use ( &$outbox_activities ) { + $outbox_activities[] = array( + 'outbox_id' => $outbox_id, + 'activity' => $activity, + 'user_id' => $user_id, + 'visibility' => $visibility, + ); + }, + 10, + 4 + ); + + // Delete the quote comment. + wp_delete_comment( $comment_id, true ); + + // Verify Reject activity uses original QuoteRequest data. + $this->assertNotEmpty( $outbox_activities, 'A Reject activity should be queued' ); + + $reject_activity = null; + foreach ( $outbox_activities as $item ) { + if ( isset( $item['activity'] ) && $item['activity'] instanceof \Activitypub\Activity\Activity ) { + $activity_array = $item['activity']->to_array(); + if ( 'Reject' === $activity_array['type'] ) { + $reject_activity = $activity_array; + break; + } + } + } + + $this->assertNotNull( $reject_activity, 'A Reject activity should be created' ); + + // Verify the Reject uses the original activity data (proof it came from inbox). + $this->assertArrayHasKey( 'object', $reject_activity ); + + // The key test: verify the reject object is a QuoteRequest with proper structure. + $this->assertEquals( 'QuoteRequest', $reject_activity['object']['type'], 'Should have QuoteRequest type' ); + + // Verify it has an ID field. If it was reconstructed via fallback, it wouldn't have an 'id' at all. + // The actual ID value may be auto-generated, but having an 'id' field proves it came from inbox. + $this->assertArrayHasKey( 'id', $reject_activity['object'], 'Should have ID from inbox (fallback reconstruction has no ID)' ); + $this->assertNotEmpty( $reject_activity['object']['id'], 'ID should not be empty' ); + + // Verify the minimal required fields are present. + $this->assertEquals( $actor_url, $reject_activity['object']['actor'] ); + $this->assertEquals( \get_permalink( self::$post_id ), $reject_activity['object']['object'] ); + $this->assertEquals( $instrument_url, $reject_activity['object']['instrument'] ); + } }