diff --git a/.dockerignore b/.dockerignore index 7c730c69..9a48c84c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,17 +8,16 @@ data/migrations_template.txt data/GeoLite2-City.* data/database.sqlite data/shlink-tests.db -**/.gitignore CHANGELOG.md +UPGRADE.md composer.lock vendor docs indocker docker-* -php* -infection.json phpstan.neon +php*xml* +infection.json **/test* build* -.github -hooks +**/.* diff --git a/.github/ISSUE_TEMPLATE/Bug.md b/.github/ISSUE_TEMPLATE/Bug.md index 59f71b26..25a433c2 100644 --- a/.github/ISSUE_TEMPLATE/Bug.md +++ b/.github/ISSUE_TEMPLATE/Bug.md @@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information * Shlink Version: x.y.z * PHP Version: x.y.z * How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image -* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z) +* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z) #### Summary diff --git a/.github/ISSUE_TEMPLATE/Question_Support.md b/.github/ISSUE_TEMPLATE/Question_Support.md index 885f866f..5d4f55c6 100644 --- a/.github/ISSUE_TEMPLATE/Question_Support.md +++ b/.github/ISSUE_TEMPLATE/Question_Support.md @@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information * Shlink Version: x.y.z * PHP Version: x.y.z * How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image -* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z) +* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z) #### Summary diff --git a/.gitignore b/.gitignore index ab121a93..8cfea409 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .idea build -!hooks/build +!docker/build composer.lock composer.phar vendor/ diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 3fa0e966..ed831706 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -6,6 +6,9 @@ checks: code_rating: true duplication: true build: + dependencies: + override: + - composer install --no-interaction --no-scripts --ignore-platform-reqs nodes: analysis: tests: diff --git a/.travis.yml b/.travis.yml index 5d6176ed..9c41ec31 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,7 @@ cache: before_install: - echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - - yes | pecl install swoole + - yes | pecl install swoole-4.4.15 - phpenv config-rm xdebug.ini || return 0 install: @@ -37,17 +37,23 @@ script: after_success: - rm -f build/clover.xml - - wget https://phar.phpunit.de/phpcov-6.0.1.phar - - phpdbg -qrr phpcov-6.0.1.phar merge build --clover build/clover.xml + - wget https://phar.phpunit.de/phpcov-7.0.2.phar + - phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml - wget https://scrutinizer-ci.com/ocular.phar - php ocular.phar code-coverage:upload --format=php-clover build/clover.xml # Before deploying, build dist file for current travis tag before_deploy: - rm -f ocular.phar - - ./build.sh ${TRAVIS_TAG#?} + - if [[ ! -z $TRAVIS_TAG && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi deploy: + - provider: script + script: bash ./docker/build + on: + all_branches: true + condition: $TRAVIS_PULL_REQUEST == 'false' + php: '7.4' - provider: releases api_key: secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I= diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e19191d..718a094e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ 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.1.0 - 2020-03-28 + +#### Added + +* [#626](https://github.com/shlinkio/shlink/issues/626) Added support for Microsoft SQL Server. +* [#556](https://github.com/shlinkio/shlink/issues/556) Short code lengths can now be customized, both globally and on a per-short URL basis. +* [#541](https://github.com/shlinkio/shlink/issues/541) Added a request ID that is returned on `X-Request-Id` header, can be provided from outside and is set in log entries. +* [#642](https://github.com/shlinkio/shlink/issues/642) IP geolocation is now performed over the non-anonymized IP address when using swoole. +* [#521](https://github.com/shlinkio/shlink/issues/521) The long URL for any existing short URL can now be edited using the `PATCH /short-urls/{shortCode}` endpoint. + +#### Changed + +* [#656](https://github.com/shlinkio/shlink/issues/656) Updated to PHPUnit 9. +* [#641](https://github.com/shlinkio/shlink/issues/641) Added two new flags to the `visit:locate` command, `--retry` and `--all`. + + * When `--retry` is provided, it will try to re-locate visits which IP address was originally considered not found, in case it was a temporal issue. + * When `--all` is provided together with `--retry`, it will try to re-locate all existing visits. A warning and confirmation are displayed, as this can have side effects. + +#### Deprecated + +* *Nothing* + +#### Removed + +* *Nothing* + +#### Fixed + +* [#665](https://github.com/shlinkio/shlink/issues/665) Fixed `base_url_redirect_to` simplified config option not being properly parsed. +* [#663](https://github.com/shlinkio/shlink/issues/663) Fixed Shlink allowing short URLs to be created with an empty custom slug. +* [#678](https://github.com/shlinkio/shlink/issues/678) Fixed `db` commands not running in a non-interactive way. + + ## 2.0.5 - 2020-02-09 #### Added diff --git a/Dockerfile b/Dockerfile index 01a93c26..64cd7ebe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,14 @@ -FROM php:7.4.1-alpine3.10 -LABEL maintainer="Alejandro Celaya " +FROM php:7.4.2-alpine3.11 as base -ARG SHLINK_VERSION=2.0.0 +ARG SHLINK_VERSION=2.0.5 ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.4.12 -ENV COMPOSER_VERSION 1.9.1 +ENV SWOOLE_VERSION 4.4.15 +ENV LC_ALL "C" WORKDIR /etc/shlink RUN \ - # Install mysl and calendar + # Install mysql and calendar docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \ # Install sqlite apk add --no-cache sqlite-libs sqlite-dev && \ @@ -24,24 +23,36 @@ RUN \ apk add --no-cache libzip-dev zlib-dev libpng-dev && \ docker-php-ext-install -j"$(nproc)" zip gd -# Install swoole -# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233 -RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \ - pecl install swoole-${SWOOLE_VERSION} && \ - docker-php-ext-enable swoole && \ - apk del .phpize-deps +# 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 + # Install shlink +FROM base as builder COPY . . -RUN rm -rf ./docker && \ - wget https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar && \ +COPY --from=composer:1.10.1 /usr/bin/composer ./composer.phar +RUN apk add --no-cache git && \ php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \ php composer.phar clear-cache && \ - rm composer.* + rm -r docker composer.* && \ + sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php -# Add shlink to the path to ease running it after container is created + +# Prepare final image +FROM base +LABEL maintainer="Alejandro Celaya " + +COPY --from=builder /etc/shlink . RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink -RUN sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php # Expose swoole port EXPOSE 8080 diff --git a/README.md b/README.md index 3eb8a33d..4a7e25f6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master) [![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) +[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/master/LICENSE) -[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://acel.me/donate) +[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain. @@ -35,8 +36,8 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 7.4 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled. -* MySQL, MariaDB, PostgreSQL or SQLite. +* PHP 7.4 or greater with JSON, curl, PDO and gd extensions enabled. +* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite. * The web server of your choice with PHP integration (Apache or Nginx recommended). ### Download @@ -67,7 +68,7 @@ In order to run Shlink, you will need a built version of the project. There are Despite how you built the project, you now need to configure it, by following these steps: -* If you are going to use MySQL, MariaDB or PostgreSQL, create an empty database with the name of your choice. +* If you are going to use MySQL, MariaDB, PostgreSQL or Microsoft SQL Server, create an empty database with the name of your choice. * Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information. * Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.** * Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API. @@ -96,7 +97,7 @@ Once Shlink is configured, you need to expose it to the web, either by using a t location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; + fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } @@ -238,7 +239,7 @@ Once shlink is installed, there are two main ways to interact with it: It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory. -* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal. +* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/documentation/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal. However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too. @@ -344,4 +345,6 @@ Those are configured during Shlink's installation or via env vars when using the Currently those are all shared for all domains serving the same Shlink instance, but the plan is to update that and allow specific ones for every existing domain. +--- + > This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com) diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 2a14b218..fae0c628 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -9,7 +9,7 @@ echo 'Starting server...' vendor/bin/mezzio-swoole start -d sleep 2 -vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $* +phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $* testsExitCode=$? vendor/bin/mezzio-swoole stop diff --git a/build.sh b/build.sh index cf42695b..b3d28b9d 100755 --- a/build.sh +++ b/build.sh @@ -25,7 +25,7 @@ cd "${builtcontent}" # Install dependencies echo "Installing dependencies with $composerBin..." ${composerBin} self-update -${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction +${composerBin} install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction # Delete development files echo 'Deleting dev files...' diff --git a/composer.json b/composer.json index 908b9af3..9cadd738 100644 --- a/composer.json +++ b/composer.json @@ -40,17 +40,20 @@ "mezzio/mezzio-helpers": "^5.3", "mezzio/mezzio-platesrenderer": "^2.1", "mezzio/mezzio-problem-details": "^1.1", - "mezzio/mezzio-swoole": "^2.4", + "mezzio/mezzio-swoole": "^2.6", "monolog/monolog": "^2.0", "nikolaposa/monolog-factory": "^3.0", - "ocramius/proxy-manager": "^2.6.0", + "ocramius/proxy-manager": "^2.7.0", "phly/phly-event-dispatcher": "^1.0", + "php-middleware/request-id": "^4.0", "predis/predis": "^1.1", "pugx/shortid-php": "^0.5", - "shlinkio/shlink-common": "^2.7.0", - "shlinkio/shlink-event-dispatcher": "^1.3", - "shlinkio/shlink-installer": "^4.0.1", - "shlinkio/shlink-ip-geolocation": "^1.3.1", + "ramsey/uuid": "^3.9", + "shlinkio/shlink-common": "^3.0", + "shlinkio/shlink-config": "^1.0", + "shlinkio/shlink-event-dispatcher": "^1.4", + "shlinkio/shlink-installer": "^4.3.1", + "shlinkio/shlink-ip-geolocation": "^1.4", "symfony/console": "^5.0", "symfony/filesystem": "^5.0", "symfony/lock": "^5.0", @@ -58,14 +61,14 @@ }, "require-dev": { "devster/ubench": "^2.0", - "dms/phpunit-arraysubset-asserts": "^0.1.0", + "dms/phpunit-arraysubset-asserts": "^0.2.0", "eaglewu/swoole-ide-helper": "dev-master", "infection/infection": "^0.15.0", "phpstan/phpstan": "^0.12.3", - "phpunit/phpunit": "^8.3", + "phpunit/phpunit": "^9.0.1", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.1.0", - "shlinkio/shlink-test-utils": "^1.3", + "shlinkio/shlink-test-utils": "^1.4", "symfony/var-dumper": "^5.0" }, "autoload": { @@ -107,7 +110,7 @@ "test:ci": [ "@test:unit:ci", "@test:db:ci", - "@test:api" + "@test:api:ci" ], "test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", "test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml", @@ -115,7 +118,8 @@ "@test:db:sqlite", "@test:db:mysql", "@test:db:maria", - "@test:db:postgres" + "@test:db:postgres", + "@test:db:ms" ], "test:db:ci": [ "@test:db:sqlite", @@ -126,7 +130,9 @@ "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", + "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", + "test:api:ci": "@test:api --coverage-php build/coverage-api.cov", "test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage", "infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered", "infect:ci": "@infect --coverage=build", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 296c0635..c40d75d1 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -30,6 +30,7 @@ return [ Option\TaskWorkerNumConfigOption::class, Option\WebWorkerNumConfigOption::class, Option\RedisServersConfigOption::class, + Option\ShortCodeLengthOption::class, ], 'installation_commands' => [ diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php index 22a51e38..25c00f22 100644 --- a/config/autoload/locks.global.php +++ b/config/autoload/locks.global.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory; use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory; use Symfony\Component\Lock; -$localLockFactory = 'Shlinkio\Shlink\LocalLockFactory'; +use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY; return [ @@ -21,7 +21,7 @@ return [ Lock\Store\FlockStore::class => ConfigAbstractFactory::class, Lock\Store\RedisStore::class => ConfigAbstractFactory::class, Lock\LockFactory::class => ConfigAbstractFactory::class, - $localLockFactory => ConfigAbstractFactory::class, + LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class, ], 'aliases' => [ // With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default @@ -44,7 +44,7 @@ return [ Lock\Store\FlockStore::class => ['config.locks.locks_dir'], Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME], Lock\LockFactory::class => ['lock_store'], - $localLockFactory => ['local_lock_store'], + LOCAL_LOCK_FACTORY => ['local_lock_store'], ], ]; diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 879f700a..7c0e4f00 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -9,6 +9,7 @@ use Monolog\Handler; use Monolog\Logger; use Monolog\Processor; use MonologFactory\DiContainerLoggerFactory; +use PhpMiddleware\RequestId; use Psr\Log\LoggerInterface; use const PHP_EOL; @@ -20,11 +21,12 @@ $processors = [ 'psr3' => [ 'name' => Processor\PsrLogMessageProcessor::class, ], + 'request_id' => RequestId\MonologProcessor::class, ]; $formatter = [ 'name' => Formatter\LineFormatter::class, 'params' => [ - 'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL, + 'format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%' . PHP_EOL, 'allow_inline_line_breaks' => true, ], ]; @@ -80,6 +82,7 @@ return [ 'swoole-http-server' => [ 'logger' => [ 'logger-name' => 'Logger_Access', + 'format' => '%h %l %u "%r" %>s %b', ], ], ], diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 2ace0700..45abf30e 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink; use Laminas\Stratigility\Middleware\ErrorHandler; use Mezzio; use Mezzio\ProblemDetails; +use PhpMiddleware\RequestId\RequestIdMiddleware; return [ @@ -21,6 +22,7 @@ return [ 'path' => '/rest', 'middleware' => [ Rest\Middleware\CrossDomainMiddleware::class, + RequestIdMiddleware::class, ProblemDetails\ProblemDetailsMiddleware::class, ], ], diff --git a/config/autoload/request_id.global.php b/config/autoload/request_id.global.php new file mode 100644 index 00000000..f057bb09 --- /dev/null +++ b/config/autoload/request_id.global.php @@ -0,0 +1,38 @@ + [ + 'allow_override' => true, + 'header_name' => 'X-Request-Id', + ], + + 'dependencies' => [ + 'factories' => [ + RequestId\Generator\RamseyUuid4StaticGenerator::class => InvokableFactory::class, + RequestId\RequestIdProviderFactory::class => ConfigAbstractFactory::class, + RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class, + RequestId\MonologProcessor::class => ConfigAbstractFactory::class, + ], + ], + + ConfigAbstractFactory::class => [ + RequestId\RequestIdProviderFactory::class => [ + RequestId\Generator\RamseyUuid4StaticGenerator::class, + 'config.request_id.allow_override', + 'config.request_id.header_name', + ], + RequestId\RequestIdMiddleware::class => [ + RequestId\RequestIdProviderFactory::class, + 'config.request_id.header_name', + ], + RequestId\MonologProcessor::class => [RequestId\RequestIdMiddleware::class], + ], + +]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 5cf4f86f..165e0258 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH; + return [ 'url_shortener' => [ @@ -11,6 +13,7 @@ return [ ], 'validate_url' => false, 'visits_webhooks' => [], + 'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH, ], ]; diff --git a/config/config.php b/config/config.php index 3fb1a3e4..98b4552b 100644 --- a/config/config.php +++ b/config/config.php @@ -19,11 +19,12 @@ return (new ConfigAggregator\ConfigAggregator([ Mezzio\Swoole\ConfigProvider::class, ProblemDetails\ConfigProvider::class, Common\ConfigProvider::class, + Config\ConfigProvider::class, IpGeolocation\ConfigProvider::class, + EventDispatcher\ConfigProvider::class, Core\ConfigProvider::class, CLI\ConfigProvider::class, Rest\ConfigProvider::class, - EventDispatcher\ConfigProvider::class, new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'), env('APP_ENV') === 'test' ? new ConfigAggregator\PhpFileProvider('config/test/*.global.php') diff --git a/config/container.php b/config/container.php index 3735e14e..7b6f0b08 100644 --- a/config/container.php +++ b/config/container.php @@ -5,13 +5,16 @@ declare(strict_types=1); use Laminas\ServiceManager\ServiceManager; use Symfony\Component\Lock; +use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY; + chdir(dirname(__DIR__)); require 'vendor/autoload.php'; // This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name -if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) { - class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory'); +// It needs to be placed here as individual config files will not be loaded once config is cached +if (! class_exists(LOCAL_LOCK_FACTORY)) { + class_alias(Lock\LockFactory::class, LOCAL_LOCK_FACTORY); } // Build container diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index c4556b8b..fa51c240 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -45,6 +45,13 @@ $buildDbConnection = function (): array { 'dbname' => 'shlink_test', 'charset' => 'utf8', ], + 'mssql' => [ + 'driver' => 'pdo_sqlsrv', + 'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms', + 'user' => 'sa', + 'password' => $isCi ? '' : 'Passw0rd!', + 'dbname' => 'shlink_test', + ], ]; $driverConfigMap['maria'] = $driverConfigMap['mysql']; diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index e92cc815..c5401651 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,4 +1,4 @@ -FROM php:7.4.1-fpm-alpine3.10 +FROM php:7.4.2-fpm-alpine3.11 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.18 @@ -65,6 +65,18 @@ RUN docker-php-ext-configure xdebug\ # cleanup 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 + # Install composer RUN php -r "readfile('https://getcomposer.org/installer');" | php RUN chmod +x composer.phar diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 8bc821d9..3f7a1513 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:7.4.1-alpine3.10 +FROM php:7.4.2-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.12 +ENV SWOOLE_VERSION 4.4.15 RUN apk update @@ -66,12 +66,17 @@ RUN docker-php-ext-configure inotify\ # cleanup RUN rm /tmp/inotify.tar.gz -# Install swoole -# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233 -RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \ - pecl install swoole-${SWOOLE_VERSION} && \ - docker-php-ext-enable swoole && \ - apk del .phpize-deps +# 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 # Install composer RUN php -r "readfile('https://getcomposer.org/installer');" | php diff --git a/data/migrations/Version20200323190014.php b/data/migrations/Version20200323190014.php new file mode 100644 index 00000000..fe3c340d --- /dev/null +++ b/data/migrations/Version20200323190014.php @@ -0,0 +1,44 @@ +getTable('visit_locations'); + $this->skipIf($visitLocations->hasColumn('is_empty')); + + $visitLocations->addColumn('is_empty', Types::BOOLEAN, ['default' => false]); + } + + public function postUp(Schema $schema): void + { + $qb = $this->connection->createQueryBuilder(); + $qb->update('visit_locations') + ->set('is_empty', true) + ->where($qb->expr()->eq('country_code', ':empty')) + ->andWhere($qb->expr()->eq('country_name', ':empty')) + ->andWhere($qb->expr()->eq('region_name', ':empty')) + ->andWhere($qb->expr()->eq('city_name', ':empty')) + ->andWhere($qb->expr()->eq('timezone', ':empty')) + ->andWhere($qb->expr()->eq('lat', 0)) + ->andWhere($qb->expr()->eq('lon', 0)) + ->setParameter('empty', '') + ->execute(); + } + + public function down(Schema $schema): void + { + $visitLocations = $schema->getTable('visit_locations'); + $this->skipIf(!$visitLocations->hasColumn('is_empty')); + + $visitLocations->dropColumn('is_empty'); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 99cc93fb..c78cf85f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,10 @@ services: - shlink_db - shlink_db_postgres - shlink_db_maria + - shlink_db_ms - shlink_redis + environment: + LC_ALL: C shlink_swoole: container_name: shlink_swoole @@ -42,7 +45,10 @@ services: - shlink_db - shlink_db_postgres - shlink_db_maria + - shlink_db_ms - shlink_redis + environment: + LC_ALL: C shlink_db: container_name: shlink_db @@ -82,6 +88,15 @@ services: MYSQL_DATABASE: shlink MYSQL_INITDB_SKIP_TZINFO: 1 + shlink_db_ms: + container_name: shlink_db_ms + image: mcr.microsoft.com/mssql/server:2019-latest + ports: + - "1433:1433" + environment: + ACCEPT_EULA: Y + SA_PASSWORD: "Passw0rd!" + shlink_redis: container_name: shlink_redis image: redis:5.0-alpine diff --git a/docker/README.md b/docker/README.md index 0af66442..3977fa37 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,6 +1,5 @@ # Shlink Docker image -[![Docker build status](https://img.shields.io/docker/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime. @@ -38,10 +37,10 @@ Or you can list all tags with: docker exec -it shlink_container shlink tag:list ``` -Or process remaining visits with: +Or locate remaining visits with: ```bash -docker exec -it shlink_container shlink visit:process +docker exec -it shlink_container shlink visit:locate ``` All shlink commands will work the same way. @@ -56,9 +55,9 @@ docker exec -it shlink_container shlink The image comes with a working sqlite database, but in production you will probably want to usa a distributed database. -It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB or PostgreSQL database. +It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB, PostgreSQL or Microsoft SQL Server database. -* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria** or **postgres** to prevent the sqlite database to be used. +* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria**, **postgres** or **mssql** to prevent the sqlite database to be used. * `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**. * `DB_USER`: **[Mandatory]**. The username credential for the database server. * `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server. @@ -67,8 +66,9 @@ It is possible to use a set of env vars to make this shlink instance interact wi * Default value is based on the value provided for `DB_DRIVER`: * **mysql** or **maria** -> `3306` * **postgres** -> `5432` + * **mssql** -> `1433` -> PostgreSQL is supported since v1.16.1 of this image. Do not try to use it with previous versions. +> PostgreSQL is supported since v1.16.1 and Microsoft SQL server since v2.1.0. Do not try to use them with previous versions. Taking this into account, you could run shlink on a local docker service like this: @@ -92,7 +92,7 @@ This is the complete list of supported env vars: * `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**. * `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**. -* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria** or **postgres**. +* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria**, **postgres** or **mssql**. * `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**. * `DB_USER`: The username credential to be used when using an external database driver. * `DB_PASSWORD`: The password credential to be used when using an external database driver. @@ -101,6 +101,7 @@ This is the complete list of supported env vars: * Default value is based on the value provided for `DB_DRIVER`: * **mysql** or **maria** -> `3306` * **postgres** -> `5432` + * **mssql** -> `1433` * `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided. * `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`. * `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`. @@ -111,6 +112,7 @@ This is the complete list of supported env vars: * `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16. * `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. @@ -144,6 +146,7 @@ docker run \ -e WEB_WORKER_NUM=64 \ -e TASK_WORKER_NUM=32 \ -e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \ + -e DEFAULT_SHORT_CODES_LENGTH=6 \ shlinkio/shlink:stable ``` @@ -168,6 +171,7 @@ The whole configuration should have this format, but it can be split into multip "base_path": "/my-campaign", "web_worker_num": 64, "task_worker_num": 32, + "default_short_codes_length": 6, "redis_servers": [ "tcp://172.20.0.1:6379", "tcp://172.20.0.2:6379" diff --git a/docker/build b/docker/build new file mode 100755 index 00000000..5eea7888 --- /dev/null +++ b/docker/build @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin + +# If there is a tag, regardless the branch, build that docker tag and also "stable" +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 +# 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 . + docker push shlinkio/shlink:latest +fi diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 7eba5560..6cf86434 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -11,16 +11,21 @@ use function explode; use function Functional\contains; use function Shlinkio\Shlink\Common\env; +use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH; +use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH; + $helper = new class { private const DB_DRIVERS_MAP = [ 'mysql' => 'pdo_mysql', 'maria' => 'pdo_mysql', 'postgres' => 'pdo_pgsql', + 'mssql' => 'pdo_sqlsrv', ]; private const DB_PORTS_MAP = [ 'mysql' => '3306', 'maria' => '3306', 'postgres' => '5432', + 'mssql' => '1433', ]; public function getDbConfig(): array @@ -68,6 +73,12 @@ $helper = new class { $redisServers = env('REDIS_SERVERS'); return $redisServers === null ? null : ['servers' => $redisServers]; } + + public function getDefaultShortCodesLength(): int + { + $value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH); + return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value; + } }; return [ @@ -94,6 +105,7 @@ return [ ], 'validate_url' => (bool) env('VALIDATE_URLS', false), 'visits_webhooks' => $helper->getVisitsWebhooks(), + 'default_short_codes_length' => $helper->getDefaultShortCodesLength(), ], 'not_found_redirects' => $helper->getNotFoundRedirectsConfig(), diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index be274ab6..ee8a6060 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -243,6 +243,10 @@ "domain": { "description": "The domain to which the short URL will be attached", "type": "string" + }, + "shortCodeLength": { + "description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided", + "type": "number" } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index b9baad92..71a6a427 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -112,6 +112,10 @@ "schema": { "type": "object", "properties": { + "longUrl": { + "description": "The long URL this short URL will redirect to", + "type": "string" + }, "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", "type": "string" @@ -157,6 +161,7 @@ "items": { "type": "string", "enum": [ + "longUrl", "validSince", "validUntil", "maxVisits" diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index ddf2b3ad..32e0caf3 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -7,7 +7,7 @@ }, "externalDocs": { - "url": "https://shlink.io/api-docs", + "url": "https://shlink.io/documentation/api-docs", "description": "Find more info on how to start using this API here" }, diff --git a/hooks/build b/hooks/build deleted file mode 100755 index 6b381d74..00000000 --- a/hooks/build +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -set -ex - -if [[ ${SOURCE_BRANCH} == 'develop' ]]; then - SHLINK_RELEASE='latest' -else - SHLINK_RELEASE=${SOURCE_BRANCH#?} -fi - -docker build --build-arg SHLINK_VERSION=${SHLINK_RELEASE} -t ${IMAGE_NAME} . diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 1f94f5a6..0f2e70a5 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\Visit; use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; @@ -19,6 +20,8 @@ use Symfony\Component\Console as SymfonyCli; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; +use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY; + return [ 'dependencies' => [ @@ -52,16 +55,20 @@ return [ ], ConfigAbstractFactory::class => [ - GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'], + GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY], - Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'], + Command\ShortUrl\GenerateShortUrlCommand::class => [ + Service\UrlShortener::class, + 'config.url_shortener.domain', + 'config.url_shortener.default_short_codes_length', + ], Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class], Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'], Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], Command\Visit\LocateVisitsCommand::class => [ - Service\VisitService::class, + Visit\VisitLocator::class, IpLocationResolverInterface::class, LockFactory::class, GeolocationDbUpdater::class, diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index 4b45fa56..5e9374cf 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -11,8 +11,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; -use function array_unshift; - abstract class AbstractDatabaseCommand extends AbstractLockedCommand { private ProcessHelper $processHelper; @@ -27,7 +25,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand protected function runPhpCommand(OutputInterface $output, array $command): void { - array_unshift($command, $this->phpBinary); + $command = [$this->phpBinary, ...$command, '--no-interaction']; $this->processHelper->mustRun($output, $command); } diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index 28d192b1..7369f1f6 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -30,12 +30,14 @@ class GenerateShortUrlCommand extends Command private UrlShortenerInterface $urlShortener; private array $domainConfig; + private int $defaultShortCodeLength; - public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig) + public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength) { parent::__construct(); $this->urlShortener = $urlShortener; $this->domainConfig = $domainConfig; + $this->defaultShortCodeLength = $defaultShortCodeLength; } protected function configure(): void @@ -87,6 +89,12 @@ class GenerateShortUrlCommand extends Command 'd', InputOption::VALUE_REQUIRED, 'The domain to which this short URL will be attached.', + ) + ->addOption( + 'shortCodeLength', + 'l', + InputOption::VALUE_REQUIRED, + 'The length for generated short code (it will be ignored if --customSlug was provided).', ); } @@ -117,6 +125,7 @@ class GenerateShortUrlCommand extends Command $tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); $customSlug = $input->getOption('customSlug'); $maxVisits = $input->getOption('maxVisits'); + $shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength; try { $shortUrl = $this->urlShortener->urlToShortCode( @@ -129,6 +138,7 @@ class GenerateShortUrlCommand extends Command ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'), ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'), + ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, ]), ); diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index 43949993..a0c2c91a 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -76,7 +77,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand $rows = map($paginator->getCurrentItems(), function (Visit $visit) { $rowData = $visit->jsonSerialize(); - $rowData['country'] = $visit->getVisitLocation()->getCountryName(); + $rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName(); return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']); }); ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows); diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index b19e8b19..bf1ac14b 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Exception; use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; @@ -14,12 +13,15 @@ use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; -use Shlinkio\Shlink\Core\Service\VisitServiceInterface; +use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface; +use Shlinkio\Shlink\Core\Visit\VisitLocatorInterface; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; +use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Lock\LockFactory; @@ -27,11 +29,11 @@ use Throwable; use function sprintf; -class LocateVisitsCommand extends AbstractLockedCommand +class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface { public const NAME = 'visit:locate'; - private VisitServiceInterface $visitService; + private VisitLocatorInterface $visitLocator; private IpLocationResolverInterface $ipLocationResolver; private GeolocationDbUpdaterInterface $dbUpdater; @@ -39,13 +41,13 @@ class LocateVisitsCommand extends AbstractLockedCommand private ?ProgressBar $progressBar = null; public function __construct( - VisitServiceInterface $visitService, + VisitLocatorInterface $visitLocator, IpLocationResolverInterface $ipLocationResolver, LockFactory $locker, GeolocationDbUpdaterInterface $dbUpdater ) { parent::__construct($locker); - $this->visitService = $visitService; + $this->visitLocator = $visitLocator; $this->ipLocationResolver = $ipLocationResolver; $this->dbUpdater = $dbUpdater; } @@ -54,32 +56,79 @@ class LocateVisitsCommand extends AbstractLockedCommand { $this ->setName(self::NAME) - ->setDescription('Resolves visits origin locations.'); + ->setDescription('Resolves visits origin locations.') + ->addOption( + 'retry', + 'r', + InputOption::VALUE_NONE, + 'Will retry the location of visits that were located with a not-found location, in case it was due to ' + . 'a temporal issue.', + ) + ->addOption( + 'all', + 'a', + InputOption::VALUE_NONE, + 'When provided together with --retry, will locate all existing visits, regardless the fact that they ' + . 'have already been located.', + ); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $retry = $input->getOption('retry'); + $all = $input->getOption('all'); + + if ($all && !$retry) { + $this->io->writeln( + 'The --all flag has no effect on its own. You have to provide it ' + . 'together with --retry.', + ); + } + + if ($all && $retry && ! $this->warnAndVerifyContinue()) { + throw new RuntimeException('Execution aborted'); + } + } + + private function warnAndVerifyContinue(): bool + { + $this->io->warning([ + 'You are about to process the location of all existing visits your short URLs received.', + 'Since shlink saves visitors IP addresses anonymized, you could end up losing precision on some of ' + . 'your visits.', + 'Also, if you have a large amount of visits, this can be a very time consuming process. ' + . 'Continue at your own risk.', + ]); + return $this->io->confirm('Do you want to proceed?', false); } protected function lockedExecute(InputInterface $input, OutputInterface $output): int { - $this->io = new SymfonyStyle($input, $output); + $retry = $input->getOption('retry'); + $all = $retry && $input->getOption('all'); try { $this->checkDbUpdate(); - $this->visitService->locateUnlocatedVisits( - [$this, 'getGeolocationDataForVisit'], - static function (VisitLocation $location) use ($output): void { - if (!$location->isEmpty()) { - $output->writeln( - sprintf(' [Address located at "%s"]', $location->getCountryName()), - ); - } - }, - ); + if ($all) { + $this->visitLocator->locateAllVisits($this); + } else { + $this->visitLocator->locateUnlocatedVisits($this); + if ($retry) { + $this->visitLocator->locateVisitsWithEmptyLocation($this); + } + } - $this->io->success('Finished processing all IPs'); + $this->io->success('Finished locating visits'); return ExitCodes::EXIT_SUCCESS; } catch (Throwable $e) { $this->io->error($e->getMessage()); - if ($e instanceof Exception && $this->io->isVerbose()) { + if ($e instanceof Throwable && $this->io->isVerbose()) { $this->getApplication()->renderThrowable($e, $this->io); } @@ -87,7 +136,10 @@ class LocateVisitsCommand extends AbstractLockedCommand } } - public function getGeolocationDataForVisit(Visit $visit): Location + /** + * @throws IpCannotBeLocatedException + */ + public function geolocateVisit(Visit $visit): Location { if (! $visit->hasRemoteAddr()) { $this->io->writeln( @@ -116,6 +168,14 @@ class LocateVisitsCommand extends AbstractLockedCommand } } + public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void + { + $message = ! $visitLocation->isEmpty() + ? sprintf(' [Address located in "%s"]', $visitLocation->getCountryName()) + : ' [Address not found]'; + $this->io->writeln($message); + } + private function checkDbUpdate(): void { try { diff --git a/module/CLI/src/ConfigProvider.php b/module/CLI/src/ConfigProvider.php index 40dcf775..8155b68b 100644 --- a/module/CLI/src/ConfigProvider.php +++ b/module/CLI/src/ConfigProvider.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI; -use function Shlinkio\Shlink\Common\loadConfigFromGlob; +use function Shlinkio\Shlink\Config\loadConfigFromGlob; class ConfigProvider { diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index c88e28fa..d890f264 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -18,6 +18,7 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; class CreateDatabaseCommandTest extends TestCase { @@ -114,7 +115,8 @@ class CreateDatabaseCommandTest extends TestCase '/usr/local/bin/php', CreateDatabaseCommand::DOCTRINE_SCRIPT, CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND, - ], Argument::cetera()); + '--no-interaction', + ], Argument::cetera())->willReturn(new Process([])); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index 15f756a7..71587eea 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; class MigrateDatabaseCommandTest extends TestCase { @@ -53,7 +54,8 @@ class MigrateDatabaseCommandTest extends TestCase '/usr/local/bin/php', MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT, MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND, - ], Argument::cetera()); + '--no-interaction', + ], Argument::cetera())->willReturn(new Process([])); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index df1019b1..bcf00acb 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -31,7 +31,7 @@ class GenerateShortUrlCommandTest extends TestCase public function setUp(): void { $this->urlShortener = $this->prophesize(UrlShortener::class); - $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG); + $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5); $app = new Application(); $app->add($command); $this->commandTester = new CommandTester($command); diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 90073f10..803ae472 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -15,18 +15,21 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Service\VisitService; +use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface; +use Shlinkio\Shlink\Core\Visit\VisitLocator; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock; -use function array_shift; use function sprintf; +use const PHP_EOL; + class LocateVisitsCommandTest extends TestCase { private CommandTester $commandTester; @@ -38,7 +41,7 @@ class LocateVisitsCommandTest extends TestCase public function setUp(): void { - $this->visitService = $this->prophesize(VisitService::class); + $this->visitService = $this->prophesize(VisitLocator::class); $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class); $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); @@ -61,31 +64,53 @@ class LocateVisitsCommandTest extends TestCase $this->commandTester = new CommandTester($command); } - /** @test */ - public function allPendingVisitsAreProcessed(): void - { + /** + * @test + * @dataProvider provideArgs + */ + public function expectedSetOfVisitsIsProcessedBasedOnArgs( + int $expectedUnlocatedCalls, + int $expectedEmptyCalls, + int $expectedAllCalls, + bool $expectWarningPrint, + array $args + ): void { $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4')); $location = new VisitLocation(Location::emptyInstance()); + $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); - $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( - function (array $args) use ($visit, $location): void { - $firstCallback = array_shift($args); - $firstCallback($visit); - - $secondCallback = array_shift($args); - $secondCallback($location, $visit); - }, + $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior); + $locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will( + $mockMethodBehavior, ); + $locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior); $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn( Location::emptyInstance(), ); - $this->commandTester->execute([]); + $this->commandTester->setInputs(['y']); + $this->commandTester->execute($args); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Processing IP 1.2.3.0', $output); - $locateVisits->shouldHaveBeenCalledOnce(); - $resolveIpLocation->shouldHaveBeenCalledOnce(); + if ($expectWarningPrint) { + $this->assertStringContainsString('Continue at your own risk', $output); + } else { + $this->assertStringNotContainsString('Continue at your own risk', $output); + } + $locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls); + $locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls); + $locateAllVisits->shouldHaveBeenCalledTimes($expectedAllCalls); + $resolveIpLocation->shouldHaveBeenCalledTimes( + $expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls, + ); + } + + public function provideArgs(): iterable + { + yield 'no args' => [1, 0, 0, false, []]; + yield 'retry' => [1, 1, 0, false, ['--retry' => true]]; + yield 'all' => [0, 0, 1, true, ['--retry' => true, '--all' => true]]; } /** @@ -98,13 +123,7 @@ class LocateVisitsCommandTest extends TestCase $location = new VisitLocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( - function (array $args) use ($visit, $location): void { - $firstCallback = array_shift($args); - $firstCallback($visit); - - $secondCallback = array_shift($args); - $secondCallback($location, $visit); - }, + $this->invokeHelperMethods($visit, $location), ); $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn( Location::emptyInstance(), @@ -137,13 +156,7 @@ class LocateVisitsCommandTest extends TestCase $location = new VisitLocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( - function (array $args) use ($visit, $location): void { - $firstCallback = array_shift($args); - $firstCallback($visit); - - $secondCallback = array_shift($args); - $secondCallback($location, $visit); - }, + $this->invokeHelperMethods($visit, $location), ); $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class); @@ -156,6 +169,17 @@ class LocateVisitsCommandTest extends TestCase $resolveIpLocation->shouldHaveBeenCalledOnce(); } + private function invokeHelperMethods(Visit $visit, VisitLocation $location): callable + { + return function (array $args) use ($visit, $location): void { + /** @var VisitGeolocationHelperInterface $helper */ + [$helper] = $args; + + $helper->geolocateVisit($visit); + $helper->onVisitLocated($location, $visit); + }; + } + /** @test */ public function noActionIsPerformedIfLockIsAcquired(): void { @@ -212,4 +236,33 @@ class LocateVisitsCommandTest extends TestCase yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.']; yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.']; } + + /** @test */ + public function providingAllFlagOnItsOwnDisplaysNotice(): void + { + $this->commandTester->execute(['--all' => true]); + $output = $this->commandTester->getDisplay(); + + $this->assertStringContainsString('The --all flag has no effect on its own', $output); + } + + /** + * @test + * @dataProvider provideAbortInputs + */ + public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Execution aborted'); + + $this->commandTester->setInputs($inputs); + $this->commandTester->execute(['--all' => true, '--retry' => true]); + } + + public function provideAbortInputs(): iterable + { + yield 'n' => [['n']]; + yield 'no' => [['no']]; + yield 'default' => [[PHP_EOL]]; + } } diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 9809c5dd..13a74c36 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -9,6 +9,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Mezzio\Router\RouterInterface; use Mezzio\Template\TemplateRendererInterface; use Psr\EventDispatcher\EventDispatcherInterface; +use Shlinkio\Shlink\Core\Domain\Resolver; use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; @@ -27,7 +28,7 @@ return [ Service\UrlShortener::class => ConfigAbstractFactory::class, Service\VisitsTracker::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class, - Service\VisitService::class => ConfigAbstractFactory::class, + Visit\VisitLocator::class => ConfigAbstractFactory::class, Service\Tag\TagService::class => ConfigAbstractFactory::class, Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, @@ -39,6 +40,8 @@ return [ Action\QrCodeAction::class => ConfigAbstractFactory::class, Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class, + + Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class, ], ], @@ -51,10 +54,10 @@ return [ Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'], Options\UrlShortenerOptions::class => ['config.url_shortener'], - Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Options\UrlShortenerOptions::class], + Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class], Service\VisitsTracker::class => ['em', EventDispatcherInterface::class], - Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class], - Service\VisitService::class => ['em'], + Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class], + Visit\VisitLocator::class => ['em'], Service\Tag\TagService::class => ['em'], Service\ShortUrl\DeleteShortUrlService::class => [ 'em', @@ -63,7 +66,7 @@ return [ ], Service\ShortUrl\ShortUrlResolver::class => ['em'], - Util\UrlValidator::class => ['httpClient'], + Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, @@ -84,6 +87,8 @@ return [ ], Middleware\QrCodeCacheMiddleware::class => [Cache::class], + + Resolver\PersistenceDomainResolver::class => ['em'], ], ]; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php index fde00abc..955fa1fa 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php @@ -44,4 +44,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('lon') ->nullable(false) ->build(); + + $builder->createField('isEmpty', Types::BOOLEAN) + ->columnName('is_empty') + ->option('default', false) + ->nullable(false) + ->build(); }; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 7ab5ebbb..3016b18c 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -10,7 +10,11 @@ use PUGX\Shortid\Factory as ShortIdFactory; use function sprintf; -function generateRandomShortCode(int $length = 5): string +const DEFAULT_SHORT_CODES_LENGTH = 5; +const MIN_SHORT_CODES_LENGTH = 4; +const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; + +function generateRandomShortCode(int $length): string { static $shortIdFactory; if ($shortIdFactory === null) { diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php index fa7a4acb..ee29d195 100644 --- a/module/Core/src/Config/SimplifiedConfigParser.php +++ b/module/Core/src/Config/SimplifiedConfigParser.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; use Laminas\Stdlib\ArrayUtils; -use Shlinkio\Shlink\Installer\Util\PathCollection; +use Shlinkio\Shlink\Config\Collection\PathCollection; use function array_flip; use function array_intersect_key; @@ -24,7 +24,7 @@ class SimplifiedConfigParser 'validate_url' => ['url_shortener', 'validate_url'], 'invalid_short_url_redirect_to' => ['not_found_redirects', 'invalid_short_url'], 'regular_404_redirect_to' => ['not_found_redirects', 'regular_404'], - 'base_url_redirect_to' => ['not_found_redirects', 'base_path'], + 'base_url_redirect_to' => ['not_found_redirects', 'base_url'], 'db_config' => ['entity_manager', 'connection'], 'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'], 'redis_servers' => ['cache', 'redis', 'servers'], @@ -32,6 +32,7 @@ class SimplifiedConfigParser 'web_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'worker_num'], 'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'], 'visits_webhooks' => ['url_shortener', 'visits_webhooks'], + 'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'], ]; private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [ 'delete_short_url_threshold' => [ diff --git a/module/Core/src/ConfigProvider.php b/module/Core/src/ConfigProvider.php index 086d093d..2c130ea9 100644 --- a/module/Core/src/ConfigProvider.php +++ b/module/Core/src/ConfigProvider.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; -use function Shlinkio\Shlink\Common\loadConfigFromGlob; +use function Shlinkio\Shlink\Config\loadConfigFromGlob; class ConfigProvider { diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 98d6a146..5453d791 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface; use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; +use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use function array_reduce; @@ -32,8 +33,9 @@ class ShortUrl extends AbstractEntity private ?Chronos $validSince = null; private ?Chronos $validUntil = null; private ?int $maxVisits = null; - private ?Domain $domain; + private ?Domain $domain = null; private bool $customSlugWasProvided; + private int $shortCodeLength; public function __construct( string $longUrl, @@ -50,7 +52,8 @@ class ShortUrl extends AbstractEntity $this->validUntil = $meta->getValidUntil(); $this->maxVisits = $meta->getMaxVisits(); $this->customSlugWasProvided = $meta->hasCustomSlug(); - $this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode(); + $this->shortCodeLength = $meta->getShortCodeLength(); + $this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength); $this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain()); } @@ -91,16 +94,19 @@ class ShortUrl extends AbstractEntity return $this; } - public function updateMeta(ShortUrlMeta $shortCodeMeta): void + public function update(ShortUrlEdit $shortUrlEdit): void { - if ($shortCodeMeta->hasValidSince()) { - $this->validSince = $shortCodeMeta->getValidSince(); + if ($shortUrlEdit->hasValidSince()) { + $this->validSince = $shortUrlEdit->validSince(); } - if ($shortCodeMeta->hasValidUntil()) { - $this->validUntil = $shortCodeMeta->getValidUntil(); + if ($shortUrlEdit->hasValidUntil()) { + $this->validUntil = $shortUrlEdit->validUntil(); } - if ($shortCodeMeta->hasMaxVisits()) { - $this->maxVisits = $shortCodeMeta->getMaxVisits(); + if ($shortUrlEdit->hasMaxVisits()) { + $this->maxVisits = $shortUrlEdit->maxVisits(); + } + if ($shortUrlEdit->hasLongUrl()) { + $this->longUrl = $shortUrlEdit->longUrl(); } } @@ -119,7 +125,7 @@ class ShortUrl extends AbstractEntity throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted(); } - $this->shortCode = generateRandomShortCode(); + $this->shortCode = generateRandomShortCode($this->shortCodeLength); return $this; } diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index d278ed6a..e8cbb119 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -10,7 +10,6 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation; use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; class Visit extends AbstractEntity implements JsonSerializable @@ -60,9 +59,9 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->shortUrl; } - public function getVisitLocation(): VisitLocationInterface + public function getVisitLocation(): ?VisitLocationInterface { - return $this->visitLocation ?? new UnknownVisitLocation(); + return $this->visitLocation; } public function isLocatable(): bool diff --git a/module/Core/src/Entity/VisitLocation.php b/module/Core/src/Entity/VisitLocation.php index 641b078e..ef545bba 100644 --- a/module/Core/src/Entity/VisitLocation.php +++ b/module/Core/src/Entity/VisitLocation.php @@ -17,6 +17,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface private float $latitude; private float $longitude; private string $timezone; + private bool $isEmpty; public function __construct(Location $location) { @@ -43,6 +44,11 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface return $this->cityName; } + public function isEmpty(): bool + { + return $this->isEmpty; + } + private function exchangeLocationInfo(Location $info): void { $this->countryCode = $info->countryCode(); @@ -52,6 +58,15 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface $this->latitude = $info->latitude(); $this->longitude = $info->longitude(); $this->timezone = $info->timeZone(); + $this->isEmpty = ( + $this->countryCode === '' && + $this->countryName === '' && + $this->regionName === '' && + $this->cityName === '' && + $this->latitude === 0.0 && + $this->longitude === 0.0 && + $this->timezone === '' + ); } public function jsonSerialize(): array @@ -64,18 +79,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface 'latitude' => $this->latitude, 'longitude' => $this->longitude, 'timezone' => $this->timezone, + 'isEmpty' => $this->isEmpty, ]; } - - public function isEmpty(): bool - { - return - $this->countryCode === '' && - $this->countryName === '' && - $this->regionName === '' && - $this->cityName === '' && - $this->latitude === 0.0 && - $this->longitude === 0.0 && - $this->timezone === ''; - } } diff --git a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php index a0f8d033..6abbe02b 100644 --- a/module/Core/src/EventDispatcher/LocateShortUrlVisit.php +++ b/module/Core/src/EventDispatcher/LocateShortUrlVisit.php @@ -53,7 +53,7 @@ class LocateShortUrlVisit } if ($this->downloadOrUpdateGeoLiteDb($visitId)) { - $this->locateVisit($visitId, $visit); + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); } $this->eventDispatcher->dispatch(new VisitLocated($visitId)); @@ -80,12 +80,13 @@ class LocateShortUrlVisit return true; } - private function locateVisit(string $visitId, Visit $visit): void + private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void { + $isLocatable = $originalIpAddress !== null || $visit->isLocatable(); + $addr = $originalIpAddress ?? $visit->getRemoteAddr(); + try { - $location = $visit->isLocatable() - ? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr()) - : Location::emptyInstance(); + $location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance(); $visit->locate(new VisitLocation($location)); $this->em->flush(); diff --git a/module/Core/src/EventDispatcher/ShortUrlVisited.php b/module/Core/src/EventDispatcher/ShortUrlVisited.php index 1f0b5b5c..c33f805a 100644 --- a/module/Core/src/EventDispatcher/ShortUrlVisited.php +++ b/module/Core/src/EventDispatcher/ShortUrlVisited.php @@ -9,10 +9,12 @@ use JsonSerializable; final class ShortUrlVisited implements JsonSerializable { private string $visitId; + private ?string $originalIpAddress; - public function __construct(string $visitId) + public function __construct(string $visitId, ?string $originalIpAddress = null) { $this->visitId = $visitId; + $this->originalIpAddress = $originalIpAddress; } public function visitId(): string @@ -20,8 +22,13 @@ final class ShortUrlVisited implements JsonSerializable return $this->visitId; } + public function originalIpAddress(): ?string + { + return $this->originalIpAddress; + } + public function jsonSerialize(): array { - return ['visitId' => $this->visitId]; + return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; } } diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php new file mode 100644 index 00000000..2f3f6919 --- /dev/null +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -0,0 +1,106 @@ +validateAndInit($data); + return $instance; + } + + /** + * @throws ValidationException + */ + private function validateAndInit(array $data): void + { + $inputFilter = new ShortUrlMetaInputFilter($data); + if (! $inputFilter->isValid()) { + throw ValidationException::fromInputFilter($inputFilter); + } + + $this->longUrlPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::LONG_URL, $data); + $this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data); + $this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data); + $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data); + + $this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL); + $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE)); + $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL)); + $this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS); + } + + private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int + { + $value = $inputFilter->getValue($fieldName); + return $value !== null ? (int) $value : null; + } + + public function longUrl(): ?string + { + return $this->longUrl; + } + + public function hasLongUrl(): bool + { + return $this->longUrlPropWasProvided && $this->longUrl !== null; + } + + public function validSince(): ?Chronos + { + return $this->validSince; + } + + public function hasValidSince(): bool + { + return $this->validSincePropWasProvided; + } + + public function validUntil(): ?Chronos + { + return $this->validUntil; + } + + public function hasValidUntil(): bool + { + return $this->validUntilPropWasProvided; + } + + public function maxVisits(): ?int + { + return $this->maxVisits; + } + + public function hasMaxVisits(): bool + { + return $this->maxVisitsPropWasProvided; + } +} diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 27c8e624..76f6d80b 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -8,22 +8,21 @@ use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; -use function array_key_exists; use function Shlinkio\Shlink\Core\parseDateField; +use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH; + final class ShortUrlMeta { - private bool $validSincePropWasProvided = false; private ?Chronos $validSince = null; - private bool $validUntilPropWasProvided = false; private ?Chronos $validUntil = null; private ?string $customSlug = null; - private bool $maxVisitsPropWasProvided = false; private ?int $maxVisits = null; private ?bool $findIfExists = null; private ?string $domain = null; + private int $shortCodeLength = 5; - // Force named constructors + // Enforce named constructors private function __construct() { } @@ -54,15 +53,21 @@ final class ShortUrlMeta } $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE)); - $this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data); $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL)); - $this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data); $this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG); - $maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS); - $this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null; - $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data); + $this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS); $this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS); $this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN); + $this->shortCodeLength = $this->getOptionalIntFromInputFilter( + $inputFilter, + ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, + ) ?? DEFAULT_SHORT_CODES_LENGTH; + } + + private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int + { + $value = $inputFilter->getValue($fieldName); + return $value !== null ? (int) $value : null; } public function getValidSince(): ?Chronos @@ -72,7 +77,7 @@ final class ShortUrlMeta public function hasValidSince(): bool { - return $this->validSincePropWasProvided; + return $this->validSince !== null; } public function getValidUntil(): ?Chronos @@ -82,7 +87,7 @@ final class ShortUrlMeta public function hasValidUntil(): bool { - return $this->validUntilPropWasProvided; + return $this->validUntil !== null; } public function getCustomSlug(): ?string @@ -102,7 +107,7 @@ final class ShortUrlMeta public function hasMaxVisits(): bool { - return $this->maxVisitsPropWasProvided; + return $this->maxVisits !== null; } public function findIfExists(): bool @@ -119,4 +124,9 @@ final class ShortUrlMeta { return $this->domain; } + + public function getShortCodeLength(): int + { + return $this->shortCodeLength; + } } diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php index aa51b871..66d76126 100644 --- a/module/Core/src/Options/AppOptions.php +++ b/module/Core/src/Options/AppOptions.php @@ -5,14 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; use Laminas\Stdlib\AbstractOptions; -use Shlinkio\Shlink\Common\Util\StringUtilsTrait; use function sprintf; class AppOptions extends AbstractOptions { - use StringUtilsTrait; - private string $name = ''; private string $version = '1.0'; private ?string $disableTrackParam = null; diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 454323ef..61b2afb8 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -12,33 +12,63 @@ use Shlinkio\Shlink\Core\Entity\Visit; class VisitRepository extends EntityRepository implements VisitRepositoryInterface { /** - * This method will allow you to iterate the whole list of unlocated visits, but loading them into memory in - * smaller blocks of a specific size. - * This will have side effects if you update those rows while you iterate them. - * If you plan to do so, pass the first argument as false in order to disable applying offsets while slicing the - * dataset - * * @return iterable|Visit[] */ - public function findUnlocatedVisits(bool $applyOffset = true, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable { - $dql = <<getEntityManager()->createQuery($dql) - ->setMaxResults($blockSize); - $remainingVisitsToProcess = $this->count(['visitLocation' => null]); - $offset = 0; + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('v') + ->from(Visit::class, 'v') + ->where($qb->expr()->isNull('v.visitLocation')); - while ($remainingVisitsToProcess > 0) { - $iterator = $query->setFirstResult($applyOffset ? $offset : null)->iterate(); - foreach ($iterator as $key => [$value]) { - yield $key => $value; + return $this->findVisitsForQuery($qb, $blockSize); + } + + /** + * @return iterable|Visit[] + */ + public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('v') + ->from(Visit::class, 'v') + ->join('v.visitLocation', 'vl') + ->where($qb->expr()->isNotNull('v.visitLocation')) + ->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty')) + ->setParameter('isEmpty', true); + + return $this->findVisitsForQuery($qb, $blockSize); + } + + public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('v') + ->from(Visit::class, 'v'); + + return $this->findVisitsForQuery($qb, $blockSize); + } + + private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable + { + $originalQueryBuilder = $qb->setMaxResults($blockSize) + ->orderBy('v.id', 'ASC'); + $lastId = '0'; + + do { + $qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId)); + $iterator = $qb->getQuery()->iterate(); + $resultsFound = false; + + /** @var Visit $visit */ + foreach ($iterator as $key => [$visit]) { + $resultsFound = true; + yield $key => $visit; } - $remainingVisitsToProcess -= $blockSize; - $offset += $blockSize; - } + // As the query is ordered by ID, we can take the last one every time in order to exclude the whole list + $lastId = isset($visit) ? $visit->getId() : $lastId; + } while ($resultsFound); } /** diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 0d0b66d0..f9cbc8d9 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -13,15 +13,19 @@ interface VisitRepositoryInterface extends ObjectRepository public const DEFAULT_BLOCK_SIZE = 10000; /** - * This method will allow you to iterate the whole list of unlocated visits, but loading them into memory in - * smaller blocks of a specific size. - * This will have side effects if you update those rows while you iterate them. - * If you plan to do so, pass the first argument as false in order to disable applying offsets while slicing the - * dataset - * * @return iterable|Visit[] */ - public function findUnlocatedVisits(bool $applyOffset = true, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; + public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; + + /** + * @return iterable|Visit[] + */ + public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; + + /** + * @return iterable|Visit[] + */ + public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; /** * @return Visit[] diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index e9aaf637..5cdab93d 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -7,14 +7,16 @@ namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM; use Laminas\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\TagManagerTrait; +use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class ShortUrlService implements ShortUrlServiceInterface { @@ -22,11 +24,16 @@ class ShortUrlService implements ShortUrlServiceInterface private ORM\EntityManagerInterface $em; private ShortUrlResolverInterface $urlResolver; + private UrlValidatorInterface $urlValidator; - public function __construct(ORM\EntityManagerInterface $em, ShortUrlResolverInterface $urlResolver) - { + public function __construct( + ORM\EntityManagerInterface $em, + ShortUrlResolverInterface $urlResolver, + UrlValidatorInterface $urlValidator + ) { $this->em = $em; $this->urlResolver = $urlResolver; + $this->urlValidator = $urlValidator; } /** @@ -59,11 +66,16 @@ class ShortUrlService implements ShortUrlServiceInterface /** * @throws ShortUrlNotFoundException + * @throws InvalidUrlException */ - public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl + public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl { + if ($shortUrlEdit->hasLongUrl()) { + $this->urlValidator->validateUrl($shortUrlEdit->longUrl()); + } + $shortUrl = $this->urlResolver->resolveShortUrl($identifier); - $shortUrl->updateMeta($shortUrlMeta); + $shortUrl->update($shortUrlEdit); $this->em->flush(); diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 379abc55..3c09e7e9 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\Core\Service; use Laminas\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; interface ShortUrlServiceInterface @@ -26,6 +27,7 @@ interface ShortUrlServiceInterface /** * @throws ShortUrlNotFoundException + * @throws InvalidUrlException */ - public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl; + public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl; } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index ab45143a..4544bfc0 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -6,12 +6,11 @@ namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM\EntityManagerInterface; use Psr\Http\Message\UriInterface; -use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver; +use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; @@ -24,17 +23,17 @@ class UrlShortener implements UrlShortenerInterface use TagManagerTrait; private EntityManagerInterface $em; - private UrlShortenerOptions $options; private UrlValidatorInterface $urlValidator; + private DomainResolverInterface $domainResolver; public function __construct( UrlValidatorInterface $urlValidator, EntityManagerInterface $em, - UrlShortenerOptions $options + DomainResolverInterface $domainResolver ) { $this->urlValidator = $urlValidator; $this->em = $em; - $this->options = $options; + $this->domainResolver = $domainResolver; } /** @@ -53,13 +52,9 @@ class UrlShortener implements UrlShortenerInterface return $existingShortUrl; } - // If the URL validation is enabled, check that the URL actually exists - if ($this->options->isUrlValidationEnabled()) { - $this->urlValidator->validateUrl($url); - } - + $this->urlValidator->validateUrl($url); $this->em->beginTransaction(); - $shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em)); + $shortUrl = new ShortUrl($url, $meta, $this->domainResolver); $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); try { diff --git a/module/Core/src/Service/VisitService.php b/module/Core/src/Service/VisitService.php deleted file mode 100644 index 20a30f4c..00000000 --- a/module/Core/src/Service/VisitService.php +++ /dev/null @@ -1,70 +0,0 @@ -em = $em; - } - - public function locateUnlocatedVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void - { - /** @var VisitRepository $repo */ - $repo = $this->em->getRepository(Visit::class); - $results = $repo->findUnlocatedVisits(false); - $count = 0; - $persistBlock = 200; - - foreach ($results as $visit) { - $count++; - - try { - /** @var Location $location */ - $location = $geolocateVisit($visit); - } catch (IpCannotBeLocatedException $e) { - if (! $e->isNonLocatableAddress()) { - // Skip if the visit's IP could not be located because of an error - continue; - } - - // If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again - $location = Location::emptyInstance(); - } - - $location = new VisitLocation($location); - $this->locateVisit($visit, $location, $notifyVisitWithLocation); - - // Flush and clear after X iterations - if ($count % $persistBlock === 0) { - $this->em->flush(); - $this->em->clear(); - } - } - - $this->em->flush(); - $this->em->clear(); - } - - private function locateVisit(Visit $visit, VisitLocation $location, ?callable $notifyVisitWithLocation): void - { - $visit->locate($location); - $this->em->persist($visit); - - if ($notifyVisitWithLocation !== null) { - $notifyVisitWithLocation($location, $visit); - } - } -} diff --git a/module/Core/src/Service/VisitServiceInterface.php b/module/Core/src/Service/VisitServiceInterface.php deleted file mode 100644 index 78543549..00000000 --- a/module/Core/src/Service/VisitServiceInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId())); + $this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress())); } /** diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index dca037cd..8d8cd072 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -9,16 +9,19 @@ use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; class UrlValidator implements UrlValidatorInterface, RequestMethodInterface { private const MAX_REDIRECTS = 15; private ClientInterface $httpClient; + private UrlShortenerOptions $options; - public function __construct(ClientInterface $httpClient) + public function __construct(ClientInterface $httpClient, UrlShortenerOptions $options) { $this->httpClient = $httpClient; + $this->options = $options; } /** @@ -26,6 +29,11 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface */ public function validateUrl(string $url): void { + // If the URL validation is not enabled, skip check + if (! $this->options->isUrlValidationEnabled()) { + return; + } + try { $this->httpClient->request(self::METHOD_GET, $url, [ RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php index 187ec66f..8fde5e98 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Validation; use DateTime; +use Laminas\InputFilter\Input; use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; +use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH; + class ShortUrlMetaInputFilter extends InputFilter { use Validation\InputFactoryTrait; @@ -19,6 +22,8 @@ class ShortUrlMetaInputFilter extends InputFilter public const MAX_VISITS = 'maxVisits'; public const FIND_IF_EXISTS = 'findIfExists'; public const DOMAIN = 'domain'; + public const SHORT_CODE_LENGTH = 'shortCodeLength'; + public const LONG_URL = 'longUrl'; public function __construct(array $data) { @@ -28,6 +33,8 @@ class ShortUrlMetaInputFilter extends InputFilter private function initialize(): void { + $this->add($this->createInput(self::LONG_URL, false)); + $validSince = $this->createInput(self::VALID_SINCE, false); $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); $this->add($validSince); @@ -36,14 +43,18 @@ class ShortUrlMetaInputFilter extends InputFilter $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); $this->add($validUntil); - $customSlug = $this->createInput(self::CUSTOM_SLUG, false); + // FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's + // empty, is by using the deprecated setContinueIfEmpty + $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new Validation\SluggerFilter()); + $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ + Validator\NotEmpty::STRING, + Validator\NotEmpty::SPACE, + ])); $this->add($customSlug); - $maxVisits = $this->createInput(self::MAX_VISITS, false); - $maxVisits->getValidatorChain()->attach(new Validator\Digits()) - ->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true])); - $this->add($maxVisits); + $this->add($this->createPositiveNumberInput(self::MAX_VISITS)); + $this->add($this->createPositiveNumberInput(self::SHORT_CODE_LENGTH, MIN_SHORT_CODES_LENGTH)); $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false)); @@ -51,4 +62,13 @@ class ShortUrlMetaInputFilter extends InputFilter $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $this->add($domain); } + + private function createPositiveNumberInput(string $name, int $min = 1): Input + { + $input = $this->createInput($name, false); + $input->getValidatorChain()->attach(new Validator\Digits()) + ->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true])); + + return $input; + } } diff --git a/module/Core/src/Visit/VisitGeolocationHelperInterface.php b/module/Core/src/Visit/VisitGeolocationHelperInterface.php new file mode 100644 index 00000000..95cca4a7 --- /dev/null +++ b/module/Core/src/Visit/VisitGeolocationHelperInterface.php @@ -0,0 +1,20 @@ +em = $em; + + /** @var VisitRepositoryInterface $repo */ + $repo = $em->getRepository(Visit::class); + $this->repo = $repo; + } + + public function locateUnlocatedVisits(VisitGeolocationHelperInterface $helper): void + { + $this->locateVisits($this->repo->findUnlocatedVisits(), $helper); + } + + public function locateVisitsWithEmptyLocation(VisitGeolocationHelperInterface $helper): void + { + $this->locateVisits($this->repo->findVisitsWithEmptyLocation(), $helper); + } + + public function locateAllVisits(VisitGeolocationHelperInterface $helper): void + { + $this->locateVisits($this->repo->findAllVisits(), $helper); + } + + /** + * @param iterable|Visit[] $results + */ + private function locateVisits(iterable $results, VisitGeolocationHelperInterface $helper): void + { + $count = 0; + $persistBlock = 200; + + foreach ($results as $visit) { + $count++; + + try { + $location = $helper->geolocateVisit($visit); + } catch (IpCannotBeLocatedException $e) { + if (! $e->isNonLocatableAddress()) { + // Skip if the visit's IP could not be located because of an error + continue; + } + + // If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again + $location = Location::emptyInstance(); + } + + $location = new VisitLocation($location); + $this->locateVisit($visit, $location, $helper); + + // Flush and clear after X iterations + if ($count % $persistBlock === 0) { + $this->em->flush(); + $this->em->clear(); + } + } + + $this->em->flush(); + $this->em->clear(); + } + + private function locateVisit(Visit $visit, VisitLocation $location, VisitGeolocationHelperInterface $helper): void + { + $prevLocation = $visit->getVisitLocation(); + + $visit->locate($location); + $this->em->persist($visit); + + // In order to avoid leaving orphan locations, remove the previous one + if ($prevLocation !== null) { + $this->em->remove($prevLocation); + } + + $helper->onVisitLocated($location, $visit); + } +} diff --git a/module/Core/src/Visit/VisitLocatorInterface.php b/module/Core/src/Visit/VisitLocatorInterface.php new file mode 100644 index 00000000..1c99de36 --- /dev/null +++ b/module/Core/src/Visit/VisitLocatorInterface.php @@ -0,0 +1,14 @@ +getEntityManager()->persist($shortUrl); + $countIterable = function (iterable $results): int { + $resultsCount = 0; + foreach ($results as $value) { + $resultsCount++; + } + + return $resultsCount; + }; for ($i = 0; $i < 6; $i++) { $visit = new Visit($shortUrl, Visitor::emptyInstance()); - if ($i % 2 === 0) { + if ($i >= 2) { $location = new VisitLocation(Location::emptyInstance()); $this->getEntityManager()->persist($location); $visit->locate($location); @@ -58,18 +66,20 @@ class VisitRepositoryTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - $resultsCount = 0; - $results = $this->repo->findUnlocatedVisits(true, $blockSize); - foreach ($results as $value) { - $resultsCount++; - } + $withEmptyLocation = $this->repo->findVisitsWithEmptyLocation($blockSize); + $unlocated = $this->repo->findUnlocatedVisits($blockSize); + $all = $this->repo->findAllVisits($blockSize); - $this->assertEquals(3, $resultsCount); + // Important! assertCount will not work here, as this iterable object loads data dynamically and the count + // is 0 if not iterated + $this->assertEquals(2, $countIterable($unlocated)); + $this->assertEquals(4, $countIterable($withEmptyLocation)); + $this->assertEquals(6, $countIterable($all)); } public function provideBlockSize(): iterable { - return map(range(1, 5), fn (int $value) => [$value]); + return map(range(1, 10), fn (int $value) => [$value]); } /** @test */ diff --git a/module/Core/test/Config/SimplifiedConfigParserTest.php b/module/Core/test/Config/SimplifiedConfigParserTest.php index 1d4f3b8d..02f96423 100644 --- a/module/Core/test/Config/SimplifiedConfigParserTest.php +++ b/module/Core/test/Config/SimplifiedConfigParserTest.php @@ -41,6 +41,8 @@ class SimplifiedConfigParserTest extends TestCase 'validate_url' => true, 'delete_short_url_threshold' => 50, 'invalid_short_url_redirect_to' => 'foobar.com', + 'regular_404_redirect_to' => 'bar.com', + 'base_url_redirect_to' => 'foo.com', 'redis_servers' => [ 'tcp://1.1.1.1:1111', 'tcp://1.2.2.2:2222', @@ -57,6 +59,7 @@ class SimplifiedConfigParserTest extends TestCase 'http://my-api.com/api/v2.3/notify', 'https://third-party.io/foo', ], + 'default_short_codes_length' => 8, ]; $expected = [ 'app_options' => [ @@ -84,6 +87,7 @@ class SimplifiedConfigParserTest extends TestCase 'http://my-api.com/api/v2.3/notify', 'https://third-party.io/foo', ], + 'default_short_codes_length' => 8, ], 'delete_short_urls' => [ @@ -112,6 +116,8 @@ class SimplifiedConfigParserTest extends TestCase 'not_found_redirects' => [ 'invalid_short_url' => 'foobar.com', + 'regular_404' => 'bar.com', + 'base_url' => 'foo.com', ], 'mezzio-swoole' => [ diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index 9aba83fa..e410dedb 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -8,6 +8,13 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; + +use function Functional\map; +use function range; +use function strlen; + +use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH; class ShortUrlTest extends TestCase { @@ -48,4 +55,23 @@ class ShortUrlTest extends TestCase $this->assertNotEquals($firstShortCode, $secondShortCode); } + + /** + * @test + * @dataProvider provideLengths + */ + public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void + { + $shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData( + [ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length], + )); + + $this->assertEquals($expectedLength, strlen($shortUrl->getShortCode())); + } + + public function provideLengths(): iterable + { + yield [null, DEFAULT_SHORT_CODES_LENGTH]; + yield from map(range(4, 10), fn (int $value) => [$value, $value]); + } } diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index 5f40bb7b..087c0e0b 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -20,7 +20,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit; use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; @@ -130,13 +129,16 @@ class LocateShortUrlVisitTest extends TestCase yield 'localhost' => [new Visit($shortUrl, new Visitor('', '', IpAddress::LOCALHOST))]; } - /** @test */ - public function locatableVisitsResolveToLocation(): void + /** + * @test + * @dataProvider provideIpAddresses + */ + public function locatableVisitsResolveToLocation(string $anonymizedIpAddress, ?string $originalIpAddress): void { - $ipAddr = '1.2.3.0'; + $ipAddr = $originalIpAddress ?? $anonymizedIpAddress; $visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr)); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new ShortUrlVisited('123'); + $event = new ShortUrlVisited('123', $originalIpAddress); $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); $flush = $this->em->flush()->will(function (): void { @@ -155,6 +157,12 @@ class LocateShortUrlVisitTest extends TestCase $dispatch->shouldHaveBeenCalledOnce(); } + public function provideIpAddresses(): iterable + { + yield 'no original IP address' => ['1.2.3.0', null]; + yield 'original IP address' => ['1.2.3.0', '1.2.3.4']; + } + /** @test */ public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void { @@ -209,7 +217,7 @@ class LocateShortUrlVisitTest extends TestCase ($this->locateVisit)($event); - $this->assertEquals($visit->getVisitLocation(), new UnknownVisitLocation()); + $this->assertNull($visit->getVisitLocation()); $findVisit->shouldHaveBeenCalledOnce(); $flush->shouldNotHaveBeenCalled(); $resolveIp->shouldNotHaveBeenCalled(); diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index 13c5ae14..7d0dd9b6 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -44,6 +44,18 @@ class ShortUrlMetaTest extends TestCase ShortUrlMetaInputFilter::VALID_UNTIL => 500, ShortUrlMetaInputFilter::DOMAIN => 4, ]]; + yield [[ + ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 3, + ]]; + yield [[ + ShortUrlMetaInputFilter::CUSTOM_SLUG => '/', + ]]; + yield [[ + ShortUrlMetaInputFilter::CUSTOM_SLUG => '', + ]]; + yield [[ + ShortUrlMetaInputFilter::CUSTOM_SLUG => ' ', + ]]; } /** @test */ diff --git a/module/Core/test/Model/VisitorTest.php b/module/Core/test/Model/VisitorTest.php index 9aa928a8..0a0c1828 100644 --- a/module/Core/test/Model/VisitorTest.php +++ b/module/Core/test/Model/VisitorTest.php @@ -5,16 +5,15 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Model; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Common\Util\StringUtilsTrait; use Shlinkio\Shlink\Core\Model\Visitor; +use function random_int; use function str_repeat; +use function strlen; use function substr; class VisitorTest extends TestCase { - use StringUtilsTrait; - /** * @test * @dataProvider provideParams @@ -60,4 +59,15 @@ class VisitorTest extends TestCase ], ]; } + + private function generateRandomString(int $length): string + { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $randomString = ''; + for ($i = 0; $i < $length; $i++) { + $randomString .= $characters[random_int(0, $charactersLength - 1)]; + } + return $randomString; + } } diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 842eac60..9becdf8b 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -12,12 +12,13 @@ use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrlService; +use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use function count; @@ -26,6 +27,7 @@ class ShortUrlServiceTest extends TestCase private ShortUrlService $service; private ObjectProphecy $em; private ObjectProphecy $urlResolver; + private ObjectProphecy $urlValidator; public function setUp(): void { @@ -34,8 +36,13 @@ class ShortUrlServiceTest extends TestCase $this->em->flush()->willReturn(null); $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); + $this->urlValidator = $this->prophesize(UrlValidatorInterface::class); - $this->service = new ShortUrlService($this->em->reveal(), $this->urlResolver->reveal()); + $this->service = new ShortUrlService( + $this->em->reveal(), + $this->urlResolver->reveal(), + $this->urlValidator->reveal(), + ); } /** @test */ @@ -74,27 +81,47 @@ class ShortUrlServiceTest extends TestCase $this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar']); } - /** @test */ - public function updateMetadataByShortCodeUpdatesProvidedData(): void - { - $shortUrl = new ShortUrl(''); + /** + * @test + * @dataProvider provideShortUrlEdits + */ + public function updateMetadataByShortCodeUpdatesProvidedData( + int $expectedValidateCalls, + ShortUrlEdit $shortUrlEdit + ): void { + $originalLongUrl = 'originalLongUrl'; + $shortUrl = new ShortUrl($originalLongUrl); $findShortUrl = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier('abc123'))->willReturn($shortUrl); $flush = $this->em->flush()->willReturn(null); - $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), ShortUrlMeta::fromRawData( + $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit); + + $this->assertSame($shortUrl, $result); + $this->assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); + $this->assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil()); + $this->assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits()); + $this->assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); + $findShortUrl->shouldHaveBeenCalled(); + $flush->shouldHaveBeenCalled(); + $this->urlValidator->validateUrl($shortUrlEdit->longUrl())->shouldHaveBeenCalledTimes($expectedValidateCalls); + } + + public function provideShortUrlEdits(): iterable + { + yield 'no long URL' => [0, ShortUrlEdit::fromRawData( [ 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(), 'maxVisits' => 5, ], - )); - - $this->assertSame($shortUrl, $result); - $this->assertEquals(Chronos::parse('2017-01-01 00:00:00'), $shortUrl->getValidSince()); - $this->assertEquals(Chronos::parse('2017-01-05 00:00:00'), $shortUrl->getValidUntil()); - $this->assertEquals(5, $shortUrl->getMaxVisits()); - $findShortUrl->shouldHaveBeenCalled(); - $flush->shouldHaveBeenCalled(); + )]; + yield 'long URL' => [1, ShortUrlEdit::fromRawData( + [ + 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), + 'maxVisits' => 10, + 'longUrl' => 'modifiedLongUrl', + ], + )]; } } diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index a0392489..2c67bf27 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -13,11 +13,11 @@ use Laminas\Diactoros\Uri; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; @@ -33,6 +33,10 @@ class UrlShortenerTest extends TestCase public function setUp(): void { $this->urlValidator = $this->prophesize(UrlValidatorInterface::class); + $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will( + function (): void { + }, + ); $this->em = $this->prophesize(EntityManagerInterface::class); $conn = $this->prophesize(Connection::class); @@ -50,15 +54,10 @@ class UrlShortenerTest extends TestCase $repo->shortCodeIsInUse(Argument::cetera())->willReturn(false); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $this->setUrlShortener(false); - } - - private function setUrlShortener(bool $urlValidationEnabled): void - { $this->urlShortener = new UrlShortener( $this->urlValidator->reveal(), $this->em->reveal(), - new UrlShortenerOptions(['validate_url' => $urlValidationEnabled]), + new SimpleDomainResolver(), ); } @@ -119,24 +118,6 @@ class UrlShortenerTest extends TestCase ); } - /** @test */ - public function validatorIsCalledWhenUrlValidationIsEnabled(): void - { - $this->setUrlShortener(true); - $validateUrl = $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will( - function (): void { - }, - ); - - $this->urlShortener->urlToShortCode( - new Uri('http://foobar.com/12345/hello?foo=bar'), - [], - ShortUrlMeta::createEmpty(), - ); - - $validateUrl->shouldHaveBeenCalledOnce(); - } - /** @test */ public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void { @@ -175,6 +156,7 @@ class UrlShortenerTest extends TestCase $findExisting->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->urlValidator->validateUrl(Argument::cetera())->shouldNotHaveBeenCalled(); $this->assertSame($expected, $result); } diff --git a/module/Core/test/Service/VisitServiceTest.php b/module/Core/test/Service/VisitServiceTest.php deleted file mode 100644 index 26e95557..00000000 --- a/module/Core/test/Service/VisitServiceTest.php +++ /dev/null @@ -1,112 +0,0 @@ -em = $this->prophesize(EntityManager::class); - $this->visitService = new VisitService($this->em->reveal()); - } - - /** @test */ - public function locateVisitsIteratesAndLocatesUnlocatedVisits(): void - { - $unlocatedVisits = map( - range(1, 200), - fn (int $i) => new Visit(new ShortUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()), - ); - - $repo = $this->prophesize(VisitRepository::class); - $findUnlocatedVisits = $repo->findUnlocatedVisits(false)->willReturn($unlocatedVisits); - $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); - - $persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void { - }); - $flush = $this->em->flush()->will(function (): void { - }); - $clear = $this->em->clear()->will(function (): void { - }); - - $this->visitService->locateUnlocatedVisits(fn () => Location::emptyInstance(), function (): void { - $args = func_get_args(); - - $this->assertInstanceOf(VisitLocation::class, array_shift($args)); - $this->assertInstanceOf(Visit::class, array_shift($args)); - }); - - $findUnlocatedVisits->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); - $persist->shouldHaveBeenCalledTimes(count($unlocatedVisits)); - $flush->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1); - $clear->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1); - } - - /** - * @test - * @dataProvider provideIsNonLocatableAddress - */ - public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty(bool $isNonLocatableAddress): void - { - $unlocatedVisits = [ - new Visit(new ShortUrl('foo'), Visitor::emptyInstance()), - ]; - - $repo = $this->prophesize(VisitRepository::class); - $findUnlocatedVisits = $repo->findUnlocatedVisits(false)->willReturn($unlocatedVisits); - $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); - - $persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void { - }); - $flush = $this->em->flush()->will(function (): void { - }); - $clear = $this->em->clear()->will(function (): void { - }); - - $this->visitService->locateUnlocatedVisits(function () use ($isNonLocatableAddress): void { - throw $isNonLocatableAddress - ? new IpCannotBeLocatedException('Cannot be located') - : IpCannotBeLocatedException::forError(new Exception('')); - }); - - $findUnlocatedVisits->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); - $persist->shouldHaveBeenCalledTimes($isNonLocatableAddress ? 1 : 0); - $flush->shouldHaveBeenCalledOnce(); - $clear->shouldHaveBeenCalledOnce(); - } - - public function provideIsNonLocatableAddress(): iterable - { - yield 'The address is locatable' => [false]; - yield 'The address is non-locatable' => [true]; - } -} diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index 5f018a05..50b70961 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -13,17 +13,20 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Util\UrlValidator; class UrlValidatorTest extends TestCase { private UrlValidator $urlValidator; private ObjectProphecy $httpClient; + private UrlShortenerOptions $options; public function setUp(): void { $this->httpClient = $this->prophesize(ClientInterface::class); - $this->urlValidator = new UrlValidator($this->httpClient->reveal()); + $this->options = new UrlShortenerOptions(['validate_url' => true]); + $this->urlValidator = new UrlValidator($this->httpClient->reveal(), $this->options); } /** @test */ @@ -52,4 +55,15 @@ class UrlValidatorTest extends TestCase $request->shouldHaveBeenCalledOnce(); } + + /** @test */ + public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void + { + $request = $this->httpClient->request(Argument::cetera())->willReturn(new Response()); + $this->options->validateUrl = false; + + $this->urlValidator->validateUrl(''); + + $request->shouldNotHaveBeenCalled(); + } } diff --git a/module/Core/test/Visit/VisitLocatorTest.php b/module/Core/test/Visit/VisitLocatorTest.php new file mode 100644 index 00000000..d856262c --- /dev/null +++ b/module/Core/test/Visit/VisitLocatorTest.php @@ -0,0 +1,169 @@ +em = $this->prophesize(EntityManager::class); + $this->repo = $this->prophesize(VisitRepositoryInterface::class); + $this->em->getRepository(Visit::class)->willReturn($this->repo->reveal()); + + $this->visitService = new VisitLocator($this->em->reveal()); + } + + /** + * @test + * @dataProvider provideMethodNames + */ + public function locateVisitsIteratesAndLocatesExpectedVisits( + string $serviceMethodName, + string $expectedRepoMethodName + ): void { + $unlocatedVisits = map( + range(1, 200), + fn (int $i) => new Visit(new ShortUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()), + ); + + $findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits); + + $persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void { + }); + $flush = $this->em->flush()->will(function (): void { + }); + $clear = $this->em->clear()->will(function (): void { + }); + + $this->visitService->{$serviceMethodName}(new class implements VisitGeolocationHelperInterface { + public function geolocateVisit(Visit $visit): Location + { + return Location::emptyInstance(); + } + + public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void + { + $args = func_get_args(); + + Assert::assertInstanceOf(VisitLocation::class, array_shift($args)); + Assert::assertInstanceOf(Visit::class, array_shift($args)); + } + }); + + $findVisits->shouldHaveBeenCalledOnce(); + $persist->shouldHaveBeenCalledTimes(count($unlocatedVisits)); + $flush->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1); + $clear->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1); + } + + public function provideMethodNames(): iterable + { + yield 'locateUnlocatedVisits' => ['locateUnlocatedVisits', 'findUnlocatedVisits']; + yield 'locateVisitsWithEmptyLocation' => ['locateVisitsWithEmptyLocation', 'findVisitsWithEmptyLocation']; + yield 'locateAllVisits' => ['locateAllVisits', 'findAllVisits']; + } + + /** + * @test + * @dataProvider provideIsNonLocatableAddress + */ + public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty( + string $serviceMethodName, + string $expectedRepoMethodName, + bool $isNonLocatableAddress + ): void { + $unlocatedVisits = [ + new Visit(new ShortUrl('foo'), Visitor::emptyInstance()), + ]; + + $findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits); + + $persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void { + }); + $flush = $this->em->flush()->will(function (): void { + }); + $clear = $this->em->clear()->will(function (): void { + }); + + $this->visitService->{$serviceMethodName}( + new class ($isNonLocatableAddress) implements VisitGeolocationHelperInterface { + private bool $isNonLocatableAddress; + + public function __construct(bool $isNonLocatableAddress) + { + $this->isNonLocatableAddress = $isNonLocatableAddress; + } + + public function geolocateVisit(Visit $visit): Location + { + throw $this->isNonLocatableAddress + ? new IpCannotBeLocatedException('Cannot be located') + : IpCannotBeLocatedException::forError(new Exception('')); + } + + public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void + { + } + }, + ); + + $findVisits->shouldHaveBeenCalledOnce(); + $persist->shouldHaveBeenCalledTimes($isNonLocatableAddress ? 1 : 0); + $flush->shouldHaveBeenCalledOnce(); + $clear->shouldHaveBeenCalledOnce(); + } + + public function provideIsNonLocatableAddress(): iterable + { + yield 'locateUnlocatedVisits - locatable address' => ['locateUnlocatedVisits', 'findUnlocatedVisits', false]; + yield 'locateUnlocatedVisits - non-locatable address' => ['locateUnlocatedVisits', 'findUnlocatedVisits', true]; + yield 'locateVisitsWithEmptyLocation - locatable address' => [ + 'locateVisitsWithEmptyLocation', + 'findVisitsWithEmptyLocation', + false, + ]; + yield 'locateVisitsWithEmptyLocation - non-locatable address' => [ + 'locateVisitsWithEmptyLocation', + 'findVisitsWithEmptyLocation', + true, + ]; + yield 'locateAllVisits - locatable address' => ['locateAllVisits', 'findAllVisits', false]; + yield 'locateAllVisits - non-locatable address' => ['locateAllVisits', 'findAllVisits', true]; + } + + private function mockRepoMethod(string $methodName): MethodProphecy + { + return (new MethodProphecy($this->repo, $methodName, new Argument\ArgumentsWildcard([]))); + } +} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index dc4c0e3b..b24ec1ee 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -38,6 +38,7 @@ return [ Middleware\CrossDomainMiddleware::class => InvokableFactory::class, Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class, Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class, + Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class, ], ], @@ -75,6 +76,9 @@ return [ Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], + Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ + 'config.url_shortener.default_short_codes_length', + ], ], ]; diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 61abb1b7..b104d81b 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -13,7 +13,11 @@ return [ Action\HealthAction::getRouteDef(), // Short codes - Action\ShortUrl\CreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware, $dropDomainMiddleware]), + Action\ShortUrl\CreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $dropDomainMiddleware, + Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, + ]), Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 8b3e65ab..da7012b6 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -8,8 +8,8 @@ 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\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -28,10 +28,10 @@ class EditShortUrlAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - $postData = (array) $request->getParsedBody(); + $shortUrlEdit = ShortUrlEdit::fromRawData((array) $request->getParsedBody()); $identifier = ShortUrlIdentifier::fromApiRequest($request); - $this->shortUrlService->updateMetadataByShortCode($identifier, ShortUrlMeta::fromRawData($postData)); + $this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit); return new EmptyResponse(); } } diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index 570eab85..130617d6 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -8,7 +8,7 @@ use Closure; use function Functional\first; use function Functional\map; -use function Shlinkio\Shlink\Common\loadConfigFromGlob; +use function Shlinkio\Shlink\Config\loadConfigFromGlob; use function sprintf; class ConfigProvider diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 8c6d3aeb..1d372c9c 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -5,20 +5,18 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Entity; use Cake\Chronos\Chronos; +use Ramsey\Uuid\Uuid; use Shlinkio\Shlink\Common\Entity\AbstractEntity; -use Shlinkio\Shlink\Common\Util\StringUtilsTrait; class ApiKey extends AbstractEntity { - use StringUtilsTrait; - private string $key; - private ?Chronos $expirationDate; + private ?Chronos $expirationDate = null; private bool $enabled; public function __construct(?Chronos $expirationDate = null) { - $this->key = $this->generateV4Uuid(); + $this->key = Uuid::uuid4()->toString(); $this->expirationDate = $expirationDate; $this->enabled = true; } @@ -30,11 +28,7 @@ class ApiKey extends AbstractEntity public function isExpired(): bool { - if ($this->expirationDate === null) { - return false; - } - - return $this->expirationDate->lt(Chronos::now()); + return $this->expirationDate !== null && $this->expirationDate->lt(Chronos::now()); } public function isEnabled(): bool diff --git a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php new file mode 100644 index 00000000..bcad748e --- /dev/null +++ b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php @@ -0,0 +1,31 @@ +defaultShortCodesLength = $defaultShortCodesLength; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $body = $request->getParsedBody(); + if (! isset($body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH])) { + $body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength; + } + + return $handler->handle($request->withParsedBody($body)); + } +} diff --git a/module/Rest/test-api/Action/EditShortUrlActionTest.php b/module/Rest/test-api/Action/EditShortUrlActionTest.php index 171a40cc..b5cd4fd4 100644 --- a/module/Rest/test-api/Action/EditShortUrlActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlActionTest.php @@ -71,6 +71,32 @@ class EditShortUrlActionTest extends ApiTestCase return $matchingShortUrl['meta'] ?? null; } + /** + * @test + * @dataProvider provideLongUrls + */ + public function longUrlCanBeEditedIfItIsValid(string $longUrl, int $expectedStatus, ?string $expectedError): void + { + $shortCode = 'abc123'; + $url = sprintf('/short-urls/%s', $shortCode); + + $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => [ + 'longUrl' => $longUrl, + ]]); + + $this->assertEquals($expectedStatus, $resp->getStatusCode()); + if ($expectedError !== null) { + $payload = $this->getJsonResponsePayload($resp); + $this->assertEquals($expectedError, $payload['type']); + } + } + + public function provideLongUrls(): iterable + { + yield 'valid URL' => ['https://shlink.io', self::STATUS_NO_CONTENT, null]; + yield 'invalid URL' => ['htt:foo', self::STATUS_BAD_REQUEST, 'INVALID_URL']; + } + /** * @test * @dataProvider provideInvalidUrls diff --git a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php new file mode 100644 index 00000000..38d875d9 --- /dev/null +++ b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php @@ -0,0 +1,54 @@ +handler = $this->prophesize(RequestHandlerInterface::class); + $this->middleware = new DefaultShortCodesLengthMiddleware(8); + } + + /** + * @test + * @dataProvider provideBodies + */ + public function defaultValueIsInjectedInBodyWhenNotProvided(array $body, int $expectedLength): void + { + $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); + $handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) use ($expectedLength) { + $parsedBody = $req->getParsedBody(); + Assert::assertArrayHasKey(ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, $parsedBody); + Assert::assertEquals($expectedLength, $parsedBody[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH]); + + return $req; + }))->willReturn(new Response()); + + $this->middleware->process($request, $this->handler->reveal()); + + $handle->shouldHaveBeenCalledOnce(); + } + + public function provideBodies(): iterable + { + yield 'value provided' => [[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 6], 6]; + yield 'value not provided' => [[], 8]; + } +}