├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── src ├── ActivityEventHandlers │ ├── AcceptHandler.php │ ├── ActivityEvent.php │ ├── ActivityPersister.php │ ├── AddHandler.php │ ├── AnnounceHandler.php │ ├── CreateHandler.php │ ├── DeleteHandler.php │ ├── DeliveryHandler.php │ ├── FollowHandler.php │ ├── InboxActivityEvent.php │ ├── LikeHandler.php │ ├── NonActivityHandler.php │ ├── OutboxActivityEvent.php │ ├── RemoveHandler.php │ ├── UndoHandler.php │ ├── UpdateHandler.php │ └── ValidationHandler.php ├── ActivityPub.php ├── Auth │ ├── AuthListener.php │ ├── AuthService.php │ └── SignatureListener.php ├── Config │ ├── ActivityPubConfig.php │ ├── ActivityPubConfigBuilder.php │ └── ActivityPubModule.php ├── Controllers │ ├── GetController.php │ └── PostController.php ├── Crypto │ ├── HttpSignatureService.php │ └── RsaKeypair.php ├── Database │ └── PrefixNamingStrategy.php ├── Entities │ ├── ActivityPubObject.php │ ├── Field.php │ └── PrivateKey.php ├── Http │ └── Router.php ├── JsonLd │ ├── Dereferencer │ │ ├── CachingDereferencer.php │ │ └── DereferencerInterface.php │ ├── Exceptions │ │ ├── NodeNotFoundException.php │ │ └── PropertyNotDefinedException.php │ ├── JsonLdGraph.php │ ├── JsonLdNode.php │ ├── JsonLdNodeFactory.php │ └── TripleStore │ │ ├── Doctrine │ │ └── DoctrineTriplestore.php │ │ ├── TriplestoreInterface.php │ │ └── TypedRdfTriple.php ├── Objects │ ├── BlockService.php │ ├── CollectionIterator.php │ ├── CollectionsService.php │ ├── ContextProvider.php │ ├── IdProvider.php │ └── ObjectsService.php └── Utils │ ├── DateTimeProvider.php │ ├── HeaderUtils.php │ ├── Logger.php │ ├── RandomProvider.php │ ├── SimpleDateTimeProvider.php │ ├── Util.php │ └── UuidProvider.php └── test ├── ActivityEventHandlers ├── AcceptHandlerTest.php ├── AddHandlerTest.php ├── AnnounceHandlerTest.php ├── CreateHandlerTest.php ├── DeleteHandlerTest.php ├── FollowHandlerTest.php ├── LikeHandlerTest.php ├── NonActivityHandlerTest.php ├── RemoveHandlerTest.php ├── UndoHandlerTest.php ├── UpdateHandlerTest.php └── ValidationHandlerTest.php ├── ActivityPubTest.php ├── Auth ├── AuthListenerTest.php ├── AuthServiceTest.php └── SignatureListenerTest.php ├── Config └── ActivityPubModuleTest.php ├── Controllers ├── GetControllerTest.php └── PostControllerTest.php ├── Crypto ├── HttpSignatureServiceTest.php └── RsaKeypairTest.php ├── Entities └── EntityTest.php ├── Http └── RouterTest.php ├── JsonLd ├── Dereferencer │ └── CachingDereferencerTest.php ├── JsonLdNodeTest.php └── TestDereferencer.php ├── Objects ├── BlockServiceTest.php ├── CollectionIteratorTest.php ├── CollectionsServiceDbTest.php ├── CollectionsServiceTest.php ├── IdProviderTest.php └── ObjectsServiceTest.php ├── TestConfig ├── APTestCase.php ├── ArrayDataSet.php └── SQLiteTestCase.php ├── TestUtils ├── TestActivityPubObject.php ├── TestDateTimeProvider.php ├── TestField.php └── TestUuidProvider.php ├── Utils └── UtilTest.php ├── bootstrap.php └── config.xml /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | test/db.sqlite 3 | ./.idea/ 4 | /.idea 5 | composer.lock 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Jeremy Dormitzer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActivityPub-PHP 2 | > A library to turn any PHP project into a full [ActivityPub](https://activitypub.rocks) implementation 3 | 4 | **This library is a work-in-progress. The documentation below reflects what the API will look like once it's done.** 5 | 6 | ActivityPub-PHP is a library that embeds a full ActivityPub server into any PHP project. It works with any SQL database and any web framework. At a high level, it provides a request handler that you can route ActivityPub requests to which will take care of persisting the received activity, performing any necessary side effects, and delivering the activity to other federated servers. It also provides a PHP API to create and manage actors and activities. 7 | 8 | ## What it does 9 | - stores incoming activities to your project's existing SQL database in a configurable fashion 10 | - implement both the client-to-server and the server-to-server parts of the ActivityPub protocol 11 | - verify HTTP signatures on incoming ActivityPub requests and sign outgoing ActivityPub requests 12 | - provide a PHP API so you can create and manage actors and send activities directly from code 13 | - hook into your application's user authentication logic to provide ways to associate your users with ActivityPub actors 14 | - manage the JSON-LD context for you, with hooks if you need to add custom fields 15 | - support PHP > 5.* 16 | 17 | ## What it doesn't do 18 | - handle standalone user authentication - this is up to your particular application 19 | - support non-SQL databases 20 | - provide a UI 21 | 22 | ## Installation 23 | ActivityPub-PHP is available via Composer: 24 | 25 | $ composer require pterotype/activitypub-php 26 | 27 | ## Usage 28 | Basic usage example: 29 | 30 | ``` php 31 | setAuthFunction( function() { 43 | if ( current_user_is_logged_in() ) { 44 | return get_actor_id_for_current_user(); 45 | } else { 46 | return false; 47 | } 48 | } ) 49 | // Database connection options, passed directly to Doctrine: 50 | ->setDbConnectionParams( array( 51 | 'driver' => 'pdo_mysql', 52 | 'user' => 'mysql' 53 | 'password' => 'thePa$$word', 54 | 'dbname' => 'my-database', 55 | ) ) 56 | // Database table name prefix for compatibility with $wpdb->prefix, etc.: 57 | // Default: '' 58 | ->setDbPrefix( 'activitypub_' ) 59 | ->build(); 60 | $activitypub = new ActivityPub( $config ); 61 | 62 | // Routing incoming ActivityPub requests to ActivityPub-PHP 63 | if ( in_array( $_SERVER['HTTP_ACCEPT'], 64 | array( 'application/ld+json', 'application/activity+json' ) ) ) { 65 | // Handle the request, perform any side effects and delivery, 66 | // and return a Symfony Response 67 | $response = $activitypub->handle(); 68 | // Send the response back to the client 69 | $response->send(); 70 | } 71 | 72 | // Creating a new actor 73 | function createActor() 74 | { 75 | $actorArray = array( 76 | 'id' => 'https://mysite.com/my_actor', 77 | 'type' => 'Person', 78 | 'preferredUsername' => 'myActor', 79 | ); 80 | $actor = $activitypub->createActor( $actorArray ); 81 | // $actor has all the ActivityPub actor fields, e.g. inbox, outbox, followers, etc. 82 | } 83 | 84 | // Posting activities from code 85 | function postActivity() 86 | { 87 | $actor = $activitypub->getActor( 'https://mysite.com/my_actor' ); 88 | $note = array( 89 | 'type' => 'Note', 90 | 'content' => 'This is a great note', 91 | 'to' => $actor['followers'], 92 | ); 93 | $actor->create( $note ); 94 | // also $actor->update(), $actor->delete(), etc. 95 | } 96 | ?> 97 | 98 | ``` 99 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pterotype/activitypub-php", 3 | "description": "An ActivityPub library", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Jeremy Dormitzer", 8 | "email": "jeremy@dormitzer.net", 9 | "homepage": "https://jeremydormitzer.com", 10 | "role": "Developer" 11 | } 12 | ], 13 | "scripts": { 14 | "test": "phpunit -c ./test/config.xml test", 15 | "test-debug": "XDEBUG_CONFIG='idekey=ap_session' php ./vendor/bin/phpunit test", 16 | "docs": "phpdoc -d ./src -t ./docs" 17 | }, 18 | "require": { 19 | "php": "^5.5 || ^7.0", 20 | "ext-json": "*", 21 | "cache/apc-adapter": "0.3.2", 22 | "cache/apcu-adapter": "0.2.2", 23 | "cache/filesystem-adapter": "0.3.3", 24 | "doctrine/annotations": "1.2.7", 25 | "doctrine/cache": "1.6.2", 26 | "doctrine/collections": "1.3.0", 27 | "doctrine/common": "2.6.2", 28 | "doctrine/instantiator": "1.0.5", 29 | "doctrine/orm": "2.5.14", 30 | "guzzlehttp/guzzle": "^6.3", 31 | "ml/json-ld": "1.1.0", 32 | "monolog/monolog": "^1.0", 33 | "phpseclib/phpseclib": "^2.0", 34 | "psr/http-message": "^1.0", 35 | "ramsey/uuid": "3.8.0", 36 | "symfony/dependency-injection": "^3.4", 37 | "symfony/event-dispatcher": "^3.4", 38 | "symfony/http-foundation": "^3.4", 39 | "symfony/http-kernel": "^3.4", 40 | "symfony/psr-http-message-bridge": "^1.1", 41 | "zendframework/zend-diactoros": "1.4.1" 42 | }, 43 | "require-dev": { 44 | "ext-pdo": "*", 45 | "cache/array-adapter": "0.4.2", 46 | "phpunit/dbunit": "^2.0", 47 | "phpunit/phpunit": "^4.0" 48 | }, 49 | "autoload": { 50 | "psr-4": { 51 | "ActivityPub\\": "src/" 52 | } 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "ActivityPub\\Test\\": "test/" 57 | } 58 | }, 59 | "config": { 60 | "sort-packages": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ActivityEventHandlers/AcceptHandler.php: -------------------------------------------------------------------------------- 1 | objectsService = $objectsService; 33 | $this->collectionsService = $collectionsService; 34 | $this->contextProvider = $contextProvider; 35 | } 36 | 37 | public static function getSubscribedEvents() 38 | { 39 | return array( 40 | InboxActivityEvent::NAME => 'handleInbox', 41 | OutboxActivityEvent::NAME => 'handleOutbox', 42 | ); 43 | } 44 | 45 | public function handleInbox( InboxActivityEvent $event ) 46 | { 47 | $activity = $event->getActivity(); 48 | if ( $activity['type'] !== 'Accept' ) { 49 | return; 50 | } 51 | $localActor = $event->getReceivingActor(); 52 | $followId = $activity['object']; 53 | if ( is_array( $followId ) && array_key_exists( 'id', $followId ) ) { 54 | $followId = $followId['id']; 55 | } 56 | if ( ! is_string( $followId ) ) { 57 | return; 58 | } 59 | $follow = $this->objectsService->dereference( $followId ); 60 | if ( ! $follow ) { 61 | return; 62 | } 63 | if ( ! ( $follow->hasField( 'actor') && $localActor->equals( $follow['actor'] ) ) ) { 64 | return; 65 | } 66 | $remoteActor = $event->getRequest()->attributes->get('actor'); 67 | if ( ! $remoteActor->equals( $follow['object'] ) ) { 68 | return; 69 | } 70 | if ( $localActor->hasField( 'following' ) ) { 71 | $following = $localActor['following']; 72 | } else { 73 | $updatedLocalActor = $localActor->asArray(); 74 | $updatedLocalActor['following'] = array( 75 | '@context' => $this->contextProvider->getContext(), 76 | 'id' => rtrim( $updatedLocalActor['id'], '/' ) . '/following', 77 | 'type' => 'Collection', 78 | 'items' => array(), 79 | ); 80 | $localActor = $this->objectsService->update( $localActor['id'], $updatedLocalActor ); 81 | $following = $localActor['following']; 82 | } 83 | $newFollowing = $follow['object']; 84 | $this->collectionsService->addItem( $following, $newFollowing->asArray() ); 85 | } 86 | 87 | public function handleOutbox( OutboxActivityEvent $event ) 88 | { 89 | $activity = $event->getActivity(); 90 | if ( $activity['type'] !== 'Accept' ) { 91 | return; 92 | } 93 | $request = $event->getRequest(); 94 | // either there is a 'follow' key on the request, 95 | // in which case this is an auto-accept dispatched from 96 | // the FollowHandler so the Follow won't be in the database yet, 97 | // or there isn't, in which case this is an ordinary Accept 98 | // sent by a client and the Follow is in the database 99 | $follow = $request->attributes->get( 'follow' ); 100 | if ( !$follow ) { 101 | $followId = $activity['object']; 102 | if ( is_array( $followId ) && array_key_exists( 'id', $followId ) ) { 103 | $followId = $followId['id']; 104 | } 105 | if ( ! is_string( $followId ) ) { 106 | return; 107 | } 108 | $follow = $this->objectsService->dereference( $followId ); 109 | if ( ! $follow ) { 110 | return; 111 | } 112 | $follow = $follow->asArray(); 113 | } 114 | if ( !$follow || !array_key_exists( 'object', $follow ) ) { 115 | return; 116 | } 117 | $followObjectId = $follow['object']; 118 | if ( is_array( $followObjectId ) && array_key_exists( 'id', $followObjectId ) ) { 119 | $followObjectId = $followObjectId['id']; 120 | } 121 | $localActor = $event->getReceivingActor(); 122 | if ( $followObjectId !== $localActor['id'] ) { 123 | return; 124 | } 125 | $followers = $localActor['followers']; 126 | if ( ! $followers ) { 127 | $updatedLocalActor = $localActor->asArray(); 128 | $updatedLocalActor['followers'] = array( 129 | '@context' => $this->contextProvider->getContext(), 130 | 'id' => rtrim( $updatedLocalActor['id'], '/' ) . '/followers', 131 | 'type' => 'Collection', 132 | 'items' => array(), 133 | ); 134 | $localActor = $this->objectsService->update( $localActor['id'], $updatedLocalActor ); 135 | $followers = $localActor['followers']; 136 | } 137 | $this->collectionsService->addItem( $followers, $follow['actor'] ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/ActivityEventHandlers/ActivityEvent.php: -------------------------------------------------------------------------------- 1 | activity = $activity; 44 | $this->receivingActor = $receivingActor; 45 | $this->request = $request; 46 | } 47 | 48 | /** 49 | * @return array The activity 50 | */ 51 | public function getActivity() 52 | { 53 | return $this->activity; 54 | } 55 | 56 | public function setActivity( array $activity ) 57 | { 58 | $this->activity = $activity; 59 | } 60 | 61 | /** 62 | * @return ActivityPubObject The actor whose inbox or outbox is receiving the activity 63 | */ 64 | public function getReceivingActor() 65 | { 66 | return $this->receivingActor; 67 | } 68 | 69 | /** 70 | * @return Request The request 71 | */ 72 | public function getRequest() 73 | { 74 | return $this->request; 75 | } 76 | 77 | /** 78 | * @return Response The response 79 | */ 80 | public function getResponse() 81 | { 82 | return $this->response; 83 | } 84 | 85 | public function setResponse( Response $response ) 86 | { 87 | $this->response = $response; 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /src/ActivityEventHandlers/ActivityPersister.php: -------------------------------------------------------------------------------- 1 | 'persistActivityToInbox', 32 | OutboxActivityEvent::NAME => 'persistActivityToOutbox', 33 | ); 34 | } 35 | 36 | public function __construct( CollectionsService $collectionsService, 37 | ObjectsService $objectsService, 38 | IdProvider $idProvider ) 39 | { 40 | $this->collectionsService = $collectionsService; 41 | $this->objectsService = $objectsService; 42 | $this->idProvider = $idProvider; 43 | } 44 | 45 | public function persistActivityToInbox( InboxActivityEvent $event ) 46 | { 47 | $activity = $event->getActivity(); 48 | if ( ! $this->objectsService->getObject( $activity['id'] ) ) { 49 | $event->getRequest()->attributes->set( 'firstTimeSeen', true ); 50 | } 51 | $receivingActor = $event->getReceivingActor(); 52 | if ( $receivingActor->hasField( 'inbox' ) ) { 53 | $this->collectionsService->addItem( $receivingActor['inbox'], $activity ); 54 | } else { 55 | $this->objectsService->persist( $activity ); 56 | } 57 | $event->setResponse( new Response( 'Activity accepted', Response::HTTP_OK ) ); 58 | } 59 | 60 | public function persistActivityToOutbox( OutboxActivityEvent $event ) 61 | { 62 | $activity = $event->getActivity(); 63 | if ( ! array_key_exists( 'id', $activity ) ) { 64 | $activity['id'] = $this->idProvider->getId( $event->getRequest(), "activities" ); 65 | } 66 | $receivingActor = $event->getReceivingActor(); 67 | if ( $receivingActor->hasField( 'outbox' ) ) { 68 | $this->collectionsService->addItem( $receivingActor['outbox'], $activity ); 69 | } else { 70 | $this->objectsService->persist( $activity ); 71 | } 72 | $event->setResponse( new Response( 73 | 'Activity accepted', Response::HTTP_CREATED, array( 'Location' => $activity['id'] ) 74 | ) ); 75 | } 76 | } -------------------------------------------------------------------------------- /src/ActivityEventHandlers/AddHandler.php: -------------------------------------------------------------------------------- 1 | 'handleAdd', 26 | OutboxActivityEvent::NAME => 'handleAdd', 27 | ); 28 | } 29 | 30 | public function __construct( ObjectsService $objectsService, 31 | CollectionsService $collectionsService ) 32 | { 33 | $this->objectsService = $objectsService; 34 | $this->collectionsService = $collectionsService; 35 | } 36 | 37 | public function handleAdd( ActivityEvent $event ) 38 | { 39 | $activity = $event->getActivity(); 40 | if ( $activity['type'] !== 'Add' ) { 41 | return; 42 | } 43 | $collectionId = $activity['target']; 44 | if ( is_array( $collectionId ) && array_key_exists( 'id', $collectionId ) ) { 45 | $collectionId = $collectionId['id']; 46 | } 47 | $collection = $this->objectsService->dereference( $collectionId ); 48 | $requestActor = $event->getRequest()->attributes->get( 'actor' ); 49 | $requestActorHost = parse_url( $requestActor['id'], PHP_URL_HOST ); 50 | $collectionHost = parse_url( $collection['id'], PHP_URL_HOST ); 51 | if ( $requestActorHost !== $collectionHost ) { 52 | throw new AccessDeniedHttpException(); 53 | } 54 | $object = $activity['object']; 55 | $this->collectionsService->addItem( $collection, $object ); 56 | } 57 | } -------------------------------------------------------------------------------- /src/ActivityEventHandlers/AnnounceHandler.php: -------------------------------------------------------------------------------- 1 | 'handleInbox', 33 | ); 34 | } 35 | 36 | public function __construct( ObjectsService $objectsService, 37 | CollectionsService $collectionsService, 38 | ContextProvider $contextProvider ) 39 | { 40 | $this->objectsService = $objectsService; 41 | $this->collectionsService = $collectionsService; 42 | $this->contextProvider = $contextProvider; 43 | } 44 | 45 | public function handleInbox( InboxActivityEvent $event ) 46 | { 47 | $activity = $event->getActivity(); 48 | if ( $activity['type'] !== 'Announce' ) { 49 | return; 50 | } 51 | $objectId = $activity['object']; 52 | if ( is_array( $objectId ) && array_key_exists( 'id', $objectId ) ) { 53 | $objectId = $objectId['id']; 54 | } 55 | if ( ! is_string( $objectId ) ) { 56 | throw new BadRequestHttpException( 'Invalid object' ); 57 | } 58 | $object = $this->objectsService->dereference( $objectId ); 59 | if ( ! $object->hasField( 'shares' ) ) { 60 | $object = $this->addCollectionToObject( $object, 'shares' ); 61 | 62 | } 63 | $shares = $object['shares']; 64 | $this->collectionsService->addItem( $shares, $activity ); 65 | } 66 | 67 | private function addCollectionToObject( ActivityPubObject $object, $collectionName ) 68 | { 69 | $updatedObject = $object->asArray(); 70 | $updatedObject[$collectionName] = array( 71 | '@context' => $this->contextProvider->getContext(), 72 | 'id' => rtrim( $updatedObject['id'], '/' ) . '/' . $collectionName, 73 | 'type' => 'Collection', 74 | 'items' => array(), 75 | ); 76 | return $this->objectsService->update( $object['id'], $updatedObject ); 77 | } 78 | } -------------------------------------------------------------------------------- /src/ActivityEventHandlers/CreateHandler.php: -------------------------------------------------------------------------------- 1 | objectsService = $objectsService; 32 | $this->idProvider = $idProvider; 33 | $this->collectionsService = $collectionsService; 34 | } 35 | 36 | public static function getSubscribedEvents() 37 | { 38 | return array( 39 | InboxActivityEvent::NAME => 'handleInbox', 40 | OutboxActivityEvent::NAME => 'handleOutbox', 41 | ); 42 | } 43 | 44 | public function handleInbox( InboxActivityEvent $event ) 45 | { 46 | $activity = $event->getActivity(); 47 | if ( $activity['type'] !== 'Create' ) { 48 | return; 49 | } 50 | $object = $activity['object']; 51 | if ( in_array( $object['type'], array( 'Collection', 'OrderedCollection' ) ) ) { 52 | $object = $this->collectionsService->normalizeCollection( $object ); 53 | } 54 | $this->objectsService->persist( $object ); 55 | $activity['object'] = $object; 56 | $event->setActivity( $activity ); 57 | } 58 | 59 | public function handleOutbox( OutboxActivityEvent $event ) 60 | { 61 | $activity = $event->getActivity(); 62 | if ( $activity['type'] !== 'Create' ) { 63 | return; 64 | } 65 | $object = $activity['object']; 66 | if ( !array_key_exists( 'id', $object ) ) { 67 | $object['id'] = $this->idProvider->getId( 68 | $event->getRequest(), 69 | strtolower( $object['type'] ) 70 | ); 71 | } 72 | $object['attributedTo'] = $this->getActorId( $activity ); 73 | $object = $this->copyFields( 74 | array( 'to', 'cc', 'audience' ), $activity, $object 75 | ); 76 | $activity = $this->copyFields( 77 | array( 'to', 'bto', 'cc', 'bcc', 'audience' ), $object, $activity 78 | ); 79 | if ( in_array( $object['type'], array( 'Collection', 'OrderedCollection' ) ) ) { 80 | $object = $this->collectionsService->normalizeCollection( $object ); 81 | } 82 | $activity['object'] = $object; 83 | $this->objectsService->persist( $object ); 84 | $event->setActivity( $activity ); 85 | } 86 | 87 | private function getActorId( array $activity ) 88 | { 89 | $actor = $activity['actor']; 90 | if ( is_string( $actor ) ) { 91 | return $actor; 92 | } else { 93 | return $actor['id']; 94 | } 95 | } 96 | 97 | private function copyFields( array $fields, array $sourceObj, array $targetObj ) 98 | { 99 | foreach ( $fields as $field ) { 100 | if ( !array_key_exists( $field, $sourceObj ) ) { 101 | continue; 102 | } 103 | if ( array_key_exists( $field, $targetObj ) && 104 | $sourceObj[$field] === $targetObj[$field] ) { 105 | continue; 106 | } else if ( !array_key_exists( $field, $targetObj ) ) { 107 | $targetObj[$field] = $sourceObj[$field]; 108 | } else if ( is_array( $sourceObj[$field] ) && 109 | is_array( $targetObj[$field] ) ) { 110 | $targetObj[$field] = array_unique( 111 | array_merge( $sourceObj[$field], $targetObj[$field] ) 112 | ); 113 | } else if ( is_array( $sourceObj[$field] ) && 114 | !is_array( $targetObj[$field] ) ) { 115 | $targetObj[$field] = array( $targetObj[$field] ); 116 | $targetObj[$field] = array_unique( 117 | array_merge( $sourceObj[$field], $targetObj[$field] ) 118 | ); 119 | } else if ( !is_array( $sourceObj[$field] ) && 120 | is_array( $targetObj[$field] ) ) { 121 | $targetObj[$field][] = $sourceObj[$field]; 122 | } else if ( !is_array( $sourceObj[$field] ) && 123 | !is_array( $targetObj[$field] ) ) { 124 | $targetObj[$field] = array( $targetObj[$field] ); 125 | $targetObj[$field][] = $sourceObj[$field]; 126 | } 127 | } 128 | return $targetObj; 129 | } 130 | } 131 | 132 | -------------------------------------------------------------------------------- /src/ActivityEventHandlers/DeleteHandler.php: -------------------------------------------------------------------------------- 1 | dateTimeProvider = $dateTimeProvider; 29 | $this->objectsService = $objectsService; 30 | } 31 | 32 | public static function getSubscribedEvents() 33 | { 34 | return array( 35 | InboxActivityEvent::NAME => 'handleDelete', 36 | OutboxActivityEvent::NAME => 'handleDelete', 37 | ); 38 | } 39 | 40 | public function handleDelete( ActivityEvent $event ) 41 | { 42 | $activity = $event->getActivity(); 43 | if ( $activity['type'] !== 'Delete' ) { 44 | return; 45 | } 46 | $objectId = $activity['object']; 47 | if ( !is_string( $objectId ) ) { 48 | if ( is_array( $objectId ) && array_key_exists( 'id', $objectId ) ) { 49 | $objectId = $objectId['id']; 50 | } else { 51 | throw new BadRequestHttpException( 'Object must have an "id" field' ); 52 | } 53 | } 54 | if ( !$this->authorized( $event->getRequest(), $objectId ) ) { 55 | throw new UnauthorizedHttpException( 56 | 'Signature realm="ActivityPub",headers="(request-target) host date"' 57 | ); 58 | } 59 | $tombstone = array( 60 | '@context' => 'https://www.w3.org/ns/activitystreams', 61 | 'id' => $objectId, 62 | 'type' => 'Tombstone', 63 | 'deleted' => $this->getNowTimestamp(), 64 | ); 65 | $existing = $this->objectsService->dereference( $objectId ); 66 | if ( $existing ) { 67 | $tombstone['formerType'] = $existing['type']; 68 | } 69 | $this->objectsService->replace( $objectId, $tombstone ); 70 | } 71 | 72 | public function authorized( Request $request, $objectId ) 73 | { 74 | if ( !$request->attributes->has( 'actor' ) ) { 75 | return false; 76 | } 77 | $requestActor = $request->attributes->get( 'actor' ); 78 | $object = $this->objectsService->dereference( $objectId ); 79 | if ( !$object || !$object->hasField( 'attributedTo' ) ) { 80 | return false; 81 | } 82 | $attributedActorId = $object['attributedTo']; 83 | if ( !is_string( $attributedActorId ) ) { 84 | $attributedActorId = $attributedActorId['id']; 85 | } 86 | return $requestActor['id'] === $attributedActorId; 87 | } 88 | 89 | private function getNowTimestamp() 90 | { 91 | return $this->dateTimeProvider->getTime( 'activities.delete' ) 92 | ->format( DateTime::ISO8601 ); 93 | } 94 | } 95 | 96 | -------------------------------------------------------------------------------- /src/ActivityEventHandlers/FollowHandler.php: -------------------------------------------------------------------------------- 1 | autoAccepts = $autoAccepts; 26 | $this->contextProvider = $contextProvider; 27 | } 28 | 29 | public static function getSubscribedEvents() 30 | { 31 | return array( 32 | InboxActivityEvent::NAME => 'handleInbox', 33 | ); 34 | } 35 | 36 | public function handleInbox( InboxActivityEvent $event, 37 | /** @noinspection PhpUnusedParameterInspection */ 38 | $eventName, 39 | EventDispatcher $eventDispatcher ) 40 | { 41 | $activity = $event->getActivity(); 42 | if ( !$activity['type'] === 'Follow' ) { 43 | return; 44 | } 45 | if ( $this->autoAccepts ) { 46 | $localActor = $event->getReceivingActor(); 47 | $objectId = $activity['object']; 48 | if ( is_array( $objectId ) && array_key_exists( 'id', $objectId ) ) { 49 | $objectId = $objectId['id']; 50 | } 51 | if ( $localActor['id'] !== $objectId ) { 52 | return; 53 | } 54 | $accept = array( 55 | '@context' => $this->contextProvider->getContext(), 56 | 'type' => 'Accept', 57 | 'actor' => $localActor['id'], 58 | 'object' => $activity['id'], 59 | ); 60 | $request = Request::create( 61 | $localActor['outbox'], 62 | Request::METHOD_POST, 63 | array(), array(), array(), 64 | array( 65 | 'HTTP_ACCEPT' => 'application/ld+json', 66 | 'CONTENT_TYPE' => 'application/json' 67 | ), 68 | json_encode( $accept ) 69 | ); 70 | $request->attributes->add( array( 71 | 'actor' => $localActor, 72 | 'follow' => $activity, 73 | ) ); 74 | $outboxEvent = new OutboxActivityEvent( $accept, $localActor, $request ); 75 | $eventDispatcher->dispatch( OutboxActivityEvent::NAME, $outboxEvent ); 76 | } 77 | } 78 | } 79 | 80 | -------------------------------------------------------------------------------- /src/ActivityEventHandlers/InboxActivityEvent.php: -------------------------------------------------------------------------------- 1 | 'handleInbox', 33 | OutboxActivityEvent::NAME => 'handleOutbox', 34 | ); 35 | } 36 | 37 | public function __construct( ObjectsService $objectsService, 38 | CollectionsService $collectionsService, 39 | ContextProvider $contextProvider ) 40 | { 41 | $this->objectsService = $objectsService; 42 | $this->collectionsService = $collectionsService; 43 | $this->contextProvider = $contextProvider; 44 | } 45 | 46 | public function handleInbox( InboxActivityEvent $event ) 47 | { 48 | $activity = $event->getActivity(); 49 | if ( $activity['type'] !== 'Like' ) { 50 | return; 51 | } 52 | $objectId = $activity['object']; 53 | if ( is_array( $objectId ) && array_key_exists( 'id', $objectId ) ) { 54 | $objectId = $objectId['id']; 55 | } 56 | if ( ! is_string( $objectId ) ) { 57 | throw new BadRequestHttpException('Invalid object'); 58 | } 59 | $object = $this->objectsService->dereference( $objectId ); 60 | if ( ! $object->hasField( 'likes' ) ) { 61 | $object = $this->addCollectionToObject( $object, 'likes' ); 62 | } 63 | $likes = $object['likes']; 64 | $this->collectionsService->addItem( $likes, $activity ); 65 | } 66 | 67 | public function handleOutbox( OutboxActivityEvent $event ) 68 | { 69 | $activity = $event->getActivity(); 70 | if ( $activity['type'] !== 'Like' ) { 71 | return; 72 | } 73 | $object = $activity['object']; 74 | $actor = $event->getReceivingActor(); 75 | if ( ! $actor->hasField( 'liked' ) ) { 76 | $actor = $this->addCollectionToObject( $actor, 'liked' ); 77 | } 78 | $liked = $actor['liked']; 79 | $this->collectionsService->addItem( $liked, $object ); 80 | } 81 | 82 | private function addCollectionToObject( ActivityPubObject $object, $collectionName ) 83 | { 84 | $updatedObject = $object->asArray(); 85 | $updatedObject[$collectionName] = array( 86 | '@context' => $this->contextProvider->getContext(), 87 | 'id' => rtrim( $updatedObject['id'], '/' ) . '/' . $collectionName, 88 | 'type' => 'Collection', 89 | 'items' => array(), 90 | ); 91 | return $this->objectsService->update( $object['id'], $updatedObject ); 92 | } 93 | } -------------------------------------------------------------------------------- /src/ActivityEventHandlers/NonActivityHandler.php: -------------------------------------------------------------------------------- 1 | contextProvider = $contextProvider; 23 | } 24 | 25 | public static function getSubscribedEvents() 26 | { 27 | return array( 28 | OutboxActivityEvent::NAME => 'handle', 29 | ); 30 | } 31 | 32 | public function handle( OutboxActivityEvent $event ) 33 | { 34 | $object = $event->getActivity(); 35 | if ( in_array( $object['type'], self::activityTypes() ) ) { 36 | return; 37 | } 38 | $actor = $event->getReceivingActor(); 39 | $create = $this->makeCreate( $object, $actor ); 40 | $event->setActivity( $create ); 41 | } 42 | 43 | public static function activityTypes() 44 | { 45 | return array( 46 | 'Accept', 'Add', 'Announce', 'Arrive', 47 | 'Block', 'Create', 'Delete', 'Dislike', 48 | 'Flag', 'Follow', 'Ignore', 'Invite', 49 | 'Join', 'Leave', 'Like', 'Listen', 50 | 'Move', 'Offer', 'Question', 'Reject', 51 | 'Read', 'Remove', 'TentativeReject', 'TentativeAccept', 52 | 'Travel', 'Undo', 'Update', 'View', 53 | ); 54 | } 55 | 56 | /** 57 | * Makes a new Create activity with $object as the object 58 | * 59 | * @param array $object The object 60 | * @param ActivityPubObject $actor The actor creating the object 61 | * 62 | * @return array The Create activity 63 | */ 64 | private function makeCreate( array $object, 65 | ActivityPubObject $actor ) 66 | { 67 | $create = array( 68 | '@context' => $this->contextProvider->getContext(), 69 | 'type' => 'Create', 70 | 'actor' => $actor['id'], 71 | 'object' => $object, 72 | ); 73 | foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $field ) { 74 | if ( array_key_exists( $field, $object ) ) { 75 | $create[$field] = $object[$field]; 76 | } 77 | } 78 | return $create; 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /src/ActivityEventHandlers/OutboxActivityEvent.php: -------------------------------------------------------------------------------- 1 | 'handleRemove', 26 | OutboxActivityEvent::NAME => 'handleRemove', 27 | ); 28 | } 29 | 30 | public function __construct( ObjectsService $objectsService, 31 | CollectionsService $collectionsService ) 32 | { 33 | $this->objectsService = $objectsService; 34 | $this->collectionsService = $collectionsService; 35 | } 36 | 37 | public function handleRemove( ActivityEvent $event ) 38 | { 39 | $activity = $event->getActivity(); 40 | if ( $activity['type'] !== 'Remove' ) { 41 | return; 42 | } 43 | $collectionId = $activity['target']; 44 | if ( is_array( $collectionId ) && array_key_exists( 'id', $collectionId ) ) { 45 | $collectionId = $collectionId['id']; 46 | } 47 | $collection = $this->objectsService->dereference( $collectionId ); 48 | $requestActor = $event->getRequest()->attributes->get( 'actor' ); 49 | $requestActorHost = parse_url( $requestActor['id'], PHP_URL_HOST ); 50 | $collectionHost = parse_url( $collection['id'], PHP_URL_HOST ); 51 | if ( $requestActorHost !== $collectionHost ) { 52 | throw new AccessDeniedHttpException(); 53 | } 54 | $objectId = $activity['object']; 55 | if ( is_array( $objectId ) && array_key_exists( 'id', $objectId ) ) { 56 | $objectId = $objectId['id']; 57 | } 58 | if ( ! is_string( $objectId ) ) { 59 | return; 60 | } 61 | $this->collectionsService->removeItem( $collection, $objectId ); 62 | } 63 | } -------------------------------------------------------------------------------- /src/ActivityEventHandlers/UndoHandler.php: -------------------------------------------------------------------------------- 1 | 'handleInbox', 27 | OutboxActivityEvent::NAME => 'handleOutbox', 28 | ); 29 | } 30 | 31 | public function __construct( ObjectsService $objectsService, 32 | CollectionsService $collectionsService ) 33 | { 34 | $this->objectsService = $objectsService; 35 | $this->collectionsService = $collectionsService; 36 | } 37 | 38 | public function handleInbox( InboxActivityEvent $event ) 39 | { 40 | $activity = $event->getActivity(); 41 | if ( $activity['type'] !== 'Undo' ) { 42 | return; 43 | } 44 | $object = $this->getUndoObject( $activity ); 45 | if ( ! ( $object && $object->hasField( 'type' ) ) ) { 46 | return; 47 | } 48 | $this->assertUndoIsValid( $activity, $object ); 49 | switch ( $object['type'] ) { 50 | case 'Follow': 51 | $this->removeFromCollection( $object['object'], 'followers', $object['actor'] ); 52 | break; 53 | case 'Like': 54 | $this->removeFromCollection( $object['object'], 'likes', $object['id'] ); 55 | break; 56 | default: 57 | return; 58 | } 59 | } 60 | 61 | public function handleOutbox( OutboxActivityEvent $event ) 62 | { 63 | $activity = $event->getActivity(); 64 | if ( $activity['type'] !== 'Undo' ) { 65 | return; 66 | } 67 | $object = $this->getUndoObject( $activity ); 68 | if ( ! ( $object && $object->hasField( 'type' ) ) ) { 69 | return; 70 | } 71 | $this->assertUndoIsValid( $activity, $object ); 72 | switch ( $object['type'] ) { 73 | case 'Follow': 74 | $this->removeFromCollection( $object['actor'], 'following', $object['object'] ); 75 | break; 76 | case 'Like': 77 | $this->removeFromCollection( $object['actor'], 'liked', $object['object'] ); 78 | break; 79 | default: 80 | return; 81 | } 82 | } 83 | 84 | private function assertUndoIsValid( $activity, ActivityPubObject $undoObject ) 85 | { 86 | if ( ! array_key_exists( 'actor', $activity ) ) { 87 | throw new AccessDeniedHttpException("You can't undo an activity you don't own"); 88 | } 89 | $actorId = $activity['actor']; 90 | if ( is_array( $actorId ) && array_key_exists( 'id', $actorId ) ) { 91 | $actorId = $actorId['id']; 92 | } 93 | if ( ! is_string( $actorId ) ) { 94 | throw new AccessDeniedHttpException("You can't undo an activity you don't own"); 95 | } 96 | $objectActor = $undoObject['actor']; 97 | if ( ! $objectActor ) { 98 | throw new AccessDeniedHttpException("You can't undo an activity you don't own"); 99 | } 100 | if ( $actorId != $objectActor['id'] ) { 101 | throw new AccessDeniedHttpException("You can't undo an activity you don't own"); 102 | } 103 | } 104 | 105 | private function removeFromCollection( $object, $collectionField, $itemId ) 106 | { 107 | if ( ! ( $object && $object instanceof ActivityPubObject ) ) { 108 | return; 109 | } 110 | if ( ! $object->hasField( $collectionField ) ) { 111 | return; 112 | } 113 | $collection = $object[$collectionField]; 114 | if ( ! ( $collection && $collection instanceof ActivityPubObject ) ) { 115 | return; 116 | } 117 | if ( ! $itemId ) { 118 | return; 119 | } 120 | if ( $itemId instanceof ActivityPubObject && $itemId->hasField( 'id' ) ) { 121 | $itemId = $itemId['id']; 122 | } else if ( is_array( $itemId ) && array_key_exists( 'id', $itemId ) ) { 123 | $itemId = $itemId['id']; 124 | } 125 | if ( ! is_string( $itemId ) ) { 126 | return; 127 | } 128 | $this->collectionsService->removeItem( $collection, $itemId ); 129 | } 130 | 131 | /** 132 | * Gets the object of the undo activity as an ActivityPubObject 133 | * @param $activity 134 | * @return \ActivityPub\Entities\ActivityPubObject|null 135 | */ 136 | private function getUndoObject( $activity ) 137 | { 138 | $objectId = $activity['object']; 139 | if ( is_array( $objectId ) ) { 140 | if ( ! array_key_exists( 'id', $objectId ) ) { 141 | return null; 142 | } 143 | $objectId = $objectId['id']; 144 | } 145 | return $this->objectsService->dereference( $objectId ); 146 | } 147 | } -------------------------------------------------------------------------------- /src/ActivityEventHandlers/UpdateHandler.php: -------------------------------------------------------------------------------- 1 | objectsService = $objectsService; 21 | } 22 | 23 | public static function getSubscribedEvents() 24 | { 25 | return array( 26 | InboxActivityEvent::NAME => 'handleInbox', 27 | OutboxActivityEvent::NAME => 'handleOutbox', 28 | ); 29 | } 30 | 31 | public function handleInbox( InboxActivityEvent $event ) 32 | { 33 | $activity = $event->getActivity(); 34 | if ( $activity['type'] !== 'Update' ) { 35 | return; 36 | } 37 | $object = $activity['object']; 38 | if ( !array_key_exists( 'id', $object ) ) { 39 | throw new BadRequestHttpException( 'Update object has no "id" field' ); 40 | } 41 | if ( !$this->authorized( $event->getRequest(), $object ) ) { 42 | throw new UnauthorizedHttpException( 43 | 'Signature realm="ActivityPub",headers="(request-target) host date"' 44 | ); 45 | } 46 | $this->objectsService->replace( $object['id'], $object ); 47 | } 48 | 49 | /** 50 | * Returns true if $request is authorized to update $object 51 | * 52 | * @param Request $request The current request 53 | * @param array $object The object 54 | * @return bool 55 | */ 56 | private function authorized( Request $request, array $object ) 57 | { 58 | if ( !$request->attributes->has( 'actor' ) ) { 59 | return false; 60 | } 61 | if ( !array_key_exists( 'id', $object ) ) { 62 | return false; 63 | } 64 | $object = $this->objectsService->dereference( $object['id'] ); 65 | if ( !$object->hasField( 'attributedTo' ) ) { 66 | return false; 67 | } 68 | $attributedActorId = $object['attributedTo']; 69 | if ( is_array( $attributedActorId ) && 70 | array_key_exists( 'id', $attributedActorId ) ) { 71 | $attributedActorId = $attributedActorId['id']; 72 | } 73 | if ( !is_string( $attributedActorId ) ) { 74 | return false; 75 | } 76 | $requestActor = $request->attributes->get( 'actor' ); 77 | return $requestActor['id'] === $attributedActorId; 78 | } 79 | 80 | public function handleOutbox( OutboxActivityEvent $event ) 81 | { 82 | $activity = $event->getActivity(); 83 | if ( $activity['type'] !== 'Update' ) { 84 | return; 85 | } 86 | $updateFields = $activity['object']; 87 | if ( !array_key_exists( 'id', $updateFields ) ) { 88 | throw new BadRequestHttpException( 'Update object has no "id" field' ); 89 | } 90 | if ( !$this->authorized( $event->getRequest(), $updateFields ) ) { 91 | throw new UnauthorizedHttpException( 92 | 'Signature realm="ActivityPub",headers="(request-target) host date"' 93 | ); 94 | } 95 | $updated = $this->objectsService->update( $updateFields['id'], $updateFields ); 96 | $activity['object'] = $updated->asArray(); 97 | $event->setActivity( $activity ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/ActivityEventHandlers/ValidationHandler.php: -------------------------------------------------------------------------------- 1 | 'verifyInboxActivity', 14 | OutboxActivityEvent::NAME => 'verifyOutboxActivity', 15 | ); 16 | } 17 | 18 | public function verifyInboxActivity( InboxActivityEvent $event ) 19 | { 20 | $activity = $event->getActivity(); 21 | $requiredFields = array( 'type', 'id', 'actor' ); 22 | if ( array_key_exists( 'type', $activity ) && 23 | in_array( $activity['type'], self::getObjectRequiredTypes() ) ) { 24 | $requiredFields[] = 'object'; 25 | } 26 | if ( array_key_exists( 'type', $activity ) && 27 | in_array( $activity['type'], self::getTargetRequiredTypes() ) ) { 28 | $requiredFields[] = 'target'; 29 | } 30 | $this->requireFields( $activity, $requiredFields ); 31 | } 32 | 33 | public static function getObjectRequiredTypes() 34 | { 35 | return array( 36 | 'Create', 'Update', 'Delete', 'Follow', 37 | 'Add', 'Remove', 'Like', 'Block', 'Undo', 38 | ); 39 | } 40 | 41 | public static function getTargetRequiredTypes() 42 | { 43 | return array( 44 | 'Add', 'Remove', 45 | ); 46 | } 47 | 48 | private function requireFields( array $activity, array $fields ) 49 | { 50 | $missing = array(); 51 | foreach ( $fields as $field ) { 52 | if ( !array_key_exists( $field, $activity ) ) { 53 | $missing[] = $field; 54 | } 55 | } 56 | if ( count( $missing ) > 0 ) { 57 | throw new BadRequestHttpException( 58 | "Missing activity fields: " . implode( ',', $missing ) 59 | ); 60 | } 61 | } 62 | 63 | public function verifyOutboxActivity( OutboxActivityEvent $event ) 64 | { 65 | $activity = $event->getActivity(); 66 | $requiredFields = array( 'type', 'actor' ); 67 | if ( array_key_exists( 'type', $activity ) && 68 | in_array( $activity['type'], self::getObjectRequiredTypes() ) ) { 69 | $requiredFields[] = 'object'; 70 | } 71 | if ( array_key_exists( 'type', $activity ) && 72 | in_array( $activity['type'], self::getTargetRequiredTypes() ) ) { 73 | $requiredFields[] = 'target'; 74 | } 75 | $this->requireFields( $activity, $requiredFields ); 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /src/ActivityPub.php: -------------------------------------------------------------------------------- 1 | module = new ActivityPubModule( $config ); 52 | } 53 | 54 | /** 55 | * Handles an incoming ActivityPub request 56 | * 57 | * @param Request $request (optional) The Symfony request object. 58 | * If not passed in, it is generated from the request globals. 59 | * 60 | * @return Response The response. Can be sent to the client with $response->send(). 61 | */ 62 | public function handle( $request = null ) 63 | { 64 | if ( !$request ) { 65 | $request = Request::createFromGlobals(); 66 | } 67 | 68 | $dispatcher = $this->module->get( EventDispatcher::class ); 69 | $dispatcher->addSubscriber( $this->module->get( Router::class ) ); 70 | $dispatcher->addSubscriber( $this->module->get( AuthListener::class ) ); 71 | $dispatcher->addSubscriber( $this->module->get( SignatureListener::class ) ); 72 | $dispatcher->addSubscriber( new ExceptionListener() ); 73 | 74 | $this->subscribeActivityHandlers( $dispatcher ); 75 | 76 | $controllerResolver = new ControllerResolver(); 77 | $argumentResolver = new ArgumentResolver(); 78 | 79 | $kernel = new HttpKernel( 80 | $dispatcher, $controllerResolver, new RequestStack(), $argumentResolver 81 | ); 82 | return $kernel->handle( $request ); 83 | } 84 | 85 | /** 86 | * Sets up the activity handling pipeline 87 | * 88 | * @param EventDispatcher $dispatcher The dispatcher to attach the event 89 | * subscribers to 90 | */ 91 | private function subscribeActivityHandlers( EventDispatcher $dispatcher ) 92 | { 93 | $dispatcher->addSubscriber( $this->module->get( NonActivityHandler::class ) ); 94 | $dispatcher->addSubscriber( $this->module->get( ValidationHandler::class ) ); 95 | $dispatcher->addSubscriber( $this->module->get( CreateHandler::class ) ); 96 | $dispatcher->addSubscriber( $this->module->get( UpdateHandler::class ) ); 97 | $dispatcher->addSubscriber( $this->module->get( DeleteHandler::class ) ); 98 | $dispatcher->addSubscriber( $this->module->get( FollowHandler::class ) ); 99 | $dispatcher->addSubscriber( $this->module->get( AcceptHandler::class ) ); 100 | $dispatcher->addSubscriber( $this->module->get( AddHandler::class ) ); 101 | $dispatcher->addSubscriber( $this->module->get( RemoveHandler::class ) ); 102 | $dispatcher->addSubscriber( $this->module->get( LikeHandler::class ) ); 103 | $dispatcher->addSubscriber( $this->module->get( AnnounceHandler::class ) ); 104 | $dispatcher->addSubscriber( $this->module->get( UndoHandler::class ) ); 105 | $dispatcher->addSubscriber( $this->module->get( ActivityPersister::class ) ); 106 | $dispatcher->addSubscriber( $this->module->get( DeliveryHandler::class ) ); 107 | } 108 | 109 | /** 110 | * Creates the database tables necessary for the library to function, 111 | * if they have not already been created. 112 | * 113 | * For best performance, this should only get called once in an application 114 | * (for example, when other database migrations get run). 115 | */ 116 | public function updateSchema() 117 | { 118 | $entityManager = @$this->module->get( EntityManager::class ); 119 | $driverName = $entityManager->getConnection()->getDriver()->getName(); 120 | if ( $driverName === 'pdo_mysql' ) 121 | { 122 | $entityManager->getConnection()->getDatabasePlatform() 123 | ->registerDoctrineTypeMapping('enum', 'string'); 124 | } 125 | $schemaTool = new SchemaTool( $entityManager ); 126 | $classes = $entityManager->getMetadataFactory()->getAllMetadata(); 127 | $schemaTool->updateSchema( $classes, true ); 128 | } 129 | } 130 | 131 | -------------------------------------------------------------------------------- /src/Auth/AuthListener.php: -------------------------------------------------------------------------------- 1 | authFunction = $authFunction; 42 | $this->objectsService = $objectsService; 43 | } 44 | 45 | public static function getSubscribedEvents() 46 | { 47 | return array( 48 | KernelEvents::REQUEST => 'checkAuth' 49 | ); 50 | } 51 | 52 | public function checkAuth( GetResponseEvent $event ) 53 | { 54 | $request = $event->getRequest(); 55 | if ( $request->attributes->has( 'actor' ) ) { 56 | return; 57 | } 58 | $actorId = call_user_func( $this->authFunction ); 59 | if ( $actorId && !empty( $actorId ) ) { 60 | $actor = $this->objectsService->dereference( $actorId ); 61 | if ( !$actor ) { 62 | throw new Exception( "Actor $actorId does not exist" ); 63 | } 64 | $request->attributes->set( 'actor', $actor ); 65 | } 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /src/Auth/AuthService.php: -------------------------------------------------------------------------------- 1 | hasAudience( $object ) ) { 14 | return true; 15 | } 16 | $audience = $this->getAudience( $object ); 17 | if ( in_array( 'https://www.w3.org/ns/activitystreams#Public', $audience ) ) { 18 | return true; 19 | } 20 | return $request->attributes->has( 'actor' ) && 21 | in_array( $request->attributes->get( 'actor' ), $audience ); 22 | } 23 | 24 | private function hasAudience( ActivityPubObject $object ) 25 | { 26 | $arr = $object->asArray( 0 ); 27 | return array_key_exists( 'audience', $arr ) || 28 | array_key_exists( 'to', $arr ) || 29 | array_key_exists( 'bto', $arr ) || 30 | array_key_exists( 'cc', $arr ) || 31 | array_key_exists( 'bcc', $arr ); 32 | } 33 | 34 | /** 35 | * Returns an array of all of the $object's audience actors, i.e. 36 | * the contents of the to, bto, cc, bcc, and audience fields, as 37 | * well as the actor who created to object 38 | * 39 | * @param ActivityPubObject $object 40 | * @return array The audience members, collapsed to an array of ids 41 | */ 42 | private function getAudience( ActivityPubObject $object ) 43 | { 44 | // TODO do I need to traverse the inReplyTo chain here? 45 | $objectArr = $object->asArray( 0 ); 46 | $audience = array(); 47 | foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience', 'attributedTo', 'actor' ) 48 | as $attribute ) { 49 | $audience = $this->checkAudienceAttribute( $audience, $attribute, $objectArr ); 50 | } 51 | return $audience; 52 | } 53 | 54 | private function checkAudienceAttribute( $audience, $attribute, $objectArr ) 55 | { 56 | if ( array_key_exists( $attribute, $objectArr ) ) { 57 | $audienceValue = $objectArr[$attribute]; 58 | if ( !is_array( $audienceValue ) ) { 59 | $audienceValue = array( $audienceValue ); 60 | } 61 | return array_merge( $audience, $audienceValue ); 62 | } else { 63 | return $audience; 64 | } 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /src/Auth/SignatureListener.php: -------------------------------------------------------------------------------- 1 | httpSignatureService = $httpSignatureService; 32 | $this->objectsService = $objectsService; 33 | } 34 | 35 | public static function getSubscribedEvents() 36 | { 37 | return array( 38 | KernelEvents::REQUEST => 'validateHttpSignature' 39 | ); 40 | } 41 | 42 | /** 43 | * Check for a valid HTTP signature on the request. If the request has a valid 44 | * signature, set the 'signed' and 'signedBy' keys on the request ('signedBy' is 45 | * the id of the actor whose key signed the request) 46 | * @param GetResponseEvent $event 47 | */ 48 | public function validateHttpSignature( GetResponseEvent $event ) 49 | { 50 | $request = $event->getRequest(); 51 | $headers = $request->headers; 52 | $signatureHeader = null; 53 | if ( $headers->has( 'signature' ) ) { 54 | $signatureHeader = $headers->get( 'signature' ); 55 | } else if ( $headers->has( 'authorization' ) && 56 | substr( $headers->get( 'authorization' ), 0, 9 ) === 'Signature' ) { 57 | $signatureHeader = substr( $headers->get( 'authorization' ), 10 ); 58 | } 59 | if ( !$signatureHeader ) { 60 | return; 61 | } 62 | $matches = array(); 63 | if ( !preg_match( '/keyId="([^"]*)"/', $signatureHeader, $matches ) ) { 64 | return; 65 | } 66 | $keyId = $matches[1]; 67 | $key = $this->objectsService->dereference( $keyId ); 68 | if ( !$key || !$key->hasField( 'owner' ) || !$key->hasField( 'publicKeyPem' ) ) { 69 | return; 70 | } 71 | $owner = $key['owner']; 72 | if ( is_string( $owner ) ) { 73 | $owner = $this->objectsService->dereference( $owner ); 74 | } 75 | if ( !$owner ) { 76 | return; 77 | } 78 | if ( !$this->httpSignatureService->verify( $request, $key['publicKeyPem'] ) ) { 79 | return; 80 | } 81 | $request->attributes->set( 'signed', true ); 82 | if ( !$request->attributes->has( 'actor' ) ) { 83 | $request->attributes->set( 'actor', $owner ); 84 | } 85 | } 86 | } 87 | 88 | -------------------------------------------------------------------------------- /src/Config/ActivityPubConfig.php: -------------------------------------------------------------------------------- 1 | createBuilder()->build() 55 | * 56 | * @param ActivityPubConfigBuilder $builder 57 | */ 58 | public function __construct( ActivityPubConfigBuilder $builder ) 59 | { 60 | $this->dbConnectionParams = $builder->getDbConnectionParams(); 61 | $this->isDevMode = $builder->getIsDevMode(); 62 | $this->dbPrefix = $builder->getDbPrefix(); 63 | $this->authFunction = $builder->getAuthFunction(); 64 | $this->jsonLdContext = $builder->getJsonLdContext(); 65 | $this->idPathPrefix = $builder->getIdPathPrefix(); 66 | $this->autoAcceptsFollows = $builder->getAutoAcceptsFollows(); 67 | $this->logger = $builder->getLogger(); 68 | } 69 | 70 | public static function createBuilder() 71 | { 72 | return new ActivityPubConfigBuilder(); 73 | } 74 | 75 | /** 76 | * @return array 77 | */ 78 | public function getDbConnectionParams() 79 | { 80 | return $this->dbConnectionParams; 81 | } 82 | 83 | /** 84 | * @return bool 85 | */ 86 | public function getIsDevMode() 87 | { 88 | return $this->isDevMode; 89 | } 90 | 91 | /** 92 | * @return string 93 | */ 94 | public function getDbPrefix() 95 | { 96 | return $this->dbPrefix; 97 | 98 | } 99 | 100 | /** 101 | * @return Callable 102 | */ 103 | public function getAuthFunction() 104 | { 105 | return $this->authFunction; 106 | } 107 | 108 | /** 109 | * @return array 110 | */ 111 | public function getJsonLdContext() 112 | { 113 | return $this->jsonLdContext; 114 | } 115 | 116 | /** 117 | * @return string 118 | */ 119 | public function getIdPathPrefix() 120 | { 121 | return $this->idPathPrefix; 122 | } 123 | 124 | /** 125 | * @return bool 126 | */ 127 | public function getAutoAcceptsFollows() 128 | { 129 | return $this->autoAcceptsFollows; 130 | } 131 | 132 | /** 133 | * @return LoggerInterface 134 | */ 135 | public function getLogger() 136 | { 137 | return $this->logger; 138 | } 139 | } 140 | 141 | -------------------------------------------------------------------------------- /src/Controllers/GetController.php: -------------------------------------------------------------------------------- 1 | objectsService = $objectsService; 48 | $this->collectionsService = $collectionsService; 49 | $this->authService = $authService; 50 | $this->blockService = $blockService; 51 | } 52 | 53 | /** 54 | * Returns a Response with the JSON representation of the requested object 55 | * 56 | * @param Request $request The HTTP request 57 | * @return Response 58 | */ 59 | public function handle( Request $request ) 60 | { 61 | $uri = $request->getUri(); 62 | $queryPos = strpos( $uri, '?' ); 63 | if ( $queryPos !== false ) { 64 | $uri = substr( $uri, 0, $queryPos ); 65 | } 66 | $object = $this->objectsService->dereference( $uri ); 67 | if ( !$object ) { 68 | throw new NotFoundHttpException(); 69 | } 70 | if ( !$this->authService->isAuthorized( $request, $object ) ) { 71 | throw new UnauthorizedHttpException( 72 | 'Signature realm="ActivityPub",headers="(request-target) host date"' 73 | ); 74 | } 75 | if ( $object->hasField( 'type' ) && 76 | ( $object['type'] === 'Collection' || 77 | $object['type'] === 'OrderedCollection' ) ) { 78 | if ( $object->hasReferencingField( 'inbox' ) ) { 79 | $inboxActor = $object->getReferencingField( 'inbox' )->getObject(); 80 | $blockedActorIds = $this->blockService->getBlockedActorIds( $inboxActor['id'] ); 81 | $filterFunc = function ( ActivityPubObject $item ) use ( $request, $blockedActorIds ) { 82 | $shouldShow = $this->authService->isAuthorized( $request, $item ); 83 | foreach ( array( 'actor', 'attributedTo' ) as $actorField ) { 84 | if ( $item->hasField( $actorField ) ) { 85 | $actorFieldValue = $item->getFieldValue( $actorField ); 86 | if ( ! $actorFieldValue ) { 87 | continue; 88 | } 89 | if ( is_string( $actorFieldValue ) && 90 | in_array( $actorFieldValue, $blockedActorIds ) ) { 91 | $shouldShow = false; 92 | break; 93 | } else if ( $actorFieldValue instanceof ActivityPubObject && 94 | in_array( $actorFieldValue['id'], $blockedActorIds ) ) { 95 | $shouldShow = false; 96 | break; 97 | } 98 | } 99 | } 100 | return $shouldShow; 101 | }; 102 | } else { 103 | $filterFunc = function ( ActivityPubObject $item ) use ( $request ) { 104 | return $this->authService->isAuthorized( $request, $item ); 105 | }; 106 | } 107 | $pagedCollection = $this->collectionsService->pageAndFilterCollection( $request, $object, $filterFunc ); 108 | return $this->makeJsonResponse( $pagedCollection ); 109 | } 110 | $response = $this->makeJsonResponse( $object->asArray() ); 111 | if ( $object->hasField( 'type' ) && 112 | $object['type'] === 'Tombstone' ) { 113 | $response->setStatusCode( 410 ); 114 | } 115 | return $response; 116 | } 117 | 118 | private function makeJsonResponse( $obj ) 119 | { 120 | $response = new Response( json_encode( $obj, JSON_UNESCAPED_UNICODE ) ); 121 | $response->headers->set( 'Content-Type', 'application/json' ); 122 | return $response; 123 | } 124 | } 125 | 126 | -------------------------------------------------------------------------------- /src/Controllers/PostController.php: -------------------------------------------------------------------------------- 1 | eventDispatcher = $eventDispatcher; 36 | $this->objectsService = $objectsService; 37 | } 38 | 39 | /** 40 | * Handles an incoming POST request 41 | * 42 | * Either dispatches an inbox/outbox activity event or throws the appropriate 43 | * HTTP error. 44 | * @param Request $request The request 45 | * @return Response 46 | */ 47 | public function handle( Request $request ) 48 | { 49 | $uri = $this->getUriWithoutQuery( $request ); 50 | $results = $this->objectsService->query( array( 'id' => $uri ) ); 51 | if ( count( $results ) === 0 ) { 52 | throw new NotFoundHttpException; 53 | } 54 | $object = $results[0]; 55 | // TODO this assumes that every actor has a unique inbox URL 56 | // and will break if multiple actors have the same inbox 57 | // TODO also handle sharedInbox here 58 | // A potential solution to both problems is to refactor things so that activities are posted directly to an 59 | // inbox collection, without any conception of a "receiving actor". Lots of details to work out there though. 60 | $inboxField = $object->getReferencingField( 'inbox' ); 61 | if ( $inboxField ) { 62 | $activity = json_decode( $request->getContent(), true ); 63 | if ( !$activity || !array_key_exists( 'actor', $activity ) ) { 64 | throw new BadRequestHttpException(); 65 | } 66 | $activityActor = $this->getActivityActor( $activity ); 67 | if ( !$activityActor ) { 68 | throw new BadRequestHttpException(); 69 | } 70 | if ( !$request->attributes->has( 'signed' ) || 71 | !$this->authorized( $request, $activityActor ) ) { 72 | throw new UnauthorizedHttpException( 73 | 'Signature realm="ActivityPub",headers="(request-target) host date"' 74 | ); 75 | } 76 | $actorWithInbox = $inboxField->getObject(); 77 | $event = new InboxActivityEvent( $activity, $actorWithInbox, $request ); 78 | $this->eventDispatcher->dispatch( InboxActivityEvent::NAME, $event ); 79 | return $event->getResponse(); 80 | } 81 | // TODO this assumes that every actor has a unique outbox URL 82 | // and will break if multiple actors have the same outbox 83 | $outboxField = $object->getReferencingField( 'outbox' ); 84 | if ( $outboxField ) { 85 | $actorWithOutbox = $outboxField->getObject(); 86 | if ( !$this->authorized( $request, $actorWithOutbox ) ) { 87 | throw new UnauthorizedHttpException( 88 | 'Signature realm="ActivityPub",headers="(request-target) host date"' 89 | ); 90 | } 91 | $activity = json_decode( $request->getContent(), true ); 92 | if ( !$activity ) { 93 | throw new BadRequestHttpException(); 94 | } 95 | $event = new OutboxActivityEvent( $activity, $actorWithOutbox, $request ); 96 | $this->eventDispatcher->dispatch( OutboxActivityEvent::NAME, $event ); 97 | return $event->getResponse(); 98 | } 99 | throw new MethodNotAllowedHttpException( array( Request::METHOD_GET ) ); 100 | } 101 | 102 | private function getUriWithoutQuery( Request $request ) 103 | { 104 | $uri = $request->getUri(); 105 | $queryPos = strpos( $uri, '?' ); 106 | if ( $queryPos !== false ) { 107 | $uri = substr( $uri, 0, $queryPos ); 108 | } 109 | return $uri; 110 | } 111 | 112 | private function getActivityActor( array $activity ) 113 | { 114 | $actor = $activity['actor']; 115 | if ( is_array( $actor ) && array_key_exists( 'id', $actor ) ) { 116 | return $this->objectsService->dereference( $actor['id'] ); 117 | } else if ( is_string( $actor ) ) { 118 | return $this->objectsService->dereference( $actor ); 119 | } 120 | return null; 121 | } 122 | 123 | private function authorized( Request $request, ActivityPubObject $activityActor ) 124 | { 125 | if ( !$request->attributes->has( 'actor' ) ) { 126 | return false; 127 | } 128 | $requestActor = $request->attributes->get( 'actor' ); 129 | if ( $requestActor['id'] !== $activityActor['id'] ) { 130 | return false; 131 | } 132 | return true; 133 | } 134 | } 135 | 136 | -------------------------------------------------------------------------------- /src/Crypto/HttpSignatureService.php: -------------------------------------------------------------------------------- 1 | dateTimeProvider = $dateTimeProvider; 43 | $this->psr7Factory = new DiactorosFactory(); 44 | } 45 | 46 | /** 47 | * Generates a signature given the request and private key 48 | * 49 | * @param RequestInterface $request The request to be signed 50 | * @param string $privateKey The private key to use to sign the request 51 | * @param string $keyId The id of the signing key 52 | * @param array $headers |null The headers to use in the signature 53 | * (default ['(request-target)', 'host', 'date']) 54 | * @return string The Signature header value 55 | */ 56 | public function sign( RequestInterface $request, $privateKey, 57 | $keyId, $headers = null ) 58 | { 59 | if ( !$headers ) { 60 | $headers = self::getDefaultHeaders(); 61 | } 62 | $headers = array_map( 'strtolower', $headers ); 63 | $signingString = $this->getSigningString( $request, $headers ); 64 | $keypair = RsaKeypair::fromPrivateKey( $privateKey ); 65 | $signature = base64_encode( $keypair->sign( $signingString, 'sha256' ) ); 66 | $headersStr = implode( ' ', $headers ); 67 | return "keyId=\"$keyId\"," . 68 | "algorithm=\"rsa-sha256\"," . 69 | "headers=\"$headersStr\"," . 70 | "signature=\"$signature\""; 71 | } 72 | 73 | public static function getDefaultHeaders() 74 | { 75 | return array( 76 | '(request-target)', 77 | 'host', 78 | 'date', 79 | ); 80 | } 81 | 82 | /** 83 | * Returns the signing string from the request 84 | * 85 | * @param RequestInterface $request The request 86 | * @param array $headers The headers to use to generate the signing string 87 | * @return string The signing string 88 | */ 89 | private function getSigningString( RequestInterface $request, $headers ) 90 | { 91 | $signingComponents = array(); 92 | foreach ( $headers as $header ) { 93 | $component = "${header}: "; 94 | if ( $header == '(request-target)' ) { 95 | $method = strtolower( $request->getMethod() ); 96 | $path = $request->getUri()->getPath(); 97 | $query = $request->getUri()->getQuery(); 98 | if ( !empty( $query ) ) { 99 | $path = "$path?$query"; 100 | } 101 | $component = $component . $method . ' ' . $path; 102 | } else { 103 | // TODO handle 'digest' specially here too 104 | $values = $request->getHeader( $header ); 105 | $component = $component . implode( ', ', $values ); 106 | } 107 | $signingComponents[] = $component; 108 | } 109 | return implode( "\n", $signingComponents ); 110 | } 111 | 112 | /** 113 | * Verifies the HTTP signature of $request 114 | * 115 | * @param Request $request The request to verify 116 | * @param string $publicKey The public key to use to verify the request 117 | * @return bool True if the signature is valid, false if it is missing or invalid 118 | */ 119 | public function verify( Request $request, $publicKey ) 120 | { 121 | $params = array(); 122 | $headers = $request->headers; 123 | 124 | if ( !$headers->has( 'date' ) ) { 125 | return false; 126 | } 127 | $now = $this->dateTimeProvider->getTime( 'http-signature.verify' ); 128 | $then = DateTime::createFromFormat( DateTime::RFC2822, $headers->get( 'date' ) ); 129 | if ( abs( $now->getTimestamp() - $then->getTimestamp() ) > self::REPLAY_THRESHOLD ) { 130 | return false; 131 | } 132 | 133 | if ( $headers->has( 'signature' ) ) { 134 | $params = $this->parseSignatureParams( $headers->get( 'signature' ) ); 135 | } else if ( $headers->has( 'authorization' ) && 136 | substr( $headers->get( 'authorization' ), 0, 9 ) === 'Signature' ) { 137 | $paramsStr = substr( $headers->get( 'authorization' ), 10 ); 138 | $params = $this->parseSignatureParams( $paramsStr ); 139 | } 140 | 141 | if ( count( $params ) === 0 ) { 142 | return false; 143 | } 144 | 145 | $targetHeaders = array( 'date' ); 146 | if ( array_key_exists( 'headers', $params ) ) { 147 | $targetHeaders = $params['headers']; 148 | } 149 | 150 | $psrRequest = $this->psr7Factory->createRequest( $request ); 151 | $signingString = $this->getSigningString( $psrRequest, $targetHeaders ); 152 | $signature = base64_decode( $params['signature'] ); 153 | // TODO handle different algorithms here, checking the 'algorithm' param and the key headers 154 | $keypair = RsaKeypair::fromPublicKey( $publicKey ); 155 | return $keypair->verify( $signingString, $signature, 'sha256' ); 156 | } 157 | 158 | /** 159 | * Parses the signature params from the provided params string 160 | * 161 | * @param string $paramsStr The params represented as a string, 162 | * e.g. 'keyId="theKey",algorithm="rsa-sha256"' 163 | * @return array The params as an associative array 164 | */ 165 | private function parseSignatureParams( $paramsStr ) 166 | { 167 | $params = array(); 168 | $split = HeaderUtils::split( $paramsStr, ',=' ); 169 | foreach ( $split as $paramArr ) { 170 | $paramName = $paramArr[0]; 171 | $paramValue = $paramArr[1]; 172 | if ( $paramName == 'headers' ) { 173 | $paramValue = explode( ' ', $paramValue ); 174 | } 175 | $params[$paramName] = $paramValue; 176 | } 177 | return $params; 178 | } 179 | } 180 | 181 | -------------------------------------------------------------------------------- /src/Crypto/RsaKeypair.php: -------------------------------------------------------------------------------- 1 | publicKey = $publicKey; 29 | $this->privateKey = $privateKey; 30 | } 31 | 32 | /** 33 | * Generates a new keypair 34 | * 35 | * @return RsaKeypair 36 | */ 37 | public static function generate() 38 | { 39 | $rsa = new RSA(); 40 | $key = $rsa->createKey( 2048 ); 41 | return new RsaKeypair( $key['publickey'], $key['privatekey'] ); 42 | } 43 | 44 | /** 45 | * Generates an RsaKeypair with the given public key 46 | * 47 | * The generated RsaKeypair will be able to verify signatures but 48 | * not sign data, since it won't have a private key. 49 | * 50 | * @param string $publicKey The public key 51 | * @return RsaKeypair 52 | */ 53 | public static function fromPublicKey( $publicKey ) 54 | { 55 | return new RsaKeypair( $publicKey, '' ); 56 | } 57 | 58 | /** 59 | * Generates an RsaKeypair with the given private key 60 | * 61 | * The generated RsaKeypair will be able to sign data but 62 | * not verify signatures, since it won't have a public key. 63 | * 64 | * @param string $privateKey The private key 65 | * @return RsaKeypair 66 | */ 67 | public static function fromPrivateKey( $privateKey ) 68 | { 69 | return new RsaKeypair( '', $privateKey ); 70 | } 71 | 72 | /** 73 | * Returns the public key as a string 74 | * 75 | * @return string The public key 76 | */ 77 | public function getPublicKey() 78 | { 79 | return $this->publicKey; 80 | } 81 | 82 | /** 83 | * Returns the private key as a string 84 | * 85 | * @return string The private key 86 | */ 87 | public function getPrivateKey() 88 | { 89 | return $this->privateKey; 90 | } 91 | 92 | /** 93 | * Generates a signature for $data 94 | * 95 | * Throws a BadMethodCallException if this RsaKeypair does not have a private key. 96 | * @param string $data The data to sign 97 | * @param string $hash The hash algorithm to use. One of: 98 | * 'md2', 'md5', 'sha1', 'sha256', 'sha384', 'sha512'. Default: 'sha256' 99 | * @return string The signature 100 | */ 101 | public function sign( $data, $hash = 'sha256' ) 102 | { 103 | if ( empty( $this->privateKey ) ) { 104 | throw new BadMethodCallException( 105 | 'Unable to sign data without a private key' 106 | ); 107 | } 108 | $rsa = new RSA(); 109 | $rsa->setHash( $hash ); 110 | $rsa->setSignatureMode( RSA::SIGNATURE_PKCS1 ); 111 | $rsa->loadKey( $this->privateKey ); 112 | return $rsa->sign( $data ); 113 | } 114 | 115 | /** 116 | * Verifies $signature for $data using this keypair's public key 117 | * 118 | * @param string $data The data 119 | * @param string $signature The signature 120 | * @param string $hash The hash algorithm to use. One of: 121 | * 'md2', 'md5', 'sha1', 'sha256', 'sha384', 'sha512'. Default: 'sha256' 122 | * @return bool 123 | */ 124 | public function verify( $data, $signature, $hash = 'sha256' ) 125 | { 126 | // TODO this throws a "Signature representative out of range" occasionally 127 | // I have no idea what that means or how to fix it 128 | $rsa = new RSA(); 129 | $rsa->setHash( $hash ); 130 | $rsa->setSignatureMode( RSA::SIGNATURE_PKCS1 ); 131 | $rsa->loadKey( $this->publicKey ); 132 | return $rsa->verify( $data, $signature ); 133 | } 134 | } 135 | 136 | -------------------------------------------------------------------------------- /src/Database/PrefixNamingStrategy.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 14 | } 15 | 16 | public function propertyToColumnName( $propertyName, $className = null ) 17 | { 18 | return $propertyName; 19 | } 20 | 21 | public function joinColumnName( $propertyName, $className = null ) 22 | { 23 | return $propertyName . '_' . $this->referenceColumnName(); 24 | } 25 | 26 | public function referenceColumnName() 27 | { 28 | return 'id'; 29 | } 30 | 31 | public function joinTableName( $sourceEntity, $targetEntity, $propertyName = null ) 32 | { 33 | return strtolower( $this->classToTableName( $sourceEntity ) . '_' . 34 | $this->classToTableName( $targetEntity ) ); 35 | } 36 | 37 | public function classToTableName( $className ) 38 | { 39 | return $this->prefix . substr( $className, strrpos( $className, '\\' ) + 1 ); 40 | } 41 | 42 | public function joinKeyColumnName( $entityName, $referencedColumnName = null ) 43 | { 44 | return strtolower( $this->classToTableName( $entityName ) . '_' . 45 | ( $referencedColumnName ?: $this->referenceColumnName() ) ); 46 | } 47 | 48 | public function embeddedFieldToColumnName( $propertyName, $embeddedColumnName, $className = null, $embeddedClassName = null ) 49 | { 50 | return $propertyName . '_' . $embeddedColumnName; 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /src/Entities/PrivateKey.php: -------------------------------------------------------------------------------- 1 | setPrivateKey() 40 | * @param string $key The private key as a string 41 | * @param ActivityPubObject $object The object associated with this key 42 | */ 43 | public function __construct( $key, ActivityPubObject $object ) 44 | { 45 | $this->key = $key; 46 | $this->object = $object; 47 | } 48 | 49 | /** 50 | * Sets the private key string 51 | * 52 | * Don't call this directly - instead, use ActivityPubObject->setPrivateKey() 53 | * @param string $key The private key as a string 54 | */ 55 | public function setKey( $key ) 56 | { 57 | $this->key = $key; 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /src/Http/Router.php: -------------------------------------------------------------------------------- 1 | getController = $getController; 29 | $this->postController = $postController; 30 | } 31 | 32 | public static function getSubscribedEvents() 33 | { 34 | return array( 35 | KernelEvents::REQUEST => 'route', 36 | ); 37 | } 38 | 39 | /** 40 | * Routes the request by setting the _controller attribute 41 | * 42 | * @param GetResponseEvent $event The request event 43 | */ 44 | public function route( GetResponseEvent $event ) 45 | { 46 | $request = $event->getRequest(); 47 | if ( $request->getMethod() === Request::METHOD_GET ) { 48 | $request->attributes->set( 49 | '_controller', array( $this->getController, 'handle' ) 50 | ); 51 | } else if ( $request->getMethod() === Request::METHOD_POST ) { 52 | $request->attributes->set( 53 | '_controller', array( $this->postController, 'handle' ) 54 | ); 55 | } else { 56 | throw new MethodNotAllowedHttpException( array( 57 | Request::METHOD_GET, Request::METHOD_POST 58 | ) ); 59 | } 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /src/JsonLd/Dereferencer/CachingDereferencer.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 84 | $this->cache = $cache; 85 | $this->httpClient = $httpClient; 86 | $this->httpSignatureService = $httpSignatureService; 87 | $this->dateTimeProvider = $dateTimeProvider; 88 | $this->keyId = $keyId; 89 | $this->privateKey = $privateKey; 90 | } 91 | 92 | public function setKeyId( $keyId ) 93 | { 94 | $this->keyId = $keyId; 95 | } 96 | 97 | public function setPrivateKey( $privateKey ) 98 | { 99 | $this->privateKey = $privateKey; 100 | } 101 | 102 | /** 103 | * @param string $iri The IRI to dereference. 104 | * @return stdClass|array The dereferenced node. 105 | * @throws NodeNotFoundException If a node with the IRI could not be found. 106 | */ 107 | public function dereference( $iri ) 108 | { 109 | $key = $this->makeCacheKey( $iri ); 110 | $cacheItem = $this->cache->getItem( $key ); 111 | if ( $cacheItem->isHit() ) { 112 | return $cacheItem->get(); 113 | } else { 114 | if ( Util::isLocalUri( $iri ) ) { 115 | // TODO fetch object from persistence backend 116 | } 117 | $headers = array( 118 | 'Accept' => 'application/ld+json', 119 | 'Date' => $this->getNowRFC1123(), 120 | ); 121 | $request = new Request( 'GET', $iri, $headers ); 122 | if ( $this->shouldSign() ) { 123 | $signature = $this->httpSignatureService->sign( $request, $this->privateKey, $this->keyId ); 124 | $request = $request->withHeader( 'Signature', $signature ); 125 | } 126 | $response = $this->httpClient->send( $request, array( 'http_errors' => false ) ); 127 | if ( $response->getStatusCode() >= 400 ) { 128 | $statusCode = $response->getStatusCode(); 129 | $this->logger->error( 130 | "[ActivityPub-PHP] Received response with status $statusCode from $iri", 131 | array( 'request' => $request, 'response' => $response ) 132 | ); 133 | } else { 134 | $body = json_decode( $response->getBody() ); 135 | if ( ! $body ) { 136 | throw new NodeNotFoundException( $iri ); 137 | } 138 | $cacheItem->set( $body ); 139 | $cacheItem->expiresAfter( self::DEFAULT_CACHE_TTL ); 140 | $this->cache->save( $cacheItem ); 141 | return $body; 142 | } 143 | } 144 | } 145 | 146 | /** 147 | * Generates a valid cache key for $id. 148 | * @param string $id 149 | * @return string 150 | */ 151 | private function makeCacheKey( $id ) 152 | { 153 | return str_replace( array( '{', '}', '(', ')', '/', '\\', '@', ':' ), '_', $id ); 154 | } 155 | 156 | /** 157 | * True if the dereferencer should sign outgoing requests. 158 | * @return bool 159 | */ 160 | private function shouldSign() 161 | { 162 | return $this->keyId && $this->privateKey; 163 | } 164 | 165 | private function getNowRFC1123() 166 | { 167 | $now = $this->dateTimeProvider->getTime( 'caching-dereferencer.dereference' ); 168 | $now->setTimezone( new DateTimeZone( 'GMT' ) ); 169 | return $now->format( 'D, d M Y H:i:s T' ); 170 | } 171 | } -------------------------------------------------------------------------------- /src/JsonLd/Dereferencer/DereferencerInterface.php: -------------------------------------------------------------------------------- 1 | graph = array(); 29 | $this->uuidProvider = $uuidProvider; 30 | } 31 | 32 | public function addNode( JsonLdNode $node ) 33 | { 34 | $id = $node->getId(); 35 | if ( is_null( $id ) ) { 36 | $id = $this->uuidProvider->uuid(); 37 | $node->setId( $id ); 38 | } 39 | $this->graph[$id] = $node; 40 | } 41 | 42 | public function getNode( $id ) 43 | { 44 | if ( array_key_exists( $id, $this->graph ) ) { 45 | return $this->graph[$id]; 46 | } 47 | } 48 | 49 | public function nameBlankNode( $blankNodeName, $newNodeName ) { 50 | if ( array_key_exists( $newNodeName, $this->graph ) ) { 51 | throw new InvalidArgumentException( "$newNodeName is already defined." ); 52 | } 53 | if ( ! array_key_exists( $blankNodeName, $this->graph ) ) { 54 | throw new InvalidArgumentException( "$blankNodeName is not in the graph." ); 55 | } 56 | $this->graph[$newNodeName] = $this->graph[$blankNodeName]; 57 | unset( $this->graph[$blankNodeName] ); 58 | } 59 | } -------------------------------------------------------------------------------- /src/JsonLd/JsonLdNodeFactory.php: -------------------------------------------------------------------------------- 1 | context = $context; 47 | $this->dereferencer = $dereferencer; 48 | $this->logger = $logger; 49 | $this->uuidProvider = $uuidProvider; 50 | } 51 | 52 | /** 53 | * Construct and return a new JsonLdNode. 54 | * @param Node|\stdClass $jsonLd The JSON-LD object input. 55 | * @param JsonLdGraph|null $graph The JSON-LD graph. 56 | * @param array $backreferences Backreferences to instantiate the new node with. 57 | * @return JsonLdNode 58 | */ 59 | public function newNode( $jsonLd, $graph = null, $backreferences = array() ) 60 | { 61 | if ( is_null( $graph ) ) { 62 | $graph = new JsonLdGraph( $this->uuidProvider ); 63 | } 64 | return new JsonLdNode( 65 | $jsonLd, $this->context, $this, $this->dereferencer, $this->logger, $graph, $backreferences 66 | ); 67 | } 68 | 69 | /** 70 | * Constructs a JsonLdNode from a collection of RdfTriples, properly setting up the graph traversals based 71 | * on relationships between the passed-in triples. 72 | * 73 | * @param TypedRdfTriple[] $triples The triples. 74 | * @param string $rootNodeId The ID of the root node - that is, the node returned from this function. This is 75 | * necessary because the RDF triples array can contain triples from multiple nodes. 76 | * @param JsonLdGraph|null $graph An existing JsonLdGraph to add this node to. 77 | */ 78 | public function nodeFromRdf( $triples, $rootNodeId, $graph = null ) 79 | { 80 | if ( is_null( $graph ) ) { 81 | $graph = new JsonLdGraph( $this->uuidProvider ); 82 | } 83 | $buckets = array(); 84 | $backreferences = array(); 85 | foreach ( $triples as $triple ) { 86 | $buckets[$triple->getSubject()][] = $triple; 87 | } 88 | if ( ! array_key_exists( $rootNodeId, $buckets ) ) { 89 | throw new InvalidArgumentException("No triple with subject $rootNodeId was found"); 90 | } 91 | $nodes = array(); 92 | foreach ( $buckets as $id => $triples ) { 93 | $obj = new stdClass(); 94 | foreach( $triples as $triple ) { 95 | if ( $triple->getObjectType() && $triple->getObjectType() === '@id' ) { 96 | $obj->$triple->getPredicate()[] = (object) array( '@id' => $triple->getObject() ); 97 | $backreferences[$triple->getObject()][] = (object) array( 98 | 'predicate' => $triple->getPredicate(), 99 | 'referer' => $triple->getSubject(), 100 | ); 101 | } else if ( $triple->getObjectType() ) { 102 | $obj->$triple->getPredicate()[] = (object) array( 103 | '@type' => $triple->getObjectType(), 104 | '@value' => $triple->getObject(), 105 | ); 106 | } else { 107 | $obj->$triple->getPredicate()[] = (object) array( '@value' => $triple->getObject() ); 108 | } 109 | } 110 | $node = $this->newNode( $obj, $graph ); 111 | $nodes[$node->getId()] = $node; 112 | } 113 | foreach ( $backreferences as $referencedId => $references ) { 114 | $referencedNode = $nodes[$referencedId]; 115 | foreach ( $references as $reference ) { 116 | $referencedNode->addBackReference( $reference->predicate, $nodes[$reference->referer] ); 117 | } 118 | } 119 | return $nodes[$rootNodeId]; 120 | } 121 | } -------------------------------------------------------------------------------- /src/JsonLd/TripleStore/Doctrine/DoctrineTriplestore.php: -------------------------------------------------------------------------------- 1 | subject = $subject; 41 | $this->predicate = $predicate; 42 | $this->object = $object; 43 | $this->objectType = $objectType; 44 | } 45 | 46 | public static function create( $subject = null, $predicate = null, $object = null, $objectType = null ) 47 | { 48 | return new TypedRdfTriple( $subject, $predicate, $object, $objectType ); 49 | } 50 | 51 | /** 52 | * @return string|null 53 | */ 54 | public function getSubject() 55 | { 56 | return $this->subject; 57 | } 58 | 59 | /** 60 | * @param string $subject 61 | * @return TypedRdfTriple 62 | */ 63 | public function setSubject( $subject ) 64 | { 65 | $this->subject = $subject; 66 | return $this; 67 | } 68 | 69 | /** 70 | * @return string|null 71 | */ 72 | public function getPredicate() 73 | { 74 | return $this->predicate; 75 | } 76 | 77 | /** 78 | * @param string $predicate 79 | * @return TypedRdfTriple 80 | */ 81 | public function setPredicate( $predicate ) 82 | { 83 | $this->predicate = $predicate; 84 | return $this; 85 | } 86 | 87 | /** 88 | * @return string|null 89 | */ 90 | public function getObject() 91 | { 92 | return $this->object; 93 | } 94 | 95 | /** 96 | * @param string $object 97 | * @return TypedRdfTriple 98 | */ 99 | public function setObject( $object ) 100 | { 101 | $this->object = $object; 102 | return $this; 103 | } 104 | 105 | /** 106 | * @return string|null 107 | */ 108 | public function getObjectType() 109 | { 110 | return $this->objectType; 111 | } 112 | 113 | /** 114 | * @param string $objectType 115 | * @return TypedRdfTriple 116 | */ 117 | public function setObjectType( $objectType ) 118 | { 119 | $this->objectType = $objectType; 120 | return $this; 121 | } 122 | 123 | /** 124 | * True if this triple has a subject, a predicate, and an object. 125 | * @return bool 126 | */ 127 | public function isFullySpecified() 128 | { 129 | return $this->getSubject() && $this->getPredicate() && $this->getObject(); 130 | } 131 | } -------------------------------------------------------------------------------- /src/Objects/BlockService.php: -------------------------------------------------------------------------------- 1 | objectsService = $objectsService; 17 | } 18 | 19 | /** 20 | * Returns an array of actorIds that are the object of a block activity 21 | * by $blockingActorId 22 | * 23 | * @param string $blockingActorId 24 | * @return array 25 | */ 26 | public function getBlockedActorIds( $blockingActorId ) 27 | { 28 | $blockQuery = array( 29 | 'type' => 'Block', 30 | 'actor' => array( 31 | 'id' => $blockingActorId, 32 | ), 33 | ); 34 | $blocks = $this->objectsService->query( $blockQuery ); 35 | 36 | // TODO this is janky and slow - there's probably a better way 37 | $undoQuery = array( 38 | 'type' => 'Undo', 39 | 'actor' => array( 40 | 'id' => $blockingActorId, 41 | ), 42 | 'object' => array( 43 | 'type' => 'Block', 44 | ), 45 | ); 46 | $undos = $this->objectsService->query( $undoQuery ); 47 | $undoneBlocks = array(); 48 | foreach ( $undos as $undo ) { 49 | if ( $undo->hasField( 'object' ) ) { 50 | $undoObject = $undo['object']; 51 | if ( is_string( $undoObject ) ) { 52 | $undoneBlocks[$undoObject] = 1; 53 | } else if ( $undoObject instanceof ActivityPubObject && $undoObject->hasField( 'id' ) ) { 54 | $undoneBlocks[$undoObject['id']] = 1; 55 | } 56 | } 57 | } 58 | 59 | $blockedIds = array(); 60 | foreach ( $blocks as $block ) { 61 | if ( array_key_exists( $block['id'], $undoneBlocks ) ) { 62 | continue; 63 | } 64 | if ( $block->hasField( 'object' ) ) { 65 | $blockedActor = $block['object']; 66 | if ( is_string( $blockedActor ) ) { 67 | $blockedIds[] = $blockedActor; 68 | } else { 69 | $blockedIds[] = $blockedActor['id']; 70 | } 71 | } 72 | } 73 | return $blockedIds; 74 | } 75 | } -------------------------------------------------------------------------------- /src/Objects/CollectionIterator.php: -------------------------------------------------------------------------------- 1 | hasField('type') && 29 | in_array( $collection['type'], array( 'Collection', 'OrderedCollection' ) ) ) ) { 30 | throw new InvalidArgumentException('Must pass a collection'); 31 | } 32 | $itemsField = 'items'; 33 | if ( $collection['type'] == 'OrderedCollection' ) { 34 | $itemsField = 'orderedItems'; 35 | } 36 | $this->items = $collection[$itemsField]; 37 | if ( ! $this->items ) { 38 | throw new InvalidArgumentException('Collection must have an items or orderedItems field!'); 39 | } 40 | $this->collection = $collection; 41 | $this->idx = 0; 42 | } 43 | 44 | public static function iterateCollection( ActivityPubObject $collection ) 45 | { 46 | return new CollectionIterator( $collection ); 47 | } 48 | 49 | /** 50 | * Return the current element 51 | * @link https://php.net/manual/en/iterator.current.php 52 | * @return mixed Can return any type. 53 | * @since 5.0.0 54 | */ 55 | public function current() 56 | { 57 | return $this->items[$this->idx]; 58 | } 59 | 60 | /** 61 | * Move forward to next element 62 | * @link https://php.net/manual/en/iterator.next.php 63 | * @return void Any returned value is ignored. 64 | * @since 5.0.0 65 | */ 66 | public function next() 67 | { 68 | $this->idx += 1; 69 | } 70 | 71 | /** 72 | * Return the key of the current element 73 | * @link https://php.net/manual/en/iterator.key.php 74 | * @return mixed scalar on success, or null on failure. 75 | * @since 5.0.0 76 | */ 77 | public function key() 78 | { 79 | return $this->idx; 80 | } 81 | 82 | /** 83 | * Checks if current position is valid 84 | * @link https://php.net/manual/en/iterator.valid.php 85 | * @return boolean The return value will be casted to boolean and then evaluated. 86 | * Returns true on success or false on failure. 87 | * @since 5.0.0 88 | */ 89 | public function valid() 90 | { 91 | return $this->items->hasField($this->idx); 92 | } 93 | 94 | /** 95 | * Rewind the Iterator to the first element 96 | * @link https://php.net/manual/en/iterator.rewind.php 97 | * @return void Any returned value is ignored. 98 | * @since 5.0.0 99 | */ 100 | public function rewind() 101 | { 102 | $this->idx = 0; 103 | } 104 | } -------------------------------------------------------------------------------- /src/Objects/ContextProvider.php: -------------------------------------------------------------------------------- 1 | ctx = $ctx; 15 | } 16 | 17 | public static function getDefaultContext() 18 | { 19 | return array( 20 | 'https://www.w3.org/ns/activitystreams', 21 | 'https://w3id.org/security/v1', 22 | ); 23 | } 24 | 25 | public function getContext() 26 | { 27 | return $this->ctx; 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/Objects/IdProvider.php: -------------------------------------------------------------------------------- 1 | objectsService = $objectsService; 39 | $this->randomProvider = $randomProvider; 40 | $this->pathPrefix = $pathPrefix; 41 | } 42 | 43 | /** 44 | * Generates a new unique ActivityPub id 45 | * 46 | * Ids look like "https://$host/$path/$randomString" 47 | * @param Request $request The current request 48 | * @param string $path The path for the the id URL, just before the random string 49 | * and after the path prefix. Default: "object" 50 | * @return string The new id 51 | */ 52 | public function getId( Request $request, $path = "objects" ) 53 | { 54 | $baseUri = $request->getSchemeAndHttpHost(); 55 | if ( !empty( $path ) ) { 56 | $baseUri = $baseUri . "/{$this->pathPrefix}/$path"; 57 | } 58 | $rnd = $this->randomProvider->randomString( self::ID_LENGTH ); 59 | $id = $baseUri . "/$rnd"; 60 | $existing = $this->objectsService->query( array( 'id' => $id ) ); 61 | while ( count( $existing ) > 0 ) { 62 | $rnd = $this->randomProvider->randomString( self::ID_LENGTH ); 63 | $id = $baseUri . "/$rnd"; 64 | $existing = $this->objectsService->query( array( 'id' => $id ) ); 65 | } 66 | return $id; 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /src/Utils/DateTimeProvider.php: -------------------------------------------------------------------------------- 1 | monoLogger = new MonoLogger( 'ActivityPub-PHP' ); 23 | $this->monoLogger->pushHandler( new ErrorLogHandler(ErrorLogHandler::SAPI, $level ) ); 24 | } 25 | 26 | /** 27 | * System is unusable. 28 | * 29 | * @param string $message 30 | * @param array $context 31 | * 32 | * @return void 33 | */ 34 | public function emergency( $message, array $context = array() ) 35 | { 36 | $this->monoLogger->emergency( $message, $context ); 37 | } 38 | 39 | /** 40 | * Action must be taken immediately. 41 | * 42 | * Example: Entire website down, database unavailable, etc. This should 43 | * trigger the SMS alerts and wake you up. 44 | * 45 | * @param string $message 46 | * @param array $context 47 | * 48 | * @return void 49 | */ 50 | public function alert( $message, array $context = array() ) 51 | { 52 | $this->monoLogger->alert( $message, $context ); 53 | } 54 | 55 | /** 56 | * Critical conditions. 57 | * 58 | * Example: Application component unavailable, unexpected exception. 59 | * 60 | * @param string $message 61 | * @param array $context 62 | * 63 | * @return void 64 | */ 65 | public function critical( $message, array $context = array() ) 66 | { 67 | $this->monoLogger->critical( $message, $context ); 68 | } 69 | 70 | /** 71 | * Runtime errors that do not require immediate action but should typically 72 | * be logged and monitored. 73 | * 74 | * @param string $message 75 | * @param array $context 76 | * 77 | * @return void 78 | */ 79 | public function error( $message, array $context = array() ) 80 | { 81 | $this->monoLogger->error( $message, $context ); 82 | } 83 | 84 | /** 85 | * Exceptional occurrences that are not errors. 86 | * 87 | * Example: Use of deprecated APIs, poor use of an API, undesirable things 88 | * that are not necessarily wrong. 89 | * 90 | * @param string $message 91 | * @param array $context 92 | * 93 | * @return void 94 | */ 95 | public function warning( $message, array $context = array() ) 96 | { 97 | $this->monoLogger->warning( $message, $context ); 98 | } 99 | 100 | /** 101 | * Normal but significant events. 102 | * 103 | * @param string $message 104 | * @param array $context 105 | * 106 | * @return void 107 | */ 108 | public function notice( $message, array $context = array() ) 109 | { 110 | $this->monoLogger->notice( $message, $context ); 111 | } 112 | 113 | /** 114 | * Interesting events. 115 | * 116 | * Example: User logs in, SQL logs. 117 | * 118 | * @param string $message 119 | * @param array $context 120 | * 121 | * @return void 122 | */ 123 | public function info( $message, array $context = array() ) 124 | { 125 | $this->monoLogger->info( $message, $context ); 126 | } 127 | 128 | /** 129 | * Detailed debug information. 130 | * 131 | * @param string $message 132 | * @param array $context 133 | * 134 | * @return void 135 | */ 136 | public function debug( $message, array $context = array() ) 137 | { 138 | $this->monoLogger->debug( $message, $context ); 139 | } 140 | 141 | /** 142 | * Logs with an arbitrary level. 143 | * 144 | * @param mixed $level 145 | * @param string $message 146 | * @param array $context 147 | * 148 | * @return void 149 | */ 150 | public function log( $level, $message, array $context = array() ) 151 | { 152 | $this->monoLogger->log( $level, $message, $context ); 153 | } 154 | } -------------------------------------------------------------------------------- /src/Utils/RandomProvider.php: -------------------------------------------------------------------------------- 1 | getHost(); 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /src/Utils/UuidProvider.php: -------------------------------------------------------------------------------- 1 | array( 21 | 'id' => 'https://elsewhere.com/collections/1', 22 | ), 23 | 'https://example.com/collections/1' => array( 24 | 'id' => 'https://example.com/collections/1', 25 | ), 26 | ); 27 | } 28 | 29 | public function provideTestHandleAdd() 30 | { 31 | return array( 32 | array( array( 33 | 'id' => 'basicTest', 34 | 'eventName' => InboxActivityEvent::NAME, 35 | 'event' => new InboxActivityEvent( 36 | array( 37 | 'id' => 'https://elsewhere.com/adds/1', 38 | 'type' => 'Add', 39 | 'object' => array( 40 | 'id' => 'https://elsewhere.com/notes/1', 41 | ), 42 | 'target' => array( 43 | 'id' => 'https://elsewhere.com/collections/1' 44 | ) 45 | ), 46 | TestActivityPubObject::fromArray( array( 47 | 'id' => 'https://example.com/actors/1', 48 | ) ), 49 | self::requestWithAttributes( 50 | 'https://example.com/actors/1/inbox', 51 | array( 52 | 'actor' => TestActivityPubObject::fromArray( array( 53 | 'id' => 'https://elsewhere.com/actors/1' 54 | ) ) 55 | ) 56 | ) 57 | ), 58 | 'expectedNewItem' => array( 59 | 'id' => 'https://elsewhere.com/notes/1', 60 | ) 61 | ) ), 62 | array( array( 63 | 'id' => 'outboxTest', 64 | 'eventName' => OutboxActivityEvent::NAME, 65 | 'event' => new OutboxActivityEvent( 66 | array( 67 | 'id' => 'https://example.com/adds/1', 68 | 'type' => 'Add', 69 | 'object' => array( 70 | 'id' => 'https://example.com/notes/1', 71 | ), 72 | 'target' => array( 73 | 'id' => 'https://example.com/collections/1' 74 | ) 75 | ), 76 | TestActivityPubObject::fromArray( array( 77 | 'id' => 'https://example.com/actors/1', 78 | ) ), 79 | self::requestWithAttributes( 80 | 'https://example.com/actors/1/inbox', 81 | array( 82 | 'actor' => TestActivityPubObject::fromArray( array( 83 | 'id' => 'https://example.com/actors/1' 84 | ) ) 85 | ) 86 | ) 87 | ), 88 | 'expectedNewItem' => array( 89 | 'id' => 'https://example.com/notes/1', 90 | ) 91 | ) ), 92 | array( array( 93 | 'id' => 'notAuthorizedTest', 94 | 'eventName' => OutboxActivityEvent::NAME, 95 | 'event' => new OutboxActivityEvent( 96 | array( 97 | 'id' => 'https://example.com/adds/1', 98 | 'type' => 'Add', 99 | 'object' => array( 100 | 'id' => 'https://example.com/notes/1', 101 | ), 102 | 'target' => array( 103 | 'id' => 'https://elsewhere.com/collections/1' 104 | ) 105 | ), 106 | TestActivityPubObject::fromArray( array( 107 | 'id' => 'https://example.com/actors/1', 108 | ) ), 109 | self::requestWithAttributes( 110 | 'https://example.com/actors/1/inbox', 111 | array( 112 | 'actor' => TestActivityPubObject::fromArray( array( 113 | 'id' => 'https://example.com/actors/1' 114 | ) ) 115 | ) 116 | ) 117 | ), 118 | 'expectedException' => AccessDeniedHttpException::class, 119 | ) ), 120 | ); 121 | } 122 | 123 | /** 124 | * @dataProvider provideTestHandleAdd 125 | */ 126 | public function testHandleAdd( $testCase ) 127 | { 128 | $objectsService = $this->getMock( ObjectsService::class ); 129 | $objectsService->method( 'dereference')->willReturnCallback( function( $id ) { 130 | $objects = self::getObjects(); 131 | if ( array_key_exists( $id, $objects ) ) { 132 | return TestActivityPubObject::fromArray( $objects[$id] ); 133 | } else { 134 | return null; 135 | } 136 | }); 137 | $collectionsService = $this->getMockBuilder( CollectionsService::class ) 138 | ->disableOriginalConstructor() 139 | ->setMethods( array( 'addItem' ) ) 140 | ->getMock(); 141 | if ( array_key_exists( 'expectedNewItem', $testCase ) ) { 142 | $collectionsService->expects( $this->once() ) 143 | ->method( 'addItem' ) 144 | ->with( 145 | $this->anything(), 146 | $this->equalTo( $testCase['expectedNewItem'] ) 147 | ); 148 | } else { 149 | $collectionsService->expects( $this->never() ) 150 | ->method( 'addItem' ); 151 | } 152 | $addHandler = new AddHandler( $objectsService, $collectionsService ); 153 | $eventDispatcher = new EventDispatcher(); 154 | $eventDispatcher->addSubscriber( $addHandler ); 155 | if ( array_key_exists( 'expectedException', $testCase ) ) { 156 | $this->setExpectedException( $testCase['expectedException'] ); 157 | } 158 | $eventDispatcher->dispatch( $testCase['eventName'], $testCase['event'] ); 159 | } 160 | } -------------------------------------------------------------------------------- /test/ActivityEventHandlers/AnnounceHandlerTest.php: -------------------------------------------------------------------------------- 1 | array( 20 | 'id' => 'https://example.com/notes/withshares', 21 | 'shares' => array( 22 | 'type' => 'Collection', 23 | 'items' => array(), 24 | ), 25 | ), 26 | 'https://example.com/notes/withoutshares' => array( 27 | 'id' => 'https://example.com/notes/withoutshares', 28 | ), 29 | ); 30 | } 31 | 32 | public function provideTestAnnounceHandler() 33 | { 34 | return array( 35 | array( array( 36 | 'id' => 'basicInboxTest', 37 | 'eventName' => InboxActivityEvent::NAME, 38 | 'event' => new InboxActivityEvent( 39 | array( 40 | 'id' => 'https://elsewhere.com/announces/1', 41 | 'type' => 'Announce', 42 | 'object' => 'https://example.com/notes/withshares', 43 | ), 44 | TestActivityPubObject::fromArray( array( 45 | 'id' => 'https://example.com/actors/1', 46 | ) ), 47 | self::requestWithAttributes( 48 | 'https://example.com/actors/1/inbox', 49 | array( 50 | 'actor' => TestActivityPubObject::fromArray( array( 51 | 'id' => 'https://elsewhere.com/actors/1', 52 | ) ), 53 | ) 54 | ) 55 | ), 56 | 'expectedNewShares' => array( 57 | 'id' => 'https://elsewhere.com/announces/1', 58 | 'type' => 'Announce', 59 | 'object' => 'https://example.com/notes/withshares', 60 | ) 61 | ) ), 62 | array( array( 63 | 'id' => 'generatesSharesCollectionTest', 64 | 'eventName' => InboxActivityEvent::NAME, 65 | 'event' => new InboxActivityEvent( 66 | array( 67 | 'id' => 'https://elsewhere.com/announces/1', 68 | 'type' => 'Announce', 69 | 'object' => 'https://example.com/notes/withoutshares', 70 | ), 71 | TestActivityPubObject::fromArray( array( 72 | 'id' => 'https://example.com/actors/1', 73 | ) ), 74 | self::requestWithAttributes( 75 | 'https://example.com/actors/1/inbox', 76 | array( 77 | 'actor' => TestActivityPubObject::fromArray( array( 78 | 'id' => 'https://elsewhere.com/actors/1', 79 | ) ), 80 | ) 81 | ) 82 | ), 83 | 'expectedNewShares' => array( 84 | 'id' => 'https://elsewhere.com/announces/1', 85 | 'type' => 'Announce', 86 | 'object' => 'https://example.com/notes/withoutshares', 87 | ) 88 | ) ), 89 | ); 90 | } 91 | 92 | /** 93 | * @dataProvider provideTestAnnounceHandler 94 | */ 95 | public function testAnnounceHandler( $testCase ) 96 | { 97 | $objectsService = $this->getMock( ObjectsService::class ); 98 | $objectsService->method( 'dereference')->willReturnCallback( function( $id ) { 99 | $objects = self::getObjects(); 100 | if ( array_key_exists( $id, $objects ) ) { 101 | return TestActivityPubObject::fromArray( $objects[$id] ); 102 | } else { 103 | return null; 104 | } 105 | }); 106 | $objectsService->method( 'update')->willReturnCallback( function( $id, $arr ) { 107 | return TestActivityPubObject::fromArray( $arr ); 108 | }); 109 | $collectionsService = $this->getMockBuilder( CollectionsService::class ) 110 | ->disableOriginalConstructor() 111 | ->setMethods( array( 'addItem' ) ) 112 | ->getMock(); 113 | if ( array_key_exists( 'expectedNewShares', $testCase ) ) { 114 | $collectionsService->expects( $this->once() ) 115 | ->method( 'addItem' ) 116 | ->with( 117 | $this->anything(), 118 | $this->equalTo( $testCase['expectedNewShares'] ) 119 | ); 120 | } else { 121 | $collectionsService->expects( $this->never() ) 122 | ->method( 'addItem' ); 123 | } 124 | $contextProvider = new ContextProvider(); 125 | $announceHandler = new AnnounceHandler( $objectsService, $collectionsService, $contextProvider ); 126 | $eventDispatcher = new EventDispatcher(); 127 | $eventDispatcher->addSubscriber( $announceHandler ); 128 | $eventDispatcher->dispatch( $testCase['eventName'], $testCase['event'] ); 129 | } 130 | } -------------------------------------------------------------------------------- /test/ActivityEventHandlers/FollowHandlerTest.php: -------------------------------------------------------------------------------- 1 | addSubscriber( $followHandler ); 22 | $outboxDispatched = false; 23 | $actor = TestActivityPubObject::fromArray( array( 24 | 'id' => 'https://example.com/actor/1', 25 | 'outbox' => 'https://example.com/actor/1/outbox', 26 | ) ); 27 | $follow = array( 28 | 'id' => 'https://elsewhere.com/activities/1', 29 | 'type' => 'Follow', 30 | 'object' => 'https://example.com/actor/1', 31 | ); 32 | $eventDispatcher->addListener( OutboxActivityEvent::NAME, function ( $event, $name ) 33 | use ( &$outboxDispatched, $actor, $follow ) { 34 | $this->assertEquals( OutboxActivityEvent::NAME, $name ); 35 | $outboxDispatched = true; 36 | $accept = array( 37 | '@context' => ContextProvider::getDefaultContext(), 38 | 'type' => 'Accept', 39 | 'actor' => 'https://example.com/actor/1', 40 | 'object' => 'https://elsewhere.com/activities/1', 41 | ); 42 | $expectedRequest = Request::create( 43 | 'https://example.com/actor/1/outbox', 44 | Request::METHOD_POST, 45 | array(), array(), array(), 46 | array( 47 | 'HTTP_ACCEPT' => 'application/ld+json', 48 | 'CONTENT_TYPE' => 'application/json', 49 | ), 50 | json_encode( $accept ) 51 | ); 52 | $expectedRequest->attributes->add( array( 53 | 'actor' => $actor, 54 | 'follow' => $follow, 55 | ) ); 56 | $this->assertEquals( 57 | new OutboxActivityEvent( $accept, $actor, $expectedRequest ), $event 58 | ); 59 | } ); 60 | $eventDispatcher->dispatch( InboxActivityEvent::NAME, new InboxActivityEvent( 61 | $follow, 62 | $actor, 63 | Request::create( 'https://example.com/actor/1/inbox' ) 64 | ) ); 65 | $this->assertTrue( $outboxDispatched ); 66 | } 67 | 68 | public function testItChecksForFollowObject() 69 | { 70 | $eventDispatcher = new EventDispatcher(); 71 | $contextProvider = new ContextProvider(); 72 | $followHandler = new FollowHandler( true, $contextProvider ); 73 | $eventDispatcher->addSubscriber( $followHandler ); 74 | $outboxDispatched = false; 75 | $actor = TestActivityPubObject::fromArray( array( 76 | 'id' => 'https://example.com/actor/1', 77 | 'outbox' => 'https://example.com/actor/1/outbox', 78 | ) ); 79 | $follow = array( 80 | 'id' => 'https://elsewhere.com/activities/1', 81 | 'type' => 'Follow', 82 | 'object' => 'https://example.com/actor/2', 83 | ); 84 | $eventDispatcher->addListener( OutboxActivityEvent::NAME, function () 85 | use ( &$outboxDispatched ) { 86 | $outboxDispatched = true; 87 | } ); 88 | $eventDispatcher->dispatch( InboxActivityEvent::NAME, new InboxActivityEvent( 89 | $follow, 90 | $actor, 91 | Request::create( 'https://example.com/actor/1/inbox' ) 92 | ) ); 93 | $this->assertFalse( $outboxDispatched ); 94 | } 95 | } 96 | 97 | -------------------------------------------------------------------------------- /test/ActivityEventHandlers/NonActivityHandlerTest.php: -------------------------------------------------------------------------------- 1 | 'testItWrapsNonObjectActivity', 19 | 'activity' => array( 20 | 'type' => 'Note' 21 | ), 22 | 'actor' => TestActivityPubObject::fromArray( array( 23 | 'id' => 'https://example.com/actor/1', 24 | ) ), 25 | 'expectedActivity' => array( 26 | '@context' => ContextProvider::getDefaultContext(), 27 | 'type' => 'Create', 28 | 'actor' => 'https://example.com/actor/1', 29 | 'object' => array( 30 | 'type' => 'Note', 31 | ), 32 | ), 33 | ) ), 34 | array( array( 35 | 'id' => 'testItDoesNotWrapActivity', 36 | 'activity' => array( 37 | 'type' => 'Update' 38 | ), 39 | 'actor' => TestActivityPubObject::fromArray( array( 40 | 'id' => 'https://example.com/actor/1', 41 | ) ), 42 | 'expectedActivity' => array( 43 | 'type' => 'Update', 44 | ), 45 | ) ), 46 | array( array( 47 | 'id' => 'testItPassesAudience', 48 | 'activity' => array( 49 | 'type' => 'Note', 50 | 'audience' => array( 51 | 'foo', 52 | ), 53 | 'to' => array( 54 | 'bar', 55 | ), 56 | 'bcc' => array( 57 | 'baz', 58 | ), 59 | ), 60 | 'actor' => TestActivityPubObject::fromArray( array( 61 | 'id' => 'https://example.com/actor/1', 62 | ) ), 63 | 'expectedActivity' => array( 64 | '@context' => ContextProvider::getDefaultContext(), 65 | 'type' => 'Create', 66 | 'actor' => 'https://example.com/actor/1', 67 | 'object' => array( 68 | 'type' => 'Note', 69 | 'audience' => array( 70 | 'foo', 71 | ), 72 | 'to' => array( 73 | 'bar', 74 | ), 75 | 'bcc' => array( 76 | 'baz', 77 | ), 78 | ), 79 | 'audience' => array( 80 | 'foo', 81 | ), 82 | 'to' => array( 83 | 'bar', 84 | ), 85 | 'bcc' => array( 86 | 'baz', 87 | ), 88 | ), 89 | ) ) 90 | ); 91 | } 92 | 93 | /** 94 | * @dataProvider provideTestNonActivityHandler 95 | */ 96 | public function testNonActivityHandler( $testCase ) 97 | { 98 | $contextProvider = new ContextProvider(); 99 | $nonActivityHandler = new NonActivityHandler( $contextProvider ); 100 | $actor = $testCase['actor']; 101 | $activity = $testCase['activity']; 102 | $request = Request::create( 'https://example.com/whatever' ); 103 | $event = new OutboxActivityEvent( $activity, $actor, $request ); 104 | $nonActivityHandler->handle( $event ); 105 | $this->assertEquals( 106 | $testCase['expectedActivity'], 107 | $event->getActivity(), 108 | "Error on test $testCase[id]" 109 | ); 110 | } 111 | } 112 | 113 | -------------------------------------------------------------------------------- /test/ActivityEventHandlers/RemoveHandlerTest.php: -------------------------------------------------------------------------------- 1 | array( 21 | 'id' => 'https://elsewhere.com/collections/1', 22 | ), 23 | 'https://example.com/collections/1' => array( 24 | 'id' => 'https://example.com/collections/1', 25 | ), 26 | ); 27 | } 28 | 29 | public function provideTestHandleRemove() 30 | { 31 | return array( 32 | array( array( 33 | 'id' => 'basicTest', 34 | 'eventName' => InboxActivityEvent::NAME, 35 | 'event' => new InboxActivityEvent( 36 | array( 37 | 'id' => 'https://elsewhere.com/removes/1', 38 | 'type' => 'Remove', 39 | 'object' => array( 40 | 'id' => 'https://elsewhere.com/notes/1', 41 | ), 42 | 'target' => array( 43 | 'id' => 'https://elsewhere.com/collections/1' 44 | ) 45 | ), 46 | TestActivityPubObject::fromArray( array( 47 | 'id' => 'https://example.com/actors/1', 48 | ) ), 49 | self::requestWithAttributes( 50 | 'https://example.com/actors/1/inbox', 51 | array( 52 | 'actor' => TestActivityPubObject::fromArray( array( 53 | 'id' => 'https://elsewhere.com/actors/1' 54 | ) ) 55 | ) 56 | ) 57 | ), 58 | 'expectedRemovedItemId' => 'https://elsewhere.com/notes/1', 59 | ) ), 60 | array( array( 61 | 'id' => 'outboxTest', 62 | 'eventName' => OutboxActivityEvent::NAME, 63 | 'event' => new OutboxActivityEvent( 64 | array( 65 | 'id' => 'https://example.com/removes/1', 66 | 'type' => 'Remove', 67 | 'object' => array( 68 | 'id' => 'https://example.com/notes/1', 69 | ), 70 | 'target' => array( 71 | 'id' => 'https://example.com/collections/1' 72 | ) 73 | ), 74 | TestActivityPubObject::fromArray( array( 75 | 'id' => 'https://example.com/actors/1', 76 | ) ), 77 | self::requestWithAttributes( 78 | 'https://example.com/actors/1/inbox', 79 | array( 80 | 'actor' => TestActivityPubObject::fromArray( array( 81 | 'id' => 'https://example.com/actors/1' 82 | ) ) 83 | ) 84 | ) 85 | ), 86 | 'expectedRemovedItemId' => 'https://example.com/notes/1', 87 | ) ), 88 | array( array( 89 | 'id' => 'notAuthorizedTest', 90 | 'eventName' => OutboxActivityEvent::NAME, 91 | 'event' => new OutboxActivityEvent( 92 | array( 93 | 'id' => 'https://example.com/removes/1', 94 | 'type' => 'Remove', 95 | 'object' => array( 96 | 'id' => 'https://example.com/notes/1', 97 | ), 98 | 'target' => array( 99 | 'id' => 'https://elsewhere.com/collections/1' 100 | ) 101 | ), 102 | TestActivityPubObject::fromArray( array( 103 | 'id' => 'https://example.com/actors/1', 104 | ) ), 105 | self::requestWithAttributes( 106 | 'https://example.com/actors/1/inbox', 107 | array( 108 | 'actor' => TestActivityPubObject::fromArray( array( 109 | 'id' => 'https://example.com/actors/1' 110 | ) ) 111 | ) 112 | ) 113 | ), 114 | 'expectedException' => AccessDeniedHttpException::class, 115 | ) ), 116 | ); 117 | } 118 | 119 | /** 120 | * @dataProvider provideTestHandleRemove 121 | */ 122 | public function testHandleRemove( $testCase ) 123 | { 124 | $objectsService = $this->getMock( ObjectsService::class ); 125 | $objectsService->method( 'dereference')->willReturnCallback( function( $id ) { 126 | $objects = self::getObjects(); 127 | if ( array_key_exists( $id, $objects ) ) { 128 | return TestActivityPubObject::fromArray( $objects[$id] ); 129 | } else { 130 | return null; 131 | } 132 | }); 133 | $collectionsService = $this->getMockBuilder( CollectionsService::class ) 134 | ->disableOriginalConstructor() 135 | ->setMethods( array( 'removeItem' ) ) 136 | ->getMock(); 137 | if ( array_key_exists( 'expectedRemovedItemId', $testCase ) ) { 138 | $collectionsService->expects( $this->once() ) 139 | ->method( 'removeItem' ) 140 | ->with( 141 | $this->anything(), 142 | $this->equalTo( $testCase['expectedRemovedItemId'] ) 143 | ); 144 | } else { 145 | $collectionsService->expects( $this->never() ) 146 | ->method( 'removeItem' ); 147 | } 148 | $removeHandler = new RemoveHandler( $objectsService, $collectionsService ); 149 | $eventDispatcher = new EventDispatcher(); 150 | $eventDispatcher->addSubscriber( $removeHandler ); 151 | if ( array_key_exists( 'expectedException', $testCase ) ) { 152 | $this->setExpectedException( $testCase['expectedException'] ); 153 | } 154 | $eventDispatcher->dispatch( $testCase['eventName'], $testCase['event'] ); 155 | } 156 | } -------------------------------------------------------------------------------- /test/ActivityPubTest.php: -------------------------------------------------------------------------------- 1 | assertTrue( file_exists( $this->getDbPath() ) ); 20 | } 21 | 22 | protected static function getDbPath() 23 | { 24 | return dirname( __FILE__ ) . '/db.sqlite'; 25 | } 26 | 27 | /** 28 | * @depends testItCreatesSchema 29 | */ 30 | public function testItUpdatesSchema() 31 | { 32 | $config = ActivityPubConfig::createBuilder() 33 | ->setDbConnectionParams( array( 34 | 'driver' => 'pdo_sqlite', 35 | 'path' => $this->getDbPath(), 36 | ) ) 37 | ->build(); 38 | $activityPub = new ActivityPub( $config ); 39 | $activityPub->updateSchema(); 40 | $this->assertTrue( file_exists( $this->getDbPath() ) ); 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /test/Auth/AuthListenerTest.php: -------------------------------------------------------------------------------- 1 | objectsService = $this->getMock( ObjectsService::class ); 21 | $this->objectsService->method( 'dereference' )->will( $this->returnValueMap( array( 22 | array( 'https://example.com/actor/1', TestActivityPubObject::fromArray( array( 23 | 'id' => 'https://example.com/actor/1', 24 | ) ) ), 25 | array( 'https://example.com/actor/2', TestActivityPubObject::fromArray( array( 26 | 'id' => 'https://example.com/actor/2', 27 | ) ) ), 28 | ) ) ); 29 | } 30 | 31 | public function provideTestAuthListener() 32 | { 33 | return array( 34 | array( array( 35 | 'id' => 'basicTest', 36 | 'authFunction' => function () { 37 | return 'https://example.com/actor/1'; 38 | }, 39 | 'expectedAttributes' => array( 40 | 'actor' => TestActivityPubObject::fromArray( array( 41 | 'id' => 'https://example.com/actor/1', 42 | ) ), 43 | ), 44 | ) ), 45 | array( array( 46 | 'id' => 'existingActorTest', 47 | 'authFunction' => function () { 48 | return 'https://example.com/actor/1'; 49 | }, 50 | 'requestAttributes' => array( 51 | 'actor' => TestActivityPubObject::fromArray( array( 52 | 'id' => 'https://example.com/actor/2', 53 | ) ), 54 | ), 55 | 'expectedAttributes' => array( 56 | 'actor' => TestActivityPubObject::fromArray( array( 57 | 'id' => 'https://example.com/actor/2', 58 | ) ), 59 | ), 60 | ) ), 61 | array( array( 62 | 'id' => 'defaultAuthTest', 63 | 'authFunction' => function () { 64 | return false; 65 | }, 66 | 'expectedAttributes' => array(), 67 | ) ), 68 | ); 69 | } 70 | 71 | /** 72 | * @dataProvider provideTestAuthListener 73 | */ 74 | public function testAuthListener( $testCase ) 75 | { 76 | $event = $this->getEvent(); 77 | if ( array_key_exists( 'requestAttributes', $testCase ) ) { 78 | foreach ( $testCase['requestAttributes'] as $attribute => $value ) { 79 | $event->getRequest()->attributes->set( $attribute, $value ); 80 | } 81 | } 82 | $authListener = new AuthListener( 83 | $testCase['authFunction'], $this->objectsService 84 | ); 85 | $authListener->checkAuth( $event ); 86 | foreach ( $testCase['expectedAttributes'] as $expectedKey => $expectedValue ) { 87 | $this->assertTrue( 88 | $event->getRequest()->attributes->has( $expectedKey ), 89 | "Error on test $testCase[id]" 90 | ); 91 | if ( $expectedValue instanceof ActivityPubObject ) { 92 | $this->assertTrue( 93 | $expectedValue->equals( 94 | $event->getRequest()->attributes->get( $expectedKey ) 95 | ), 96 | "Error on test $testCase[id]" 97 | ); 98 | } else { 99 | $this->assertEquals( 100 | $expectedValue, 101 | $event->getRequest()->attributes->get( $expectedKey ), 102 | "Error on test $testCase[id]" 103 | ); 104 | } 105 | } 106 | } 107 | 108 | public function getEvent() 109 | { 110 | $kernel = $this->getMock( HttpKernelInterface::class ); 111 | $request = Request::create( 'https://example.com/foo', Request::METHOD_GET ); 112 | return new GetResponseEvent( 113 | $kernel, $request, HttpKernelInterface::MASTER_REQUEST 114 | ); 115 | } 116 | } 117 | 118 | -------------------------------------------------------------------------------- /test/Auth/AuthServiceTest.php: -------------------------------------------------------------------------------- 1 | authService = new AuthService(); 20 | } 21 | 22 | public function provideTestAuthService() 23 | { 24 | return array( 25 | array( array( 26 | 'id' => 'addressedTo', 27 | 'actor' => 'https://example.com/actor/1', 28 | 'object' => array( 29 | 'to' => 'https://example.com/actor/1', 30 | ), 31 | 'expectedResult' => true, 32 | ) ), 33 | array( array( 34 | 'id' => 'noAuth', 35 | 'object' => array( 36 | 'to' => 'https://example.com/actor/1', 37 | ), 38 | 'expectedResult' => false, 39 | ) ), 40 | array( array( 41 | 'id' => 'noAudience', 42 | 'object' => array( 43 | 'type' => 'Note' 44 | ), 45 | 'expectedResult' => true, 46 | ) ), 47 | array( array( 48 | 'id' => 'actor', 49 | 'object' => array( 50 | 'actor' => 'https://example.com/actor/1', 51 | 'to' => 'https://example.com/actor/2', 52 | ), 53 | 'actor' => 'https://example.com/actor/1', 54 | 'expectedResult' => true, 55 | ) ), 56 | array( array( 57 | 'id' => 'attributedTo', 58 | 'object' => array( 59 | 'attributedTo' => 'https://example.com/actor/1', 60 | 'to' => 'https://example.com/actor/2', 61 | ), 62 | 'actor' => 'https://example.com/actor/1', 63 | 'expectedResult' => true, 64 | ) ), 65 | ); 66 | } 67 | 68 | /** 69 | * @dataProvider provideTestAuthService 70 | */ 71 | public function testAuthService( $testCase ) 72 | { 73 | $request = Request::create( 'https://example.com/objects/1' ); 74 | if ( array_key_exists( 'actor', $testCase ) ) { 75 | $request->attributes->set( 'actor', $testCase['actor'] ); 76 | } 77 | $object = TestActivityPubObject::fromArray( $testCase['object'] ); 78 | $actual = $this->authService->isAuthorized( $request, $object ); 79 | $this->assertEquals( 80 | $testCase['expectedResult'], $actual, "Error on test $testCase[id]" 81 | ); 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /test/Auth/SignatureListenerTest.php: -------------------------------------------------------------------------------- 1 | DateTime::createFromFormat( 36 | DateTime::RFC2822, 'Sun, 05 Jan 2014 21:31:40 GMT' 37 | ), 38 | ) ); 39 | $httpSignatureService = new HttpSignatureService( $dateTimeProvider ); 40 | $objectsService = $this->getMock( ObjectsService::class ); 41 | $objectsService->method( 'dereference' ) 42 | ->will( $this->returnValueMap( array( 43 | array( self::KEY_ID, TestActivityPubObject::fromArray( self::getKey() ) ), 44 | array( self::ACTOR_ID, TestActivityPubObject::fromArray( self::getActor() ) ), 45 | ) ) ); 46 | $this->signatureListener = new SignatureListener( 47 | $httpSignatureService, $objectsService 48 | ); 49 | } 50 | 51 | private static function getKey() 52 | { 53 | return array( 54 | 'id' => self::KEY_ID, 55 | 'owner' => 'https://example.com/actor/1', 56 | 'publicKeyPem' => self::PUBLIC_KEY, 57 | ); 58 | } 59 | 60 | private static function getActor() 61 | { 62 | return array( 'id' => self::ACTOR_ID ); 63 | } 64 | 65 | public function provideTestSignatureListener() 66 | { 67 | return array( 68 | array( array( 69 | 'id' => 'basicTest', 70 | 'headers' => array( 71 | 'Authorization' => 'Signature keyId="https://example.com/actor/1/key",algorithm="rsa-sha256",headers="(request-target) host date", signature="qdx+H7PHHDZgy4y/Ahn9Tny9V3GP6YgBPyUXMmoxWtLbHpUnXS2mg2+SbrQDMCJypxBLSPQR2aAjn7ndmw2iicw3HMbe8VfEdKFYRqzic+efkb3nndiv/x1xSHDJWeSWkx3ButlYSuBskLu6kd9Fswtemr3lgdDEmn04swr2Os0="', 72 | ), 73 | 'expectedAttributes' => array( 74 | 'signed' => true, 75 | 'actor' => TestActivityPubObject::fromArray( array( 76 | 'id' => 'https://example.com/actor/1', 77 | ) ), 78 | ), 79 | ) ), 80 | array( array( 81 | 'id' => 'existingActorTest', 82 | 'headers' => array( 83 | 'Authorization' => 'Signature keyId="https://example.com/actor/1/key",algorithm="rsa-sha256",headers="(request-target) host date", signature="qdx+H7PHHDZgy4y/Ahn9Tny9V3GP6YgBPyUXMmoxWtLbHpUnXS2mg2+SbrQDMCJypxBLSPQR2aAjn7ndmw2iicw3HMbe8VfEdKFYRqzic+efkb3nndiv/x1xSHDJWeSWkx3ButlYSuBskLu6kd9Fswtemr3lgdDEmn04swr2Os0="', 84 | ), 85 | 'requestAttributes' => array( 86 | 'actor' => TestActivityPubObject::fromArray( array( 87 | 'id' => 'https://example.com/actor/2', 88 | ) ), 89 | ), 90 | 'expectedAttributes' => array( 91 | 'signed' => true, 92 | 'actor' => TestActivityPubObject::fromArray( array( 93 | 'id' => 'https://example.com/actor/2', 94 | ) ), 95 | ), 96 | ) ), 97 | array( array( 98 | 'id' => 'signatureHeaderTest', 99 | 'headers' => array( 100 | 'Signature' => 'keyId="https://example.com/actor/1/key",algorithm="rsa-sha256",headers="(request-target) host date", signature="qdx+H7PHHDZgy4y/Ahn9Tny9V3GP6YgBPyUXMmoxWtLbHpUnXS2mg2+SbrQDMCJypxBLSPQR2aAjn7ndmw2iicw3HMbe8VfEdKFYRqzic+efkb3nndiv/x1xSHDJWeSWkx3ButlYSuBskLu6kd9Fswtemr3lgdDEmn04swr2Os0="', 101 | ), 102 | 'expectedAttributes' => array( 103 | 'signed' => true, 104 | 'actor' => TestActivityPubObject::fromArray( array( 105 | 'id' => 'https://example.com/actor/1', 106 | ) ), 107 | ), 108 | ) ), 109 | array( array( 110 | 'id' => 'noSignatureTest', 111 | 'expectedAttributes' => array(), 112 | ) ), 113 | ); 114 | } 115 | 116 | /** 117 | * @dataProvider provideTestSignatureListener 118 | */ 119 | public function testSignatureListener( $testCase ) 120 | { 121 | $event = $this->getEvent(); 122 | if ( array_key_exists( 'headers', $testCase ) ) { 123 | foreach ( $testCase['headers'] as $header => $value ) { 124 | $event->getRequest()->headers->set( $header, $value ); 125 | } 126 | } 127 | if ( array_key_exists( 'requestAttributes', $testCase ) ) { 128 | foreach ( $testCase['requestAttributes'] as $attribute => $value ) { 129 | $event->getRequest()->attributes->set( $attribute, $value ); 130 | } 131 | } 132 | $this->signatureListener->validateHttpSignature( $event ); 133 | $this->assertEquals( 134 | $testCase['expectedAttributes'], 135 | $event->getRequest()->attributes->all(), 136 | "Error on test $testCase[id]" 137 | ); 138 | } 139 | 140 | private function getEvent() 141 | { 142 | $kernel = $this->getMock( HttpKernelInterface::class ); 143 | $request = Request::create( 144 | 'https://example.com/foo?param=value&pet=dog', 145 | Request::METHOD_POST, 146 | array(), 147 | array(), 148 | array(), 149 | array(), 150 | '{"hello": "world"}' 151 | ); 152 | $request->headers->set( 'host', 'example.com' ); 153 | $request->headers->set( 'content-type', 'application/json' ); 154 | $request->headers->set( 155 | 'digest', 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=' 156 | ); 157 | $request->headers->set( 'content-length', 18 ); 158 | $request->headers->set( 'date', 'Sun, 05 Jan 2014 21:31:40 GMT' ); 159 | $event = new GetResponseEvent( $kernel, $request, HttpKernelInterface::MASTER_REQUEST ); 160 | return $event; 161 | } 162 | } 163 | 164 | -------------------------------------------------------------------------------- /test/Config/ActivityPubModuleTest.php: -------------------------------------------------------------------------------- 1 | setDbConnectionParams( array( 22 | 'driver' => 'pdo_sqlite', 23 | 'path' => ':memory:', 24 | ) ) 25 | ->build(); 26 | $this->module = new ActivityPubModule( $config ); 27 | } 28 | 29 | public function testItInjects() 30 | { 31 | $entityManager = $this->module->get( EntityManager::class ); 32 | $this->assertNotNull( $entityManager ); 33 | $this->assertInstanceOf( EntityManager::class, $entityManager ); 34 | 35 | $router = $this->module->get( Router::class ); 36 | $this->assertNotNull( $router ); 37 | $this->assertInstanceOf( Router::class, $router ); 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /test/Crypto/RsaKeypairTest.php: -------------------------------------------------------------------------------- 1 | assertStringStartsWith( '-----BEGIN PUBLIC KEY-----', $keypair->getPublicKey() ); 15 | $this->assertStringEndsWith( '-----END PUBLIC KEY-----', $keypair->getPublicKey() ); 16 | $this->assertStringStartsWith( 17 | '-----BEGIN RSA PRIVATE KEY-----', $keypair->getPrivateKey() 18 | ); 19 | $this->assertStringEndsWith( 20 | '-----END RSA PRIVATE KEY-----', $keypair->getPrivateKey() 21 | ); 22 | } 23 | 24 | public function testItSignsAndValidatesSignatures() 25 | { 26 | $keypair = RsaKeypair::generate(); 27 | $data = 'This is some data'; 28 | $signature = $keypair->sign( $data ); 29 | $this->assertInternalType( 'string', $signature ); 30 | $this->assertNotEmpty( $signature ); 31 | $verified = $keypair->verify( $data, $signature ); 32 | $this->assertTrue( $verified ); 33 | } 34 | 35 | public function testItGivesErrorValidatingInvalidSignature() 36 | { 37 | $keypair = RsaKeypair::generate(); 38 | $data = 'This is some data'; 39 | $signature = 'not a real signature'; 40 | $this->setExpectedException( \PHPUnit_Framework_Error::class ); 41 | $keypair->verify( $data, $signature ); 42 | } 43 | 44 | public function testItReturnsNotVerifiedForValidButWrongSignature() 45 | { 46 | $keypairOne = RsaKeypair::generate(); 47 | $data = 'This is some data'; 48 | $signature = $keypairOne->sign( $data ); 49 | $keypairTwo = RsaKeypair::generate(); 50 | $verified = $keypairTwo->verify( $data, $signature ); 51 | $this->assertFalse( $verified ); 52 | } 53 | 54 | public function testItCreatesValidPublicKeyOnly() 55 | { 56 | $fullKeypair = RsaKeypair::generate(); 57 | $publicKeyOnly = RsaKeypair::fromPublicKey( $fullKeypair->getPublicKey() ); 58 | $data = 'This is some data'; 59 | $signature = $fullKeypair->sign( $data ); 60 | $verified = $publicKeyOnly->verify( $data, $signature ); 61 | $this->assertTrue( $verified ); 62 | } 63 | 64 | public function testItCannotSignWithPublicKeyOnly() 65 | { 66 | $fullKeypair = RsaKeypair::generate(); 67 | $publicKeyOnly = RsaKeypair::fromPublicKey( $fullKeypair->getPublicKey() ); 68 | $data = 'This is some data'; 69 | $this->setExpectedException( BadMethodCallException::class, 'Unable to sign data without a private key' ); 70 | $publicKeyOnly->sign( $data ); 71 | } 72 | 73 | public function testItSignsAndVerifiesEmptyData() 74 | { 75 | $keypair = RsaKeypair::generate(); 76 | $data = ''; 77 | $signature = $keypair->sign( $data ); 78 | $verified = $keypair->verify( $data, $signature ); 79 | $this->assertTrue( $verified ); 80 | } 81 | 82 | public function testItHandlesInvalidPublicKeyOnly() 83 | { 84 | $fullKeypair = RsaKeypair::generate(); 85 | $publicKeyOnly = RsaKeypair::fromPublicKey( 'not a real public key' ); 86 | $data = 'This is some data'; 87 | $signature = $fullKeypair->sign( $data ); 88 | $verified = $publicKeyOnly->verify( $data, $signature ); 89 | $this->assertFalse( $verified ); 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /test/Entities/EntityTest.php: -------------------------------------------------------------------------------- 1 | dateTimeProvider->getTime( 'objects-service.create' ) ); 29 | $privateKey = 'a private key'; 30 | $object->setPrivateKey( $privateKey ); 31 | $this->entityManager->persist( $object ); 32 | $this->entityManager->flush(); 33 | $now = $this->getTime( 'objects-service.create' ); 34 | $expected = new ArrayDataSet( array( 35 | 'objects' => array( 36 | array( 'id' => 1, 'created' => $now, 'lastUpdated' => $now ), 37 | ), 38 | 'keys' => array( 39 | array( 'id' => 1, 'object_id' => 1, 'key' => $privateKey ) 40 | ), 41 | ) ); 42 | $expectedObjectsTable = $expected->getTable( 'objects' ); 43 | $expectedKeysTable = $expected->getTable( 'keys' ); 44 | $objectsQueryTable = $this->getConnection()->createQueryTable( 45 | 'objects', 'SELECT * FROM objects' 46 | ); 47 | $keysQueryTable = $this->getConnection()->createQueryTable( 48 | 'keys', 'SELECT * FROM keys' 49 | ); 50 | $this->assertTablesEqual( $expectedObjectsTable, $objectsQueryTable ); 51 | $this->assertTablesEqual( $expectedKeysTable, $keysQueryTable ); 52 | } 53 | 54 | private function getTime( $context ) 55 | { 56 | return $this->dateTimeProvider 57 | ->getTime( $context ) 58 | ->format( "Y-m-d H:i:s" ); 59 | } 60 | 61 | public function itUpdatesAPrivateKey() 62 | { 63 | $object = new ActivityPubObject( $this->dateTimeProvider->getTime( 'objects-service.create' ) ); 64 | $privateKey = 'a private key'; 65 | $object->setPrivateKey( $privateKey ); 66 | $this->entityManager->persist( $object ); 67 | $this->entityManager->flush(); 68 | $newPrivateKey = 'a new private key'; 69 | $object->setPrivateKey( $newPrivateKey ); 70 | $this->entityManager->persist( $object ); 71 | $this->entityManager->flush(); 72 | $now = $this->getTime( 'objects-service.create' ); 73 | $expected = new ArrayDataSet( array( 74 | 'objects' => array( 75 | array( 'id' => 1, 'created' => $now, 'lastUpdated' => $now ), 76 | ), 77 | 'keys' => array( 78 | array( 'id' => 1, 'object_id' => 1, 'key' => $newPrivateKey ) 79 | ), 80 | ) ); 81 | $expectedObjectsTable = $expected->getTable( 'objects' ); 82 | $expectedKeysTable = $expected->getTable( 'keys' ); 83 | $objectsQueryTable = $this->getConnection()->createQueryTable( 84 | 'objects', 'SELECT * FROM objects' 85 | ); 86 | $keysQueryTable = $this->getConnection()->createQueryTable( 87 | 'keys', 'SELECT * FROM keys' 88 | ); 89 | $this->assertTablesEqual( $expectedObjectsTable, $objectsQueryTable ); 90 | $this->assertTablesEqual( $expectedKeysTable, $keysQueryTable ); 91 | } 92 | 93 | protected function getDataSet() 94 | { 95 | return new ArrayDataSet( array( 96 | 'objects' => array(), 97 | 'fields' => array(), 98 | 'keys' => array(), 99 | ) ); 100 | } 101 | 102 | protected function setUp() 103 | { 104 | parent::setUp(); 105 | $dbConfig = Setup::createAnnotationMetadataConfiguration( 106 | array( __DIR__ . '/../../src/Entities' ), true 107 | ); 108 | $namingStrategy = new PrefixNamingStrategy( '' ); 109 | $dbConfig->setNamingStrategy( $namingStrategy ); 110 | $dbParams = array( 111 | 'driver' => 'pdo_sqlite', 112 | 'path' => $this->getDbPath(), 113 | ); 114 | $this->entityManager = EntityManager::create( $dbParams, $dbConfig ); 115 | $this->dateTimeProvider = new TestDateTimeProvider( array( 116 | 'objects-service.create' => new DateTime( "12:00" ), 117 | 'objects-service.update' => new DateTime( "12:01" ), 118 | ) ); 119 | } 120 | } 121 | 122 | -------------------------------------------------------------------------------- /test/Http/RouterTest.php: -------------------------------------------------------------------------------- 1 | getController = $this->getMock( GetController::class ); 29 | $this->postController = $this->getMock( PostController::class ); 30 | $this->router = new Router( $this->getController, $this->postController ); 31 | $this->kernel = $this->getMock( HttpKernel::class ); 32 | } 33 | 34 | public function provideTestRouter() 35 | { 36 | $this->getController = $this->getMock( GetController::class ); 37 | $this->postController = $this->getMock( PostController::class ); 38 | return array( 39 | array( array( 40 | 'id' => 'GET', 41 | 'request' => Request::create( 'https://foo.com', Request::METHOD_GET ), 42 | 'expectedController' => array( $this->getController, 'handle' ), 43 | ) ), 44 | array( array( 45 | 'id' => 'POST', 46 | 'request' => Request::create( 'https://foo.com', Request::METHOD_POST ), 47 | 'expectedController' => array( $this->postController, 'handle' ), 48 | ) ), 49 | array( array( 50 | 'id' => 'MethodNotAllowed', 51 | 'request' => Request::create( 'https://foo.com', Request::METHOD_PUT ), 52 | 'expectedException' => MethodNotAllowedHttpException::class, 53 | ) ), 54 | ); 55 | } 56 | 57 | /** 58 | * @dataProvider provideTestRouter 59 | */ 60 | public function testRouter( $testCase ) 61 | { 62 | $request = $testCase['request']; 63 | $event = new GetResponseEvent( 64 | $this->kernel, $request, HttpKernelInterface::MASTER_REQUEST 65 | ); 66 | if ( array_key_exists( 'expectedException', $testCase ) ) { 67 | $this->setExpectedException( $testCase['expectedException'] ); 68 | } 69 | $this->router->route( $event ); 70 | $this->assertEquals( 71 | $testCase['expectedController'], 72 | $request->attributes->get( '_controller' ), 73 | "Error on test $testCase[id]" 74 | ); 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /test/JsonLd/Dereferencer/CachingDereferencerTest.php: -------------------------------------------------------------------------------- 1 | 'https://example.com/sally', 28 | 'type' => 'Person', 29 | ), 30 | array( 31 | new Request( 32 | 'GET', 33 | 'https://example.com/sally', 34 | array( 35 | 'Host' => 'example.com', 36 | 'Accept' => 'application/ld+json', 37 | 'Date' => 'Sun, 05 Jan 2014 21:31:40 GMT', 38 | ) 39 | ), 40 | ), 41 | array( 42 | new Response( 200, array(), json_encode( 43 | (object) array( 44 | 'id' => 'https://example.com/sally', 45 | 'type' => 'Person', 46 | ) 47 | )), 48 | ), 49 | ), 50 | array( 51 | 'https://example.com/sally', 52 | (object) array( 53 | 'id' => 'https://example.com/sally', 54 | 'type' => 'Person', 55 | ), 56 | array( 57 | new Request( 58 | 'GET', 59 | 'https://example.com/sally', 60 | array( 61 | 'Host' => 'example.com', 62 | 'Accept' => 'application/ld+json', 63 | 'Date' => 'Sun, 05 Jan 2014 21:31:40 GMT', 64 | 'Signature' => 'keyId="Test",algorithm="rsa-sha256",headers="(request-target) host date",signature="PXfYgMWE1CpS7DDuo8iB7Sj3qCBuN8bDuND3mQBU06rh7EMfWqisB0USH0DWFbZVcbsHr/YnKJlcmbWG5mpU6Kf0B4SAoMKGHCUNT1i55uw5XLPSZfrd2c38md2Pv8Dt0lO7cFP8SeiTlBM3gTmpvnuKn+AxpR9jpvwAQT8riQw="', 65 | ) 66 | ), 67 | ), 68 | array( 69 | new Response( 200, array(), json_encode( 70 | (object) array( 71 | 'id' => 'https://example.com/sally', 72 | 'type' => 'Person', 73 | ) 74 | )), 75 | ), 76 | 'Test', 77 | "-----BEGIN RSA PRIVATE KEY----- 78 | MIICXgIBAAKBgQDCFENGw33yGihy92pDjZQhl0C36rPJj+CvfSC8+q28hxA161QF 79 | NUd13wuCTUcq0Qd2qsBe/2hFyc2DCJJg0h1L78+6Z4UMR7EOcpfdUE9Hf3m/hs+F 80 | UR45uBJeDK1HSFHD8bHKD6kv8FPGfJTotc+2xjJwoYi+1hqp1fIekaxsyQIDAQAB 81 | AoGBAJR8ZkCUvx5kzv+utdl7T5MnordT1TvoXXJGXK7ZZ+UuvMNUCdN2QPc4sBiA 82 | QWvLw1cSKt5DsKZ8UETpYPy8pPYnnDEz2dDYiaew9+xEpubyeW2oH4Zx71wqBtOK 83 | kqwrXa/pzdpiucRRjk6vE6YY7EBBs/g7uanVpGibOVAEsqH1AkEA7DkjVH28WDUg 84 | f1nqvfn2Kj6CT7nIcE3jGJsZZ7zlZmBmHFDONMLUrXR/Zm3pR5m0tCmBqa5RK95u 85 | 412jt1dPIwJBANJT3v8pnkth48bQo/fKel6uEYyboRtA5/uHuHkZ6FQF7OUkGogc 86 | mSJluOdc5t6hI1VsLn0QZEjQZMEOWr+wKSMCQQCC4kXJEsHAve77oP6HtG/IiEn7 87 | kpyUXRNvFsDE0czpJJBvL/aRFUJxuRK91jhjC68sA7NsKMGg5OXb5I5Jj36xAkEA 88 | gIT7aFOYBFwGgQAQkWNKLvySgKbAZRTeLBacpHMuQdl1DfdntvAyqpAZ0lY0RKmW 89 | G6aFKaqQfOXKCyWoUiVknQJAXrlgySFci/2ueKlIE1QqIiLSZ8V8OlpFLRnb1pzI 90 | 7U1yQXnTAEFYM560yJlzUpOb1V4cScGd365tiSMvxLOvTA== 91 | -----END RSA PRIVATE KEY-----", 92 | ), 93 | ); 94 | } 95 | 96 | /** 97 | * @dataProvider provideForItCaches 98 | */ 99 | public function testItCaches( $iri, 100 | $expectedJsonObj, 101 | $expectedRequestHistory = array(), 102 | $mockResponses = array(), 103 | $keyId = null, 104 | $privateKey = null ) 105 | { 106 | $logger = new Logger(); 107 | $cache = new ArrayCachePool(); 108 | $dateTimeProvider = new TestDateTimeProvider( array ( 109 | 'caching-dereferencer.dereference' => DateTime::createFromFormat( 110 | DateTime::RFC2822, 'Sun, 05 Jan 2014 21:31:40 GMT' 111 | ), 112 | ) ); 113 | $sigService = new HttpSignatureService( $dateTimeProvider ); 114 | 115 | $mock = new MockHandler( $mockResponses ); 116 | $handler = HandlerStack::create( $mock ); 117 | $historyContainer = array(); 118 | $history = Middleware::history( $historyContainer ); 119 | $handler->push( $history ); 120 | $client = new Client( array( 'handler' => $handler ) ); 121 | 122 | $dereferencer = new CachingDereferencer( 123 | $logger, $cache, $client, $sigService, $dateTimeProvider, $keyId, $privateKey 124 | ); 125 | 126 | $jsonObj = $dereferencer->dereference( $iri ); 127 | $this->assertEquals( $expectedJsonObj, $jsonObj ); 128 | 129 | $cachedObj = $dereferencer->dereference( $iri ); 130 | $this->assertEquals( $expectedJsonObj, $cachedObj ); 131 | 132 | $this->assertEquals( count( $expectedRequestHistory ), count( $historyContainer ) ); 133 | for ( $i = 0; $i < count( $expectedRequestHistory ); $i += 1 ) { 134 | $expectedRequest = $expectedRequestHistory[$i]; 135 | $actualRequest = $historyContainer[$i]['request']; 136 | foreach ( $expectedRequest->getHeaders() as $name => $expectedHeaders ) { 137 | $this->assertEquals( $expectedHeaders, $actualRequest->getHeader( $name ) ); 138 | } 139 | } 140 | } 141 | } -------------------------------------------------------------------------------- /test/JsonLd/TestDereferencer.php: -------------------------------------------------------------------------------- 1 | nodes = $nodes; 16 | } 17 | 18 | /** 19 | * @param string $iri The IRI to dereference. 20 | * @return stdClass|array The dereferenced node. 21 | * @throws NodeNotFoundException If a node with the IRI could not be found. 22 | */ 23 | public function dereference( $iri ) 24 | { 25 | if ( array_key_exists( $iri, $this->nodes ) ) { 26 | return $this->nodes[$iri]; 27 | } else { 28 | throw new NodeNotFoundException( $iri ); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /test/Objects/CollectionIteratorTest.php: -------------------------------------------------------------------------------- 1 | 'basicIteration', 18 | 'collection' => TestActivityPubObject::fromArray( array( 19 | 'id' => 'mycollection', 20 | 'type' => 'Collection', 21 | 'items' => array( 22 | array( 23 | 'id' => 'item1', 24 | ), 25 | array( 26 | 'id' => 'item2', 27 | ), 28 | array( 29 | 'id' => 'item3', 30 | ), 31 | ) 32 | ) ), 33 | 'expectedItems' => array( 34 | array( 35 | 'id' => 'item1', 36 | ), 37 | array( 38 | 'id' => 'item2', 39 | ), 40 | array( 41 | 'id' => 'item3', 42 | ), 43 | ), 44 | ) ), 45 | array( array( 46 | 'id' => 'orderedCollection', 47 | 'collection' => TestActivityPubObject::fromArray( array( 48 | 'id' => 'mycollection', 49 | 'type' => 'OrderedCollection', 50 | 'orderedItems' => array( 51 | array( 52 | 'id' => 'item1', 53 | ), 54 | array( 55 | 'id' => 'item2', 56 | ), 57 | array( 58 | 'id' => 'item3', 59 | ), 60 | ) 61 | ) ), 62 | 'expectedItems' => array( 63 | array( 64 | 'id' => 'item1', 65 | ), 66 | array( 67 | 'id' => 'item2', 68 | ), 69 | array( 70 | 'id' => 'item3', 71 | ), 72 | ), 73 | ) ), 74 | array( array( 75 | 'id' => 'orderedCollectionWrongItems', 76 | 'collection' => TestActivityPubObject::fromArray( array( 77 | 'id' => 'mycollection', 78 | 'type' => 'OrderedCollection', 79 | 'items' => array( 80 | array( 81 | 'id' => 'item1', 82 | ), 83 | array( 84 | 'id' => 'item2', 85 | ), 86 | array( 87 | 'id' => 'item3', 88 | ), 89 | ) 90 | ) ), 91 | 'expectedException' => \InvalidArgumentException::class, 92 | ) ), 93 | array( array( 94 | 'id' => 'unorderedCollectionWrongItems', 95 | 'collection' => TestActivityPubObject::fromArray( array( 96 | 'id' => 'mycollection', 97 | 'type' => 'Collection', 98 | 'orderedItems' => array( 99 | array( 100 | 'id' => 'item1', 101 | ), 102 | array( 103 | 'id' => 'item2', 104 | ), 105 | array( 106 | 'id' => 'item3', 107 | ), 108 | ) 109 | ) ), 110 | 'expectedException' => \InvalidArgumentException::class, 111 | ) ), 112 | ); 113 | } 114 | 115 | /** 116 | * @dataProvider provideTestCollectionIterator 117 | */ 118 | public function testCollectionIterator( $testCase ) 119 | { 120 | $this->setExpectedException( null ); 121 | if ( array_key_exists( 'expectedException', $testCase ) ) { 122 | $this->setExpectedException( $testCase['expectedException'] ); 123 | } 124 | foreach ( CollectionIterator::iterateCollection( $testCase['collection'] ) as $idx => $item ) { 125 | if ( $item instanceof ActivityPubObject ) { 126 | $item = $item->asArray(); 127 | } 128 | $this->assertEquals( $testCase['expectedItems'][$idx], $item, "Error on test $testCase[id]" ); 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /test/Objects/IdProviderTest.php: -------------------------------------------------------------------------------- 1 | objectsService = $this->getMock( ObjectsService::class ); 20 | $this->objectsService->method( 'query' ) 21 | ->will( $this->returnCallback( function ( $query ) { 22 | $existsId = sprintf( 23 | 'https://example.com/ap/objects/%s', self::EXISTING_ID_STR 24 | ); 25 | if ( array_key_exists( 'id', $query ) && $query['id'] == $existsId ) { 26 | return array( 'existingObject' ); 27 | } else { 28 | return array(); 29 | } 30 | } ) ); 31 | } 32 | 33 | public function provideTestIdProvider() 34 | { 35 | return array( 36 | array( array( 37 | 'id' => 'providesId', 38 | 'providedRnd' => array( 'foo' ), 39 | 'expectedId' => 'https://example.com/ap/objects/foo', 40 | ) ), 41 | array( array( 42 | 'id' => 'checksForExisting', 43 | 'providedRnd' => array( self::EXISTING_ID_STR, 'bar' ), 44 | 'expectedId' => 'https://example.com/ap/objects/bar', 45 | ) ), 46 | array( array( 47 | 'id' => 'addsPath', 48 | 'providedRnd' => array( 'foo' ), 49 | 'path' => 'notes', 50 | 'expectedId' => 'https://example.com/ap/notes/foo', 51 | ) ), 52 | ); 53 | } 54 | 55 | /** 56 | * @dataProvider provideTestIdProvider 57 | */ 58 | public function testIdProvider( $testCase ) 59 | { 60 | $randomProvider = $this->getMock( RandomProvider::class ); 61 | call_user_func_array( 62 | array( $randomProvider->method( 'randomString' ), 'willReturnOnConsecutiveCalls' ), 63 | $testCase['providedRnd'] 64 | ); 65 | $idProvider = new IdProvider( $this->objectsService, $randomProvider, 'ap' ); 66 | $request = Request::create( 'https://example.com' ); 67 | if ( array_key_exists( 'path', $testCase ) ) { 68 | $id = $idProvider->getId( $request, $testCase['path'] ); 69 | } else { 70 | $id = $idProvider->getId( $request ); 71 | } 72 | $this->assertEquals( $testCase['expectedId'], $id, "Error on test $testCase[id]" ); 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /test/TestConfig/APTestCase.php: -------------------------------------------------------------------------------- 1 | attributes->add( $attributes ); 24 | return $request; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /test/TestConfig/ArrayDataSet.php: -------------------------------------------------------------------------------- 1 | $rows ) { 20 | $columns = []; 21 | if ( isset( $rows[0] ) ) { 22 | $columns = array_keys( $rows[0] ); 23 | } 24 | 25 | $metaData = new \PHPUnit_Extensions_Database_DataSet_DefaultTableMetaData( $tableName, $columns ); 26 | $table = new \PHPUnit_Extensions_Database_DataSet_DefaultTable( $metaData ); 27 | 28 | foreach ( $rows as $row ) { 29 | $table->addRow( $row ); 30 | } 31 | $this->tables[$tableName] = $table; 32 | } 33 | } 34 | 35 | public function getTable( $tableName ) 36 | { 37 | if ( !isset( $this->tables[$tableName] ) ) { 38 | throw new InvalidArgumentException( "$tableName is not a table in the current database." ); 39 | } 40 | 41 | return $this->tables[$tableName]; 42 | } 43 | 44 | protected function createIterator( $reverse = false ) 45 | { 46 | return new \PHPUnit_Extensions_Database_DataSet_DefaultTableIterator( $this->tables, $reverse ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/TestConfig/SQLiteTestCase.php: -------------------------------------------------------------------------------- 1 | conn ) ) { 22 | if ( ! isset( $this->pdo ) ) { 23 | $this->dbPath = $this->getDbPath(); 24 | $this->pdo = new \PDO( "sqlite:{$this->dbPath}" ); 25 | } 26 | $this->conn = $this->createDefaultDBConnection( $this->pdo, $this->dbPath ); 27 | } 28 | return $this->conn; 29 | } 30 | 31 | protected static function getDbPath() 32 | { 33 | return dirname( __FILE__ ) . '/db.sqlite'; 34 | } 35 | 36 | protected function setUp() 37 | { 38 | parent::setUp(); 39 | $dbPath = $this->getDbPath(); 40 | if ( file_exists( $dbPath ) ) { 41 | unlink( $dbPath ); 42 | } 43 | $config = ActivityPubConfig::createBuilder() 44 | ->setDbConnectionParams( array( 45 | 'driver' => 'pdo_sqlite', 46 | 'path' => $dbPath, 47 | ) ) 48 | ->build(); 49 | $activityPub = new ActivityPub( $config ); 50 | $activityPub->updateSchema(); 51 | } 52 | 53 | protected function tearDown() 54 | { 55 | parent::tearDown(); 56 | if ( file_exists( $this->getDbPath() ) ) { 57 | unlink( $this->getDbPath() ); 58 | } 59 | unset( $this->conn ); 60 | unset( $this->pdo ); 61 | } 62 | } 63 | 64 | -------------------------------------------------------------------------------- /test/TestUtils/TestActivityPubObject.php: -------------------------------------------------------------------------------- 1 | fixedTime = $time; 22 | parent::__construct( $time ); 23 | } 24 | 25 | public static function getDefaultTime() 26 | { 27 | return DateTime::createFromFormat( 28 | DateTime::RFC2822, 'Sun, 05 Jan 2014 21:31:40 GMT' 29 | ); 30 | } 31 | 32 | public static function fromArray( array $arr, DateTime $time = null ) 33 | { 34 | if ( !$time ) { 35 | $time = self::getDefaultTime(); 36 | } 37 | $object = new TestActivityPubObject( $time ); 38 | foreach ( $arr as $name => $value ) { 39 | if ( is_array( $value ) ) { 40 | $child = self::fromArray( $value, $time ); 41 | TestField::withObject( $object, $name, $child, $time ); 42 | } else { 43 | TestField::withValue( $object, $name, $value, $time ); 44 | } 45 | } 46 | return $object; 47 | } 48 | 49 | public function addField( Field $field, DateTime $time = null ) 50 | { 51 | parent::addField( $field, $time ); 52 | $this->lastUpdated = $this->fixedTime; 53 | } 54 | 55 | public function removeField( Field $field, DateTime $time = null ) 56 | { 57 | parent::removeField( $field, $time ); 58 | $this->lastUpdated = $this->fixedTime; 59 | } 60 | 61 | public function setLastUpdated( $lastUpdated ) 62 | { 63 | // do not change lastUpdated 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /test/TestUtils/TestDateTimeProvider.php: -------------------------------------------------------------------------------- 1 | context = $context; 21 | } 22 | 23 | public function getTime( $context = '' ) 24 | { 25 | if ( array_key_exists( $context, $this->context ) ) { 26 | return $this->context[$context]; 27 | } else { 28 | return new DateTime( 'now' ); 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /test/TestUtils/TestField.php: -------------------------------------------------------------------------------- 1 | fixedTime = $time; 23 | } 24 | 25 | public function setTargetObject( ActivityPubObject $targetObject, DateTime $time = null ) 26 | { 27 | parent::setTargetObject( $targetObject, $time ); 28 | $this->lastUpdated = $this->fixedTime; 29 | } 30 | 31 | public function setValue( $value, DateTime $time = null ) 32 | { 33 | parent::setValue( $value, $time ); 34 | $this->lastUpdated = $this->fixedTime; 35 | } 36 | 37 | protected function setCreated( DateTime $timestamp ) 38 | { 39 | // don't set created 40 | } 41 | 42 | protected function setLastupdated( DateTime $timestamp ) 43 | { 44 | // don't set lastUpdated 45 | } 46 | 47 | } 48 | 49 | -------------------------------------------------------------------------------- /test/TestUtils/TestUuidProvider.php: -------------------------------------------------------------------------------- 1 | uuids = $uuids; 26 | $this->uuidIdx = 0; 27 | } 28 | 29 | public function uuid() 30 | { 31 | $uuid = $this->uuids[$this->uuidIdx]; 32 | $this->uuidIdx = ( $this->uuidIdx + 1 ) % count( $this->uuids ); 33 | return $uuid; 34 | } 35 | } -------------------------------------------------------------------------------- /test/Utils/UtilTest.php: -------------------------------------------------------------------------------- 1 | 'bar' ); 13 | $isAssoc = Util::isAssoc( $arr ); 14 | $this->assertTrue( $isAssoc ); 15 | } 16 | 17 | public function testItReturnsFalseForNonAssoc() 18 | { 19 | $arr = array( 'foo', 'bar' ); 20 | $isAssoc = Util::isAssoc( $arr ); 21 | $this->assertFalse( $isAssoc ); 22 | } 23 | 24 | public function testItHandlesMixedArray() 25 | { 26 | $arr = array( 'foo' => 'bar', 'baz' ); 27 | $isAssoc = Util::isAssoc( $arr ); 28 | $this->assertTrue( $isAssoc ); 29 | } 30 | 31 | public function testItChecksEmptyArrayIsAssoc() 32 | { 33 | $arr = array(); 34 | $isAssoc = Util::isAssoc( $arr ); 35 | $this->assertFalse( $isAssoc ); 36 | } 37 | 38 | public function testArrayKeysExist() 39 | { 40 | $arr = array( 'foo' => 'bar', 'baz' => 'qux' ); 41 | $keys = array( 'foo', 'baz' ); 42 | $keysExist = Util::arrayKeysExist( $arr, $keys ); 43 | $this->assertTrue( $keysExist ); 44 | } 45 | 46 | public function testItChecksForAllKeys() 47 | { 48 | $arr = array( 'foo' => 'bar' ); 49 | $keys = array( 'foo', 'baz' ); 50 | $keysExist = Util::arrayKeysExist( $arr, $keys ); 51 | $this->assertFalse( $keysExist ); 52 | } 53 | 54 | public function testItAllowsExtraKeys() 55 | { 56 | $arr = array( 'foo' => 'bar', 'baz' => 'qux' ); 57 | $keys = array( 'foo' ); 58 | $keysExist = Util::arrayKeysExist( $arr, $keys ); 59 | $this->assertTrue( $keysExist ); 60 | } 61 | 62 | public function testItHandlesEmptyArray() 63 | { 64 | $arr = array(); 65 | $keys = array( 'foo' ); 66 | $keysExist = Util::arrayKeysExist( $arr, $keys ); 67 | $this->assertFalse( $keysExist ); 68 | } 69 | 70 | public function testItHandlesEmptyKeys() 71 | { 72 | $arr = array( 'foo' => 'bar', 'baz' => 'qux' ); 73 | $keys = array(); 74 | $keysExist = Util::arrayKeysExist( $arr, $keys ); 75 | $this->assertTrue( $keysExist ); 76 | } 77 | 78 | public function testItHandlesBothEmpty() 79 | { 80 | $arr = array(); 81 | $keys = array(); 82 | $keysExist = Util::arrayKeysExist( $arr, $keys ); 83 | $this->assertTrue( $keysExist ); 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /test/bootstrap.php: -------------------------------------------------------------------------------- 1 | setDbConnectionParams( array( 14 | 'driver' => 'pdo_sqlite', 15 | 'path' => $dbPath, 16 | ) ) 17 | ->build(); 18 | $activityPub = new ActivityPub( $config ); 19 | $activityPub->updateSchema(); 20 | 21 | -------------------------------------------------------------------------------- /test/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | --------------------------------------------------------------------------------