diff --git a/composer.json b/composer.json index a2f57e83..532f5aaa 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "Illuminate\\Events\\": "src/event/illuminate/", "SwooleTW\\Hyperf\\": "src/framework/src/", "SwooleTW\\Hyperf\\Auth\\": "src/auth/src/", + "SwooleTW\\Hyperf\\Broadcasting\\": "src/broadcasting/src/", "SwooleTW\\Hyperf\\Bus\\": "src/bus/src/", "SwooleTW\\Hyperf\\Cache\\": "src/cache/src/", "SwooleTW\\Hyperf\\Config\\": "src/config/src/", @@ -110,6 +111,7 @@ }, "replace": { "swooletw/hyperf-auth": "self.version", + "swooletw/hyperf-broadcasting": "self.version", "swooletw/hyperf-bus": "self.version", "swooletw/hyperf-cache": "self.version", "swooletw/hyperf-config": "self.version", @@ -142,9 +144,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", "filp/whoops": "^2.15", "friendsofphp/php-cs-fixer": "^3.57.2", @@ -161,6 +166,7 @@ "pda/pheanstalk": "v5.x-dev", "phpstan/phpstan": "^1.11.5", "phpunit/phpunit": "^10.0.7", + "pusher/pusher-php-server": "^7.2", "swoole/ide-helper": "~5.1.0" }, "config": { @@ -171,6 +177,7 @@ "config": [ "SwooleTW\\Hyperf\\ConfigProvider", "SwooleTW\\Hyperf\\Auth\\ConfigProvider", + "SwooleTW\\Hyperf\\Broadcasting\\ConfigProvider", "SwooleTW\\Hyperf\\Bus\\ConfigProvider", "SwooleTW\\Hyperf\\Cache\\ConfigProvider", "SwooleTW\\Hyperf\\Cookie\\ConfigProvider", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 9fece068..255e00f4 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -39,4 +39,4 @@ parameters: - '#Access to an undefined property SwooleTW\\Hyperf\\Queue\\Jobs\\DatabaseJobRecord::\$.*#' - '#Access to an undefined property SwooleTW\\Hyperf\\Queue\\Contracts\\Job::\$.*#' - '#Call to an undefined method Hyperf\\Database\\Query\\Builder::where[a-zA-Z0-9\\\\_]+#' - - '#Call to an undefined method Hyperf\\Database\\Query\\Builder::firstOrFail\(\)#' \ No newline at end of file + - '#Call to an undefined method Hyperf\\Database\\Query\\Builder::firstOrFail\(\)#' diff --git a/src/auth/src/Authenticatable.php b/src/auth/src/Authenticatable.php index 230b18c9..363b45f1 100644 --- a/src/auth/src/Authenticatable.php +++ b/src/auth/src/Authenticatable.php @@ -22,6 +22,14 @@ public function getAuthIdentifier(): mixed return $this->{$this->getAuthIdentifierName()}; } + /** + * Get the unique broadcast identifier for the user. + */ + public function getAuthIdentifierForBroadcasting(): mixed + { + return $this->getAuthIdentifier(); + } + /** * Get the password for the user. */ diff --git a/src/broadcasting/LICENSE.md b/src/broadcasting/LICENSE.md new file mode 100644 index 00000000..68781284 --- /dev/null +++ b/src/broadcasting/LICENSE.md @@ -0,0 +1,25 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Copyright (c) Hyperf + +Copyright (c) Laravel Hyperf + +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: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +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. diff --git a/src/broadcasting/README.md b/src/broadcasting/README.md new file mode 100644 index 00000000..d41d68aa --- /dev/null +++ b/src/broadcasting/README.md @@ -0,0 +1,2 @@ +Broadcasting for Laravel Hyperf +=== diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json new file mode 100644 index 00000000..d6eecb1d --- /dev/null +++ b/src/broadcasting/composer.json @@ -0,0 +1,64 @@ +{ + "name": "swooletw/hyperf-filesystem", + "type": "library", + "description": "The filesystem package for Hyperf.", + "license": "MIT", + "keywords": [ + "php", + "hyperf", + "swoole", + "broadcasting", + "laravel-hyperf" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@laravel-hyperf.com", + "homepage": "https://albert-chen.com" + } + ], + "support": { + "issues": "https://github.com/swooletw/hyperf-packages/issues", + "source": "https://github.com/swooletw/hyperf-packages" + }, + "autoload": { + "psr-4": { + "SwooleTW\\Hyperf\\Broadcasting": "src/" + } + }, + "require": { + "php": "^8.2", + "hyperf/collection": "~3.1.0", + "hyperf/contract": "~3.1.0", + "hyperf/http-server": "~3.1.0", + "hyperf/pool": "~3.1.0", + "hyperf/redis": "~3.1.0", + "hyperf/stringable": "~3.1.0", + "swooletw/hyperf-auth": "dev-master", + "swooletw/hyperf-bus": "dev-master", + "swooletw/hyperf-cache": "dev-master", + "swooletw/hyperf-foundation": "dev-master", + "swooletw/hyperf-framework": "dev-master", + "swooletw/hyperf-queue": "dev-master", + "swooletw/hyperf-router": "dev-master", + "swooletw/hyperf-support": "dev-master", + "swooletw/object-pool": "dev-master" + }, + "suggest": { + "ext-hash": "Required to use the Ably and Pusher broadcast drivers.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "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)." + }, + "config": { + "sort-packages": true + }, + "extra": { + "hyperf": { + "config": "SwooleTW\\Hyperf\\Broadcasting\\ConfigProvider" + }, + "branch-alias": { + "dev-main": "3.1-dev" + } + } +} diff --git a/src/broadcasting/src/AnonymousEvent.php b/src/broadcasting/src/AnonymousEvent.php new file mode 100644 index 00000000..a5b0ee6d --- /dev/null +++ b/src/broadcasting/src/AnonymousEvent.php @@ -0,0 +1,152 @@ +channels = Arr::wrap($channels); + } + + /** + * Set the connection the event should be broadcast on. + */ + public function via(string $connection): static + { + $this->connection = $connection; + + return $this; + } + + /** + * Set the name the event should be broadcast as. + */ + public function as(string $name): static + { + $this->name = $name; + + return $this; + } + + /** + * Set the payload the event should be broadcast with. + */ + public function with(array|Arrayable $payload): static + { + $this->payload = $payload instanceof Arrayable + ? $payload->toArray() + : collect($payload)->map( + fn ($p) => $p instanceof Arrayable ? $p->toArray() : $p + )->all(); + + return $this; + } + + /** + * Broadcast the event to everyone except the current user. + */ + public function toOthers(): static + { + $this->includeCurrentUser = false; + + return $this; + } + + /** + * Broadcast the event. + */ + public function sendNow(): void + { + $this->shouldBroadcastNow = true; + + $this->send(); + } + + /** + * Broadcast the event. + */ + public function send(): void + { + $broadcast = $this->broadcastManager->event($this)->via($this->connection); + + if (! $this->includeCurrentUser) { + $broadcast->toOthers(); + } + } + + /** + * Get the name the event should broadcast as. + */ + public function broadcastAs(): string + { + return $this->name ?: class_basename($this); + } + + /** + * Get the payload the event should broadcast with. + * + * @return array + */ + public function broadcastWith(): array + { + return $this->payload; + } + + /** + * Get the channels the event should broadcast on. + * + * @return Channel[]|string[] + */ + public function broadcastOn(): array + { + return $this->channels; + } + + /** + * Determine if the event should be broadcast synchronously. + */ + public function shouldBroadcastNow(): bool + { + return $this->shouldBroadcastNow; + } +} diff --git a/src/broadcasting/src/BroadcastController.php b/src/broadcasting/src/BroadcastController.php new file mode 100644 index 00000000..a233990a --- /dev/null +++ b/src/broadcasting/src/BroadcastController.php @@ -0,0 +1,32 @@ +event = $event; + $this->tries = $event->tries ?? null; + $this->timeout = $event->timeout ?? null; + $this->backoff = $event->backoff ?? null; + $this->afterCommit = $event->afterCommit ?? null; + $this->maxExceptions = $event->maxExceptions ?? null; + } + + /** + * Handle the queued job. + */ + public function handle(BroadcastingFactory $manager): void + { + $channels = Arr::wrap($this->event->broadcastOn()); + + if (empty($channels)) { + return; + } + + $name = method_exists($this->event, 'broadcastAs') + ? $this->event->broadcastAs() + : get_class($this->event); + + $connections = method_exists($this->event, 'broadcastConnections') + ? $this->event->broadcastConnections() + : [null]; + + $payload = $this->getPayloadFromEvent($this->event); + + foreach ($connections as $connection) { + $manager->connection($connection)->broadcast( + $channels, + $name, + $payload + ); + } + } + + /** + * Get the payload for the given event. + */ + protected function getPayloadFromEvent(mixed $event): array + { + if (method_exists($event, 'broadcastWith') + && ! is_null($payload = $event->broadcastWith()) + ) { + return array_merge($payload, ['socket' => data_get($event, 'socket')]); + } + + $payload = []; + + foreach ((new ReflectionClass($event))->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + $payload[$property->getName()] = $this->formatProperty($property->getValue($event)); + } + + unset($payload['broadcastQueue']); + + return $payload; + } + + /** + * Format the given value for a property. + */ + protected function formatProperty(mixed $value): mixed + { + if ($value instanceof Arrayable) { + return $value->toArray(); + } + + return $value; + } + + /** + * Get the display name for the queued job. + */ + public function displayName(): string + { + return get_class($this->event); + } + + /** + * Prepare the instance for cloning. + */ + public function __clone() + { + $this->event = clone $this->event; + } +} diff --git a/src/broadcasting/src/BroadcastException.php b/src/broadcasting/src/BroadcastException.php new file mode 100644 index 00000000..83261605 --- /dev/null +++ b/src/broadcasting/src/BroadcastException.php @@ -0,0 +1,11 @@ + ['web']]; + + $this->app->get(RouterDispatcherFactory::class)->getRouter() + ->addRoute( + ['GET', 'POST'], + '/broadcasting/auth', + [BroadcastController::class, 'authenticate'], + $attributes, + ); + } + + /** + * Register the routes for handling broadcast user authentication. + */ + public function userRoutes(?array $attributes = null): void + { + $attributes = $attributes ?: ['middleware' => ['web']]; + + $this->app->get(RouterDispatcherFactory::class)->getRouter() + ->addRoute( + ['GET', 'POST'], + '/broadcasting/user-auth', + [BroadcastController::class, 'authenticateUser'], + $attributes, + ); + } + + /** + * Register the routes for handling broadcast authentication and sockets. + * + * Alias of "routes" method. + */ + public function channelRoutes(?array $attributes = null): void + { + $this->routes($attributes); + } + + /** + * Get the socket ID for the given request. + */ + public function socket(?RequestInterface $request = null): ?string + { + $request ??= $this->app->get(RequestInterface::class); + + return $request?->header('X-Socket-ID'); + } + + /** + * Begin sending an anonymous broadcast to the given channels. + */ + public function on(array|Channel|string $channels): AnonymousEvent + { + return new AnonymousEvent($this, $channels); + } + + /** + * Begin sending an anonymous broadcast to the given private channels. + */ + public function private(string $channel): AnonymousEvent + { + return $this->on(new PrivateChannel($channel)); + } + + /** + * Begin sending an anonymous broadcast to the given presence channels. + */ + public function presence(string $channel): AnonymousEvent + { + return $this->on(new PresenceChannel($channel)); + } + + /** + * Begin broadcasting an event. + */ + public function event(mixed $event = null): PendingBroadcast + { + return new PendingBroadcast( + $this->app->get(EventDispatcherInterface::class), + $event, + ); + } + + /** + * Queue the given event for broadcast. + */ + public function queue(mixed $event): void + { + if ($event instanceof ShouldBroadcastNow + || (is_object($event) && method_exists($event, 'shouldBroadcastNow') && $event->shouldBroadcastNow()) + ) { + $this->app->get(Dispatcher::class)->dispatchNow(new BroadcastEvent(clone $event)); + return; + } + + $queue = match (true) { + method_exists($event, 'broadcastQueue') => $event->broadcastQueue(), + isset($event->broadcastQueue) => $event->broadcastQueue, + isset($event->queue) => $event->queue, + default => null, + }; + + $broadcastEvent = new BroadcastEvent(clone $event); + + if ($event instanceof ShouldBeUnique) { + $broadcastEvent = new UniqueBroadcastEvent($this->app, clone $event); + + if ($this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) { + return; + } + } + + $this->app->get('queue') + ->connection($event->connection ?? null) + ->pushOn($queue, $broadcastEvent); + } + + /** + * Determine if the broadcastable event must be unique and determine if we can acquire the necessary lock. + */ + protected function mustBeUniqueAndCannotAcquireLock(UniqueBroadcastEvent $event): bool + { + return ! (new UniqueLock( + method_exists($event, 'uniqueVia') + ? $event->uniqueVia() + : $this->app->get(Cache::class) + ))->acquire($event); + } + + /** + * Get a driver instance. + */ + public function connection(?string $driver = null): Broadcaster + { + return $this->driver($driver); + } + + /** + * Get a driver instance. + */ + public function driver(?string $name = null): Broadcaster + { + $name = $name ?: $this->getDefaultDriver(); + + return $this->drivers[$name] = $this->get($name); + } + + /** + * Attempt to get the connection from the local cache. + */ + protected function get(string $name): Broadcaster + { + return $this->drivers[$name] ?? $this->resolve($name); + } + + /** + * Resolve the given broadcaster with Pool Proxy if need. + * + * @throws InvalidArgumentException + */ + protected function resolve(string $name): Broadcaster + { + $config = $this->getConfig($name); + + if (is_null($config)) { + throw new InvalidArgumentException("Broadcast connection [{$name}] is not defined."); + } + + return in_array($config['driver'], $this->poolables) + ? $this->createPoolProxy( + $name, + fn () => $this->doResolve($config), + $config['pool'] ?? [] + ) + : $this->doResolve($config); + } + + /** + * Resolve the given broadcaster. + * + * @throws InvalidArgumentException + */ + protected function doResolve(array $config): Broadcaster + { + if (isset($this->customCreators[$config['driver']])) { + return $this->callCustomCreator($config); + } + + $driverMethod = 'create' . ucfirst($config['driver']) . 'Driver'; + + if (! method_exists($this, $driverMethod)) { + throw new InvalidArgumentException("Driver [{$config['driver']}] is not supported."); + } + + return $this->{$driverMethod}($config); + } + + /** + * Call a custom driver creator. + */ + protected function callCustomCreator(array $config): Broadcaster + { + return $this->customCreators[$config['driver']]($this->app, $config); + } + + /** + * Create an instance of the driver. + */ + protected function createReverbDriver(array $config): Broadcaster + { + return $this->createPusherDriver($config); + } + + /** + * Create an instance of the driver. + */ + protected function createPusherDriver(array $config): Broadcaster + { + return new PusherBroadcaster($this->app, $this->pusher($config)); + } + + /** + * Get a Pusher instance for the given configuration. + */ + public function pusher(array $config): Pusher + { + $guzzleClient = new GuzzleClient( + array_merge( + [ + 'connect_timeout' => 10, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, + 'timeout' => 30, + ], + $config['client_options'] ?? [], + ), + ); + + $pusher = new Pusher( + $config['key'], + $config['secret'], + $config['app_id'], + $config['options'] ?? [], + $guzzleClient, + ); + + if ($config['log'] ?? false) { + $pusher->setLogger($this->app->get(LoggerInterface::class)); + } + + return $pusher; + } + + /** + * Create an instance of the driver. + */ + protected function createAblyDriver(array $config): Broadcaster + { + return new AblyBroadcaster($this->app, $this->ably($config)); + } + + /** + * Get an Ably instance for the given configuration. + */ + public function ably(array $config): AblyRest + { + return new AblyRest($config); + } + + /** + * Create an instance of the driver. + */ + protected function createRedisDriver(array $config): Broadcaster + { + return new RedisBroadcaster( + $this->app, + $this->app->get(RedisFactory::class), + $config['connection'] ?? null, + $this->app->get(ConfigInterface::class)->get('database.redis.options.prefix', ''), + ); + } + + /** + * Create an instance of the driver. + */ + protected function createLogDriver(array $config): Broadcaster + { + return new LogBroadcaster($this->app->get(LoggerInterface::class)); + } + + /** + * Create an instance of the driver. + */ + protected function createNullDriver(array $config): Broadcaster + { + return new NullBroadcaster(); + } + + /** + * Get the connection configuration. + */ + protected function getConfig(string $name): ?array + { + if (! is_null($name) && $name !== 'null') { + return $this->app->get(ConfigInterface::class)->get("broadcasting.connections.{$name}"); + } + + return ['driver' => 'null']; + } + + /** + * Get the default driver name. + */ + public function getDefaultDriver(): string + { + return $this->app->get(ConfigInterface::class)->get('broadcasting.default'); + } + + /** + * Set the default driver name. + */ + public function setDefaultDriver(string $name): void + { + $this->app->get(ConfigInterface::class)->set('broadcasting.default', $name); + } + + /** + * Disconnect the given disk and remove from local cache. + */ + public function purge(?string $name = null): void + { + $name ??= $this->getDefaultDriver(); + + unset($this->drivers[$name]); + } + + /** + * Register a custom driver creator Closure. + */ + public function extend(string $driver, Closure $callback): static + { + $this->customCreators[$driver] = $callback; + + return $this; + } + + /** + * Get the application instance used by the manager. + */ + public function getApplication(): ContainerInterface + { + return $this->app; + } + + /** + * Set the application instance used by the manager. + */ + public function setApplication(ContainerInterface $app): static + { + $this->app = $app; + + return $this; + } + + /** + * Forget all of the resolved driver instances. + */ + public function forgetDrivers(): static + { + $this->drivers = []; + + return $this; + } + + /** + * Dynamically call the default driver instance. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->driver()->{$method}(...$parameters); + } +} diff --git a/src/broadcasting/src/BroadcastPoolProxy.php b/src/broadcasting/src/BroadcastPoolProxy.php new file mode 100644 index 00000000..e21b7db3 --- /dev/null +++ b/src/broadcasting/src/BroadcastPoolProxy.php @@ -0,0 +1,33 @@ +__call(__FUNCTION__, func_get_args()); + } + + /** + * Return the valid authentication response. + */ + public function validAuthenticationResponse(RequestInterface $request, mixed $result): mixed + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + /** + * Broadcast the given event. + */ + public function broadcast(array $channels, string $event, array $payload = []): void + { + $this->__call(__FUNCTION__, func_get_args()); + } +} diff --git a/src/broadcasting/src/Broadcasters/AblyBroadcaster.php b/src/broadcasting/src/Broadcasters/AblyBroadcaster.php new file mode 100644 index 00000000..59522354 --- /dev/null +++ b/src/broadcasting/src/Broadcasters/AblyBroadcaster.php @@ -0,0 +1,201 @@ +input('channel_name'); + $normalizeChannelName = $this->normalizeChannelName($channelName); + + if (empty($channelName) + || ($this->isGuardedChannel($channelName) && ! $this->retrieveUser($normalizeChannelName)) + ) { + throw new AccessDeniedHttpException(); + } + + return parent::verifyUserCanAccessChannel( + $request, + $normalizeChannelName + ); + } + + /** + * Return the valid authentication response. + */ + public function validAuthenticationResponse(RequestInterface $request, mixed $result): mixed + { + $channelName = $request->input('channel_name'); + $socketId = $request->input('socket_id'); + + if (str_starts_with($channelName, 'private')) { + $signature = $this->generateAblySignature($channelName, $socketId); + + return ['auth' => $this->getPublicToken() . ':' . $signature]; + } + + $user = $this->retrieveUser( + $this->normalizeChannelName($channelName) + ); + + $broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting') + ? $user->getAuthIdentifierForBroadcasting() + : $user->getAuthIdentifier(); + + $signature = $this->generateAblySignature( + $channelName, + $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 $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( // @phpstan-ignore-line + $this->buildAblyMessage($event, $payload) // @phpstan-ignore-line + ); // @phpstan-ignore-line + } + } 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/src/broadcasting/src/Broadcasters/Broadcaster.php b/src/broadcasting/src/Broadcasters/Broadcaster.php new file mode 100644 index 00000000..0a6503f8 --- /dev/null +++ b/src/broadcasting/src/Broadcasters/Broadcaster.php @@ -0,0 +1,295 @@ +authenticatedUserCallback) { + return $this->authenticatedUserCallback->__invoke($request); + } + + return null; + } + + /** + * Register the user retrieval callback used to authenticate connections. + * + * See: https://pusher.com/docs/channels/library_auth_reference/auth-signatures/#user-authentication. + */ + public function resolveAuthenticatedUserUsing(Closure $callback): void + { + $this->authenticatedUserCallback = $callback; + } + + /** + * Register a channel authenticator. + */ + public function channel(HasBroadcastChannel|string $channel, callable|string $callback, array $options = []): static + { + if ($channel instanceof HasBroadcastChannel) { + $channel = $channel->broadcastChannelRoute(); + } elseif (is_string($channel) && class_exists($channel) && is_a($channel, HasBroadcastChannel::class, true)) { + $channel = (new $channel())->broadcastChannelRoute(); + } + + $this->channels[$channel] = $callback; + + $this->channelOptions[$channel] = $options; + + return $this; + } + + /** + * Authenticate the incoming request for a given channel. + * + * @throws AccessDeniedHttpException + */ + protected function verifyUserCanAccessChannel(RequestInterface $request, string $channel): mixed + { + foreach ($this->channels as $pattern => $callback) { + if (! $this->channelNameMatchesPattern($channel, $pattern)) { + continue; + } + + $parameters = $this->extractAuthParameters($pattern, $channel, $callback); + + $handler = $this->normalizeChannelHandlerToCallable($callback); + + $result = $handler($this->retrieveUser($channel), ...$parameters); + + if ($result === false) { + throw new AccessDeniedHttpException(); + } + if ($result) { + return $this->validAuthenticationResponse($request, $result); + } + } + + throw new AccessDeniedHttpException(); + } + + /** + * Extract the parameters from the given pattern and channel. + */ + protected function extractAuthParameters(string $pattern, string $channel, callable|string $callback): array + { + $callbackParameters = $this->extractParameters($callback); + + return collect($this->extractChannelKeys($pattern, $channel))->reject(function ($value, $key) { + return is_numeric($key); + })->map(function ($value, $key) use ($callbackParameters) { + return $this->resolveBinding($key, $value, $callbackParameters); + })->values()->all(); + } + + /** + * Extracts the parameters out of what the user passed to handle the channel authentication. + * + * @return ReflectionParameter[] + */ + protected function extractParameters(callable|string $callback): array + { + return match (true) { + is_callable($callback) => (new ReflectionFunction($callback))->getParameters(), + is_string($callback) => $this->extractParametersFromClass($callback), + default => [], + }; + } + + /** + * Extracts the parameters out of a class channel's "join" method. + * + * @return ReflectionParameter[] + * + * @throws Exception + */ + protected function extractParametersFromClass(string $callback): array + { + $reflection = new ReflectionClass($callback); + + if (! $reflection->hasMethod('join')) { + throw new Exception('Class based channel must define a "join" method.'); + } + + return $reflection->getMethod('join')->getParameters(); + } + + /** + * Extract the channel keys from the incoming channel name. + */ + protected function extractChannelKeys(string $pattern, string $channel): array + { + preg_match('/^' . preg_replace('/\{(.*?)\}/', '(?<$1>[^\.]+)', $pattern) . '/', $channel, $keys); + + return $keys; + } + + /** + * Resolve the given parameter binding. + */ + protected function resolveBinding(string $key, string $value, array $callbackParameters): mixed + { + return $this->resolveImplicitBindingIfPossible( + $key, + $value, + $callbackParameters + ); + } + + /** + * Resolve an implicit parameter binding if applicable. + * + * @throws AccessDeniedHttpException + */ + protected function resolveImplicitBindingIfPossible(string $key, string $value, array $callbackParameters): mixed + { + foreach ($callbackParameters as $parameter) { + if (! $this->isImplicitlyBindable($key, $parameter)) { + continue; + } + + $className = Reflector::getParameterClassName($parameter); + + if (is_null($model = (new $className())->resolveRouteBinding($value))) { + throw new AccessDeniedHttpException(); + } + + return $model; + } + + return $value; + } + + /** + * Determine if a given key and parameter is implicitly bindable. + */ + protected function isImplicitlyBindable(string $key, ReflectionParameter $parameter): bool + { + return $parameter->getName() === $key + && Reflector::isParameterSubclassOf($parameter, UrlRoutable::class); + } + + /** + * Format the channel array into an array of strings. + */ + protected function formatChannels(array $channels): array + { + return array_map(function ($channel) { + return (string) $channel; + }, $channels); + } + + /** + * Normalize the given callback into a callable. + * + * @param mixed $callback + * @return callable + */ + protected function normalizeChannelHandlerToCallable($callback) + { + return is_callable($callback) ? $callback : function (...$args) use ($callback) { + return $this->container->get($callback)->join(...$args); + }; + } + + /** + * Retrieve the authenticated user using the configured guard (if any). + */ + protected function retrieveUser(string $channel): mixed + { + $options = $this->retrieveChannelOptions($channel); + $guards = $options['guards'] ?? null; + + $auth = $this->container->get(AuthManager::class); + + if (is_null($guards)) { + return $auth->user(); + } + + foreach (Arr::wrap($guards) as $guard) { + $user = $auth->guard($guard)->user(); + if ($user) { + return $user; + } + } + + return null; + } + + /** + * Retrieve options for a certain channel. + */ + protected function retrieveChannelOptions(string $channel): array + { + foreach ($this->channelOptions as $pattern => $options) { + if (! $this->channelNameMatchesPattern($channel, $pattern)) { + continue; + } + + return $options; + } + + return []; + } + + /** + * Check if the channel name from the request matches a pattern from registered channels. + */ + protected function channelNameMatchesPattern(string $channel, string $pattern): bool + { + return (bool) preg_match('/^' . preg_replace('/\{(.*?)\}/', '([^\.]+)', $pattern) . '$/', $channel); + } + + /** + * Get all of the registered channels. + */ + public function getChannels(): Collection + { + return collect($this->channels); + } +} diff --git a/src/broadcasting/src/Broadcasters/LogBroadcaster.php b/src/broadcasting/src/Broadcasters/LogBroadcaster.php new file mode 100644 index 00000000..8903115f --- /dev/null +++ b/src/broadcasting/src/Broadcasters/LogBroadcaster.php @@ -0,0 +1,38 @@ +formatChannels($channels)); + + $payload = json_encode($payload, JSON_PRETTY_PRINT); + + $this->logger->info("Broadcasting [{$event}] on channels [{$channels}] with payload:" . PHP_EOL . $payload); + } +} diff --git a/src/broadcasting/src/Broadcasters/NullBroadcaster.php b/src/broadcasting/src/Broadcasters/NullBroadcaster.php new file mode 100644 index 00000000..c454e9f6 --- /dev/null +++ b/src/broadcasting/src/Broadcasters/NullBroadcaster.php @@ -0,0 +1,24 @@ +pusher, 'authenticateUser')) { + return json_decode( + $this->pusher->authenticateUser($request->input('socket_id'), $user), + true, + ); + } + + $settings = $this->pusher->getSettings(); + $encodedUser = json_encode($user); + $decodedString = "{$request->input('socket_id')}::user::{$encodedUser}"; + + $auth = $settings['auth_key'] . ':' . hash_hmac( + 'sha256', + $decodedString, + $settings['secret'] + ); + + return [ + 'auth' => $auth, + 'user_data' => $encodedUser, + ]; + } + + /** + * Authenticate the incoming request for a given channel. + * + * @throws AccessDeniedHttpException + */ + public function auth(RequestInterface $request): mixed + { + $channelName = $request->input('channel_name'); + $normalizeChannelName = $this->normalizeChannelName($channelName); + + if (empty($channelName) + || ($this->isGuardedChannel($channelName) && ! $this->retrieveUser($normalizeChannelName)) + ) { + throw new AccessDeniedHttpException(); + } + + return parent::verifyUserCanAccessChannel( + $request, + $normalizeChannelName + ); + } + + /** + * Return the valid authentication response. + */ + public function validAuthenticationResponse(RequestInterface $request, mixed $result): mixed + { + $channelName = $request->input('channel_name'); + $socketId = $request->input('socket_id'); + + if (str_starts_with($channelName, 'private')) { + return $this->decodePusherResponse( + $this->pusher->authorizeChannel($channelName, $socketId), + ); + } + + $user = $this->retrieveUser( + $this->normalizeChannelName($channelName) + ); + + $broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting') + ? $user->getAuthIdentifierForBroadcasting() + : $user->getAuthIdentifier(); + + return $this->decodePusherResponse( + $this->pusher->authorizePresenceChannel($channelName, $socketId, (string) $broadcastIdentifier, $result) + ); + } + + /** + * Decode the given Pusher response. + */ + protected function decodePusherResponse(mixed $response): array + { + return json_decode($response, true); + } + + /** + * Broadcast the given event. + */ + public function broadcast(array $channels, string $event, array $payload = []): void + { + $socket = Arr::pull($payload, 'socket'); + + $parameters = $socket !== null ? ['socket_id' => $socket] : []; + + $channels = Collection::make($this->formatChannels($channels)); + + try { + $channels->chunk(100)->each(function ($channels) use ($event, $payload, $parameters) { + $this->pusher->trigger($channels->toArray(), $event, $payload, $parameters); + }); + } catch (ApiErrorException $e) { + throw new BroadcastException( + sprintf('Pusher error: %s.', $e->getMessage()) + ); + } + } + + /** + * Get the Pusher SDK instance. + */ + public function getPusher(): Pusher + { + return $this->pusher; + } + + /** + * Set the Pusher SDK instance. + */ + public function setPusher(Pusher $pusher): void + { + $this->pusher = $pusher; + } +} diff --git a/src/broadcasting/src/Broadcasters/RedisBroadcaster.php b/src/broadcasting/src/Broadcasters/RedisBroadcaster.php new file mode 100644 index 00000000..9e2731e2 --- /dev/null +++ b/src/broadcasting/src/Broadcasters/RedisBroadcaster.php @@ -0,0 +1,134 @@ +input('channel_name'); + $normalizeChannelName = $this->normalizeChannelName( + str_replace($this->prefix, '', $channelName) + ); + + if (empty($channelName) + || ($this->isGuardedChannel($channelName) && ! $this->retrieveUser($normalizeChannelName)) + ) { + throw new AccessDeniedHttpException(); + } + + return parent::verifyUserCanAccessChannel( + $request, + $normalizeChannelName + ); + } + + /** + * Return the valid authentication response. + */ + public function validAuthenticationResponse(RequestInterface $request, mixed $result): mixed + { + if (is_bool($result)) { + return json_encode($result); + } + + $channelName = $this->normalizeChannelName($request->input('channel_name')); + + $user = $this->retrieveUser($channelName); + + $broadcastIdentifier = method_exists($user, 'getAuthIdentifierForBroadcasting') + ? $user->getAuthIdentifierForBroadcasting() + : $user->getAuthIdentifier(); + + return json_encode(['channel_data' => [ + 'user_id' => $broadcastIdentifier, + 'user_info' => $result, + ]]); + } + + /** + * Broadcast the given event. + * + * @throws BroadcastException + */ + public function broadcast(array $channels, string $event, array $payload = []): void + { + if (empty($channels)) { + return; + } + + $connection = $this->factory->get($this->connection); + + $payload = json_encode([ + 'event' => $event, + 'data' => $payload, + 'socket' => Arr::pull($payload, 'socket'), + ]); + + try { + $connection->eval( + $this->broadcastMultipleChannelsScript(), + [$payload, ...$this->formatChannels($channels)], + 0, + ); + } catch (ConnectionException|RedisException $e) { + throw new BroadcastException( + sprintf('Redis error: %s.', $e->getMessage()) + ); + } + } + + /** + * Get the Lua script for broadcasting to multiple channels. + * + * ARGV[1] - The payload + * ARGV[2...] - The channels + */ + protected function broadcastMultipleChannelsScript(): string + { + return <<<'LUA' + for i = 2, #ARGV do + redis.call('publish', ARGV[i], ARGV[1]) + end + LUA; + } + + /** + * Format the channel array into an array of strings. + */ + protected function formatChannels(array $channels): array + { + return array_map(function ($channel) { + return $this->prefix . $channel; + }, parent::formatChannels($channels)); + } +} diff --git a/src/broadcasting/src/Broadcasters/UsePusherChannelConventions.php b/src/broadcasting/src/Broadcasters/UsePusherChannelConventions.php new file mode 100644 index 00000000..54ae47fe --- /dev/null +++ b/src/broadcasting/src/Broadcasters/UsePusherChannelConventions.php @@ -0,0 +1,32 @@ +name = $name instanceof HasBroadcastChannel ? $name->broadcastChannel() : $name; + } + + /** + * Convert the channel instance to a string. + */ + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/broadcasting/src/ConfigProvider.php b/src/broadcasting/src/ConfigProvider.php new file mode 100644 index 00000000..ecaeab3c --- /dev/null +++ b/src/broadcasting/src/ConfigProvider.php @@ -0,0 +1,20 @@ + [ + Factory::class => fn (ContainerInterface $container) => new BroadcastManager($container), + ], + ]; + } +} diff --git a/src/broadcasting/src/Contracts/Broadcaster.php b/src/broadcasting/src/Contracts/Broadcaster.php new file mode 100644 index 00000000..4d5462dc --- /dev/null +++ b/src/broadcasting/src/Contracts/Broadcaster.php @@ -0,0 +1,25 @@ +broadcastConnection = is_null($connection) + ? [null] + : Arr::wrap($connection); + + return $this; + } + + /** + * Get the broadcaster connections the event should be broadcast on. + */ + public function broadcastConnections(): array + { + return $this->broadcastConnection; + } +} diff --git a/src/broadcasting/src/InteractsWithSockets.php b/src/broadcasting/src/InteractsWithSockets.php new file mode 100644 index 00000000..454c8afe --- /dev/null +++ b/src/broadcasting/src/InteractsWithSockets.php @@ -0,0 +1,35 @@ +socket = Broadcast::socket(); + + return $this; + } + + /** + * Broadcast the event to everyone. + */ + public function broadcastToEveryone(): static + { + $this->socket = null; + + return $this; + } +} diff --git a/src/broadcasting/src/PendingBroadcast.php b/src/broadcasting/src/PendingBroadcast.php new file mode 100644 index 00000000..8d760ddc --- /dev/null +++ b/src/broadcasting/src/PendingBroadcast.php @@ -0,0 +1,51 @@ +event, 'broadcastVia')) { + $this->event->broadcastVia($connection); + } + + return $this; + } + + /** + * Broadcast the event to everyone except the current user. + */ + public function toOthers(): static + { + if (method_exists($this->event, 'dontBroadcastToCurrentUser')) { + $this->event->dontBroadcastToCurrentUser(); + } + + return $this; + } + + /** + * Handle the object's destruction. + */ + public function __destruct() + { + $this->eventDispatcher->dispatch($this->event); + } +} diff --git a/src/broadcasting/src/PresenceChannel.php b/src/broadcasting/src/PresenceChannel.php new file mode 100644 index 00000000..38b00332 --- /dev/null +++ b/src/broadcasting/src/PresenceChannel.php @@ -0,0 +1,16 @@ +broadcastChannel() : $name; + + parent::__construct('private-' . $name); + } +} diff --git a/src/broadcasting/src/UniqueBroadcastEvent.php b/src/broadcasting/src/UniqueBroadcastEvent.php new file mode 100644 index 00000000..a1287ce7 --- /dev/null +++ b/src/broadcasting/src/UniqueBroadcastEvent.php @@ -0,0 +1,61 @@ +container = $container; + + $this->uniqueId = get_class($event); + + if (method_exists($event, 'uniqueId')) { + $this->uniqueId .= $event->uniqueId(); + } elseif (property_exists($event, 'uniqueId')) { + $this->uniqueId .= $event->uniqueId; + } + + if (method_exists($event, 'uniqueFor')) { + $this->uniqueFor = $event->uniqueFor(); + } elseif (property_exists($event, 'uniqueFor')) { + $this->uniqueFor = $event->uniqueFor; + } + + parent::__construct($event); + } + + /** + * Resolve the cache implementation that should manage the event's uniqueness. + */ + public function uniqueVia(): Cache + { + return method_exists($this->event, 'uniqueVia') + ? $this->event->uniqueVia() + : $this->container->get(Cache::class); + } +} diff --git a/src/event/src/EventDispatcher.php b/src/event/src/EventDispatcher.php index 3c2c71db..b392fbde 100644 --- a/src/event/src/EventDispatcher.php +++ b/src/event/src/EventDispatcher.php @@ -14,6 +14,8 @@ use Psr\EventDispatcher\StoppableEventInterface; use Psr\Log\LoggerInterface; use ReflectionClass; +use SwooleTW\Hyperf\Broadcasting\Contracts\Factory as BroadcastFactory; +use SwooleTW\Hyperf\Broadcasting\Contracts\ShouldBroadcast; use SwooleTW\Hyperf\Database\TransactionManager; use SwooleTW\Hyperf\Event\Contracts\EventDispatcherContract; use SwooleTW\Hyperf\Event\Contracts\ListenerProviderContract; @@ -138,10 +140,14 @@ public function until(object|string $event, mixed $payload = []): object|string } /** - * Fire an event and call the listeners. + * Broadcast an event and call the listeners. */ protected function invokeListeners(object|string $event, mixed $payload, bool $halt = false): object|string { + if ($this->shouldBroadcast($event)) { + $this->broadcastEvent($event); + } + foreach ($this->getListeners($event) as $listener) { $response = $listener($event, $payload); @@ -155,6 +161,32 @@ protected function invokeListeners(object|string $event, mixed $payload, bool $h return $event; } + /** + * Determine if the payload has a broadcastable event. + */ + protected function shouldBroadcast(object|string $event): bool + { + return is_object($event) + && $event instanceof ShouldBroadcast + && $this->broadcastWhen($event); + } + + /** + * Check if the event should be broadcasted by the condition. + */ + protected function broadcastWhen(mixed $event): bool + { + return method_exists($event, 'broadcastWhen') ? $event->broadcastWhen() : true; + } + + /** + * Broadcast the given event class. + */ + protected function broadcastEvent(ShouldBroadcast $event): void + { + $this->container->get(BroadcastFactory::class)->queue($event); + } + /** * Get all of the listeners for a given event name. */ diff --git a/src/foundation/src/Events/Dispatchable.php b/src/foundation/src/Events/Dispatchable.php index a2c4fe6e..27dbd63c 100644 --- a/src/foundation/src/Events/Dispatchable.php +++ b/src/foundation/src/Events/Dispatchable.php @@ -4,6 +4,8 @@ namespace SwooleTW\Hyperf\Foundation\Events; +use SwooleTW\Hyperf\Broadcasting\PendingBroadcast; + trait Dispatchable { /** @@ -19,9 +21,7 @@ public static function dispatch(): mixed */ public static function dispatchIf(bool $boolean, mixed ...$arguments): mixed { - if ($boolean) { - return event(new static(...$arguments)); - } + return $boolean ? event(new static(...$arguments)) : null; } /** @@ -29,8 +29,14 @@ public static function dispatchIf(bool $boolean, mixed ...$arguments): mixed */ public static function dispatchUnless(bool $boolean, mixed ...$arguments): mixed { - if (! $boolean) { - return event(new static(...$arguments)); - } + return $boolean ? null : event(new static(...$arguments)); + } + + /** + * Broadcast the event with the given arguments. + */ + public static function broadcast(): PendingBroadcast + { + return broadcast(new static(...func_get_args())); } } diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index 46abc1fd..098cac9e 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -16,6 +16,8 @@ use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; use SwooleTW\Hyperf\Auth\Contracts\Gate; +use SwooleTW\Hyperf\Broadcasting\Contracts\Factory as BroadcastFactory; +use SwooleTW\Hyperf\Broadcasting\PendingBroadcast; use SwooleTW\Hyperf\Bus\PendingClosureDispatch; use SwooleTW\Hyperf\Bus\PendingDispatch; use SwooleTW\Hyperf\Cookie\Contracts\Cookie as CookieContract; @@ -108,6 +110,16 @@ function app_path(string $path = ''): string } } +if (! function_exists('broadcast')) { + /** + * Begin broadcasting an event. + */ + function broadcast(mixed $event = null): PendingBroadcast + { + return app(BroadcastFactory::class)->event($event); + } +} + if (! function_exists('database_path')) { /** * Get the path to the database folder. diff --git a/src/support/src/Facades/Broadcast.php b/src/support/src/Facades/Broadcast.php new file mode 100644 index 00000000..5f6e1dd6 --- /dev/null +++ b/src/support/src/Facades/Broadcast.php @@ -0,0 +1,50 @@ +container = m::mock(ContainerInterface::class); + $this->ably = m::mock(AblyRest::class, ['abcd:efg']); + $this->broadcaster = m::mock(AblyBroadcaster::class, [$this->container, $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') + ); + } + + protected function getMockRequestWithUserForChannel(string $channel): Request + { + $request = m::mock(Request::class); + $request->shouldReceive('input')->with('channel_name')->andReturn($channel); + + $user = m::mock('User'); + $user->shouldReceive('getAuthIdentifier')->andReturn(42); + + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('user')->andReturn($user); + + $this->container->shouldReceive('get') + ->with(AuthManager::class) + ->andReturn($authManager); + + return $request; + } + + protected function getMockRequestWithoutUserForChannel(string $channel): Request + { + $request = m::mock(Request::class); + $request->shouldReceive('input')->with('channel_name')->andReturn($channel); + + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('user')->andReturn(null); + + $this->container->shouldReceive('get') + ->with(AuthManager::class) + ->andReturn($authManager); + + return $request; + } +} diff --git a/tests/Broadcasting/BroadcastEventTest.php b/tests/Broadcasting/BroadcastEventTest.php new file mode 100644 index 00000000..29a81505 --- /dev/null +++ b/tests/Broadcasting/BroadcastEventTest.php @@ -0,0 +1,116 @@ +shouldReceive('broadcast')->once()->with( + ['test-channel'], + TestBroadcastEvent::class, + ['firstName' => 'Taylor', 'lastName' => 'Otwell', 'collection' => ['foo' => 'bar']] + ); + + $manager = m::mock(BroadcastingFactory::class); + + $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); + + $event = new TestBroadcastEvent(); + + (new BroadcastEvent($event))->handle($manager); + } + + public function testManualParameterSpecification() + { + $broadcaster = m::mock(Broadcaster::class); + + $broadcaster->shouldReceive('broadcast')->once()->with( + ['test-channel'], + TestBroadcastEventWithManualData::class, + ['name' => 'Taylor', 'socket' => null] + ); + + $manager = m::mock(BroadcastingFactory::class); + + $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); + + $event = new TestBroadcastEventWithManualData(); + + (new BroadcastEvent($event))->handle($manager); + } + + public function testSpecificBroadcasterGiven() + { + $broadcaster = m::mock(Broadcaster::class); + + $broadcaster->shouldReceive('broadcast')->once(); + + $manager = m::mock(BroadcastingFactory::class); + + $manager->shouldReceive('connection')->once()->with('log')->andReturn($broadcaster); + + $event = new TestBroadcastEventWithSpecificBroadcaster(); + + (new BroadcastEvent($event))->handle($manager); + } +} + +class TestBroadcastEvent +{ + public $firstName = 'Taylor'; + + public $lastName = 'Otwell'; + + public $collection; + + private $title = 'Developer'; + + public function __construct() + { + $this->collection = collect(['foo' => 'bar']); + } + + public function broadcastOn() + { + return ['test-channel']; + } +} + +class TestBroadcastEventWithManualData extends TestBroadcastEvent +{ + public function broadcastWith() + { + return ['name' => 'Taylor']; + } +} + +class TestBroadcastEventWithSpecificBroadcaster extends TestBroadcastEvent +{ + use InteractsWithBroadcasting; + + public function __construct() + { + $this->broadcastVia('log'); + } +} diff --git a/tests/Broadcasting/BroadcasterTest.php b/tests/Broadcasting/BroadcasterTest.php new file mode 100644 index 00000000..d7718cb9 --- /dev/null +++ b/tests/Broadcasting/BroadcasterTest.php @@ -0,0 +1,488 @@ +container = m::mock(ContainerInterface::class); + + $this->broadcaster = new FakeBroadcaster($this->container); + } + + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + } + + public function testExtractingParametersWhileCheckingForUserAccess() + { + Booted::$container[BroadcasterTestEloquentModelStub::class] = true; + + $callback = function ($user, BroadcasterTestEloquentModelStub $model, $nonModel) { + }; + $parameters = $this->broadcaster->extractAuthParameters('asd.{model}.{nonModel}', 'asd.1.something', $callback); + $this->assertEquals(['model.1.instance', 'something'], $parameters); + + $callback = function ($user, BroadcasterTestEloquentModelStub $model, BroadcasterTestEloquentModelStub $model2, $something) { + }; + $parameters = $this->broadcaster->extractAuthParameters('asd.{model}.{model2}.{nonModel}', 'asd.1.uid.something', $callback); + $this->assertEquals(['model.1.instance', 'model.uid.instance', 'something'], $parameters); + + $callback = function ($user) { + }; + $parameters = $this->broadcaster->extractAuthParameters('asd', 'asd', $callback); + $this->assertEquals([], $parameters); + + $callback = function ($user, $something) { + }; + $parameters = $this->broadcaster->extractAuthParameters('asd', 'asd', $callback); + $this->assertEquals([], $parameters); + } + + public function testCanUseChannelClasses() + { + $parameters = $this->broadcaster->extractAuthParameters('asd.{model}.{nonModel}', 'asd.1.something', DummyBroadcastingChannel::class); + $this->assertEquals(['model.1.instance', 'something'], $parameters); + } + + public function testUnknownChannelAuthHandlerTypeThrowsException() + { + $this->expectException(Exception::class); + + $this->broadcaster->extractAuthParameters('asd.{model}.{nonModel}', 'asd.1.something', 'notClassString'); + } + + public function testCanRegisterChannelsAsClasses() + { + $this->broadcaster->channel('something', function () { + }); + + $this->broadcaster->channel('somethingelse', DummyBroadcastingChannel::class); + } + + public function testNotFoundThrowsHttpException() + { + Booted::$container[BroadcasterTestEloquentModelNotFoundStub::class] = true; + + $this->expectException(HttpException::class); + + $callback = function ($user, BroadcasterTestEloquentModelNotFoundStub $model) { + }; + $this->broadcaster->extractAuthParameters('asd.{model}', 'asd.1', $callback); + } + + public function testCanRegisterChannelsWithoutOptions() + { + $this->broadcaster->channel('somechannel', function () { + }); + } + + public function testCanRegisterChannelsWithOptions() + { + $options = ['a' => ['b', 'c']]; + $this->broadcaster->channel('somechannel', function () { + }, $options); + } + + public function testCanRetrieveChannelsOptions() + { + $options = ['a' => ['b', 'c']]; + $this->broadcaster->channel('somechannel', function () { + }, $options); + + $this->assertEquals( + $options, + $this->broadcaster->retrieveChannelOptions('somechannel') + ); + } + + public function testCanRetrieveChannelsOptionsUsingAChannelNameContainingArgs() + { + $options = ['a' => ['b', 'c']]; + $this->broadcaster->channel('somechannel.{id}.test.{text}', function () { + }, $options); + + $this->assertEquals( + $options, + $this->broadcaster->retrieveChannelOptions('somechannel.23.test.mytext') + ); + } + + public function testCanRetrieveChannelsOptionsWhenMultipleChannelsAreRegistered() + { + $options = ['a' => ['b', 'c']]; + $this->broadcaster->channel('somechannel', function () { + }); + $this->broadcaster->channel('someotherchannel', function () { + }, $options); + + $this->assertEquals( + $options, + $this->broadcaster->retrieveChannelOptions('someotherchannel') + ); + } + + public function testDontRetrieveChannelsOptionsWhenChannelDoesntExists() + { + $options = ['a' => ['b', 'c']]; + $this->broadcaster->channel('somechannel', function () { + }, $options); + + $this->assertEquals( + [], + $this->broadcaster->retrieveChannelOptions('someotherchannel') + ); + } + + public function testRetrieveUserWithoutGuard() + { + $this->broadcaster->channel('somechannel', function () { + }); + + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('user') + ->once() + ->withNoArgs() + ->andReturn(new DummyUser()); + + $this->container->shouldReceive('get') + ->once() + ->with(AuthManager::class) + ->andReturn($authManager); + + $this->assertInstanceOf( + DummyUser::class, + $this->broadcaster->retrieveUser('somechannel') + ); + } + + public function testRetrieveUserWithOneGuardUsingAStringForSpecifyingGuard() + { + $this->broadcaster->channel('somechannel', function () { + }, ['guards' => 'myguard']); + + $guard = m::mock(Guard::class); + $guard->shouldReceive('user') + ->once() + ->withNoArgs() + ->andReturn(new DummyUser()); + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('guard') + ->once() + ->with('myguard') + ->andReturn($guard); + + $this->container->shouldReceive('get') + ->once() + ->with(AuthManager::class) + ->andReturn($authManager); + + $this->assertInstanceOf( + DummyUser::class, + $this->broadcaster->retrieveUser('somechannel') + ); + } + + public function testRetrieveUserWithMultipleGuardsAndRespectGuardsOrder() + { + $this->broadcaster->channel('somechannel', function () { + }, ['guards' => ['myguard1', 'myguard2']]); + $this->broadcaster->channel('someotherchannel', function () { + }, ['guards' => ['myguard2', 'myguard1']]); + + $guard1 = m::mock(Guard::class); + $guard1->shouldReceive('user') + ->once() + ->andReturn(null); + $guard2 = m::mock(Guard::class); + $guard2->shouldReceive('user') + ->twice() + ->andReturn(new DummyUser()); + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('guard') + ->once() + ->with('myguard1') + ->andReturn($guard1); + $authManager->shouldReceive('guard') + ->twice() + ->with('myguard2') + ->andReturn($guard2); + $authManager->shouldNotReceive('guard') + ->withNoArgs(); + + $this->container->shouldReceive('get') + ->twice() + ->with(AuthManager::class) + ->andReturn($authManager); + + $this->assertInstanceOf( + DummyUser::class, + $this->broadcaster->retrieveUser('somechannel') + ); + + $this->assertInstanceOf( + DummyUser::class, + $this->broadcaster->retrieveUser('someotherchannel') + ); + } + + public function testRetrieveUserDontUseDefaultGuardWhenOneGuardSpecified() + { + $this->broadcaster->channel('somechannel', function () { + }, ['guards' => 'myguard']); + + $guard = m::mock(Guard::class); + $guard->shouldReceive('user') + ->once() + ->andReturn(new DummyUser()); + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('guard') + ->once() + ->with('myguard') + ->andReturn($guard); + $authManager->shouldNotReceive('guard') + ->withNoArgs(); + + $this->container->shouldReceive('get') + ->once() + ->with(AuthManager::class) + ->andReturn($authManager); + + $this->broadcaster->retrieveUser('somechannel'); + } + + public function testRetrieveUserDontUseDefaultGuardWhenMultipleGuardsSpecified() + { + $this->broadcaster->channel('somechannel', function () { + }, ['guards' => ['myguard1', 'myguard2']]); + + $guard = m::mock(Guard::class); + $guard->shouldReceive('user') + ->twice() + ->andReturn(null); + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('guard') + ->once() + ->with('myguard1') + ->andReturn($guard); + $authManager->shouldReceive('guard') + ->once() + ->with('myguard2') + ->andReturn($guard); + $authManager->shouldNotReceive('guard') + ->withNoArgs(); + + $this->container->shouldReceive('get') + ->once() + ->with(AuthManager::class) + ->andReturn($authManager); + + $this->broadcaster->retrieveUser('somechannel'); + } + + public function testUserAuthenticationWithValidUser() + { + $this->broadcaster->resolveAuthenticatedUserUsing(function ($request) { + return ['id' => '12345', 'socket' => $request->input('socket_id')]; + }); + + $this->mockRequest('http://exa.com/foo?socket_id=1234.1234#boom'); + $user = $this->broadcaster->resolveAuthenticatedUser(new Request()); + + $this->assertSame([ + 'id' => '12345', + 'socket' => '1234.1234', + ], $user); + } + + private function mockRequest(?string $uri = null): void + { + $request = new ServerRequest('GET', $uri ?: 'http://example.com/foo?bar=baz#boom'); + parse_str($request->getUri()->getQuery(), $result); + $request = $request->withQueryParams($result); + + RequestContext::set($request); + } + + public function testUserAuthenticationWithInvalidUser() + { + $this->broadcaster->resolveAuthenticatedUserUsing(function ($request) { + return null; + }); + + $this->mockRequest('http://exa.com/foo?socket_id=1234.1234#boom'); + $user = $this->broadcaster->resolveAuthenticatedUser(new Request()); + + $this->assertNull($user); + } + + public function testUserAuthenticationWithoutResolve() + { + $this->mockRequest('http://exa.com/foo?socket_id=1234.1234#boom'); + $this->assertNull($this->broadcaster->resolveAuthenticatedUser(new Request())); + } + + #[DataProvider('channelNameMatchPatternProvider')] + public function testChannelNameMatchPattern($channel, $pattern, $shouldMatch) + { + $this->assertEquals($shouldMatch, $this->broadcaster->channelNameMatchesPattern($channel, $pattern)); + } + + public static function channelNameMatchPatternProvider() + { + return [ + ['something', 'something', true], + ['something.23', 'something.{id}', true], + ['something.23.test', 'something.{id}.test', true], + ['something.23.test.42', 'something.{id}.test.{id2}', true], + ['something-23:test-42', 'something-{id}:test-{id2}', true], + ['something..test.42', 'something.{id}.test.{id2}', false], + ['23:string:test', '{id}:string:{text}', true], + ['something.23', 'something', false], + ['something.23.test.42', 'something.test.{id}', false], + ['something-23-test-42', 'something-{id}-test', false], + ['23:test', '{id}:test:abcd', false], + ['customer.order.1', 'order.{id}', false], + ['customerorder.1', 'order.{id}', false], + ]; + } +} + +class FakeBroadcaster extends Broadcaster +{ + public function __construct( + protected ContainerInterface $container + ) { + } + + public function auth(RequestInterface $request): mixed + { + return null; + } + + public function validAuthenticationResponse(RequestInterface $request, mixed $result): mixed + { + return null; + } + + public function broadcast(array $channels, string $event, array $payload = []): void + { + } + + public function extractAuthParameters(string $pattern, string $channel, callable|string $callback): array + { + return parent::extractAuthParameters($pattern, $channel, $callback); + } + + public function retrieveChannelOptions(string $channel): array + { + return parent::retrieveChannelOptions($channel); + } + + public function retrieveUser(string $channel): mixed + { + return parent::retrieveUser($channel); + } + + public function channelNameMatchesPattern(string $channel, string $pattern): bool + { + return parent::channelNameMatchesPattern($channel, $pattern); + } +} + +class BroadcasterTestEloquentModelStub extends Model +{ + public function getRouteKeyName() + { + return 'id'; + } + + public function where($key, $value) + { + $this->value = $value; + + return $this; + } + + public function firstOrFail() + { + return "model.{$this->value}.instance"; + } +} + +class BroadcasterTestEloquentModelNotFoundStub extends Model +{ + public function getRouteKeyName() + { + return 'id'; + } + + public function where($key, $value) + { + $this->value = $value; + + return $this; + } + + public function firstOrFail() + { + } +} + +class DummyBroadcastingChannel +{ + public function join($user, BroadcasterTestEloquentModelStub $model, $nonModel) + { + } +} + +class DummyUser implements Authenticatable +{ + public function getAuthIdentifierName(): string + { + return 'dummy_user'; + } + + public function getAuthIdentifier(): mixed + { + return 'dummy_user'; + } + + public function getAuthPassword(): string + { + return 'dummy_password'; + } +} diff --git a/tests/Broadcasting/PusherBroadcasterTest.php b/tests/Broadcasting/PusherBroadcasterTest.php new file mode 100644 index 00000000..f1e7b5da --- /dev/null +++ b/tests/Broadcasting/PusherBroadcasterTest.php @@ -0,0 +1,217 @@ +container = m::mock(ContainerInterface::class); + $this->pusher = m::mock(Pusher::class); + $this->broadcaster = m::mock(PusherBroadcaster::class, [$this->container, $this->pusher])->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') + ); + } + + public function testValidAuthenticationResponseCallPusherSocketAuthMethodWithPrivateChannel() + { + $request = $this->getMockRequestWithUserForChannel('private-test'); + + $data = [ + 'auth' => 'abcd:efgh', + ]; + + $this->pusher->shouldReceive('authorizeChannel') + ->once() + ->andReturn(json_encode($data)); + + $this->assertEquals( + $data, + $this->broadcaster->validAuthenticationResponse($request, true) + ); + } + + public function testValidAuthenticationResponseCallPusherPresenceAuthMethodWithPresenceChannel() + { + $request = $this->getMockRequestWithUserForChannel('presence-test'); + + $data = [ + 'auth' => 'abcd:efgh', + 'channel_data' => [ + 'user_id' => 42, + 'user_info' => [1, 2, 3, 4], + ], + ]; + + $this->pusher->shouldReceive('authorizePresenceChannel') + ->once() + ->andReturn(json_encode($data)); + + $this->assertEquals( + $data, + $this->broadcaster->validAuthenticationResponse($request, true) + ); + } + + public function testUserAuthenticationForPusher() + { + $authenticateUser = [ + 'auth' => '278d425bdf160c739803:4708d583dada6a56435fb8bc611c77c359a31eebde13337c16ab43aa6de336ba', + 'user_data' => json_encode(['id' => '12345']), + ]; + + $this->pusher + ->shouldReceive('authenticateUser') + ->andReturn(json_encode($authenticateUser)); + + $this->broadcaster->resolveAuthenticatedUserUsing(function () { + return ['id' => '12345']; + }); + + $response = $this->broadcaster->resolveAuthenticatedUser( + $this->getMockRequestWithUserForChannel('presence-test') + ); + + $this->assertSame($authenticateUser, $response); + } + + protected function getMockRequestWithUserForChannel(string $channel): Request + { + $request = m::mock(Request::class); + $request->shouldReceive('input')->with('channel_name')->andReturn($channel); + $request->shouldReceive('input')->with('socket_id')->andReturn('1234.1234'); + + $user = m::mock('User'); + $user->shouldReceive('getAuthIdentifier')->andReturn(42); + + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('user')->andReturn($user); + + $this->container->shouldReceive('get') + ->with(AuthManager::class) + ->andReturn($authManager); + + return $request; + } + + protected function getMockRequestWithoutUserForChannel(string $channel): Request + { + $request = m::mock(Request::class); + $request->shouldReceive('input')->with('channel_name')->andReturn($channel); + + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('user')->andReturn(null); + + $this->container->shouldReceive('get') + ->with(AuthManager::class) + ->andReturn($authManager); + + return $request; + } +} diff --git a/tests/Broadcasting/RedisBroadcasterTest.php b/tests/Broadcasting/RedisBroadcasterTest.php new file mode 100644 index 00000000..fbdbafc4 --- /dev/null +++ b/tests/Broadcasting/RedisBroadcasterTest.php @@ -0,0 +1,190 @@ +container = m::mock(ContainerInterface::class); + $factory = m::mock(RedisFactory::class); + $this->broadcaster = m::mock(RedisBroadcaster::class, [$this->container, $factory])->makePartial(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + + Facade::clearResolvedInstances(); + } + + 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') + ); + } + + public function testValidAuthenticationResponseWithPrivateChannel() + { + $request = $this->getMockRequestWithUserForChannel('private-test'); + + $this->assertEquals( + json_encode(true), + $this->broadcaster->validAuthenticationResponse($request, true) + ); + } + + public function testValidAuthenticationResponseWithPresenceChannel() + { + $request = $this->getMockRequestWithUserForChannel('presence-test'); + + $this->assertEquals( + json_encode([ + 'channel_data' => [ + 'user_id' => 42, + 'user_info' => [ + 'a' => 'b', + 'c' => 'd', + ], + ], + ]), + $this->broadcaster->validAuthenticationResponse($request, [ + 'a' => 'b', + 'c' => 'd', + ]) + ); + } + + protected function getMockRequestWithUserForChannel(string $channel): Request + { + $request = m::mock(Request::class); + $request->shouldReceive('input')->with('channel_name')->andReturn($channel); + + $user = m::mock('User'); + $user->shouldReceive('getAuthIdentifier')->andReturn(42); + + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('user')->andReturn($user); + + $this->container->shouldReceive('get') + ->with(AuthManager::class) + ->andReturn($authManager); + + return $request; + } + + protected function getMockRequestWithoutUserForChannel(string $channel): Request + { + $request = m::mock(Request::class); + $request->shouldReceive('input')->with('channel_name')->andReturn($channel); + + $authManager = m::mock(AuthManager::class); + $authManager->shouldReceive('user')->andReturn(null); + + $this->container->shouldReceive('get') + ->with(AuthManager::class) + ->andReturn($authManager); + + return $request; + } +} diff --git a/tests/Broadcasting/UsePusherChannelsNamesTest.php b/tests/Broadcasting/UsePusherChannelsNamesTest.php new file mode 100644 index 00000000..6c40c628 --- /dev/null +++ b/tests/Broadcasting/UsePusherChannelsNamesTest.php @@ -0,0 +1,112 @@ +assertSame( + $normalizedName, + $broadcaster->normalizeChannelName($requestChannelName) + ); + } + + public function testChannelNameNormalizationSpecialCase() + { + $broadcaster = new FakeBroadcasterUsingPusherChannelsNames(); + + $this->assertSame( + 'private-123', + $broadcaster->normalizeChannelName('private-encrypted-private-123') + ); + } + + #[DataProvider('channelsProvider')] + public function testIsGuardedChannel($requestChannelName, $_, $guarded) + { + $broadcaster = new FakeBroadcasterUsingPusherChannelsNames(); + + $this->assertSame( + $guarded, + $broadcaster->isGuardedChannel($requestChannelName) + ); + } + + public static function channelsProvider() + { + $prefixesInfos = [ + ['prefix' => 'private-', 'guarded' => true], + ['prefix' => 'private-encrypted-', 'guarded' => true], + ['prefix' => 'presence-', 'guarded' => true], + ['prefix' => '', 'guarded' => false], + ]; + + $channels = [ + 'test', + 'test-channel', + 'test-private-channel', + 'test-presence-channel', + 'abcd.efgh', + 'abcd.efgh.ijkl', + 'test.{param}', + 'test-{param}', + '{a}.{b}', + '{a}-{b}', + '{a}-{b}.{c}', + ]; + + $tests = []; + foreach ($prefixesInfos as $prefixInfos) { + foreach ($channels as $channel) { + $tests[] = [ + $prefixInfos['prefix'] . $channel, + $channel, + $prefixInfos['guarded'], + ]; + } + } + + $tests[] = ['private-private-test', 'private-test', true]; + $tests[] = ['private-presence-test', 'presence-test', true]; + $tests[] = ['presence-private-test', 'private-test', true]; + $tests[] = ['presence-presence-test', 'presence-test', true]; + $tests[] = ['public-test', 'public-test', false]; + + return $tests; + } +} + +class FakeBroadcasterUsingPusherChannelsNames extends Broadcaster +{ + use UsePusherChannelConventions; + + public function auth(RequestInterface $request): mixed + { + return null; + } + + public function validAuthenticationResponse(RequestInterface $request, mixed $result): mixed + { + return null; + } + + public function broadcast(array $channels, string $event, array $payload = []): void + { + } +} diff --git a/tests/Event/BroadcastedEventsTest.php b/tests/Event/BroadcastedEventsTest.php new file mode 100644 index 00000000..904f0dad --- /dev/null +++ b/tests/Event/BroadcastedEventsTest.php @@ -0,0 +1,102 @@ +makePartial()->shouldAllowMockingProtectedMethods(); + + $event = new BroadcastEvent(); + + $this->assertTrue($d->shouldBroadcast($event)); + + $event = new AlwaysBroadcastEvent(); + + $this->assertTrue($d->shouldBroadcast($event)); + } + + public function testShouldBroadcastAsQueuedAndCallNormalListeners() + { + unset($_SERVER['__event.test']); + $broadcast = m::mock(BroadcastFactory::class); + $broadcast->shouldReceive('queue')->once(); + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('get')->once()->with(BroadcastFactory::class)->andReturn($broadcast); + $d = new EventDispatcher(new ListenerProvider(), null, $container); + + $d->listen(AlwaysBroadcastEvent::class, function ($payload) { + $_SERVER['__event.test'] = $payload; + }); + + $d->dispatch($e = new AlwaysBroadcastEvent()); + + $this->assertSame($e, $_SERVER['__event.test']); + } + + public function testShouldBroadcastFail() + { + $d = m::mock(EventDispatcher::class); + + $d->makePartial()->shouldAllowMockingProtectedMethods(); + + $event = new BroadcastFalseCondition(); + + $this->assertFalse($d->shouldBroadcast($event)); + + $event = new ExampleEvent(); + + $this->assertFalse($d->shouldBroadcast($event)); + } +} + +class BroadcastEvent implements ShouldBroadcast +{ + public function broadcastOn(): array + { + return ['test-channel']; + } + + public function broadcastWhen() + { + return true; + } +} + +class AlwaysBroadcastEvent implements ShouldBroadcast +{ + public function broadcastOn(): array + { + return ['test-channel']; + } +} + +class BroadcastFalseCondition extends BroadcastEvent +{ + public function broadcastWhen() + { + return false; + } +} diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php new file mode 100644 index 00000000..e715167a --- /dev/null +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -0,0 +1,159 @@ +container = new Application( + new DefinitionSource([ + BusDispatcherContract::class => fn () => m::mock(QueueingDispatcher::class), + ConfigInterface::class => fn () => m::mock(ConfigInterface::class), + QueueFactoryContract::class => fn () => m::mock(QueueFactoryContract::class), + BroadcastingFactoryContract::class => fn ($container) => new BroadcastManager($container), + ]), + 'bath_path', + ); + + ApplicationContext::setContainer($this->container); + } + + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + + Facade::clearResolvedInstances(); + } + + public function testEventCanBeBroadcastNow() + { + Bus::fake(); + Queue::fake(); + + Broadcast::queue(new TestEventNow()); + + Bus::assertDispatched(BroadcastEvent::class); + Queue::assertNotPushed(BroadcastEvent::class); + } + + public function testEventsCanBeBroadcast() + { + Bus::fake(); + Queue::fake(); + + Broadcast::queue(new TestEvent()); + + Bus::assertNotDispatched(BroadcastEvent::class); + Queue::assertPushed(BroadcastEvent::class); + } + + public function testUniqueEventsCanBeBroadcast() + { + Bus::fake(); + Queue::fake(); + + $lockKey = 'laravel_unique_job:' . UniqueBroadcastEvent::class . ':' . TestEventUnique::class; + $cache = m::mock(Cache::class); + $cache->shouldReceive('lock')->with($lockKey, 0)->andReturnSelf(); + $cache->shouldReceive('get')->andReturn(true); + $this->container->bind(Cache::class, fn () => $cache); + + Broadcast::queue(new TestEventUnique()); + + Bus::assertNotDispatched(UniqueBroadcastEvent::class); + Queue::assertPushed(UniqueBroadcastEvent::class); + } + + public function testThrowExceptionWhenUnknownStoreIsUsed() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Broadcast connection [alien_connection] is not defined.'); + + $config = m::mock(ContainerInterface::class); + $config->shouldReceive('get')->with('broadcasting.connections.alien_connection')->andReturn(null); + + $app = m::mock(ContainerInterface::class); + $app->shouldReceive('get')->with(ConfigInterface::class)->andReturn($config); + + $broadcastManager = new BroadcastManager($app); + + $broadcastManager->connection('alien_connection'); + } +} + +class TestEvent implements ShouldBroadcast +{ + /** + * Get the channels the event should broadcast on. + * + * @return Channel[]|string[] + */ + public function broadcastOn(): array + { + return []; + } +} + +class TestEventNow implements ShouldBroadcastNow +{ + /** + * Get the channels the event should broadcast on. + * + * @return Channel[]|string[] + */ + public function broadcastOn(): array + { + return []; + } +} + +class TestEventUnique implements ShouldBroadcast, ShouldBeUnique +{ + /** + * Get the channels the event should broadcast on. + * + * @return Channel[]|string[] + */ + public function broadcastOn(): array + { + return []; + } +} diff --git a/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php b/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php new file mode 100644 index 00000000..3090c1e1 --- /dev/null +++ b/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php @@ -0,0 +1,193 @@ +container = new Application( + new DefinitionSource([ + EventDispatcherInterface::class => fn () => m::mock(EventDispatcherInterface::class), + BroadcastingFactoryContract::class => fn ($container) => new BroadcastManager($container), + ]), + 'bath_path', + ); + + ApplicationContext::setContainer($this->container); + } + + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + + Facade::clearResolvedInstances(); + } + + public function testBroadcastIsSent() + { + Event::fake(); + + Broadcast::on('test-channel') + ->with(['some' => 'data']) + ->as('test-event') + ->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return (new ReflectionClass($event))->getProperty('connection')->getValue($event) === null + && $event->broadcastOn() === ['test-channel'] + && $event->broadcastAs() === 'test-event' + && $event->broadcastWith() === ['some' => 'data']; + }); + } + + public function testBroadcastIsSentNow() + { + Event::fake(); + + Broadcast::on('test-channel') + ->with(['some' => 'data']) + ->as('test-event') + ->sendNow(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return (new ReflectionClass($event))->getProperty('connection')->getValue($event) === null + && $event->shouldBroadcastNow(); + }); + } + + public function testDefaultNameIsSet() + { + Event::fake(); + + Broadcast::on('test-channel') + ->with(['some' => 'data']) + ->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return $event->broadcastAs() === 'AnonymousEvent'; + }); + } + + public function testDefaultPayloadIsSet() + { + Event::fake(); + + Broadcast::on('test-channel')->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return $event->broadcastWith() === []; + }); + } + + public function testSendToMultipleChannels() + { + Event::fake(); + + Broadcast::on([ + 'test-channel', + new PrivateChannel('test-channel'), + 'presence-test-channel', + ])->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + [$one, $two, $three] = $event->broadcastOn(); + + return $one === 'test-channel' + && $two instanceof PrivateChannel + && $two->name === 'private-test-channel' + && $three === 'presence-test-channel'; + }); + } + + public function testSendViaANonDefaultConnection() + { + Event::fake(); + + Broadcast::on('test-channel') + ->via('pusher') + ->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return (new ReflectionClass($event))->getProperty('connection')->getValue($event) === 'pusher'; + }); + } + + public function testSendToOthersOnly() + { + Event::fake(); + + $request = m::mock(RequestInterface::class); + $request->shouldReceive('header')->with('X-Socket-ID')->andReturn('12345'); + $this->container->set(RequestInterface::class, $request); + + Broadcast::on('test-channel')->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return $event->socket === null; + }); + + Broadcast::on('test-channel') + ->toOthers() + ->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return $event->socket = '12345'; + }); + } + + public function testSendToPrivateChannel() + { + Event::fake(); + + Broadcast::private('test-channel')->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + $channel = $event->broadcastOn()[0]; + + return $channel instanceof PrivateChannel && $channel->name === 'private-test-channel'; + }); + } + + public function testSendToPresenceChannel() + { + Event::fake(); + + Broadcast::presence('test-channel')->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + $channel = $event->broadcastOn()[0]; + + return $channel instanceof PresenceChannel && $channel->name === 'presence-test-channel'; + }); + } +}