From 93ae3a2b80833664f517d6622b9d7a1069c19317 Mon Sep 17 00:00:00 2001 From: Fan2Shrek Date: Thu, 12 Dec 2024 22:21:24 +0100 Subject: [PATCH] [Turbo] Add support for authentication to the EventSource via `turbo_stream_listen` --- src/Turbo/CHANGELOG.md | 1 + .../assets/dist/turbo_stream_controller.d.ts | 2 + .../assets/dist/turbo_stream_controller.js | 3 +- .../assets/src/turbo_stream_controller.ts | 4 +- src/Turbo/config/services.php | 9 ++- src/Turbo/doc/index.rst | 3 + .../Mercure/TurboStreamListenRenderer.php | 25 ++++++++- .../Compiler/RegisterMercureHubsPass.php | 53 ++++++++++++++++++ .../DependencyInjection/TurboExtension.php | 2 +- src/Turbo/src/TurboBundle.php | 3 + src/Turbo/src/Twig/TurboRuntime.php | 55 +++++++++++++++++++ .../TurboStreamListenRendererInterface.php | 2 +- ...reamListenRendererWithOptionsInterface.php | 19 +++++++ src/Turbo/src/Twig/TwigExtension.php | 29 +--------- .../Mercure/TurboStreamListenRendererTest.php | 8 +++ .../Compiler/RegisterMercureHubsPassTest.php | 51 +++++++++++++++++ 16 files changed, 233 insertions(+), 36 deletions(-) create mode 100644 src/Turbo/src/DependencyInjection/Compiler/RegisterMercureHubsPass.php create mode 100644 src/Turbo/src/Twig/TurboRuntime.php create mode 100644 src/Turbo/src/Twig/TurboStreamListenRendererWithOptionsInterface.php create mode 100644 src/Turbo/tests/Compiler/RegisterMercureHubsPassTest.php diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md index 9bf757acdb0..fef22f69cf1 100644 --- a/src/Turbo/CHANGELOG.md +++ b/src/Turbo/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.24.0 - Add Twig Extensions for `meta` tags +- Add support for authentication to the EventSource via `turbo_stream_listen` ## 2.22.0 diff --git a/src/Turbo/assets/dist/turbo_stream_controller.d.ts b/src/Turbo/assets/dist/turbo_stream_controller.d.ts index 2806afea3cc..cc4db88a562 100644 --- a/src/Turbo/assets/dist/turbo_stream_controller.d.ts +++ b/src/Turbo/assets/dist/turbo_stream_controller.d.ts @@ -4,11 +4,13 @@ export default class extends Controller { topic: StringConstructor; topics: ArrayConstructor; hub: StringConstructor; + withCredentials: BooleanConstructor; }; es: EventSource | undefined; url: string | undefined; readonly topicValue: string; readonly topicsValue: string[]; + readonly withCredentialsValue: boolean; readonly hubValue: string; readonly hasHubValue: boolean; readonly hasTopicValue: boolean; diff --git a/src/Turbo/assets/dist/turbo_stream_controller.js b/src/Turbo/assets/dist/turbo_stream_controller.js index 3d55567c772..d5962232feb 100644 --- a/src/Turbo/assets/dist/turbo_stream_controller.js +++ b/src/Turbo/assets/dist/turbo_stream_controller.js @@ -23,7 +23,7 @@ class default_1 extends Controller { } connect() { if (this.url) { - this.es = new EventSource(this.url); + this.es = new EventSource(this.url, { withCredentials: this.withCredentialsValue }); connectStreamSource(this.es); } } @@ -38,6 +38,7 @@ default_1.values = { topic: String, topics: Array, hub: String, + withCredentials: Boolean, }; export { default_1 as default }; diff --git a/src/Turbo/assets/src/turbo_stream_controller.ts b/src/Turbo/assets/src/turbo_stream_controller.ts index 4c8fd4d915a..aaa19c78396 100644 --- a/src/Turbo/assets/src/turbo_stream_controller.ts +++ b/src/Turbo/assets/src/turbo_stream_controller.ts @@ -18,12 +18,14 @@ export default class extends Controller { topic: String, topics: Array, hub: String, + withCredentials: Boolean, }; es: EventSource | undefined; url: string | undefined; declare readonly topicValue: string; declare readonly topicsValue: string[]; + declare readonly withCredentialsValue: boolean; declare readonly hubValue: string; declare readonly hasHubValue: boolean; declare readonly hasTopicValue: boolean; @@ -50,7 +52,7 @@ export default class extends Controller { connect() { if (this.url) { - this.es = new EventSource(this.url); + this.es = new EventSource(this.url, { withCredentials: this.withCredentialsValue }); connectStreamSource(this.es); } } diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php index 0344e95a6e9..e3a3fabcde5 100644 --- a/src/Turbo/config/services.php +++ b/src/Turbo/config/services.php @@ -18,6 +18,7 @@ use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster; use Symfony\UX\Turbo\Doctrine\BroadcastListener; use Symfony\UX\Turbo\Request\RequestListener; +use Symfony\UX\Turbo\Twig\TurboRuntime; use Symfony\UX\Turbo\Twig\TwigExtension; /* @@ -47,9 +48,15 @@ ->decorate('turbo.broadcaster.imux') ->set('turbo.twig.extension', TwigExtension::class) - ->args([tagged_locator('turbo.renderer.stream_listen', 'transport'), abstract_arg('default')]) ->tag('twig.extension') + ->set('turbo.twig.runtime', TurboRuntime::class) + ->args([ + tagged_locator('turbo.renderer.stream_listen', 'transport'), + abstract_arg('default_transport'), + ]) + ->tag('twig.runtime') + ->set('turbo.doctrine.event_listener', BroadcastListener::class) ->args([ service('turbo.broadcaster.imux'), diff --git a/src/Turbo/doc/index.rst b/src/Turbo/doc/index.rst index 9f5a5b7a8f9..d215aaaa8df 100644 --- a/src/Turbo/doc/index.rst +++ b/src/Turbo/doc/index.rst @@ -754,6 +754,9 @@ Let's create our chat:: {% endblock %} +If you're using a private hub, you can add ``{ withCredentials: true }`` +as ``turbo_stream_listen()`` third argument to authenticate with the hub + .. code-block:: html+twig {# chat/message.stream.html.twig #} diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 68eadd82079..a5e511ca9e7 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -12,18 +12,20 @@ namespace Symfony\UX\Turbo\Bridge\Mercure; use Symfony\Component\Mercure\HubInterface; +use Symfony\Component\Mercure\Twig\MercureExtension; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\UX\Turbo\Broadcaster\IdAccessor; -use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; +use Symfony\UX\Turbo\Twig\TurboStreamListenRendererWithOptionsInterface; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; use Twig\Environment; +use Twig\Error\RuntimeError; /** * Renders the attributes to load the "mercure-turbo-stream" controller. * * @author Kévin Dunglas */ -final class TurboStreamListenRenderer implements TurboStreamListenRendererInterface +final class TurboStreamListenRenderer implements TurboStreamListenRendererWithOptionsInterface { private StimulusHelper $stimulusHelper; @@ -31,6 +33,7 @@ public function __construct( private HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, private IdAccessor $idAccessor, + private Environment $twig, ) { if ($stimulus instanceof StimulusTwigExtension) { trigger_deprecation('symfony/ux-turbo', '2.9', 'Passing an instance of "%s" as second argument of "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); @@ -42,8 +45,12 @@ public function __construct( $this->stimulusHelper = $stimulus; } - public function renderTurboStreamListen(Environment $env, $topic): string + public function renderTurboStreamListen(Environment $env, $topic /* array $eventSourceOptions = [] */): string { + if (\func_num_args() > 2) { + $eventSourceOptions = func_get_arg(2); + } + $topics = $topic instanceof TopicSet ? array_map($this->resolveTopic(...), $topic->getTopics()) : [$this->resolveTopic($topic)]; @@ -55,6 +62,18 @@ public function renderTurboStreamListen(Environment $env, $topic): string $controllerAttributes['topic'] = current($topics); } + if (isset($eventSourceOptions)) { + try { + $mercure = $this->twig->getExtension(MercureExtension::class); + $mercure->mercure($topics, $eventSourceOptions); + + if (isset($eventSourceOptions['withCredentials'])) { + $controllerAttributes['withCredentials'] = $eventSourceOptions['withCredentials']; + } + } catch (RuntimeError $e) { + } + } + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); $stimulusAttributes->addController( 'symfony/ux-turbo/mercure-turbo-stream', diff --git a/src/Turbo/src/DependencyInjection/Compiler/RegisterMercureHubsPass.php b/src/Turbo/src/DependencyInjection/Compiler/RegisterMercureHubsPass.php new file mode 100644 index 00000000000..ebe97b68e98 --- /dev/null +++ b/src/Turbo/src/DependencyInjection/Compiler/RegisterMercureHubsPass.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\UX\Turbo\Bridge\Mercure\Broadcaster; +use Symfony\UX\Turbo\Bridge\Mercure\TurboStreamListenRenderer; + +/** + * This compiler pass ensures that TurboStreamListenRenderer + * and Broadcast are registered per Mercure hub. + * + * @author Pierre Ambroise + */ +final class RegisterMercureHubsPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + foreach ($container->findTaggedServiceIds('mercure.hub') as $hubId => $tags) { + $name = str_replace('mercure.hub.', '', $hubId); + + $container->register("turbo.mercure.$name.renderer", TurboStreamListenRenderer::class) + ->addArgument(new Reference($hubId)) + ->addArgument(new Reference('turbo.mercure.stimulus_helper')) + ->addArgument(new Reference('turbo.id_accessor')) + ->addArgument(new Reference('twig')) + ->addTag('turbo.renderer.stream_listen', ['transport' => $name]); + + foreach ($tags as $tag) { + if (isset($tag['default']) && $tag['default'] && 'default' !== $name) { + $container->getDefinition("turbo.mercure.$name.renderer") + ->addTag('turbo.renderer.stream_listen', ['transport' => 'default']); + } + } + + $container->register("turbo.mercure.$name.broadcaster", Broadcaster::class) + ->addArgument($name) + ->addArgument(new Reference($hubId)) + ->addTag('turbo.broadcaster'); + } + } +} diff --git a/src/Turbo/src/DependencyInjection/TurboExtension.php b/src/Turbo/src/DependencyInjection/TurboExtension.php index ca53f6d2b09..761d8e4b27e 100644 --- a/src/Turbo/src/DependencyInjection/TurboExtension.php +++ b/src/Turbo/src/DependencyInjection/TurboExtension.php @@ -37,7 +37,7 @@ public function load(array $configs, ContainerBuilder $container): void $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config'))); $loader->load('services.php'); - $container->getDefinition('turbo.twig.extension')->replaceArgument(1, $config['default_transport']); + $container->getDefinition('turbo.twig.runtime')->replaceArgument(1, $config['default_transport']); $this->registerTwig($config, $container); $this->registerBroadcast($config, $container, $loader); diff --git a/src/Turbo/src/TurboBundle.php b/src/Turbo/src/TurboBundle.php index e1524e4ba34..ca149e8fb1a 100644 --- a/src/Turbo/src/TurboBundle.php +++ b/src/Turbo/src/TurboBundle.php @@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\UX\Turbo\DependencyInjection\Compiler\RegisterMercureHubsPass; /** * @author Kévin Dunglas @@ -28,6 +29,8 @@ public function build(ContainerBuilder $container): void { parent::build($container); + $container->addCompilerPass(new RegisterMercureHubsPass()); + $container->addCompilerPass(new class implements CompilerPassInterface { public function process(ContainerBuilder $container): void { diff --git a/src/Turbo/src/Twig/TurboRuntime.php b/src/Turbo/src/Twig/TurboRuntime.php new file mode 100644 index 00000000000..30238c64ee3 --- /dev/null +++ b/src/Turbo/src/Twig/TurboRuntime.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Twig; + +use Psr\Container\ContainerInterface; +use Symfony\UX\Turbo\Bridge\Mercure\TopicSet; +use Twig\Environment; +use Twig\Extension\RuntimeExtensionInterface; + +/** + * @author Kévin Dunglas + * @author Pierre Ambroise + * + * @internal + */ +final class TurboRuntime implements RuntimeExtensionInterface +{ + public function __construct( + private ContainerInterface $turboStreamListenRenderers, + private readonly string $defaultTransport, + ) { + } + + /** + * @param object|string|array $topic + * @param array $options + */ + public function renderTurboStreamListen(Environment $env, $topic, ?string $transport = null, array $options = []): string + { + $options['transport'] = $transport ??= $this->defaultTransport; + + if (!$this->turboStreamListenRenderers->has($transport)) { + throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport)); + } + + if (\is_array($topic)) { + $topic = new TopicSet($topic); + } + + $renderer = $this->turboStreamListenRenderers->get($transport); + + return $renderer instanceof TurboStreamListenRendererWithOptionsInterface + ? $renderer->renderTurboStreamListen($env, $topic, $options) // @phpstan-ignore-line + : $renderer->renderTurboStreamListen($env, $topic); + } +} diff --git a/src/Turbo/src/Twig/TurboStreamListenRendererInterface.php b/src/Turbo/src/Twig/TurboStreamListenRendererInterface.php index 3670e40bd28..240721317f1 100644 --- a/src/Turbo/src/Twig/TurboStreamListenRendererInterface.php +++ b/src/Turbo/src/Twig/TurboStreamListenRendererInterface.php @@ -23,5 +23,5 @@ interface TurboStreamListenRendererInterface /** * @param string|object $topic */ - public function renderTurboStreamListen(Environment $env, $topic): string; + public function renderTurboStreamListen(Environment $env, $topic /* , array $eventSourceOptions = [] */): string; } diff --git a/src/Turbo/src/Twig/TurboStreamListenRendererWithOptionsInterface.php b/src/Turbo/src/Twig/TurboStreamListenRendererWithOptionsInterface.php new file mode 100644 index 00000000000..6364fe3b97b --- /dev/null +++ b/src/Turbo/src/Twig/TurboStreamListenRendererWithOptionsInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Twig; + +/** + * @internal + */ +interface TurboStreamListenRendererWithOptionsInterface extends TurboStreamListenRendererInterface +{ +} diff --git a/src/Turbo/src/Twig/TwigExtension.php b/src/Turbo/src/Twig/TwigExtension.php index eb7aedfe806..f3e992ce0a0 100644 --- a/src/Turbo/src/Twig/TwigExtension.php +++ b/src/Turbo/src/Twig/TwigExtension.php @@ -11,9 +11,6 @@ namespace Symfony\UX\Turbo\Twig; -use Psr\Container\ContainerInterface; -use Symfony\UX\Turbo\Bridge\Mercure\TopicSet; -use Twig\Environment; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -28,16 +25,10 @@ final class TwigExtension extends AbstractExtension private const REFRESH_SCROLL_RESET = 'reset'; private const REFRESH_SCROLL_PRESERVE = 'preserve'; - public function __construct( - private ContainerInterface $turboStreamListenRenderers, - private string $default, - ) { - } - public function getFunctions(): array { return [ - new TwigFunction('turbo_stream_listen', $this->turboStreamListen(...), ['needs_environment' => true, 'is_safe' => ['html']]), + new TwigFunction('turbo_stream_listen', [TurboRuntime::class, 'renderTurboStreamListen'], ['needs_environment' => true, 'is_safe' => ['html']]), new TwigFunction('turbo_exempts_page_from_cache', $this->turboExemptsPageFromCache(...), ['is_safe' => ['html']]), new TwigFunction('turbo_exempts_page_from_preview', $this->turboExemptsPageFromPreview(...), ['is_safe' => ['html']]), new TwigFunction('turbo_page_requires_reload', $this->turboPageRequiresReload(...), ['is_safe' => ['html']]), @@ -47,24 +38,6 @@ public function getFunctions(): array ]; } - /** - * @param object|string|array $topic - */ - public function turboStreamListen(Environment $env, $topic, ?string $transport = null): string - { - $transport ??= $this->default; - - if (!$this->turboStreamListenRenderers->has($transport)) { - throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport)); - } - - if (\is_array($topic)) { - $topic = new TopicSet($topic); - } - - return $this->turboStreamListenRenderers->get($transport)->renderTurboStreamListen($env, $topic); - } - /** * Generates a tag to disable caching of a page. * diff --git a/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php index 9b19ba4db09..108715b06f8 100644 --- a/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php +++ b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php @@ -71,5 +71,13 @@ public static function provideTestCases(): iterable ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="["a_topic","AppEntityBook","https:\/\/symfony.com\/ux-turbo\/App%5CEntity%5CBook\/123"]"' : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="["a_topic","AppEntityBook","https:\/\/symfony.com\/ux-turbo\/App%5CEntity%5CBook\/123"]"', ]; + + yield [ + "{{ turbo_stream_listen('a_topic', 'default', { withCredentials: true }) }}", + [], + $newEscape + ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic" data-symfony--ux-turbo--mercure-turbo-stream-with-credentials-value="true"' + : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic" data-symfony--ux-turbo--mercure-turbo-stream-with-credentials-value="true"', + ]; } } diff --git a/src/Turbo/tests/Compiler/RegisterMercureHubsPassTest.php b/src/Turbo/tests/Compiler/RegisterMercureHubsPassTest.php new file mode 100644 index 00000000000..5d369ab886f --- /dev/null +++ b/src/Turbo/tests/Compiler/RegisterMercureHubsPassTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace src\Turbo\tests\Compiler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\UX\Turbo\DependencyInjection\Compiler\RegisterMercureHubsPass; + +final class RegisterMercureHubsPassTest extends TestCase +{ + public function testProcess(): void + { + $pass = new RegisterMercureHubsPass(); + + $container = new ContainerBuilder(); + $container->register('hub') + ->addTag('mercure.hub'); + + $pass->process($container); + + $this->assertTrue($container->has('turbo.mercure.hub.renderer')); + $this->assertTrue($container->has('turbo.mercure.hub.broadcaster')); + } + + public function testProcessWithDefault(): void + { + $pass = new RegisterMercureHubsPass(); + + $container = new ContainerBuilder(); + $container->register('hub1') + ->addTag('mercure.hub'); + + $container->register('default_hub') + ->addTag('mercure.hub', ['default' => true]); + + $pass->process($container); + + $this->assertSame([ + 'transport' => 'default', + ], $container->getDefinition('turbo.mercure.default_hub.renderer')->getTag('turbo.renderer.stream_listen')[1]); + } +}