diff --git a/.github/changelog/fix-c2s-activity-date-fallback b/.github/changelog/fix-c2s-activity-date-fallback new file mode 100644 index 0000000000..d4e38cb445 --- /dev/null +++ b/.github/changelog/fix-c2s-activity-date-fallback @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Activities read from the inbox and outbox C2S endpoints now use the local record date as a fallback when no publish date is set, so client apps show consistent timestamps. diff --git a/includes/collection/class-inbox.php b/includes/collection/class-inbox.php index 55778a6b52..92d12d6ea8 100644 --- a/includes/collection/class-inbox.php +++ b/includes/collection/class-inbox.php @@ -225,6 +225,60 @@ public static function get_by_guid( $guid ) { return \get_post( $post_id ); } + /** + * Reconstruct the Activity stored in an inbox item. + * + * Hydrates the JSON from `post_content` into an Activity object and, when + * the activity is missing `published`/`updated`, falls back to the inbox + * row's `post_date_gmt`/`post_modified_gmt`. The CPT timestamps record when + * we received the activity, which is the natural fallback when the remote + * sender omitted those fields. + * + * @param int|\WP_Post $inbox_item The inbox post or post ID. + * + * @return Activity|\WP_Error The Activity object or WP_Error. + */ + public static function get_activity( $inbox_item ) { + $inbox_item = \get_post( $inbox_item ); + + if ( ! $inbox_item || self::POST_TYPE !== $inbox_item->post_type ) { + return new \WP_Error( + 'activitypub_inbox_item_not_found', + \__( 'Inbox item not found.', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + $data = \json_decode( $inbox_item->post_content, true ); + + if ( ! \is_array( $data ) ) { + return new \WP_Error( + 'activitypub_inbox_item_invalid', + \__( 'Inbox item is not a valid activity.', 'activitypub' ), + array( 'status' => 500 ) + ); + } + + $activity = Activity::init_from_array( $data ); + + if ( \is_wp_error( $activity ) ) { + return $activity; + } + + $post_date_gmt = empty( $inbox_item->post_date_gmt ) || '0000-00-00 00:00:00' === $inbox_item->post_date_gmt ? '' : $inbox_item->post_date_gmt; + $post_modified_gmt = empty( $inbox_item->post_modified_gmt ) || '0000-00-00 00:00:00' === $inbox_item->post_modified_gmt ? '' : $inbox_item->post_modified_gmt; + + if ( ! $activity->get_published() && $post_date_gmt ) { + $activity->set_published( \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, \strtotime( $post_date_gmt ) ) ); + } + + if ( ! $activity->get_updated() && $post_modified_gmt && $post_modified_gmt > $post_date_gmt ) { + $activity->set_updated( \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, \strtotime( $post_modified_gmt ) ) ); + } + + return $activity; + } + /** * Undo a received activity. * diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index f30c5ff3d2..463ef35b95 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -395,8 +395,45 @@ public static function get_activity( $outbox_item ) { $activity->set_object( $activity_object ); } - if ( 'Update' === $type ) { - $activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified ) ) ); + /* + * Fall back to the outbox row's timestamps when the hydrated activity is + * missing `published`/`updated`. The CPT row is the authoritative record + * of when the activity was emitted, so dropping it on the floor here + * means downstream consumers (federation, REST listings, audit tooling) + * see a date-less activity even though we know exactly when it left. + * + * `Outbox::add` inserts with `post_status = 'pending'`, which leaves the + * `_gmt` columns as the `0000-00-00 00:00:00` sentinel while the local + * columns are populated via `current_time( 'mysql' )`. The Dispatcher + * reads the activity while the row is still pending, so the GMT columns + * are derived from the local columns when the sentinel is present — + * synthesizing `1970-01-01T00:00:00Z` would be worse than the field + * being empty, and `Update` activities would lose `updated` on + * federation if we relied on the GMT column alone. + */ + $post_date_gmt = $outbox_item->post_date_gmt; + if ( empty( $post_date_gmt ) || '0000-00-00 00:00:00' === $post_date_gmt ) { + $post_date_gmt = empty( $outbox_item->post_date ) || '0000-00-00 00:00:00' === $outbox_item->post_date + ? '' + : \get_gmt_from_date( $outbox_item->post_date ); + } + + $post_modified_gmt = $outbox_item->post_modified_gmt; + if ( empty( $post_modified_gmt ) || '0000-00-00 00:00:00' === $post_modified_gmt ) { + $post_modified_gmt = empty( $outbox_item->post_modified ) || '0000-00-00 00:00:00' === $outbox_item->post_modified + ? '' + : \get_gmt_from_date( $outbox_item->post_modified ); + } + + if ( ! $activity->get_published() && $post_date_gmt ) { + $activity->set_published( \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, \strtotime( $post_date_gmt ) ) ); + } + + if ( ! $activity->get_updated() && $post_modified_gmt ) { + $needs_updated = ( 'Update' === $type ) || ( $post_modified_gmt > $post_date_gmt ); + if ( $needs_updated ) { + $activity->set_updated( \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, \strtotime( $post_modified_gmt ) ) ); + } } /** diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 855e9e009e..d46120e2ae 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -207,7 +207,13 @@ public function get_items( $request ) { continue; } - $response['orderedItems'][] = $this->prepare_item_for_response( $inbox_item, $request ); + $item = $this->prepare_item_for_response( $inbox_item, $request ); + + if ( \is_wp_error( $item ) ) { + continue; + } + + $response['orderedItems'][] = $item; } $response = $this->prepare_collection_response( $response, $request ); @@ -249,12 +255,16 @@ public function get_items( $request ) { * * @param mixed $item WordPress representation of the item. * @param \WP_REST_Request $request Request object. - * @return array Response object on success. + * @return array|\WP_Error Response object on success, or WP_Error object on failure. */ public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $activity = \json_decode( $item->post_content, true ); + $activity = Inbox::get_activity( $item->ID ); + + if ( \is_wp_error( $activity ) ) { + return $activity; + } - return $activity; + return $activity->to_array( false ); } /** diff --git a/tests/phpunit/tests/includes/collection/class-test-inbox.php b/tests/phpunit/tests/includes/collection/class-test-inbox.php index cb64c292fc..f90269b709 100644 --- a/tests/phpunit/tests/includes/collection/class-test-inbox.php +++ b/tests/phpunit/tests/includes/collection/class-test-inbox.php @@ -1283,4 +1283,119 @@ public function test_purge_returns_deleted_count() { // Should return exact count of deleted posts. $this->assertEquals( 15, $deleted ); } + + /** + * Helper: build a Create activity and store it in the inbox. + * + * @return int Inbox post ID. + */ + private function add_test_activity_to_inbox() { + $object = new Base_Object(); + $object->set_id( 'https://remote.example.com/objects/' . \wp_generate_uuid4() ); + $object->set_type( 'Note' ); + $object->set_content( 'fallback-published-test' ); + + $activity = new Activity(); + $activity->set_id( 'https://remote.example.com/activities/' . \wp_generate_uuid4() ); + $activity->set_type( 'Create' ); + $activity->set_actor( 'https://remote.example.com/users/testuser' ); + $activity->set_object( $object ); + + $id = Inbox::add( $activity, 1 ); + $this->assertIsInt( $id ); + + return $id; + } + + /** + * `Inbox::get_activity` fills `published` from `post_date_gmt` when the stored JSON has none. + * + * @covers ::get_activity + */ + public function test_get_activity_fills_missing_published_from_post_date_gmt() { + $id = $this->add_test_activity_to_inbox(); + + $post = \get_post( $id ); + $raw = \json_decode( $post->post_content, true ); + unset( $raw['published'] ); + \wp_update_post( + array( + 'ID' => $id, + 'post_content' => \wp_slash( \wp_json_encode( $raw ) ), + ) + ); + + $activity = Inbox::get_activity( $id ); + $this->assertInstanceOf( Activity::class, $activity ); + + $post = \get_post( $id ); + $expected = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_date_gmt ) ); + $this->assertEquals( $expected, $activity->get_published() ); + } + + /** + * `Inbox::get_activity` preserves a `published` value that was present in the stored JSON. + * + * @covers ::get_activity + */ + public function test_get_activity_preserves_existing_published() { + $id = $this->add_test_activity_to_inbox(); + + $frozen = '2019-06-07T08:09:10Z'; + $post = \get_post( $id ); + $raw = \json_decode( $post->post_content, true ); + $raw['published'] = $frozen; + \wp_update_post( + array( + 'ID' => $id, + 'post_content' => \wp_slash( \wp_json_encode( $raw ) ), + ) + ); + + $activity = Inbox::get_activity( $id ); + $this->assertEquals( $frozen, $activity->get_published() ); + } + + /** + * Invalid post ID returns WP_Error. + * + * @covers ::get_activity + */ + public function test_get_activity_invalid_post_id_returns_error() { + $result = Inbox::get_activity( 999999999 ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_inbox_item_not_found', $result->get_error_code() ); + } + + /** + * A non-inbox post (wrong post_type) returns WP_Error. + * + * @covers ::get_activity + */ + public function test_get_activity_wrong_post_type_returns_error() { + $regular_post_id = self::factory()->post->create(); + $result = Inbox::get_activity( $regular_post_id ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_inbox_item_not_found', $result->get_error_code() ); + } + + /** + * Corrupt post_content returns WP_Error. + * + * @covers ::get_activity + */ + public function test_get_activity_corrupt_json_returns_error() { + $id = $this->add_test_activity_to_inbox(); + + \wp_update_post( + array( + 'ID' => $id, + 'post_content' => '{not valid json', + ) + ); + + $result = Inbox::get_activity( $id ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_inbox_item_invalid', $result->get_error_code() ); + } } diff --git a/tests/phpunit/tests/includes/collection/class-test-outbox.php b/tests/phpunit/tests/includes/collection/class-test-outbox.php index 80eaa5fad4..215acedb54 100644 --- a/tests/phpunit/tests/includes/collection/class-test-outbox.php +++ b/tests/phpunit/tests/includes/collection/class-test-outbox.php @@ -653,16 +653,259 @@ public function test_update_activity_has_updated_attribute() { $id = \Activitypub\add_to_outbox( $object, 'Update', 1 ); $this->assertNotFalse( $id ); + global $wpdb; + $now = \gmdate( 'Y-m-d H:i:s' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->update( + $wpdb->posts, + array( + 'post_date' => $now, + 'post_date_gmt' => $now, + 'post_modified' => $now, + 'post_modified_gmt' => $now, + ), + array( 'ID' => $id ) + ); + \clean_post_cache( $id ); + // Get the activity from the outbox. $activity = Outbox::get_activity( $id ); $this->assertNotInstanceOf( \WP_Error::class, $activity ); // Verify the updated attribute is set and matches the post's modified date. $post = \get_post( $id ); - $expected_updated = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_modified ) ); + $expected_updated = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_modified_gmt ) ); $this->assertEquals( $expected_updated, $activity->get_updated() ); } + /** + * Stored Create activity without `published` gets it from the outbox row. + * + * @covers ::get_activity + */ + public function test_get_activity_fills_missing_published_from_post_date_gmt() { + $object = $this->get_dummy_activity_object(); + $id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); + $this->assertNotFalse( $id ); + + // Strip `published` from the stored JSON to simulate a transformer that omitted it. + $post = \get_post( $id ); + $raw = \json_decode( $post->post_content, true ); + unset( $raw['published'] ); + \wp_update_post( + array( + 'ID' => $id, + 'post_content' => \wp_slash( \wp_json_encode( $raw ) ), + ) + ); + + // Populate post_date_gmt explicitly — Outbox::add inserts with status=pending, + // so WordPress leaves the GMT date as 0000-00-00 00:00:00 and the fallback + // would otherwise round-trip an epoch-zero value. + global $wpdb; + $now = \gmdate( 'Y-m-d H:i:s' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->update( + $wpdb->posts, + array( + 'post_date' => $now, + 'post_date_gmt' => $now, + ), + array( 'ID' => $id ) + ); + \clean_post_cache( $id ); + + $activity = Outbox::get_activity( $id ); + $this->assertNotInstanceOf( \WP_Error::class, $activity ); + + $post = \get_post( $id ); + $expected = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $post->post_date_gmt ) ); + $this->assertEquals( $expected, $activity->get_published() ); + } + + /** + * Stored activity that already has a `published` value is not overwritten. + * + * @covers ::get_activity + */ + public function test_get_activity_preserves_existing_published() { + $object = $this->get_dummy_activity_object(); + $id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); + $this->assertNotFalse( $id ); + + $frozen = '2020-01-02T03:04:05Z'; + $post = \get_post( $id ); + $raw = \json_decode( $post->post_content, true ); + $raw['published'] = $frozen; + \wp_update_post( + array( + 'ID' => $id, + 'post_content' => \wp_slash( \wp_json_encode( $raw ) ), + ) + ); + + $activity = Outbox::get_activity( $id ); + $this->assertEquals( $frozen, $activity->get_published() ); + } + + /** + * Zero-sentinel `post_date_gmt` must not synthesize a 1970-01-01 published date. + * + * @covers ::get_activity + */ + public function test_get_activity_does_not_synthesize_epoch_published_from_zero_date() { + $object = $this->get_dummy_activity_object(); + $id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); + $this->assertNotFalse( $id ); + + // Strip `published` from the stored JSON. + $post = \get_post( $id ); + $raw = \json_decode( $post->post_content, true ); + unset( $raw['published'] ); + \wp_update_post( + array( + 'ID' => $id, + 'post_content' => \wp_slash( \wp_json_encode( $raw ) ), + ) + ); + + // Force the zero sentinel that pending outbox rows actually have. + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->update( + $wpdb->posts, + array( + 'post_date' => '0000-00-00 00:00:00', + 'post_date_gmt' => '0000-00-00 00:00:00', + 'post_modified' => '0000-00-00 00:00:00', + 'post_modified_gmt' => '0000-00-00 00:00:00', + ), + array( 'ID' => $id ) + ); + \clean_post_cache( $id ); + + $activity = Outbox::get_activity( $id ); + $this->assertEmpty( $activity->get_published() ); + $this->assertEmpty( $activity->get_updated() ); + } + + /** + * Pending outbox row (sentinel GMT, populated local) — the Dispatcher reads + * the row in this state, so an `Update` activity must still federate with + * `updated` derived from `post_modified` via `get_gmt_from_date()`. + * + * @covers ::get_activity + */ + public function test_get_activity_derives_gmt_from_local_for_pending_update_row() { + $object = $this->get_dummy_activity_object(); + $id = \Activitypub\add_to_outbox( $object, 'Update', 1 ); + $this->assertNotFalse( $id ); + + // Reproduce the column state of a freshly-added, still-pending outbox row: + // local columns populated, GMT columns left as the zero sentinel. + $local = \current_time( 'mysql' ); + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->update( + $wpdb->posts, + array( + 'post_date' => $local, + 'post_date_gmt' => '0000-00-00 00:00:00', + 'post_modified' => $local, + 'post_modified_gmt' => '0000-00-00 00:00:00', + ), + array( 'ID' => $id ) + ); + \clean_post_cache( $id ); + + $activity = Outbox::get_activity( $id ); + $this->assertNotInstanceOf( \WP_Error::class, $activity ); + + $expected = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( \get_gmt_from_date( $local ) ) ); + $this->assertEquals( $expected, $activity->get_updated() ); + $this->assertEquals( $expected, $activity->get_published() ); + } + + /** + * Non-Update activity whose outbox row was later modified picks up `updated` from `post_modified_gmt`. + * + * @covers ::get_activity + */ + public function test_get_activity_fills_updated_when_row_modified() { + $object = $this->get_dummy_activity_object(); + $id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); + $this->assertNotFalse( $id ); + + global $wpdb; + $earlier = \gmdate( 'Y-m-d H:i:s', \strtotime( '-1 hour' ) ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->update( + $wpdb->posts, + array( + 'post_date' => $earlier, + 'post_date_gmt' => $earlier, + ), + array( 'ID' => $id ) + ); + + // Bump post_modified_gmt to be strictly later than post_date_gmt. + $later = \gmdate( 'Y-m-d H:i:s', \strtotime( '+1 hour' ) ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->update( + $wpdb->posts, + array( + 'post_modified' => $later, + 'post_modified_gmt' => $later, + ), + array( 'ID' => $id ) + ); + \clean_post_cache( $id ); + + $activity = Outbox::get_activity( $id ); + $expected = \gmdate( 'Y-m-d\TH:i:s\Z', \strtotime( $later ) ); + $this->assertEquals( $expected, $activity->get_updated() ); + } + + /** + * Non-Update activity that was never modified must not synthesize an `updated` field. + * + * @covers ::get_activity + */ + public function test_get_activity_leaves_updated_empty_when_not_modified() { + $object = $this->get_dummy_activity_object(); + $id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); + $this->assertNotFalse( $id ); + + global $wpdb; + $now = \gmdate( 'Y-m-d H:i:s' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->update( + $wpdb->posts, + array( + 'post_date' => $now, + 'post_date_gmt' => $now, + ), + array( 'ID' => $id ) + ); + \clean_post_cache( $id ); + $post = \get_post( $id ); + + // Force post_modified_gmt == post_date_gmt. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->update( + $wpdb->posts, + array( + 'post_modified' => $post->post_date, + 'post_modified_gmt' => $post->post_date_gmt, + ), + array( 'ID' => $id ) + ); + \clean_post_cache( $id ); + + $activity = Outbox::get_activity( $id ); + $this->assertEmpty( $activity->get_updated() ); + } + /** * Test purge method with more than 20 posts. *