[2.x] feat(realtime): let extensions register their own websocket channels - #4913
Open
ekumanov wants to merge 1 commit into
Open
[2.x] feat(realtime): let extensions register their own websocket channels#4913ekumanov wants to merge 1 commit into
ekumanov wants to merge 1 commit into
Conversation
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 1 of #4912. Fixes the closed-set half; the client-event relay hook is
deferred to a second PR, as discussed there.
The problem
AuthControllerresolves a channel's subject to a method on itself:so the only channels that can exist are the ones it defines.
Extend\Realtime::authorizePresenceChannel()adds a guard to an existing channelrather than defining one, so there is no way for an extension to obtain a channel
of its own. It has to put its data on one of realtime's instead and inherit that
channel's audience — for
private-typing={id}that is everyone who can see thediscussion, guests included, whatever permission the extension checks when it
renders.
Changes
A
ChannelRegistry, populated by the extender.Authorization stays where it already was: an ordinary HTTP request, once per
subscription, with a real actor and the full permission machinery. The websocket
server still does no permission work per event.
Realtime registers its own channels the same way, from
extend.php, insteadof keeping a private path beside the public one. The subject authorizers move to
Websocket\Api\DefaultChannelswith their explanations intact;AuthControlleris left routing.
Channel-name patterns. A subject may now contain hyphens, which retires the
private-index-typing-tag={id}special case that existed because[a-zA-Z]+could not match it. A presence channel may now carry
={id}: presence channelswere forum-wide by construction, which is why a roster scoped to one object
couldn't use one.
Guest handling is unchanged, and now stated. Private channels have never
required a session, so the callback decides; presence channels key a member list
by user id, so guests are refused before it runs.
Duplicate registration throws, rather than silently giving one extension's
channel another extension's permissions.
Drive-by fix
Because
method_exists()matched any method, not just the authorizers, everymethod name was a channel name.
private-handle=1andprivate-online=1calledhandle('1')/online('1'), died on the argument type and returned 500 to anunauthenticated caller. No authorization was bypassed —
authorizeChannel()isnever reached — but unregistered subjects are now simply refused, with a
regression test.
Compatibility
authorize identically (byte-identical signatures before/after against a running
forum).
authorizePresenceChannel()is untouched and keeps working; guards stack,definitions don't. The docblock now says which to reach for.
AuthController's constructor drops the unusedPush\Payload\Generatorandgains the registry. It's resolved from the container by the route.
Reviewers may want to look at
separate path. Dogfooding is what makes it one code path, and it's most of the
churn in
AuthController— happy to reduce it to an explicit built-inallowlist plus a registry for extensions if you'd rather have a smaller diff.
extensions choosing the same subject would take the forum down until one is
disabled. The alternative is logging and keeping the first.
privatechannels admitting guests. Preserved from today's behaviour, butit is a sharp edge for anyone registering one, so it's documented on both the
extender method and the registry.
Tests
tests/unit/Websocket/ChannelRegistryTest.php— registration and refusalsemantics: unregistered subjects, callbacks returning truthy-but-not-
true,optional presence id, duplicate registration, private/presence subjects being
separate namespaces.
tests/unit/Extend/RealtimeExtenderTest.php— the two new extender methodsreach the registry, and stay chainable with the rest.
tests/integration/api/ExtensionChannelAuthTest.php— drives/api/websocket/auth: an extension channel authorized by its own permissionand refused to someone who can see the discussion but lacks it; guests excluded
from an extension channel while still admitted to
private-typing; a presencechannel scoped with
={id}; unregistered subjects; the controller-methodregression; built-in channels still authorizing.
extensions/realtimeunit 57 passed, integration 53 passed (1 pre-existingskip), PHPStan level 6 clean. Run on PHP 8.4 / MySQL 8.0.
🤖 Generated with Claude Code