Skip to content

Commit

Permalink
add ably broadcaster
Browse files Browse the repository at this point in the history
  • Loading branch information
taylorotwell committed Nov 17, 2020
1 parent acb4b77 commit e0f3f8e
Show file tree
Hide file tree
Showing 3 changed files with 362 additions and 0 deletions.
13 changes: 13 additions & 0 deletions src/Illuminate/Broadcasting/BroadcastManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Illuminate\Broadcasting;

use Ably\AblyRest;
use Closure;
use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster;
use Illuminate\Broadcasting\Broadcasters\LogBroadcaster;
use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster;
Expand Down Expand Up @@ -220,6 +222,17 @@ protected function createPusherDriver(array $config)
return new PusherBroadcaster($pusher);
}

/**
* Create an instance of the driver.
*
* @param array $config
* @return \Illuminate\Contracts\Broadcasting\Broadcaster
*/
protected function createAblyDriver(array $config)
{
return new AblyBroadcaster(new AblyRest($config['key']));
}

/**
* Create an instance of the driver.
*
Expand Down
200 changes: 200 additions & 0 deletions src/Illuminate/Broadcasting/Broadcasters/AblyBroadcaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

namespace Illuminate\Broadcasting\Broadcasters;

use Ably\AblyRest;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

/**
* @author Matthew Hall (matthall28@gmail.com)
* @author Taylor Otwell (taylor@laravel.com)
*/
class AblyBroadcaster extends Broadcaster
{
/**
* The AblyRest SDK instance.
*
* @var \Ably\AblyRest
*/
protected $ably;

/**
* Create a new broadcaster instance.
*
* @param \Ably\AblyRest $ably
* @return void
*/
public function __construct(AblyRest $ably)
{
$this->ably = $ably;
}

/**
* Authenticate the incoming request for a given channel.
*
* @param \Illuminate\Http\Request $request
* @return mixed
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
*/
public function auth($request)
{
$channelName = $this->normalizeChannelName($request->channel_name);

if (empty($request->channel_name) ||
($this->isGuardedChannel($request->channel_name) &&
! $this->retrieveUser($request, $channelName))) {
throw new AccessDeniedHttpException;
}

return parent::verifyUserCanAccessChannel(
$request, $channelName
);
}

/**
* Return the valid authentication response.
*
* @param \Illuminate\Http\Request $request
* @param mixed $result
* @return mixed
*/
public function validAuthenticationResponse($request, $result)
{
if (Str::startsWith($request->channel_name, 'private')) {
$signature = $this->generateAblySignature(
$request->channel_name, $request->socket_id
);

return ['auth' => $this->getPublicToken().':'.$signature];
}

$channelName = $this->normalizeChannelName($request->channel_name);

$signature = $this->generateAblySignature(
$request->channel_name,
$request->socket_id,
$userData = array_filter([
'user_id' => $this->retrieveUser($request, $channelName)->getAuthIdentifier(),
'user_info' => $result,
])
);

return [
'auth' => $this->getPublicToken().':'.$signature,
'channel_data' => json_encode($userData),
];
}

/**
* Generate the signature needed for Ably authentication headers.
*
* @param string $channelName
* @param string $socketId
* @param array|null $userData
* @return string
*/
public function generateAblySignature($channelName, $socketId, $userData = null)
{
return hash_hmac(
'sha256',
sprintf('%s:%s%s', $socketId, $channelName, $userData ? ':'.json_encode($userData) : ''),
$this->getPrivateToken(),
);
}

/**
* Broadcast the given event.
*
* @param array $channels
* @param string $event
* @param array $payload
* @return void
*/
public function broadcast(array $channels, $event, array $payload = [])
{
foreach ($this->formatChannels($channels) as $channel) {
$this->ably->channels->get($channel)->publish($event, $payload);
}
}

/**
* Return true if channel is protected by authentication.
*
* @param string $channel
* @return bool
*/
public function isGuardedChannel($channel)
{
return Str::startsWith($channel, ['private-', 'presence-']);
}

/**
* Remove prefix from channel name.
*
* @param string $channel
* @return string
*/
public function normalizeChannelName($channel)
{
if ($this->isGuardedChannel($channel)) {
return Str::startsWith($channel, 'private-')
? Str::replaceFirst('private-', '', $channel)
: Str::replaceFirst('presence-', '', $channel);
}

return $channel;
}

/**
* Format the channel array into an array of strings.
*
* @param array $channels
* @return array
*/
protected function formatChannels(array $channels)
{
return array_map(function ($channel) {
$channel = (string) $channel;

if (Str::startsWith($channel, ['private-', 'presence-'])) {
return Str::startsWith($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.
*
* @return mixed
*/
protected function getPublicToken()
{
return Str::before($this->ably->options->key, ':');
}

/**
* Get the private token value from the Ably key.
*
* @return mixed
*/
protected function getPrivateToken()
{
return Str::after($this->ably->options->key, ':');
}

/**
* Get the underlying Ably SDK instance.
*
* @return \Ably\AblyRest
*/
public function getAbly()
{
return $this->ably;
}
}
149 changes: 149 additions & 0 deletions tests/Broadcasting/AblyBroadcasterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

namespace Illuminate\Tests\Broadcasting;

use Illuminate\Broadcasting\Broadcasters\AblyBroadcaster;
use Illuminate\Http\Request;
use Mockery as m;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class AblyBroadcasterTest extends TestCase
{
/**
* @var \Illuminate\Broadcasting\Broadcasters\AblyBroadcaster
*/
public $broadcaster;

public $ably;

protected function setUp(): void
{
parent::setUp();

$this->ably = m::mock('Ably\AblyRest');
$this->ably->options = (object) ['key' => 'abcd:efgh'];

$this->broadcaster = m::mock(AblyBroadcaster::class, [$this->ably])->makePartial();
}

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->channel_name = $channel;
$request->socket_id = 'abcd.1234';

$request->shouldReceive('input')
->with('callback', false)
->andReturn(false);

$user = m::mock('User');
$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->channel_name = $channel;

$request->shouldReceive('user')
->andReturn(null);

return $request;
}
}

0 comments on commit e0f3f8e

Please sign in to comment.