mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 12:13:13 +08:00
Compare commits
50 Commits
v2.1.2
...
v2.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704958994d | ||
|
|
a6864bca7c | ||
|
|
15a8305209 | ||
|
|
469b70d708 | ||
|
|
4f988d223b | ||
|
|
e95abc4efb | ||
|
|
4917e53acd | ||
|
|
45db4c321a | ||
|
|
e6d914cfe1 | ||
|
|
85714c931d | ||
|
|
66a7f279c2 | ||
|
|
7c6827ea9f | ||
|
|
078c8ea011 | ||
|
|
655fd58a9d | ||
|
|
6ba6b951bf | ||
|
|
8e0e11f3b3 | ||
|
|
18b12ab1e6 | ||
|
|
3908f63b0d | ||
|
|
ca2c32fa8c | ||
|
|
a3a3ac1859 | ||
|
|
f5e0d0c2b1 | ||
|
|
ba0678946f | ||
|
|
934fa937b5 | ||
|
|
8d888cb43d | ||
|
|
7f888c49b4 | ||
|
|
e97dfbfdda | ||
|
|
b858d79b9e | ||
|
|
72d8edf4ff | ||
|
|
31db97228d | ||
|
|
2ffbf03cf8 | ||
|
|
85440c1c5f | ||
|
|
69962f1fe8 | ||
|
|
10cad33248 | ||
|
|
0c9deca3f8 | ||
|
|
e1cd4a6ee3 | ||
|
|
f915b97606 | ||
|
|
3ee5853b32 | ||
|
|
832a24e4c7 | ||
|
|
551368c30d | ||
|
|
9f24b8eb76 | ||
|
|
4c83ae2b22 | ||
|
|
28e0fb049b | ||
|
|
f79a369884 | ||
|
|
34c7b870a7 | ||
|
|
ec9f874bb9 | ||
|
|
1980d35691 | ||
|
|
ec8cbf82e5 | ||
|
|
2b1011de52 | ||
|
|
fa9ace83ad | ||
|
|
a9a53a9652 |
63
CHANGELOG.md
63
CHANGELOG.md
@@ -4,6 +4,69 @@ 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, two topics (events) are published, when new visits occur. Both include a payload with the visit and the shortUrl:
|
||||
|
||||
* A visit occurs on any short URL: `https://shlink.io/new-visit`.
|
||||
* A visit occurs on short URLs with a specific short code: `https://shlink.io/new-visit/{shortCode}`.
|
||||
|
||||
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
|
||||
|
||||
* [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql.
|
||||
* [#735](https://github.com/shlinkio/shlink/issues/735) Fixed error when cleaning metadata cache during installation when APCu is enabled.
|
||||
* [#677](https://github.com/shlinkio/shlink/issues/677) Fixed `/health` endpoint returning `503` fail responses when the database connection has expired.
|
||||
|
||||
|
||||
## 2.1.3 - 2020-04-09
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#712](https://github.com/shlinkio/shlink/issues/712) Fixed app set-up not clearing entities metadata cache.
|
||||
* [#711](https://github.com/shlinkio/shlink/issues/711) Fixed `HEAD` requests returning a duplicated `Content-Length` header.
|
||||
* [#716](https://github.com/shlinkio/shlink/issues/716) Fixed Twitter not properly displaying preview for final long URL.
|
||||
* [#717](https://github.com/shlinkio/shlink/issues/717) Fixed DB connection expiring on task workers when using swoole.
|
||||
* [#705](https://github.com/shlinkio/shlink/issues/705) Fixed how the short URL domain is inferred when generating QR codes, making sure the configured domain is respected even if the request is performed using a different one, and only when a custom domain is used, then that one is used instead.
|
||||
|
||||
|
||||
## 2.1.2 - 2020-03-29
|
||||
|
||||
#### Added
|
||||
|
||||
@@ -17,13 +17,11 @@
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^1.0",
|
||||
"cakephp/chronos": "^1.2",
|
||||
"cocur/slugify": "^3.0",
|
||||
"doctrine/cache": "^1.9",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"doctrine/migrations": "^2.2",
|
||||
"doctrine/orm": "^2.7",
|
||||
"endroid/qr-code": "^3.6",
|
||||
"firebase/php-jwt": "^4.0",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^6.5.1",
|
||||
"laminas/laminas-config": "^3.3",
|
||||
@@ -34,6 +32,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",
|
||||
@@ -49,14 +48,15 @@
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "^3.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.1",
|
||||
"shlinkio/shlink-installer": "dev-master#f51a2186cf474fb5773b0ef74b8533878de9dd1e as 5.0.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.4",
|
||||
"symfony/console": "^5.0",
|
||||
"symfony/filesystem": "^5.0",
|
||||
"symfony/lock": "^5.0",
|
||||
"symfony/mercure": "^0.3.0",
|
||||
"symfony/process": "^5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -65,7 +65,7 @@
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"infection/infection": "^0.16.1",
|
||||
"phpstan/phpstan": "^0.12.18",
|
||||
"phpunit/phpunit": "^9.0.1",
|
||||
"phpunit/phpunit": "~9.0.1",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.1.0",
|
||||
"shlinkio/shlink-test-utils": "^1.4",
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
// When running tests, any mysql-specific option can interfere with other drivers
|
||||
$driverOptions = env('APP_ENV') === 'test' ? [] : [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
@@ -10,9 +18,7 @@ return [
|
||||
'password' => 'root',
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => 'shlink_db',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
],
|
||||
'driverOptions' => $driverOptions,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -8,29 +8,33 @@ return [
|
||||
|
||||
'installer' => [
|
||||
'enabled_options' => [
|
||||
Option\DatabaseDriverConfigOption::class,
|
||||
Option\DatabaseNameConfigOption::class,
|
||||
Option\DatabaseHostConfigOption::class,
|
||||
Option\DatabasePortConfigOption::class,
|
||||
Option\DatabaseUserConfigOption::class,
|
||||
Option\DatabasePasswordConfigOption::class,
|
||||
Option\DatabaseSqlitePathConfigOption::class,
|
||||
Option\DatabaseMySqlOptionsConfigOption::class,
|
||||
Option\ShortDomainHostConfigOption::class,
|
||||
Option\ShortDomainSchemaConfigOption::class,
|
||||
Option\ValidateUrlConfigOption::class,
|
||||
Option\VisitsWebhooksConfigOption::class,
|
||||
Option\BaseUrlRedirectConfigOption::class,
|
||||
Option\InvalidShortUrlRedirectConfigOption::class,
|
||||
Option\Regular404RedirectConfigOption::class,
|
||||
Option\Database\DatabaseDriverConfigOption::class,
|
||||
Option\Database\DatabaseNameConfigOption::class,
|
||||
Option\Database\DatabaseHostConfigOption::class,
|
||||
Option\Database\DatabasePortConfigOption::class,
|
||||
Option\Database\DatabaseUserConfigOption::class,
|
||||
Option\Database\DatabasePasswordConfigOption::class,
|
||||
Option\Database\DatabaseSqlitePathConfigOption::class,
|
||||
Option\Database\DatabaseMySqlOptionsConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainHostConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
|
||||
Option\UrlShortener\ValidateUrlConfigOption::class,
|
||||
Option\Visit\VisitsWebhooksConfigOption::class,
|
||||
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
||||
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
|
||||
Option\Redirect\Regular404RedirectConfigOption::class,
|
||||
Option\DisableTrackParamConfigOption::class,
|
||||
Option\CheckVisitsThresholdConfigOption::class,
|
||||
Option\VisitsThresholdConfigOption::class,
|
||||
Option\Visit\CheckVisitsThresholdConfigOption::class,
|
||||
Option\Visit\VisitsThresholdConfigOption::class,
|
||||
Option\BasePathConfigOption::class,
|
||||
Option\TaskWorkerNumConfigOption::class,
|
||||
Option\WebWorkerNumConfigOption::class,
|
||||
Option\Worker\TaskWorkerNumConfigOption::class,
|
||||
Option\Worker\WebWorkerNumConfigOption::class,
|
||||
Option\RedisServersConfigOption::class,
|
||||
Option\ShortCodeLengthOption::class,
|
||||
Option\UrlShortener\ShortCodeLengthOption::class,
|
||||
Option\Mercure\EnableMercureConfigOption::class,
|
||||
Option\Mercure\MercurePublicUrlConfigOption::class,
|
||||
Option\Mercure\MercureInternalUrlConfigOption::class,
|
||||
Option\Mercure\MercureJwtSecretConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
|
||||
36
config/autoload/mercure.global.php
Normal file
36
config/autoload/mercure.global.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
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 [
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => null,
|
||||
'internal_hub_url' => null,
|
||||
'jwt_secret' => null,
|
||||
'jwt_issuer' => 'Shlink',
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'delegators' => [
|
||||
LcobucciJwtProvider::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
Publisher::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
],
|
||||
'lazy_services' => [
|
||||
'class_map' => [
|
||||
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
|
||||
Publisher::class => PublisherInterface::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
13
config/autoload/mercure.local.php.dist
Normal file
13
config/autoload/mercure.local.php.dist
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => 'http://localhost:8001',
|
||||
'internal_hub_url' => 'http://shlink_mercure_proxy',
|
||||
'jwt_secret' => 'mercure_jwt_key',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -5,8 +5,9 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Laminas\Stratigility\Middleware\ErrorHandler;
|
||||
use Mezzio;
|
||||
use Mezzio\Helper;
|
||||
use Mezzio\ProblemDetails;
|
||||
use Mezzio\Router;
|
||||
use PhpMiddleware\RequestId\RequestIdMiddleware;
|
||||
|
||||
return [
|
||||
@@ -14,7 +15,7 @@ return [
|
||||
'middleware_pipeline' => [
|
||||
'error-handler' => [
|
||||
'middleware' => [
|
||||
Mezzio\Helper\ContentLengthMiddleware::class,
|
||||
Helper\ContentLengthMiddleware::class,
|
||||
ErrorHandler::class,
|
||||
],
|
||||
],
|
||||
@@ -35,14 +36,15 @@ return [
|
||||
|
||||
'routing' => [
|
||||
'middleware' => [
|
||||
Mezzio\Router\Middleware\RouteMiddleware::class,
|
||||
Router\Middleware\RouteMiddleware::class,
|
||||
Router\Middleware\ImplicitHeadMiddleware::class,
|
||||
],
|
||||
],
|
||||
|
||||
'rest' => [
|
||||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
Mezzio\Router\Middleware\ImplicitOptionsMiddleware::class,
|
||||
Router\Middleware\ImplicitOptionsMiddleware::class,
|
||||
Rest\Middleware\BodyParserMiddleware::class,
|
||||
Rest\Middleware\AuthenticationMiddleware::class,
|
||||
],
|
||||
@@ -50,7 +52,7 @@ return [
|
||||
|
||||
'dispatch' => [
|
||||
'middleware' => [
|
||||
Mezzio\Router\Middleware\DispatchMiddleware::class,
|
||||
Router\Middleware\DispatchMiddleware::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -67,4 +69,5 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,7 @@ $buildDbConnection = function (): array {
|
||||
'charset' => 'utf8',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
],
|
||||
],
|
||||
'postgres' => [
|
||||
@@ -79,13 +80,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([
|
||||
|
||||
17
data/infra/mercure_proxy_vhost.conf
Normal file
17
data/infra/mercure_proxy_vhost.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
|
||||
error_log /home/shlink/www/data/infra/nginx/mercure_proxy.error.log;
|
||||
|
||||
location / {
|
||||
proxy_pass http://shlink_mercure;
|
||||
proxy_read_timeout 24h;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
## Be sure to set USE_FORWARDED_HEADERS=1 to allow the hub to use those headers ##
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_nginx:
|
||||
container_name: shlink_nginx
|
||||
image: nginx:1.17.6-alpine
|
||||
image: nginx:1.17.10-alpine
|
||||
ports:
|
||||
- "8000:80"
|
||||
volumes:
|
||||
@@ -27,6 +27,8 @@ services:
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
@@ -47,6 +49,8 @@ services:
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
@@ -102,3 +106,24 @@ services:
|
||||
image: redis:5.0-alpine
|
||||
ports:
|
||||
- "6380:6379"
|
||||
|
||||
shlink_mercure_proxy:
|
||||
container_name: shlink_mercure_proxy
|
||||
image: nginx:1.17.10-alpine
|
||||
ports:
|
||||
- "8001:80"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
- ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
|
||||
links:
|
||||
- shlink_mercure
|
||||
|
||||
shlink_mercure:
|
||||
container_name: shlink_mercure
|
||||
image: dunglas/mercure:v0.8
|
||||
ports:
|
||||
- "3080:80"
|
||||
environment:
|
||||
CORS_ALLOWED_ORIGINS: "*"
|
||||
JWT_KEY: "mercure_jwt_key"
|
||||
USE_FORWARDED_HEADERS: "1"
|
||||
|
||||
@@ -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_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:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
--name shlink \
|
||||
-p 8080:8080 \
|
||||
-e SHORT_DOMAIN_HOST=doma.in \
|
||||
-e SHORT_DOMAIN_SCHEMA=https \
|
||||
-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
|
||||
```
|
||||
|
||||
## 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_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:
|
||||
|
||||
@@ -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_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
|
||||
```
|
||||
|
||||
@@ -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_public_hub_url": "https://example.com",
|
||||
"mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local",
|
||||
"mercure_jwt_secret": "super_secret_key"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ $helper = new class {
|
||||
$driverOptions = ! contains(['maria', 'mysql'], $driver) ? [] : [
|
||||
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
|
||||
1002 => 'SET NAMES utf8',
|
||||
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
|
||||
1000 => true,
|
||||
];
|
||||
return [
|
||||
'driver' => self::DB_DRIVERS_MAP[$driver],
|
||||
@@ -79,6 +81,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 +160,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'mercure' => $helper->getMercureConfig(),
|
||||
|
||||
];
|
||||
|
||||
@@ -12,6 +12,9 @@ php bin/cli db:migrate -n -q
|
||||
echo "Generating proxies..."
|
||||
php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
|
||||
|
||||
echo "Clearing entities cache..."
|
||||
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q
|
||||
|
||||
# When restarting the container, swoole might think it is already in execution
|
||||
# This forces the app to be started every second until the exit code is 0
|
||||
until php vendor/mezzio/mezzio-swoole/bin/mezzio-swoole start; do sleep 1 ; done
|
||||
|
||||
210
docs/async-api/async-api.json
Normal file
210
docs/async-api/async-api.json
Normal file
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"asyncapi": "2.0.0",
|
||||
"info": {
|
||||
"title": "Shlink",
|
||||
"version": "2.0.0",
|
||||
"description": "Shlink, the self-hosted URL shortener",
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "https://github.com/shlinkio/shlink/blob/develop/LICENSE"
|
||||
}
|
||||
},
|
||||
"defaultContentType": "application/json",
|
||||
"channels": {
|
||||
"http://shlink.io/new-visit": {
|
||||
"subscribe": {
|
||||
"summary": "Receive information about any new visit occurring on any short URL.",
|
||||
"operationId": "newVisit",
|
||||
"message": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shortUrl": {
|
||||
"$ref": "#/components/schemas/ShortUrl"
|
||||
},
|
||||
"visit": {
|
||||
"$ref": "#/components/schemas/Visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http://shlink.io/new-visit/{shortCode}": {
|
||||
"parameters": {
|
||||
"shortCode": {
|
||||
"description": "The short code of the short URL",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"summary": "Receive information about any new visit occurring on a specific short URL.",
|
||||
"operationId": "newShortUrlVisit",
|
||||
"message": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shortUrl": {
|
||||
"$ref": "#/components/schemas/ShortUrl"
|
||||
},
|
||||
"visit": {
|
||||
"$ref": "#/components/schemas/Visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ShortUrl": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shortCode": {
|
||||
"type": "string",
|
||||
"description": "The short code for this short URL."
|
||||
},
|
||||
"shortUrl": {
|
||||
"type": "string",
|
||||
"description": "The short URL."
|
||||
},
|
||||
"longUrl": {
|
||||
"type": "string",
|
||||
"description": "The original long URL."
|
||||
},
|
||||
"dateCreated": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the short URL was created in ISO format."
|
||||
},
|
||||
"visitsCount": {
|
||||
"type": "integer",
|
||||
"description": "The number of visits that this short URL has recieved."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "A list of tags applied to this short URL"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/ShortUrlMeta"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 328,
|
||||
"tags": [
|
||||
"games",
|
||||
"tech"
|
||||
],
|
||||
"meta": {
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": "example.com"
|
||||
}
|
||||
},
|
||||
"ShortUrlMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validSince",
|
||||
"validUntil",
|
||||
"maxVisits"
|
||||
],
|
||||
"properties": {
|
||||
"validSince": {
|
||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"validUntil": {
|
||||
"description": "The date (in ISO-8601 format) until which this short code will be valid",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"maxVisits": {
|
||||
"description": "The maximum number of allowed visits for this short code",
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Visit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"referer": {
|
||||
"type": "string",
|
||||
"description": "The origin from which the visit was performed"
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the visit was performed"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "string",
|
||||
"description": "The user agent from which the visit was performed"
|
||||
},
|
||||
"visitLocation": {
|
||||
"$ref": "#/components/schemas/VisitLocation"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": 37.3042,
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisitLocation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cityName": {
|
||||
"type": "string"
|
||||
},
|
||||
"countryCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"countryName": {
|
||||
"type": "string"
|
||||
},
|
||||
"latitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"regionName": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
docs/swagger/definitions/MercureInfo.json
Normal file
18
docs/swagger/definitions/MercureInfo.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
67
docs/swagger/paths/v2_mercure-info.json
Normal file
67
docs/swagger/paths/v2_mercure-info.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -4,9 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Mezzio\Router\RouterInterface;
|
||||
use Mezzio\Template\TemplateRendererInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
@@ -39,9 +37,9 @@ return [
|
||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
|
||||
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
|
||||
|
||||
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -81,14 +79,14 @@ return [
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\QrCodeAction::class => [
|
||||
RouterInterface::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
'config.url_shortener.domain',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
|
||||
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
|
||||
|
||||
Resolver\PersistenceDomainResolver::class => ['em'],
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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,13 @@ return [
|
||||
'factories' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
|
||||
'delegators' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => [
|
||||
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -47,6 +56,12 @@ return [
|
||||
'config.url_shortener.domain',
|
||||
Options\AppOptions::class,
|
||||
],
|
||||
EventDispatcher\NotifyVisitToMercure::class => [
|
||||
Publisher::class,
|
||||
Mercure\MercureUpdatesGenerator::class,
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Action;
|
||||
use Shlinkio\Shlink\Core\Middleware;
|
||||
|
||||
return [
|
||||
|
||||
@@ -32,7 +31,6 @@ return [
|
||||
'name' => Action\QrCodeAction::class,
|
||||
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
|
||||
'middleware' => [
|
||||
Middleware\QrCodeCacheMiddleware::class,
|
||||
Action\QrCodeAction::class,
|
||||
],
|
||||
'allowed_methods' => [RequestMethod::METHOD_GET],
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
@@ -24,7 +26,7 @@ use function array_merge;
|
||||
use function GuzzleHttp\Psr7\build_query;
|
||||
use function GuzzleHttp\Psr7\parse_query;
|
||||
|
||||
abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
|
||||
{
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private VisitsTrackerInterface $visitTracker;
|
||||
@@ -50,14 +52,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
||||
|
||||
try {
|
||||
$url = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
|
||||
// Track visit to this short code
|
||||
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {
|
||||
$this->visitTracker->track($url, Visitor::fromRequest($request));
|
||||
if ($this->shouldTrackRequest($request, $query, $disableTrackParam)) {
|
||||
$this->visitTracker->track($shortUrl, Visitor::fromRequest($request));
|
||||
}
|
||||
|
||||
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
|
||||
return $this->createSuccessResp($this->buildUrlToRedirectTo($shortUrl, $query, $disableTrackParam));
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
|
||||
return $this->createErrorResp($request, $handler);
|
||||
@@ -76,6 +77,16 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
return (string) $uri->withQuery(build_query($mergedQuery));
|
||||
}
|
||||
|
||||
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool
|
||||
{
|
||||
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
|
||||
if ($forwardedMethod === self::METHOD_HEAD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query);
|
||||
}
|
||||
|
||||
abstract protected function createSuccessResp(string $longUrl): ResponseInterface;
|
||||
|
||||
abstract protected function createErrorResp(
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Mezzio\Router\RouterInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
@@ -23,17 +22,17 @@ class QrCodeAction implements MiddlewareInterface
|
||||
private const MIN_SIZE = 50;
|
||||
private const MAX_SIZE = 1000;
|
||||
|
||||
private RouterInterface $router;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private array $domainConfig;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
RouterInterface $router,
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
$this->router = $router;
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->domainConfig = $domainConfig;
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
}
|
||||
|
||||
@@ -42,23 +41,19 @@ class QrCodeAction implements MiddlewareInterface
|
||||
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
||||
|
||||
try {
|
||||
$this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$path = $this->router->generateUri(RedirectAction::class, ['shortCode' => $identifier->shortCode()]);
|
||||
$size = $this->getSizeParam($request);
|
||||
|
||||
$qrCode = new QrCode((string) $request->getUri()->withPath($path)->withQuery(''));
|
||||
$qrCode->setSize($size);
|
||||
$qrCode = new QrCode($shortUrl->toString($this->domainConfig));
|
||||
$qrCode->setSize($this->getSizeParam($request));
|
||||
$qrCode->setMargin(0);
|
||||
|
||||
return new QrCodeResponse($qrCode);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
private function getSizeParam(Request $request): int
|
||||
{
|
||||
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
|
||||
class CloseDbConnectionEventListener
|
||||
{
|
||||
private ReopeningEntityManagerInterface $em;
|
||||
/** @var callable */
|
||||
private $wrapped;
|
||||
|
||||
public function __construct(ReopeningEntityManagerInterface $em, callable $wrapped)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->wrapped = $wrapped;
|
||||
}
|
||||
|
||||
public function __invoke(object $event): void
|
||||
{
|
||||
$this->em->open();
|
||||
|
||||
try {
|
||||
($this->wrapped)($event);
|
||||
} finally {
|
||||
$this->em->getConnection()->close();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
|
||||
class CloseDbConnectionEventListenerDelegator
|
||||
{
|
||||
public function __invoke(
|
||||
ContainerInterface $container,
|
||||
string $name,
|
||||
callable $callback
|
||||
): CloseDbConnectionEventListener {
|
||||
/** @var callable $wrapped */
|
||||
$wrapped = $callback();
|
||||
/** @var ReopeningEntityManagerInterface $em */
|
||||
$em = $container->get('em');
|
||||
|
||||
return new CloseDbConnectionEventListener($em, $wrapped);
|
||||
}
|
||||
}
|
||||
55
module/Core/src/EventDispatcher/NotifyVisitToMercure.php
Normal file
55
module/Core/src/EventDispatcher/NotifyVisitToMercure.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
|
||||
use Symfony\Component\Mercure\PublisherInterface;
|
||||
use Throwable;
|
||||
|
||||
class NotifyVisitToMercure
|
||||
{
|
||||
private PublisherInterface $publisher;
|
||||
private MercureUpdatesGeneratorInterface $updatesGenerator;
|
||||
private EntityManagerInterface $em;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
PublisherInterface $publisher,
|
||||
MercureUpdatesGeneratorInterface $updatesGenerator,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->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->newShortUrlVisitUpdate($visit));
|
||||
($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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Promise\Promise;
|
||||
use GuzzleHttp\Promise\PromiseInterface;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
@@ -89,12 +90,14 @@ class NotifyVisitToWebHooks
|
||||
*/
|
||||
private function performRequests(array $requestOptions, string $visitId): array
|
||||
{
|
||||
return map($this->webhooks, function (string $webhook) use ($requestOptions, $visitId) {
|
||||
$promise = $this->httpClient->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions);
|
||||
return $promise->otherwise(
|
||||
partial_left(Closure::fromCallable([$this, 'logWebhookFailure']), $webhook, $visitId),
|
||||
);
|
||||
});
|
||||
$logWebhookFailure = Closure::fromCallable([$this, 'logWebhookFailure']);
|
||||
|
||||
return map(
|
||||
$this->webhooks,
|
||||
fn (string $webhook): PromiseInterface => $this->httpClient
|
||||
->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions)
|
||||
->otherwise(partial_left($logWebhookFailure, $webhook, $visitId)),
|
||||
);
|
||||
}
|
||||
|
||||
private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void
|
||||
|
||||
50
module/Core/src/Mercure/MercureUpdatesGenerator.php
Normal file
50
module/Core/src/Mercure/MercureUpdatesGenerator.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Mercure;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
use function json_encode;
|
||||
use function sprintf;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
|
||||
{
|
||||
private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit';
|
||||
|
||||
private ShortUrlDataTransformer $transformer;
|
||||
|
||||
public function __construct(array $domainConfig)
|
||||
{
|
||||
$this->transformer = new ShortUrlDataTransformer($domainConfig);
|
||||
}
|
||||
|
||||
public function newVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
return new Update(self::NEW_VISIT_TOPIC, $this->serialize([
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
||||
'visit' => $visit,
|
||||
]));
|
||||
}
|
||||
|
||||
public function newShortUrlVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
$shortUrl = $visit->getShortUrl();
|
||||
$topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode());
|
||||
|
||||
return new Update($topic, $this->serialize([
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
||||
'visit' => $visit,
|
||||
]));
|
||||
}
|
||||
|
||||
private function serialize(array $data): string
|
||||
{
|
||||
return json_encode($data, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
15
module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php
Normal file
15
module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Mercure;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
interface MercureUpdatesGeneratorInterface
|
||||
{
|
||||
public function newVisitUpdate(Visit $visit): Update;
|
||||
|
||||
public function newShortUrlVisitUpdate(Visit $visit): Update;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Middleware;
|
||||
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Laminas\Diactoros\Response as DiactResp;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class QrCodeCacheMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private Cache $cache;
|
||||
|
||||
public function __construct(Cache $cache)
|
||||
{
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming server request and return a response, optionally delegating
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
*
|
||||
*/
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
$cacheKey = $request->getUri()->getPath();
|
||||
|
||||
// If this QR code is already cached, just return it
|
||||
if ($this->cache->contains($cacheKey)) {
|
||||
$qrData = $this->cache->fetch($cacheKey);
|
||||
$response = new DiactResp();
|
||||
$response->getBody()->write($qrData['body']);
|
||||
return $response->withHeader('Content-Type', $qrData['content-type']);
|
||||
}
|
||||
|
||||
// If not, call the next middleware and cache it
|
||||
/** @var Response $resp */
|
||||
$resp = $handler->handle($request);
|
||||
$this->cache->save($cacheKey, [
|
||||
'body' => $resp->getBody()->__toString(),
|
||||
'content-type' => $resp->getHeaderLine('Content-Type'),
|
||||
]);
|
||||
return $resp;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class QrCodeActionTest extends TestCase
|
||||
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
|
||||
$this->action = new QrCodeAction($router->reveal(), $this->urlResolver->reveal());
|
||||
$this->action = new QrCodeAction($this->urlResolver->reveal(), ['domain' => 'doma.in']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
@@ -89,4 +91,23 @@ class RedirectActionTest extends TestCase
|
||||
|
||||
$handle->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
|
||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl);
|
||||
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
|
||||
});
|
||||
|
||||
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)
|
||||
->withAttribute(
|
||||
ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE,
|
||||
RequestMethodInterface::METHOD_HEAD,
|
||||
);
|
||||
$this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
|
||||
|
||||
$track->shouldNotHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListenerDelegator;
|
||||
|
||||
class CloseDbConnectionEventListenerDelegatorTest extends TestCase
|
||||
{
|
||||
private CloseDbConnectionEventListenerDelegator $delegator;
|
||||
private ObjectProphecy $container;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->container = $this->prophesize(ContainerInterface::class);
|
||||
$this->delegator = new CloseDbConnectionEventListenerDelegator();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properDependenciesArePassed(): void
|
||||
{
|
||||
$callbackInvoked = false;
|
||||
$callback = function () use (&$callbackInvoked): callable {
|
||||
$callbackInvoked = true;
|
||||
|
||||
return function (): void {
|
||||
};
|
||||
};
|
||||
|
||||
$em = $this->prophesize(ReopeningEntityManagerInterface::class);
|
||||
$getEm = $this->container->get('em')->willReturn($em->reveal());
|
||||
|
||||
($this->delegator)($this->container->reveal(), '', $callback);
|
||||
|
||||
$this->assertTrue($callbackInvoked);
|
||||
$getEm->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use RuntimeException;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListener;
|
||||
use stdClass;
|
||||
use Throwable;
|
||||
|
||||
class CloseDbConnectionEventListenerTest extends TestCase
|
||||
{
|
||||
private ObjectProphecy $em;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(ReopeningEntityManagerInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideWrapped
|
||||
*/
|
||||
public function connectionIsOpenedBeforeAndClosedAfter(callable $wrapped, bool &$wrappedWasCalled): void
|
||||
{
|
||||
$conn = $this->prophesize(Connection::class);
|
||||
$close = $conn->close()->will(function (): void {
|
||||
});
|
||||
$getConn = $this->em->getConnection()->willReturn($conn->reveal());
|
||||
$clear = $this->em->clear()->will(function (): void {
|
||||
});
|
||||
$open = $this->em->open()->will(function (): void {
|
||||
});
|
||||
|
||||
$eventListener = new CloseDbConnectionEventListener($this->em->reveal(), $wrapped);
|
||||
|
||||
try {
|
||||
($eventListener)(new stdClass());
|
||||
} catch (Throwable $e) {
|
||||
// Ignore exceptions
|
||||
}
|
||||
|
||||
$this->assertTrue($wrappedWasCalled);
|
||||
$close->shouldHaveBeenCalledOnce();
|
||||
$getConn->shouldHaveBeenCalledOnce();
|
||||
$clear->shouldHaveBeenCalledOnce();
|
||||
$open->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideWrapped(): iterable
|
||||
{
|
||||
yield 'does not throw exception' => (function (): array {
|
||||
$wrappedWasCalled = false;
|
||||
$wrapped = function () use (&$wrappedWasCalled): void {
|
||||
$wrappedWasCalled = true;
|
||||
};
|
||||
|
||||
return [$wrapped, &$wrappedWasCalled];
|
||||
})();
|
||||
yield 'throws exception' => (function (): array {
|
||||
$wrappedWasCalled = false;
|
||||
$wrapped = function () use (&$wrappedWasCalled): void {
|
||||
$wrappedWasCalled = true;
|
||||
throw new RuntimeException('Some error');
|
||||
};
|
||||
|
||||
return [$wrapped, &$wrappedWasCalled];
|
||||
})();
|
||||
}
|
||||
}
|
||||
123
module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php
Normal file
123
module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToMercure;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
|
||||
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Symfony\Component\Mercure\PublisherInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
class NotifyVisitToMercureTest extends TestCase
|
||||
{
|
||||
private NotifyVisitToMercure $listener;
|
||||
private ObjectProphecy $publisher;
|
||||
private ObjectProphecy $updatesGenerator;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $logger;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->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 notificationsAreNotSentWhenVisitCannotBeFound(): 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());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate(
|
||||
Argument::type(Visit::class),
|
||||
)->willReturn(new Update('', ''));
|
||||
$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();
|
||||
$buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function notificationsAreSentWhenVisitIsFound(): 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());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
|
||||
$publish = $this->publisher->__invoke($update);
|
||||
|
||||
($this->listener)(new VisitLocated($visitId));
|
||||
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldNotHaveBeenCalled();
|
||||
$logDebug->shouldNotHaveBeenCalled();
|
||||
$buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$publish->shouldHaveBeenCalledTimes(2);
|
||||
}
|
||||
|
||||
/** @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,
|
||||
]);
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$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();
|
||||
$buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
66
module/Core/test/Mercure/MercureUpdatesGeneratorTest.php
Normal file
66
module/Core/test/Mercure/MercureUpdatesGeneratorTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Mercure;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGenerator;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
||||
use function Shlinkio\Shlink\Common\json_decode;
|
||||
|
||||
class MercureUpdatesGeneratorTest extends TestCase
|
||||
{
|
||||
private MercureUpdatesGenerator $generator;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->generator = new MercureUpdatesGenerator([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideMethod
|
||||
*/
|
||||
public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['customSlug' => 'foo']));
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
||||
|
||||
$update = $this->generator->{$method}($visit);
|
||||
|
||||
$this->assertEquals([$expectedTopic], $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()));
|
||||
}
|
||||
|
||||
public function provideMethod(): iterable
|
||||
{
|
||||
yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit'];
|
||||
yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo'];
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Middleware;
|
||||
|
||||
use Doctrine\Common\Cache\ArrayCache;
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Middleware\QrCodeCacheMiddleware;
|
||||
|
||||
class QrCodeCacheMiddlewareTest extends TestCase
|
||||
{
|
||||
private QrCodeCacheMiddleware $middleware;
|
||||
private Cache $cache;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->cache = new ArrayCache();
|
||||
$this->middleware = new QrCodeCacheMiddleware($this->cache);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function noCachedPathFallsBackToNextMiddleware(): void
|
||||
{
|
||||
$delegate = $this->prophesize(RequestHandlerInterface::class);
|
||||
$delegate->handle(Argument::any())->willReturn(new Response())->shouldBeCalledOnce();
|
||||
|
||||
$this->middleware->process((new ServerRequest())->withUri(new Uri('/foo/bar')), $delegate->reveal());
|
||||
|
||||
$this->assertTrue($this->cache->contains('/foo/bar'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cachedPathReturnsCacheContent(): void
|
||||
{
|
||||
$isCalled = false;
|
||||
$uri = (new Uri())->withPath('/foo');
|
||||
$this->cache->save('/foo', ['body' => 'the body', 'content-type' => 'image/png']);
|
||||
$delegate = $this->prophesize(RequestHandlerInterface::class);
|
||||
|
||||
$resp = $this->middleware->process((new ServerRequest())->withUri($uri), $delegate->reveal());
|
||||
|
||||
$this->assertFalse($isCalled);
|
||||
$resp->getBody()->rewind();
|
||||
$this->assertEquals('the body', $resp->getBody()->getContents());
|
||||
$this->assertEquals('image/png', $resp->getHeaderLine('Content-Type'));
|
||||
$delegate->handle(Argument::any())->shouldHaveBeenCalledTimes(0);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
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 +20,7 @@ return [
|
||||
ApiKeyService::class => ConfigAbstractFactory::class,
|
||||
|
||||
Action\HealthAction::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,
|
||||
@@ -45,7 +46,8 @@ return [
|
||||
ConfigAbstractFactory::class => [
|
||||
ApiKeyService::class => ['em'],
|
||||
|
||||
Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'],
|
||||
Action\HealthAction::class => ['em', AppOptions::class, 'Logger_Shlink'],
|
||||
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure', 'Logger_Shlink'],
|
||||
Action\ShortUrl\CreateShortUrlAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
'config.url_shortener.domain',
|
||||
|
||||
@@ -33,6 +33,8 @@ return [
|
||||
Action\Tag\DeleteTagsAction::getRouteDef(),
|
||||
Action\Tag\CreateTagsAction::getRouteDef(),
|
||||
Action\Tag\UpdateTagAction::getRouteDef(),
|
||||
|
||||
Action\MercureInfoAction::getRouteDef(),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -21,13 +21,13 @@ class HealthAction extends AbstractRestAction
|
||||
protected const ROUTE_PATH = '/health';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private AppOptions $options;
|
||||
private Connection $conn;
|
||||
|
||||
public function __construct(Connection $conn, AppOptions $options, ?LoggerInterface $logger = null)
|
||||
public function __construct(EntityManagerInterface $em, AppOptions $options, ?LoggerInterface $logger = null)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->conn = $conn;
|
||||
$this->em = $em;
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class HealthAction extends AbstractRestAction
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$connected = $this->conn->ping();
|
||||
$connected = $this->em->getConnection()->ping();
|
||||
} catch (Throwable $e) {
|
||||
$connected = false;
|
||||
}
|
||||
|
||||
58
module/Rest/src/Action/MercureInfoAction.php
Normal file
58
module/Rest/src/Action/MercureInfoAction.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
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';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private JwtProviderInterface $jwtProvider;
|
||||
private array $mercureConfig;
|
||||
|
||||
public function __construct(
|
||||
JwtProviderInterface $jwtProvider,
|
||||
array $mercureConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
$this->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'] ?? 1;
|
||||
$expiresAt = Chronos::now()->addDays($days);
|
||||
|
||||
try {
|
||||
$jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt);
|
||||
} catch (Throwable $e) {
|
||||
throw MercureException::mercureNotConfigured($e);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'mercureHubUrl' => sprintf('%s/.well-known/mercure', $hubUrl),
|
||||
'token' => $jwt,
|
||||
'jwtExpiration' => $expiresAt->toAtomString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
module/Rest/src/Exception/MercureException.php
Normal file
30
module/Rest/src/Exception/MercureException.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Exception;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Mercure integration not configured';
|
||||
private const TYPE = 'MERCURE_NOT_CONFIGURED';
|
||||
|
||||
public static function mercureNotConfigured(?Throwable $prev = null): self
|
||||
{
|
||||
$e = new self('This Shlink instance is not integrated with a mercure hub.', 1, $prev);
|
||||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->status = StatusCodeInterface::STATUS_NOT_IMPLEMENTED;
|
||||
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\Rest\Action;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
@@ -21,7 +22,10 @@ class HealthActionTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->conn = $this->prophesize(Connection::class);
|
||||
$this->action = new HealthAction($this->conn->reveal(), new AppOptions(['version' => '1.2.3']));
|
||||
$em = $this->prophesize(EntityManagerInterface::class);
|
||||
$em->getConnection()->willReturn($this->conn->reveal());
|
||||
|
||||
$this->action = new HealthAction($em->reveal(), new AppOptions(['version' => '1.2.3']));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
106
module/Rest/test/Action/MercureInfoActionTest.php
Normal file
106
module/Rest/test/Action/MercureInfoActionTest.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Action;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use RuntimeException;
|
||||
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\MercureInfoAction;
|
||||
use Shlinkio\Shlink\Rest\Exception\MercureException;
|
||||
|
||||
class MercureInfoActionTest extends TestCase
|
||||
{
|
||||
private ObjectProphecy $provider;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->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/.well-known/mercure', $payload['mercureHubUrl']);
|
||||
$this->assertArrayHasKey('token', $payload);
|
||||
$this->assertArrayHasKey('jwtExpiration', $payload);
|
||||
$this->assertEquals(
|
||||
Chronos::now()->addDays($days ?? 1)->startOfDay(),
|
||||
Chronos::parse($payload['jwtExpiration'])->startOfDay(),
|
||||
);
|
||||
$buildToken->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideDays(): iterable
|
||||
{
|
||||
yield 'days not defined' => [null];
|
||||
yield 'days defined' => [10];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user