diff --git a/.env.dist b/.env.dist index eeafa62..07a293b 100644 --- a/.env.dist +++ b/.env.dist @@ -7,3 +7,9 @@ MYSQL_VERSION=8.0.27 MYSQL_DATABASE=databasorus MYSQL_USER=user MYSQL_PASSWORD=password + +MERCURE_URL=http://mercure/.well-known/mercure +MERCURE_PUBLIC_URL=http://localhost:81/.well-known/mercure +MERCURE_SERVER_NAME=:80 +MERCURE_CORS_ALLOWED_ORIGINS="http://localhost" +MERCURE_JWT_KEY=!ChangeThisMercureHubJWTSecretKey! diff --git a/composer.json b/composer.json index f760f5c..94a5088 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "symfony/http-client": "^7.0", "symfony/intl": "^7.0", "symfony/mailer": "^7.0", + "symfony/mercure-bundle": "^0.3.8", "symfony/mime": "^7.0", "symfony/monolog-bundle": "^3.10", "symfony/notifier": "^7.0", diff --git a/composer.lock b/composer.lock index a3c72ad..44cc58d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e1d68fda857d3a6a99a9c9d4e0c1c4dd", + "content-hash": "c8a29d7d9743605bbbbbff21d5cf0908", "packages": [ { "name": "composer/package-versions-deprecated", @@ -1505,6 +1505,79 @@ ], "time": "2023-10-18T10:00:55+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.27.0", + "lcobucci/clock": "^3.0", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2.9", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^10.2.6" + }, + "suggest": { + "lcobucci/clock": ">= 3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.3.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2024-04-11T23:07:54+00:00" + }, { "name": "monolog/monolog", "version": "3.6.0", @@ -4193,6 +4266,173 @@ ], "time": "2024-03-28T09:20:36+00:00" }, + { + "name": "symfony/mercure", + "version": "v0.6.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure.git", + "reference": "304cf84609ef645d63adc65fc6250292909a461b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure/zipball/304cf84609ef645d63adc65fc6250292909a461b", + "reference": "304cf84609ef645d63adc65fc6250292909a461b", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/deprecation-contracts": "^2.0|^3.0|^4.0", + "symfony/http-client": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-foundation": "^4.4|^5.0|^6.0|^7.0", + "symfony/polyfill-php80": "^1.22", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", + "symfony/stopwatch": "^4.4|^5.0|^6.0|^7.0", + "twig/twig": "^2.0|^3.0|^4.0" + }, + "suggest": { + "symfony/stopwatch": "Integration with the profiler performances" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.6.x-dev" + }, + "thanks": { + "name": "dunglas/mercure", + "url": "https://github.com/dunglas/mercure" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mercure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Mercure Component", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure/issues", + "source": "https://github.com/symfony/mercure/tree/v0.6.5" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure", + "type": "tidelift" + } + ], + "time": "2024-04-08T12:51:34+00:00" + }, + { + "name": "symfony/mercure-bundle", + "version": "v0.3.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure-bundle.git", + "reference": "e21ad84694b84c9a3c94bedf4edd82b66728abfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/e21ad84694b84c9a3c94bedf4edd82b66728abfc", + "reference": "e21ad84694b84c9a3c94bedf4edd82b66728abfc", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "php": ">=7.1.3", + "symfony/config": "^4.4|^5.0|^6.0|^7.0", + "symfony/dependency-injection": "^4.4|^5.4|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/mercure": "^0.6.1", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.3.7|^5.0|^6.0|^7.0", + "symfony/stopwatch": "^4.3.7|^5.0|^6.0|^7.0", + "symfony/ux-turbo": "*", + "symfony/var-dumper": "^4.3.7|^5.0|^6.0|^7.0" + }, + "suggest": { + "symfony/messenger": "To use the Messenger integration" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MercureBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MercureBundle", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure-bundle/issues", + "source": "https://github.com/symfony/mercure-bundle/tree/v0.3.8" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle", + "type": "tidelift" + } + ], + "time": "2023-12-03T22:26:24+00:00" + }, { "name": "symfony/mime", "version": "v7.0.6", diff --git a/config/bundles.php b/config/bundles.php index 3106eee..74d202e 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -12,4 +12,5 @@ Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], ]; diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml new file mode 100644 index 0000000..4a27845 --- /dev/null +++ b/config/packages/mercure.yaml @@ -0,0 +1,8 @@ +mercure: + hubs: + default: + url: '%env(MERCURE_URL)%' + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_KEY)%' + publish: '*' diff --git a/config/services.yaml b/config/services.yaml index 1d3246d..35c5c1c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,5 +24,18 @@ services: - '../src/Entity/' - '../src/Kernel.php' + App\Listener\MercureDiscoveryListener: + tags: + - { name: kernel.event_listener, event: ResponseEvent, method: onKernelResponse } + + App\Listener\MercureAuthorizationListener: + tags: + - { name: kernel.event_listener, event: ResponseEvent, method: onKernelResponse } + + App\Listener\AuthenticationListener: + tags: + - { name: kernel.event_listener, event: loginSuccessEvent, method: onLoginSuccess } + - { name: kernel.event_listener, event: loginFailureEvent, method: onLoginFailure } + # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/docker-compose.yml b/docker-compose.yml index 6f9f8ba..3721932 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,18 @@ services: volumes: - db-data:/var/lib/mysql:rw restart: unless-stopped + mercure: + image: dunglas/mercure + restart: unless-stopped + command: /usr/bin/caddy run --config /etc/caddy/Caddyfile.dev + environment: + SERVER_NAME: ${MERCURE_SERVER_NAME} + MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_KEY} + MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_KEY} + MERCURE_EXTRA_DIRECTIVES: | + cors_origins ${MERCURE_CORS_ALLOWED_ORIGINS} + ports: + - 81:80 volumes: db-data: ~ diff --git a/public/js/activity.js b/public/js/activity.js new file mode 100644 index 0000000..d973042 --- /dev/null +++ b/public/js/activity.js @@ -0,0 +1,68 @@ +const activityContainer = document.querySelector('#activity-container') + + +const dicoverMercureHub = async () => fetch(window.location) + .then(response => { + const hubUrl = response + .headers + .get('Link') + .match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1]; + + return { + url: new URL(hubUrl), + token: response.headers.get('X-Mercure-JWT'), + } + } +) + +const addLogItem = (message) => { + const item = document.createElement('li') + + item.setAttribute('class', 'alert alert-primary') + item.setAttribute('role', 'alert') + + item.innerHTML = message + + activityContainer.append(item) +} + +document.addEventListener('DOMContentLoaded', async () => { + const { url, token } = await dicoverMercureHub() + + url.searchParams.append('topic', 'http://localhost/activity') + url.searchParams.append('topic', 'http://localhost/dinosaurs') + + const options = token ? { headers: { Authorization: `Bearer ${token}` }, withCredentials: true } : {} + + const es = new EventSourcePolyfill(url, options) + + es.addEventListener('login', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) + + es.addEventListener('logout', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) + + es.addEventListener('created', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) + + es.addEventListener('updated', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) + + es.addEventListener('deleted', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) +}); diff --git a/public/js/dinosaurs.js b/public/js/dinosaurs.js new file mode 100644 index 0000000..c1929d1 --- /dev/null +++ b/public/js/dinosaurs.js @@ -0,0 +1,100 @@ +const alertContainer = document.querySelector('#alert-container') +const template = document.querySelector('#dinosaur-item-template') +const dinosaurList = document.querySelector('#dinosaurs-list') + +const dicoverMercureHub = async () => fetch(window.location) + .then(response => { + const hubUrl = response + .headers + .get('Link') + .match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1]; + + return { + url: new URL(hubUrl), + token: response.headers.get('X-Mercure-JWT'), + } + } +) + +const displayToast = (message) => { + alertContainer.innerHTML = `
${message}
` + + window.setTimeout(() => { + const alert = document.querySelector('.alert') + alert.parentNode.removeChild(alert) + }, 5000) +} + +const addDinosaur = (id, name, link, message) => { + const clone = template.content.cloneNode(true) + const dinosaurTemplateNameContainer = clone.querySelector('.dinosaur-name') + const dinosaurTemplateLinkContainer = clone.querySelector('.dinosaur-link') + + dinosaurTemplateNameContainer.innerHTML = name + dinosaurTemplateLinkContainer.href = link + dinosaurTemplateLinkContainer['data-id'] = id + + dinosaurList.append(clone) + + displayToast(message) +} + +const updateDinosaur = (id, name, message) => { + const dinosaur = document.querySelector(`[data-id='${id}']`) + + if(dinosaur) { + dinosaur.querySelector('.dinosaur-name').innerHTML = name + + displayToast(message) + } +} + +const removeDinosaur = (id, message) => { + const dinosaur = document.querySelector(`[data-id='${id}']`) + + if(dinosaur) { + dinosaur.parentNode.removeChild(dinosaur) + + displayToast(message) + } +} + +document.addEventListener('DOMContentLoaded', async () => { + const { url, token } = await dicoverMercureHub(); + + url.searchParams.append('topic', 'http://localhost/dinosaurs') + + const options = token ? { headers: { Authorization: `Bearer ${token}` }, withCredentials: true } : {} + + const es = new EventSourcePolyfill(url, options); + + es.addEventListener('created', e => { + const message = JSON.parse(e.data) + + addDinosaur( + message.id, + message.name, + message.link, + message.message + ) + }) + + es.addEventListener('updated', e => { + const message = JSON.parse(e.data) + + updateDinosaur( + message.id, + message.name, + message.message + ) + }) + + es.addEventListener('deleted', e => { + const message = JSON.parse(e.data) + + removeDinosaur( + message.id, + message.message + ) + }) +}); diff --git a/public/js/eventsource.min.js b/public/js/eventsource.min.js new file mode 100644 index 0000000..689c7cd --- /dev/null +++ b/public/js/eventsource.min.js @@ -0,0 +1,6 @@ +/** @license + * eventsource.js + * Available under MIT License (MIT) + * https://github.com/Yaffle/EventSource/ + */ +!function(e){"use strict";var r,H=e.setTimeout,N=e.clearTimeout,j=e.XMLHttpRequest,o=e.XDomainRequest,t=e.ActiveXObject,n=e.EventSource,i=e.document,w=e.Promise,d=e.fetch,a=e.Response,h=e.TextDecoder,s=e.TextEncoder,p=e.AbortController;function c(){this.bitsNeeded=0,this.codePoint=0}"undefined"==typeof window||void 0===i||"readyState"in i||null!=i.body||(i.readyState="loading",window.addEventListener("load",function(e){i.readyState="complete"},!1)),null==j&&null!=t&&(j=function(){return new t("Microsoft.XMLHTTP")}),null==Object.create&&(Object.create=function(e){function t(){}return t.prototype=e,new t}),Date.now||(Date.now=function(){return(new Date).getTime()}),null==p&&(r=d,d=function(e,t){var n=t.signal;return r(e,{headers:t.headers,credentials:t.credentials,cache:t.cache}).then(function(e){var t=e.body.getReader();return n._reader=t,n._aborted&&n._reader.cancel(),{status:e.status,statusText:e.statusText,headers:e.headers,body:{getReader:function(){return t}}}})},p=function(){this.signal={_reader:null,_aborted:!1},this.abort=function(){null!=this.signal._reader&&this.signal._reader.cancel(),this.signal._aborted=!0}}),c.prototype.decode=function(e){function t(e,t,n){if(1===n)return 128>>t<=e&&e<>t<=e&&e<>t<=e&&e<>t<=e&&e<>6?3:31>10)))+String.fromCharCode(56320+(i-65535-1&1023)))}return this.bitsNeeded=o,this.codePoint=i,r};function u(){}null!=h&&null!=s&&function(){try{return"test"===(new h).decode((new s).encode("test"),{stream:!0})}catch(e){}return!1}()||(h=c);function I(e){this.withCredentials=!1,this.readyState=0,this.status=0,this.statusText="",this.responseText="",this.onprogress=u,this.onload=u,this.onerror=u,this.onreadystatechange=u,this._contentType="",this._xhr=e,this._sendTimeout=0,this._abort=u}function l(e){return e.replace(/[A-Z]/g,function(e){return String.fromCharCode(e.charCodeAt(0)+32)})}function f(e){for(var t=Object.create(null),n=e.split("\r\n"),r=0;rrender('activity.html.twig'); + } +} diff --git a/src/Controller/DinosaursController.php b/src/Controller/DinosaursController.php index 4ced07c..85ffc62 100644 --- a/src/Controller/DinosaursController.php +++ b/src/Controller/DinosaursController.php @@ -5,6 +5,10 @@ use App\Entity\Dinosaur; use App\Form\Type\DinosaurType; use App\Form\Type\SearchType; +use App\Service\Realtime\Publisher; +use App\Realtime\Trigger\DinosaurCreated; +use App\Realtime\Trigger\DinosaurDeleted; +use App\Realtime\Trigger\DinosaurUpdated; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -14,8 +18,10 @@ final class DinosaursController extends AbstractController { #[Route('/dinosaurs', name: 'app_list_dinosaurs')] - public function list(Request $request, ManagerRegistry $doctrine): Response - { + public function list( + Request $request, + ManagerRegistry $doctrine, + ): Response { $q = null; $form = $this->createForm(SearchType::class); @@ -58,8 +64,11 @@ public function single(string $id, ManagerRegistry $doctrine): Response } #[Route('/dinosaurs/create', name: 'app_create_dinosaur')] - public function create(Request $request, ManagerRegistry $doctrine): Response - { + public function create( + Request $request, + ManagerRegistry $doctrine, + Publisher $publisher + ): Response { $form = $this->createForm(DinosaurType::class); $form->handleRequest($request); @@ -73,6 +82,14 @@ public function create(Request $request, ManagerRegistry $doctrine): Response $this->addFlash('success', 'The dinosaur has been created!'); + $publisher->publish(new DinosaurCreated( + $dinosaur, + $this->generateUrl( + 'app_single_dinosaur', + ['id' => $dinosaur->getId()] + ) + )); + return $this->redirectToRoute('app_list_dinosaurs'); } @@ -86,12 +103,18 @@ public function create(Request $request, ManagerRegistry $doctrine): Response name: 'app_edit_dinosaur', requirements: ['id' => '\d+'] )] - public function edit(Request $request, int $id, ManagerRegistry $doctrine): Response - { + public function edit( + Request $request, + int $id, + ManagerRegistry $doctrine, + Publisher $publisher + ): Response { $dinosaur = $doctrine ->getRepository(Dinosaur::class) ->find($id); + $oldName = $dinosaur->getName(); + if (false === $dinosaur) { throw $this->createNotFoundException('The dinosaur you are looking for does not exists.'); } @@ -108,6 +131,15 @@ public function edit(Request $request, int $id, ManagerRegistry $doctrine): Resp $this->addFlash('success', 'The dinosaur has been edited!'); + $publisher->publish(new DinosaurUpdated( + $dinosaur, + $oldName, + $this->generateUrl( + 'app_single_dinosaur', + ['id' => $dinosaur->getId()] + ) + )); + return $this->redirectToRoute('app_list_dinosaurs'); } @@ -121,8 +153,11 @@ public function edit(Request $request, int $id, ManagerRegistry $doctrine): Resp name: 'app_remove_dinosaur', requirements: ['id' => '\d+'] )] - public function remove(int $id, ManagerRegistry $doctrine): Response - { + public function remove( + int $id, + ManagerRegistry $doctrine, + Publisher $publisher + ): Response { $dinosaur = $doctrine ->getRepository(Dinosaur::class) ->find($id); @@ -137,6 +172,8 @@ public function remove(int $id, ManagerRegistry $doctrine): Response $this->addFlash('success', 'The dinosaur has been removed!'); + $publisher->publish(new DinosaurDeleted($id, $dinosaur)); + return $this->redirectToRoute('app_list_dinosaurs'); } } diff --git a/src/Listener/AuthenticationListener.php b/src/Listener/AuthenticationListener.php new file mode 100644 index 0000000..331a3cc --- /dev/null +++ b/src/Listener/AuthenticationListener.php @@ -0,0 +1,36 @@ +getAuthenticatedToken()->getUserIdentifier(); + + $this->publisher->publish(new UserLoggetIn($identifier)); + } + + #[AsEventListener(event: LogoutEvent::class)] + public function onLogout(LogoutEvent $event): void + { + $identifier = $event->getToken()->getUserIdentifier(); + + $this->publisher->publish(new UserLoggetOut($identifier)); + } +} diff --git a/src/Listener/MercureAuthorizationListener.php b/src/Listener/MercureAuthorizationListener.php new file mode 100644 index 0000000..a6f32c6 --- /dev/null +++ b/src/Listener/MercureAuthorizationListener.php @@ -0,0 +1,50 @@ +security->isGranted('ROLE_USER')) { + $topics[] = 'http://localhost/dinosaurs'; + } + + if ($this->security->isGranted('ROLE_ADMIN')) { + $topics[] = 'http://localhost/activity'; + } + + if (empty($topics)) { + return; + } + + $response = $event->getResponse(); + + // Generate the JWT token + $JWTfactory = $this->hubInterface->getFactory(); + + if (null === $JWTfactory) { + throw new \RuntimeException('The hub factory is not available.'); + } + + $token = $JWTfactory->create($topics); + + $response->headers->set('X-Mercure-JWT', $token); + } +} diff --git a/src/Listener/MercureDiscoveryListener.php b/src/Listener/MercureDiscoveryListener.php new file mode 100644 index 0000000..d31efd2 --- /dev/null +++ b/src/Listener/MercureDiscoveryListener.php @@ -0,0 +1,24 @@ +getRequest(); + + $this->discovery->addLink($request); + } +} diff --git a/src/Realtime/Trigger.php b/src/Realtime/Trigger.php new file mode 100644 index 0000000..32d9392 --- /dev/null +++ b/src/Realtime/Trigger.php @@ -0,0 +1,27 @@ + $topics + * @param array $data + */ + public function __construct( + public string $type, + public array $topics, + private array $data, + public bool $isPrivate = true + ) { + } + + public function jsonSerialize(): array + { + return $this->data; + } +} diff --git a/src/Realtime/Trigger/DinosaurCreated.php b/src/Realtime/Trigger/DinosaurCreated.php new file mode 100644 index 0000000..59a54d1 --- /dev/null +++ b/src/Realtime/Trigger/DinosaurCreated.php @@ -0,0 +1,27 @@ + $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'link' => $link, + 'message' => "The dinosaur {$dinosaur->getName()} has been created !" + ] + ); + } +} diff --git a/src/Realtime/Trigger/DinosaurDeleted.php b/src/Realtime/Trigger/DinosaurDeleted.php new file mode 100644 index 0000000..21b0e99 --- /dev/null +++ b/src/Realtime/Trigger/DinosaurDeleted.php @@ -0,0 +1,23 @@ + $id, + 'message' => "The dinosaur {$dinosaur->getName()} has been removed !" + ] + ); + } +} diff --git a/src/Realtime/Trigger/DinosaurUpdated.php b/src/Realtime/Trigger/DinosaurUpdated.php new file mode 100644 index 0000000..33b5e8a --- /dev/null +++ b/src/Realtime/Trigger/DinosaurUpdated.php @@ -0,0 +1,28 @@ + $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'link' => $link, + 'message' => "The dinosaur {$oldName} has been edited !" + ] + ); + } +} diff --git a/src/Realtime/Trigger/UserLoggedIn.php b/src/Realtime/Trigger/UserLoggedIn.php new file mode 100644 index 0000000..2ce06bc --- /dev/null +++ b/src/Realtime/Trigger/UserLoggedIn.php @@ -0,0 +1,22 @@ + $userIdentifier, + 'message' => "The user {$userIdentifier} has been logged in !" + ] + ); + } +} diff --git a/src/Realtime/Trigger/UserLoggetOut.php b/src/Realtime/Trigger/UserLoggetOut.php new file mode 100644 index 0000000..f7b5950 --- /dev/null +++ b/src/Realtime/Trigger/UserLoggetOut.php @@ -0,0 +1,22 @@ + $userIdentifier, + 'message' => "The user {$userIdentifier} has been logged out !" + ] + ); + } +} diff --git a/src/Service/Realtime/Publisher.php b/src/Service/Realtime/Publisher.php new file mode 100644 index 0000000..97756af --- /dev/null +++ b/src/Service/Realtime/Publisher.php @@ -0,0 +1,27 @@ +hub->publish(new Update( + $trigger->topics, + json_encode($trigger), + type: $trigger->type, + private: $trigger->isPrivate + )); + } +} diff --git a/symfony.lock b/symfony.lock index e355d56..49abc32 100644 --- a/symfony.lock +++ b/symfony.lock @@ -353,6 +353,18 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/mercure-bundle": { + "version": "0.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.3", + "ref": "d097c114aae82c5bc88d48ac164fe523f1003292" + }, + "files": [ + "config/packages/mercure.yaml" + ] + }, "symfony/mime": { "version": "v5.3.8" }, diff --git a/templates/_dinosaur-list-item.html.twig b/templates/_dinosaur-list-item.html.twig new file mode 100644 index 0000000..04eea41 --- /dev/null +++ b/templates/_dinosaur-list-item.html.twig @@ -0,0 +1,16 @@ + + twbs +
+ {{ dinosaur|default(null) is not same as null ? dinosaur.name : '' }} +
+
diff --git a/templates/activity.html.twig b/templates/activity.html.twig new file mode 100644 index 0000000..49f59cc --- /dev/null +++ b/templates/activity.html.twig @@ -0,0 +1,19 @@ +{% extends 'base.html.twig' %} + +{% block title %}Activity panel{% endblock %} + +{% block javascripts %} + + + +{% endblock %} + +{% block body %} +
+

Activity Panel

+ +
    + {# Here will go the realtime activity messages #} +
+
+{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index 1fabcb1..a8d5bbc 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -36,8 +36,8 @@ {% if 'ROLE_ADMIN' in app.user.roles %} {% endif %} {% endif %} + {% else %}