diff --git a/.travis.yml b/.travis.yml index 8b232c76..6ab12fa8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,8 +8,6 @@ php: - '7.4' services: - - mysql - - postgresql - docker cache: @@ -17,8 +15,10 @@ cache: - $HOME/.composer/cache/files before_install: + - sudo ./data/infra/ci/install-ms-odbc.sh + - docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria + - yes | pecl install pdo_sqlsrv swoole-4.4.18 - echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - - yes | pecl install swoole-4.4.15 - phpenv config-rm xdebug.ini || return 0 install: @@ -26,8 +26,7 @@ install: - composer install --no-interaction --prefer-dist before_script: - - mysql -e 'CREATE DATABASE shlink_test;' - - psql -c 'create database shlink_test;' -U postgres + - docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" - mkdir build - export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb6d03b..f2491f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,58 @@ 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). +## 2.2.0 - 2020-05-09 + +#### 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 subscribe to updates. + +* [#673](https://github.com/shlinkio/shlink/issues/673) Added new `[GET /visits]` rest endpoint which returns basic visits stats. +* [#674](https://github.com/shlinkio/shlink/issues/674) Added new `[GET /tags/{tag}/visits]` rest endpoint which returns visits by tag. + + It works in the same way as the `[GET /short-urls/{shortCode}/visits]` one, returning the same response payload, and supporting the same query params, but the response is the list of visits in all short URLs which have provided tag. + +* [#672](https://github.com/shlinkio/shlink/issues/672) Enhanced `[GET /tags]` rest endpoint so that it is possible to get basic stats info for every tag. + + Now, if the `withStats=true` query param is provided, the response payload will include a new `stats` property which is a list with the amount of short URLs and visits for every tag. + + Also, the `tag:list` CLI command has been changed and it always behaves like this. + +* [#640](https://github.com/shlinkio/shlink/issues/640) Allowed to optionally disable visitors' IP address anonymization. This will make Shlink no longer be GDPR-compliant, but it's OK if you only plan to share your URLs in countries without this regulation. + +#### Changed + +* [#692](https://github.com/shlinkio/shlink/issues/692) Drastically improved performance when loading visits. Specially noticeable when loading big result sets. +* [#657](https://github.com/shlinkio/shlink/issues/657) Updated how DB tests are run in travis by using docker containers which allow all engines to be covered. +* [#751](https://github.com/shlinkio/shlink/issues/751) Updated PHP and swoole versions used in docker image, and removed mssql-tools, as they are not needed. + +#### 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. +* [#732](https://github.com/shlinkio/shlink/issues/732) Fixed wrong client IP in access logs when serving app with swoole behind load balancer. + + ## 2.1.4 - 2020-04-30 #### Added diff --git a/Dockerfile b/Dockerfile index 64cd7ebe..3f2c5856 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM php:7.4.2-alpine3.11 as base +FROM php:7.4.5-alpine3.11 as base -ARG SHLINK_VERSION=2.0.5 +ARG SHLINK_VERSION=2.1.4 ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.4.15 +ENV SWOOLE_VERSION 4.4.18 ENV LC_ALL "C" WORKDIR /etc/shlink @@ -25,15 +25,12 @@ RUN \ # Install swoole and sqlsrv driver RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ - wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ - apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \ docker-php-ext-enable swoole pdo_sqlsrv && \ apk del .phpize-deps && \ - rm msodbcsql17_17.5.1.1-1_amd64.apk && \ - rm mssql-tools_17.5.1.1-1_amd64.apk + rm msodbcsql17_17.5.1.1-1_amd64.apk # Install shlink diff --git a/composer.json b/composer.json index e6c59e98..5d531a37 100644 --- a/composer.json +++ b/composer.json @@ -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,13 +32,14 @@ "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", "mezzio/mezzio-helpers": "^5.3", "mezzio/mezzio-platesrenderer": "^2.1", "mezzio/mezzio-problem-details": "^1.1", - "mezzio/mezzio-swoole": "^2.6", + "mezzio/mezzio-swoole": "^2.6.4", "monolog/monolog": "^2.0", "nikolaposa/monolog-factory": "^3.0", "ocramius/proxy-manager": "^2.7.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": "^3.1.0", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^1.4", - "shlinkio/shlink-installer": "^4.4.0", + "shlinkio/shlink-installer": "^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": { @@ -109,7 +109,7 @@ ], "test:ci": [ "@test:unit:ci", - "@test:db:ci", + "@test:db", "@test:api:ci" ], "test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", @@ -121,11 +121,6 @@ "@test:db:postgres", "@test:db:ms" ], - "test:db:ci": [ - "@test:db:sqlite", - "@test:db:mysql", - "@test:db:postgres" - ], "test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml", "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", @@ -152,8 +147,7 @@ "test:ci": "Runs all test suites, generating all needed reports and logs for CI envs", "test:unit": "Runs unit test suites", "test:unit:ci": "Runs unit test suites, generating all needed reports and logs for CI envs", - "test:db": "Runs database test suites on a SQLite, MySQL, MariaDB and PostgreSQL", - "test:db:ci": "Runs database test suites on a SQLite, MySQL and PostgreSQL", + "test:db": "Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL", "test:db:sqlite": "Runs database test suites on a SQLite database", "test:db:mysql": "Runs database test suites on a MySQL database", "test:db:maria": "Runs database test suites on a MariaDB database", diff --git a/config/autoload/entity-manager.local.php.dist b/config/autoload/entity-manager.local.php.dist index 3c38fb82..1faed328 100644 --- a/config/autoload/entity-manager.local.php.dist +++ b/config/autoload/entity-manager.local.php.dist @@ -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, ], ], diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 7d44744c..db1914db 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -8,30 +8,35 @@ 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\GeoLiteLicenseKeyConfigOption::class, + Option\UrlShortener\ShortCodeLengthOption::class, + Option\Mercure\EnableMercureConfigOption::class, + Option\Mercure\MercurePublicUrlConfigOption::class, + Option\Mercure\MercureInternalUrlConfigOption::class, + Option\Mercure\MercureJwtSecretConfigOption::class, + Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class, + Option\UrlShortener\IpAnonymizationConfigOption::class, ], 'installation_commands' => [ diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php new file mode 100644 index 00000000..1a404dca --- /dev/null +++ b/config/autoload/mercure.global.php @@ -0,0 +1,36 @@ + [ + '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, + ], + ], + ], + +]; diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist new file mode 100644 index 00000000..b10ad86e --- /dev/null +++ b/config/autoload/mercure.local.php.dist @@ -0,0 +1,13 @@ + [ + 'public_hub_url' => 'http://localhost:8001', + 'internal_hub_url' => 'http://shlink_mercure_proxy', + 'jwt_secret' => 'mercure_jwt_key', + ], + +]; diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index afc48a97..29c1ea37 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -5,7 +5,8 @@ declare(strict_types=1); return [ 'mezzio-swoole' => [ - 'enable_coroutine' => true, + // Setting this to true can have unexpected behaviors when running several concurrent slow DB queries + 'enable_coroutine' => false, 'swoole-http-server' => [ 'host' => '0.0.0.0', diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 165e0258..5ad66bea 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -12,6 +12,7 @@ return [ 'hostname' => '', ], 'validate_url' => false, + 'anonymize_remote_addr' => true, 'visits_webhooks' => [], 'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH, ], 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/config/test/test_config.global.php b/config/test/test_config.global.php index fa51c240..0086bcd0 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -20,6 +20,7 @@ $buildDbConnection = function (): array { $driver = env('DB_DRIVER', 'sqlite'); $isCi = env('TRAVIS', false); $getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria'); + $getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308'; $driverConfigMap = [ 'sqlite' => [ @@ -29,19 +30,22 @@ $buildDbConnection = function (): array { 'mysql' => [ 'driver' => 'pdo_mysql', 'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver), + 'port' => $isCi ? $getCiMysqlPort($driver) : '3306', 'user' => 'root', - 'password' => $isCi ? '' : 'root', + 'password' => 'root', 'dbname' => 'shlink_test', 'charset' => 'utf8', 'driverOptions' => [ PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', + PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, ], ], 'postgres' => [ 'driver' => 'pdo_pgsql', 'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres', + 'port' => $isCi ? '5433' : '5432', 'user' => 'postgres', - 'password' => $isCi ? '' : 'root', + 'password' => 'root', 'dbname' => 'shlink_test', 'charset' => 'utf8', ], @@ -49,7 +53,7 @@ $buildDbConnection = function (): array { 'driver' => 'pdo_sqlsrv', 'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms', 'user' => 'sa', - 'password' => $isCi ? '' : 'Passw0rd!', + 'password' => 'Passw0rd!', 'dbname' => 'shlink_test', ], ]; @@ -79,13 +83,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/data/infra/ci/install-ms-odbc.sh b/data/infra/ci/install-ms-odbc.sh new file mode 100755 index 00000000..8cd60580 --- /dev/null +++ b/data/infra/ci/install-ms-odbc.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -ex + +curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - +curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list +apt-get update +ACCEPT_EULA=Y apt-get install msodbcsql17 +apt-get install unixodbc-dev diff --git a/data/infra/mercure_proxy_vhost.conf b/data/infra/mercure_proxy_vhost.conf new file mode 100644 index 00000000..df986b37 --- /dev/null +++ b/data/infra/mercure_proxy_vhost.conf @@ -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; + } +} diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index c5401651..33b654c5 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,4 +1,4 @@ -FROM php:7.4.2-fpm-alpine3.11 +FROM php:7.4.5-fpm-alpine3.11 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.18 @@ -67,15 +67,12 @@ RUN rm /tmp/xdebug.tar.gz # Install sqlsrv driver RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ - wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ - apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ pecl install pdo_sqlsrv && \ docker-php-ext-enable pdo_sqlsrv && \ apk del .phpize-deps && \ - rm msodbcsql17_17.5.1.1-1_amd64.apk && \ - rm mssql-tools_17.5.1.1-1_amd64.apk + rm msodbcsql17_17.5.1.1-1_amd64.apk # Install composer RUN php -r "readfile('https://getcomposer.org/installer');" | php diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 3f7a1513..9d8d4240 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:7.4.2-alpine3.11 +FROM php:7.4.5-alpine3.11 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.18 ENV APCU_BC_VERSION 1.0.5 ENV INOTIFY_VERSION 2.0.0 -ENV SWOOLE_VERSION 4.4.15 +ENV SWOOLE_VERSION 4.4.18 RUN apk update @@ -68,15 +68,12 @@ RUN rm /tmp/inotify.tar.gz # Install swoole and mssql driver RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ - wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ - apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \ docker-php-ext-enable swoole pdo_sqlsrv && \ apk del .phpize-deps && \ - rm msodbcsql17_17.5.1.1-1_amd64.apk && \ - rm mssql-tools_17.5.1.1-1_amd64.apk + rm msodbcsql17_17.5.1.1-1_amd64.apk # Install composer RUN php -r "readfile('https://getcomposer.org/installer');" | php diff --git a/data/infra/swoole_proxy_vhost.conf b/data/infra/swoole_proxy_vhost.conf new file mode 100644 index 00000000..af31b1ea --- /dev/null +++ b/data/infra/swoole_proxy_vhost.conf @@ -0,0 +1,14 @@ +server { + listen 80 default_server; + + error_log /home/shlink/www/data/infra/nginx/swoole_proxy.error.log; + + location / { + proxy_http_version 1.1; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://shlink_swoole:8080; + proxy_read_timeout 90s; + } +} diff --git a/data/migrations/Version20200503170404.php b/data/migrations/Version20200503170404.php new file mode 100644 index 00000000..a102c2c8 --- /dev/null +++ b/data/migrations/Version20200503170404.php @@ -0,0 +1,27 @@ +getTable('visits'); + $this->skipIf($visits->hasIndex(self::INDEX_NAME)); + $visits->addIndex(['date'], self::INDEX_NAME); + } + + public function down(Schema $schema): void + { + $visits = $schema->getTable('visits'); + $this->skipIf(! $visits->hasIndex(self::INDEX_NAME)); + $visits->dropIndex(self::INDEX_NAME); + } +} diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 00000000..3783fef2 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,14 @@ +version: '3' + +services: + shlink_db: + environment: + MYSQL_DATABASE: shlink_test + + shlink_db_postgres: + environment: + POSTGRES_DB: shlink_test + + shlink_db_maria: + environment: + MYSQL_DATABASE: shlink_test diff --git a/docker-compose.yml b/docker-compose.yml index c78cf85f..d700f3b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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,9 +27,22 @@ services: - shlink_db_maria - shlink_db_ms - shlink_redis + - shlink_mercure + - shlink_mercure_proxy environment: LC_ALL: C + shlink_swoole_proxy: + container_name: shlink_swoole_proxy + image: nginx:1.17.10-alpine + ports: + - "8002:80" + volumes: + - ./:/home/shlink/www + - ./data/infra/swoole_proxy_vhost.conf:/etc/nginx/conf.d/default.conf + links: + - shlink_swoole + shlink_swoole: container_name: shlink_swoole build: @@ -47,6 +60,8 @@ services: - shlink_db_maria - shlink_db_ms - shlink_redis + - shlink_mercure + - shlink_mercure_proxy environment: LC_ALL: C @@ -64,7 +79,7 @@ services: shlink_db_postgres: container_name: shlink_db_postgres - image: postgres:10.7-alpine + image: postgres:12.2-alpine ports: - "5433:5432" volumes: @@ -77,7 +92,7 @@ services: shlink_db_maria: container_name: shlink_db_maria - image: mariadb:10.2 + image: mariadb:10.5 ports: - "3308:3306" volumes: @@ -99,6 +114,27 @@ services: shlink_redis: container_name: shlink_redis - image: redis:5.0-alpine + image: redis:6.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.9 + ports: + - "3080:80" + environment: + CORS_ALLOWED_ORIGINS: "*" + JWT_KEY: "mercure_jwt_key" + USE_FORWARDED_HEADERS: "1" diff --git a/docker/README.md b/docker/README.md index 1e565bb7..e17e570e 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_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. @@ -113,15 +168,12 @@ This is the complete list of supported env vars: * `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16. * `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. - * `GEOLITE_LICENSE_KEY`: The license key used to download new GeoLite2 database files. This is not mandatory, as a default license key is provided, but it is **strongly recommended** that you provide your own. Go to [https://shlink.io/documentation/geolite-license-key](https://shlink.io/documentation/geolite-license-key) to know how to generate it. +* `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_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. +* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations. An example using all env vars could look like this: @@ -150,6 +202,10 @@ docker run \ -e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \ -e DEFAULT_SHORT_CODES_LENGTH=6 \ -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \ + -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 \ + -e ANONYMIZE_REMOTE_ADDR=false \ shlinkio/shlink:stable ``` @@ -191,7 +247,11 @@ The whole configuration should have this format, but it can be split into multip "host": "something.rds.amazonaws.com", "port": "3306" }, - "geolite_license_key": "kjh23ljkbndskj345" + "geolite_license_key": "kjh23ljkbndskj345", + "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", + "anonymize_remote_addr": false } ``` diff --git a/docker/build b/docker/build index 5eea7888..f7e4b923 100755 --- a/docker/build +++ b/docker/build @@ -7,7 +7,9 @@ echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin if [[ ! -z $TRAVIS_TAG ]]; then docker build --build-arg SHLINK_VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink:${TRAVIS_TAG#?} -t shlinkio/shlink:stable . docker push shlinkio/shlink:${TRAVIS_TAG#?} - docker push shlinkio/shlink:stable + + # Push stable tag only if this is not an alpha or beta tag + [[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && docker push shlinkio/shlink:stable # If build branch is develop, build latest (on master, when there's no tag, do not build anything) elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then docker build -t shlinkio/shlink:latest . diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index a20d1100..b870ccd7 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -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 [ @@ -104,6 +117,7 @@ return [ 'hostname' => env('SHORT_DOMAIN_HOST', ''), ], 'validate_url' => (bool) env('VALIDATE_URLS', false), + 'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true), 'visits_webhooks' => $helper->getVisitsWebhooks(), 'default_short_codes_length' => $helper->getDefaultShortCodesLength(), ], @@ -151,4 +165,6 @@ return [ 'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), ], + 'mercure' => $helper->getMercureConfig(), + ]; diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json new file mode 100644 index 00000000..5279ce91 --- /dev/null +++ b/docs/async-api/async-api.json @@ -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" + } + } + } + } + } +} 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/definitions/TagInfo.json b/docs/swagger/definitions/TagInfo.json new file mode 100644 index 00000000..e881ce02 --- /dev/null +++ b/docs/swagger/definitions/TagInfo.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "The unique tag name" + }, + "shortUrlsCount": { + "type": "number", + "description": "The amount of short URLs using this tag" + }, + "userAgent": { + "type": "number", + "description": "The combined amount of visits received by short URLs with this tag" + } + } +} diff --git a/docs/swagger/definitions/VisitStats.json b/docs/swagger/definitions/VisitStats.json new file mode 100644 index 00000000..5f439c9b --- /dev/null +++ b/docs/swagger/definitions/VisitStats.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "required": ["visitsCount"], + "properties": { + "visitsCount": { + "type": "number", + "description": "The total amount of visits received." + } + } +} diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 5e7fd71c..83bc7d68 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -14,6 +14,19 @@ "parameters": [ { "$ref": "../parameters/version.json" + }, + { + "name": "withStats", + "description": "Whether you want to include also a list with general stats by tag or not.", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } } ], "responses": { @@ -26,12 +39,20 @@ "properties": { "tags": { "type": "object", + "required": ["data"], "properties": { "data": { "type": "array", "items": { "type": "string" } + }, + "stats": { + "description": "The tag stats will be returned only if the withStats param was provided with value 'true'", + "type": "array", + "items": { + "$ref": "../definitions/TagInfo.json" + } } } } 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/paths/v2_tags_{tag}_visits.json b/docs/swagger/paths/v2_tags_{tag}_visits.json new file mode 100644 index 00000000..d9d9dda7 --- /dev/null +++ b/docs/swagger/paths/v2_tags_{tag}_visits.json @@ -0,0 +1,154 @@ +{ + "get": { + "operationId": "getTagVisits", + "tags": [ + "Visits" + ], + "summary": "List visits for tag", + "description": "Get the list of visits on any short URL which is tagged with provided tag.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "name": "tag", + "in": "path", + "description": "The tag from which we want to get the visits.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "description": "The date (in ISO-8601 format) from which we want to get visits.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "endDate", + "in": "query", + "description": "The date (in ISO-8601 format) until which we want to get visits.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page to display. Defaults to 1", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The amount of items to return on every page. Defaults to all the items", + "required": false, + "schema": { + "type": "number" + } + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "List of visits.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visits": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../definitions/Visit.json" + } + }, + "pagination": { + "$ref": "../definitions/Pagination.json" + } + } + } + } + } + } + }, + "examples": { + "application/json": { + "visits": { + "data": [ + { + "referer": "https://twitter.com", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", + "visitLocation": null + }, + { + "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" + } + }, + { + "referer": null, + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "some_web_crawler/1.4", + "visitLocation": null + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 115 + } + } + } + } + }, + "404": { + "description": "The tag does not exist.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, + "500": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/paths/v2_visits.json b/docs/swagger/paths/v2_visits.json new file mode 100644 index 00000000..089223b3 --- /dev/null +++ b/docs/swagger/paths/v2_visits.json @@ -0,0 +1,54 @@ +{ + "get": { + "operationId": "getGlobalVisits", + "tags": [ + "Visits" + ], + "summary": "Get general visits stats", + "description": "Get general visits stats not linked to one specific short URL.", + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "Visits stats.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visits": { + "$ref": "../definitions/VisitStats.json" + } + } + } + } + }, + "examples": { + "application/json": { + "visits": { + "visitsCount": 1569874 + } + } + } + }, + "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..8dc21997 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -78,9 +78,19 @@ "$ref": "paths/v1_tags.json" }, + "/rest/v{version}/visits": { + "$ref": "paths/v2_visits.json" + }, "/rest/v{version}/short-urls/{shortCode}/visits": { "$ref": "paths/v1_short-urls_{shortCode}_visits.json" }, + "/rest/v{version}/tags/{tag}/visits": { + "$ref": "paths/v2_tags_{tag}_visits.json" + }, + + "/rest/v{version}/mercure-info": { + "$ref": "paths/v2_mercure-info.json" + }, "/rest/health": { "$ref": "paths/health.json" diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 0f2e70a5..516bbbd4 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Service; +use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; @@ -78,10 +79,10 @@ return [ Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class], - Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class], - Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class], - Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class], - Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class], + Command\Tag\ListTagsCommand::class => [TagService::class], + Command\Tag\CreateTagCommand::class => [TagService::class], + Command\Tag\RenameTagCommand::class => [TagService::class], + Command\Tag\DeleteTagsCommand::class => [TagService::class], Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, diff --git a/module/CLI/src/Command/Tag/CreateTagCommand.php b/module/CLI/src/Command/Tag/CreateTagCommand.php index 5fe56d46..451eb81e 100644 --- a/module/CLI/src/Command/Tag/CreateTagCommand.php +++ b/module/CLI/src/Command/Tag/CreateTagCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 1cebe895..2b3eae14 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 0b8f0aa3..11e22a4f 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; -use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -35,17 +35,20 @@ class ListTagsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { - ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows()); + ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows()); return ExitCodes::EXIT_SUCCESS; } private function getTagsRows(): array { - $tags = $this->tagService->listTags(); + $tags = $this->tagService->tagsInfo(); if (empty($tags)) { - return [['No tags yet']]; + return [['No tags found', '-', '-']]; } - return map($tags, fn (Tag $tag) => [(string) $tag]); + return map( + $tags, + fn (TagInfo $tagInfo) => [(string) $tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()], + ); } } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index f30bc757..fe42a832 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php index bed087a5..e156cf28 100644 --- a/module/CLI/test/Command/Tag/CreateTagCommandTest.php +++ b/module/CLI/test/Command/Tag/CreateTagCommandTest.php @@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index 060e5aac..27a95de8 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index f171127c..b6087307 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -8,7 +8,8 @@ use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -31,28 +32,32 @@ class ListTagsCommandTest extends TestCase /** @test */ public function noTagsPrintsEmptyMessage(): void { - $listTags = $this->tagService->listTags()->willReturn([]); + $tagsInfo = $this->tagService->tagsInfo()->willReturn([]); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('No tags yet', $output); - $listTags->shouldHaveBeenCalled(); + $this->assertStringContainsString('No tags found', $output); + $tagsInfo->shouldHaveBeenCalled(); } /** @test */ public function listOfTagsIsPrinted(): void { - $listTags = $this->tagService->listTags()->willReturn([ - new Tag('foo'), - new Tag('bar'), + $tagsInfo = $this->tagService->tagsInfo()->willReturn([ + new TagInfo(new Tag('foo'), 10, 2), + new TagInfo(new Tag('bar'), 7, 32), ]); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('foo', $output); - $this->assertStringContainsString('bar', $output); - $listTags->shouldHaveBeenCalled(); + $this->assertStringContainsString('| foo', $output); + $this->assertStringContainsString('| bar', $output); + $this->assertStringContainsString('| 10 ', $output); + $this->assertStringContainsString('| 2 ', $output); + $this->assertStringContainsString('| 7 ', $output); + $this->assertStringContainsString('| 32 ', $output); + $tagsInfo->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 59f8d89c..ee499c48 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index a055e34b..debf021f 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -27,7 +27,8 @@ return [ Service\VisitsTracker::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class, Visit\VisitLocator::class => ConfigAbstractFactory::class, - Service\Tag\TagService::class => ConfigAbstractFactory::class, + Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, + Tag\TagService::class => ConfigAbstractFactory::class, Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, @@ -38,6 +39,8 @@ return [ Action\QrCodeAction::class => ConfigAbstractFactory::class, Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class, + + Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, ], ], @@ -51,10 +54,15 @@ return [ Options\UrlShortenerOptions::class => ['config.url_shortener'], Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class], - Service\VisitsTracker::class => ['em', EventDispatcherInterface::class], + Service\VisitsTracker::class => [ + 'em', + EventDispatcherInterface::class, + 'config.url_shortener.anonymize_remote_addr', + ], Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class], Visit\VisitLocator::class => ['em'], - Service\Tag\TagService::class => ['em'], + Visit\VisitsStatsHelper::class => ['em'], + Tag\TagService::class => ['em'], Service\ShortUrl\DeleteShortUrlService::class => [ 'em', Options\DeleteShortUrlsOptions::class, @@ -83,6 +91,8 @@ return [ ], Resolver\PersistenceDomainResolver::class => ['em'], + + Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'], ], ]; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php index a4aef29f..871ac113 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php @@ -60,6 +60,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') + ->setOrderBy(['name' => 'ASC']) ->build(); $builder->createManyToOne('domain', Entity\Domain::class) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php index 214396bd..97d15758 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php @@ -24,4 +24,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createField('name', Types::STRING) ->unique() ->build(); + + $builder->addInverseManyToMany('shortUrls', Entity\ShortUrl::class, 'tags'); }; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php index 803b9790..5143389b 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php @@ -32,6 +32,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('`date`') ->build(); + $builder->addIndex(['date'], 'IDX_visits_date'); + $builder->createField('remoteAddr', Types::STRING) ->columnName('remote_addr') ->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH) diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 29dbbd11..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,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', + ], ], ]; diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php index edfe1b79..81f05d14 100644 --- a/module/Core/src/Config/SimplifiedConfigParser.php +++ b/module/Core/src/Config/SimplifiedConfigParser.php @@ -34,6 +34,10 @@ class SimplifiedConfigParser 'visits_webhooks' => ['url_shortener', 'visits_webhooks'], 'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'], 'geolite_license_key' => ['geolite2', 'license_key'], + 'mercure_public_hub_url' => ['mercure', 'public_hub_url'], + 'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'], + 'mercure_jwt_secret' => ['mercure', 'jwt_secret'], + 'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'], ]; private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [ 'delete_short_url_threshold' => [ diff --git a/module/Core/src/Entity/Tag.php b/module/Core/src/Entity/Tag.php index 7530b70a..54c05c56 100644 --- a/module/Core/src/Entity/Tag.php +++ b/module/Core/src/Entity/Tag.php @@ -4,16 +4,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Entity; +use Doctrine\Common\Collections; use JsonSerializable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; class Tag extends AbstractEntity implements JsonSerializable { private string $name; + private Collections\Collection $shortUrls; public function __construct(string $name) { $this->name = $name; + $this->shortUrls = new Collections\ArrayCollection(); } public function rename(string $name): void diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index e8cbb119..7e6ed060 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -21,24 +21,24 @@ class Visit extends AbstractEntity implements JsonSerializable private ShortUrl $shortUrl; private ?VisitLocation $visitLocation = null; - public function __construct(ShortUrl $shortUrl, Visitor $visitor, ?Chronos $date = null) + public function __construct(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true, ?Chronos $date = null) { $this->shortUrl = $shortUrl; $this->date = $date ?? Chronos::now(); $this->userAgent = $visitor->getUserAgent(); $this->referer = $visitor->getReferer(); - $this->remoteAddr = $this->obfuscateAddress($visitor->getRemoteAddress()); + $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress()); } - private function obfuscateAddress(?string $address): ?string + private function processAddress(bool $anonymize, ?string $address): ?string { - // Localhost addresses do not need to be obfuscated - if ($address === null || $address === IpAddress::LOCALHOST) { + // Localhost addresses do not need to be anonymized + if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) { return $address; } try { - return (string) IpAddress::fromString($address)->getObfuscatedCopy(); + return (string) IpAddress::fromString($address)->getAnonymizedCopy(); } catch (InvalidArgumentException $e) { return null; } diff --git a/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php b/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php new file mode 100644 index 00000000..7f2c7297 --- /dev/null +++ b/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php @@ -0,0 +1,32 @@ +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(); + } + } +} diff --git a/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php b/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php new file mode 100644 index 00000000..cbfc7208 --- /dev/null +++ b/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php @@ -0,0 +1,24 @@ +get('em'); + + return new CloseDbConnectionEventListener($em, $wrapped); + } +} diff --git a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php index ff382770..6abbe02b 100644 --- a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php +++ b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php @@ -9,7 +9,6 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; -use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManager; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; @@ -42,35 +41,22 @@ class LocateShortUrlVisit public function __invoke(ShortUrlVisited $shortUrlVisited): void { - // FIXME Temporarily handling DB connection reset here to fix https://github.com/shlinkio/shlink/issues/717 - // Remove when https://github.com/shlinkio/shlink-event-dispatcher/issues/23 is implemented - if ($this->em instanceof ReopeningEntityManager) { - $this->em->open(); - } - $visitId = $shortUrlVisited->visitId(); - try { - /** @var Visit|null $visit */ - $visit = $this->em->find(Visit::class, $visitId); - if ($visit === null) { - $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - if ($this->downloadOrUpdateGeoLiteDb($visitId)) { - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); - } - - $this->eventDispatcher->dispatch(new VisitLocated($visitId)); - } finally { - // FIXME Temporarily handling DB connection reset here to fix https://github.com/shlinkio/shlink/issues/717 - // Remove when https://github.com/shlinkio/shlink-event-dispatcher/issues/23 is implemented - $this->em->getConnection()->close(); - $this->em->clear(); + /** @var Visit|null $visit */ + $visit = $this->em->find(Visit::class, $visitId); + if ($visit === null) { + $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ + 'visitId' => $visitId, + ]); + return; } + + if ($this->downloadOrUpdateGeoLiteDb($visitId)) { + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); + } + + $this->eventDispatcher->dispatch(new VisitLocated($visitId)); } private function downloadOrUpdateGeoLiteDb(string $visitId): bool diff --git a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php new file mode 100644 index 00000000..af6dd33f --- /dev/null +++ b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php @@ -0,0 +1,55 @@ +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, + ]); + } + } +} diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index de07d5ef..b3923b86 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -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 diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php new file mode 100644 index 00000000..aad072f2 --- /dev/null +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -0,0 +1,50 @@ +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); + } +} diff --git a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php b/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php new file mode 100644 index 00000000..d433d9ad --- /dev/null +++ b/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php @@ -0,0 +1,15 @@ +count !== null) { + return $this->count; + } + + return $this->count = $this->doCount(); + } + + abstract protected function doCount(): int; +} diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php new file mode 100644 index 00000000..e80fbcdd --- /dev/null +++ b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php @@ -0,0 +1,37 @@ +visitRepository = $visitRepository; + $this->params = $params; + $this->tag = $tag; + } + + public function getItems($offset, $itemCountPerPage): array // phpcs:ignore + { + return $this->visitRepository->findVisitsByTag( + $this->tag, + $this->params->getDateRange(), + $itemCountPerPage, + $offset, + ); + } + + protected function doCount(): int + { + return $this->visitRepository->countVisitsByTag($this->tag, $this->params->getDateRange()); + } +} diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php index 247ea93e..404ae309 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php @@ -4,12 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; -use Laminas\Paginator\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; -class VisitsPaginatorAdapter implements AdapterInterface +class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { private VisitRepositoryInterface $visitRepository; private ShortUrlIdentifier $identifier; @@ -36,7 +35,7 @@ class VisitsPaginatorAdapter implements AdapterInterface ); } - public function count(): int + protected function doCount(): int { return $this->visitRepository->countVisitsByShortCode( $this->identifier->shortCode(), diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 92328630..05b2481c 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -6,6 +6,9 @@ namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\EntityRepository; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; + +use function Functional\map; class TagRepository extends EntityRepository implements TagRepositoryInterface { @@ -21,4 +24,25 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface return $qb->getQuery()->execute(); } + + /** + * @return TagInfo[] + */ + public function findTagsWithInfo(): array + { + $dql = <<getEntityManager()->createQuery($dql); + + return map( + $query->getResult(), + fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), + ); + } } diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index e253f7a4..37179e21 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -5,8 +5,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; interface TagRepositoryInterface extends ObjectRepository { public function deleteByName(array $names): int; + + /** + * @return TagInfo[] + */ + public function findTagsWithInfo(): array; } diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 61b2afb8..b3761c9f 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -5,9 +5,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Entity\VisitLocation; + +use function array_column; + +use const PHP_INT_MAX; class VisitRepository extends EntityRepository implements VisitRepositoryInterface { @@ -21,7 +28,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ->from(Visit::class, 'v') ->where($qb->expr()->isNull('v.visitLocation')); - return $this->findVisitsForQuery($qb, $blockSize); + return $this->visitsIterableForQuery($qb, $blockSize); } /** @@ -37,7 +44,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty')) ->setParameter('isEmpty', true); - return $this->findVisitsForQuery($qb, $blockSize); + return $this->visitsIterableForQuery($qb, $blockSize); } public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable @@ -46,10 +53,10 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa $qb->select('v') ->from(Visit::class, 'v'); - return $this->findVisitsForQuery($qb, $blockSize); + return $this->visitsIterableForQuery($qb, $blockSize); } - private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable + private function visitsIterableForQuery(QueryBuilder $qb, int $blockSize): iterable { $originalQueryBuilder = $qb->setMaxResults($blockSize) ->orderBy('v.id', 'ASC'); @@ -82,23 +89,13 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ?int $offset = null ): array { $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); - $qb->select('v') - ->orderBy('v.date', 'DESC'); - - if ($limit !== null) { - $qb->setMaxResults($limit); - } - if ($offset !== null) { - $qb->setFirstResult($offset); - } - - return $qb->getQuery()->getResult(); + return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); } public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int { $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); - $qb->select('COUNT(DISTINCT v.id)'); + $qb->select('COUNT(v.id)'); return (int) $qb->getQuery()->getSingleScalarResult(); } @@ -108,31 +105,103 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ?string $domain, ?DateRange $dateRange ): QueryBuilder { + /** @var ShortUrlRepositoryInterface $shortUrlRepo */ + $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); + $shortUrl = $shortUrlRepo->findOne($shortCode, $domain); + $shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1; + + // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later + // Since they are not strictly provided by the caller, it's reasonably safe $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(Visit::class, 'v') - ->join('v.shortUrl', 'su') - ->where($qb->expr()->eq('su.shortCode', ':shortCode')) - ->setParameter('shortCode', $shortCode); - - // Apply domain filtering - if ($domain !== null) { - $qb->join('su.domain', 'd') - ->andWhere($qb->expr()->eq('d.authority', ':domain')) - ->setParameter('domain', $domain); - } else { - $qb->andWhere($qb->expr()->isNull('su.domain')); - } + ->where($qb->expr()->eq('v.shortUrl', $shortUrlId)); // Apply date range filtering - if ($dateRange !== null && $dateRange->getStartDate() !== null) { - $qb->andWhere($qb->expr()->gte('v.date', ':startDate')) - ->setParameter('startDate', $dateRange->getStartDate()); - } - if ($dateRange !== null && $dateRange->getEndDate() !== null) { - $qb->andWhere($qb->expr()->lte('v.date', ':endDate')) - ->setParameter('endDate', $dateRange->getEndDate()); - } + $this->applyDatesInline($qb, $dateRange); return $qb; } + + public function findVisitsByTag( + string $tag, + ?DateRange $dateRange = null, + ?int $limit = null, + ?int $offset = null + ): array { + $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange); + return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); + } + + public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int + { + $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange); + $qb->select('COUNT(v.id)'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('s.id') + ->from(ShortUrl::class, 's') + ->join('s.tags', 't') + ->where($qb->expr()->eq('t.name', ':tag')) + ->setParameter('tag', $tag); + + $shortUrlIds = array_column($qb->getQuery()->getArrayResult(), 'id'); + $shortUrlIds[] = '-1'; // Add an invalid ID, in case the list is empty + + // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later + // Since they are not strictly provided by the caller, it's reasonably safe + $qb2 = $this->getEntityManager()->createQueryBuilder(); + $qb2->from(Visit::class, 'v') + ->where($qb2->expr()->in('v.shortUrl', $shortUrlIds)); + + // Apply date range filtering + $this->applyDatesInline($qb2, $dateRange); + + return $qb2; + } + + private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void + { + if ($dateRange !== null && $dateRange->getStartDate() !== null) { + $qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\'')); + } + if ($dateRange !== null && $dateRange->getEndDate() !== null) { + $qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\'')); + } + } + + private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array + { + $qb->select('v.id') + ->orderBy('v.id', 'DESC') + // Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing + // order on sub-queries without offset + ->setMaxResults($limit ?? PHP_INT_MAX) + ->setFirstResult($offset ?? 0); + $subQuery = $qb->getQuery()->getSQL(); + + // A native query builder needs to be used here because DQL and ORM query builders do not accept + // sub-queries at "from" and "join" level. + // If no sub-query is used, then performance drops dramatically while the "offset" grows. + $nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder(); + $nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*') + ->from('visits', 'v') + ->join('v', '(' . $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id')) + ->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id')) + ->orderBy('v.id', 'DESC'); + + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); + $rsm->addRootEntityFromClassMetadata(Visit::class, 'v', ['id' => 'visit_id']); + $rsm->addJoinedEntityFromClassMetadata(VisitLocation::class, 'vl', 'v', 'visitLocation', [ + 'id' => 'visit_location_id', + ]); + + $query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm); + + return $query->getResult(); + } } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index f9cbc8d9..5a540171 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -43,4 +43,16 @@ interface VisitRepositoryInterface extends ObjectRepository ?string $domain = null, ?DateRange $dateRange = null ): int; + + /** + * @return Visit[] + */ + public function findVisitsByTag( + string $tag, + ?DateRange $dateRange = null, + ?int $limit = null, + ?int $offset = null + ): array; + + public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int; } diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index f477681a..e777af76 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -8,33 +8,39 @@ use Doctrine\ORM; use Laminas\Paginator\Paginator; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; +use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; -use Shlinkio\Shlink\Core\Repository\VisitRepository; +use Shlinkio\Shlink\Core\Repository\TagRepository; +use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; class VisitsTracker implements VisitsTrackerInterface { private ORM\EntityManagerInterface $em; private EventDispatcherInterface $eventDispatcher; + private bool $anonymizeRemoteAddr; - public function __construct(ORM\EntityManagerInterface $em, EventDispatcherInterface $eventDispatcher) - { + public function __construct( + ORM\EntityManagerInterface $em, + EventDispatcherInterface $eventDispatcher, + bool $anonymizeRemoteAddr + ) { $this->em = $em; $this->eventDispatcher = $eventDispatcher; + $this->anonymizeRemoteAddr = $anonymizeRemoteAddr; } - /** - * Tracks a new visit to provided short code from provided visitor - */ public function track(ShortUrl $shortUrl, Visitor $visitor): void { - $visit = new Visit($shortUrl, $visitor); + $visit = new Visit($shortUrl, $visitor, $this->anonymizeRemoteAddr); $this->em->persist($visit); $this->em->flush(); @@ -43,8 +49,6 @@ class VisitsTracker implements VisitsTrackerInterface } /** - * Returns the visits on certain short code - * * @return Visit[]|Paginator * @throws ShortUrlNotFoundException */ @@ -56,7 +60,7 @@ class VisitsTracker implements VisitsTrackerInterface throw ShortUrlNotFoundException::fromNotFound($identifier); } - /** @var VisitRepository $repo */ + /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params)); $paginator->setItemCountPerPage($params->getItemsPerPage()) @@ -64,4 +68,26 @@ class VisitsTracker implements VisitsTrackerInterface return $paginator; } + + /** + * @return Visit[]|Paginator + * @throws TagNotFoundException + */ + public function visitsForTag(string $tag, VisitsParams $params): Paginator + { + /** @var TagRepository $tagRepo */ + $tagRepo = $this->em->getRepository(Tag::class); + $count = $tagRepo->count(['name' => $tag]); + if ($count === 0) { + throw TagNotFoundException::fromTag($tag); + } + + /** @var VisitRepositoryInterface $repo */ + $repo = $this->em->getRepository(Visit::class); + $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params)); + $paginator->setItemCountPerPage($params->getItemsPerPage()) + ->setCurrentPageNumber($params->getPage()); + + return $paginator; + } } diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index 1ec4e110..2c2759c2 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -8,22 +8,24 @@ use Laminas\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; interface VisitsTrackerInterface { - /** - * Tracks a new visit to provided short code from provided visitor - */ public function track(ShortUrl $shortUrl, Visitor $visitor): void; /** - * Returns the visits on certain short code - * * @return Visit[]|Paginator * @throws ShortUrlNotFoundException */ public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator; + + /** + * @return Visit[]|Paginator + * @throws TagNotFoundException + */ + public function visitsForTag(string $tag, VisitsParams $params): Paginator; } diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php new file mode 100644 index 00000000..dbc51316 --- /dev/null +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -0,0 +1,46 @@ +tag = $tag; + $this->shortUrlsCount = $shortUrlsCount; + $this->visitsCount = $visitsCount; + } + + public function tag(): Tag + { + return $this->tag; + } + + public function shortUrlsCount(): int + { + return $this->shortUrlsCount; + } + + public function visitsCount(): int + { + return $this->visitsCount; + } + + public function jsonSerialize(): array + { + return [ + 'tag' => $this->tag, + 'shortUrlsCount' => $this->shortUrlsCount, + 'visitsCount' => $this->visitsCount, + ]; + } +} diff --git a/module/Core/src/Service/Tag/TagService.php b/module/Core/src/Tag/TagService.php similarity index 85% rename from module/Core/src/Service/Tag/TagService.php rename to module/Core/src/Tag/TagService.php index b95ddf82..7137e885 100644 --- a/module/Core/src/Service/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Service\Tag; +namespace Shlinkio\Shlink\Core\Tag; use Doctrine\Common\Collections\Collection; use Doctrine\ORM; @@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; +use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Util\TagManagerTrait; class TagService implements TagServiceInterface @@ -25,7 +27,6 @@ class TagService implements TagServiceInterface /** * @return Tag[] - * @throws \UnexpectedValueException */ public function listTags(): array { @@ -34,6 +35,16 @@ class TagService implements TagServiceInterface return $tags; } + /** + * @return TagInfo[] + */ + public function tagsInfo(): array + { + /** @var TagRepositoryInterface $repo */ + $repo = $this->em->getRepository(Tag::class); + return $repo->findTagsWithInfo(); + } + /** * @param string[] $tagNames */ diff --git a/module/Core/src/Service/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php similarity index 82% rename from module/Core/src/Service/Tag/TagServiceInterface.php rename to module/Core/src/Tag/TagServiceInterface.php index 16da503c..ed643fc5 100644 --- a/module/Core/src/Service/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Service\Tag; +namespace Shlinkio\Shlink\Core\Tag; use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; interface TagServiceInterface { @@ -16,6 +17,11 @@ interface TagServiceInterface */ public function listTags(): array; + /** + * @return TagInfo[] + */ + public function tagsInfo(): array; + /** * @param string[] $tagNames */ diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php new file mode 100644 index 00000000..ac5083c7 --- /dev/null +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -0,0 +1,24 @@ +visitsCount = $visitsCount; + } + + public function jsonSerialize(): array + { + return [ + 'visitsCount' => $this->visitsCount, + ]; + } +} diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php new file mode 100644 index 00000000..de3219ff --- /dev/null +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -0,0 +1,32 @@ +em = $em; + } + + public function getVisitsStats(): VisitsStats + { + return new VisitsStats($this->getVisitsCount()); + } + + private function getVisitsCount(): int + { + /** @var VisitRepository $visitsRepo */ + $visitsRepo = $this->em->getRepository(Visit::class); + return $visitsRepo->count([]); + } +} diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php new file mode 100644 index 00000000..81423cb0 --- /dev/null +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -0,0 +1,12 @@ +assertEquals(2, $this->repo->deleteByName($toDelete)); } + + /** @test */ + public function properTagsInfoIsReturned(): void + { + $names = ['foo', 'bar', 'baz', 'another']; + $tags = []; + foreach ($names as $name) { + $tag = new Tag($name); + $tags[] = $tag; + $this->getEntityManager()->persist($tag); + } + + [$firstUrlTags] = array_chunk($tags, 3); + $secondUrlTags = [$tags[0]]; + + $shortUrl = new ShortUrl(''); + $shortUrl->setTags(new ArrayCollection($firstUrlTags)); + $this->getEntityManager()->persist($shortUrl); + $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance())); + + $shortUrl2 = new ShortUrl(''); + $shortUrl2->setTags(new ArrayCollection($secondUrlTags)); + $this->getEntityManager()->persist($shortUrl2); + $this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance())); + + $this->getEntityManager()->flush(); + + $result = $this->repo->findTagsWithInfo(); + + $this->assertCount(4, $result); + $this->assertEquals( + ['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0], + $result[0]->jsonSerialize(), + ); + $this->assertEquals( + ['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3], + $result[1]->jsonSerialize(), + ); + $this->assertEquals( + ['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3], + $result[2]->jsonSerialize(), + ); + $this->assertEquals( + ['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4], + $result[3]->jsonSerialize(), + ); + } } diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 034b15f9..529a5ae0 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -5,9 +5,11 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Repository; use Cake\Chronos\Chronos; +use Doctrine\Common\Collections\ArrayCollection; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -27,6 +29,7 @@ class VisitRepositoryTest extends DatabaseTestCase Visit::class, ShortUrl::class, Domain::class, + Tag::class, ]; private VisitRepository $repo; @@ -125,33 +128,99 @@ class VisitRepositoryTest extends DatabaseTestCase ))); } - private function createShortUrlsAndVisits(): array + /** @test */ + public function findVisitsByTagReturnsProperData(): void + { + $foo = new Tag('foo'); + $this->getEntityManager()->persist($foo); + + /** @var ShortUrl $shortUrl */ + [,, $shortUrl] = $this->createShortUrlsAndVisits(false); + /** @var ShortUrl $shortUrl2 */ + [,, $shortUrl2] = $this->createShortUrlsAndVisits(false); + /** @var ShortUrl $shortUrl3 */ + [,, $shortUrl3] = $this->createShortUrlsAndVisits(false); + + $shortUrl->setTags(new ArrayCollection([$foo])); + $shortUrl2->setTags(new ArrayCollection([$foo])); + $shortUrl3->setTags(new ArrayCollection([$foo])); + + $this->getEntityManager()->flush(); + + $this->assertCount(0, $this->repo->findVisitsByTag('invalid')); + $this->assertCount(18, $this->repo->findVisitsByTag((string) $foo)); + $this->assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange( + Chronos::parse('2016-01-02'), + Chronos::parse('2016-01-03'), + ))); + $this->assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange( + Chronos::parse('2016-01-03'), + ))); + } + + /** @test */ + public function countVisitsByTagReturnsProperData(): void + { + $foo = new Tag('foo'); + $this->getEntityManager()->persist($foo); + + /** @var ShortUrl $shortUrl */ + [,, $shortUrl] = $this->createShortUrlsAndVisits(false); + /** @var ShortUrl $shortUrl2 */ + [,, $shortUrl2] = $this->createShortUrlsAndVisits(false); + + $shortUrl->setTags(new ArrayCollection([$foo])); + $shortUrl2->setTags(new ArrayCollection([$foo])); + + $this->getEntityManager()->flush(); + + $this->assertEquals(0, $this->repo->countVisitsByTag('invalid')); + $this->assertEquals(12, $this->repo->countVisitsByTag((string) $foo)); + $this->assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange( + Chronos::parse('2016-01-02'), + Chronos::parse('2016-01-03'), + ))); + $this->assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange( + Chronos::parse('2016-01-03'), + ))); + } + + private function createShortUrlsAndVisits(bool $withDomain = true): array { $shortUrl = new ShortUrl(''); $domain = 'example.com'; $shortCode = $shortUrl->getShortCode(); - $shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([ - 'customSlug' => $shortCode, - 'domain' => $domain, - ])); - $this->getEntityManager()->persist($shortUrl); - $this->getEntityManager()->persist($shortUrlWithDomain); for ($i = 0; $i < 6; $i++) { - $visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1))); - $this->getEntityManager()->persist($visit); - } - for ($i = 0; $i < 3; $i++) { $visit = new Visit( - $shortUrlWithDomain, + $shortUrl, Visitor::emptyInstance(), + true, Chronos::parse(sprintf('2016-01-0%s', $i + 1)), ); $this->getEntityManager()->persist($visit); } - $this->getEntityManager()->flush(); - return [$shortCode, $domain]; + if ($withDomain) { + $shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([ + 'customSlug' => $shortCode, + 'domain' => $domain, + ])); + $this->getEntityManager()->persist($shortUrlWithDomain); + + for ($i = 0; $i < 3; $i++) { + $visit = new Visit( + $shortUrlWithDomain, + Visitor::emptyInstance(), + true, + Chronos::parse(sprintf('2016-01-0%s', $i + 1)), + ); + $this->getEntityManager()->persist($visit); + } + $this->getEntityManager()->flush(); + } + + return [$shortCode, $domain, $shortUrl]; } } diff --git a/module/Core/test/Config/SimplifiedConfigParserTest.php b/module/Core/test/Config/SimplifiedConfigParserTest.php index 268731db..3700b042 100644 --- a/module/Core/test/Config/SimplifiedConfigParserTest.php +++ b/module/Core/test/Config/SimplifiedConfigParserTest.php @@ -61,6 +61,10 @@ class SimplifiedConfigParserTest extends TestCase ], 'default_short_codes_length' => 8, 'geolite_license_key' => 'kjh23ljkbndskj345', + 'mercure_public_hub_url' => 'public_url', + 'mercure_internal_hub_url' => 'internal_url', + 'mercure_jwt_secret' => 'super_secret_value', + 'anonymize_remote_addr' => false, ]; $expected = [ 'app_options' => [ @@ -89,6 +93,7 @@ class SimplifiedConfigParserTest extends TestCase 'https://third-party.io/foo', ], 'default_short_codes_length' => 8, + 'anonymize_remote_addr' => false, ], 'delete_short_urls' => [ @@ -132,6 +137,12 @@ class SimplifiedConfigParserTest extends TestCase 'geolite2' => [ 'license_key' => 'kjh23ljkbndskj345', ], + + 'mercure' => [ + 'public_hub_url' => 'public_url', + 'internal_hub_url' => 'internal_url', + 'jwt_secret' => 'super_secret_value', + ], ]; $result = ($this->postProcessor)(array_merge($config, $simplified)); diff --git a/module/Core/test/Entity/VisitTest.php b/module/Core/test/Entity/VisitTest.php index 9af71a09..73e41f12 100644 --- a/module/Core/test/Entity/VisitTest.php +++ b/module/Core/test/Entity/VisitTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Entity; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\Visitor; @@ -18,7 +19,7 @@ class VisitTest extends TestCase */ public function isProperlyJsonSerialized(?Chronos $date): void { - $visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), $date); + $visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date); $this->assertEquals([ 'referer' => 'some site', @@ -33,4 +34,25 @@ class VisitTest extends TestCase yield 'null date' => [null]; yield 'not null date' => [Chronos::now()->subDays(10)]; } + + /** + * @test + * @dataProvider provideAddresses + */ + public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void + { + $visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', $address), $anonymize); + + $this->assertEquals($expectedAddress, $visit->getRemoteAddr()); + } + + public function provideAddresses(): iterable + { + yield 'anonymized null address' => [true, null, null]; + yield 'non-anonymized null address' => [false, null, null]; + yield 'anonymized localhost' => [true, IpAddress::LOCALHOST, IpAddress::LOCALHOST]; + yield 'non-anonymized localhost' => [false, IpAddress::LOCALHOST, IpAddress::LOCALHOST]; + yield 'anonymized regular address' => [true, '1.2.3.4', '1.2.3.0']; + yield 'non-anonymized regular address' => [false, '1.2.3.4', '1.2.3.4']; + } } diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php new file mode 100644 index 00000000..60113fc7 --- /dev/null +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php @@ -0,0 +1,43 @@ +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(); + } +} diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php new file mode 100644 index 00000000..acc1784f --- /dev/null +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php @@ -0,0 +1,75 @@ +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]; + })(); + } +} diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index e35e7921..087c0e0b 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher; -use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -38,10 +37,6 @@ class LocateShortUrlVisitTest extends TestCase { $this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); - $conn = $this->prophesize(Connection::class); - $this->em->getConnection()->willReturn($conn->reveal()); - $this->em->clear()->will(function (): void { - }); $this->logger = $this->prophesize(LoggerInterface::class); $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php new file mode 100644 index 00000000..fce53344 --- /dev/null +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -0,0 +1,123 @@ +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(); + } +} diff --git a/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php similarity index 98% rename from module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php rename to module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 369960e1..7a138960 100644 --- a/module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Rest\EventDispatcher; +namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; use Exception; diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php new file mode 100644 index 00000000..992e25d6 --- /dev/null +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -0,0 +1,66 @@ +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']; + } +} diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php new file mode 100644 index 00000000..e4418c5b --- /dev/null +++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -0,0 +1,52 @@ +repo = $this->prophesize(VisitRepositoryInterface::class); + $this->adapter = new VisitsForTagPaginatorAdapter($this->repo->reveal(), 'foo', VisitsParams::fromRawData([])); + } + + /** @test */ + public function repoIsCalledEveryTimeItemsAreFetched(): void + { + $count = 3; + $limit = 1; + $offset = 5; + $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset)->willReturn([]); + + for ($i = 0; $i < $count; $i++) { + $this->adapter->getItems($offset, $limit); + } + + $findVisits->shouldHaveBeenCalledTimes($count); + } + + /** @test */ + public function repoIsCalledOnlyOnceForCount(): void + { + $count = 3; + $countVisits = $this->repo->countVisitsByTag('foo', new DateRange())->willReturn(3); + + for ($i = 0; $i < $count; $i++) { + $this->adapter->count(); + } + + $countVisits->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php new file mode 100644 index 00000000..744582b7 --- /dev/null +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -0,0 +1,57 @@ +repo = $this->prophesize(VisitRepositoryInterface::class); + $this->adapter = new VisitsPaginatorAdapter( + $this->repo->reveal(), + new ShortUrlIdentifier(''), + VisitsParams::fromRawData([]), + ); + } + + /** @test */ + public function repoIsCalledEveryTimeItemsAreFetched(): void + { + $count = 3; + $limit = 1; + $offset = 5; + $findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset)->willReturn([]); + + for ($i = 0; $i < $count; $i++) { + $this->adapter->getItems($offset, $limit); + } + + $findVisits->shouldHaveBeenCalledTimes($count); + } + + /** @test */ + public function repoIsCalledOnlyOnceForCount(): void + { + $count = 3; + $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange())->willReturn(3); + + for ($i = 0; $i < $count; $i++) { + $this->adapter->count(); + } + + $countVisits->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index b8c9d59b..c031e51f 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Service\Tag; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; @@ -13,16 +12,21 @@ use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; -use Shlinkio\Shlink\Core\Service\Tag\TagService; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\TagService; class TagServiceTest extends TestCase { private TagService $service; private ObjectProphecy $em; + private ObjectProphecy $repo; public function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); + $this->repo = $this->prophesize(TagRepository::class); + $this->em->getRepository(Tag::class)->willReturn($this->repo->reveal())->shouldBeCalled(); + $this->service = new TagService($this->em->reveal()); } @@ -31,36 +35,41 @@ class TagServiceTest extends TestCase { $expected = [new Tag('foo'), new Tag('bar')]; - $repo = $this->prophesize(EntityRepository::class); - $find = $repo->findBy(Argument::cetera())->willReturn($expected); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findBy(Argument::cetera())->willReturn($expected); $result = $this->service->listTags(); $this->assertEquals($expected, $result); $find->shouldHaveBeenCalled(); - $getRepo->shouldHaveBeenCalled(); + } + + /** @test */ + public function tagsInfoDelegatesOnRepository(): void + { + $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; + + $find = $this->repo->findTagsWithInfo()->willReturn($expected); + + $result = $this->service->tagsInfo(); + + $this->assertEquals($expected, $result); + $find->shouldHaveBeenCalled(); } /** @test */ public function deleteTagsDelegatesOnRepository(): void { - $repo = $this->prophesize(TagRepository::class); - $delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4); $this->service->deleteTags(['foo', 'bar']); $delete->shouldHaveBeenCalled(); - $getRepo->shouldHaveBeenCalled(); } /** @test */ public function createTagsPersistsEntities(): void { - $repo = $this->prophesize(TagRepository::class); - $find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); $persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null); $flush = $this->em->flush()->willReturn(null); @@ -68,7 +77,6 @@ class TagServiceTest extends TestCase $this->assertCount(2, $result); $find->shouldHaveBeenCalled(); - $getRepo->shouldHaveBeenCalled(); $persist->shouldHaveBeenCalledTimes(2); $flush->shouldHaveBeenCalled(); } @@ -76,12 +84,9 @@ class TagServiceTest extends TestCase /** @test */ public function renameInvalidTagThrowsException(): void { - $repo = $this->prophesize(TagRepository::class); - $find = $repo->findOneBy(Argument::cetera())->willReturn(null); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findOneBy(Argument::cetera())->willReturn(null); $find->shouldBeCalled(); - $getRepo->shouldBeCalled(); $this->expectException(TagNotFoundException::class); $this->service->renameTag('foo', 'bar'); @@ -95,10 +100,8 @@ class TagServiceTest extends TestCase { $expected = new Tag('foo'); - $repo = $this->prophesize(TagRepository::class); - $find = $repo->findOneBy(Argument::cetera())->willReturn($expected); - $countTags = $repo->count(Argument::cetera())->willReturn($count); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findOneBy(Argument::cetera())->willReturn($expected); + $countTags = $this->repo->count(Argument::cetera())->willReturn($count); $flush = $this->em->flush()->willReturn(null); $tag = $this->service->renameTag($oldName, $newName); @@ -106,7 +109,6 @@ class TagServiceTest extends TestCase $this->assertSame($expected, $tag); $this->assertEquals($newName, (string) $tag); $find->shouldHaveBeenCalled(); - $getRepo->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled(); $countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1); } @@ -120,14 +122,11 @@ class TagServiceTest extends TestCase /** @test */ public function renameTagToAnExistingNameThrowsException(): void { - $repo = $this->prophesize(TagRepository::class); - $find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); - $countTags = $repo->count(Argument::cetera())->willReturn(1); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); + $countTags = $this->repo->count(Argument::cetera())->willReturn(1); $flush = $this->em->flush(Argument::any())->willReturn(null); $find->shouldBeCalled(); - $getRepo->shouldBeCalled(); $countTags->shouldBeCalled(); $flush->shouldNotBeCalled(); $this->expectException(TagConflictException::class); diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 9028d2c7..5893b952 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -6,20 +6,22 @@ namespace ShlinkioTest\Shlink\Core\Service; use Doctrine\ORM\EntityManager; use Laminas\Stdlib\ArrayUtils; -use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Service\VisitsTracker; @@ -37,7 +39,7 @@ class VisitsTrackerTest extends TestCase $this->em = $this->prophesize(EntityManager::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); - $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal()); + $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true); } /** @test */ @@ -53,25 +55,6 @@ class VisitsTrackerTest extends TestCase $this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); } - /** @test */ - public function trackedIpAddressGetsObfuscated(): void - { - $shortCode = '123ABC'; - - $this->em->persist(Argument::any())->will(function ($args) { - /** @var Visit $visit */ - $visit = $args[0]; - Assert::assertEquals('4.3.2.0', $visit->getRemoteAddr()); - $visit->setId('1'); - return $visit; - })->shouldBeCalledOnce(); - $this->em->flush()->shouldBeCalledOnce(); - - $this->visitsTracker->track(new ShortUrl($shortCode), new Visitor('', '', '4.3.2.1')); - - $this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); - } - /** @test */ public function infoReturnsVisitsForCertainShortCode(): void { @@ -105,4 +88,40 @@ class VisitsTrackerTest extends TestCase $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams()); } + + /** @test */ + public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void + { + $tag = 'foo'; + $repo = $this->prophesize(TagRepository::class); + $count = $repo->count(['name' => $tag])->willReturn(0); + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + + $this->expectException(TagNotFoundException::class); + $count->shouldBeCalledOnce(); + $getRepo->shouldBeCalledOnce(); + + $this->visitsTracker->visitsForTag($tag, new VisitsParams()); + } + + /** @test */ + public function visitsForTagAreReturnedAsExpected(): void + { + $tag = 'foo'; + $repo = $this->prophesize(TagRepository::class); + $count = $repo->count(['name' => $tag])->willReturn(1); + $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + + $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); + $repo2 = $this->prophesize(VisitRepository::class); + $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0)->willReturn($list); + $repo2->countVisitsByTag($tag, Argument::type(DateRange::class))->willReturn(1); + $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + + $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams()); + + $this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); + $count->shouldHaveBeenCalledOnce(); + $getRepo->shouldHaveBeenCalledOnce(); + } } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php new file mode 100644 index 00000000..a4b692d5 --- /dev/null +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -0,0 +1,50 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->helper = new VisitsStatsHelper($this->em->reveal()); + } + + /** + * @test + * @dataProvider provideCounts + */ + public function returnsExpectedVisitsStats(int $expectedCount): void + { + $repo = $this->prophesize(VisitRepository::class); + $count = $repo->count([])->willReturn($expectedCount); + $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); + + $stats = $this->helper->getVisitsStats(); + + $this->assertEquals(new VisitsStats($expectedCount), $stats); + $count->shouldHaveBeenCalledOnce(); + $getRepo->shouldHaveBeenCalledOnce(); + } + + public function provideCounts(): iterable + { + return map(range(0, 50, 5), fn (int $value) => [$value]); + } +} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index b24ec1ee..258404ef 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -4,13 +4,14 @@ 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\Core\Tag\TagService; +use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Rest\Service\ApiKeyService; return [ @@ -20,6 +21,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, @@ -27,7 +29,9 @@ return [ Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class, Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class, - Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class, + Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class, + Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class, + Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class, Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class, Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class, Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, @@ -45,35 +49,29 @@ return [ ConfigAbstractFactory::class => [ ApiKeyService::class => ['em'], - Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'], - Action\ShortUrl\CreateShortUrlAction::class => [ - Service\UrlShortener::class, - 'config.url_shortener.domain', - 'Logger_Shlink', - ], + Action\HealthAction::class => ['em', AppOptions::class], + Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'], + Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'], Action\ShortUrl\SingleStepCreateShortUrlAction::class => [ Service\UrlShortener::class, ApiKeyService::class, 'config.url_shortener.domain', - 'Logger_Shlink', ], - Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'], - Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'], + Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class], + Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class], Action\ShortUrl\ResolveShortUrlAction::class => [ Service\ShortUrl\ShortUrlResolver::class, 'config.url_shortener.domain', ], - Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'], - Action\ShortUrl\ListShortUrlsAction::class => [ - Service\ShortUrlService::class, - 'config.url_shortener.domain', - 'Logger_Shlink', - ], - Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'], - Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], - Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], - Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], - Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class], + Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class], + Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::class], + Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], + Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], + Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class], + Action\Tag\ListTagsAction::class => [TagService::class], + Action\Tag\DeleteTagsAction::class => [TagService::class], + Action\Tag\CreateTagsAction::class => [TagService::class], + Action\Tag\UpdateTagAction::class => [TagService::class], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index b104d81b..0bde3da0 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -26,13 +26,17 @@ return [ Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]), // Visits - Action\Visit\GetVisitsAction::getRouteDef([$dropDomainMiddleware]), + Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), + Action\Visit\TagVisitsAction::getRouteDef(), + Action\Visit\GlobalVisitsAction::getRouteDef(), // Tags Action\Tag\ListTagsAction::getRouteDef(), Action\Tag\DeleteTagsAction::getRouteDef(), Action\Tag\CreateTagsAction::getRouteDef(), Action\Tag\UpdateTagAction::getRouteDef(), + + Action\MercureInfoAction::getRouteDef(), ], ]; diff --git a/module/Rest/src/Action/AbstractRestAction.php b/module/Rest/src/Action/AbstractRestAction.php index 826290b7..da8b6d80 100644 --- a/module/Rest/src/Action/AbstractRestAction.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -7,8 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action; use Fig\Http\Message\RequestMethodInterface; use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Server\RequestHandlerInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use function array_merge; @@ -17,13 +15,6 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet protected const ROUTE_PATH = ''; protected const ROUTE_ALLOWED_METHODS = []; - protected LoggerInterface $logger; - - public function __construct(?LoggerInterface $logger = null) - { - $this->logger = $logger ?: new NullLogger(); - } - public static function getRouteDef(array $prevMiddleware = [], array $postMiddleware = []): array { return [ diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index 4f3b9c64..ef89da64 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -4,11 +4,10 @@ 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; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Options\AppOptions; use Throwable; @@ -21,13 +20,12 @@ 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) { - parent::__construct($logger); - $this->conn = $conn; + $this->em = $em; $this->options = $options; } @@ -39,7 +37,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; } diff --git a/module/Rest/src/Action/MercureInfoAction.php b/module/Rest/src/Action/MercureInfoAction.php new file mode 100644 index 00000000..75893ab9 --- /dev/null +++ b/module/Rest/src/Action/MercureInfoAction.php @@ -0,0 +1,53 @@ +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(), + ]); + } +} diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index 3335e1fa..feed626d 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; @@ -19,12 +18,8 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction private UrlShortenerInterface $urlShortener; private array $domainConfig; - public function __construct( - UrlShortenerInterface $urlShortener, - array $domainConfig, - ?LoggerInterface $logger = null - ) { - parent::__construct($logger); + public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig) + { $this->urlShortener = $urlShortener; $this->domainConfig = $domainConfig; } diff --git a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php index d86c60e9..bd5b487e 100644 --- a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -19,9 +18,8 @@ class DeleteShortUrlAction extends AbstractRestAction private DeleteShortUrlServiceInterface $deleteShortUrlService; - public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService, ?LoggerInterface $logger = null) + public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService) { - parent::__construct($logger); $this->deleteShortUrlService = $deleteShortUrlService; } diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index da7012b6..30d95ae1 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -20,9 +19,8 @@ class EditShortUrlAction extends AbstractRestAction private ShortUrlServiceInterface $shortUrlService; - public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null) + public function __construct(ShortUrlServiceInterface $shortUrlService) { - parent::__construct($logger); $this->shortUrlService = $shortUrlService; } diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index 0a48d986..def36d6c 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -20,9 +19,8 @@ class EditShortUrlTagsAction extends AbstractRestAction private ShortUrlServiceInterface $shortUrlService; - public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null) + public function __construct(ShortUrlServiceInterface $shortUrlService) { - parent::__construct($logger); $this->shortUrlService = $shortUrlService; } diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 5801eeec..10a0effc 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -24,12 +23,8 @@ class ListShortUrlsAction extends AbstractRestAction private ShortUrlServiceInterface $shortUrlService; private array $domainConfig; - public function __construct( - ShortUrlServiceInterface $shortUrlService, - array $domainConfig, - ?LoggerInterface $logger = null - ) { - parent::__construct($logger); + public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig) + { $this->shortUrlService = $shortUrlService; $this->domainConfig = $domainConfig; } diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index 41cd2b2d..9c2cb3e4 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; @@ -21,12 +20,8 @@ class ResolveShortUrlAction extends AbstractRestAction private ShortUrlResolverInterface $urlResolver; private array $domainConfig; - public function __construct( - ShortUrlResolverInterface $urlResolver, - array $domainConfig, - ?LoggerInterface $logger = null - ) { - parent::__construct($logger); + public function __construct(ShortUrlResolverInterface $urlResolver, array $domainConfig) + { $this->urlResolver = $urlResolver; $this->domainConfig = $domainConfig; } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index e754e3ad..daeb3d04 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\Uri; use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; @@ -22,10 +21,9 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction public function __construct( UrlShortenerInterface $urlShortener, ApiKeyServiceInterface $apiKeyService, - array $domainConfig, - ?LoggerInterface $logger = null + array $domainConfig ) { - parent::__construct($urlShortener, $domainConfig, $logger); + parent::__construct($urlShortener, $domainConfig); $this->apiKeyService = $apiKeyService; } diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php index eb4a279b..08f617c2 100644 --- a/module/Rest/src/Action/Tag/CreateTagsAction.php +++ b/module/Rest/src/Action/Tag/CreateTagsAction.php @@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; class CreateTagsAction extends AbstractRestAction @@ -18,9 +17,8 @@ class CreateTagsAction extends AbstractRestAction private TagServiceInterface $tagService; - public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null) + public function __construct(TagServiceInterface $tagService) { - parent::__construct($logger); $this->tagService = $tagService; } diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php index b8bedab9..f38c443a 100644 --- a/module/Rest/src/Action/Tag/DeleteTagsAction.php +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; class DeleteTagsAction extends AbstractRestAction @@ -18,9 +17,8 @@ class DeleteTagsAction extends AbstractRestAction private TagServiceInterface $tagService; - public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null) + public function __construct(TagServiceInterface $tagService) { - parent::__construct($logger); $this->tagService = $tagService; } diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 7cc7e063..0832f17c 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -7,10 +7,12 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use function Functional\map; + class ListTagsAction extends AbstractRestAction { protected const ROUTE_PATH = '/tags'; @@ -18,24 +20,31 @@ class ListTagsAction extends AbstractRestAction private TagServiceInterface $tagService; - public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null) + public function __construct(TagServiceInterface $tagService) { - parent::__construct($logger); $this->tagService = $tagService; } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * - * @throws \InvalidArgumentException - */ public function handle(ServerRequestInterface $request): ResponseInterface { + $query = $request->getQueryParams(); + $withStats = ($query['withStats'] ?? null) === 'true'; + + if (! $withStats) { + return new JsonResponse([ + 'tags' => [ + 'data' => $this->tagService->listTags(), + ], + ]); + } + + $tagsInfo = $this->tagService->tagsInfo(); + $data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag()); + return new JsonResponse([ 'tags' => [ - 'data' => $this->tagService->listTags(), + 'data' => $data, + 'stats' => $tagsInfo, ], ]); } diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index 6fb72e01..fbf93f50 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -7,9 +7,8 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; class UpdateTagAction extends AbstractRestAction @@ -19,9 +18,8 @@ class UpdateTagAction extends AbstractRestAction private TagServiceInterface $tagService; - public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null) + public function __construct(TagServiceInterface $tagService) { - parent::__construct($logger); $this->tagService = $tagService; } diff --git a/module/Rest/src/Action/Visit/GlobalVisitsAction.php b/module/Rest/src/Action/Visit/GlobalVisitsAction.php new file mode 100644 index 00000000..a27412b2 --- /dev/null +++ b/module/Rest/src/Action/Visit/GlobalVisitsAction.php @@ -0,0 +1,31 @@ +statsHelper = $statsHelper; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new JsonResponse([ + 'visits' => $this->statsHelper->getVisitsStats(), + ]); + } +} diff --git a/module/Rest/src/Action/Visit/GetVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php similarity index 88% rename from module/Rest/src/Action/Visit/GetVisitsAction.php rename to module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index bd6ae5a5..92a7e873 100644 --- a/module/Rest/src/Action/Visit/GetVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -7,14 +7,13 @@ namespace Shlinkio\Shlink\Rest\Action\Visit; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -class GetVisitsAction extends AbstractRestAction +class ShortUrlVisitsAction extends AbstractRestAction { use PaginatorUtilsTrait; @@ -23,9 +22,8 @@ class GetVisitsAction extends AbstractRestAction private VisitsTrackerInterface $visitsTracker; - public function __construct(VisitsTrackerInterface $visitsTracker, ?LoggerInterface $logger = null) + public function __construct(VisitsTrackerInterface $visitsTracker) { - parent::__construct($logger); $this->visitsTracker = $visitsTracker; } diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php new file mode 100644 index 00000000..1107ca5c --- /dev/null +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -0,0 +1,38 @@ +visitsTracker = $visitsTracker; + } + + public function handle(Request $request): Response + { + $tag = $request->getAttribute('tag', ''); + $visits = $this->visitsTracker->visitsForTag($tag, VisitsParams::fromRawData($request->getQueryParams())); + + return new JsonResponse([ + 'visits' => $this->serializePaginator($visits), + ]); + } +} 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; + } +} diff --git a/module/Rest/test-api/Action/GlobalVisitsActionTest.php b/module/Rest/test-api/Action/GlobalVisitsActionTest.php new file mode 100644 index 00000000..8e4f5e11 --- /dev/null +++ b/module/Rest/test-api/Action/GlobalVisitsActionTest.php @@ -0,0 +1,21 @@ +callApiWithKey(self::METHOD_GET, '/visits'); + $payload = $this->getJsonResponsePayload($resp); + + $this->assertArrayHasKey('visits', $payload); + $this->assertArrayHasKey('visitsCount', $payload['visits']); + $this->assertEquals(7, $payload['visits']['visitsCount']); + } +} diff --git a/module/Rest/test-api/Action/ListTagsActionTest.php b/module/Rest/test-api/Action/ListTagsActionTest.php new file mode 100644 index 00000000..0690d4f2 --- /dev/null +++ b/module/Rest/test-api/Action/ListTagsActionTest.php @@ -0,0 +1,50 @@ +callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query]); + $payload = $this->getJsonResponsePayload($resp); + + $this->assertEquals(['tags' => $expectedTags], $payload); + } + + public function provideQueries(): iterable + { + yield 'stats not requested' => [[], [ + 'data' => ['bar', 'baz', 'foo'], + ]]; + yield 'stats requested' => [['withStats' => 'true'], [ + 'data' => ['bar', 'baz', 'foo'], + 'stats' => [ + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, + ], + [ + 'tag' => 'baz', + 'shortUrlsCount' => 0, + 'visitsCount' => 0, + ], + [ + 'tag' => 'foo', + 'shortUrlsCount' => 2, + 'visitsCount' => 5, + ], + ], + ]]; + } +} diff --git a/module/Rest/test-api/Action/GetVisitsActionTest.php b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php similarity index 97% rename from module/Rest/test-api/Action/GetVisitsActionTest.php rename to module/Rest/test-api/Action/ShortUrlVisitsActionTest.php index cee466a3..ea39a267 100644 --- a/module/Rest/test-api/Action/GetVisitsActionTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php @@ -11,7 +11,7 @@ use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; use function GuzzleHttp\Psr7\build_query; use function sprintf; -class GetVisitsActionTest extends ApiTestCase +class ShortUrlVisitsActionTest extends ApiTestCase { use NotFoundUrlHelpersTrait; diff --git a/module/Rest/test-api/Action/TagVisitsActionTest.php b/module/Rest/test-api/Action/TagVisitsActionTest.php new file mode 100644 index 00000000..94e592f6 --- /dev/null +++ b/module/Rest/test-api/Action/TagVisitsActionTest.php @@ -0,0 +1,46 @@ +callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag)); + $payload = $this->getJsonResponsePayload($resp); + + $this->assertArrayHasKey('visits', $payload); + $this->assertArrayHasKey('data', $payload['visits']); + $this->assertCount($expectedVisitsAmount, $payload['visits']['data']); + } + + public function provideTags(): iterable + { + yield 'foo' => ['foo', 5]; + yield 'bar' => ['bar', 2]; + yield 'baz' => ['baz', 0]; + } + + /** @test */ + public function notFoundErrorIsReturnedForInvalidTags(): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, '/tags/invalid_tag/visits'); + $payload = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + $this->assertEquals('TAG_NOT_FOUND', $payload['type']); + $this->assertEquals('Tag with name "invalid_tag" could not be found', $payload['detail']); + $this->assertEquals('Tag not found', $payload['title']); + } +} diff --git a/module/Rest/test-api/Fixtures/TagsFixture.php b/module/Rest/test-api/Fixtures/TagsFixture.php index 5bd10ca7..5d3333cc 100644 --- a/module/Rest/test-api/Fixtures/TagsFixture.php +++ b/module/Rest/test-api/Fixtures/TagsFixture.php @@ -24,6 +24,7 @@ class TagsFixture extends AbstractFixture implements DependentFixtureInterface $manager->persist($fooTag); $barTag = new Tag('bar'); $manager->persist($barTag); + $manager->persist(new Tag('baz')); /** @var ShortUrl $abcShortUrl */ $abcShortUrl = $this->getReference('abc123_short_url'); diff --git a/module/Rest/test/Action/HealthActionTest.php b/module/Rest/test/Action/HealthActionTest.php index 813fa5cc..2ec68d25 100644 --- a/module/Rest/test/Action/HealthActionTest.php +++ b/module/Rest/test/Action/HealthActionTest.php @@ -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 */ diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php new file mode 100644 index 00000000..d40b3f70 --- /dev/null +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -0,0 +1,106 @@ +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]; + } +} diff --git a/module/Rest/test/Action/Tag/CreateTagsActionTest.php b/module/Rest/test/Action/Tag/CreateTagsActionTest.php index 357abc5d..33aa0ba7 100644 --- a/module/Rest/test/Action/Tag/CreateTagsActionTest.php +++ b/module/Rest/test/Action/Tag/CreateTagsActionTest.php @@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\CreateTagsAction; class CreateTagsActionTest extends TestCase diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index 484bd549..819a608a 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction; class DeleteTagsActionTest extends TestCase diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 7e9b061f..461ddd3f 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -4,15 +4,15 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Tag; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; -use function Shlinkio\Shlink\Common\json_decode; - class ListTagsActionTest extends TestCase { private ListTagsAction $action; @@ -24,18 +24,53 @@ class ListTagsActionTest extends TestCase $this->action = new ListTagsAction($this->tagService->reveal()); } - /** @test */ - public function returnsDataFromService(): void + /** + * @test + * @dataProvider provideNoStatsQueries + */ + public function returnsBaseDataWhenStatsAreNotRequested(array $query): void { - $listTags = $this->tagService->listTags()->willReturn([new Tag('foo'), new Tag('bar')]); + $tags = [new Tag('foo'), new Tag('bar')]; + $listTags = $this->tagService->listTags()->willReturn($tags); - $resp = $this->action->handle(new ServerRequest()); + /** @var JsonResponse $resp */ + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams($query)); + $payload = $resp->getPayload(); + + $this->assertEquals([ + 'tags' => [ + 'data' => $tags, + ], + ], $payload); + $listTags->shouldHaveBeenCalled(); + } + + public function provideNoStatsQueries(): iterable + { + yield 'no query' => [[]]; + yield 'withStats is false' => [['withStats' => 'withStats']]; + yield 'withStats is something else' => [['withStats' => 'foo']]; + } + + /** @test */ + public function returnsStatsWhenRequested(): void + { + $stats = [ + new TagInfo(new Tag('foo'), 1, 1), + new TagInfo(new Tag('bar'), 3, 10), + ]; + $tagsInfo = $this->tagService->tagsInfo()->willReturn($stats); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true'])); + $payload = $resp->getPayload(); $this->assertEquals([ 'tags' => [ 'data' => ['foo', 'bar'], + 'stats' => $stats, ], - ], json_decode((string) $resp->getBody())); - $listTags->shouldHaveBeenCalled(); + ], $payload); + $tagsInfo->shouldHaveBeenCalled(); } } diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index ab09b4ea..11b2c1c4 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; +use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; class UpdateTagActionTest extends TestCase diff --git a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php new file mode 100644 index 00000000..7e1dec06 --- /dev/null +++ b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php @@ -0,0 +1,39 @@ +helper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->action = new GlobalVisitsAction($this->helper->reveal()); + } + + /** @test */ + public function statsAreReturnedFromHelper(): void + { + $stats = new VisitsStats(5); + $getStats = $this->helper->getVisitsStats()->willReturn($stats); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); + $payload = $resp->getPayload(); + + $this->assertEquals($payload, ['visits' => $stats]); + $getStats->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Rest/test/Action/Visit/GetVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php similarity index 89% rename from module/Rest/test/Action/Visit/GetVisitsActionTest.php rename to module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index a1f1681a..07508acf 100644 --- a/module/Rest/test/Action/Visit/GetVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -15,17 +15,17 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; -use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction; +use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction; -class GetVisitsActionTest extends TestCase +class ShortUrlVisitsActionTest extends TestCase { - private GetVisitsAction $action; + private ShortUrlVisitsAction $action; private ObjectProphecy $visitsTracker; public function setUp(): void { $this->visitsTracker = $this->prophesize(VisitsTracker::class); - $this->action = new GetVisitsAction($this->visitsTracker->reveal()); + $this->action = new ShortUrlVisitsAction($this->visitsTracker->reveal()); } /** @test */ diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php new file mode 100644 index 00000000..863bc725 --- /dev/null +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -0,0 +1,41 @@ +visitsTracker = $this->prophesize(VisitsTracker::class); + $this->action = new TagVisitsAction($this->visitsTracker->reveal()); + } + + /** @test */ + public function providingCorrectShortCodeReturnsVisits(): void + { + $tag = 'foo'; + $getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class))->willReturn( + new Paginator(new ArrayAdapter([])), + ); + + $response = $this->action->handle((new ServerRequest())->withAttribute('tag', $tag)); + + $this->assertEquals(200, $response->getStatusCode()); + $getVisits->shouldHaveBeenCalledOnce(); + } +} 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; diff --git a/phpstan.neon b/phpstan.neon index d983a985..e065acef 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,3 +4,4 @@ parameters: ignoreErrors: - '#AbstractQuery::setParameters()#' - '#mustRun()#' + - '#AssociationBuilder::setOrderBy#'