From 163c28399497fb8371eea3ab8fe7ac3ee3326c98 Mon Sep 17 00:00:00 2001 From: ekumanov Date: Sat, 8 Aug 2026 15:59:00 +0300 Subject: [PATCH] feat(realtime): let extensions register their own websocket channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel names were a closed set. `AuthController` matched the subject out of the name and looked for a method of that name on itself, so the only channels that could exist were the ones it defined; `Extend\Realtime::authorizePresenceChannel()` adds a *guard* to a channel realtime already defines, which is not the same thing. An extension therefore had no way to obtain a channel of its own, and had to carry its data on one of realtime's — inheriting that channel's audience. For the discussion typing channel that audience is everyone who can see the discussion, guests included, so anything an extension puts there is disclosed to all of them no matter what permission it checks when rendering. Channels come from a `ChannelRegistry` now, which the extender populates: (new Flarum\Realtime\Extend\Realtime()) ->privateChannel('acme-readers', fn (User $actor, int $id) => ...) ->presenceChannel('acme-readers', fn (User $actor, ?int $id) => ...) Authorization stays where it already was — an ordinary request, once per subscription, with a real actor and the full permission machinery — so the websocket server still does no permission work per event. Realtime registers its own channels the same way, from `extend.php`, rather than keeping a private path beside the public one. The subject authorizers move to `Websocket\Api\DefaultChannels` with their explanations intact, and `AuthController` is left doing only what its name says. Two changes to the channel-name patterns come with it: - a subject may contain hyphens, so `private-index-typing-tag={id}` stops needing the special case it had (`[a-zA-Z]+` could not match it); - a presence channel may carry `={id}`. Presence channels were forum-wide by construction, which is why a per-object roster — the case that motivated this — could not use one, and had to be rebuilt out of client events on somebody else's channel. Guest handling is unchanged and now explicit: private channels have never required a session, so the callback decides; presence channels publish a member list keyed by user id, so guests are refused before the callback runs. Resolving the subject by `method_exists()` also meant every method on the controller was reachable as a channel name. `private-handle=1` and `private-online=1` called `handle('1')` and `online('1')`, died on the argument type, and returned 500 to an unauthenticated caller. Unregistered subjects are now simply refused, and a regression test covers it. Co-Authored-By: Claude Opus 5 --- extensions/realtime/extend.php | 13 +- extensions/realtime/src/Extend/Realtime.php | 107 ++++++++++- .../src/Websocket/Api/AuthController.php | 146 +++++--------- .../src/Websocket/Api/ChannelRegistry.php | 119 ++++++++++++ .../src/Websocket/Api/DefaultChannels.php | 97 ++++++++++ .../src/Websocket/Message/Message.php | 2 +- extensions/realtime/src/WebsocketProvider.php | 2 + .../api/ExtensionChannelAuthTest.php | 181 ++++++++++++++++++ .../unit/Extend/RealtimeExtenderTest.php | 45 ++++- .../unit/Websocket/ChannelRegistryTest.php | 148 ++++++++++++++ 10 files changed, 755 insertions(+), 105 deletions(-) create mode 100644 extensions/realtime/src/Websocket/Api/ChannelRegistry.php create mode 100644 extensions/realtime/src/Websocket/Api/DefaultChannels.php create mode 100644 extensions/realtime/tests/integration/api/ExtensionChannelAuthTest.php create mode 100644 extensions/realtime/tests/unit/Websocket/ChannelRegistryTest.php diff --git a/extensions/realtime/extend.php b/extensions/realtime/extend.php index 72bd36f829..b73fb96803 100644 --- a/extensions/realtime/extend.php +++ b/extensions/realtime/extend.php @@ -15,6 +15,7 @@ use Flarum\Api\Schema; use Flarum\Discussion\Discussion; use Flarum\Extend; +use Flarum\Realtime\Extend\Realtime as RealtimeExtender; use Flarum\Settings\SettingsRepositoryInterface; use Flarum\User\User; @@ -41,6 +42,16 @@ (new Extend\Routes('api')) ->post('/websocket/auth', 'websocket.auth', Websocket\Api\AuthController::class), + // Realtime's own channels, registered through the same extender an extension + // would use. See Websocket\Api\DefaultChannels for what each one requires. + (new RealtimeExtender()) + ->privateChannel('user', [Websocket\Api\DefaultChannels::class, 'user']) + ->privateChannel('typing', [Websocket\Api\DefaultChannels::class, 'typing']) + ->privateChannel('typingIdentified', [Websocket\Api\DefaultChannels::class, 'typingIdentified']) + ->privateChannel('privateMessageTyping', [Websocket\Api\DefaultChannels::class, 'privateMessageTyping']) + ->privateChannel('index-typing-tag', [Websocket\Api\DefaultChannels::class, 'indexTypingTag']) + ->presenceChannel('online', [Websocket\Api\DefaultChannels::class, 'online']), + (new Extend\ApiResource(Resource\ForumResource::class)) ->fields(Websocket\Api\ForumAttributes::class), @@ -68,7 +79,7 @@ // Whether to also subscribe to the channel that names users who are typing // while hiding their online status. The channel is authorized server-side - // regardless (see AuthController::typingIdentified); this only saves the + // regardless (see DefaultChannels::typingIdentified); this only saves the // client a subscription it would be refused. Schema\Boolean::make('canViewHiddenTypers') ->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id) diff --git a/extensions/realtime/src/Extend/Realtime.php b/extensions/realtime/src/Extend/Realtime.php index 6ba5b14818..9bc698ed2c 100644 --- a/extensions/realtime/src/Extend/Realtime.php +++ b/extensions/realtime/src/Extend/Realtime.php @@ -14,6 +14,7 @@ use Flarum\Extend\ExtenderInterface; use Flarum\Extension\Extension; use Flarum\Realtime\Push\RealtimeRegistry; +use Flarum\Realtime\Websocket\Api\ChannelRegistry; use Flarum\Realtime\Websocket\Api\PresenceChannelAuthorizer; use Flarum\Realtime\Websocket\Settings; use Flarum\User\User; @@ -49,12 +50,32 @@ class Realtime implements ExtenderInterface */ protected array $presenceChannelGuards = []; + /** + * @var array + */ + protected array $privateChannels = []; + + /** + * @var array + */ + protected array $presenceChannels = []; + public function extend(Container $container, ?Extension $extension = null): void { $container->afterResolving(Settings::class, function (Settings $settings) { $settings->use($this->configuration); }); + $container->afterResolving(ChannelRegistry::class, function (ChannelRegistry $registry) { + foreach ($this->privateChannels as $subject => $callback) { + $registry->addPrivate($subject, $callback); + } + + foreach ($this->presenceChannels as $subject => $callback) { + $registry->addPresence($subject, $callback); + } + }); + $container->afterResolving(PresenceChannelAuthorizer::class, function (PresenceChannelAuthorizer $authorizer) { foreach ($this->presenceChannelGuards as $channel => $callbacks) { foreach ($callbacks as $callback) { @@ -283,9 +304,93 @@ public function registerModelEndpoint(string $modelClass, string $endpoint): sel } // ------------------------------------------------------------------------- - // Presence channel authorization + // Channels // ------------------------------------------------------------------------- + /** + * Register a private channel, and how to decide who may subscribe to it. + * + * The channel is `private-{subject}={id}`, and the callback is given the actor + * and that id. Return true to sign the subscription; anything else refuses it. + * + * Because the check runs here — in an ordinary request, once per subscription — + * it has the full permission machinery available, and the websocket server does + * no permission work per event. That makes the channel a real privilege + * boundary: an extension can carry data on it that the audience of an existing + * channel is not entitled to, rather than having to borrow one of realtime's and + * inherit its audience. + * + * Guests reach the callback. Private channels have never required a session (the + * discussion typing channel is authorized for anyone who can see the discussion, + * guests included), so a members-only channel must say so itself. + * + * Prefix the subject to keep it yours — registering one twice throws. + * + * Example (in an extension's extend.php): + * + * (new Extend\Conditional()) + * ->whenExtensionEnabled('flarum-realtime', fn () => [ + * (new \Flarum\Realtime\Extend\Realtime()) + * ->privateChannel('acme-readers', function (User $actor, int $discussionId) { + * $discussion = Discussion::whereVisibleTo($actor)->find($discussionId); + * + * return $discussion !== null + * && $actor->can('acme-readers.view', $discussion); + * }), + * ]), + * + * @param string $subject Channel subject, e.g. 'acme-readers'. + * @param callable(User $actor, int $id): bool $authorize + */ + public function privateChannel(string $subject, callable $authorize): self + { + $this->privateChannels[$subject] = $authorize; + + return $this; + } + + /** + * Register a presence channel, and how to decide who may subscribe to it. + * + * Presence channels keep a member list and emit `member_added`/`member_removed`, + * so a roster needs no announce/keepalive protocol of its own. Return the member + * data to publish for this actor — it is broadcast to the rest of the channel, so + * put only what everyone there may see — or false to refuse. + * + * The channel is `presence-{subject}` for a forum-wide one, or + * `presence-{subject}={id}` to scope it to a single object, in which case the id + * is passed to the callback (and is null otherwise). + * + * Guests are refused before the callback runs: the member list is keyed by user + * id, so there is nothing to publish for them. + * + * Prefix the subject to keep it yours — registering one twice throws. To add a + * further condition to a channel someone else registered, use + * {@link authorizePresenceChannel()} instead; guards stack, definitions do not. + * + * Example: + * + * (new \Flarum\Realtime\Extend\Realtime()) + * ->presenceChannel('acme-readers', function (User $actor, ?int $discussionId) { + * $discussion = Discussion::whereVisibleTo($actor)->find($discussionId); + * + * if ($discussion === null || ! $actor->can('acme-readers.view', $discussion)) { + * return false; + * } + * + * return ['displayName' => $actor->display_name]; + * }), + * + * @param string $subject Channel subject, e.g. 'acme-readers'. + * @param callable(User $actor, ?int $id): (array|bool|null) $authorize + */ + public function presenceChannel(string $subject, callable $authorize): self + { + $this->presenceChannels[$subject] = $authorize; + + return $this; + } + /** * Register a callback to authorize access to a presence channel. * diff --git a/extensions/realtime/src/Websocket/Api/AuthController.php b/extensions/realtime/src/Websocket/Api/AuthController.php index 0d13194001..e2a71daa94 100644 --- a/extensions/realtime/src/Websocket/Api/AuthController.php +++ b/extensions/realtime/src/Websocket/Api/AuthController.php @@ -9,11 +9,7 @@ namespace Flarum\Realtime\Websocket\Api; -use Flarum\Discussion\Discussion; use Flarum\Http\RequestUtil; -use Flarum\Realtime\Push\Payload\Generator; -use Flarum\Settings\SettingsRepositoryInterface; -use Flarum\User\User; use Illuminate\Support\Arr; use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\JsonResponse; @@ -22,15 +18,32 @@ use Psr\Http\Server\RequestHandlerInterface; use Pusher\Pusher; +/** + * Signs a client's subscription to a websocket channel, once the + * {@link ChannelRegistry} says the actor may join it. + * + * This only routes: it parses the channel name into a subject and an optional id, + * and hands those to the registry. Which channels exist, and what each one + * requires, lives there — including realtime's own, which are registered from + * `extend.php` like any extension's. + */ class AuthController implements RequestHandlerInterface { - private User $actor; + /** + * Both allow a hyphenated subject, so a multi-word channel needs no special + * case (`private-index-typing-tag={id}` used to need one). + * + * Private channels address a single subject instance and always carry an id. + * Presence channels may omit it: `presence-online` is forum-wide, while a + * per-object roster is `presence-{subject}={id}`. + */ + private const PRIVATE_CHANNEL = '~^private-(?[a-zA-Z][a-zA-Z0-9-]*)=(?[0-9]+)$~'; + private const PRESENCE_CHANNEL = '~^presence-(?[a-zA-Z][a-zA-Z0-9-]*)(?:=(?[0-9]+))?$~'; public function __construct( protected Pusher $pusher, - protected Generator $generator, - protected PresenceChannelAuthorizer $presenceAuthorizer, - protected SettingsRepositoryInterface $settings + protected ChannelRegistry $registry, + protected PresenceChannelAuthorizer $presenceAuthorizer ) { } @@ -38,113 +51,48 @@ public function handle(ServerRequestInterface $request): ResponseInterface { $attributes = $request->getParsedBody(); - $this->actor = RequestUtil::getActor($request); + $actor = RequestUtil::getActor($request); $channel = Arr::get($attributes, 'channel_name'); + $socketId = Arr::get($attributes, 'socket_id'); - if (preg_match('~^private-index-typing-tag=(?[0-9]+)$~', $channel, $m)) { - if ($this->indexTypingTag((int) $m['id'])) { - $socketId = Arr::get($attributes, 'socket_id'); - $body = $this->pusher->authorizeChannel($channel, $socketId); + if (! is_string($channel)) { + return new EmptyResponse(403); + } - return new JsonResponse(json_decode($body, true)); + if (preg_match(self::PRIVATE_CHANNEL, $channel, $m)) { + if ($this->registry->authorizePrivate($m['subject'], $actor, (int) $m['id'])) { + return new JsonResponse(json_decode( + $this->pusher->authorizeChannel($channel, $socketId), + true + )); } return new EmptyResponse(403); } - if (preg_match('~^private-(?[a-zA-Z]+)=(?[0-9]+)$~', $channel, $m)) { - if (method_exists($this, $m['subject']) && call_user_func([$this, $m['subject']], $m['id'])) { - $socketId = Arr::get($attributes, 'socket_id'); - - // Compute the auth body - $body = $this->pusher->authorizeChannel($channel, $socketId); - - return new JsonResponse(json_decode($body, true)); + if (preg_match(self::PRESENCE_CHANNEL, $channel, $m)) { + // A presence channel publishes a member list keyed by user id, so there + // is nothing to put in it for a guest. + if ($actor->isGuest() || ! $this->presenceAuthorizer->authorize($m['subject'], $actor)) { + return new EmptyResponse(403); } - } - if (preg_match('~^presence-(?[a-z-]+)$~', $channel, $m)) { - if (! $this->actor->isGuest() && method_exists($this, $m['subject']) - && $this->presenceAuthorizer->authorize($m['subject'], $this->actor) - ) { - $payload = call_user_func([$this, $m['subject']], $this->actor); + $id = ($m['id'] ?? '') === '' ? null : (int) $m['id']; + $memberData = $this->registry->authorizePresence($m['subject'], $actor, $id); - // Only if the method returns anything, will we allow authentication. - if ($payload) { - $socketId = Arr::get($attributes, 'socket_id'); - $body = $this->pusher->authorizePresenceChannel( + if ($memberData !== null) { + return new JsonResponse(json_decode( + $this->pusher->authorizePresenceChannel( $channel, $socketId, - (string) $this->actor->id, - $payload - ); - - return new JsonResponse(json_decode($body, true)); - } + (string) $actor->id, + $memberData + ), + true + )); } } return new EmptyResponse(403); } - - protected function user(int $id): bool - { - return ! $this->actor->isGuest() && $this->actor->id === $id; - } - - protected function typing(int $id): bool - { - return Discussion::whereVisibleTo($this->actor)->where('id', $id)->exists(); - } - - protected function privateMessageTyping(int $id): bool - { - return \Flarum\Messages\Dialog::whereVisibleTo($this->actor)->where('id', $id)->exists(); - } - - /** - * Authorize the channel that discloses who is typing while hiding their online - * status. `user.viewLastSeenAt` is core's override for the `discloseOnline` - * preference, so it gates this too — plus the ordinary requirements for seeing - * the typing indicator at all, since this channel carries nothing else. - * - * Doing it here means the permission is evaluated once per subscription, against - * a real actor in a normal request, rather than per event inside the websocket - * server. See {@link \Flarum\Realtime\Websocket\Message\Message::relayTyping()}. - */ - protected function typingIdentified(int $id): bool - { - if (! $this->settings->get('flarum-realtime.typing-indicator') - || ! $this->actor->hasPermission('user.viewLastSeenAt')) { - return false; - } - - $discussion = Discussion::whereVisibleTo($this->actor)->find($id); - - return $discussion !== null - && $this->actor->can('flarum-realtime.view-who-types', $discussion); - } - - /** - * Authorize a restricted-tag index-typing channel: the actor may listen iff - * they can see the tag. Reaching this without flarum-tags active is rejected. - */ - protected function indexTypingTag(int $id): bool - { - if (! class_exists(\Flarum\Tags\Tag::class)) { - return false; - } - - return \Flarum\Tags\Tag::whereVisibleTo($this->actor)->where('id', $id)->exists(); - } - - protected function online(User $actor): array - { - // @todo It returns [] - $generate = $this->generator; - - return [ - 'displayName' => $actor->display_name - ]; - } } diff --git a/extensions/realtime/src/Websocket/Api/ChannelRegistry.php b/extensions/realtime/src/Websocket/Api/ChannelRegistry.php new file mode 100644 index 0000000000..126389c0eb --- /dev/null +++ b/extensions/realtime/src/Websocket/Api/ChannelRegistry.php @@ -0,0 +1,119 @@ + + */ + private array $private = []; + + /** + * @var array + */ + private array $presence = []; + + /** + * @param callable(User $actor, int $id): bool $authorize + */ + public function addPrivate(string $subject, callable $authorize): void + { + $this->assertUnclaimed('private', $subject, isset($this->private[$subject])); + + $this->private[$subject] = $authorize; + } + + /** + * @param callable(User $actor, ?int $id): (array|bool|null) $authorize + */ + public function addPresence(string $subject, callable $authorize): void + { + $this->assertUnclaimed('presence', $subject, isset($this->presence[$subject])); + + $this->presence[$subject] = $authorize; + } + + /** + * Whether the actor may subscribe to `private-{subject}={id}`. + * + * An unregistered subject is indistinguishable from a refused one — both are + * simply "no", so a caller cannot probe for which channels exist. + * + * Note that guests reach these callbacks: private channels have never required + * a session (the discussion typing channel is authorized for anyone who can + * see the discussion, guests included). A channel that should be members-only + * has to say so itself. + */ + public function authorizePrivate(string $subject, User $actor, int $id): bool + { + $authorize = $this->private[$subject] ?? null; + + return $authorize !== null && $authorize($actor, $id) === true; + } + + /** + * The member data to publish for the actor on `presence-{subject}[={id}]`, or + * null if they may not join. `$id` is null for a forum-wide channel. + * + * Presence channels carry a member list keyed by user id, so unlike private + * channels they are inherently members-only; the caller rejects guests before + * reaching here. + * + * @return array|null + */ + public function authorizePresence(string $subject, User $actor, ?int $id): ?array + { + $authorize = $this->presence[$subject] ?? null; + + if ($authorize === null) { + return null; + } + + $data = $authorize($actor, $id); + + return is_array($data) ? $data : null; + } + + /** + * Two extensions claiming one subject would silently give the loser's channel + * the winner's permissions, so this fails loudly instead. Prefix subjects to + * avoid it — `acme-readers` rather than `readers`. + */ + private function assertUnclaimed(string $type, string $subject, bool $taken): void + { + if ($taken) { + throw new InvalidArgumentException( + "The $type channel subject \"$subject\" is already registered." + ); + } + } +} diff --git a/extensions/realtime/src/Websocket/Api/DefaultChannels.php b/extensions/realtime/src/Websocket/Api/DefaultChannels.php new file mode 100644 index 0000000000..98f9c67de6 --- /dev/null +++ b/extensions/realtime/src/Websocket/Api/DefaultChannels.php @@ -0,0 +1,97 @@ +isGuest() && $actor->id === $id; + } + + /** + * Who is typing in a discussion. Audience: everyone who can see the discussion, + * guests included. + */ + public static function typing(User $actor, int $id): bool + { + return Discussion::whereVisibleTo($actor)->where('id', $id)->exists(); + } + + public static function privateMessageTyping(User $actor, int $id): bool + { + return \Flarum\Messages\Dialog::whereVisibleTo($actor)->where('id', $id)->exists(); + } + + /** + * Authorize the channel that discloses who is typing while hiding their online + * status. `user.viewLastSeenAt` is core's override for the `discloseOnline` + * preference, so it gates this too — plus the ordinary requirements for seeing + * the typing indicator at all, since this channel carries nothing else. + * + * Doing it here means the permission is evaluated once per subscription, against + * a real actor in a normal request, rather than per event inside the websocket + * server. See {@link \Flarum\Realtime\Websocket\Message\Message::relayTyping()}. + */ + public static function typingIdentified(User $actor, int $id): bool + { + if (! resolve(SettingsRepositoryInterface::class)->get('flarum-realtime.typing-indicator') + || ! $actor->hasPermission('user.viewLastSeenAt')) { + return false; + } + + $discussion = Discussion::whereVisibleTo($actor)->find($id); + + return $discussion !== null + && $actor->can('flarum-realtime.view-who-types', $discussion); + } + + /** + * Authorize a restricted-tag index-typing channel: the actor may listen iff + * they can see the tag. Reaching this without flarum-tags active is rejected. + */ + public static function indexTypingTag(User $actor, int $id): bool + { + if (! class_exists(\Flarum\Tags\Tag::class)) { + return false; + } + + return \Flarum\Tags\Tag::whereVisibleTo($actor)->where('id', $id)->exists(); + } + + /** + * @return array + */ + public static function online(User $actor, ?int $id): array + { + return [ + 'displayName' => $actor->display_name, + ]; + } +} diff --git a/extensions/realtime/src/Websocket/Message/Message.php b/extensions/realtime/src/Websocket/Message/Message.php index 0fc1674ed6..50855a2452 100644 --- a/extensions/realtime/src/Websocket/Message/Message.php +++ b/extensions/realtime/src/Websocket/Message/Message.php @@ -45,7 +45,7 @@ public function respond(): void /** * The channel carrying the identities of users who are typing while hiding their * online status. Subscription requires `user.viewLastSeenAt` — see - * {@link \Flarum\Realtime\Websocket\Api\AuthController::typingIdentified()}. + * {@link \Flarum\Realtime\Websocket\Api\DefaultChannels::typingIdentified()}. */ public static function identifiedTypingChannel(int $discussionId): string { diff --git a/extensions/realtime/src/WebsocketProvider.php b/extensions/realtime/src/WebsocketProvider.php index 8256a3c7bd..eb883824e1 100644 --- a/extensions/realtime/src/WebsocketProvider.php +++ b/extensions/realtime/src/WebsocketProvider.php @@ -12,6 +12,7 @@ use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\Config; use Flarum\Realtime\Push\RealtimeRegistry; +use Flarum\Realtime\Websocket\Api\ChannelRegistry; use Flarum\Realtime\Websocket\Api\PresenceChannelAuthorizer; use Flarum\Realtime\Websocket\Channel\Manager; use Flarum\Realtime\Websocket\IndexTypingPresence; @@ -30,6 +31,7 @@ public function register() $this->container->singleton(Manager::class); $this->container->singleton(IndexTypingPresence::class); $this->container->singleton(PresenceChannelAuthorizer::class); + $this->container->singleton(ChannelRegistry::class); $this->container->singleton(TypingIdentity::class); $this->container->singleton(Pusher::class, function (Container $container) { diff --git a/extensions/realtime/tests/integration/api/ExtensionChannelAuthTest.php b/extensions/realtime/tests/integration/api/ExtensionChannelAuthTest.php new file mode 100644 index 0000000000..1fc0808805 --- /dev/null +++ b/extensions/realtime/tests/integration/api/ExtensionChannelAuthTest.php @@ -0,0 +1,181 @@ +extension('flarum-realtime'); + + $this->extend( + (new RealtimeExtender()) + ->privateChannel('acme-readers', function (User $actor, int $id) { + return $actor->hasPermission('acme-readers.view') + && Discussion::whereVisibleTo($actor)->where('id', $id)->exists(); + }) + ->presenceChannel('acme-readers', function (User $actor, ?int $id) { + if ($id === null || ! $actor->hasPermission('acme-readers.view')) { + return false; + } + + return ['displayName' => $actor->display_name]; + }) + ); + + $this->prepareDatabase([ + User::class => [ + $this->normalUser(), // id 2, Members — can see the discussion, not the roster + ['id' => 3, 'username' => 'reader', 'email' => 'reader@machine.local', 'is_email_confirmed' => 1], + ], + Group::class => [ + ['id' => 100, 'name_singular' => 'Reader', 'name_plural' => 'Readers'], + ], + 'group_user' => [ + ['user_id' => 3, 'group_id' => 100], + ], + 'group_permission' => [ + ['group_id' => 100, 'permission' => 'acme-readers.view'], + ], + Discussion::class => [ + ['id' => 1, 'title' => 'Visible', 'user_id' => 2, 'first_post_id' => 1, 'comment_count' => 1], + ], + Post::class => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '

