Skip to content

Commit a43b78e

Browse files
authored
Add Undo functionality (#1301)
* Add Undo functionality * Rebase leftover * changelog * second pass * Default followers to null so they don't get set * Fix unrelated tests * Alternate approach * update changelog * Add undo CLI command
1 parent e2908e8 commit a43b78e

File tree

9 files changed

+184
-68
lines changed

9 files changed

+184
-68
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
* Undo API for Outbox items.
13+
814
## [5.2.0] - 2025-02-13
915

1016
### Added

includes/activity/class-activity.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
* @method Activity set_attachment( array $attachment ) Sets the attachment property of the object.
5353
* @method Activity set_icon( array $icon ) Sets the icon property of the object.
5454
* @method Activity set_image( array $image ) Sets the image property of the object.
55+
* @method Activity set_content( string $content ) Sets the content property of the object.
5556
*/
5657
class Activity extends Base_Object {
5758
const JSON_LD_CONTEXT = array(

includes/activity/class-base-object.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ public function to_array( $include_json_ld_context = true ) {
621621
}
622622

623623
// If value is still empty, ignore it for the array and continue.
624-
if ( isset( $value ) ) {
624+
if ( ! empty( $value ) || false === $value ) {
625625
$array[ snake_to_camel_case( $key ) ] = $value;
626626
}
627627
}

includes/class-cli.php

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77

88
namespace Activitypub;
99

10+
use Activitypub\Collection\Outbox;
11+
use Activitypub\Scheduler\Comment;
12+
use Activitypub\Scheduler\Post;
1013
use WP_CLI;
1114
use WP_CLI_Command;
12-
use Activitypub\Scheduler\Post;
13-
use Activitypub\Scheduler\Comment;
1415

1516
/**
1617
* WP-CLI commands.
@@ -130,4 +131,39 @@ public function comment( $args ) {
130131
WP_CLI::error( 'Unknown action.' );
131132
}
132133
}
134+
135+
/**
136+
* Undo an activity that was sent to the Fediverse.
137+
*
138+
* ## OPTIONS
139+
*
140+
* <outbox_item_id>
141+
* The ID or URL of the outbox item to undo.
142+
*
143+
* ## EXAMPLES
144+
*
145+
* $ wp activitypub undo 123
146+
* $ wp activitypub undo "https://example.com/?post_type=ap_outbox&p=123"
147+
*
148+
* @synopsis <outbox_item_id>
149+
*
150+
* @param array $args The arguments.
151+
*/
152+
public function undo( $args ) {
153+
$outbox_item_id = $args[0];
154+
if ( ! is_numeric( $outbox_item_id ) ) {
155+
$outbox_item_id = url_to_postid( $outbox_item_id );
156+
}
157+
158+
$outbox_item_id = get_post( $outbox_item_id );
159+
if ( ! $outbox_item_id ) {
160+
WP_CLI::error( 'Activity not found.' );
161+
}
162+
163+
$undo_id = Outbox::undo( $outbox_item_id );
164+
if ( ! $undo_id ) {
165+
WP_CLI::error( 'Failed to undo activity.' );
166+
}
167+
WP_CLI::success( 'Undo activity scheduled.' );
168+
}
133169
}

includes/class-dispatcher.php

Lines changed: 5 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
namespace Activitypub;
99

1010
use Activitypub\Activity\Activity;
11-
use Activitypub\Collection\Actors;
1211
use Activitypub\Collection\Followers;
12+
use Activitypub\Collection\Outbox;
1313

1414
/**
1515
* ActivityPub Dispatcher Class.
@@ -77,14 +77,14 @@ public static function process_outbox( $id ) {
7777
return;
7878
}
7979

80-
$actor = self::get_actor( $outbox_item );
80+
$actor = Outbox::get_actor( $outbox_item );
8181
if ( \is_wp_error( $actor ) ) {
8282
// If the actor is not found, publish the post and don't try again.
8383
\wp_publish_post( $outbox_item );
8484
return;
8585
}
8686

87-
$activity = self::get_activity( $outbox_item );
87+
$activity = Outbox::get_activity( $outbox_item );
8888

8989
// Send to mentioned and replied-to users. Everyone other than followers.
9090
self::send_to_interactees( $activity, $actor->get__id(), $outbox_item );
@@ -117,8 +117,8 @@ public static function process_outbox( $id ) {
117117
* @return array|void The next batch of followers to process, or void if done.
118118
*/
119119
public static function send_to_followers( $outbox_item_id, $batch_size = 50, $offset = 0 ) {
120-
$activity = self::get_activity( $outbox_item_id );
121-
$actor = self::get_actor( \get_post( $outbox_item_id ) );
120+
$activity = Outbox::get_activity( $outbox_item_id );
121+
$actor = Outbox::get_actor( \get_post( $outbox_item_id ) );
122122
$json = $activity->to_json();
123123
$inboxes = Followers::get_inboxes_for_activity( $json, $actor->get__id(), $batch_size, $offset );
124124

@@ -339,59 +339,4 @@ protected static function should_send_to_followers( $activity, $actor, $outbox_i
339339
*/
340340
return apply_filters( 'activitypub_send_activity_to_followers', $send, $activity, $actor->get__id(), $outbox_item );
341341
}
342-
343-
/**
344-
* Get the Activity object from the Outbox item.
345-
*
346-
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
347-
* @return Activity|\WP_Error The Activity object or WP_Error.
348-
*/
349-
private static function get_activity( $outbox_item ) {
350-
$outbox_item = get_post( $outbox_item );
351-
$actor = self::get_actor( $outbox_item );
352-
if ( is_wp_error( $actor ) ) {
353-
return $actor;
354-
}
355-
356-
$type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
357-
$activity = new Activity();
358-
$activity->set_type( $type );
359-
$activity->set_id( $outbox_item->guid );
360-
// Pre-fill the Activity with data (for example cc and to).
361-
$activity->set_object( \json_decode( $outbox_item->post_content, true ) );
362-
$activity->set_actor( $actor->get_id() );
363-
364-
// Use simple Object (only ID-URI) for Like and Announce.
365-
if ( in_array( $type, array( 'Like', 'Delete' ), true ) ) {
366-
$activity->set_object( $activity->get_object()->get_id() );
367-
}
368-
369-
return $activity;
370-
}
371-
372-
/**
373-
* Get the Actor object from the Outbox item.
374-
*
375-
* @param \WP_Post $outbox_item The Outbox post.
376-
*
377-
* @return \Activitypub\Model\User|\Activitypub\Model\Blog|\WP_Error The Actor object or WP_Error.
378-
*/
379-
private static function get_actor( $outbox_item ) {
380-
$actor_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_actor', true );
381-
382-
switch ( $actor_type ) {
383-
case 'blog':
384-
$actor_id = Actors::BLOG_USER_ID;
385-
break;
386-
case 'application':
387-
$actor_id = Actors::APPLICATION_USER_ID;
388-
break;
389-
case 'user':
390-
default:
391-
$actor_id = $outbox_item->post_author;
392-
break;
393-
}
394-
395-
return Actors::get_by_id( $actor_id );
396-
}
397342
}

includes/collection/class-outbox.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
namespace Activitypub\Collection;
99

10+
use Activitypub\Activity\Activity;
1011
use Activitypub\Dispatcher;
1112

1213
use function Activitypub\is_activity;
14+
use function Activitypub\add_to_outbox;
1315

1416
/**
1517
* ActivityPub Outbox Collection
@@ -151,4 +153,78 @@ private static function invalidate_existing_items( $object_id, $activity_type, $
151153
\delete_post_meta( $existing_item_id, '_activitypub_outbox_offset' );
152154
}
153155
}
156+
157+
/**
158+
* Creates an Undo activity.
159+
*
160+
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
161+
* @return int|bool The ID of the outbox item or false on failure.
162+
*/
163+
public static function undo( $outbox_item ) {
164+
$outbox_item = get_post( $outbox_item );
165+
$activity = self::get_activity( $outbox_item );
166+
167+
$type = 'Undo';
168+
if ( 'Create' === $activity->get_type() ) {
169+
$type = 'Delete';
170+
} elseif ( 'Add' === $activity->get_type() ) {
171+
$type = 'Remove';
172+
}
173+
174+
return add_to_outbox( $activity, $type, $outbox_item->post_author );
175+
}
176+
177+
/**
178+
* Get the Activity object from the Outbox item.
179+
*
180+
* @param int|\WP_Post $outbox_item The Outbox post or post ID.
181+
* @return Activity|\WP_Error The Activity object or WP_Error.
182+
*/
183+
public static function get_activity( $outbox_item ) {
184+
$outbox_item = get_post( $outbox_item );
185+
$actor = self::get_actor( $outbox_item );
186+
if ( is_wp_error( $actor ) ) {
187+
return $actor;
188+
}
189+
190+
$type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_type', true );
191+
$activity = new Activity();
192+
$activity->set_type( $type );
193+
$activity->set_id( $outbox_item->guid );
194+
// Pre-fill the Activity with data (for example cc and to).
195+
$activity->set_object( \json_decode( $outbox_item->post_content, true ) );
196+
$activity->set_actor( $actor->get_id() );
197+
198+
// Use simple Object (only ID-URI) for Like and Announce.
199+
if ( in_array( $type, array( 'Like', 'Delete' ), true ) ) {
200+
$activity->set_object( $activity->get_object()->get_id() );
201+
}
202+
203+
return $activity;
204+
}
205+
206+
/**
207+
* Get the Actor object from the Outbox item.
208+
*
209+
* @param \WP_Post $outbox_item The Outbox post.
210+
* @return \Activitypub\Model\User|\Activitypub\Model\Blog|\WP_Error The Actor object or WP_Error.
211+
*/
212+
public static function get_actor( $outbox_item ) {
213+
$actor_type = \get_post_meta( $outbox_item->ID, '_activitypub_activity_actor', true );
214+
215+
switch ( $actor_type ) {
216+
case 'blog':
217+
$actor_id = Actors::BLOG_USER_ID;
218+
break;
219+
case 'application':
220+
$actor_id = Actors::APPLICATION_USER_ID;
221+
break;
222+
case 'user':
223+
default:
224+
$actor_id = $outbox_item->post_author;
225+
break;
226+
}
227+
228+
return Actors::get_by_id( $actor_id );
229+
}
154230
}

includes/transformer/class-base.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ protected function transform_object_properties( $activity_object ) {
9090
if ( \method_exists( $this, $getter ) ) {
9191
$value = \call_user_func( array( $this, $getter ) );
9292

93-
if ( isset( $value ) ) {
93+
if ( ! empty( $value ) ) {
9494
$setter = 'set_' . $var;
9595

9696
/**

readme.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ For reasons of data protection, it is not possible to see the followers of other
129129

130130
== Changelog ==
131131

132+
= Unreleased =
133+
134+
* Added: Undo API for Outbox items.
135+
132136
= 5.2.0 =
133137

134138
* Added: Batch Outbox-Processing.

tests/includes/collection/class-test-outbox.php

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
namespace Activitypub\Tests\Collection;
99

10+
use Activitypub\Collection\Outbox;
11+
1012
/**
1113
* Test class for Outbox collection.
1214
*
@@ -64,7 +66,7 @@ public function activity_object_provider() {
6466
),
6567
'Create',
6668
1,
67-
'{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/' . self::$user_id . '","type":"Note","content":"\u003Cp\u003EThis is a note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is a note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}',
69+
'{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/' . self::$user_id . '","type":"Note","content":"\u003Cp\u003EThis is a note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is a note\u003C\/p\u003E"},"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"mediaType":"text\/html","sensitive":false}',
6870
),
6971
array(
7072
array(
@@ -75,7 +77,7 @@ public function activity_object_provider() {
7577
),
7678
'Create',
7779
2,
78-
'{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/2","type":"Note","content":"\u003Cp\u003EThis is another note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is another note\u003C\/p\u003E"},"tag":[],"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"cc":[],"mediaType":"text\/html","sensitive":false}',
80+
'{"@context":["https:\/\/www.w3.org\/ns\/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https:\/\/example.com\/2","type":"Note","content":"\u003Cp\u003EThis is another note\u003C\/p\u003E","contentMap":{"en":"\u003Cp\u003EThis is another note\u003C\/p\u003E"},"to":["https:\/\/www.w3.org\/ns\/activitystreams#Public"],"mediaType":"text\/html","sensitive":false}',
7981
),
8082
);
8183
}
@@ -192,13 +194,59 @@ public function test_delete_invalidates_all_activities() {
192194
/**
193195
* Helper method to create a dummy activity object for testing.
194196
*
195-
* @return \Activitypub\Activity\Base_Object
197+
* @return \Activitypub\Activity\Activity
196198
*/
197199
private function get_dummy_activity_object() {
198-
$object = new \Activitypub\Activity\Base_Object();
200+
$object = new \Activitypub\Activity\Activity();
199201
$object->set_id( 'https://example.com/test-object' );
200202
$object->set_type( 'Note' );
201203
$object->set_content( 'Test content' );
204+
202205
return $object;
203206
}
207+
208+
/**
209+
* Test undo.
210+
*
211+
* @covers ::undo
212+
* @dataProvider undo_object_provider
213+
*
214+
* @param string $type Type of the activity to be undone.
215+
* @param string $expected Expected type.
216+
*/
217+
public function test_undo( $type, $expected ) {
218+
$data = array(
219+
'@context' => 'https://www.w3.org/ns/activitystreams',
220+
'id' => 'https://example.com/' . self::$user_id,
221+
'type' => 'Note',
222+
'content' => '<p>This is a note</p>',
223+
);
224+
225+
$id = \Activitypub\add_to_outbox( $data, $type, self::$user_id );
226+
227+
$undo_id = Outbox::undo( $id );
228+
$activity = Outbox::get_activity( $undo_id );
229+
230+
// Only ID for Deletes.
231+
if ( 'Delete' === $expected ) {
232+
$this->assertSame( get_permalink( $id ), $activity->get_object() );
233+
} else {
234+
$this->assertEquals( json_decode( get_post( $undo_id )->post_content, true ), $activity->get_object()->to_array() );
235+
}
236+
237+
$this->assertSame( $expected, $activity->get_type() );
238+
}
239+
240+
/**
241+
* Data provider for test_undo.
242+
*
243+
* @return array[]
244+
*/
245+
public function undo_object_provider() {
246+
return array(
247+
array( 'Create', 'Delete' ),
248+
array( 'Update', 'Undo' ),
249+
array( 'Add', 'Remove' ),
250+
);
251+
}
204252
}

0 commit comments

Comments
 (0)