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)); + } +}