x

'], + ], + ]); + } + + private function authorize(string $channel, ?int $actorId): int + { + $options = ['json' => ['channel_name' => $channel, 'socket_id' => '123.456']]; + + if ($actorId !== null) { + $options['authenticatedAs'] = $actorId; + } + + return $this->send( + $this->request('POST', '/api/websocket/auth', $options) + )->getStatusCode(); + } + + #[Test] + public function extension_private_channel_is_authorized_by_its_own_permission(): void + { + $this->assertSame(200, $this->authorize('private-acme-readers=1', 3)); + } + + /** + * The whole point: user 2 may see the discussion — and is admitted to realtime's + * own typing channel for it — but holds no `acme-readers.view`, so the + * extension's channel is closed to them. + */ + #[Test] + public function seeing_the_discussion_does_not_grant_the_extension_channel(): void + { + $this->assertSame(200, $this->authorize('private-typing=1', 2)); + $this->assertSame(403, $this->authorize('private-acme-readers=1', 2)); + } + + #[Test] + public function extension_private_channel_can_exclude_guests(): void + { + // Guests are admitted to the discussion's typing channel, so this is a + // narrowing the extension could not previously express. + $this->assertSame(200, $this->authorize('private-typing=1', null)); + $this->assertSame(403, $this->authorize('private-acme-readers=1', null)); + } + + #[Test] + public function extension_channel_still_checks_the_object(): void + { + // Holding the permission is not visibility of the thing it names. + $this->assertSame(403, $this->authorize('private-acme-readers=404', 3)); + } + + /** + * Presence channels used to be forum-wide by construction — the subject pattern + * admitted no id — which is why a per-discussion roster could not use one. + */ + #[Test] + public function extension_presence_channel_can_be_scoped_to_an_object(): void + { + $this->assertSame(200, $this->authorize('presence-acme-readers=1', 3)); + $this->assertSame(403, $this->authorize('presence-acme-readers=1', 2)); + } + + #[Test] + public function presence_channels_refuse_guests(): void + { + $this->assertSame(403, $this->authorize('presence-acme-readers=1', null)); + } + + #[Test] + public function an_unregistered_subject_is_refused(): void + { + $this->assertSame(403, $this->authorize('private-acme-nothing=1', 3)); + $this->assertSame(403, $this->authorize('presence-acme-nothing', 3)); + } + + /** + * Subjects used to be resolved with `method_exists()` on the controller, so any + * method name was a channel name: the controller would call `handle('1')` or + * `online('1')` and die on the argument type, handing an unauthenticated caller + * a 500. Channels come from the registry now, and a name that is not registered + * is simply refused. + */ + #[Test] + public function a_controller_method_name_is_not_a_channel_name(): void + { + $this->assertSame(403, $this->authorize('private-handle=1', null)); + $this->assertSame(403, $this->authorize('private-handle=1', 3)); + + // `online` is a presence subject, so it is not a private channel either. + $this->assertSame(403, $this->authorize('private-online=1', null)); + } + + /** + * `private-index-typing-tag={id}` needed its own branch in the controller + * because the subject pattern rejected hyphens. It is an ordinary registration + * now, so the pattern has to accept them. + */ + #[Test] + public function built_in_channels_still_authorize(): void + { + $this->assertSame(200, $this->authorize('private-user=2', 2)); + $this->assertSame(403, $this->authorize('private-user=3', 2)); + $this->assertSame(200, $this->authorize('presence-online', 2)); + } +} diff --git a/extensions/realtime/tests/unit/Extend/RealtimeExtenderTest.php b/extensions/realtime/tests/unit/Extend/RealtimeExtenderTest.php index 7e155faa79..b1167d7a6b 100644 --- a/extensions/realtime/tests/unit/Extend/RealtimeExtenderTest.php +++ b/extensions/realtime/tests/unit/Extend/RealtimeExtenderTest.php @@ -11,7 +11,9 @@ use Flarum\Realtime\Extend\Realtime as RealtimeExtender; use Flarum\Realtime\Push\RealtimeRegistry; +use Flarum\Realtime\Websocket\Api\ChannelRegistry; use Flarum\Realtime\Websocket\Settings; +use Flarum\User\User; use Illuminate\Contracts\Container\Container; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -19,16 +21,18 @@ class RealtimeExtenderTest extends TestCase { private RealtimeRegistry $registry; + private ChannelRegistry $channels; protected function setUp(): void { parent::setUp(); $this->registry = new RealtimeRegistry(); + $this->channels = new ChannelRegistry(); } /** * Runs the extender against a fake container that immediately calls the - * afterResolving callbacks for both Settings and RealtimeRegistry. + * afterResolving callbacks for Settings, RealtimeRegistry and ChannelRegistry. */ private function runExtender(RealtimeExtender $extender): void { @@ -38,10 +42,11 @@ private function runExtender(RealtimeExtender $extender): void $container = $this->createMock(Container::class); $registry = $this->registry; + $channels = $this->channels; $container ->method('afterResolving') - ->willReturnCallback(function (string $abstract, callable $callback) use ($settings, $registry) { + ->willReturnCallback(function (string $abstract, callable $callback) use ($settings, $registry, $channels) { if ($abstract === Settings::class) { $callback($settings); } @@ -49,6 +54,10 @@ private function runExtender(RealtimeExtender $extender): void if ($abstract === RealtimeRegistry::class) { $callback($registry); } + + if ($abstract === ChannelRegistry::class) { + $callback($channels); + } }); $extender->extend($container); @@ -140,6 +149,32 @@ public function register_model_endpoint_registers_with_registry(): void $this->assertSame('dialog-messages', $endpoints['Flarum\\Messages\\DialogMessage']); } + #[Test] + public function private_channel_registers_with_the_channel_registry(): void + { + $extender = (new RealtimeExtender()) + ->privateChannel('acme-readers', fn (User $actor, int $id) => $id === 1); + + $this->runExtender($extender); + + $this->assertTrue($this->channels->authorizePrivate('acme-readers', new User, 1)); + $this->assertFalse($this->channels->authorizePrivate('acme-readers', new User, 2)); + } + + #[Test] + public function presence_channel_registers_with_the_channel_registry(): void + { + $extender = (new RealtimeExtender()) + ->presenceChannel('acme-readers', fn (User $actor, ?int $id) => ['scopedTo' => $id]); + + $this->runExtender($extender); + + $this->assertSame( + ['scopedTo' => 5], + $this->channels->authorizePresence('acme-readers', new User, 5) + ); + } + #[Test] public function extender_can_be_chained(): void { @@ -148,7 +183,9 @@ public function extender_can_be_chained(): void ->broadcastModelEvent('EventB', fn ($e) => $e, null, 'nameB') ->broadcastDialogEvent('DialogEvent', fn ($e) => $e->message) ->broadcastFlagEvent('FlagEvent', fn ($e) => $e->discussion, 'flagged') - ->registerModelEndpoint('MyModel', 'my-models'); + ->registerModelEndpoint('MyModel', 'my-models') + ->privateChannel('acme-readers', fn () => true) + ->presenceChannel('acme-readers', fn () => ['ok' => true]); $this->runExtender($extender); @@ -156,6 +193,8 @@ public function extender_can_be_chained(): void $this->assertCount(1, $this->registry->getDialogEvents()); $this->assertCount(1, $this->registry->getFlagEvents()); $this->assertCount(1, $this->registry->getModelEndpoints()); + $this->assertTrue($this->channels->authorizePrivate('acme-readers', new User, 1)); + $this->assertSame(['ok' => true], $this->channels->authorizePresence('acme-readers', new User, null)); } #[Test] diff --git a/extensions/realtime/tests/unit/Websocket/ChannelRegistryTest.php b/extensions/realtime/tests/unit/Websocket/ChannelRegistryTest.php new file mode 100644 index 0000000000..318f9c0d84 --- /dev/null +++ b/extensions/realtime/tests/unit/Websocket/ChannelRegistryTest.php @@ -0,0 +1,148 @@ +registry = new ChannelRegistry; + } + + private function actor(): User + { + return new User; + } + + #[Test] + public function registered_private_channel_is_authorized_by_its_callback(): void + { + $this->registry->addPrivate('acme', fn (User $actor, int $id) => $id === 1); + + $this->assertTrue($this->registry->authorizePrivate('acme', $this->actor(), 1)); + $this->assertFalse($this->registry->authorizePrivate('acme', $this->actor(), 2)); + } + + #[Test] + public function private_callback_receives_the_channel_id(): void + { + $seen = null; + + $this->registry->addPrivate('acme', function (User $actor, int $id) use (&$seen) { + $seen = $id; + + return true; + }); + + $this->registry->authorizePrivate('acme', $this->actor(), 42); + + $this->assertSame(42, $seen); + } + + #[Test] + public function unregistered_private_subject_is_refused(): void + { + $this->assertFalse($this->registry->authorizePrivate('nope', $this->actor(), 1)); + } + + /** + * Guards against a callback whose truthy-but-not-true return (a model, a + * non-empty string) is mistaken for permission. + */ + #[Test] + public function private_callback_must_return_exactly_true(): void + { + $this->registry->addPrivate('truthy', fn () => 'yes'); + $this->registry->addPrivate('nullish', fn () => null); + + $this->assertFalse($this->registry->authorizePrivate('truthy', $this->actor(), 1)); + $this->assertFalse($this->registry->authorizePrivate('nullish', $this->actor(), 1)); + } + + #[Test] + public function registered_presence_channel_returns_its_member_data(): void + { + $this->registry->addPresence('acme', fn () => ['displayName' => 'Alice']); + + $this->assertSame( + ['displayName' => 'Alice'], + $this->registry->authorizePresence('acme', $this->actor(), null) + ); + } + + #[Test] + public function presence_callback_receives_the_optional_channel_id(): void + { + $seen = 'unset'; + + $this->registry->addPresence('acme', function (User $actor, ?int $id) use (&$seen) { + $seen = $id; + + return []; + }); + + $this->registry->authorizePresence('acme', $this->actor(), 7); + $this->assertSame(7, $seen); + + $this->registry->authorizePresence('acme', $this->actor(), null); + $this->assertNull($seen); + } + + #[Test] + public function presence_channel_refusal_yields_no_member_data(): void + { + $this->registry->addPresence('refuses', fn () => false); + + $this->assertNull($this->registry->authorizePresence('refuses', $this->actor(), null)); + $this->assertNull($this->registry->authorizePresence('unregistered', $this->actor(), null)); + } + + /** + * A second registration silently overwriting the first would give one + * extension's channel another extension's permissions. + */ + #[Test] + public function a_subject_cannot_be_registered_twice(): void + { + $this->registry->addPrivate('acme', fn () => true); + + $this->expectException(InvalidArgumentException::class); + + $this->registry->addPrivate('acme', fn () => true); + } + + #[Test] + public function private_and_presence_subjects_are_separate_namespaces(): void + { + $this->registry->addPrivate('acme', fn () => true); + $this->registry->addPresence('acme', fn () => ['ok' => true]); + + $this->assertTrue($this->registry->authorizePrivate('acme', $this->actor(), 1)); + $this->assertSame(['ok' => true], $this->registry->authorizePresence('acme', $this->actor(), null)); + } +}