Skip to content
4 changes: 4 additions & 0 deletions .github/changelog/fix-c2s-activity-date-fallback
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions includes/collection/class-inbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
41 changes: 39 additions & 2 deletions includes/collection/class-outbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,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 ) ) );
}
}

/**
Expand Down
18 changes: 14 additions & 4 deletions includes/rest/class-actors-inbox-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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 );
}

/**
Expand Down
115 changes: 115 additions & 0 deletions tests/phpunit/tests/includes/collection/class-test-inbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
}
}
Loading
Loading