From e5e77d3a85abdb4bc8a9b235307c3983aadbd5b2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 19 May 2026 11:29:25 +0200 Subject: [PATCH 1/8] Backfill published/updated on outbox reads from the row's timestamps Outbox::get_activity already had the row in hand but only set updated, and only for Update activities. Generalize: when published is empty, fill it from post_date_gmt; when updated is empty AND (type is Update OR the row was modified after creation), fill from post_modified_gmt. The existing line also read post_modified (local TZ); switch to post_modified_gmt so the value is correct on non-UTC sites. Tests cover both fill paths plus the "no synthesis when unmodified" case. --- includes/collection/class-outbox.php | 18 ++- .../includes/collection/class-test-outbox.php | 113 +++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 6ba0b0e1f2..a29e5cd839 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -372,8 +372,22 @@ 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. + */ + if ( ! $activity->get_published() && ! empty( $outbox_item->post_date_gmt ) ) { + $activity->set_published( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_date_gmt ) ) ); + } + + if ( ! $activity->get_updated() && ! empty( $outbox_item->post_modified_gmt ) ) { + $needs_updated = ( 'Update' === $type ) || ( $outbox_item->post_modified_gmt > $outbox_item->post_date_gmt ); + if ( $needs_updated ) { + $activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_modified_gmt ) ) ); + } } /** diff --git a/tests/phpunit/tests/includes/collection/class-test-outbox.php b/tests/phpunit/tests/includes/collection/class-test-outbox.php index eefe982682..163c364d16 100644 --- a/tests/phpunit/tests/includes/collection/class-test-outbox.php +++ b/tests/phpunit/tests/includes/collection/class-test-outbox.php @@ -501,10 +501,121 @@ public function test_update_activity_has_updated_attribute() { // 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 ) ), + ) + ); + + $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() ); + } + + /** + * 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 ); + + // Bump post_modified_gmt to be strictly later than post_date_gmt. + global $wpdb; + $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 ); + + // Force post_modified_gmt == post_date_gmt. + $post = \get_post( $id ); + global $wpdb; + // 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. * From edc7181699b6598172d4f9e05161bdb17dd236dc Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 19 May 2026 11:33:20 +0200 Subject: [PATCH 2/8] Reject zero-sentinel post_date_gmt so the fallback can't synthesize 1970 dates Outbox::add inserts with post_status = 'pending', so WordPress leaves post_date_gmt as '0000-00-00 00:00:00'. The previous ! empty() guard treated that string as truthy and would have set published to '1970-01-01T00:00:00Z' on every still-pending row -- worse than leaving the field empty. Reject the sentinel explicitly for both post_date_gmt and post_modified_gmt. Tests now populate the date columns to real timestamps so they verify meaningful behavior instead of round-tripping epoch zero, and a new test pins the zero-sentinel case to "stay empty". --- includes/collection/class-outbox.php | 18 +++- .../includes/collection/class-test-outbox.php | 101 +++++++++++++++++- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index a29e5cd839..6bbbc1145a 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -378,15 +378,23 @@ public static function get_activity( $outbox_item ) { * 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. + * + * `post_date_gmt` is left as `0000-00-00 00:00:00` when an outbox row is + * inserted with `post_status = 'pending'`, so the sentinel must be + * rejected explicitly — synthesizing `1970-01-01T00:00:00Z` from it + * would be worse than leaving the field empty. */ - if ( ! $activity->get_published() && ! empty( $outbox_item->post_date_gmt ) ) { - $activity->set_published( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $outbox_item->post_date_gmt ) ) ); + $post_date_gmt = empty( $outbox_item->post_date_gmt ) || '0000-00-00 00:00:00' === $outbox_item->post_date_gmt ? '' : $outbox_item->post_date_gmt; + $post_modified_gmt = empty( $outbox_item->post_modified_gmt ) || '0000-00-00 00:00:00' === $outbox_item->post_modified_gmt ? '' : $outbox_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() && ! empty( $outbox_item->post_modified_gmt ) ) { - $needs_updated = ( 'Update' === $type ) || ( $outbox_item->post_modified_gmt > $outbox_item->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( $outbox_item->post_modified_gmt ) ) ); + $activity->set_updated( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $post_modified_gmt ) ) ); } } diff --git a/tests/phpunit/tests/includes/collection/class-test-outbox.php b/tests/phpunit/tests/includes/collection/class-test-outbox.php index 163c364d16..fbacef85fa 100644 --- a/tests/phpunit/tests/includes/collection/class-test-outbox.php +++ b/tests/phpunit/tests/includes/collection/class-test-outbox.php @@ -495,6 +495,21 @@ 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 ); @@ -526,6 +541,22 @@ public function test_get_activity_fills_missing_published_from_post_date_gmt() { ) ); + // 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 ); @@ -559,6 +590,47 @@ public function test_get_activity_preserves_existing_published() { $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() ); + } + /** * Non-Update activity whose outbox row was later modified picks up `updated` from `post_modified_gmt`. * @@ -569,8 +641,19 @@ public function test_get_activity_fills_updated_when_row_modified() { $id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); $this->assertNotFalse( $id ); - // Bump post_modified_gmt to be strictly later than post_date_gmt. 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( @@ -598,9 +681,21 @@ public function test_get_activity_leaves_updated_empty_when_not_modified() { $id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); $this->assertNotFalse( $id ); - // Force post_modified_gmt == post_date_gmt. - $post = \get_post( $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, From 14a33a06fb4cd51b40c3ab694381360c3129d18f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 19 May 2026 11:39:09 +0200 Subject: [PATCH 3/8] Backslash-prefix gmdate/strtotime in the outbox fallback --- includes/collection/class-outbox.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 6bbbc1145a..8d0e3d30b5 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -388,13 +388,13 @@ public static function get_activity( $outbox_item ) { $post_modified_gmt = empty( $outbox_item->post_modified_gmt ) || '0000-00-00 00:00:00' === $outbox_item->post_modified_gmt ? '' : $outbox_item->post_modified_gmt; if ( ! $activity->get_published() && $post_date_gmt ) { - $activity->set_published( gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, strtotime( $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 ) ) ); + $activity->set_updated( \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, \strtotime( $post_modified_gmt ) ) ); } } From e80f97f6b24640a56bcca9f83a4048753595515a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 19 May 2026 11:42:31 +0200 Subject: [PATCH 4/8] Add Inbox::get_activity helper with published/updated fallback Mirrors Outbox::get_activity: decodes the stored JSON, returns an Activity object, and falls back to the inbox row's post_date_gmt / post_modified_gmt when the activity itself is missing published or updated. Rejects the '0000-00-00 00:00:00' sentinel for both columns so the fallback can't synthesize a 1970-01-01 date when a pending row's GMT timestamps haven't been populated yet. --- includes/collection/class-inbox.php | 54 ++++++++ .../includes/collection/class-test-inbox.php | 115 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/includes/collection/class-inbox.php b/includes/collection/class-inbox.php index 8415131fab..c4d2759f85 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/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() ); + } } From 400c62135741f7bd59dafd49b5f1714243c3457f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 19 May 2026 11:48:15 +0200 Subject: [PATCH 5/8] Route the C2S inbox listing through Inbox::get_activity The actors inbox controller's prepare_item_for_response used to return the raw json_decoded post_content, which meant the new published/updated fallback in Inbox::get_activity bypassed it. C2S clients reading the inbox endpoint now see the enriched dates instead of empty fields when the remote sender omitted them. --- includes/rest/class-actors-inbox-controller.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 855e9e009e..6475315123 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -252,9 +252,13 @@ public function get_items( $request ) { * @return array Response object on success. */ 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 ); - return $activity; + if ( \is_wp_error( $activity ) ) { + return array(); + } + + return $activity->to_array( true, true ); } /** From dc7c4836cac6f3ad01440fd85c5dbf21da833a63 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 19 May 2026 11:50:16 +0200 Subject: [PATCH 6/8] Changelog: published/updated fallback for C2S inbox/outbox reads --- .github/changelog/fix-c2s-activity-date-fallback | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/fix-c2s-activity-date-fallback 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. From 2c31231260df763eb5ed41d56bee00a0ea29a17c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 19 May 2026 12:01:41 +0200 Subject: [PATCH 7/8] Mirror outbox controller's call pattern in the inbox controller prepare_item_for_response now passes $item->ID to Inbox::get_activity, returns the WP_Error on failure (instead of swallowing it as array()), and the get_items loop skips WP_Error items the same way the outbox loop does. Also drops the per-item JSON-LD context and blind audience (bto/bcc) on the wire by calling to_array(false), matching the outbox controller's serialization. Inbox and Outbox collections themselves are unchanged. --- includes/rest/class-actors-inbox-controller.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 6475315123..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,16 +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 = Inbox::get_activity( $item ); + $activity = Inbox::get_activity( $item->ID ); if ( \is_wp_error( $activity ) ) { - return array(); + return $activity; } - return $activity->to_array( true, true ); + return $activity->to_array( false ); } /** From cfbf10f02923e3aa39c981de5e431811f19def7e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 19 May 2026 12:11:02 +0200 Subject: [PATCH 8/8] Derive GMT from local columns for pending outbox rows Switching post_modified to post_modified_gmt fixed the non-UTC timezone bug but introduced a regression for Update activities at federation time: the Dispatcher reads outbox rows while they are still pending, and pending rows leave _gmt columns as the 0000-00-00 sentinel while the local columns are populated via current_time('mysql'). The sentinel rejection then short-circuited the Update carve-out, so Update activities started federating without an updated field. Fall back to get_gmt_from_date( $post->post_modified ) when the GMT column is the sentinel but the local column is set. Apply the same derivation to post_date_gmt so pending Create activities also gain a published field. A regression test pins the pending-row state the Dispatcher actually sees. --- includes/collection/class-outbox.php | 27 +++++++++++--- .../includes/collection/class-test-outbox.php | 37 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 8d0e3d30b5..060ac3ef8c 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -379,13 +379,28 @@ public static function get_activity( $outbox_item ) { * means downstream consumers (federation, REST listings, audit tooling) * see a date-less activity even though we know exactly when it left. * - * `post_date_gmt` is left as `0000-00-00 00:00:00` when an outbox row is - * inserted with `post_status = 'pending'`, so the sentinel must be - * rejected explicitly — synthesizing `1970-01-01T00:00:00Z` from it - * would be worse than leaving the field empty. + * `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 = empty( $outbox_item->post_date_gmt ) || '0000-00-00 00:00:00' === $outbox_item->post_date_gmt ? '' : $outbox_item->post_date_gmt; - $post_modified_gmt = empty( $outbox_item->post_modified_gmt ) || '0000-00-00 00:00:00' === $outbox_item->post_modified_gmt ? '' : $outbox_item->post_modified_gmt; + $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 ) ) ); diff --git a/tests/phpunit/tests/includes/collection/class-test-outbox.php b/tests/phpunit/tests/includes/collection/class-test-outbox.php index fbacef85fa..5c16a4f78c 100644 --- a/tests/phpunit/tests/includes/collection/class-test-outbox.php +++ b/tests/phpunit/tests/includes/collection/class-test-outbox.php @@ -631,6 +631,43 @@ public function test_get_activity_does_not_synthesize_epoch_published_from_zero_ $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`. *