diff --git a/src/ActivityEventHandlers/UndoHandler.php b/src/ActivityEventHandlers/UndoHandler.php index 6d66a69..d8d41e4 100644 --- a/src/ActivityEventHandlers/UndoHandler.php +++ b/src/ActivityEventHandlers/UndoHandler.php @@ -6,6 +6,7 @@ use ActivityPub\Entities\ActivityPubObject; use ActivityPub\Objects\CollectionsService; use ActivityPub\Objects\ObjectsService; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; class UndoHandler implements EventSubscriberInterface { @@ -34,10 +35,6 @@ class UndoHandler implements EventSubscriberInterface $this->collectionsService = $collectionsService; } - // make sure actors match for undo activity and its object - // Undoing likes: remove from likes/liked collection - // Undoing follow: remove from following/followers collection - public function handleInbox( InboxActivityEvent $event ) { $activity = $event->getActivity(); @@ -48,15 +45,13 @@ class UndoHandler implements EventSubscriberInterface if ( ! ( $object && $object->hasField( 'type' ) ) ) { return; } - if ( ! $this->undoIsValid( $activity, $object ) ) { - return; - } + $this->assertUndoIsValid( $activity, $object ); switch ( $object['type'] ) { case 'Follow': $this->removeFromCollection( $object['object'], 'followers', $object['actor'] ); break; case 'Like': - $this->removeFromCollection( $object['object'], 'likes', $object['actor'] ); + $this->removeFromCollection( $object['object'], 'likes', $object['id'] ); break; default: return; @@ -73,9 +68,7 @@ class UndoHandler implements EventSubscriberInterface if ( ! ( $object && $object->hasField( 'type' ) ) ) { return; } - if ( ! $this->undoIsValid( $activity, $object ) ) { - return; - } + $this->assertUndoIsValid( $activity, $object ); switch ( $object['type'] ) { case 'Follow': $this->removeFromCollection( $object['actor'], 'following', $object['object'] ); @@ -88,23 +81,25 @@ class UndoHandler implements EventSubscriberInterface } } - private function undoIsValid( $activity, ActivityPubObject $undoObject ) + private function assertUndoIsValid( $activity, ActivityPubObject $undoObject ) { if ( ! array_key_exists( 'actor', $activity ) ) { - return false; + throw new AccessDeniedHttpException("You can't undo an activity you don't own"); } $actorId = $activity['actor']; if ( is_array( $actorId ) && array_key_exists( 'id', $actorId ) ) { $actorId = $actorId['id']; } if ( ! is_string( $actorId ) ) { - return false; + throw new AccessDeniedHttpException("You can't undo an activity you don't own"); } $objectActor = $undoObject['actor']; if ( ! $objectActor ) { - return false; + throw new AccessDeniedHttpException("You can't undo an activity you don't own"); + } + if ( $actorId != $objectActor['id'] ) { + throw new AccessDeniedHttpException("You can't undo an activity you don't own"); } - return $actorId == $objectActor['id']; } private function removeFromCollection( $object, $collectionField, $itemId ) @@ -147,10 +142,6 @@ class UndoHandler implements EventSubscriberInterface } $objectId = $objectId['id']; } - $object = $this->objectsService->dereference( $objectId ); - if ( ! $object ) { - return null; - } - return $object; + return $this->objectsService->dereference( $objectId ); } } \ No newline at end of file diff --git a/test/ActivityEventHandlers/UndoHandlerTest.php b/test/ActivityEventHandlers/UndoHandlerTest.php new file mode 100644 index 0000000..ecd8822 --- /dev/null +++ b/test/ActivityEventHandlers/UndoHandlerTest.php @@ -0,0 +1,227 @@ + 'https://elsewhere.com/follows/1', + 'type' => 'Follow', + 'actor' => array( + 'id' => 'https://elsewhere.com/actors/1', + ), + 'object' => array( + 'id' => 'https://example.com/actors/1', + 'followers' => array( + 'id' => 'https://example.com/actors/1/followers', + ) + ), + ) ); + $likeForUndoLikeInbox = TestActivityPubObject::fromArray( array( + 'id' => 'https://elsewhere.com/likes/1', + 'type' => 'Like', + 'actor' => array( + 'id' => 'https://elsewhere.com/actors/1', + ), + 'object' => array( + 'id' => 'https://example.com/notes/1', + 'likes' => array( + 'id' => 'https://example.com/notes/1/likes', + ), + ), + ) ); + $followForUndoFollowOutbox = TestActivityPubObject::fromArray( array( + 'id' => 'https://example.com/follows/1', + 'type' => 'Follow', + 'actor' => array( + 'id' => 'https://example.com/actors/1', + 'following' => array( + 'id' => 'https://example.com/actors/1/following', + ), + ), + 'object' => 'https://elsewhere.com/actors/1', + ) ); + $likeForUndoLikeOutbox = TestActivityPubObject::fromArray( array( + 'id' => 'https://example.com/likes/1', + 'type' => 'Like', + 'actor' => array( + 'id' => 'https://example.com/actors/1', + 'liked' => array( + 'id' => 'https://example.com/actors/1/liked', + ), + ), + 'object' => array( + 'id' => 'https://elsewhere.com/notes/1', + ), + ) ); + $testCases = array( + array( + 'id' => 'undoFollowInbox', + 'objects' => array( + 'https://elsewhere.com/follows/1' => $followForUndoFollowInbox, + ), + 'eventName' => InboxActivityEvent::NAME, + 'event' => new InboxActivityEvent( + array( + 'id' => 'https://elsewhere.com/undos/1', + 'type' => 'Undo', + 'actor' => array( + 'id' => 'https://elsewhere.com/actors/1' + ), + 'object' => array( + 'id' => 'https://elsewhere.com/follows/1', + 'type' => 'Follow', + 'actor' => 'https://elsewhere.com/actors/1', + 'object' => 'https://example.com/actors/1', + ) + ), + TestActivityPubObject::fromArray( array( + 'id' => 'https://example.com/actors/1', + ) ), + Request::create( 'https://example.com/actors/1/inbox' ) + ), + 'collectionToRemoveFrom' => $followForUndoFollowInbox['object']['followers'], + 'itemToRemove' => 'https://elsewhere.com/actors/1', + ), + array( + 'id' => 'undoLikeInbox', + 'objects' => array( + 'https://elsewhere.com/likes/1' => $likeForUndoLikeInbox, + ), + 'eventName' => InboxActivityEvent::NAME, + 'event' => new InboxActivityEvent( + array( + 'id' => 'https://elsewhere.com/undos/1', + 'type' => 'Undo', + 'actor' => 'https://elsewhere.com/actors/1', + 'object' => 'https://elsewhere.com/likes/1' + ), + TestActivityPubObject::fromArray( array( + 'id' => 'https://example.com/actors/1', + ) ), + Request::create( 'https://example.com/actors/1/inbox' ) + ), + 'collectionToRemoveFrom' => $likeForUndoLikeInbox['object']['likes'], + 'itemToRemove' => 'https://elsewhere.com/likes/1', + ), + array( + 'id' => 'undoFollowOutbox', + 'objects' => array( + 'https://example.com/follows/1' => $followForUndoFollowOutbox, + ), + 'eventName' => OutboxActivityEvent::NAME, + 'event' => new OutboxActivityEvent( + array( + 'id' => 'https://example.com/undos/1', + 'type' => 'Undo', + 'actor' => 'https://example.com/actors/1', + 'object' => 'https://example.com/follows/1', + ), + TestActivityPubObject::fromArray( array( + 'id' => 'https://example.com/actors/1', + ) ), + Request::create( 'https://example.com/actors/1/outbox' ) + ), + 'collectionToRemoveFrom' => $followForUndoFollowOutbox['actor']['following'], + 'itemToRemove' => 'https://elsewhere.com/actors/1', + ), + array( + 'id' => 'undoLikeOutbox', + 'objects' => array( + 'https://example.com/likes/1' => $likeForUndoLikeOutbox, + ), + 'eventName' => OutboxActivityEvent::NAME, + 'event' => new OutboxActivityEvent( + array( + 'id' => 'https://example.com/undos/1', + 'type' => 'Undo', + 'actor' => 'https://example.com/actors/1', + 'object' => 'https://example.com/likes/1', + ), + TestActivityPubObject::fromArray( array( + 'id' => 'https://example.com/actors/1', + ) ), + Request::create( 'https://example.com/actors/1/outbox' ) + ), + 'collectionToRemoveFrom' => $likeForUndoLikeOutbox['actor']['liked'], + 'itemToRemove' => $likeForUndoLikeOutbox['object']['id'] + ), + array( + 'id' => 'undoActorDoesNotMatchObjectActor', + 'objects' => array( + 'https://elsewhere.com/follows/1' => TestActivityPubObject::fromArray( array( + 'id' => 'https://elsewhere.com/follows/1', + 'type' => 'Follow', + 'actor' => array( + 'id' => 'https://somewhereelse.com/actors/1', + ), + 'object' => 'https://example.com/actors/1', + ) ) + ), + 'eventName' => InboxActivityEvent::NAME, + 'event' => new InboxActivityEvent( + array( + 'id' => 'https://elsewhere.com/undos/1', + 'type' => 'Undo', + 'actor' => 'https://elsewhere.com/actors/1', + 'object' => 'https://elsewhere.com/follows/1', + ), + TestActivityPubObject::fromArray( array( + 'id' => 'https://example.com/actors/1', + ) ), + Request::create( 'https://example.com/actors/1/inbox' ) + ), + 'expectedException' => AccessDeniedHttpException::class, + ) + ); + foreach ( $testCases as $testCase ) { + $objectsService = $this->getMock( ObjectsService::class ); + $objectsService->method( 'dereference' )->will( + $this->returnCallback( + function( $id) use ( $testCase ) { + $objects = $testCase['objects']; + if ( array_key_exists( $id, $objects ) ) { + return $objects[$id]; + } else { + return null; + } + } + ) + ); + $collectionsService = $this->getMockBuilder( CollectionsService::class ) + ->disableOriginalConstructor() + ->setMethods( array( 'removeItem' ) ) + ->getMock(); + if ( array_key_exists( 'collectionToRemoveFrom', $testCase ) ) { + $collectionsService->expects( $this->once() ) + ->method( 'removeItem' ) + ->with( + $testCase['collectionToRemoveFrom'], + $testCase['itemToRemove'] + ); + } else { + $collectionsService->expects( $this->never() )->method( 'removeItem' ); + } + if ( array_key_exists( 'expectedException', $testCase ) ) { + $this->setExpectedException( $testCase['expectedException'] ); + } + $undoHandler = new UndoHandler( $objectsService, $collectionsService ); + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber( $undoHandler ); + $eventDispatcher->dispatch( $testCase['eventName'], $testCase['event'] ); + } + } +} \ No newline at end of file