Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion extensions/realtime/extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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),

Expand Down Expand Up @@ -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)
Expand Down
107 changes: 106 additions & 1 deletion extensions/realtime/src/Extend/Realtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,12 +50,32 @@ class Realtime implements ExtenderInterface
*/
protected array $presenceChannelGuards = [];

/**
* @var array<string, callable>
*/
protected array $privateChannels = [];

/**
* @var array<string, callable>
*/
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) {
Expand Down Expand Up @@ -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.
*
Expand Down
146 changes: 47 additions & 99 deletions extensions/realtime/src/Websocket/Api/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,129 +18,81 @@
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-(?<subject>[a-zA-Z][a-zA-Z0-9-]*)=(?<id>[0-9]+)$~';
private const PRESENCE_CHANNEL = '~^presence-(?<subject>[a-zA-Z][a-zA-Z0-9-]*)(?:=(?<id>[0-9]+))?$~';

public function __construct(
protected Pusher $pusher,
protected Generator $generator,
protected PresenceChannelAuthorizer $presenceAuthorizer,
protected SettingsRepositoryInterface $settings
protected ChannelRegistry $registry,
protected PresenceChannelAuthorizer $presenceAuthorizer
) {
}

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=(?<id>[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-(?<subject>[a-zA-Z]+)=(?<id>[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-(?<subject>[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
];
}
}
Loading
Loading