From d7a09b65a49c2465ee93e23af7623bea581f67c5 Mon Sep 17 00:00:00 2001 From: Yu Shing Date: Sat, 16 Nov 2024 10:22:32 +0800 Subject: [PATCH] [WIP] --- composer.json | 5 +- src/broadcasting/src/BroadcastException.php | 10 + .../src/Broadcasters/AblyBroadcaster.php | 207 ++++++++++++++++++ tests/Broadcasting/AblyBroadcasterTest.php | 159 ++++++++++++++ tests/Broadcasting/BroadcasterTest.php | 11 +- 5 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 src/broadcasting/src/BroadcastException.php create mode 100644 src/broadcasting/src/Broadcasters/AblyBroadcaster.php create mode 100644 tests/Broadcasting/AblyBroadcasterTest.php diff --git a/composer.json b/composer.json index 054096f..6e3752a 100644 --- a/composer.json +++ b/composer.json @@ -133,9 +133,12 @@ "aws/aws-sdk-php": "Required to use the SES mail driver (^3.235.5).", "symfony/http-client": "Required to use the Symfony API mail transports (^6.2).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.2)." + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.2).", + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0)." }, "require-dev": { + "ably/ably-php": "^1.0", "fakerphp/faker": "^2.0", "friendsofphp/php-cs-fixer": "^3.57.2", "hyperf/devtool": "~3.1.0", diff --git a/src/broadcasting/src/BroadcastException.php b/src/broadcasting/src/BroadcastException.php new file mode 100644 index 0000000..290f5b2 --- /dev/null +++ b/src/broadcasting/src/BroadcastException.php @@ -0,0 +1,10 @@ +ably = $ably; + } + + /** + * Authenticate the incoming request for a given channel. + * + * @throws AccessDeniedHttpException + */ + public function auth(RequestInterface $request): mixed + { + $originalChannelName = $request->input('channel_name'); + $channelName = $this->normalizeChannelName($originalChannelName); + + if (empty($originalChannelName) + || ($this->isGuardedChannel($originalChannelName) && ! $this->retrieveUser($channelName)) + ) { + throw new AccessDeniedHttpException; + } + + return parent::verifyUserCanAccessChannel( + $request, $channelName + ); + } + + /** + * Return the valid authentication response. + */ + public function validAuthenticationResponse(RequestInterface $request, mixed $result): mixed + { + $originalChannelName = $request->input('channel_name'); + $socketId = $request->input('socket_id'); + + if (str_starts_with($originalChannelName, 'private')) { + $signature = $this->generateAblySignature($originalChannelName, $socketId); + + return ['auth' => $this->getPublicToken().':'.$signature]; + } + + $channelName = $this->normalizeChannelName($originalChannelName); + + $user = $this->retrieveUser($channelName); + + $broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting') + ? $user->getAuthIdentifierForBroadcasting() + : $user->getAuthIdentifier(); + + $signature = $this->generateAblySignature( + $originalChannelName, + $socketId, + $userData = array_filter([ + 'user_id' => (string) $broadcastIdentifier, + 'user_info' => $result, + ]) + ); + + return [ + 'auth' => $this->getPublicToken().':'.$signature, + 'channel_data' => json_encode($userData), + ]; + } + + /** + * Generate the signature needed for Ably authentication headers. + */ + public function generateAblySignature(string $channelName, string $socketId, array|null $userData = null): string + { + return hash_hmac( + 'sha256', + sprintf('%s:%s%s', $socketId, $channelName, $userData ? ':'.json_encode($userData) : ''), + $this->getPrivateToken(), + ); + } + + /** + * Broadcast the given event. + * + * @throws BroadcastException + */ + public function broadcast(array $channels, string $event, array $payload = []): void + { + try { + foreach ($this->formatChannels($channels) as $channel) { + $this->ably->channels->get($channel)->publish( + $this->buildAblyMessage($event, $payload) + ); + } + } catch (AblyException $e) { + throw new BroadcastException( + sprintf('Ably error: %s', $e->getMessage()) + ); + } + } + + /** + * Build an Ably message object for broadcasting. + */ + protected function buildAblyMessage(string $event, array $payload = []): AblyMessage + { + return tap(new AblyMessage, function ($message) use ($event, $payload) { + $message->name = $event; + $message->data = $payload; + $message->connectionKey = data_get($payload, 'socket'); + }); + } + + /** + * Return true if the channel is protected by authentication. + */ + public function isGuardedChannel(string $channel): bool + { + return Str::startsWith($channel, ['private-', 'presence-']); + } + + /** + * Remove prefix from channel name. + */ + public function normalizeChannelName(string $channel): string + { + if ($this->isGuardedChannel($channel)) { + return str_starts_with($channel, 'private-') + ? Str::replaceFirst('private-', '', $channel) + : Str::replaceFirst('presence-', '', $channel); + } + + return $channel; + } + + /** + * Format the channel array into an array of strings. + */ + protected function formatChannels(array $channels): array + { + return array_map(function ($channel) { + $channel = (string) $channel; + + if (Str::startsWith($channel, ['private-', 'presence-'])) { + return str_starts_with($channel, 'private-') + ? Str::replaceFirst('private-', 'private:', $channel) + : Str::replaceFirst('presence-', 'presence:', $channel); + } + + return 'public:'.$channel; + }, $channels); + } + + /** + * Get the public token value from the Ably key. + */ + protected function getPublicToken(): string + { + return Str::before($this->ably->options->key, ':'); + } + + /** + * Get the private token value from the Ably key. + */ + protected function getPrivateToken(): string + { + return Str::after($this->ably->options->key, ':'); + } + + /** + * Get the underlying Ably SDK instance. + */ + public function getAbly(): AblyRest + { + return $this->ably; + } + + /** + * Set the underlying Ably SDK instance. + */ + public function setAbly(AblyRest $ably): void + { + $this->ably = $ably; + } +} diff --git a/tests/Broadcasting/AblyBroadcasterTest.php b/tests/Broadcasting/AblyBroadcasterTest.php new file mode 100644 index 0000000..70ca5b3 --- /dev/null +++ b/tests/Broadcasting/AblyBroadcasterTest.php @@ -0,0 +1,159 @@ +ably = m::mock(AblyRest::class, ['abcd:efgh']); + + $this->broadcaster = m::mock(AblyBroadcaster::class, [$this->ably])->makePartial(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + } + + public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue() + { + $this->broadcaster->channel('test', function () { + return true; + }); + + $this->broadcaster->shouldReceive('validAuthenticationResponse') + ->once(); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('private-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return false; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('private-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return true; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithoutUserForChannel('private-test') + ); + } + + public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray() + { + $returnData = [1, 2, 3, 4]; + $this->broadcaster->channel('test', function () use ($returnData) { + return $returnData; + }); + + $this->broadcaster->shouldReceive('validAuthenticationResponse') + ->once(); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('presence-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + // + }); + + $this->broadcaster->auth( + $this->getMockRequestWithUserForChannel('presence-test') + ); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound() + { + $this->expectException(AccessDeniedHttpException::class); + + $this->broadcaster->channel('test', function () { + return [1, 2, 3, 4]; + }); + + $this->broadcaster->auth( + $this->getMockRequestWithoutUserForChannel('presence-test') + ); + } + + /** + * @param string $channel + * @return \Illuminate\Http\Request + */ + protected function getMockRequestWithUserForChannel($channel) + { + $request = m::mock(Request::class); + $request->shouldReceive('all')->andReturn(['channel_name' => $channel, 'socket_id' => 'abcd.1234']); + + $request->shouldReceive('input') + ->with('callback', false) + ->andReturn(false); + + $user = m::mock('User'); + $user->shouldReceive('getAuthIdentifierForBroadcasting') + ->andReturn(42); + $user->shouldReceive('getAuthIdentifier') + ->andReturn(42); + + $request->shouldReceive('user') + ->andReturn($user); + + return $request; + } + + /** + * @param string $channel + * @return \Illuminate\Http\Request + */ + protected function getMockRequestWithoutUserForChannel($channel) + { + $request = m::mock(Request::class); + $request->shouldReceive('all')->andReturn(['channel_name' => $channel]); + + $request->shouldReceive('user') + ->andReturn(null); + + return $request; + } +} diff --git a/tests/Broadcasting/BroadcasterTest.php b/tests/Broadcasting/BroadcasterTest.php index 49c195a..f1af75c 100644 --- a/tests/Broadcasting/BroadcasterTest.php +++ b/tests/Broadcasting/BroadcasterTest.php @@ -40,17 +40,10 @@ protected function setUp(): void $this->broadcaster = new FakeBroadcaster(); - $this->container = $this->getApplication([ + $container = $this->getApplication([ FactoryContract::class => fn () => new stdClass(), ]); - ApplicationContext::setContainer($this->container); - } - - protected function tearDown(): void - { - m::close(); - // - // Container::setInstance(null); + ApplicationContext::setContainer($container); } public function testExtractingParametersWhileCheckingForUserAccess()