From 0c9deca3f8cd861b90811507168ca14e003bdd38 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 14 Mar 2020 19:47:22 +0100 Subject: [PATCH 01/13] Added symfony/mercure package and a container for development --- composer.json | 1 + docker-compose.yml | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/composer.json b/composer.json index 164d17df..c0f3514f 100644 --- a/composer.json +++ b/composer.json @@ -57,6 +57,7 @@ "symfony/console": "^5.0", "symfony/filesystem": "^5.0", "symfony/lock": "^5.0", + "symfony/mercure": "^0.3.0", "symfony/process": "^5.0" }, "require-dev": { diff --git a/docker-compose.yml b/docker-compose.yml index c78cf85f..d7867f32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - shlink_db_maria - shlink_db_ms - shlink_redis + - shlink_mercure environment: LC_ALL: C @@ -47,6 +48,7 @@ services: - shlink_db_maria - shlink_db_ms - shlink_redis + - shlink_mercure environment: LC_ALL: C @@ -102,3 +104,12 @@ services: image: redis:5.0-alpine ports: - "6380:6379" + + shlink_mercure: + container_name: shlink_mercure + image: dunglas/mercure:v0.8 + ports: + - "3080:80" + environment: + CORS_ALLOWED_ORIGINS: "*" + JWT_KEY: "super_secret_key" From 10cad3324828b249125f200ec553dbbb4f925eab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 14 Mar 2020 20:19:16 +0100 Subject: [PATCH 02/13] Added configuration for mercure integration --- config/autoload/mercure.global.php | 13 +++++++++++++ config/autoload/mercure.local.php.dist | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 config/autoload/mercure.global.php create mode 100644 config/autoload/mercure.local.php.dist diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php new file mode 100644 index 00000000..c70c9f06 --- /dev/null +++ b/config/autoload/mercure.global.php @@ -0,0 +1,13 @@ + [ + 'public_hub_url' => null, + 'internal_hub_url' => null, + 'jwt_secret' => null, + ], + +]; diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist new file mode 100644 index 00000000..508d0f63 --- /dev/null +++ b/config/autoload/mercure.local.php.dist @@ -0,0 +1,13 @@ + [ + 'public_hub_url' => 'http://localhost:3080', + 'internal_hub_url' => 'http://shlink_mercure', + 'jwt_secret' => 'super_secret_key', + ], + +]; From 69962f1fe8cc9e42e7f68a57c661cc0f39c29507 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Mar 2020 17:13:57 +0100 Subject: [PATCH 03/13] Added package to handle JWTs --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index c0f3514f..987da145 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "laminas/laminas-paginator": "^2.8", "laminas/laminas-servicemanager": "^3.4", "laminas/laminas-stdlib": "^3.2", + "lcobucci/jwt": "^4.0@alpha", "lstrojny/functional-php": "^1.9", "mezzio/mezzio": "^3.2", "mezzio/mezzio-fastroute": "^3.0", From 85440c1c5ff24cfbeb9c918026f329d6dd78e342 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 Apr 2020 12:21:05 +0200 Subject: [PATCH 04/13] Improved mercure-related configs --- composer.json | 2 +- config/autoload/mercure.global.php | 2 ++ config/autoload/mercure.local.php.dist | 2 +- config/config.php | 2 -- docker-compose.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 987da145..d71f0610 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.5", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "dev-master#aafa221ec979271713f87e23f17f6a6b5ae5ee67 as 3.0.1", + "shlinkio/shlink-common": "dev-master#3178fd18ce729d1dd63f4fb54f6a3696a11fef3f as 3.1.0", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^1.4", "shlinkio/shlink-installer": "^4.3.2", diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php index c70c9f06..e04336f3 100644 --- a/config/autoload/mercure.global.php +++ b/config/autoload/mercure.global.php @@ -8,6 +8,8 @@ return [ 'public_hub_url' => null, 'internal_hub_url' => null, 'jwt_secret' => null, + 'jwt_days_duration' => 5, + 'jwt_issuer' => 'Shlink', ], ]; diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist index 508d0f63..c760e771 100644 --- a/config/autoload/mercure.local.php.dist +++ b/config/autoload/mercure.local.php.dist @@ -7,7 +7,7 @@ return [ 'mercure' => [ 'public_hub_url' => 'http://localhost:3080', 'internal_hub_url' => 'http://shlink_mercure', - 'jwt_secret' => 'super_secret_key', + 'jwt_secret' => 'mercure_jwt_key', ], ]; diff --git a/config/config.php b/config/config.php index 98b4552b..5ab429f0 100644 --- a/config/config.php +++ b/config/config.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink; use Laminas\ConfigAggregator; -use Laminas\ZendFrameworkBridge; use Mezzio; use Mezzio\ProblemDetails; @@ -30,7 +29,6 @@ return (new ConfigAggregator\ConfigAggregator([ ? new ConfigAggregator\PhpFileProvider('config/test/*.global.php') : new ConfigAggregator\LaminasConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'), ], 'data/cache/app_config.php', [ - ZendFrameworkBridge\ConfigPostProcessor::class, Core\Config\SimplifiedConfigParser::class, Core\Config\BasePathPrefixer::class, Core\Config\DeprecatedConfigParser::class, diff --git a/docker-compose.yml b/docker-compose.yml index d7867f32..51a8a83b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,4 +112,4 @@ services: - "3080:80" environment: CORS_ALLOWED_ORIGINS: "*" - JWT_KEY: "super_secret_key" + JWT_KEY: "mercure_jwt_key" From 2ffbf03cf81fbd891e458f49a605ea81d4e29940 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 Apr 2020 13:59:10 +0200 Subject: [PATCH 05/13] Created action to get mercure integration info --- config/autoload/mercure.global.php | 16 +++++ docs/swagger/definitions/MercureInfo.json | 18 +++++ docs/swagger/paths/v2_mercure-info.json | 67 +++++++++++++++++++ docs/swagger/swagger.json | 4 ++ module/Rest/config/dependencies.config.php | 3 + module/Rest/config/routes.config.php | 2 + module/Rest/src/Action/MercureAction.php | 56 ++++++++++++++++ .../Rest/src/Exception/MercureException.php | 30 +++++++++ 8 files changed, 196 insertions(+) create mode 100644 docs/swagger/definitions/MercureInfo.json create mode 100644 docs/swagger/paths/v2_mercure-info.json create mode 100644 module/Rest/src/Action/MercureAction.php create mode 100644 module/Rest/src/Exception/MercureException.php diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php index e04336f3..3466ce64 100644 --- a/config/autoload/mercure.global.php +++ b/config/autoload/mercure.global.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Laminas\ServiceManager\Proxy\LazyServiceFactory; +use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; + return [ 'mercure' => [ @@ -12,4 +15,17 @@ return [ 'jwt_issuer' => 'Shlink', ], + 'dependencies' => [ + 'delegators' => [ + LcobucciJwtProvider::class => [ + LazyServiceFactory::class, + ], + ], + 'lazy_services' => [ + 'class_map' => [ + LcobucciJwtProvider::class => LcobucciJwtProvider::class, + ], + ], + ], + ]; diff --git a/docs/swagger/definitions/MercureInfo.json b/docs/swagger/definitions/MercureInfo.json new file mode 100644 index 00000000..ac1f273a --- /dev/null +++ b/docs/swagger/definitions/MercureInfo.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "required": ["mercureHubUrl", "jwt", "jwtExpiration"], + "properties": { + "mercureHubUrl": { + "type": "string", + "description": "The public URL of the mercure hub that can be used to get real-time updates published by Shlink" + }, + "jwt": { + "type": "string", + "description": "A JWT with subscribe permissions which is valid with the mercure hub" + }, + "jwtExpiration": { + "type": "string", + "description": "The date (in ISO-8601 format) in which the JWT will expire" + } + } +} diff --git a/docs/swagger/paths/v2_mercure-info.json b/docs/swagger/paths/v2_mercure-info.json new file mode 100644 index 00000000..24f7fb5f --- /dev/null +++ b/docs/swagger/paths/v2_mercure-info.json @@ -0,0 +1,67 @@ +{ + "get": { + "operationId": "mercureInfo", + "tags": [ + "Integrations" + ], + "summary": "Get mercure integration info", + "description": "Returns information to consume updates published by Shlink on a mercure hub. https://mercure.rocks/", + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "The mercure integration info", + "content": { + "application/json": { + "schema": { + "$ref": "../definitions/MercureInfo.json" + } + } + }, + "examples": { + "application/json": { + "mercureHubUrl": "https://example.com/.well-known/mercure", + "jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE1ODY2ODY3MzIsImV4cCI6MTU4Njk0NTkzMiwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOltdfX0.P-519lgU7dFz0bbNlRG1CXyqugGbaHon4kw6fu4QBdQ", + "jwtExpiration": "2020-04-15T12:18:52+02:00" + } + } + }, + "501": { + "description": "This Shlink instance is not integrated with a mercure hub", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + }, + "examples": { + "application/json": { + "title": "Mercure integration not configured", + "type": "MERCURE_NOT_CONFIGURED", + "detail": "This Shlink instance is not integrated with a mercure hub.", + "status": 501 + } + } + }, + "500": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 32e0caf3..c30bab97 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -82,6 +82,10 @@ "$ref": "paths/v1_short-urls_{shortCode}_visits.json" }, + "/rest/v{version}/mercure-info": { + "$ref": "paths/v2_mercure-info.json" + }, + "/rest/health": { "$ref": "paths/health.json" }, diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index b24ec1ee..e9fcbfa5 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -9,6 +9,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -20,6 +21,7 @@ return [ ApiKeyService::class => ConfigAbstractFactory::class, Action\HealthAction::class => ConfigAbstractFactory::class, + Action\MercureAction::class => ConfigAbstractFactory::class, Action\ShortUrl\CreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class, @@ -46,6 +48,7 @@ return [ ApiKeyService::class => ['em'], Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'], + Action\MercureAction::class => [LcobucciJwtProvider::class, 'config.mercure', 'Logger_Shlink'], Action\ShortUrl\CreateShortUrlAction::class => [ Service\UrlShortener::class, 'config.url_shortener.domain', diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index b104d81b..afb44249 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -33,6 +33,8 @@ return [ Action\Tag\DeleteTagsAction::getRouteDef(), Action\Tag\CreateTagsAction::getRouteDef(), Action\Tag\UpdateTagAction::getRouteDef(), + + Action\MercureAction::getRouteDef(), ], ]; diff --git a/module/Rest/src/Action/MercureAction.php b/module/Rest/src/Action/MercureAction.php new file mode 100644 index 00000000..7c33fa31 --- /dev/null +++ b/module/Rest/src/Action/MercureAction.php @@ -0,0 +1,56 @@ +jwtProvider = $jwtProvider; + $this->mercureConfig = $mercureConfig; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $hubUrl = $this->mercureConfig['public_hub_url'] ?? null; + if ($hubUrl === null) { + throw MercureException::mercureNotConfigured(); + } + + $days = $this->mercureConfig['jwt_days_duration'] ?? 3; + $expiresAt = Chronos::now()->addDays($days); + + try { + $jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt); + } catch (Throwable $e) { + throw MercureException::mercureNotConfigured($e); + } + + return new JsonResponse([ + 'mercureHubUrl' => $hubUrl, + 'token' => $jwt, + 'jwtExpiration' => $expiresAt->toAtomString(), + ]); + } +} diff --git a/module/Rest/src/Exception/MercureException.php b/module/Rest/src/Exception/MercureException.php new file mode 100644 index 00000000..6c318e93 --- /dev/null +++ b/module/Rest/src/Exception/MercureException.php @@ -0,0 +1,30 @@ +detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_NOT_IMPLEMENTED; + + return $e; + } +} From 31db97228d885176bb0542ba78adad57ce206d9e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 Apr 2020 14:19:51 +0200 Subject: [PATCH 06/13] Created MercureInfoActionTest --- module/Rest/config/dependencies.config.php | 4 +- module/Rest/config/routes.config.php | 2 +- ...ercureAction.php => MercureInfoAction.php} | 2 +- .../test/Action/MercureInfoActionTest.php | 107 ++++++++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) rename module/Rest/src/Action/{MercureAction.php => MercureInfoAction.php} (96%) create mode 100644 module/Rest/test/Action/MercureInfoActionTest.php diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index e9fcbfa5..2434c88b 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -21,7 +21,7 @@ return [ ApiKeyService::class => ConfigAbstractFactory::class, Action\HealthAction::class => ConfigAbstractFactory::class, - Action\MercureAction::class => ConfigAbstractFactory::class, + Action\MercureInfoAction::class => ConfigAbstractFactory::class, Action\ShortUrl\CreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class, @@ -48,7 +48,7 @@ return [ ApiKeyService::class => ['em'], Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'], - Action\MercureAction::class => [LcobucciJwtProvider::class, 'config.mercure', 'Logger_Shlink'], + Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure', 'Logger_Shlink'], Action\ShortUrl\CreateShortUrlAction::class => [ Service\UrlShortener::class, 'config.url_shortener.domain', diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index afb44249..3ced8357 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -34,7 +34,7 @@ return [ Action\Tag\CreateTagsAction::getRouteDef(), Action\Tag\UpdateTagAction::getRouteDef(), - Action\MercureAction::getRouteDef(), + Action\MercureInfoAction::getRouteDef(), ], ]; diff --git a/module/Rest/src/Action/MercureAction.php b/module/Rest/src/Action/MercureInfoAction.php similarity index 96% rename from module/Rest/src/Action/MercureAction.php rename to module/Rest/src/Action/MercureInfoAction.php index 7c33fa31..906a0b45 100644 --- a/module/Rest/src/Action/MercureAction.php +++ b/module/Rest/src/Action/MercureInfoAction.php @@ -13,7 +13,7 @@ use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; use Shlinkio\Shlink\Rest\Exception\MercureException; use Throwable; -class MercureAction extends AbstractRestAction +class MercureInfoAction extends AbstractRestAction { protected const ROUTE_PATH = '/mercure-info'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php new file mode 100644 index 00000000..f9489c99 --- /dev/null +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -0,0 +1,107 @@ +provider = $this->prophesize(JwtProviderInterface::class); + } + + /** + * @test + * @dataProvider provideNoHostConfigs + */ + public function throwsExceptionWhenConfigDoesNotHavePublicHost(array $mercureConfig): void + { + $buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willReturn('abc.123'); + + $action = new MercureInfoAction($this->provider->reveal(), $mercureConfig); + + $this->expectException(MercureException::class); + $buildToken->shouldNotBeCalled(); + + $action->handle(ServerRequestFactory::fromGlobals()); + } + + public function provideNoHostConfigs(): iterable + { + yield 'host not defined' => [[]]; + yield 'host is null' => [['public_hub_url' => null]]; + } + + /** + * @test + * @dataProvider provideValidConfigs + */ + public function throwsExceptionWhenBuildingTokenFails(array $mercureConfig): void + { + $buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willThrow( + new RuntimeException('Error'), + ); + + $action = new MercureInfoAction($this->provider->reveal(), $mercureConfig); + + $this->expectException(MercureException::class); + $buildToken->shouldBeCalledOnce(); + + $action->handle(ServerRequestFactory::fromGlobals()); + } + + public function provideValidConfigs(): iterable + { + yield 'days not defined' => [['public_hub_url' => 'http://foobar.com']]; + yield 'days defined' => [['public_hub_url' => 'http://foobar.com', 'jwt_days_duration' => 20]]; + } + + /** + * @test + * @dataProvider provideDays + */ + public function returnsExpectedInfoWhenEverythingIsOk(?int $days): void + { + $buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willReturn('abc.123'); + + $action = new MercureInfoAction($this->provider->reveal(), [ + 'public_hub_url' => 'http://foobar.com', + 'jwt_days_duration' => $days, + ]); + + /** @var JsonResponse $resp */ + $resp = $action->handle(ServerRequestFactory::fromGlobals()); + $payload = $resp->getPayload(); + + $this->assertArrayHasKey('mercureHubUrl', $payload); + $this->assertEquals('http://foobar.com', $payload['mercureHubUrl']); + $this->assertArrayHasKey('token', $payload); + $this->assertArrayHasKey('jwtExpiration', $payload); + $this->assertEquals( + Chronos::now()->addDays($days ?? 3)->startOfDay(), + Chronos::parse($payload['jwtExpiration'])->startOfDay(), + ); + $buildToken->shouldHaveBeenCalledOnce(); + } + + public function provideDays(): iterable + { + yield 'days not defined' => [null]; + yield 'days defined' => [10]; + } +} From 72d8edf4ff578139bcfe9a323c3b44e0c960c984 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 Apr 2020 17:05:59 +0200 Subject: [PATCH 07/13] Created event listener that notifies mercure hub for new visits --- composer.json | 2 +- config/autoload/mercure.global.php | 6 +++ module/Core/config/dependencies.config.php | 4 ++ .../Core/config/event_dispatcher.config.php | 9 ++++ .../EventDispatcher/NotifyVisitToMercure.php | 54 +++++++++++++++++++ .../src/Mercure/MercureUpdatesGenerator.php | 33 ++++++++++++ .../MercureUpdatesGeneratorInterface.php | 13 +++++ 7 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 module/Core/src/EventDispatcher/NotifyVisitToMercure.php create mode 100644 module/Core/src/Mercure/MercureUpdatesGenerator.php create mode 100644 module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php diff --git a/composer.json b/composer.json index d71f0610..324d83b2 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.5", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "dev-master#3178fd18ce729d1dd63f4fb54f6a3696a11fef3f as 3.1.0", + "shlinkio/shlink-common": "dev-master#e659cf9d9b5b3b131419e2f55f2e595f562baafc as 3.1.0", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^1.4", "shlinkio/shlink-installer": "^4.3.2", diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php index 3466ce64..04a698c3 100644 --- a/config/autoload/mercure.global.php +++ b/config/autoload/mercure.global.php @@ -4,6 +4,8 @@ declare(strict_types=1); use Laminas\ServiceManager\Proxy\LazyServiceFactory; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; +use Symfony\Component\Mercure\Publisher; +use Symfony\Component\Mercure\PublisherInterface; return [ @@ -20,10 +22,14 @@ return [ LcobucciJwtProvider::class => [ LazyServiceFactory::class, ], + Publisher::class => [ + LazyServiceFactory::class, + ], ], 'lazy_services' => [ 'class_map' => [ LcobucciJwtProvider::class => LcobucciJwtProvider::class, + Publisher::class => PublisherInterface::class, ], ], ], diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index a055e34b..63e6cfed 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -38,6 +38,8 @@ return [ Action\QrCodeAction::class => ConfigAbstractFactory::class, Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class, + + Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, ], ], @@ -83,6 +85,8 @@ return [ ], Resolver\PersistenceDomainResolver::class => ['em'], + + Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'], ], ]; diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index e885a283..c72e2d7a 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -8,12 +8,14 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; +use Symfony\Component\Mercure\Publisher; return [ 'events' => [ 'regular' => [ EventDispatcher\VisitLocated::class => [ + EventDispatcher\NotifyVisitToMercure::class, EventDispatcher\NotifyVisitToWebHooks::class, ], ], @@ -28,6 +30,7 @@ return [ 'factories' => [ EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, + EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, ], 'delegators' => [ @@ -53,6 +56,12 @@ return [ 'config.url_shortener.domain', Options\AppOptions::class, ], + EventDispatcher\NotifyVisitToMercure::class => [ + Publisher::class, + Mercure\MercureUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + ], ], ]; diff --git a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php new file mode 100644 index 00000000..69527413 --- /dev/null +++ b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php @@ -0,0 +1,54 @@ +publisher = $publisher; + $this->em = $em; + $this->logger = $logger; + $this->updatesGenerator = $updatesGenerator; + } + + public function __invoke(VisitLocated $shortUrlLocated): void + { + $visitId = $shortUrlLocated->visitId(); + + /** @var Visit|null $visit */ + $visit = $this->em->find(Visit::class, $visitId); + if ($visit === null) { + $this->logger->warning('Tried to notify mercure for visit with id "{visitId}", but it does not exist.', [ + 'visitId' => $visitId, + ]); + return; + } + + try { + ($this->publisher)($this->updatesGenerator->newVisitUpdate($visit)); + } catch (Throwable $e) { + $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [ + 'e' => $e, + ]); + } + } +} diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php new file mode 100644 index 00000000..47d4b7be --- /dev/null +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -0,0 +1,33 @@ +transformer = new ShortUrlDataTransformer($domainConfig); + } + + public function newVisitUpdate(Visit $visit): Update + { + return new Update(self::NEW_VISIT_TOPIC, json_encode([ + 'shortUrl' => $this->transformer->transform($visit->getShortUrl()), + 'visit' => $visit->jsonSerialize(), + ], JSON_THROW_ON_ERROR)); + } +} diff --git a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php b/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php new file mode 100644 index 00000000..af539803 --- /dev/null +++ b/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php @@ -0,0 +1,13 @@ + Date: Sun, 12 Apr 2020 17:50:09 +0200 Subject: [PATCH 08/13] Fixed mercure hub URL returned by MercureInfoAction --- module/Rest/src/Action/MercureInfoAction.php | 4 +++- module/Rest/test/Action/MercureInfoActionTest.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/module/Rest/src/Action/MercureInfoAction.php b/module/Rest/src/Action/MercureInfoAction.php index 906a0b45..73526d9f 100644 --- a/module/Rest/src/Action/MercureInfoAction.php +++ b/module/Rest/src/Action/MercureInfoAction.php @@ -13,6 +13,8 @@ use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; use Shlinkio\Shlink\Rest\Exception\MercureException; use Throwable; +use function sprintf; + class MercureInfoAction extends AbstractRestAction { protected const ROUTE_PATH = '/mercure-info'; @@ -48,7 +50,7 @@ class MercureInfoAction extends AbstractRestAction } return new JsonResponse([ - 'mercureHubUrl' => $hubUrl, + 'mercureHubUrl' => sprintf('%s/.well-known/mercure', $hubUrl), 'token' => $jwt, 'jwtExpiration' => $expiresAt->toAtomString(), ]); diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index f9489c99..5cb5e41b 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -89,7 +89,7 @@ class MercureInfoActionTest extends TestCase $payload = $resp->getPayload(); $this->assertArrayHasKey('mercureHubUrl', $payload); - $this->assertEquals('http://foobar.com', $payload['mercureHubUrl']); + $this->assertEquals('http://foobar.com/.well-known/mercure', $payload['mercureHubUrl']); $this->assertArrayHasKey('token', $payload); $this->assertArrayHasKey('jwtExpiration', $payload); $this->assertEquals( From e97dfbfdda923cfe34aae4dffdf77f976a470198 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 Apr 2020 17:50:25 +0200 Subject: [PATCH 09/13] Created NotifyVisitToMercureTest --- .../NotifyVisitToMercureTest.php | 115 ++++++++++++++++++ .../test/Action/MercureInfoActionTest.php | 1 - 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php new file mode 100644 index 00000000..ff5fb7c3 --- /dev/null +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -0,0 +1,115 @@ +publisher = $this->prophesize(PublisherInterface::class); + $this->updatesGenerator = $this->prophesize(MercureUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + + $this->listener = new NotifyVisitToMercure( + $this->publisher->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + ); + } + + /** @test */ + public function notificationIsNotSentWhenVisitCannotBeFound(): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); + $logWarning = $this->logger->warning( + 'Tried to notify mercure for visit with id "{visitId}", but it does not exist.', + ['visitId' => $visitId], + ); + $logDebug = $this->logger->debug(Argument::cetera()); + $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class))->willReturn( + new Update('', ''), + ); + $publish = $this->publisher->__invoke(Argument::type(Update::class)); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $logWarning->shouldHaveBeenCalledOnce(); + $logDebug->shouldNotHaveBeenCalled(); + $buildNewVisitUpdate->shouldNotHaveBeenCalled(); + $publish->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function notificationIsSentWhenVisitIsFound(): void + { + $visitId = '123'; + $visit = new Visit(new ShortUrl(''), Visitor::emptyInstance()); + $update = new Update('', ''); + + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + $logWarning = $this->logger->warning(Argument::cetera()); + $logDebug = $this->logger->debug(Argument::cetera()); + $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); + $publish = $this->publisher->__invoke($update); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $logWarning->shouldNotHaveBeenCalled(); + $logDebug->shouldNotHaveBeenCalled(); + $buildNewVisitUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function debugIsLoggedWhenExceptionIsThrown(): void + { + $visitId = '123'; + $visit = new Visit(new ShortUrl(''), Visitor::emptyInstance()); + $update = new Update('', ''); + $e = new RuntimeException('Error'); + + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + $logWarning = $this->logger->warning(Argument::cetera()); + $logDebug = $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [ + 'e' => $e, + ]); + $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); + $publish = $this->publisher->__invoke($update)->willThrow($e); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $logWarning->shouldNotHaveBeenCalled(); + $logDebug->shouldHaveBeenCalledOnce(); + $buildNewVisitUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index 5cb5e41b..5300fbaa 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -9,7 +9,6 @@ use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\Argument\ArgumentsWildcard; use Prophecy\Prophecy\ObjectProphecy; use RuntimeException; use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; From 7f888c49b45a18bf7a6bcc1cd1d95b25be18e250 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 Apr 2020 18:01:13 +0200 Subject: [PATCH 10/13] Created MercureUpdatesGeneratorTest --- ...DbConnectionEventListenerDelegatorTest.php | 2 +- .../CloseDbConnectionEventListenerTest.php | 2 +- .../NotifyVisitToMercureTest.php | 2 +- .../Mercure/MercureUpdatesGeneratorTest.php | 56 +++++++++++++++++++ .../test/Action/MercureInfoActionTest.php | 2 +- .../DefaultShortCodesLengthMiddlewareTest.php | 2 +- 6 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 module/Core/test/Mercure/MercureUpdatesGeneratorTest.php diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php index 41fe94fa..60113fc7 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioApiTest\Shlink\Rest\EventDispatcher; +namespace ShlinkioTest\Shlink\Rest\EventDispatcher; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php index 349e7724..acc1784f 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioApiTest\Shlink\Core\EventDispatcher; +namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\DBAL\Connection; use PHPUnit\Framework\TestCase; diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php index ff5fb7c3..07f8bd1c 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioApiTest\Shlink\Core\EventDispatcher; +namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php new file mode 100644 index 00000000..361d3b2f --- /dev/null +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -0,0 +1,56 @@ +generator = new MercureUpdatesGenerator([]); + } + + /** @test */ + public function visitIsProperlySerializedIntoUpdate(): void + { + $shortUrl = new ShortUrl(''); + $visit = new Visit($shortUrl, Visitor::emptyInstance()); + + $update = $this->generator->newVisitUpdate($visit); + + $this->assertEquals(['https://shlink.io/new_visit'], $update->getTopics()); + $this->assertEquals([ + 'shortUrl' => [ + 'shortCode' => $shortUrl->getShortCode(), + 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), + 'longUrl' => '', + 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), + 'visitsCount' => 0, + 'tags' => [], + 'meta' => [ + 'validSince' => null, + 'validUntil' => null, + 'maxVisits' => null, + ], + 'domain' => null, + ], + 'visit' => [ + 'referer' => '', + 'userAgent' => '', + 'visitLocation' => null, + 'date' => $visit->getDate()->toAtomString(), + ], + ], json_decode($update->getData())); + } +} diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index 5300fbaa..df4b7260 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioApiTest\Shlink\Rest\Action; +namespace ShlinkioTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use Laminas\Diactoros\Response\JsonResponse; diff --git a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php index 38d875d9..c14a2f5c 100644 --- a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioApiTest\Shlink\Rest\Middleware\ShortUrl; +namespace ShlinkioTest\Shlink\Rest\Middleware\ShortUrl; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; From 8d888cb43de0251f838eede91aacf9fd6ca9d229 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 Apr 2020 18:39:28 +0200 Subject: [PATCH 11/13] Documented how to use a mercure hub when using the docker image --- CHANGELOG.md | 31 ++++++++++++ config/test/test_config.global.php | 8 ++- docker/README.md | 78 ++++++++++++++++++++++++++---- 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af2297d4..429c521a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] + +#### Added + +* [#712](https://github.com/shlinkio/shlink/issues/712) Added support to integrate Shlink with a [mercure hub](https://mercure.rocks/) server. + + Thanks to that, Shlink will be able to publish events that can be consumed in real time. + + For now, only one topic (event) is published, identified by `https://shlink.io/new_visit`, which includes a payload with the visit and the shortUrl, every time a new visit occurs. + + The updates are only published when serving Shlink with swoole. + + Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subsribe to updates. + +#### Changed + +* *Nothing* + +#### Deprecated + +* *Nothing* + +#### Removed + +* *Nothing* + +#### Fixed + +* *Nothing* + + ## 2.1.3 - 2020-04-09 #### Added diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index fa51c240..5d57e981 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -79,13 +79,17 @@ return [ 'process-name' => 'shlink_test', 'options' => [ 'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid', - 'worker_num' => 1, - 'task_worker_num' => 1, 'enable_coroutine' => false, ], ], ], + 'mercure' => [ + 'public_hub_url' => null, + 'internal_hub_url' => null, + 'jwt_secret' => null, + ], + 'dependencies' => [ 'services' => [ 'shlink_test_api_client' => new Client([ diff --git a/docker/README.md b/docker/README.md index 3977fa37..3240f3d0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -73,18 +73,73 @@ It is possible to use a set of env vars to make this shlink instance interact wi Taking this into account, you could run shlink on a local docker service like this: ```bash -docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e DB_DRIVER=mysql -e DB_USER=root -e DB_PASSWORD=123abc -e DB_HOST=something.rds.amazonaws.com shlinkio/shlink:stable +docker run \ + --name shlink \ + -p 8080:8080 \ + -e SHORT_DOMAIN_HOST=doma.in \ + -e SHORT_DOMAIN_SCHEMA=https \ + -e DB_DRIVER=mysql \ + -e DB_USER=root \ + -e DB_PASSWORD=123abc \ + -e DB_HOST=something.rds.amazonaws.com \ + shlinkio/shlink:stable ``` You could even link to a local database running on a different container: ```bash -docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink:stable +docker run \ + --name shlink \ + -p 8080:8080 \ + [...] \ + -e DB_HOST=some_mysql_container \ + --link some_mysql_container \ + shlinkio/shlink:stable ``` > If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first. -## Supported env vars +## Other integrations + +### Use an external redis server + +If you plan to run more than one Shlink instance, there are some resources that should be shared ([Multi instance considerations](#multi-instance-considerations)). + +One of those resources are the locks Shlink generates to prevent some operations to be run more than once in parallel (in the future, these redis servers could be used for other caching operations). + +In order to share those locks, you should use an external redis server (or a cluster of redis servers), by providing the `REDIS_SERVERS` env var. + +It can be either one server name or a comma-separated list of servers. + +> If more than one redis server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial). + +### Integrate with a mercure hub server + +One way to get real time updates when certain events happen in Shlink is by integrating it with a [mercure hub](https://mercure.rocks/) server. + +If you do that, Shlink will publish updates and other clients can subscribe to those. + +There are three env vars you need to provide if you want to enable this: + +* `MERCURE_HUB_PUBLIC_URL`: **[Mandatory]**. The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates. +* `MERCURE_HUB_INTERNAL_URL`: **[Optional]**. An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided, the `MERCURE_HUB_PUBLIC_URL` one will be used to publish updates. +* `MERCURE_JWT_SECRET`: **[Mandatory]**. The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server. + +So in order to run shlink with mercure integration, you would do it like this: + +```bash +docker run \ + --name shlink \ + -p 8080:8080 \ + -e SHORT_DOMAIN_HOST=doma.in \ + -e SHORT_DOMAIN_SCHEMA=https \ + -e "MERCURE_HUB_PUBLIC_URL=https://example.com" + -e "MERCURE_HUB_INTERNAL_URL=http://my-mercure-hub.prod.svc.cluster.local" + -e MERCURE_JWT_SECRET=super_secret_key + shlinkio/shlink:stable +``` + +## All supported env vars A few env vars have been already used in previous examples, but this image supports others that can be used to customize its behavior. @@ -114,12 +169,9 @@ This is the complete list of supported env vars: * `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit. * `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4. * `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel). - - This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately. - - If more than one server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial). - - In the future, these redis servers could be used for other caching operations performed by shlink. +* `MERCURE_HUB_PUBLIC_URL`: The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates. +* `MERCURE_HUB_INTERNAL_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_HUB_PUBLIC_URL` was, the former one will be used to publish updates. +* `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server. An example using all env vars could look like this: @@ -147,6 +199,9 @@ docker run \ -e TASK_WORKER_NUM=32 \ -e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \ -e DEFAULT_SHORT_CODES_LENGTH=6 \ + -e "MERCURE_HUB_PUBLIC_URL=https://example.com" + -e "MERCURE_HUB_INTERNAL_URL=http://my-mercure-hub.prod.svc.cluster.local" + -e MERCURE_JWT_SECRET=super_secret_key shlinkio/shlink:stable ``` @@ -187,7 +242,10 @@ The whole configuration should have this format, but it can be split into multip "password": "123abc", "host": "something.rds.amazonaws.com", "port": "3306" - } + }, + "mercure_hub_public_url": "https://example.com", + "mercure_hub_internal_url": "http://my-mercure-hub.prod.svc.cluster.local", + "mercure_jwt_secret": "super_secret_key" } ``` From 934fa937b5ba92131a4261e595635ca03aeb698e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 12 Apr 2020 20:41:23 +0200 Subject: [PATCH 12/13] Updated config parsers for docker image to accept new mercure env vars and configs --- docker/README.md | 20 +++++++++---------- docker/config/shlink_in_docker.local.php | 13 ++++++++++++ .../src/Config/SimplifiedConfigParser.php | 3 +++ .../Config/SimplifiedConfigParserTest.php | 9 +++++++++ 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docker/README.md b/docker/README.md index 3240f3d0..7cb4fb54 100644 --- a/docker/README.md +++ b/docker/README.md @@ -121,8 +121,8 @@ If you do that, Shlink will publish updates and other clients can subscribe to t There are three env vars you need to provide if you want to enable this: -* `MERCURE_HUB_PUBLIC_URL`: **[Mandatory]**. The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates. -* `MERCURE_HUB_INTERNAL_URL`: **[Optional]**. An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided, the `MERCURE_HUB_PUBLIC_URL` one will be used to publish updates. +* `MERCURE_PUBLIC_HUB_URL`: **[Mandatory]**. The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates. +* `MERCURE_INTERNAL_HUB_URL`: **[Optional]**. An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided, the `MERCURE_PUBLIC_HUB_URL` one will be used to publish updates. * `MERCURE_JWT_SECRET`: **[Mandatory]**. The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server. So in order to run shlink with mercure integration, you would do it like this: @@ -133,8 +133,8 @@ docker run \ -p 8080:8080 \ -e SHORT_DOMAIN_HOST=doma.in \ -e SHORT_DOMAIN_SCHEMA=https \ - -e "MERCURE_HUB_PUBLIC_URL=https://example.com" - -e "MERCURE_HUB_INTERNAL_URL=http://my-mercure-hub.prod.svc.cluster.local" + -e "MERCURE_PUBLIC_HUB_URL=https://example.com" + -e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" -e MERCURE_JWT_SECRET=super_secret_key shlinkio/shlink:stable ``` @@ -169,8 +169,8 @@ This is the complete list of supported env vars: * `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit. * `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4. * `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel). -* `MERCURE_HUB_PUBLIC_URL`: The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates. -* `MERCURE_HUB_INTERNAL_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_HUB_PUBLIC_URL` was, the former one will be used to publish updates. +* `MERCURE_PUBLIC_HUB_URL`: The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates. +* `MERCURE_INTERNAL_HUB_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_PUBLIC_HUB_URL` was, the former one will be used to publish updates. * `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server. An example using all env vars could look like this: @@ -199,8 +199,8 @@ docker run \ -e TASK_WORKER_NUM=32 \ -e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \ -e DEFAULT_SHORT_CODES_LENGTH=6 \ - -e "MERCURE_HUB_PUBLIC_URL=https://example.com" - -e "MERCURE_HUB_INTERNAL_URL=http://my-mercure-hub.prod.svc.cluster.local" + -e "MERCURE_PUBLIC_HUB_URL=https://example.com" + -e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" -e MERCURE_JWT_SECRET=super_secret_key shlinkio/shlink:stable ``` @@ -243,8 +243,8 @@ The whole configuration should have this format, but it can be split into multip "host": "something.rds.amazonaws.com", "port": "3306" }, - "mercure_hub_public_url": "https://example.com", - "mercure_hub_internal_url": "http://my-mercure-hub.prod.svc.cluster.local", + "mercure_public_hub_url": "https://example.com", + "mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local", "mercure_jwt_secret": "super_secret_key" } ``` diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 6cf86434..f1842de2 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -79,6 +79,17 @@ $helper = new class { $value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH); return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value; } + + public function getMercureConfig(): array + { + $publicUrl = env('MERCURE_PUBLIC_HUB_URL'); + + return [ + 'public_hub_url' => $publicUrl, + 'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl), + 'jwt_secret' => env('MERCURE_JWT_SECRET'), + ]; + } }; return [ @@ -147,4 +158,6 @@ return [ ], ], + 'mercure' => $helper->getMercureConfig(), + ]; diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php index ee29d195..439a931a 100644 --- a/module/Core/src/Config/SimplifiedConfigParser.php +++ b/module/Core/src/Config/SimplifiedConfigParser.php @@ -33,6 +33,9 @@ class SimplifiedConfigParser 'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'], 'visits_webhooks' => ['url_shortener', 'visits_webhooks'], 'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'], + 'mercure_public_hub_url' => ['mercure', 'public_hub_url'], + 'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'], + 'mercure_jwt_secret' => ['mercure', 'jwt_secret'], ]; private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [ 'delete_short_url_threshold' => [ diff --git a/module/Core/test/Config/SimplifiedConfigParserTest.php b/module/Core/test/Config/SimplifiedConfigParserTest.php index 02f96423..6eee6737 100644 --- a/module/Core/test/Config/SimplifiedConfigParserTest.php +++ b/module/Core/test/Config/SimplifiedConfigParserTest.php @@ -60,6 +60,9 @@ class SimplifiedConfigParserTest extends TestCase 'https://third-party.io/foo', ], 'default_short_codes_length' => 8, + 'mercure_public_hub_url' => 'public_url', + 'mercure_internal_hub_url' => 'internal_url', + 'mercure_jwt_secret' => 'super_secret_value', ]; $expected = [ 'app_options' => [ @@ -127,6 +130,12 @@ class SimplifiedConfigParserTest extends TestCase ], ], ], + + 'mercure' => [ + 'public_hub_url' => 'public_url', + 'internal_hub_url' => 'internal_url', + 'jwt_secret' => 'super_secret_value', + ], ]; $result = ($this->postProcessor)(array_merge($config, $simplified)); From ba0678946f20ef1e68928fff005228dea7e8d4b3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 13 Apr 2020 09:38:18 +0200 Subject: [PATCH 13/13] Updated installer to use a version supporting mercure options --- composer.json | 2 +- config/autoload/installer.global.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 324d83b2..7e0a8bfe 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,7 @@ "shlinkio/shlink-common": "dev-master#e659cf9d9b5b3b131419e2f55f2e595f562baafc as 3.1.0", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^1.4", - "shlinkio/shlink-installer": "^4.3.2", + "shlinkio/shlink-installer": "dev-master#c1412b9e9a150f443874f05452f7ce8e6f9e0339 as 4.4.0", "shlinkio/shlink-ip-geolocation": "^1.4", "symfony/console": "^5.0", "symfony/filesystem": "^5.0", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index c40d75d1..8f8562e6 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -31,6 +31,10 @@ return [ Option\WebWorkerNumConfigOption::class, Option\RedisServersConfigOption::class, Option\ShortCodeLengthOption::class, + Option\Mercure\EnableMercureConfigOption::class, + Option\Mercure\MercurePublicUrlConfigOption::class, + Option\Mercure\MercureInternalUrlConfigOption::class, + Option\Mercure\MercureJwtSecretConfigOption::class, ], 'installation_commands' => [