From e0f0bb5523e6a739c223ed1b08f3b86c146c8fef Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 May 2021 11:57:31 +0200 Subject: [PATCH 01/69] Migrated all constructor props to property promotion when possible --- .github/workflows/ci.yml | 22 +++++++-------- .github/workflows/publish-release.yml | 4 +-- README.md | 2 +- composer.json | 2 +- data/infra/examples/nginx-vhost.conf | 2 +- module/CLI/src/ApiKey/RoleResolver.php | 5 +--- .../CLI/src/Command/Api/DisableKeyCommand.php | 5 +--- .../src/Command/Api/GenerateKeyCommand.php | 11 +++----- .../CLI/src/Command/Api/ListKeysCommand.php | 5 +--- .../Command/Db/AbstractDatabaseCommand.php | 4 +-- .../src/Command/Db/CreateDatabaseCommand.php | 9 ++----- .../src/Command/Domain/ListDomainsCommand.php | 5 +--- .../ShortUrl/DeleteShortUrlCommand.php | 5 +--- .../ShortUrl/GenerateShortUrlCommand.php | 13 +++------ .../src/Command/ShortUrl/GetVisitsCommand.php | 5 +--- .../Command/ShortUrl/ListShortUrlsCommand.php | 11 +++----- .../Command/ShortUrl/ResolveUrlCommand.php | 5 +--- .../CLI/src/Command/Tag/CreateTagCommand.php | 5 +--- .../CLI/src/Command/Tag/DeleteTagsCommand.php | 5 +--- .../CLI/src/Command/Tag/ListTagsCommand.php | 5 +--- .../CLI/src/Command/Tag/RenameTagCommand.php | 5 +--- .../Command/Util/AbstractLockedCommand.php | 5 +--- .../src/Command/Util/LockedCommandConfig.php | 14 ++++------ .../Visit/DownloadGeoLiteDbCommand.php | 4 +-- .../src/Command/Visit/LocateVisitsCommand.php | 9 ++----- module/CLI/src/Util/GeolocationDbUpdater.php | 14 ++++------ module/CLI/src/Util/ProcessRunner.php | 4 +-- module/CLI/src/Util/ShlinkTable.php | 5 +--- module/Core/functions/functions.php | 20 +++++--------- .../src/Action/AbstractTrackingAction.php | 12 +++------ module/Core/src/Action/QrCodeAction.php | 8 ++---- module/Core/src/Action/RedirectAction.php | 5 +--- module/Core/src/Action/RobotsAction.php | 5 +--- module/Core/src/Crawling/CrawlingHelper.php | 5 +--- module/Core/src/Domain/DomainService.php | 7 +---- module/Core/src/Domain/Model/DomainItem.php | 7 +---- module/Core/src/Entity/Domain.php | 5 +--- module/Core/src/Entity/Tag.php | 4 +-- .../src/ErrorHandler/Model/NotFoundType.php | 25 +++++++---------- .../ErrorHandler/NotFoundRedirectHandler.php | 9 ++----- .../ErrorHandler/NotFoundTemplateHandler.php | 1 + .../NotFoundTrackerMiddleware.php | 5 +--- .../NotFoundTypeResolverMiddleware.php | 5 +--- .../CloseDbConnectionEventListener.php | 4 +-- .../Event/AbstractVisitEvent.php | 5 +--- .../src/EventDispatcher/Event/UrlVisited.php | 5 +--- .../Core/src/EventDispatcher/LocateVisit.php | 21 ++++----------- .../EventDispatcher/NotifyVisitToMercure.php | 17 +++--------- .../EventDispatcher/NotifyVisitToWebHooks.php | 27 +++++-------------- .../src/EventDispatcher/UpdateGeoLiteDb.php | 7 +---- .../src/Importer/ImportedLinksProcessor.php | 18 ++++--------- .../Core/src/Importer/ShortUrlImporting.php | 7 +---- .../src/Mercure/MercureUpdatesGenerator.php | 9 ++----- module/Core/src/Model/ShortUrlIdentifier.php | 7 +---- module/Core/src/Model/VisitsParams.php | 8 ++---- .../Adapter/OrphanVisitsPaginatorAdapter.php | 7 +---- .../Adapter/ShortUrlRepositoryAdapter.php | 14 ++++------ .../Adapter/VisitsForTagPaginatorAdapter.php | 17 +++--------- .../Adapter/VisitsPaginatorAdapter.php | 17 +++--------- .../ShortUrl/DeleteShortUrlService.php | 13 +++------ .../src/Service/ShortUrl/ShortCodeHelper.php | 5 +--- .../src/Service/ShortUrl/ShortUrlResolver.php | 5 +--- module/Core/src/Service/ShortUrlService.php | 17 +++--------- module/Core/src/Service/UrlShortener.php | 17 +++--------- .../ShortUrl/Helper/ShortUrlStringifier.php | 7 +---- .../Helper/ShortUrlTitleResolutionHelper.php | 5 +--- .../PersistenceShortUrlRelationResolver.php | 5 +--- .../src/ShortUrl/Spec/BelongsToApiKey.php | 7 +---- .../ShortUrl/Spec/BelongsToApiKeyInlined.php | 5 +--- .../src/ShortUrl/Spec/BelongsToDomain.php | 7 +---- .../ShortUrl/Spec/BelongsToDomainInlined.php | 5 +--- .../Transformer/ShortUrlDataTransformer.php | 5 +--- module/Core/src/Spec/InDateRange.php | 7 +---- module/Core/src/Tag/Model/TagInfo.php | 9 +------ .../Core/src/Tag/Spec/CountTagsWithName.php | 5 +--- module/Core/src/Tag/TagService.php | 5 +--- .../src/Util/CocurSymfonySluggerBridge.php | 5 +--- module/Core/src/Util/DoctrineBatchHelper.php | 5 +--- .../Core/src/Util/RedirectResponseHelper.php | 5 +--- module/Core/src/Util/UrlValidator.php | 7 +---- module/Core/src/Visit/Model/VisitsStats.php | 7 +---- .../Persistence/VisitsCountFiltering.php | 14 ++++------ .../Visit/Persistence/VisitsListFiltering.php | 9 ++----- .../src/Visit/Spec/CountOfOrphanVisits.php | 5 +--- .../src/Visit/Spec/CountOfShortUrlVisits.php | 5 +--- module/Core/src/Visit/VisitLocator.php | 5 +--- module/Core/src/Visit/VisitsStatsHelper.php | 5 +--- module/Core/src/Visit/VisitsTracker.php | 13 +++------ .../Repository/DomainRepositoryTest.php | 5 +--- module/Core/test/Visit/VisitLocatorTest.php | 5 +--- .../src/Action/Domain/ListDomainsAction.php | 5 +--- module/Rest/src/Action/HealthAction.php | 9 ++----- module/Rest/src/Action/MercureInfoAction.php | 7 +---- .../ShortUrl/AbstractCreateShortUrlAction.php | 11 +++----- .../Action/ShortUrl/DeleteShortUrlAction.php | 5 +--- .../Action/ShortUrl/EditShortUrlAction.php | 11 +++----- .../ShortUrl/EditShortUrlTagsAction.php | 5 +--- .../Action/ShortUrl/ListShortUrlsAction.php | 11 +++----- .../Action/ShortUrl/ResolveShortUrlAction.php | 11 +++----- .../Rest/src/Action/Tag/CreateTagsAction.php | 5 +--- .../Rest/src/Action/Tag/DeleteTagsAction.php | 5 +--- module/Rest/src/Action/Tag/ListTagsAction.php | 5 +--- .../Rest/src/Action/Tag/UpdateTagAction.php | 5 +--- .../src/Action/Visit/GlobalVisitsAction.php | 5 +--- .../src/Action/Visit/OrphanVisitsAction.php | 9 ++----- .../src/Action/Visit/ShortUrlVisitsAction.php | 5 +--- .../Rest/src/Action/Visit/TagVisitsAction.php | 5 +--- module/Rest/src/ApiKey/Model/ApiKeyMeta.php | 16 +++++------ .../Rest/src/ApiKey/Model/RoleDefinition.php | 7 +---- .../Spec/WithApiKeySpecsEnsuringJoin.php | 7 +---- module/Rest/src/Entity/ApiKeyRole.php | 9 +------ .../Middleware/AuthenticationMiddleware.php | 13 +++------ .../src/Middleware/CrossDomainMiddleware.php | 5 +--- .../DefaultShortCodesLengthMiddleware.php | 5 +--- ...DropDefaultDomainFromRequestMiddleware.php | 5 +--- .../ShortUrl/OverrideDomainMiddleware.php | 5 +--- module/Rest/src/Service/ApiKeyCheckResult.php | 5 +--- module/Rest/src/Service/ApiKeyService.php | 27 +++++++------------ 118 files changed, 237 insertions(+), 713 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1aefb80..741f80c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -63,7 +63,7 @@ jobs: - run: composer install --no-interaction --prefer-dist - run: composer test:unit:ci - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '7.4' }} + if: ${{ matrix.php-version == '8.0' }} with: name: coverage-unit path: | @@ -74,7 +74,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -89,7 +89,7 @@ jobs: - run: composer install --no-interaction --prefer-dist - run: composer test:db:sqlite:ci - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '7.4' }} + if: ${{ matrix.php-version == '8.0' }} with: name: coverage-db path: | @@ -100,7 +100,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -120,7 +120,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -140,7 +140,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -160,7 +160,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -184,7 +184,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -201,7 +201,7 @@ jobs: - run: composer install --no-interaction --prefer-dist - run: bin/test/run-api-tests.sh - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '7.4' }} + if: ${{ matrix.php-version == '8.0' }} with: name: coverage-api path: | @@ -216,7 +216,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] test-group: ['unit', 'db'] steps: - name: Checkout code diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 26ee4ac0..6ce63cf3 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4', '8.0'] + php-version: ['8.0'] swoole: ['yes', 'no'] steps: - name: Checkout code @@ -54,7 +54,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: [ '7.4', '8.0' ] + php-version: [ '8.0' ] swoole: [ 'yes', 'no' ] steps: - uses: geekyeggo/delete-artifact@v1 diff --git a/README.md b/README.md index 8d51f05b..cd15bb5c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The idea is that you can just generate a container using the image and provide t First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 7.4 or 8.0 +* PHP 8.0 * The next PHP extensions: json, curl, pdo, intl, gd and gmp. * apcu extension is recommended if you don't plan to use swoole. * xml extension is required if you want to generate QR codes in svg format. diff --git a/composer.json b/composer.json index 7a84c886..1c601b63 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "php": "^7.4 || ^8.0", + "php": "^8.0", "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.0", diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index d5bb0a2c..80ff8afd 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -11,7 +11,7 @@ server { location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; + fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index 67747983..179fff53 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -10,11 +10,8 @@ use Symfony\Component\Console\Input\InputInterface; class RoleResolver implements RoleResolverInterface { - private DomainServiceInterface $domainService; - - public function __construct(DomainServiceInterface $domainService) + public function __construct(private DomainServiceInterface $domainService) { - $this->domainService = $domainService; } public function determineRoles(InputInterface $input): array diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 1a8024ec..7296632a 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -19,12 +19,9 @@ class DisableKeyCommand extends Command { public const NAME = 'api-key:disable'; - private ApiKeyServiceInterface $apiKeyService; - - public function __construct(ApiKeyServiceInterface $apiKeyService) + public function __construct(private ApiKeyServiceInterface $apiKeyService) { parent::__construct(); - $this->apiKeyService = $apiKeyService; } protected function configure(): void diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 31df82a1..c8f607a4 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -23,14 +23,11 @@ class GenerateKeyCommand extends BaseCommand { public const NAME = 'api-key:generate'; - private ApiKeyServiceInterface $apiKeyService; - private RoleResolverInterface $roleResolver; - - public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver) - { + public function __construct( + private ApiKeyServiceInterface $apiKeyService, + private RoleResolverInterface $roleResolver + ) { parent::__construct(); - $this->apiKeyService = $apiKeyService; - $this->roleResolver = $roleResolver; } protected function configure(): void diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index e8326826..6a7124e3 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -27,12 +27,9 @@ class ListKeysCommand extends BaseCommand public const NAME = 'api-key:list'; - private ApiKeyServiceInterface $apiKeyService; - - public function __construct(ApiKeyServiceInterface $apiKeyService) + public function __construct(private ApiKeyServiceInterface $apiKeyService) { parent::__construct(); - $this->apiKeyService = $apiKeyService; } protected function configure(): void diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index e4515ab5..1b0a4f9b 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -13,16 +13,14 @@ use Symfony\Component\Process\PhpExecutableFinder; abstract class AbstractDatabaseCommand extends AbstractLockedCommand { - private ProcessRunnerInterface $processRunner; private string $phpBinary; public function __construct( LockFactory $locker, - ProcessRunnerInterface $processRunner, + private ProcessRunnerInterface $processRunner, PhpExecutableFinder $phpFinder ) { parent::__construct($locker); - $this->processRunner = $processRunner; $this->phpBinary = $phpFinder->find(false) ?: 'php'; } diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index ca68f818..ad3959ca 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -21,19 +21,14 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php'; public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create'; - private Connection $regularConn; - private Connection $noDbNameConn; - public function __construct( LockFactory $locker, ProcessRunnerInterface $processRunner, PhpExecutableFinder $phpFinder, - Connection $conn, - Connection $noDbNameConn + private Connection $regularConn, + private Connection $noDbNameConn ) { parent::__construct($locker, $processRunner, $phpFinder); - $this->regularConn = $conn; - $this->noDbNameConn = $noDbNameConn; } protected function configure(): void diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index ddcfa1bd..6fa25097 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -18,12 +18,9 @@ class ListDomainsCommand extends Command { public const NAME = 'domain:list'; - private DomainServiceInterface $domainService; - - public function __construct(DomainServiceInterface $domainService) + public function __construct(private DomainServiceInterface $domainService) { parent::__construct(); - $this->domainService = $domainService; } protected function configure(): void diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index f57001b0..fc4e8331 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -21,12 +21,9 @@ class DeleteShortUrlCommand extends Command { public const NAME = 'short-url:delete'; - private DeleteShortUrlServiceInterface $deleteShortUrlService; - - public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService) + public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService) { parent::__construct(); - $this->deleteShortUrlService = $deleteShortUrlService; } protected function configure(): void diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index cafd0e5a..e0d2babc 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -30,19 +30,12 @@ class GenerateShortUrlCommand extends BaseCommand { public const NAME = 'short-url:generate'; - private UrlShortenerInterface $urlShortener; - private ShortUrlStringifierInterface $stringifier; - private int $defaultShortCodeLength; - public function __construct( - UrlShortenerInterface $urlShortener, - ShortUrlStringifierInterface $stringifier, - int $defaultShortCodeLength + private UrlShortenerInterface $urlShortener, + private ShortUrlStringifierInterface $stringifier, + private int $defaultShortCodeLength ) { parent::__construct(); - $this->urlShortener = $urlShortener; - $this->stringifier = $stringifier; - $this->defaultShortCodeLength = $defaultShortCodeLength; } protected function configure(): void diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index 7b020356..aac45aff 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -27,11 +27,8 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand { public const NAME = 'short-url:visits'; - private VisitsStatsHelperInterface $visitsHelper; - - public function __construct(VisitsStatsHelperInterface $visitsHelper) + public function __construct(private VisitsStatsHelperInterface $visitsHelper) { - $this->visitsHelper = $visitsHelper; parent::__construct(); } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 0d637f5f..9673641d 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -33,14 +33,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand public const NAME = 'short-url:list'; - private ShortUrlServiceInterface $shortUrlService; - private DataTransformerInterface $transformer; - - public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer) - { + public function __construct( + private ShortUrlServiceInterface $shortUrlService, + private DataTransformerInterface $transformer + ) { parent::__construct(); - $this->shortUrlService = $shortUrlService; - $this->transformer = $transformer; } protected function doConfigure(): void diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index e6fdef3d..47a30c8e 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -21,12 +21,9 @@ class ResolveUrlCommand extends Command { public const NAME = 'short-url:parse'; - private ShortUrlResolverInterface $urlResolver; - - public function __construct(ShortUrlResolverInterface $urlResolver) + public function __construct(private ShortUrlResolverInterface $urlResolver) { parent::__construct(); - $this->urlResolver = $urlResolver; } protected function configure(): void diff --git a/module/CLI/src/Command/Tag/CreateTagCommand.php b/module/CLI/src/Command/Tag/CreateTagCommand.php index 0003319d..99eef614 100644 --- a/module/CLI/src/Command/Tag/CreateTagCommand.php +++ b/module/CLI/src/Command/Tag/CreateTagCommand.php @@ -17,12 +17,9 @@ class CreateTagCommand extends Command { public const NAME = 'tag:create'; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { parent::__construct(); - $this->tagService = $tagService; } protected function configure(): void diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 2b3eae14..5a4f81ac 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -16,12 +16,9 @@ class DeleteTagsCommand extends Command { public const NAME = 'tag:delete'; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { parent::__construct(); - $this->tagService = $tagService; } protected function configure(): void diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 11e22a4f..99889fa3 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -18,12 +18,9 @@ class ListTagsCommand extends Command { public const NAME = 'tag:list'; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { parent::__construct(); - $this->tagService = $tagService; } protected function configure(): void diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 8bfb0242..23c1568d 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -19,12 +19,9 @@ class RenameTagCommand extends Command { public const NAME = 'tag:rename'; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { parent::__construct(); - $this->tagService = $tagService; } protected function configure(): void diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index 8a43653d..9482694b 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -14,12 +14,9 @@ use function sprintf; abstract class AbstractLockedCommand extends Command { - private LockFactory $locker; - - public function __construct(LockFactory $locker) + public function __construct(private LockFactory $locker) { parent::__construct(); - $this->locker = $locker; } final protected function execute(InputInterface $input, OutputInterface $output): ?int diff --git a/module/CLI/src/Command/Util/LockedCommandConfig.php b/module/CLI/src/Command/Util/LockedCommandConfig.php index 8de204c5..af9d704d 100644 --- a/module/CLI/src/Command/Util/LockedCommandConfig.php +++ b/module/CLI/src/Command/Util/LockedCommandConfig.php @@ -8,15 +8,11 @@ final class LockedCommandConfig { public const DEFAULT_TTL = 600.0; // 10 minutes - private string $lockName; - private bool $isBlocking; - private float $ttl; - - private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL) - { - $this->lockName = $lockName; - $this->isBlocking = $isBlocking; - $this->ttl = $ttl; + private function __construct( + private string $lockName, + private bool $isBlocking, + private float $ttl = self::DEFAULT_TTL + ) { } public static function blocking(string $lockName): self diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index 3d76663a..f86edfd1 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -19,13 +19,11 @@ class DownloadGeoLiteDbCommand extends Command { public const NAME = 'visit:download-db'; - private GeolocationDbUpdaterInterface $dbUpdater; private ?ProgressBar $progressBar = null; - public function __construct(GeolocationDbUpdaterInterface $dbUpdater) + public function __construct(private GeolocationDbUpdaterInterface $dbUpdater) { parent::__construct(); - $this->dbUpdater = $dbUpdater; } protected function configure(): void diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 0bcfb1d7..a9854c5b 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -30,19 +30,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat { public const NAME = 'visit:locate'; - private VisitLocatorInterface $visitLocator; - private IpLocationResolverInterface $ipLocationResolver; - private SymfonyStyle $io; public function __construct( - VisitLocatorInterface $visitLocator, - IpLocationResolverInterface $ipLocationResolver, + private VisitLocatorInterface $visitLocator, + private IpLocationResolverInterface $ipLocationResolver, LockFactory $locker ) { parent::__construct($locker); - $this->visitLocator = $visitLocator; - $this->ipLocationResolver = $ipLocationResolver; } protected function configure(): void diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index 6e7c2da2..215ee85c 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -18,15 +18,11 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface { private const LOCK_NAME = 'geolocation-db-update'; - private DbUpdaterInterface $dbUpdater; - private Reader $geoLiteDbReader; - private LockFactory $locker; - - public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, LockFactory $locker) - { - $this->dbUpdater = $dbUpdater; - $this->geoLiteDbReader = $geoLiteDbReader; - $this->locker = $locker; + public function __construct( + private DbUpdaterInterface $dbUpdater, + private Reader $geoLiteDbReader, + private LockFactory $locker + ) { } /** diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index 1a6b826e..66e94eb6 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -18,12 +18,10 @@ use function str_replace; class ProcessRunner implements ProcessRunnerInterface { - private ProcessHelper $helper; private Closure $createProcess; - public function __construct(ProcessHelper $helper, ?callable $createProcess = null) + public function __construct(private ProcessHelper $helper, ?callable $createProcess = null) { - $this->helper = $helper; $this->createProcess = $createProcess !== null ? Closure::fromCallable($createProcess) : static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL); diff --git a/module/CLI/src/Util/ShlinkTable.php b/module/CLI/src/Util/ShlinkTable.php index ac8733ad..5788ce12 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -12,11 +12,8 @@ final class ShlinkTable private const DEFAULT_STYLE_NAME = 'default'; private const TABLE_TITLE_STYLE = ' %s '; - private ?Table $baseTable; - - public function __construct(Table $baseTable) + public function __construct(private Table $baseTable) { - $this->baseTable = $baseTable; } public static function fromOutput(OutputInterface $output): self diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 867f7c7d..7910ad1a 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -51,20 +51,12 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en $startDate = parseDateFromQuery($query, $startDateName); $endDate = parseDateFromQuery($query, $endDateName); - // TODO Use match expression when migrating to PHP8 - if ($startDate === null && $endDate === null) { - return DateRange::emptyInstance(); - } - - if ($startDate !== null && $endDate !== null) { - return DateRange::withStartAndEndDate($startDate, $endDate); - } - - if ($startDate !== null) { - return DateRange::withStartDate($startDate); - } - - return DateRange::withEndDate($endDate); + return match (true) { + $startDate === null && $endDate === null => DateRange::emptyInstance(), + $startDate !== null && $endDate !== null => DateRange::withStartAndEndDate($startDate, $endDate), + $startDate !== null => DateRange::withStartDate($startDate), + default => DateRange::withEndDate($endDate), + }; } /** diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 567e930c..582b7bce 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -27,20 +27,14 @@ use function array_merge; abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { - private ShortUrlResolverInterface $urlResolver; - private VisitsTrackerInterface $visitTracker; - private TrackingOptions $trackingOptions; private LoggerInterface $logger; public function __construct( - ShortUrlResolverInterface $urlResolver, - VisitsTrackerInterface $visitTracker, - TrackingOptions $trackingOptions, + private ShortUrlResolverInterface $urlResolver, + private VisitsTrackerInterface $visitTracker, + private TrackingOptions $trackingOptions, ?LoggerInterface $logger = null ) { - $this->urlResolver = $urlResolver; - $this->visitTracker = $visitTracker; - $this->trackingOptions = $trackingOptions; $this->logger = $logger ?? new NullLogger(); } diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 1b2b5012..177d90fc 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -24,18 +24,14 @@ class QrCodeAction implements MiddlewareInterface private const MIN_SIZE = 50; private const MAX_SIZE = 1000; - private ShortUrlResolverInterface $urlResolver; - private ShortUrlStringifierInterface $stringifier; private LoggerInterface $logger; public function __construct( - ShortUrlResolverInterface $urlResolver, - ShortUrlStringifierInterface $stringifier, + private ShortUrlResolverInterface $urlResolver, + private ShortUrlStringifierInterface $stringifier, ?LoggerInterface $logger = null ) { - $this->urlResolver = $urlResolver; $this->logger = $logger ?? new NullLogger(); - $this->stringifier = $stringifier; } public function process(Request $request, RequestHandlerInterface $handler): Response diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 7da67b59..e1c6757c 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -16,17 +16,14 @@ use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface { - private RedirectResponseHelperInterface $redirectResponseHelper; - public function __construct( ShortUrlResolverInterface $urlResolver, VisitsTrackerInterface $visitTracker, Options\TrackingOptions $trackingOptions, - RedirectResponseHelperInterface $redirectResponseHelper, + private RedirectResponseHelperInterface $redirectResponseHelper, ?LoggerInterface $logger = null ) { parent::__construct($urlResolver, $visitTracker, $trackingOptions, $logger); - $this->redirectResponseHelper = $redirectResponseHelper; } protected function createSuccessResp(string $longUrl): Response diff --git a/module/Core/src/Action/RobotsAction.php b/module/Core/src/Action/RobotsAction.php index 31539b92..b7fa7d42 100644 --- a/module/Core/src/Action/RobotsAction.php +++ b/module/Core/src/Action/RobotsAction.php @@ -17,11 +17,8 @@ use const PHP_EOL; class RobotsAction implements RequestHandlerInterface, StatusCodeInterface { - private CrawlingHelperInterface $crawlingHelper; - - public function __construct(CrawlingHelperInterface $crawlingHelper) + public function __construct(private CrawlingHelperInterface $crawlingHelper) { - $this->crawlingHelper = $crawlingHelper; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Core/src/Crawling/CrawlingHelper.php b/module/Core/src/Crawling/CrawlingHelper.php index 5f688645..e620370f 100644 --- a/module/Core/src/Crawling/CrawlingHelper.php +++ b/module/Core/src/Crawling/CrawlingHelper.php @@ -10,11 +10,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; class CrawlingHelper implements CrawlingHelperInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } public function listCrawlableShortCodes(): iterable diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 5a573799..3a582ee6 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -16,13 +16,8 @@ use function Functional\map; class DomainService implements DomainServiceInterface { - private EntityManagerInterface $em; - private string $defaultDomain; - - public function __construct(EntityManagerInterface $em, string $defaultDomain) + public function __construct(private EntityManagerInterface $em, private string $defaultDomain) { - $this->em = $em; - $this->defaultDomain = $defaultDomain; } /** diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 4006b186..f389f1e7 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -8,13 +8,8 @@ use JsonSerializable; final class DomainItem implements JsonSerializable { - private string $domain; - private bool $isDefault; - - public function __construct(string $domain, bool $isDefault) + public function __construct(private string $domain, private bool $isDefault) { - $this->domain = $domain; - $this->isDefault = $isDefault; } public function jsonSerialize(): array diff --git a/module/Core/src/Entity/Domain.php b/module/Core/src/Entity/Domain.php index f836f7ed..ee094576 100644 --- a/module/Core/src/Entity/Domain.php +++ b/module/Core/src/Entity/Domain.php @@ -9,11 +9,8 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; class Domain extends AbstractEntity implements JsonSerializable { - private string $authority; - - public function __construct(string $authority) + public function __construct(private string $authority) { - $this->authority = $authority; } public function getAuthority(): string diff --git a/module/Core/src/Entity/Tag.php b/module/Core/src/Entity/Tag.php index 54c05c56..8dc5cf29 100644 --- a/module/Core/src/Entity/Tag.php +++ b/module/Core/src/Entity/Tag.php @@ -10,12 +10,10 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; class Tag extends AbstractEntity implements JsonSerializable { - private string $name; private Collections\Collection $shortUrls; - public function __construct(string $name) + public function __construct(private string $name) { - $this->name = $name; $this->shortUrls = new Collections\ArrayCollection(); } diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php index 57176e84..39970dea 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -13,31 +13,24 @@ use function rtrim; class NotFoundType { - private string $type; - - private function __construct(string $type) + private function __construct(private string $type) { - $this->type = $type; } public static function fromRequest(ServerRequestInterface $request, string $basePath): self { - $isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath; - if ($isBaseUrl) { - return new self(Visit::TYPE_BASE_URL); - } - /** @var RouteResult $routeResult */ $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null)); - if ($routeResult->isFailure()) { - return new self(Visit::TYPE_REGULAR_404); - } + $isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath; - if ($routeResult->getMatchedRouteName() === RedirectAction::class) { - return new self(Visit::TYPE_INVALID_SHORT_URL); - } + $type = match (true) { + $isBaseUrl => Visit::TYPE_BASE_URL, + $routeResult->isFailure() => Visit::TYPE_REGULAR_404, + $routeResult->getMatchedRouteName() === RedirectAction::class => Visit::TYPE_INVALID_SHORT_URL, + default => self::class, + }; - return new self(self::class); + return new self($type); } public function isBaseUrl(): bool diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index 1f3b4fed..27fcf991 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -14,15 +14,10 @@ use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; class NotFoundRedirectHandler implements MiddlewareInterface { - private Options\NotFoundRedirectOptions $redirectOptions; - private RedirectResponseHelperInterface $redirectResponseHelper; - public function __construct( - Options\NotFoundRedirectOptions $redirectOptions, - RedirectResponseHelperInterface $redirectResponseHelper + private Options\NotFoundRedirectOptions $redirectOptions, + private RedirectResponseHelperInterface $redirectResponseHelper ) { - $this->redirectOptions = $redirectOptions; - $this->redirectResponseHelper = $redirectResponseHelper; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php index 61d67403..cd0f60be 100644 --- a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php @@ -20,6 +20,7 @@ class NotFoundTemplateHandler implements RequestHandlerInterface private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates'; public const NOT_FOUND_TEMPLATE = '404.html'; public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html'; + private Closure $readFile; public function __construct(?callable $readFile = null) diff --git a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php index b81e55de..473a0b60 100644 --- a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php @@ -14,11 +14,8 @@ use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; class NotFoundTrackerMiddleware implements MiddlewareInterface { - private VisitsTrackerInterface $visitsTracker; - - public function __construct(VisitsTrackerInterface $visitsTracker) + public function __construct(private VisitsTrackerInterface $visitsTracker) { - $this->visitsTracker = $visitsTracker; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php index 6f13db73..7e36135a 100644 --- a/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php @@ -12,11 +12,8 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; class NotFoundTypeResolverMiddleware implements MiddlewareInterface { - private string $shlinkBasePath; - - public function __construct(string $shlinkBasePath) + public function __construct(private string $shlinkBasePath) { - $this->shlinkBasePath = $shlinkBasePath; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php b/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php index 7f2c7297..079c6195 100644 --- a/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php +++ b/module/Core/src/EventDispatcher/CloseDbConnectionEventListener.php @@ -8,13 +8,11 @@ use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface; class CloseDbConnectionEventListener { - private ReopeningEntityManagerInterface $em; /** @var callable */ private $wrapped; - public function __construct(ReopeningEntityManagerInterface $em, callable $wrapped) + public function __construct(private ReopeningEntityManagerInterface $em, callable $wrapped) { - $this->em = $em; $this->wrapped = $wrapped; } diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 09869cb2..c4bc1818 100644 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -8,11 +8,8 @@ use JsonSerializable; abstract class AbstractVisitEvent implements JsonSerializable { - protected string $visitId; - - public function __construct(string $visitId) + public function __construct(protected string $visitId) { - $this->visitId = $visitId; } public function visitId(): string diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index 87b9e4cb..633b439e 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -6,12 +6,9 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event; final class UrlVisited extends AbstractVisitEvent { - private ?string $originalIpAddress; - - public function __construct(string $visitId, ?string $originalIpAddress = null) + public function __construct(string $visitId, private ?string $originalIpAddress = null) { parent::__construct($visitId); - $this->originalIpAddress = $originalIpAddress; } public function originalIpAddress(): ?string diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index 0150c529..046430df 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -19,24 +19,13 @@ use Throwable; class LocateVisit { - private IpLocationResolverInterface $ipLocationResolver; - private EntityManagerInterface $em; - private LoggerInterface $logger; - private DbUpdaterInterface $dbUpdater; - private EventDispatcherInterface $eventDispatcher; - public function __construct( - IpLocationResolverInterface $ipLocationResolver, - EntityManagerInterface $em, - LoggerInterface $logger, - DbUpdaterInterface $dbUpdater, - EventDispatcherInterface $eventDispatcher + private IpLocationResolverInterface $ipLocationResolver, + private EntityManagerInterface $em, + private LoggerInterface $logger, + private DbUpdaterInterface $dbUpdater, + private EventDispatcherInterface $eventDispatcher ) { - $this->ipLocationResolver = $ipLocationResolver; - $this->em = $em; - $this->logger = $logger; - $this->dbUpdater = $dbUpdater; - $this->eventDispatcher = $eventDispatcher; } public function __invoke(UrlVisited $shortUrlVisited): void diff --git a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php index 33adf965..d1ad8201 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php @@ -17,21 +17,12 @@ use function Functional\each; class NotifyVisitToMercure { - private HubInterface $hub; - private MercureUpdatesGeneratorInterface $updatesGenerator; - private EntityManagerInterface $em; - private LoggerInterface $logger; - public function __construct( - HubInterface $hub, - MercureUpdatesGeneratorInterface $updatesGenerator, - EntityManagerInterface $em, - LoggerInterface $logger + private HubInterface $hub, + private MercureUpdatesGeneratorInterface $updatesGenerator, + private EntityManagerInterface $em, + private LoggerInterface $logger ) { - $this->hub = $hub; - $this->em = $em; - $this->logger = $logger; - $this->updatesGenerator = $updatesGenerator; } public function __invoke(VisitLocated $shortUrlLocated): void diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index b236a1c1..5b4e2818 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -24,28 +24,15 @@ use function Functional\partial_left; class NotifyVisitToWebHooks { - private ClientInterface $httpClient; - private EntityManagerInterface $em; - private LoggerInterface $logger; - /** @var string[] */ - private array $webhooks; - private DataTransformerInterface $transformer; - private AppOptions $appOptions; - public function __construct( - ClientInterface $httpClient, - EntityManagerInterface $em, - LoggerInterface $logger, - array $webhooks, - DataTransformerInterface $transformer, - AppOptions $appOptions + private ClientInterface $httpClient, + private EntityManagerInterface $em, + private LoggerInterface $logger, + /** @var string[] */ + private array $webhooks, + private DataTransformerInterface $transformer, + private AppOptions $appOptions ) { - $this->httpClient = $httpClient; - $this->em = $em; - $this->logger = $logger; - $this->webhooks = $webhooks; - $this->transformer = $transformer; - $this->appOptions = $appOptions; } public function __invoke(VisitLocated $shortUrlLocated): void diff --git a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php index f17a7ffb..13941f43 100644 --- a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php +++ b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php @@ -12,13 +12,8 @@ use function sprintf; class UpdateGeoLiteDb { - private GeolocationDbUpdaterInterface $dbUpdater; - private LoggerInterface $logger; - - public function __construct(GeolocationDbUpdaterInterface $dbUpdater, LoggerInterface $logger) + public function __construct(private GeolocationDbUpdaterInterface $dbUpdater, private LoggerInterface $logger) { - $this->dbUpdater = $dbUpdater; - $this->logger = $logger; } public function __invoke(): void diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index e700e8a8..7427057f 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -20,22 +20,14 @@ use function sprintf; class ImportedLinksProcessor implements ImportedLinksProcessorInterface { - private EntityManagerInterface $em; - private ShortUrlRelationResolverInterface $relationResolver; - private ShortCodeHelperInterface $shortCodeHelper; - private DoctrineBatchHelperInterface $batchHelper; private ShortUrlRepositoryInterface $shortUrlRepo; public function __construct( - EntityManagerInterface $em, - ShortUrlRelationResolverInterface $relationResolver, - ShortCodeHelperInterface $shortCodeHelper, - DoctrineBatchHelperInterface $batchHelper + private EntityManagerInterface $em, + private ShortUrlRelationResolverInterface $relationResolver, + private ShortCodeHelperInterface $shortCodeHelper, + private DoctrineBatchHelperInterface $batchHelper ) { - $this->em = $em; - $this->relationResolver = $relationResolver; - $this->shortCodeHelper = $shortCodeHelper; - $this->batchHelper = $batchHelper; $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line } @@ -64,7 +56,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface try { $shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict); - } catch (NonUniqueSlugException $e) { + } catch (NonUniqueSlugException) { $io->text(sprintf('%s: Error', $longUrl)); continue; } diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index b5ae4651..9b3fa998 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -14,13 +14,8 @@ use function sprintf; final class ShortUrlImporting { - private ShortUrl $shortUrl; - private bool $isNew; - - private function __construct(ShortUrl $shortUrl, bool $isNew) + private function __construct(private ShortUrl $shortUrl, private bool $isNew) { - $this->shortUrl = $shortUrl; - $this->isNew = $isNew; } public static function fromExistingShortUrl(ShortUrl $shortUrl): self diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php index 23b3796c..c6c7a4d6 100644 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -18,15 +18,10 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit'; private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit'; - private DataTransformerInterface $shortUrlTransformer; - private DataTransformerInterface $orphanVisitTransformer; - public function __construct( - DataTransformerInterface $shortUrlTransformer, - DataTransformerInterface $orphanVisitTransformer + private DataTransformerInterface $shortUrlTransformer, + private DataTransformerInterface $orphanVisitTransformer ) { - $this->shortUrlTransformer = $shortUrlTransformer; - $this->orphanVisitTransformer = $orphanVisitTransformer; } public function newVisitUpdate(Visit $visit): Update diff --git a/module/Core/src/Model/ShortUrlIdentifier.php b/module/Core/src/Model/ShortUrlIdentifier.php index a277782c..cf7c17d3 100644 --- a/module/Core/src/Model/ShortUrlIdentifier.php +++ b/module/Core/src/Model/ShortUrlIdentifier.php @@ -10,13 +10,8 @@ use Symfony\Component\Console\Input\InputInterface; final class ShortUrlIdentifier { - private string $shortCode; - private ?string $domain; - - public function __construct(string $shortCode, ?string $domain = null) + public function __construct(private string $shortCode, private ?string $domain = null) { - $this->shortCode = $shortCode; - $this->domain = $domain; } public static function fromApiRequest(ServerRequestInterface $request): self diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index f8498c7a..659eb5dc 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -14,20 +14,16 @@ final class VisitsParams private const ALL_ITEMS = -1; private ?DateRange $dateRange; - private int $page; private int $itemsPerPage; - private bool $excludeBots; public function __construct( ?DateRange $dateRange = null, - int $page = self::FIRST_PAGE, + private int $page = self::FIRST_PAGE, ?int $itemsPerPage = null, - bool $excludeBots = false + private bool $excludeBots = false ) { $this->dateRange = $dateRange ?? new DateRange(); - $this->page = $page; $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage); - $this->excludeBots = $excludeBots; } private function determineItemsPerPage(?int $itemsPerPage): int diff --git a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index d7361fb3..18c2c435 100644 --- a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -11,13 +11,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - private VisitRepositoryInterface $repo; - private VisitsParams $params; - - public function __construct(VisitRepositoryInterface $repo, VisitsParams $params) + public function __construct(private VisitRepositoryInterface $repo, private VisitsParams $params) { - $this->repo = $repo; - $this->params = $params; } protected function doCount(): int diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 093bd8fd..a0d4305f 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -12,15 +12,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapter implements AdapterInterface { - private ShortUrlRepositoryInterface $repository; - private ShortUrlsParams $params; - private ?ApiKey $apiKey; - - public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params, ?ApiKey $apiKey) - { - $this->repository = $repository; - $this->params = $params; - $this->apiKey = $apiKey; + public function __construct( + private ShortUrlRepositoryInterface $repository, + private ShortUrlsParams $params, + private ?ApiKey $apiKey + ) { } public function getSlice($offset, $length): array // phpcs:ignore diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php index d7c0580f..2dfdc618 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php @@ -13,21 +13,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - private VisitRepositoryInterface $visitRepository; - private string $tag; - private VisitsParams $params; - private ?ApiKey $apiKey; - public function __construct( - VisitRepositoryInterface $visitRepository, - string $tag, - VisitsParams $params, - ?ApiKey $apiKey + private VisitRepositoryInterface $visitRepository, + private string $tag, + private VisitsParams $params, + private ?ApiKey $apiKey ) { - $this->visitRepository = $visitRepository; - $this->params = $params; - $this->tag = $tag; - $this->apiKey = $apiKey; } public function getSlice($offset, $length): array // phpcs:ignore diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php index d651b1b5..fa6833f8 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php @@ -13,21 +13,12 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - private VisitRepositoryInterface $visitRepository; - private ShortUrlIdentifier $identifier; - private VisitsParams $params; - private ?Specification $spec; - public function __construct( - VisitRepositoryInterface $visitRepository, - ShortUrlIdentifier $identifier, - VisitsParams $params, - ?Specification $spec + private VisitRepositoryInterface $visitRepository, + private ShortUrlIdentifier $identifier, + private VisitsParams $params, + private ?Specification $spec ) { - $this->visitRepository = $visitRepository; - $this->params = $params; - $this->identifier = $identifier; - $this->spec = $spec; } public function getSlice($offset, $length): array // phpcs:ignore diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php index 07af448d..53d0db61 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php @@ -13,18 +13,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteShortUrlService implements DeleteShortUrlServiceInterface { - private EntityManagerInterface $em; - private DeleteShortUrlsOptions $deleteShortUrlsOptions; - private ShortUrlResolverInterface $urlResolver; - public function __construct( - EntityManagerInterface $em, - DeleteShortUrlsOptions $deleteShortUrlsOptions, - ShortUrlResolverInterface $urlResolver + private EntityManagerInterface $em, + private DeleteShortUrlsOptions $deleteShortUrlsOptions, + private ShortUrlResolverInterface $urlResolver ) { - $this->em = $em; - $this->deleteShortUrlsOptions = $deleteShortUrlsOptions; - $this->urlResolver = $urlResolver; } /** diff --git a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php b/module/Core/src/Service/ShortUrl/ShortCodeHelper.php index 83c3397e..5bb992c5 100644 --- a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php +++ b/module/Core/src/Service/ShortUrl/ShortCodeHelper.php @@ -11,11 +11,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php index 1394e1ab..09cd2005 100644 --- a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php @@ -13,11 +13,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlResolver implements ShortUrlResolverInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } /** diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index dcb1d8cc..f8a144cc 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -21,21 +21,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlService implements ShortUrlServiceInterface { - private ORM\EntityManagerInterface $em; - private ShortUrlResolverInterface $urlResolver; - private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; - private ShortUrlRelationResolverInterface $relationResolver; - public function __construct( - ORM\EntityManagerInterface $em, - ShortUrlResolverInterface $urlResolver, - ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - ShortUrlRelationResolverInterface $relationResolver + private ORM\EntityManagerInterface $em, + private ShortUrlResolverInterface $urlResolver, + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private ShortUrlRelationResolverInterface $relationResolver ) { - $this->em = $em; - $this->urlResolver = $urlResolver; - $this->titleResolutionHelper = $titleResolutionHelper; - $this->relationResolver = $relationResolver; } /** diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 78064259..ff2b5f62 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -16,21 +16,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; class UrlShortener implements UrlShortenerInterface { - private EntityManagerInterface $em; - private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; - private ShortUrlRelationResolverInterface $relationResolver; - private ShortCodeHelperInterface $shortCodeHelper; - public function __construct( - ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - EntityManagerInterface $em, - ShortUrlRelationResolverInterface $relationResolver, - ShortCodeHelperInterface $shortCodeHelper + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private EntityManagerInterface $em, + private ShortUrlRelationResolverInterface $relationResolver, + private ShortCodeHelperInterface $shortCodeHelper ) { - $this->titleResolutionHelper = $titleResolutionHelper; - $this->em = $em; - $this->relationResolver = $relationResolver; - $this->shortCodeHelper = $shortCodeHelper; } /** diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 4d34e26b..1ec36677 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -11,13 +11,8 @@ use function sprintf; class ShortUrlStringifier implements ShortUrlStringifierInterface { - private array $domainConfig; - private string $basePath; - - public function __construct(array $domainConfig, string $basePath = '') + public function __construct(private array $domainConfig, private string $basePath = '') { - $this->domainConfig = $domainConfig; - $this->basePath = $basePath; } public function stringify(ShortUrl $shortUrl): string diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index 4615e45f..00eecc61 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -8,11 +8,8 @@ use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface { - private UrlValidatorInterface $urlValidator; - - public function __construct(UrlValidatorInterface $urlValidator) + public function __construct(private UrlValidatorInterface $urlValidator) { - $this->urlValidator = $urlValidator; } public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 8601a045..a9456712 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -16,16 +16,13 @@ use function Functional\unique; class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface { - private EntityManagerInterface $em; - /** @var array */ private array $memoizedNewDomains = []; /** @var array */ private array $memoizedNewTags = []; - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; $this->em->getEventManager()->addEventListener(Events::postFlush, $this); } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php index 4aa3579f..84852275 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -11,13 +11,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class BelongsToApiKey extends BaseSpecification { - private ApiKey $apiKey; - private ?string $dqlAlias; - - public function __construct(ApiKey $apiKey, ?string $dqlAlias = null) + public function __construct(private ApiKey $apiKey, private ?string $dqlAlias = null) { - $this->apiKey = $apiKey; - $this->dqlAlias = $dqlAlias; parent::__construct(); } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php index 579407cd..6b103058 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php @@ -10,11 +10,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class BelongsToApiKeyInlined implements Filter { - private ApiKey $apiKey; - - public function __construct(ApiKey $apiKey) + public function __construct(private ApiKey $apiKey) { - $this->apiKey = $apiKey; } public function getFilter(QueryBuilder $qb, string $dqlAlias): string diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php index 7745ff27..33eacec8 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -10,13 +10,8 @@ use Happyr\DoctrineSpecification\Specification\BaseSpecification; class BelongsToDomain extends BaseSpecification { - private string $domainId; - private ?string $dqlAlias; - - public function __construct(string $domainId, ?string $dqlAlias = null) + public function __construct(private string $domainId, private ?string $dqlAlias = null) { - $this->domainId = $domainId; - $this->dqlAlias = $dqlAlias; parent::__construct(); } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php index cb69a359..414b3f74 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php @@ -9,11 +9,8 @@ use Happyr\DoctrineSpecification\Filter\Filter; class BelongsToDomainInlined implements Filter { - private string $domainId; - - public function __construct(string $domainId) + public function __construct(private string $domainId) { - $this->domainId = $domainId; } public function getFilter(QueryBuilder $qb, string $dqlAlias): string diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index 52b98c36..61049626 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -13,11 +13,8 @@ use function Functional\invoke_if; class ShortUrlDataTransformer implements DataTransformerInterface { - private ShortUrlStringifierInterface $stringifier; - - public function __construct(ShortUrlStringifierInterface $stringifier) + public function __construct(private ShortUrlStringifierInterface $stringifier) { - $this->stringifier = $stringifier; } /** diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php index 953ed9f2..7ddcf0a4 100644 --- a/module/Core/src/Spec/InDateRange.php +++ b/module/Core/src/Spec/InDateRange.php @@ -11,14 +11,9 @@ use Shlinkio\Shlink\Common\Util\DateRange; class InDateRange extends BaseSpecification { - private ?DateRange $dateRange; - private string $field; - - public function __construct(?DateRange $dateRange, string $field = 'date') + public function __construct(private ?DateRange $dateRange, private string $field = 'date') { parent::__construct(); - $this->dateRange = $dateRange; - $this->field = $field; } protected function getSpec(): Specification diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index dbc51316..1a436cd4 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -9,15 +9,8 @@ use Shlinkio\Shlink\Core\Entity\Tag; final class TagInfo implements JsonSerializable { - private Tag $tag; - private int $shortUrlsCount; - private int $visitsCount; - - public function __construct(Tag $tag, int $shortUrlsCount, int $visitsCount) + public function __construct(private Tag $tag, private int $shortUrlsCount, private int $visitsCount) { - $this->tag = $tag; - $this->shortUrlsCount = $shortUrlsCount; - $this->visitsCount = $visitsCount; } public function tag(): Tag diff --git a/module/Core/src/Tag/Spec/CountTagsWithName.php b/module/Core/src/Tag/Spec/CountTagsWithName.php index 8dd3e44d..021507d1 100644 --- a/module/Core/src/Tag/Spec/CountTagsWithName.php +++ b/module/Core/src/Tag/Spec/CountTagsWithName.php @@ -10,12 +10,9 @@ use Happyr\DoctrineSpecification\Specification\Specification; class CountTagsWithName extends BaseSpecification { - private string $tagName; - - public function __construct(string $tagName) + public function __construct(private string $tagName) { parent::__construct(); - $this->tagName = $tagName; } protected function getSpec(): Specification diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 4619bd9d..61ed211d 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -23,11 +23,8 @@ class TagService implements TagServiceInterface { use TagManagerTrait; - private ORM\EntityManagerInterface $em; - - public function __construct(ORM\EntityManagerInterface $em) + public function __construct(private ORM\EntityManagerInterface $em) { - $this->em = $em; } /** diff --git a/module/Core/src/Util/CocurSymfonySluggerBridge.php b/module/Core/src/Util/CocurSymfonySluggerBridge.php index 9415e47c..a612068c 100644 --- a/module/Core/src/Util/CocurSymfonySluggerBridge.php +++ b/module/Core/src/Util/CocurSymfonySluggerBridge.php @@ -12,11 +12,8 @@ use function Symfony\Component\String\s; class CocurSymfonySluggerBridge implements SluggerInterface { - private SlugifyInterface $slugger; - - public function __construct(SlugifyInterface $slugger) + public function __construct(private SlugifyInterface $slugger) { - $this->slugger = $slugger; } public function slug(string $string, string $separator = '-', ?string $locale = null): AbstractUnicodeString diff --git a/module/Core/src/Util/DoctrineBatchHelper.php b/module/Core/src/Util/DoctrineBatchHelper.php index 207d2093..5591ddb2 100644 --- a/module/Core/src/Util/DoctrineBatchHelper.php +++ b/module/Core/src/Util/DoctrineBatchHelper.php @@ -12,11 +12,8 @@ use Throwable; */ class DoctrineBatchHelper implements DoctrineBatchHelperInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } /** diff --git a/module/Core/src/Util/RedirectResponseHelper.php b/module/Core/src/Util/RedirectResponseHelper.php index 58f6e145..5f9edf99 100644 --- a/module/Core/src/Util/RedirectResponseHelper.php +++ b/module/Core/src/Util/RedirectResponseHelper.php @@ -13,11 +13,8 @@ use function sprintf; class RedirectResponseHelper implements RedirectResponseHelperInterface { - private UrlShortenerOptions $options; - - public function __construct(UrlShortenerOptions $options) + public function __construct(private UrlShortenerOptions $options) { - $this->options = $options; } public function buildRedirectResponse(string $location): ResponseInterface diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index 23de39ef..0756f55e 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -23,13 +23,8 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' . 'Chrome/51.0.2704.103 Safari/537.36'; - private ClientInterface $httpClient; - private UrlShortenerOptions $options; - - public function __construct(ClientInterface $httpClient, UrlShortenerOptions $options) + public function __construct(private ClientInterface $httpClient, private UrlShortenerOptions $options) { - $this->httpClient = $httpClient; - $this->options = $options; } /** diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index 982f03c4..475a25b5 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -8,13 +8,8 @@ use JsonSerializable; final class VisitsStats implements JsonSerializable { - private int $visitsCount; - private int $orphanVisitsCount; - - public function __construct(int $visitsCount, int $orphanVisitsCount) + public function __construct(private int $visitsCount, private int $orphanVisitsCount) { - $this->visitsCount = $visitsCount; - $this->orphanVisitsCount = $orphanVisitsCount; } public function jsonSerialize(): array diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index bc9ac5de..9f48275f 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -9,15 +9,11 @@ use Shlinkio\Shlink\Common\Util\DateRange; class VisitsCountFiltering { - private ?DateRange $dateRange; - private bool $excludeBots; - private ?Specification $spec; - - public function __construct(?DateRange $dateRange = null, bool $excludeBots = false, ?Specification $spec = null) - { - $this->dateRange = $dateRange; - $this->excludeBots = $excludeBots; - $this->spec = $spec; + public function __construct( + private ?DateRange $dateRange = null, + private bool $excludeBots = false, + private ?Specification $spec = null + ) { } public function dateRange(): ?DateRange diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php index 4f67967d..173e308e 100644 --- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -9,19 +9,14 @@ use Shlinkio\Shlink\Common\Util\DateRange; final class VisitsListFiltering extends VisitsCountFiltering { - private ?int $limit; - private ?int $offset; - public function __construct( ?DateRange $dateRange = null, bool $excludeBots = false, ?Specification $spec = null, - ?int $limit = null, - ?int $offset = null + private ?int $limit = null, + private ?int $offset = null ) { parent::__construct($dateRange, $excludeBots, $spec); - $this->limit = $limit; - $this->offset = $offset; } public function limit(): ?int diff --git a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php index b2cc9efd..d8e6b2d2 100644 --- a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php @@ -12,12 +12,9 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; class CountOfOrphanVisits extends BaseSpecification { - private VisitsCountFiltering $filtering; - - public function __construct(VisitsCountFiltering $filtering) + public function __construct(private VisitsCountFiltering $filtering) { parent::__construct(); - $this->filtering = $filtering; } protected function getSpec(): Specification diff --git a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php b/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php index ea4a4800..49d8db93 100644 --- a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php +++ b/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php @@ -12,12 +12,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class CountOfShortUrlVisits extends BaseSpecification { - private ?ApiKey $apiKey; - - public function __construct(?ApiKey $apiKey) + public function __construct(private ?ApiKey $apiKey) { parent::__construct(); - $this->apiKey = $apiKey; } protected function getSpec(): Specification diff --git a/module/Core/src/Visit/VisitLocator.php b/module/Core/src/Visit/VisitLocator.php index d7f0e426..e9d0a8f9 100644 --- a/module/Core/src/Visit/VisitLocator.php +++ b/module/Core/src/Visit/VisitLocator.php @@ -13,13 +13,10 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; class VisitLocator implements VisitLocatorInterface { - private EntityManagerInterface $em; private VisitRepositoryInterface $repo; - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; - /** @var VisitRepositoryInterface $repo */ $repo = $em->getRepository(Visit::class); $this->repo = $repo; diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index dfa00a4c..06f990f6 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -27,11 +27,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsStatsHelper implements VisitsStatsHelperInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index f77cd624..523454fc 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -14,18 +14,11 @@ use Shlinkio\Shlink\Core\Options\TrackingOptions; class VisitsTracker implements VisitsTrackerInterface { - private ORM\EntityManagerInterface $em; - private EventDispatcherInterface $eventDispatcher; - private TrackingOptions $options; - public function __construct( - ORM\EntityManagerInterface $em, - EventDispatcherInterface $eventDispatcher, - TrackingOptions $options + private ORM\EntityManagerInterface $em, + private EventDispatcherInterface $eventDispatcher, + private TrackingOptions $options ) { - $this->em = $em; - $this->eventDispatcher = $eventDispatcher; - $this->options = $options; } public function track(ShortUrl $shortUrl, Visitor $visitor): void diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index aaa63d9f..0f5aa259 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -94,11 +94,8 @@ class DomainRepositoryTest extends DatabaseTestCase return ShortUrl::fromMeta( ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'apiKey' => $apiKey, 'longUrl' => 'foo']), new class ($domain) implements ShortUrlRelationResolverInterface { - private Domain $domain; - - public function __construct(Domain $domain) + public function __construct(private Domain $domain) { - $this->domain = $domain; } public function resolveDomain(?string $domain): ?Domain diff --git a/module/Core/test/Visit/VisitLocatorTest.php b/module/Core/test/Visit/VisitLocatorTest.php index c99d051b..11e7062f 100644 --- a/module/Core/test/Visit/VisitLocatorTest.php +++ b/module/Core/test/Visit/VisitLocatorTest.php @@ -122,11 +122,8 @@ class VisitLocatorTest extends TestCase $this->visitService->{$serviceMethodName}( new class ($isNonLocatableAddress) implements VisitGeolocationHelperInterface { - private bool $isNonLocatableAddress; - - public function __construct(bool $isNonLocatableAddress) + public function __construct(private bool $isNonLocatableAddress) { - $this->isNonLocatableAddress = $isNonLocatableAddress; } public function geolocateVisit(Visit $visit): Location diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index 35ce04f3..c8f9a475 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -16,11 +16,8 @@ class ListDomainsAction extends AbstractRestAction protected const ROUTE_PATH = '/domains'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private DomainServiceInterface $domainService; - - public function __construct(DomainServiceInterface $domainService) + public function __construct(private DomainServiceInterface $domainService) { - $this->domainService = $domainService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index ef89da64..5f9d052c 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -20,13 +20,8 @@ class HealthAction extends AbstractRestAction protected const ROUTE_PATH = '/health'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private EntityManagerInterface $em; - private AppOptions $options; - - public function __construct(EntityManagerInterface $em, AppOptions $options) + public function __construct(private EntityManagerInterface $em, private AppOptions $options) { - $this->em = $em; - $this->options = $options; } /** @@ -38,7 +33,7 @@ class HealthAction extends AbstractRestAction { try { $connected = $this->em->getConnection()->ping(); - } catch (Throwable $e) { + } catch (Throwable) { $connected = false; } diff --git a/module/Rest/src/Action/MercureInfoAction.php b/module/Rest/src/Action/MercureInfoAction.php index 75893ab9..d6710357 100644 --- a/module/Rest/src/Action/MercureInfoAction.php +++ b/module/Rest/src/Action/MercureInfoAction.php @@ -19,13 +19,8 @@ class MercureInfoAction extends AbstractRestAction protected const ROUTE_PATH = '/mercure-info'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private JwtProviderInterface $jwtProvider; - private array $mercureConfig; - - public function __construct(JwtProviderInterface $jwtProvider, array $mercureConfig) + public function __construct(private JwtProviderInterface $jwtProvider, private array $mercureConfig) { - $this->jwtProvider = $jwtProvider; - $this->mercureConfig = $mercureConfig; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index 587c4bc5..90616dc5 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -15,13 +15,10 @@ use Shlinkio\Shlink\Rest\Action\AbstractRestAction; abstract class AbstractCreateShortUrlAction extends AbstractRestAction { - private UrlShortenerInterface $urlShortener; - private DataTransformerInterface $transformer; - - public function __construct(UrlShortenerInterface $urlShortener, DataTransformerInterface $transformer) - { - $this->urlShortener = $urlShortener; - $this->transformer = $transformer; + public function __construct( + private UrlShortenerInterface $urlShortener, + private DataTransformerInterface $transformer, + ) { } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php index 73eaa6ee..8059e5ab 100644 --- a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php @@ -17,11 +17,8 @@ class DeleteShortUrlAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE]; - private DeleteShortUrlServiceInterface $deleteShortUrlService; - - public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService) + public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService) { - $this->deleteShortUrlService = $deleteShortUrlService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 49187314..87c21aec 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -19,13 +19,10 @@ class EditShortUrlAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH, self::METHOD_PUT]; - private ShortUrlServiceInterface $shortUrlService; - private DataTransformerInterface $transformer; - - public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer) - { - $this->shortUrlService = $shortUrlService; - $this->transformer = $transformer; + public function __construct( + private ShortUrlServiceInterface $shortUrlService, + private DataTransformerInterface $transformer, + ) { } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index d114049c..d3211d1c 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -21,11 +21,8 @@ class EditShortUrlTagsAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT]; - private ShortUrlServiceInterface $shortUrlService; - - public function __construct(ShortUrlServiceInterface $shortUrlService) + public function __construct(private ShortUrlServiceInterface $shortUrlService) { - $this->shortUrlService = $shortUrlService; } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index ee077790..075b56e1 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -21,13 +21,10 @@ class ListShortUrlsAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private ShortUrlServiceInterface $shortUrlService; - private DataTransformerInterface $transformer; - - public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer) - { - $this->shortUrlService = $shortUrlService; - $this->transformer = $transformer; + public function __construct( + private ShortUrlServiceInterface $shortUrlService, + private DataTransformerInterface $transformer + ) { } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index c14423ce..aae1a895 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -18,13 +18,10 @@ class ResolveShortUrlAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private ShortUrlResolverInterface $urlResolver; - private DataTransformerInterface $transformer; - - public function __construct(ShortUrlResolverInterface $urlResolver, DataTransformerInterface $transformer) - { - $this->urlResolver = $urlResolver; - $this->transformer = $transformer; + public function __construct( + private ShortUrlResolverInterface $urlResolver, + private DataTransformerInterface $transformer, + ) { } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php index 8aaf907b..e3d0bb9f 100644 --- a/module/Rest/src/Action/Tag/CreateTagsAction.php +++ b/module/Rest/src/Action/Tag/CreateTagsAction.php @@ -16,11 +16,8 @@ class CreateTagsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_POST]; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { - $this->tagService = $tagService; } /** diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php index b1be8af5..48e7acd9 100644 --- a/module/Rest/src/Action/Tag/DeleteTagsAction.php +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -16,11 +16,8 @@ class DeleteTagsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE]; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { - $this->tagService = $tagService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 48cf923b..89371b71 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -19,11 +19,8 @@ class ListTagsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { - $this->tagService = $tagService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index d83d8b9a..a4bce7c0 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -17,11 +17,8 @@ class UpdateTagAction extends AbstractRestAction protected const ROUTE_PATH = '/tags'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT]; - private TagServiceInterface $tagService; - - public function __construct(TagServiceInterface $tagService) + public function __construct(private TagServiceInterface $tagService) { - $this->tagService = $tagService; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Visit/GlobalVisitsAction.php b/module/Rest/src/Action/Visit/GlobalVisitsAction.php index 4810b100..1f2e1211 100644 --- a/module/Rest/src/Action/Visit/GlobalVisitsAction.php +++ b/module/Rest/src/Action/Visit/GlobalVisitsAction.php @@ -16,11 +16,8 @@ class GlobalVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsStatsHelperInterface $statsHelper; - - public function __construct(VisitsStatsHelperInterface $statsHelper) + public function __construct(private VisitsStatsHelperInterface $statsHelper) { - $this->statsHelper = $statsHelper; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index 7a65b920..b05d7b31 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -20,15 +20,10 @@ class OrphanVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/visits/orphan'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsStatsHelperInterface $visitsHelper; - private DataTransformerInterface $orphanVisitTransformer; - public function __construct( - VisitsStatsHelperInterface $visitsHelper, - DataTransformerInterface $orphanVisitTransformer + private VisitsStatsHelperInterface $visitsHelper, + private DataTransformerInterface $orphanVisitTransformer ) { - $this->visitsHelper = $visitsHelper; - $this->orphanVisitTransformer = $orphanVisitTransformer; } public function handle(ServerRequestInterface $request): ResponseInterface diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index 8175d1c7..5496ba35 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -21,11 +21,8 @@ class ShortUrlVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/short-urls/{shortCode}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsStatsHelperInterface $visitsHelper; - - public function __construct(VisitsStatsHelperInterface $visitsHelper) + public function __construct(private VisitsStatsHelperInterface $visitsHelper) { - $this->visitsHelper = $visitsHelper; } public function handle(Request $request): Response diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php index 8d981c82..b577ce06 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -20,11 +20,8 @@ class TagVisitsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags/{tag}/visits'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - private VisitsStatsHelperInterface $visitsHelper; - - public function __construct(VisitsStatsHelperInterface $visitsHelper) + public function __construct(private VisitsStatsHelperInterface $visitsHelper) { - $this->visitsHelper = $visitsHelper; } public function handle(Request $request): Response diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index aa3c117a..39b5dca1 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -8,16 +8,12 @@ use Cake\Chronos\Chronos; final class ApiKeyMeta { - private ?string $name = null; - private ?Chronos $expirationDate = null; - /** @var RoleDefinition[] */ - private array $roleDefinitions; - - private function __construct(?string $name, ?Chronos $expirationDate, array $roleDefinitions) - { - $this->name = $name; - $this->expirationDate = $expirationDate; - $this->roleDefinitions = $roleDefinitions; + private function __construct( + private ?string $name, + private ?Chronos $expirationDate, + /** @var RoleDefinition[] */ + private array $roleDefinitions, + ) { } public static function withName(string $name): self diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index 569044dc..fdd4d5cb 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -9,13 +9,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; final class RoleDefinition { - private string $roleName; - private array $meta; - - private function __construct(string $roleName, array $meta) + private function __construct(private string $roleName, private array $meta) { - $this->roleName = $roleName; - $this->meta = $meta; } public static function forAuthoredShortUrls(): self diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index a1f9b361..ddfabe81 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -11,14 +11,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class WithApiKeySpecsEnsuringJoin extends BaseSpecification { - private ?ApiKey $apiKey; - private string $fieldToJoin; - - public function __construct(?ApiKey $apiKey, string $fieldToJoin = 'shortUrls') + public function __construct(private ?ApiKey $apiKey, private string $fieldToJoin = 'shortUrls') { parent::__construct(); - $this->apiKey = $apiKey; - $this->fieldToJoin = $fieldToJoin; } protected function getSpec(): Specification diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 99dbb627..1155c37b 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -8,15 +8,8 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; class ApiKeyRole extends AbstractEntity { - private string $roleName; - private array $meta; - private ApiKey $apiKey; - - public function __construct(string $roleName, array $meta, ApiKey $apiKey) + public function __construct(private string $roleName, private array $meta, private ApiKey $apiKey) { - $this->roleName = $roleName; - $this->meta = $meta; - $this->apiKey = $apiKey; } public function name(): string diff --git a/module/Rest/src/Middleware/AuthenticationMiddleware.php b/module/Rest/src/Middleware/AuthenticationMiddleware.php index cb8f8b7a..705bc9c5 100644 --- a/module/Rest/src/Middleware/AuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/AuthenticationMiddleware.php @@ -23,18 +23,11 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa { public const API_KEY_HEADER = 'X-Api-Key'; - private ApiKeyServiceInterface $apiKeyService; - private array $routesWithoutApiKey; - private array $routesWithQueryApiKey; - public function __construct( - ApiKeyServiceInterface $apiKeyService, - array $routesWithoutApiKey, - array $routesWithQueryApiKey + private ApiKeyServiceInterface $apiKeyService, + private array $routesWithoutApiKey, + private array $routesWithQueryApiKey ) { - $this->apiKeyService = $apiKeyService; - $this->routesWithoutApiKey = $routesWithoutApiKey; - $this->routesWithQueryApiKey = $routesWithQueryApiKey; } public function process(Request $request, RequestHandlerInterface $handler): Response diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index b265fe13..b0d63dc7 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -16,11 +16,8 @@ use function implode; class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface { - private array $config; - - public function __construct(array $config) + public function __construct(private array $config) { - $this->config = $config; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php index c1991de2..cffc56b0 100644 --- a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php @@ -12,11 +12,8 @@ use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; class DefaultShortCodesLengthMiddleware implements MiddlewareInterface { - private int $defaultShortCodesLength; - - public function __construct(int $defaultShortCodesLength) + public function __construct(private int $defaultShortCodesLength) { - $this->defaultShortCodesLength = $defaultShortCodesLength; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php index 3d76a975..59515242 100644 --- a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php @@ -11,11 +11,8 @@ use Psr\Http\Server\RequestHandlerInterface; class DropDefaultDomainFromRequestMiddleware implements MiddlewareInterface { - private string $defaultDomain; - - public function __construct(string $defaultDomain) + public function __construct(private string $defaultDomain) { - $this->defaultDomain = $defaultDomain; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php index c875a9ab..6943f986 100644 --- a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -16,11 +16,8 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class OverrideDomainMiddleware implements MiddlewareInterface { - private DomainServiceInterface $domainService; - - public function __construct(DomainServiceInterface $domainService) + public function __construct(private DomainServiceInterface $domainService) { - $this->domainService = $domainService; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Rest/src/Service/ApiKeyCheckResult.php b/module/Rest/src/Service/ApiKeyCheckResult.php index 8ec3f65e..2caee4e1 100644 --- a/module/Rest/src/Service/ApiKeyCheckResult.php +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -8,11 +8,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class ApiKeyCheckResult { - private ?ApiKey $apiKey; - - public function __construct(?ApiKey $apiKey = null) + public function __construct(private ?ApiKey $apiKey = null) { - $this->apiKey = $apiKey; } public function isValid(): bool diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index e81c446f..0aad928f 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -15,11 +15,8 @@ use function sprintf; class ApiKeyService implements ApiKeyServiceInterface { - private EntityManagerInterface $em; - - public function __construct(EntityManagerInterface $em) + public function __construct(private EntityManagerInterface $em) { - $this->em = $em; } public function create( @@ -40,20 +37,14 @@ class ApiKeyService implements ApiKeyServiceInterface private function buildApiKeyWithParams(?Chronos $expirationDate, ?string $name): ApiKey { - // TODO Use match expression when migrating to PHP8 - if ($expirationDate === null && $name === null) { - return ApiKey::create(); - } - - if ($expirationDate !== null && $name !== null) { - return ApiKey::fromMeta(ApiKeyMeta::withNameAndExpirationDate($name, $expirationDate)); - } - - if ($name === null) { - return ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)); - } - - return ApiKey::fromMeta(ApiKeyMeta::withName($name)); + return match (true) { + $expirationDate === null && $name === null => ApiKey::create(), + $expirationDate !== null && $name !== null => ApiKey::fromMeta( + ApiKeyMeta::withNameAndExpirationDate($name, $expirationDate), + ), + $name === null => ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)), + default => ApiKey::fromMeta(ApiKeyMeta::withName($name)), + }; } public function check(string $key): ApiKeyCheckResult From c01121d61a0a606bd4f8064423213f7e66243075 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 May 2021 12:31:10 +0200 Subject: [PATCH 02/69] Added nullsafe operator to simplify conditions --- module/CLI/src/Command/Api/ListKeysCommand.php | 2 +- module/CLI/src/Command/BaseCommand.php | 2 +- .../src/Command/ShortUrl/ListShortUrlsCommand.php | 15 ++++++--------- module/CLI/test/ApiKey/RoleResolverTest.php | 2 +- .../ShortUrl/DeleteShortUrlCommandTest.php | 2 +- .../Command/ShortUrl/ListShortUrlsCommandTest.php | 4 ++-- .../Visit/DownloadGeoLiteDbCommandTest.php | 2 +- .../Command/Visit/LocateVisitsCommandTest.php | 2 +- module/Core/src/Action/AbstractTrackingAction.php | 2 +- module/Core/src/Action/PixelAction.php | 2 +- module/Core/src/Domain/DomainService.php | 2 +- module/Core/src/Entity/ShortUrl.php | 6 +++--- .../CloseDbConnectionEventListenerDelegator.php | 2 +- .../Core/src/Importer/ImportedLinksProcessor.php | 4 ++-- module/Core/src/Importer/ShortUrlImporting.php | 5 +---- module/Core/src/Model/ShortUrlIdentifier.php | 2 +- .../Adapter/ShortUrlRepositoryAdapter.php | 10 ++-------- .../Adapter/VisitsForTagPaginatorAdapter.php | 10 ++-------- module/Core/src/Repository/ShortUrlRepository.php | 12 ++++++------ .../Repository/ShortUrlRepositoryInterface.php | 4 ++-- module/Core/src/Repository/VisitRepository.php | 12 ++++++------ .../Service/ShortUrl/DeleteShortUrlService.php | 2 +- .../ShortUrl/DeleteShortUrlServiceInterface.php | 2 +- .../src/Service/ShortUrl/ShortUrlResolver.php | 4 ++-- module/Core/src/Service/ShortUrlService.php | 2 +- .../Core/src/Service/ShortUrlServiceInterface.php | 2 +- module/Core/src/Service/UrlShortener.php | 2 +- module/Core/src/Spec/InDateRange.php | 4 ++-- module/Core/src/Visit/VisitsStatsHelper.php | 4 ++-- .../Core/src/Visit/VisitsStatsHelperInterface.php | 2 +- module/Core/test/Action/QrCodeActionTest.php | 2 +- module/Core/test/Config/BasePathPrefixerTest.php | 2 +- module/Core/test/Domain/DomainServiceTest.php | 2 +- module/Core/test/Entity/ShortUrlTest.php | 2 +- .../ErrorHandler/NotFoundRedirectHandlerTest.php | 2 +- .../test/EventDispatcher/UpdateGeoLiteDbTest.php | 2 +- .../Exception/DeleteShortUrlExceptionTest.php | 2 +- .../ForbiddenTagOperationExceptionTest.php | 2 +- .../Exception/ShortUrlNotFoundExceptionTest.php | 2 +- .../test/Importer/ImportedLinksProcessorTest.php | 2 +- .../Adapter/ShortUrlRepositoryAdapterTest.php | 4 ++-- .../Adapter/VisitsPaginatorAdapterTest.php | 2 +- .../Service/ShortUrl/ShortUrlResolverTest.php | 4 ++-- module/Core/test/Service/ShortUrlServiceTest.php | 2 +- .../ShortUrl/Helper/ShortUrlStringifierTest.php | 2 +- module/Core/test/Util/DoctrineBatchHelperTest.php | 2 +- .../Core/test/Util/RedirectResponseHelperTest.php | 2 +- module/Core/test/Util/UrlValidatorTest.php | 2 +- module/Core/test/Visit/VisitLocatorTest.php | 4 ++-- module/Core/test/Visit/VisitsStatsHelperTest.php | 2 +- module/Rest/src/Entity/ApiKey.php | 2 +- module/Rest/src/Service/ApiKeyService.php | 2 +- .../Rest/src/Service/ApiKeyServiceInterface.php | 2 +- .../Rest/test-api/Action/DeleteShortUrlTest.php | 2 +- .../Rest/test-api/Action/EditShortUrlTagsTest.php | 2 +- module/Rest/test-api/Action/EditShortUrlTest.php | 6 +++--- module/Rest/test-api/Action/OrphanVisitsTest.php | 2 +- .../Rest/test-api/Action/ResolveShortUrlTest.php | 2 +- .../Rest/test-api/Action/ShortUrlVisitsTest.php | 2 +- module/Rest/test-api/Action/TagVisitsTest.php | 2 +- module/Rest/test-api/Middleware/CorsTest.php | 2 +- .../Action/ShortUrl/ListShortUrlsActionTest.php | 2 +- .../Middleware/AuthenticationMiddlewareTest.php | 2 +- .../test/Middleware/CrossDomainMiddlewareTest.php | 4 ++-- 64 files changed, 95 insertions(+), 113 deletions(-) diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 6a7124e3..f435f1ea 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -58,7 +58,7 @@ class ListKeysCommand extends BaseCommand if (! $enabledOnly) { $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); } - $rowData[] = $expiration !== null ? $expiration->toAtomString() : '-'; + $rowData[] = $expiration?->toAtomString() ?? '-'; $rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles( fn (string $roleName, array $meta) => empty($meta) diff --git a/module/CLI/src/Command/BaseCommand.php b/module/CLI/src/Command/BaseCommand.php index 443b37ec..ef6b5435 100644 --- a/module/CLI/src/Command/BaseCommand.php +++ b/module/CLI/src/Command/BaseCommand.php @@ -22,7 +22,7 @@ abstract class BaseCommand extends Command ?string $shortcut = null, ?int $mode = null, string $description = '', - $default = null + $default = null, ): self { $this->addOption($name, $shortcut, $mode, $description, $default); diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 9673641d..b5d242dc 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -126,8 +126,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm, ShortUrlsParamsInputFilter::TAGS => $tags, ShortUrlsOrdering::ORDER_BY => $orderBy, - ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null, - ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null, + ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(), + ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(), ]; if ($all) { @@ -155,7 +155,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand OutputInterface $output, array $columnsMap, ShortUrlsParams $params, - bool $all + bool $all, ): Paginator { $shortUrls = $this->shortUrlService->listShortUrls($params); @@ -200,14 +200,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand } if ($input->getOption('show-api-key')) { $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => - (string) $shortUrl->authorApiKey(); + (string) $shortUrl->authorApiKey(); } if ($input->getOption('show-api-key-name')) { - $columnsMap['API Key Name'] = static function (array $_, ShortUrl $shortUrl): ?string { - $apiKey = $shortUrl->authorApiKey(); - - return $apiKey !== null ? $apiKey->name() : null; - }; + $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string => + $shortUrl->authorApiKey()?->name(); } return $columnsMap; diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index a50c2b12..5e4de2c3 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -33,7 +33,7 @@ class RoleResolverTest extends TestCase public function properRolesAreResolvedBasedOnInput( InputInterface $input, array $expectedRoles, - int $expectedDomainCalls + int $expectedDomainCalls, ): void { $getDomain = $this->domainService->getOrCreate('example.com')->willReturn( (new Domain('example.com'))->setId('1'), diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index a6b6fc78..765a1c4b 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -74,7 +74,7 @@ class DeleteShortUrlCommandTest extends TestCase public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted( array $retryAnswer, int $expectedDeleteCalls, - string $expectedMessage + string $expectedMessage, ): void { $shortCode = 'abc123'; $identifier = new ShortUrlIdentifier($shortCode); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 6f7b11a6..f4ba2bb1 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -110,7 +110,7 @@ class ListShortUrlsCommandTest extends TestCase array $input, array $expectedContents, array $notExpectedContents, - ApiKey $apiKey + ApiKey $apiKey, ): void { $this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance()) ->willReturn(new Paginator(new ArrayAdapter([ @@ -185,7 +185,7 @@ class ListShortUrlsCommandTest extends TestCase ?string $searchTerm, array $tags, ?string $startDate = null, - ?string $endDate = null + ?string $endDate = null, ): void { $listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([ 'page' => $page, diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php index 7ead517d..62ea161a 100644 --- a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -36,7 +36,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase public function showsProperMessageWhenGeoLiteUpdateFails( bool $olderDbExists, string $expectedMessage, - int $expectedExitCode + int $expectedExitCode, ): void { $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( function (array $args) use ($olderDbExists): void { diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 74148f9c..fa666516 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -73,7 +73,7 @@ class LocateVisitsCommandTest extends TestCase int $expectedEmptyCalls, int $expectedAllCalls, bool $expectWarningPrint, - array $args + array $args, ): void { $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 582b7bce..7de21fa8 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -84,6 +84,6 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet abstract protected function createErrorResp( ServerRequestInterface $request, - RequestHandlerInterface $handler + RequestHandlerInterface $handler, ): ResponseInterface; } diff --git a/module/Core/src/Action/PixelAction.php b/module/Core/src/Action/PixelAction.php index 5435c582..3f67bdec 100644 --- a/module/Core/src/Action/PixelAction.php +++ b/module/Core/src/Action/PixelAction.php @@ -18,7 +18,7 @@ class PixelAction extends AbstractTrackingAction protected function createErrorResp( ServerRequestInterface $request, - RequestHandlerInterface $handler + RequestHandlerInterface $handler, ): ResponseInterface { return new PixelResponse(); } diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 3a582ee6..95ba05c5 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -30,7 +30,7 @@ class DomainService implements DomainServiceInterface $domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey); $mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false)); - if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) { + if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { return $mappedDomains; } diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 6f502da3..78527115 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -60,7 +60,7 @@ class ShortUrl extends AbstractEntity public static function fromMeta( ShortUrlMeta $meta, - ?ShortUrlRelationResolverInterface $relationResolver = null + ?ShortUrlRelationResolverInterface $relationResolver = null, ): self { $instance = new self(); $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); @@ -87,7 +87,7 @@ class ShortUrl extends AbstractEntity public static function fromImport( ImportedShlinkUrl $url, bool $importShortCode, - ?ShortUrlRelationResolverInterface $relationResolver = null + ?ShortUrlRelationResolverInterface $relationResolver = null, ): self { $meta = [ ShortUrlInputFilter::VALIDATE_URL => false, @@ -209,7 +209,7 @@ class ShortUrl extends AbstractEntity public function update( ShortUrlEdit $shortUrlEdit, - ?ShortUrlRelationResolverInterface $relationResolver = null + ?ShortUrlRelationResolverInterface $relationResolver = null, ): void { if ($shortUrlEdit->validSinceWasProvided()) { $this->validSince = $shortUrlEdit->validSince(); diff --git a/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php b/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php index cbfc7208..6f8f00d1 100644 --- a/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php +++ b/module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php @@ -12,7 +12,7 @@ class CloseDbConnectionEventListenerDelegator public function __invoke( ContainerInterface $container, string $name, - callable $callback + callable $callback, ): CloseDbConnectionEventListener { /** @var callable $wrapped */ $wrapped = $callback(); diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 7427057f..6073dd26 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -69,7 +69,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface private function resolveShortUrl( ImportedShlinkUrl $importedUrl, bool $importShortCodes, - callable $skipOnShortCodeConflict + callable $skipOnShortCodeConflict, ): ShortUrlImporting { $alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl); if ($alreadyImportedShortUrl !== null) { @@ -88,7 +88,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface private function handleShortCodeUniqueness( ShortUrl $shortUrl, bool $importShortCodes, - callable $skipOnShortCodeConflict + callable $skipOnShortCodeConflict, ): bool { if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) { return true; diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index 9b3fa998..a925c5d5 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -38,10 +38,7 @@ final class ShortUrlImporting $importedVisits = 0; foreach ($visits as $importedVisit) { // Skip visits which are older than the most recent already imported visit's date - if ( - $mostRecentImportedDate !== null - && $mostRecentImportedDate->gte(Chronos::instance($importedVisit->date())) - ) { + if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date()))) { continue; } diff --git a/module/Core/src/Model/ShortUrlIdentifier.php b/module/Core/src/Model/ShortUrlIdentifier.php index cf7c17d3..e0d6c9b4 100644 --- a/module/Core/src/Model/ShortUrlIdentifier.php +++ b/module/Core/src/Model/ShortUrlIdentifier.php @@ -41,7 +41,7 @@ final class ShortUrlIdentifier public static function fromShortUrl(ShortUrl $shortUrl): self { $domain = $shortUrl->getDomain(); - $domainAuthority = $domain !== null ? $domain->getAuthority() : null; + $domainAuthority = $domain?->getAuthority(); return new self($shortUrl->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index a0d4305f..e297b6c0 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; -use Happyr\DoctrineSpecification\Specification\Specification; use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; @@ -28,7 +27,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->tags(), $this->params->orderBy(), $this->params->dateRange(), - $this->resolveSpec(), + $this->apiKey?->spec(), ); } @@ -38,12 +37,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->searchTerm(), $this->params->tags(), $this->params->dateRange(), - $this->resolveSpec(), + $this->apiKey?->spec(), ); } - - private function resolveSpec(): ?Specification - { - return $this->apiKey !== null ? $this->apiKey->spec() : null; - } } diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php index 2dfdc618..dbbf8bb9 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; -use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; @@ -28,7 +27,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte new VisitsListFiltering( $this->params->getDateRange(), $this->params->excludeBots(), - $this->resolveSpec(), + $this->apiKey?->spec(true), $length, $offset, ), @@ -42,13 +41,8 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte new VisitsCountFiltering( $this->params->getDateRange(), $this->params->excludeBots(), - $this->resolveSpec(), + $this->apiKey?->spec(true), ), ); } - - private function resolveSpec(): ?Specification - { - return $this->apiKey !== null ? $this->apiKey->spec(true) : null; - } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index c658d478..c4ccedbf 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -35,7 +35,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU array $tags = [], ?ShortUrlsOrdering $orderBy = null, ?DateRange $dateRange = null, - ?Specification $spec = null + ?Specification $spec = null, ): array { $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb->select('DISTINCT s') @@ -43,7 +43,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ->setFirstResult($offset); // In case the ordering has been specified, the query could be more complex. Process it - if ($orderBy !== null && $orderBy->hasOrderField()) { + if ($orderBy?->hasOrderField()) { return $this->processOrderByForList($qb, $orderBy); } @@ -85,7 +85,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null, - ?Specification $spec = null + ?Specification $spec = null, ): int { $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb->select('COUNT(DISTINCT s)'); @@ -97,17 +97,17 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ?string $searchTerm, array $tags, ?DateRange $dateRange, - ?Specification $spec + ?Specification $spec, ): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') ->where('1=1'); - if ($dateRange !== null && $dateRange->getStartDate() !== null) { + if ($dateRange?->getStartDate() !== null) { $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); $qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME); } - if ($dateRange !== null && $dateRange->getEndDate() !== null) { + if ($dateRange?->getEndDate() !== null) { $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); $qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME); } diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 7489f2a0..e2927286 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -23,14 +23,14 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat array $tags = [], ?ShortUrlsOrdering $orderBy = null, ?DateRange $dateRange = null, - ?Specification $spec = null + ?Specification $spec = null, ): array; public function countList( ?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null, - ?Specification $spec = null + ?Specification $spec = null, ): int; public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl; diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 61cd108e..6adba193 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -71,14 +71,14 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $iterator = $qb->getQuery()->toIterable(); $resultsFound = false; - /** @var Visit $visit */ foreach ($iterator as $key => $visit) { $resultsFound = true; yield $key => $visit; } // 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; + /** @var Visit|null $visit */ + $lastId = $visit?->getId() ?? $lastId; } while ($resultsFound); } @@ -101,12 +101,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo private function createVisitsByShortCodeQueryBuilder( ShortUrlIdentifier $identifier, - VisitsCountFiltering $filtering + VisitsCountFiltering $filtering, ): QueryBuilder { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); $shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec()); - $shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1; + $shortUrlId = $shortUrl?->getId() ?? '-1'; // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later // Since they are not strictly provided by the caller, it's reasonably safe @@ -187,10 +187,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void { - if ($dateRange !== null && $dateRange->getStartDate() !== null) { + if ($dateRange?->getStartDate() !== null) { $qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\'')); } - if ($dateRange !== null && $dateRange->getEndDate() !== null) { + if ($dateRange?->getEndDate() !== null) { $qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\'')); } } diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php index 53d0db61..1bcd5ccb 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php @@ -27,7 +27,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface public function deleteByShortCode( ShortUrlIdentifier $identifier, bool $ignoreThreshold = false, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): void { $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php index b1f01839..0767c723 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php @@ -17,6 +17,6 @@ interface DeleteShortUrlServiceInterface public function deleteByShortCode( ShortUrlIdentifier $identifier, bool $ignoreThreshold = false, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): void; } diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php index 09cd2005..61c57d36 100644 --- a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php @@ -24,7 +24,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null); + $shortUrl = $shortUrlRepo->findOne($identifier, $apiKey?->spec()); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } @@ -40,7 +40,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier->shortCode(), $identifier->domain()); - if ($shortUrl === null || ! $shortUrl->isEnabled()) { + if (! $shortUrl?->isEnabled()) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index f8a144cc..2a576ce9 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -50,7 +50,7 @@ class ShortUrlService implements ShortUrlServiceInterface public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { /** @var ShortUrlEdit $shortUrlEdit */ diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 3884b55e..e0a73981 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -27,6 +27,6 @@ interface ShortUrlServiceInterface public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): ShortUrl; } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index ff2b5f62..24ac2c70 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -69,7 +69,7 @@ class UrlShortener implements UrlShortenerInterface if (! $couldBeMadeUnique) { $domain = $shortUrlToBeCreated->getDomain(); - $domainAuthority = $domain !== null ? $domain->getAuthority() : null; + $domainAuthority = $domain?->getAuthority(); throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php index 7ddcf0a4..81d11b9e 100644 --- a/module/Core/src/Spec/InDateRange.php +++ b/module/Core/src/Spec/InDateRange.php @@ -20,11 +20,11 @@ class InDateRange extends BaseSpecification { $criteria = []; - if ($this->dateRange !== null && $this->dateRange->getStartDate() !== null) { + if ($this->dateRange?->getStartDate() !== null) { $criteria[] = Spec::gte($this->field, $this->dateRange->getStartDate()->toDateTimeString()); } - if ($this->dateRange !== null && $this->dateRange->getEndDate() !== null) { + if ($this->dateRange?->getEndDate() !== null) { $criteria[] = Spec::lte($this->field, $this->dateRange->getEndDate()->toDateTimeString()); } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 06f990f6..8138d170 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -49,9 +49,9 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): Paginator { - $spec = $apiKey !== null ? $apiKey->spec() : null; + $spec = $apiKey?->spec(); /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index d2bf6032..5e15be4f 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -24,7 +24,7 @@ interface VisitsStatsHelperInterface public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, - ?ApiKey $apiKey = null + ?ApiKey $apiKey = null, ): Paginator; /** diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index aeaec13f..7326c41c 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -84,7 +84,7 @@ class QrCodeActionTest extends TestCase */ public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat( array $query, - string $expectedContentType + string $expectedContentType, ): void { $code = 'abc123'; $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( diff --git a/module/Core/test/Config/BasePathPrefixerTest.php b/module/Core/test/Config/BasePathPrefixerTest.php index e0949514..f01b9195 100644 --- a/module/Core/test/Config/BasePathPrefixerTest.php +++ b/module/Core/test/Config/BasePathPrefixerTest.php @@ -24,7 +24,7 @@ class BasePathPrefixerTest extends TestCase array $originalConfig, array $expectedRoutes, array $expectedMiddlewares, - string $expectedHostname + string $expectedHostname, ): void { [ 'routes' => $routes, diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 0306f387..80326b3c 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -126,7 +126,7 @@ class DomainServiceTest extends TestCase $repo = $this->prophesize(DomainRepositoryInterface::class); $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $persist = $this->em->persist($foundDomain !== null ? $foundDomain : Argument::type(Domain::class)); + $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); $flush = $this->em->flush(); $result = $this->domainService->getOrCreate($authority); diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index fceba3e2..89ccc805 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -26,7 +26,7 @@ class ShortUrlTest extends TestCase */ public function regenerateShortCodeThrowsExceptionIfStateIsInvalid( ShortUrl $shortUrl, - string $expectedMessage + string $expectedMessage, ): void { $this->expectException(ShortCodeCannotBeRegeneratedException::class); $this->expectExceptionMessage($expectedMessage); diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index 9df49879..f3054f49 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -43,7 +43,7 @@ class NotFoundRedirectHandlerTest extends TestCase */ public function expectedRedirectionIsReturnedDependingOnTheCase( ServerRequestInterface $request, - string $expectedRedirectTo + string $expectedRedirectTo, ): void { $this->redirectOptions->invalidShortUrl = 'invalidShortUrl'; $this->redirectOptions->regular404 = 'regular404'; diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php index a492f9dd..178a142f 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -79,7 +79,7 @@ class UpdateGeoLiteDbTest extends TestCase int $total, int $downloaded, bool $oldDbExists, - ?string $expectedMessage + ?string $expectedMessage, ): void { $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( function (array $args) use ($total, $downloaded, $oldDbExists): void { diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index a7028a02..43dcc2e5 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -21,7 +21,7 @@ class DeleteShortUrlExceptionTest extends TestCase public function fromVisitsThresholdGeneratesMessageProperly( int $threshold, string $shortCode, - string $expectedMessage + string $expectedMessage, ): void { $e = DeleteShortUrlException::fromVisitsThreshold($threshold, $shortCode); diff --git a/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php index c42f864a..40ccd0ee 100644 --- a/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php +++ b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php @@ -15,7 +15,7 @@ class ForbiddenTagOperationExceptionTest extends TestCase */ public function createsExpectedExceptionForDeletion( ForbiddenTagOperationException $e, - string $expectedMessage + string $expectedMessage, ): void { $this->assertExceptionShape($e, $expectedMessage); } diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index e6a48914..ea4e606d 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -17,7 +17,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase public function properlyCreatesExceptionFromNotFoundShortCode( string $expectedMessage, string $shortCode, - ?string $domain + ?string $domain, ): void { $expectedAdditional = ['shortCode' => $shortCode]; if ($domain !== null) { diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index d17c5720..1a4a4de1 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -160,7 +160,7 @@ class ImportedLinksProcessorTest extends TestCase ImportedShlinkUrl $importedUrl, string $expectedOutput, int $amountOfPersistedVisits, - ?ShortUrl $foundShortUrl + ?ShortUrl $foundShortUrl, ): void { $findExisting = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn($foundShortUrl); $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 5420e4b6..33fdb8f6 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -33,7 +33,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase array $tags = [], ?string $startDate = null, ?string $endDate = null, - ?string $orderBy = null + ?string $orderBy = null, ): void { $params = ShortUrlsParams::fromRawData([ 'searchTerm' => $searchTerm, @@ -58,7 +58,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase ?string $searchTerm = null, array $tags = [], ?string $startDate = null, - ?string $endDate = null + ?string $endDate = null, ): void { $params = ShortUrlsParams::fromRawData([ 'searchTerm' => $searchTerm, diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 2a9e5fc4..97a2c1f0 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -70,7 +70,7 @@ class VisitsPaginatorAdapterTest extends TestCase $this->repo->reveal(), new ShortUrlIdentifier(''), VisitsParams::fromRawData([]), - $apiKey !== null ? $apiKey->spec() : null, + $apiKey?->spec(), ); } } diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 73823729..41f2b492 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -49,7 +49,7 @@ class ShortUrlResolverTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null)->willReturn($shortUrl); + $findOne = $repo->findOne($identifier, $apiKey?->spec())->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $result = $this->urlResolver->resolveShortUrl($identifier, $apiKey); @@ -69,7 +69,7 @@ class ShortUrlResolverTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null)->willReturn(null); + $findOne = $repo->findOne($identifier, $apiKey?->spec())->willReturn(null); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal(), $apiKey); $this->expectException(ShortUrlNotFoundException::class); diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 67420edc..b07d4df9 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -82,7 +82,7 @@ class ShortUrlServiceTest extends TestCase public function updateShortUrlUpdatesProvidedData( int $expectedValidateCalls, ShortUrlEdit $shortUrlEdit, - ?ApiKey $apiKey + ?ApiKey $apiKey, ): void { $originalLongUrl = 'originalLongUrl'; $shortUrl = ShortUrl::withLongUrl($originalLongUrl); diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index 483fd57d..b4acc417 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -19,7 +19,7 @@ class ShortUrlStringifierTest extends TestCase array $config, string $basePath, ShortUrl $shortUrl, - string $expected + string $expected, ): void { $stringifier = new ShortUrlStringifier($config, $basePath); diff --git a/module/Core/test/Util/DoctrineBatchHelperTest.php b/module/Core/test/Util/DoctrineBatchHelperTest.php index b655c070..f6f9981d 100644 --- a/module/Core/test/Util/DoctrineBatchHelperTest.php +++ b/module/Core/test/Util/DoctrineBatchHelperTest.php @@ -31,7 +31,7 @@ class DoctrineBatchHelperTest extends TestCase public function entityManagerIsFlushedAndClearedTheExpectedAmountOfTimes( array $iterable, int $batchSize, - int $expectedCalls + int $expectedCalls, ): void { $wrappedIterable = $this->helper->wrapIterable($iterable, $batchSize); diff --git a/module/Core/test/Util/RedirectResponseHelperTest.php b/module/Core/test/Util/RedirectResponseHelperTest.php index 0eb8c0fe..eb26768f 100644 --- a/module/Core/test/Util/RedirectResponseHelperTest.php +++ b/module/Core/test/Util/RedirectResponseHelperTest.php @@ -28,7 +28,7 @@ class RedirectResponseHelperTest extends TestCase int $configuredStatus, int $configuredLifetime, int $expectedStatus, - ?string $expectedCacheControl + ?string $expectedCacheControl, ): void { $this->shortenerOpts->redirectStatusCode = $configuredStatus; $this->shortenerOpts->redirectCacheLifetime = $configuredLifetime; diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index 25710172..57a5d3ce 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -90,7 +90,7 @@ class UrlValidatorTest extends TestCase */ public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled( ?bool $doValidate, - bool $validateUrl + bool $validateUrl, ): void { $request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class); $this->options->validateUrl = $validateUrl; diff --git a/module/Core/test/Visit/VisitLocatorTest.php b/module/Core/test/Visit/VisitLocatorTest.php index 11e7062f..5c51b848 100644 --- a/module/Core/test/Visit/VisitLocatorTest.php +++ b/module/Core/test/Visit/VisitLocatorTest.php @@ -53,7 +53,7 @@ class VisitLocatorTest extends TestCase */ public function locateVisitsIteratesAndLocatesExpectedVisits( string $serviceMethodName, - string $expectedRepoMethodName + string $expectedRepoMethodName, ): void { $unlocatedVisits = map( range(1, 200), @@ -105,7 +105,7 @@ class VisitLocatorTest extends TestCase public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty( string $serviceMethodName, string $expectedRepoMethodName, - bool $isNonLocatableAddress + bool $isNonLocatableAddress, ): void { $unlocatedVisits = [ Visit::forValidShortUrl(ShortUrl::withLongUrl('foo'), Visitor::emptyInstance()), diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index cae3fbb1..ab76bbf1 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -80,7 +80,7 @@ class VisitsStatsHelperTest extends TestCase { $shortCode = '123ABC'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $spec = $apiKey === null ? null : $apiKey->spec(); + $spec = $apiKey?->spec(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); $count = $repo->shortCodeIsInUse($identifier, $spec)->willReturn( diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 0317390e..6c63c67b 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -116,7 +116,7 @@ class ApiKey extends AbstractEntity { /** @var ApiKeyRole|null $role */ $role = $this->roles->get($roleName); - return $role === null ? [] : $role->meta(); + return $role?->meta() ?? []; } public function mapRoles(callable $fun): array diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 0aad928f..545ff310 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -22,7 +22,7 @@ class ApiKeyService implements ApiKeyServiceInterface public function create( ?Chronos $expirationDate = null, ?string $name = null, - RoleDefinition ...$roleDefinitions + RoleDefinition ...$roleDefinitions, ): ApiKey { $key = $this->buildApiKeyWithParams($expirationDate, $name); foreach ($roleDefinitions as $definition) { diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 982bdf4f..85b726df 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -14,7 +14,7 @@ interface ApiKeyServiceInterface public function create( ?Chronos $expirationDate = null, ?string $name = null, - RoleDefinition ...$roleDefinitions + RoleDefinition ...$roleDefinitions, ): ApiKey; public function check(string $key): ApiKeyCheckResult; diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 360287ec..479527c1 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -19,7 +19,7 @@ class DeleteShortUrlTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/EditShortUrlTagsTest.php b/module/Rest/test-api/Action/EditShortUrlTagsTest.php index 18f6f3b0..f940a52d 100644 --- a/module/Rest/test-api/Action/EditShortUrlTagsTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTagsTest.php @@ -35,7 +35,7 @@ class EditShortUrlTagsTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $url = $this->buildShortUrlPath($shortCode, $domain, '/tags'); $resp = $this->callApiWithKey(self::METHOD_PUT, $url, [RequestOptions::JSON => [ diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 6652c1a4..a25ccddd 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -6,12 +6,12 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; +use GuzzleHttp\Psr7\Query; use GuzzleHttp\RequestOptions; use Laminas\Diactoros\Uri; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; -use function GuzzleHttp\Psr7\build_query; use function sprintf; class EditShortUrlTest extends ApiTestCase @@ -105,7 +105,7 @@ class EditShortUrlTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $url = $this->buildShortUrlPath($shortCode, $domain); $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []], $apiKey); @@ -147,7 +147,7 @@ class EditShortUrlTest extends ApiTestCase $url = new Uri(sprintf('/short-urls/%s', $shortCode)); if ($domain !== null) { - $url = $url->withQuery(build_query(['domain' => $domain])); + $url = $url->withQuery(Query::build(['domain' => $domain])); } $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index 067cf9a4..21f4cae1 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -45,7 +45,7 @@ class OrphanVisitsTest extends ApiTestCase array $query, int $totalItems, int $expectedAmount, - array $expectedVisits + array $expectedVisits, ): void { $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', [RequestOptions::QUERY => $query]); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index ca99f058..216e35e9 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -51,7 +51,7 @@ class ResolveShortUrlTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 1d572004..327c7c05 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -23,7 +23,7 @@ class ShortUrlVisitsTest extends ApiTestCase string $shortCode, ?string $domain, string $expectedDetail, - string $apiKey + string $apiKey, ): void { $resp = $this->callApiWithKey( self::METHOD_GET, diff --git a/module/Rest/test-api/Action/TagVisitsTest.php b/module/Rest/test-api/Action/TagVisitsTest.php index 07b0576d..544fcccf 100644 --- a/module/Rest/test-api/Action/TagVisitsTest.php +++ b/module/Rest/test-api/Action/TagVisitsTest.php @@ -19,7 +19,7 @@ class TagVisitsTest extends ApiTestCase string $apiKey, string $tag, bool $excludeBots, - int $expectedVisitsAmount + int $expectedVisitsAmount, ): void { $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [ RequestOptions::QUERY => $excludeBots ? ['excludeBots' => true] : [], diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php index 093a200c..a51d6a7b 100644 --- a/module/Rest/test-api/Middleware/CorsTest.php +++ b/module/Rest/test-api/Middleware/CorsTest.php @@ -28,7 +28,7 @@ class CorsTest extends ApiTestCase public function responseIncludesCorsHeadersIfOriginIsSent( string $origin, string $endpoint, - int $expectedStatusCode + int $expectedStatusCode, ): void { $resp = $this->callApiWithKey(self::METHOD_GET, $endpoint, [ RequestOptions::HEADERS => ['Origin' => $origin], diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 712d605d..170ccc09 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -49,7 +49,7 @@ class ListShortUrlsActionTest extends TestCase array $expectedTags, ?string $expectedOrderBy, ?string $startDate = null, - ?string $endDate = null + ?string $endDate = null, ): void { $apiKey = ApiKey::create(); $request = (new ServerRequest())->withQueryParams($query)->withAttribute(ApiKey::class, $apiKey); diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index 68503b58..c915098a 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -88,7 +88,7 @@ class AuthenticationMiddlewareTest extends TestCase */ public function throwsExceptionWhenNoApiKeyIsProvided( ServerRequestInterface $request, - string $expectedMessage + string $expectedMessage, ): void { $this->apiKeyService->check(Argument::any())->shouldNotBeCalled(); $this->handler->handle($request)->shouldNotBeCalled(); diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index a274e1f8..acdc9600 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -91,7 +91,7 @@ class CrossDomainMiddlewareTest extends TestCase */ public function optionsRequestParsesRouteMatchToDetermineAllowedMethods( ?string $allowHeader, - string $expectedAllowedMethods + string $expectedAllowedMethods, ): void { $originalResponse = new Response(); if ($allowHeader !== null) { @@ -121,7 +121,7 @@ class CrossDomainMiddlewareTest extends TestCase public function expectedStatusCodeIsReturnDependingOnRequestMethod( string $method, int $status, - int $expectedStatus + int $expectedStatus, ): void { $originalResponse = (new Response())->withStatus($status); $request = (new ServerRequest())->withMethod($method) From 9c6ba4bc6179cf9638c744c9f74eec99e56c2aae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 May 2021 12:37:53 +0200 Subject: [PATCH 03/69] More PHP 8 syntactic sugar --- CHANGELOG.md | 17 +++++++++++++++++ data/migrations/Version20180913205455.php | 2 +- .../Util/AbstractWithDateRangeCommand.php | 2 +- .../Command/Visit/DownloadGeoLiteDbCommand.php | 2 +- .../src/Command/Visit/LocateVisitsCommand.php | 4 ++-- module/Core/src/Entity/Visit.php | 2 +- .../CloseDbConnectionEventListenerTest.php | 2 +- ...eateShortUrlContentNegotiationMiddleware.php | 4 ++-- .../ShortUrl/ResolveShortUrlActionTest.php | 4 +--- 9 files changed, 27 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7df04c1..d967d392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [2.7.0] - 2021-05-23 ### Added * [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows. diff --git a/data/migrations/Version20180913205455.php b/data/migrations/Version20180913205455.php index ee6cd861..727e4400 100644 --- a/data/migrations/Version20180913205455.php +++ b/data/migrations/Version20180913205455.php @@ -57,7 +57,7 @@ final class Version20180913205455 extends AbstractMigration try { return (string) IpAddress::fromString($addr)->getAnonymizedCopy(); - } catch (InvalidArgumentException $e) { + } catch (InvalidArgumentException) { return null; } } diff --git a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php index 39e60c9a..51731e3a 100644 --- a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php +++ b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php @@ -63,7 +63,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand )); if ($output->isVeryVerbose()) { - $this->getApplication()->renderThrowable($e, $output); + $this->getApplication()?->renderThrowable($e, $output); } return null; diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index f86edfd1..aef30427 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -69,7 +69,7 @@ class DownloadGeoLiteDbCommand extends Command } if ($io->isVerbose()) { - $this->getApplication()->renderThrowable($e, $io); + $this->getApplication()?->renderThrowable($e, $io); } return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE; diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index a9854c5b..31b1fb05 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -119,7 +119,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat } catch (Throwable $e) { $this->io->error($e->getMessage()); if ($this->io->isVerbose()) { - $this->getApplication()->renderThrowable($e, $this->io); + $this->getApplication()?->renderThrowable($e, $this->io); } return ExitCodes::EXIT_FAILURE; @@ -151,7 +151,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat } catch (WrongIpException $e) { $this->io->writeln(' [An error occurred while locating IP. Skipped]'); if ($this->io->isVerbose()) { - $this->getApplication()->renderThrowable($e, $this->io); + $this->getApplication()?->renderThrowable($e, $this->io); } throw IpCannotBeLocatedException::forError($e); diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index 358bedde..8174e8be 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -104,7 +104,7 @@ class Visit extends AbstractEntity implements JsonSerializable try { return (string) IpAddress::fromString($address)->getAnonymizedCopy(); - } catch (InvalidArgumentException $e) { + } catch (InvalidArgumentException) { return null; } } diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php index 5f08e5fe..3d830cf3 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php @@ -44,7 +44,7 @@ class CloseDbConnectionEventListenerTest extends TestCase try { ($eventListener)(new stdClass()); - } catch (Throwable $e) { + } catch (Throwable) { // Ignore exceptions } diff --git a/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php b/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php index a0896976..08503757 100644 --- a/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddleware.php @@ -13,7 +13,7 @@ use Psr\Http\Server\RequestHandlerInterface; use function array_shift; use function explode; -use function strpos; +use function str_contains; use function strtolower; class CreateShortUrlContentNegotiationMiddleware implements MiddlewareInterface @@ -62,7 +62,7 @@ class CreateShortUrlContentNegotiationMiddleware implements MiddlewareInterface { $accepts = explode(',', $acceptValue); $accept = strtolower(array_shift($accepts)); - return strpos($accept, 'text/plain') !== false ? self::PLAIN_TEXT : self::JSON; + return str_contains($accept, 'text/plain') ? self::PLAIN_TEXT : self::JSON; } private function determineBody(JsonResponse $resp): string diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index 6f8ddbb9..04ffb107 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -16,8 +16,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function strpos; - class ResolveShortUrlActionTest extends TestCase { use ProphecyTrait; @@ -46,6 +44,6 @@ class ResolveShortUrlActionTest extends TestCase $response = $this->action->handle($request); self::assertEquals(200, $response->getStatusCode()); - self::assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0); + self::assertStringContainsString('http://domain.com/foo/bar', $response->getBody()->getContents()); } } From d1df225e471384de36da2d61a573cfe148314327 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 May 2021 12:39:00 +0200 Subject: [PATCH 04/69] Moved changelog line --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d967d392..349788a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Changed -* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4. +* *Nothing* ### Deprecated * *Nothing* ### Removed -* *Nothing* +* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4. ### Fixed * *Nothing* From bfdece1c237e0a0d7ca348cd554feb7d8e87d456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Bernardini?= Date: Wed, 26 May 2021 15:45:24 +0200 Subject: [PATCH 05/69] add ENABLE_PERIODIC_VISIT_LOCATE opt-in This will trigger `visit:locate` every hour --- docker/docker-entrypoint.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 1f9337c4..b1923fe5 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -21,6 +21,15 @@ if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then php bin/cli visit:download-db -n -q fi +# Periodicaly run visit:locate every hour +# https://shlink.io/documentation/long-running-tasks/#locate-visits +# set env var "ENABLE_PERIODIC_VISIT_LOCATE=1" to enable +if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then + echo "Starting periodic visite locate..." + echo "0 * * * * php bin/cli visit:locate -q" > /etc/crontabs/root + /usr/sbin/crond & +fi + # When restarting the container, swoole might think it is already in execution # This forces the app to be started every second until the exit code is 0 until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done From b9e5eaf6899c56cb493720084acce5a4ce63cfd8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 30 May 2021 17:41:00 +0200 Subject: [PATCH 06/69] Update docker/docker-entrypoint.sh --- docker/docker-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index b1923fe5..9a199028 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -26,7 +26,7 @@ fi # set env var "ENABLE_PERIODIC_VISIT_LOCATE=1" to enable if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then echo "Starting periodic visite locate..." - echo "0 * * * * php bin/cli visit:locate -q" > /etc/crontabs/root + echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root /usr/sbin/crond & fi From 58262e8604a5509d306689d1978efe4641e1fc37 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 30 May 2021 17:41:40 +0200 Subject: [PATCH 07/69] Update docker/docker-entrypoint.sh --- docker/docker-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 9a199028..915c5f83 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -23,7 +23,7 @@ fi # Periodicaly run visit:locate every hour # https://shlink.io/documentation/long-running-tasks/#locate-visits -# set env var "ENABLE_PERIODIC_VISIT_LOCATE=1" to enable +# set env var "ENABLE_PERIODIC_VISIT_LOCATE=true" to enable if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then echo "Starting periodic visite locate..." echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root From d8b4827601c29f2c5c6c34d6a67cc82149fb588c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 30 May 2021 17:55:30 +0200 Subject: [PATCH 08/69] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f32bad..6a7410ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* *Nothing* +* [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`. ### Changed * *Nothing* From 655652f94f26aa84fb7e2154ea4247cd54633252 Mon Sep 17 00:00:00 2001 From: Sonny Alves Dias Date: Sun, 13 Jun 2021 22:24:20 +0800 Subject: [PATCH 09/69] Update CONTRIBUTING.md Fixing a typo --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 837f7593..28f174dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,7 +121,7 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, For example, `test:db:postgres`. * Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used. -* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/). +* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/). * Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration. * Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible. From 090b215179a5b3de4e41e6700b1d379e6f5629b6 Mon Sep 17 00:00:00 2001 From: kanadaj Date: Sun, 13 Jun 2021 23:51:16 +0100 Subject: [PATCH 10/69] Update Dockerfile --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Dockerfile b/Dockerfile index c07adc28..9858d54b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -78,4 +78,9 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/ +# Change the ownership of /etc/shlink/data to be writable, then change the user to non-root +RUN chown 1001 -R /etc/shlink/data + +USER 1001 + ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"] From 2b97f9ac9ee5809212113dfb8987958ae7bc5037 Mon Sep 17 00:00:00 2001 From: kanadaj Date: Sun, 13 Jun 2021 23:54:35 +0100 Subject: [PATCH 11/69] Update Dockerfile Security update --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9858d54b..e327c092 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,7 +79,11 @@ COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.l COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/ # Change the ownership of /etc/shlink/data to be writable, then change the user to non-root -RUN chown 1001 -R /etc/shlink/data +RUN chown 1001 /etc/shlink/data +RUN chown 1001 /etc/shlink/data/locks +RUN chown 1001 /etc/shlink/data/proxies +RUN chown 1001 /etc/shlink/data/cache +RUN chown 1001 /etc/shlink/data/log USER 1001 From 5a2350bac17d65eeb02f83a78d2633e7166d5d67 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Jul 2021 13:22:50 +0200 Subject: [PATCH 12/69] Added suport for error correction level to QR codes --- docs/swagger/paths/{shortCode}_qr-code.json | 15 +++++++--- module/Core/src/Action/QrCodeAction.php | 32 +++++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 00502ad5..43c1d38a 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -35,10 +35,7 @@ "required": false, "schema": { "type": "string", - "enum": [ - "png", - "svg" - ] + "enum": ["png", "svg"] } }, { @@ -51,6 +48,16 @@ "minimum": 0, "default": 0 } + }, + { + "name": "errorCorrection", + "in": "query", + "description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).", + "required": false, + "schema": { + "type": "string", + "enum": ["L", "M", "Q", "H"] + } } ], "responses": { diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 177d90fc..a8dee3fd 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -5,6 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; use Endroid\QrCode\Builder\Builder; +use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; +use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface; +use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow; +use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium; +use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile; use Endroid\QrCode\Writer\SvgWriter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -18,6 +23,9 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; +use function strtoupper; +use function trim; + class QrCodeAction implements MiddlewareInterface { private const DEFAULT_SIZE = 300; @@ -46,17 +54,18 @@ class QrCodeAction implements MiddlewareInterface } $query = $request->getQueryParams(); - $qrCode = Builder::create() + $qrCodeBuilder = Builder::create() ->data($this->stringifier->stringify($shortUrl)) ->size($this->resolveSize($request, $query)) - ->margin($this->resolveMargin($query)); + ->margin($this->resolveMargin($query)) + ->errorCorrectionLevel($this->resolveErrorCorrection($query)); $format = $query['format'] ?? 'png'; if ($format === 'svg') { - $qrCode->writer(new SvgWriter()); + $qrCodeBuilder->writer(new SvgWriter()); } - return new QrCodeResponse($qrCode->build()); + return new QrCodeResponse($qrCodeBuilder->build()); } private function resolveSize(Request $request, array $query): int @@ -72,11 +81,11 @@ class QrCodeAction implements MiddlewareInterface private function resolveMargin(array $query): int { - if (! isset($query['margin'])) { + $margin = $query['margin'] ?? null; + if ($margin === null) { return 0; } - $margin = $query['margin']; $intMargin = (int) $margin; if ($margin !== (string) $intMargin) { return 0; @@ -84,4 +93,15 @@ class QrCodeAction implements MiddlewareInterface return $intMargin < 0 ? 0 : $intMargin; } + + private function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface + { + $errorCorrectionLevel = strtoupper(trim($query['errorCorrection'] ?? '')); + return match ($errorCorrectionLevel) { + 'H' => new ErrorCorrectionLevelHigh(), + 'Q' => new ErrorCorrectionLevelQuartile(), + 'M' => new ErrorCorrectionLevelMedium(), + default => new ErrorCorrectionLevelLow(), // 'L' + }; + } } From d6e155d87435b4ccf6ddf94bfc7a6b0f9d95ead8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Jul 2021 13:45:46 +0200 Subject: [PATCH 13/69] Extracted logic to determine QR code params to its own data object --- module/Core/src/Action/Model/QrCodeParams.php | 113 ++++++++++++++++++ module/Core/src/Action/QrCodeAction.php | 71 ++--------- module/Core/test/Action/QrCodeActionTest.php | 2 + 3 files changed, 122 insertions(+), 64 deletions(-) create mode 100644 module/Core/src/Action/Model/QrCodeParams.php diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php new file mode 100644 index 00000000..bcde20c6 --- /dev/null +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -0,0 +1,113 @@ +getQueryParams(); + + return new self( + self::resolveSize($request, $query), + self::resolveMargin($query), + self::resolveWriter($query), + self::resolveErrorCorrection($query), + ); + } + + private static function resolveSize(Request $request, array $query): int + { + // FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead + $size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE); + if ($size < self::MIN_SIZE) { + return self::MIN_SIZE; + } + + return $size > self::MAX_SIZE ? self::MAX_SIZE : $size; + } + + private static function resolveMargin(array $query): int + { + $margin = $query['margin'] ?? null; + if ($margin === null) { + return 0; + } + + $intMargin = (int) $margin; + if ($margin !== (string) $intMargin) { + return 0; + } + + return $intMargin < 0 ? 0 : $intMargin; + } + + private static function resolveWriter(array $query): WriterInterface + { + $format = strtolower(trim($query['format'] ?? 'png')); + return match ($format) { + 'svg' => new SvgWriter(), + default => new PngWriter(), + }; + } + + private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface + { + $errorCorrectionLevel = strtoupper(trim($query['errorCorrection'] ?? '')); + return match ($errorCorrectionLevel) { + 'H' => new ErrorCorrectionLevelHigh(), + 'Q' => new ErrorCorrectionLevelQuartile(), + 'M' => new ErrorCorrectionLevelMedium(), + default => new ErrorCorrectionLevelLow(), // 'L' + }; + } + + public function size(): int + { + return $this->size; + } + + public function margin(): int + { + return $this->margin; + } + + public function writer(): WriterInterface + { + return $this->writer; + } + + public function errorCorrectionLevel(): ErrorCorrectionLevelInterface + { + return $this->errorCorrectionLevel; + } +} diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index a8dee3fd..2f816c98 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -5,41 +5,25 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; use Endroid\QrCode\Builder\Builder; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile; -use Endroid\QrCode\Writer\SvgWriter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\QrCodeResponse; +use Shlinkio\Shlink\Core\Action\Model\QrCodeParams; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; -use function strtoupper; -use function trim; - class QrCodeAction implements MiddlewareInterface { - private const DEFAULT_SIZE = 300; - private const MIN_SIZE = 50; - private const MAX_SIZE = 1000; - - private LoggerInterface $logger; - public function __construct( private ShortUrlResolverInterface $urlResolver, private ShortUrlStringifierInterface $stringifier, - ?LoggerInterface $logger = null + private LoggerInterface $logger ) { - $this->logger = $logger ?? new NullLogger(); } public function process(Request $request, RequestHandlerInterface $handler): Response @@ -53,55 +37,14 @@ class QrCodeAction implements MiddlewareInterface return $handler->handle($request); } - $query = $request->getQueryParams(); + $params = QrCodeParams::fromRequest($request); $qrCodeBuilder = Builder::create() ->data($this->stringifier->stringify($shortUrl)) - ->size($this->resolveSize($request, $query)) - ->margin($this->resolveMargin($query)) - ->errorCorrectionLevel($this->resolveErrorCorrection($query)); - - $format = $query['format'] ?? 'png'; - if ($format === 'svg') { - $qrCodeBuilder->writer(new SvgWriter()); - } + ->size($params->size()) + ->margin($params->margin()) + ->writer($params->writer()) + ->errorCorrectionLevel($params->errorCorrectionLevel()); return new QrCodeResponse($qrCodeBuilder->build()); } - - private function resolveSize(Request $request, array $query): int - { - // Size attribute is deprecated. After v3.0.0, always use the query param instead - $size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE); - if ($size < self::MIN_SIZE) { - return self::MIN_SIZE; - } - - return $size > self::MAX_SIZE ? self::MAX_SIZE : $size; - } - - private function resolveMargin(array $query): int - { - $margin = $query['margin'] ?? null; - if ($margin === null) { - return 0; - } - - $intMargin = (int) $margin; - if ($margin !== (string) $intMargin) { - return 0; - } - - return $intMargin < 0 ? 0 : $intMargin; - } - - private function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface - { - $errorCorrectionLevel = strtoupper(trim($query['errorCorrection'] ?? '')); - return match ($errorCorrectionLevel) { - 'H' => new ErrorCorrectionLevelHigh(), - 'Q' => new ErrorCorrectionLevelQuartile(), - 'M' => new ErrorCorrectionLevelMedium(), - default => new ErrorCorrectionLevelLow(), // 'L' - }; - } } diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 7326c41c..0595734e 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -14,6 +14,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Core\Action\QrCodeAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -41,6 +42,7 @@ class QrCodeActionTest extends TestCase $this->action = new QrCodeAction( $this->urlResolver->reveal(), new ShortUrlStringifier(['domain' => 'doma.in']), + new NullLogger(), ); } From 01e06f0503b26e11096535dbac2eb49bf8cb241b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Jul 2021 13:53:10 +0200 Subject: [PATCH 14/69] Improved swagger docs for QR code endpoint --- docs/swagger/paths/{shortCode}_qr-code.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 43c1d38a..04a88fd7 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -5,7 +5,7 @@ "URL Shortener" ], "summary": "Short URL QR code", - "description": "Generates a QR code image pointing to a short URL", + "description": "Generates a QR code image pointing to a short URL.
Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.", "parameters": [ { "name": "shortCode", @@ -35,7 +35,8 @@ "required": false, "schema": { "type": "string", - "enum": ["png", "svg"] + "enum": ["png", "svg"], + "default": "png" } }, { @@ -56,7 +57,8 @@ "required": false, "schema": { "type": "string", - "enum": ["L", "M", "Q", "H"] + "enum": ["L", "M", "Q", "H"], + "default": "L" } } ], From 67c7e503d97bb61765d3d7309e7a1ebf9acce9d3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Jul 2021 13:55:00 +0200 Subject: [PATCH 15/69] Used lowercase values when trying to match the QR code error level --- module/Core/src/Action/Model/QrCodeParams.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index bcde20c6..742d3f07 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -16,7 +16,6 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface as Request; use function strtolower; -use function strtoupper; use function trim; final class QrCodeParams @@ -82,12 +81,12 @@ final class QrCodeParams private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface { - $errorCorrectionLevel = strtoupper(trim($query['errorCorrection'] ?? '')); + $errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l')); return match ($errorCorrectionLevel) { - 'H' => new ErrorCorrectionLevelHigh(), - 'Q' => new ErrorCorrectionLevelQuartile(), - 'M' => new ErrorCorrectionLevelMedium(), - default => new ErrorCorrectionLevelLow(), // 'L' + 'h' => new ErrorCorrectionLevelHigh(), + 'q' => new ErrorCorrectionLevelQuartile(), + 'm' => new ErrorCorrectionLevelMedium(), + default => new ErrorCorrectionLevelLow(), // 'l' }; } From 6466045363a0107030acaa3df2983b0cd0f33381 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Jul 2021 14:00:54 +0200 Subject: [PATCH 16/69] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7410ac..76e14a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added * [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`. +* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes. + + Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High. ### Changed * *Nothing* From d4cad337fc7293d8050d0cfe3f3c0bec2ce9b481 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 14 Jul 2021 16:36:03 +0200 Subject: [PATCH 17/69] Created component wrapping the logic to determine what's the URL to redirect to for a ShortUrl --- module/Core/config/dependencies.config.php | 4 ++ .../src/Action/AbstractTrackingAction.php | 26 +++------- module/Core/src/Action/RedirectAction.php | 4 +- .../Helper/ShortUrlRedirectionBuilder.php | 50 +++++++++++++++++++ .../ShortUrlRedirectionBuilderInterface.php | 12 +++++ module/Core/test/Action/PixelActionTest.php | 5 ++ .../Core/test/Action/RedirectActionTest.php | 37 ++++++++------ 7 files changed, 103 insertions(+), 35 deletions(-) create mode 100644 module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php create mode 100644 module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 7dfd5df2..7a53f343 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -53,6 +53,7 @@ return [ ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class, + ShortUrl\Helper\ShortUrlRedirectionBuilder::class => ConfigAbstractFactory::class, ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, @@ -117,6 +118,7 @@ return [ Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, Visit\VisitsTracker::class, + ShortUrl\Helper\ShortUrlRedirectionBuilder::class, Options\TrackingOptions::class, Util\RedirectResponseHelper::class, 'Logger_Shlink', @@ -124,6 +126,7 @@ return [ Action\PixelAction::class => [ Service\ShortUrl\ShortUrlResolver::class, Visit\VisitsTracker::class, + ShortUrl\Helper\ShortUrlRedirectionBuilder::class, Options\TrackingOptions::class, 'Logger_Shlink', ], @@ -137,6 +140,7 @@ return [ ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'], ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'], ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class], + ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class], ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], Mercure\MercureUpdatesGenerator::class => [ diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 7de21fa8..c6b2279d 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; use Fig\Http\Message\RequestMethodInterface; -use GuzzleHttp\Psr7\Query; -use League\Uri\Uri; use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -14,16 +12,15 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; use function array_key_exists; -use function array_merge; abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { @@ -32,6 +29,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet public function __construct( private ShortUrlResolverInterface $urlResolver, private VisitsTrackerInterface $visitTracker, + private ShortUrlRedirectionBuilderInterface $redirectionBuilder, private TrackingOptions $trackingOptions, ?LoggerInterface $logger = null ) { @@ -42,36 +40,24 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet { $identifier = ShortUrlIdentifier::fromRedirectRequest($request); $query = $request->getQueryParams(); - $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); try { $shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); - if ($this->shouldTrackRequest($request, $query, $disableTrackParam)) { + if ($this->shouldTrackRequest($request, $query)) { $this->visitTracker->track($shortUrl, Visitor::fromRequest($request)); } - return $this->createSuccessResp($this->buildUrlToRedirectTo($shortUrl, $query, $disableTrackParam)); + return $this->createSuccessResp($this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query)); } catch (ShortUrlNotFoundException $e) { $this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]); return $this->createErrorResp($request, $handler); } } - private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string - { - $uri = Uri::createFromString($shortUrl->getLongUrl()); - $hardcodedQuery = Query::parse($uri->getQuery() ?? ''); - if ($disableTrackParam !== null) { - unset($currentQuery[$disableTrackParam]); - } - $mergedQuery = array_merge($hardcodedQuery, $currentQuery); - - return (string) (empty($mergedQuery) ? $uri : $uri->withQuery(Query::build($mergedQuery))); - } - - private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool + private function shouldTrackRequest(ServerRequestInterface $request, array $query): bool { + $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); $forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE); if ($forwardedMethod === self::METHOD_HEAD) { return false; diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index e1c6757c..7c313c53 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -11,6 +11,7 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; @@ -19,11 +20,12 @@ class RedirectAction extends AbstractTrackingAction implements StatusCodeInterfa public function __construct( ShortUrlResolverInterface $urlResolver, VisitsTrackerInterface $visitTracker, + ShortUrlRedirectionBuilderInterface $redirectionBuilder, Options\TrackingOptions $trackingOptions, private RedirectResponseHelperInterface $redirectResponseHelper, ?LoggerInterface $logger = null ) { - parent::__construct($urlResolver, $visitTracker, $trackingOptions, $logger); + parent::__construct($urlResolver, $visitTracker, $redirectionBuilder, $trackingOptions, $logger); } protected function createSuccessResp(string $longUrl): Response diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php new file mode 100644 index 00000000..1c45698f --- /dev/null +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -0,0 +1,50 @@ +getLongUrl()); + + return $uri + ->withQuery($this->resolveQuery($uri, $currentQuery)) + ->withPath($this->resolvePath($uri, $extraPath)) + ->__toString(); + } + + private function resolveQuery(Uri $uri, array $currentQuery): string + { + $hardcodedQuery = Query::parse($uri->getQuery() ?? ''); + + $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); + if ($disableTrackParam !== null) { + unset($currentQuery[$disableTrackParam]); + } + + $mergedQuery = array_merge($hardcodedQuery, $currentQuery); + + return empty($mergedQuery) ? '' : Query::build($mergedQuery); + } + + private function resolvePath(Uri $uri, ?string $extraPath): string + { + $hardcodedPath = $uri->getPath(); + return $extraPath === null ? $hardcodedPath : sprintf('%s%s', $hardcodedPath, $extraPath); + } +} diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php new file mode 100644 index 00000000..d957ad14 --- /dev/null +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php @@ -0,0 +1,12 @@ +urlResolver = $this->prophesize(ShortUrlResolverInterface::class); $this->visitTracker = $this->prophesize(VisitsTracker::class); + $redirectBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); + $redirectBuilder->buildShortUrlRedirect(Argument::cetera())->willReturn('http://domain.com/foo/bar'); + $this->action = new PixelAction( $this->urlResolver->reveal(), $this->visitTracker->reveal(), + $redirectBuilder->reveal(), new TrackingOptions(), ); } diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index dde9144c..72408302 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -19,6 +19,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; @@ -28,6 +29,8 @@ class RedirectActionTest extends TestCase { use ProphecyTrait; + private const LONG_URL = 'https://domain.com/foo/bar?some=thing'; + private RedirectAction $action; private ObjectProphecy $urlResolver; private ObjectProphecy $visitTracker; @@ -39,9 +42,13 @@ class RedirectActionTest extends TestCase $this->visitTracker = $this->prophesize(VisitsTrackerInterface::class); $this->redirectRespHelper = $this->prophesize(RedirectResponseHelperInterface::class); + $redirectBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); + $redirectBuilder->buildShortUrlRedirect(Argument::cetera())->willReturn(self::LONG_URL); + $this->action = new RedirectAction( $this->urlResolver->reveal(), $this->visitTracker->reveal(), + $redirectBuilder->reveal(), new Options\TrackingOptions(['disableTrackParam' => 'foobar']), $this->redirectRespHelper->reveal(), ); @@ -51,17 +58,17 @@ class RedirectActionTest extends TestCase * @test * @dataProvider provideQueries */ - public function redirectionIsPerformedToLongUrl(string $expectedUrl, array $query): void + public function redirectionIsPerformedToLongUrl(array $query): void { $shortCode = 'abc123'; - $shortUrl = ShortUrl::withLongUrl('http://domain.com/foo/bar?some=thing'); + $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); $shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl( new ShortUrlIdentifier($shortCode, ''), )->willReturn($shortUrl); $track = $this->visitTracker->track(Argument::cetera())->will(function (): void { }); - $expectedResp = new Response\RedirectResponse($expectedUrl); - $buildResp = $this->redirectRespHelper->buildRedirectResponse($expectedUrl)->willReturn($expectedResp); + $expectedResp = new Response\RedirectResponse(self::LONG_URL); + $buildResp = $this->redirectRespHelper->buildRedirectResponse(self::LONG_URL)->willReturn($expectedResp); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withQueryParams($query); $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); @@ -74,12 +81,14 @@ class RedirectActionTest extends TestCase public function provideQueries(): iterable { - yield ['http://domain.com/foo/bar?some=thing', []]; - yield ['http://domain.com/foo/bar?some=thing', ['foobar' => 'notrack']]; - yield ['http://domain.com/foo/bar?some=thing&else', ['else' => null]]; - yield ['http://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar']]; - yield ['http://domain.com/foo/bar?some=overwritten&foo=bar', ['foo' => 'bar', 'some' => 'overwritten']]; - yield ['http://domain.com/foo/bar?some=overwritten', ['foobar' => 'notrack', 'some' => 'overwritten']]; + yield [[]]; + yield [['foobar' => 'notrack']]; + yield [['foobar' => 'barfoo']]; + yield [['foobar' => null]]; +// yield ['http://domain.com/foo/bar?some=thing&else', ['else' => null]]; +// yield ['http://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar']]; +// yield ['http://domain.com/foo/bar?some=overwritten&foo=bar', ['foo' => 'bar', 'some' => 'overwritten']]; +// yield ['http://domain.com/foo/bar?some=overwritten', ['foobar' => 'notrack', 'some' => 'overwritten']]; } /** @test */ @@ -104,13 +113,13 @@ class RedirectActionTest extends TestCase public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void { $shortCode = 'abc123'; - $shortUrl = ShortUrl::withLongUrl('http://domain.com/foo/bar?some=thing'); + $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl); $track = $this->visitTracker->track(Argument::cetera())->will(function (): void { }); - $buildResp = $this->redirectRespHelper->buildRedirectResponse( - 'http://domain.com/foo/bar?some=thing', - )->willReturn(new Response\RedirectResponse('')); + $buildResp = $this->redirectRespHelper->buildRedirectResponse(self::LONG_URL)->willReturn( + new Response\RedirectResponse(''), + ); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode) ->withAttribute( From fe5460e0c59b4e7497d2c41dce79d39968a7a755 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 14 Jul 2021 16:44:21 +0200 Subject: [PATCH 18/69] Created ShortUrlRedirectBuilder test --- .../Core/test/Action/RedirectActionTest.php | 4 -- .../Helper/ShortUrlRedirectionBuilderTest.php | 49 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index 72408302..e3c4694a 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -85,10 +85,6 @@ class RedirectActionTest extends TestCase yield [['foobar' => 'notrack']]; yield [['foobar' => 'barfoo']]; yield [['foobar' => null]]; -// yield ['http://domain.com/foo/bar?some=thing&else', ['else' => null]]; -// yield ['http://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar']]; -// yield ['http://domain.com/foo/bar?some=overwritten&foo=bar', ['foo' => 'bar', 'some' => 'overwritten']]; -// yield ['http://domain.com/foo/bar?some=overwritten', ['foobar' => 'notrack', 'some' => 'overwritten']]; } /** @test */ diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php new file mode 100644 index 00000000..c64147c1 --- /dev/null +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -0,0 +1,49 @@ +trackingOptions = new TrackingOptions(['disable_track_param' => 'foobar']); + $this->redirectionBuilder = new ShortUrlRedirectionBuilder($this->trackingOptions); + } + + /** + * @test + * @dataProvider provideData + */ + public function buildShortUrlRedirectBuildsExpectedUrl(string $expectedUrl, array $query, ?string $extraPath): void + { + $shortUrl = ShortUrl::withLongUrl('https://domain.com/foo/bar?some=thing'); + $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); + + self::assertEquals($expectedUrl, $result); + } + + public function provideData(): iterable + { + yield ['https://domain.com/foo/bar?some=thing', [], null]; + yield ['https://domain.com/foo/bar?some=thing&else', ['else' => null], null]; + yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null]; + yield ['https://domain.com/foo/bar?some=overwritten&foo=bar', ['foo' => 'bar', 'some' => 'overwritten'], null]; + yield ['https://domain.com/foo/bar?some=overwritten', ['foobar' => 'notrack', 'some' => 'overwritten'], null]; + yield ['https://domain.com/foo/bar/something/else-baz?some=thing', [], '/something/else-baz']; + yield [ + 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', + ['hello' => 'world'], + '/something/else-baz', + ]; + } +} From 265e8cdeaf4dc27e4fa15c5262b158de35a2bccf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Jul 2021 13:28:31 +0200 Subject: [PATCH 19/69] Refactored tracking actions --- config/autoload/url-shortener.global.php | 5 +++- module/Core/config/dependencies.config.php | 5 +--- .../src/Action/AbstractTrackingAction.php | 24 ++++++++----------- module/Core/src/Action/PixelAction.php | 3 ++- module/Core/src/Action/RedirectAction.php | 16 ++++--------- .../Core/src/Options/UrlShortenerOptions.php | 11 +++++++++ module/Core/test/Action/PixelActionTest.php | 5 ---- .../Core/test/Action/RedirectActionTest.php | 2 +- 8 files changed, 34 insertions(+), 37 deletions(-) diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index d7cd8b02..4a6afbc2 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -16,9 +16,12 @@ return [ 'validate_url' => false, // Deprecated 'visits_webhooks' => [], 'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH, + 'auto_resolve_titles' => false, + 'append_extra_path' => false, + + // TODO Move these two options to their own config namespace. Maybe "redirects". 'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE, 'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME, - 'auto_resolve_titles' => false, ], ]; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 7a53f343..34de226d 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -118,17 +118,14 @@ return [ Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, Visit\VisitsTracker::class, - ShortUrl\Helper\ShortUrlRedirectionBuilder::class, Options\TrackingOptions::class, + ShortUrl\Helper\ShortUrlRedirectionBuilder::class, Util\RedirectResponseHelper::class, - 'Logger_Shlink', ], Action\PixelAction::class => [ Service\ShortUrl\ShortUrlResolver::class, Visit\VisitsTracker::class, - ShortUrl\Helper\ShortUrlRedirectionBuilder::class, Options\TrackingOptions::class, - 'Logger_Shlink', ], Action\QrCodeAction::class => [ Service\ShortUrl\ShortUrlResolver::class, diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index c6b2279d..b0f3d6ee 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -7,33 +7,27 @@ namespace Shlinkio\Shlink\Core\Action; use Fig\Http\Message\RequestMethodInterface; use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; use function array_key_exists; abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { - private LoggerInterface $logger; - public function __construct( private ShortUrlResolverInterface $urlResolver, private VisitsTrackerInterface $visitTracker, - private ShortUrlRedirectionBuilderInterface $redirectionBuilder, private TrackingOptions $trackingOptions, - ?LoggerInterface $logger = null ) { - $this->logger = $logger ?? new NullLogger(); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -48,9 +42,8 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet $this->visitTracker->track($shortUrl, Visitor::fromRequest($request)); } - return $this->createSuccessResp($this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query)); + return $this->createSuccessResp($shortUrl, $request); } catch (ShortUrlNotFoundException $e) { - $this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]); return $this->createErrorResp($request, $handler); } } @@ -66,10 +59,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query); } - abstract protected function createSuccessResp(string $longUrl): ResponseInterface; - - abstract protected function createErrorResp( + abstract protected function createSuccessResp( + ShortUrl $shortUrl, ServerRequestInterface $request, - RequestHandlerInterface $handler, ): ResponseInterface; + + protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response + { + return $handler->handle($request); + } } diff --git a/module/Core/src/Action/PixelAction.php b/module/Core/src/Action/PixelAction.php index 3f67bdec..0cf2a801 100644 --- a/module/Core/src/Action/PixelAction.php +++ b/module/Core/src/Action/PixelAction.php @@ -8,10 +8,11 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Common\Response\PixelResponse; +use Shlinkio\Shlink\Core\Entity\ShortUrl; class PixelAction extends AbstractTrackingAction { - protected function createSuccessResp(string $longUrl): ResponseInterface + protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): ResponseInterface { return new PixelResponse(); } diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 7c313c53..2711f5bc 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Core\Action; use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; @@ -20,21 +19,16 @@ class RedirectAction extends AbstractTrackingAction implements StatusCodeInterfa public function __construct( ShortUrlResolverInterface $urlResolver, VisitsTrackerInterface $visitTracker, - ShortUrlRedirectionBuilderInterface $redirectionBuilder, Options\TrackingOptions $trackingOptions, + private ShortUrlRedirectionBuilderInterface $redirectionBuilder, private RedirectResponseHelperInterface $redirectResponseHelper, - ?LoggerInterface $logger = null ) { - parent::__construct($urlResolver, $visitTracker, $redirectionBuilder, $trackingOptions, $logger); + parent::__construct($urlResolver, $visitTracker, $trackingOptions); } - protected function createSuccessResp(string $longUrl): Response + protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response { + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request->getQueryParams()); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } - - protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response - { - return $handler->handle($request); - } } diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index a0005da2..31ecc137 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -19,6 +19,7 @@ class UrlShortenerOptions extends AbstractOptions private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; private bool $autoResolveTitles = false; + private bool $appendExtraPath = false; public function isUrlValidationEnabled(): bool { @@ -67,6 +68,16 @@ class UrlShortenerOptions extends AbstractOptions $this->autoResolveTitles = $autoResolveTitles; } + public function appendExtraPath(): bool + { + return $this->appendExtraPath; + } + + protected function setAppendExtraPath(bool $appendExtraPath): void + { + $this->appendExtraPath = $appendExtraPath; + } + /** @deprecated */ protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void { diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index 2436ac27..6df2498a 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -16,7 +16,6 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Visit\VisitsTracker; class PixelActionTest extends TestCase @@ -32,13 +31,9 @@ class PixelActionTest extends TestCase $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); $this->visitTracker = $this->prophesize(VisitsTracker::class); - $redirectBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); - $redirectBuilder->buildShortUrlRedirect(Argument::cetera())->willReturn('http://domain.com/foo/bar'); - $this->action = new PixelAction( $this->urlResolver->reveal(), $this->visitTracker->reveal(), - $redirectBuilder->reveal(), new TrackingOptions(), ); } diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index e3c4694a..cc5690d0 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -48,8 +48,8 @@ class RedirectActionTest extends TestCase $this->action = new RedirectAction( $this->urlResolver->reveal(), $this->visitTracker->reveal(), - $redirectBuilder->reveal(), new Options\TrackingOptions(['disableTrackParam' => 'foobar']), + $redirectBuilder->reveal(), $this->redirectRespHelper->reveal(), ); } From 32f7b4fbf6e979806dd0fc5d903c19010369178f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Jul 2021 16:54:54 +0200 Subject: [PATCH 20/69] Created new middleware that redirects to short URLs with an extra path --- .../autoload/middleware-pipeline.global.php | 1 + module/Core/config/dependencies.config.php | 8 ++ .../src/Action/AbstractTrackingAction.php | 2 +- .../Helper/ShortUrlRedirectionBuilder.php | 4 +- .../ExtraPathRedirectMiddleware.php | 74 +++++++++++++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index c60e1ba7..0466ebc5 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -68,6 +68,7 @@ return [ // This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking IpAddress::class, Core\ErrorHandler\NotFoundTypeResolverMiddleware::class, + Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class, Core\ErrorHandler\NotFoundTrackerMiddleware::class, Core\ErrorHandler\NotFoundRedirectHandler::class, Core\ErrorHandler\NotFoundTemplateHandler::class, diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 34de226d..baecce9f 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -55,6 +55,7 @@ return [ ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortUrlRedirectionBuilder::class => ConfigAbstractFactory::class, ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, + ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class, Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, @@ -139,6 +140,13 @@ return [ ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class], ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class], ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], + ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [ + Service\ShortUrl\ShortUrlResolver::class, + Visit\VisitsTracker::class, + ShortUrl\Helper\ShortUrlRedirectionBuilder::class, + Util\RedirectResponseHelper::class, + Options\UrlShortenerOptions::class, + ], Mercure\MercureUpdatesGenerator::class => [ ShortUrl\Transformer\ShortUrlDataTransformer::class, diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index b0f3d6ee..554c1844 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -43,7 +43,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet } return $this->createSuccessResp($shortUrl, $request); - } catch (ShortUrlNotFoundException $e) { + } catch (ShortUrlNotFoundException) { return $this->createErrorResp($request, $handler); } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index 1c45698f..43ea4993 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -28,7 +28,7 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface ->__toString(); } - private function resolveQuery(Uri $uri, array $currentQuery): string + private function resolveQuery(Uri $uri, array $currentQuery): ?string { $hardcodedQuery = Query::parse($uri->getQuery() ?? ''); @@ -39,7 +39,7 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface $mergedQuery = array_merge($hardcodedQuery, $currentQuery); - return empty($mergedQuery) ? '' : Query::build($mergedQuery); + return empty($mergedQuery) ? null : Query::build($mergedQuery); } private function resolvePath(Uri $uri, ?string $extraPath): string diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php new file mode 100644 index 00000000..401c7a0a --- /dev/null +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -0,0 +1,74 @@ +getAttribute(NotFoundType::class); + + // We'll apply this logic only if actively opted in and current URL is potentially /{shortCode}/[...] + if (! $notFoundType?->isRegularNotFound() || ! $this->urlShortenerOptions->appendExtraPath()) { + return $handler->handle($request); + } + + $uri = $request->getUri(); + $query = $request->getQueryParams(); + [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority()); + + try { + $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); + + // TODO Track visit + + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); + return $this->redirectResponseHelper->buildRedirectResponse($longUrl); + } catch (ShortUrlNotFoundException) { + return $handler->handle($request); + } + } + + /** + * @return array{0: string, 1: string|null} + */ + private function resolvePotentialShortCodeAndExtraPath(UriInterface $uri): array + { + $pathParts = explode('/', trim($uri->getPath(), '/'), 2); + [$potentialShortCode, $extraPath] = array_pad($pathParts, 2, null); + + return [$potentialShortCode, $extraPath === null ? null : sprintf('/%s', $extraPath)]; + } +} From 050f83e3bbfd8579a12461faa9753b65ae473ed2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Jul 2021 17:23:09 +0200 Subject: [PATCH 21/69] Wrapped logic to track requests to a new RequestTracker service --- module/Core/config/dependencies.config.php | 15 +-- .../src/Action/AbstractTrackingAction.php | 27 +---- module/Core/src/Action/RedirectAction.php | 8 +- .../NotFoundTrackerMiddleware.php | 19 +--- .../ExtraPathRedirectMiddleware.php | 7 +- module/Core/src/Visit/RequestTracker.php | 60 ++++++++++ .../src/Visit/RequestTrackerInterface.php | 15 +++ module/Core/test/Action/PixelActionTest.php | 15 +-- .../Core/test/Action/RedirectActionTest.php | 85 +++++++------- .../NotFoundTrackerMiddlewareTest.php | 106 ++++++++++-------- 10 files changed, 194 insertions(+), 163 deletions(-) create mode 100644 module/Core/src/Visit/RequestTracker.php create mode 100644 module/Core/src/Visit/RequestTrackerInterface.php diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index baecce9f..03147bcb 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -37,6 +37,7 @@ return [ Domain\DomainService::class => ConfigAbstractFactory::class, Visit\VisitsTracker::class => ConfigAbstractFactory::class, + Visit\RequestTracker::class => ConfigAbstractFactory::class, Visit\VisitLocator::class => ConfigAbstractFactory::class, Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class, @@ -71,7 +72,7 @@ return [ ConfigAbstractFactory::class => [ ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'], - ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class], + ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], ErrorHandler\NotFoundRedirectHandler::class => [ NotFoundRedirectOptions::class, Util\RedirectResponseHelper::class, @@ -94,6 +95,7 @@ return [ EventDispatcherInterface::class, Options\TrackingOptions::class, ], + Visit\RequestTracker::class => [Visit\VisitsTracker::class, Options\TrackingOptions::class], Service\ShortUrlService::class => [ 'em', Service\ShortUrl\ShortUrlResolver::class, @@ -118,16 +120,11 @@ return [ Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, - Visit\VisitsTracker::class, - Options\TrackingOptions::class, + Visit\RequestTracker::class, ShortUrl\Helper\ShortUrlRedirectionBuilder::class, Util\RedirectResponseHelper::class, ], - Action\PixelAction::class => [ - Service\ShortUrl\ShortUrlResolver::class, - Visit\VisitsTracker::class, - Options\TrackingOptions::class, - ], + Action\PixelAction::class => [Service\ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class], Action\QrCodeAction::class => [ Service\ShortUrl\ShortUrlResolver::class, ShortUrl\Helper\ShortUrlStringifier::class, @@ -142,7 +139,7 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [ Service\ShortUrl\ShortUrlResolver::class, - Visit\VisitsTracker::class, + Visit\RequestTracker::class, ShortUrl\Helper\ShortUrlRedirectionBuilder::class, Util\RedirectResponseHelper::class, Options\UrlShortenerOptions::class, diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 554c1844..8e9aaa09 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; use Fig\Http\Message\RequestMethodInterface; -use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface; @@ -14,33 +13,24 @@ use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; - -use function array_key_exists; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { public function __construct( private ShortUrlResolverInterface $urlResolver, - private VisitsTrackerInterface $visitTracker, - private TrackingOptions $trackingOptions, + private RequestTrackerInterface $requestTracker, ) { } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $identifier = ShortUrlIdentifier::fromRedirectRequest($request); - $query = $request->getQueryParams(); try { $shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); - - if ($this->shouldTrackRequest($request, $query)) { - $this->visitTracker->track($shortUrl, Visitor::fromRequest($request)); - } + $this->requestTracker->trackIfApplicable($shortUrl, $request); return $this->createSuccessResp($shortUrl, $request); } catch (ShortUrlNotFoundException) { @@ -48,17 +38,6 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet } } - private function shouldTrackRequest(ServerRequestInterface $request, array $query): bool - { - $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); - $forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE); - if ($forwardedMethod === self::METHOD_HEAD) { - return false; - } - - return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query); - } - abstract protected function createSuccessResp( ShortUrl $shortUrl, ServerRequestInterface $request, diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 2711f5bc..8126a85a 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -8,22 +8,20 @@ use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; -use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface { public function __construct( ShortUrlResolverInterface $urlResolver, - VisitsTrackerInterface $visitTracker, - Options\TrackingOptions $trackingOptions, + RequestTrackerInterface $requestTracker, private ShortUrlRedirectionBuilderInterface $redirectionBuilder, private RedirectResponseHelperInterface $redirectResponseHelper, ) { - parent::__construct($urlResolver, $visitTracker, $trackingOptions); + parent::__construct($urlResolver, $requestTracker); } protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response diff --git a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php index 473a0b60..f3342c5a 100644 --- a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php @@ -8,30 +8,17 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; -use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class NotFoundTrackerMiddleware implements MiddlewareInterface { - public function __construct(private VisitsTrackerInterface $visitsTracker) + public function __construct(private RequestTrackerInterface $requestTracker) { } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - /** @var NotFoundType $notFoundType */ - $notFoundType = $request->getAttribute(NotFoundType::class); - $visitor = Visitor::fromRequest($request); - - if ($notFoundType->isBaseUrl()) { - $this->visitsTracker->trackBaseUrlVisit($visitor); - } elseif ($notFoundType->isRegularNotFound()) { - $this->visitsTracker->trackRegularNotFoundVisit($visitor); - } elseif ($notFoundType->isInvalidShortUrl()) { - $this->visitsTracker->trackInvalidShortUrlVisit($visitor); - } - + $this->requestTracker->trackNotFoundIfApplicable($request); return $handler->handle($request); } } diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 401c7a0a..9d92067c 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; -use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; use function array_pad; use function explode; @@ -27,7 +27,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface { public function __construct( private ShortUrlResolverInterface $resolver, - private VisitsTrackerInterface $visitTracker, + private RequestTrackerInterface $requestTracker, private ShortUrlRedirectionBuilderInterface $redirectionBuilder, private RedirectResponseHelperInterface $redirectResponseHelper, private UrlShortenerOptions $urlShortenerOptions, @@ -51,8 +51,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface try { $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); - - // TODO Track visit + $this->requestTracker->trackIfApplicable($shortUrl, $request); $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php new file mode 100644 index 00000000..3e5bfb51 --- /dev/null +++ b/module/Core/src/Visit/RequestTracker.php @@ -0,0 +1,60 @@ +shouldTrackRequest($request)) { + $this->visitsTracker->track($shortUrl, Visitor::fromRequest($request)); + } + } + + public function trackNotFoundIfApplicable(ServerRequestInterface $request): void + { + if (! $this->shouldTrackRequest($request)) { + return; + } + + /** @var NotFoundType|null $notFoundType */ + $notFoundType = $request->getAttribute(NotFoundType::class); + $visitor = Visitor::fromRequest($request); + + if ($notFoundType?->isBaseUrl()) { + $this->visitsTracker->trackBaseUrlVisit($visitor); + } elseif ($notFoundType?->isRegularNotFound()) { + $this->visitsTracker->trackRegularNotFoundVisit($visitor); + } elseif ($notFoundType?->isInvalidShortUrl()) { + $this->visitsTracker->trackInvalidShortUrlVisit($visitor); + } + } + + private function shouldTrackRequest(ServerRequestInterface $request): bool + { + $query = $request->getQueryParams(); + $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); + $forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE); + if ($forwardedMethod === self::METHOD_HEAD) { + return false; + } + + return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query); + } +} diff --git a/module/Core/src/Visit/RequestTrackerInterface.php b/module/Core/src/Visit/RequestTrackerInterface.php new file mode 100644 index 00000000..ec2c4cb1 --- /dev/null +++ b/module/Core/src/Visit/RequestTrackerInterface.php @@ -0,0 +1,15 @@ +urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->visitTracker = $this->prophesize(VisitsTracker::class); + $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); - $this->action = new PixelAction( - $this->urlResolver->reveal(), - $this->visitTracker->reveal(), - new TrackingOptions(), - ); + $this->action = new PixelAction($this->urlResolver->reveal(), $this->requestTracker->reveal()); } /** @test */ @@ -45,7 +40,7 @@ class PixelActionTest extends TestCase $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn( ShortUrl::withLongUrl('http://domain.com/foo/bar'), )->shouldBeCalledOnce(); - $this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce(); + $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldBeCalledOnce(); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index cc5690d0..3932810e 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -17,13 +17,10 @@ use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; -use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; - -use function array_key_exists; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class RedirectActionTest extends TestCase { @@ -33,13 +30,13 @@ class RedirectActionTest extends TestCase private RedirectAction $action; private ObjectProphecy $urlResolver; - private ObjectProphecy $visitTracker; + private ObjectProphecy $requestTracker; private ObjectProphecy $redirectRespHelper; public function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->visitTracker = $this->prophesize(VisitsTrackerInterface::class); + $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); $this->redirectRespHelper = $this->prophesize(RedirectResponseHelperInterface::class); $redirectBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); @@ -47,45 +44,41 @@ class RedirectActionTest extends TestCase $this->action = new RedirectAction( $this->urlResolver->reveal(), - $this->visitTracker->reveal(), - new Options\TrackingOptions(['disableTrackParam' => 'foobar']), + $this->requestTracker->reveal(), $redirectBuilder->reveal(), $this->redirectRespHelper->reveal(), ); } - /** - * @test - * @dataProvider provideQueries - */ - public function redirectionIsPerformedToLongUrl(array $query): void + /** @test */ + public function redirectionIsPerformedToLongUrl(): void { $shortCode = 'abc123'; $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); $shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl( new ShortUrlIdentifier($shortCode, ''), )->willReturn($shortUrl); - $track = $this->visitTracker->track(Argument::cetera())->will(function (): void { + $track = $this->requestTracker->trackIfApplicable(Argument::cetera())->will(function (): void { }); $expectedResp = new Response\RedirectResponse(self::LONG_URL); $buildResp = $this->redirectRespHelper->buildRedirectResponse(self::LONG_URL)->willReturn($expectedResp); - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withQueryParams($query); + $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); self::assertSame($expectedResp, $response); $buildResp->shouldHaveBeenCalledOnce(); $shortCodeToUrl->shouldHaveBeenCalledOnce(); - $track->shouldHaveBeenCalledTimes(array_key_exists('foobar', $query) ? 0 : 1); + $track->shouldHaveBeenCalledOnce(); } - public function provideQueries(): iterable - { - yield [[]]; - yield [['foobar' => 'notrack']]; - yield [['foobar' => 'barfoo']]; - yield [['foobar' => null]]; - } +// public function provideQueries(): iterable +// { +// yield [[]]; +// yield [['foobar' => 'notrack']]; +// yield [['foobar' => 'barfoo']]; +// yield [['foobar' => null]]; +// } /** @test */ public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void @@ -94,7 +87,7 @@ class RedirectActionTest extends TestCase $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) ->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); - $this->visitTracker->track(Argument::cetera())->shouldNotBeCalled(); + $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotBeCalled(); $handler = $this->prophesize(RequestHandlerInterface::class); $handle = $handler->handle(Argument::any())->willReturn(new Response()); @@ -105,26 +98,26 @@ class RedirectActionTest extends TestCase $handle->shouldHaveBeenCalledOnce(); } - /** @test */ - public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void - { - $shortCode = 'abc123'; - $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl); - $track = $this->visitTracker->track(Argument::cetera())->will(function (): void { - }); - $buildResp = $this->redirectRespHelper->buildRedirectResponse(self::LONG_URL)->willReturn( - new Response\RedirectResponse(''), - ); - - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode) - ->withAttribute( - ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE, - RequestMethodInterface::METHOD_HEAD, - ); - $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); - - $buildResp->shouldHaveBeenCalled(); - $track->shouldNotHaveBeenCalled(); - } +// /** @test */ +// public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void +// { +// $shortCode = 'abc123'; +// $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); +// $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl); +// $track = $this->requestTracker->trackIfApplicable(Argument::cetera())->will(function (): void { +// }); +// $buildResp = $this->redirectRespHelper->buildRedirectResponse(self::LONG_URL)->willReturn( +// new Response\RedirectResponse(''), +// ); +// +// $request = (new ServerRequest())->withAttribute('shortCode', $shortCode) +// ->withAttribute( +// ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE, +// RequestMethodInterface::METHOD_HEAD, +// ); +// $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); +// +// $buildResp->shouldHaveBeenCalled(); +// $track->shouldNotHaveBeenCalled(); +// } } diff --git a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php index 560a2468..1a29a4b5 100644 --- a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -14,8 +14,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTrackerMiddleware; -use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; +use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class NotFoundTrackerMiddlewareTest extends TestCase { @@ -23,7 +22,7 @@ class NotFoundTrackerMiddlewareTest extends TestCase private NotFoundTrackerMiddleware $middleware; private ServerRequestInterface $request; - private ObjectProphecy $visitsTracker; + private ObjectProphecy $requestTracker; private ObjectProphecy $notFoundType; private ObjectProphecy $handler; @@ -33,8 +32,8 @@ class NotFoundTrackerMiddlewareTest extends TestCase $this->handler = $this->prophesize(RequestHandlerInterface::class); $this->handler->handle(Argument::cetera())->willReturn(new Response()); - $this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class); - $this->middleware = new NotFoundTrackerMiddleware($this->visitsTracker->reveal()); + $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); + $this->middleware = new NotFoundTrackerMiddleware($this->requestTracker->reveal()); $this->request = ServerRequestFactory::fromGlobals()->withAttribute( NotFoundType::class, @@ -43,53 +42,62 @@ class NotFoundTrackerMiddlewareTest extends TestCase } /** @test */ - public function baseUrlErrorIsTracked(): void + public function delegatesIntoRequestTracker(): void { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); - $this->middleware->process($this->request, $this->handler->reveal()); - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldNotHaveBeenCalled(); - $isInvalidShortUrl->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->requestTracker->trackNotFoundIfApplicable($this->request)->shouldHaveBeenCalledOnce(); + $this->handler->handle($this->request)->shouldHaveBeenCalledOnce(); } - /** @test */ - public function regularNotFoundErrorIsTracked(): void - { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); - - $this->middleware->process($this->request, $this->handler->reveal()); - - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldHaveBeenCalledOnce(); - $isInvalidShortUrl->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - } - - /** @test */ - public function invalidShortUrlErrorIsTracked(): void - { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true); - - $this->middleware->process($this->request, $this->handler->reveal()); - - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldHaveBeenCalledOnce(); - $isInvalidShortUrl->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); - } +// /** @test */ +// public function baseUrlErrorIsTracked(): void +// { +// $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true); +// $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); +// $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); +// +// $this->middleware->process($this->request, $this->handler->reveal()); +// +// $isBaseUrl->shouldHaveBeenCalledOnce(); +// $isRegularNotFound->shouldNotHaveBeenCalled(); +// $isInvalidShortUrl->shouldNotHaveBeenCalled(); +// $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); +// $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); +// $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); +// } +// +// /** @test */ +// public function regularNotFoundErrorIsTracked(): void +// { +// $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); +// $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true); +// $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); +// +// $this->middleware->process($this->request, $this->handler->reveal()); +// +// $isBaseUrl->shouldHaveBeenCalledOnce(); +// $isRegularNotFound->shouldHaveBeenCalledOnce(); +// $isInvalidShortUrl->shouldNotHaveBeenCalled(); +// $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); +// $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); +// $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); +// } +// +// /** @test */ +// public function invalidShortUrlErrorIsTracked(): void +// { +// $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); +// $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); +// $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true); +// +// $this->middleware->process($this->request, $this->handler->reveal()); +// +// $isBaseUrl->shouldHaveBeenCalledOnce(); +// $isRegularNotFound->shouldHaveBeenCalledOnce(); +// $isInvalidShortUrl->shouldHaveBeenCalledOnce(); +// $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); +// $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); +// $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); +// } } From 0096a778ac1a8d118392f6b875cbb83480dffb52 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Jul 2021 17:43:29 +0200 Subject: [PATCH 22/69] Created RequestTracker test --- .../Core/test/Action/RedirectActionTest.php | 33 ---- .../NotFoundTrackerMiddlewareTest.php | 51 ------ module/Core/test/Visit/RequestTrackerTest.php | 147 ++++++++++++++++++ 3 files changed, 147 insertions(+), 84 deletions(-) create mode 100644 module/Core/test/Visit/RequestTrackerTest.php diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index 3932810e..b3017fad 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Action; -use Fig\Http\Message\RequestMethodInterface; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; -use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -72,14 +70,6 @@ class RedirectActionTest extends TestCase $track->shouldHaveBeenCalledOnce(); } -// public function provideQueries(): iterable -// { -// yield [[]]; -// yield [['foobar' => 'notrack']]; -// yield [['foobar' => 'barfoo']]; -// yield [['foobar' => null]]; -// } - /** @test */ public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void { @@ -97,27 +87,4 @@ class RedirectActionTest extends TestCase $handle->shouldHaveBeenCalledOnce(); } - -// /** @test */ -// public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void -// { -// $shortCode = 'abc123'; -// $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); -// $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl); -// $track = $this->requestTracker->trackIfApplicable(Argument::cetera())->will(function (): void { -// }); -// $buildResp = $this->redirectRespHelper->buildRedirectResponse(self::LONG_URL)->willReturn( -// new Response\RedirectResponse(''), -// ); -// -// $request = (new ServerRequest())->withAttribute('shortCode', $shortCode) -// ->withAttribute( -// ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE, -// RequestMethodInterface::METHOD_HEAD, -// ); -// $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); -// -// $buildResp->shouldHaveBeenCalled(); -// $track->shouldNotHaveBeenCalled(); -// } } diff --git a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php index 1a29a4b5..81fef1a6 100644 --- a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -49,55 +49,4 @@ class NotFoundTrackerMiddlewareTest extends TestCase $this->requestTracker->trackNotFoundIfApplicable($this->request)->shouldHaveBeenCalledOnce(); $this->handler->handle($this->request)->shouldHaveBeenCalledOnce(); } - -// /** @test */ -// public function baseUrlErrorIsTracked(): void -// { -// $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true); -// $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); -// $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); -// -// $this->middleware->process($this->request, $this->handler->reveal()); -// -// $isBaseUrl->shouldHaveBeenCalledOnce(); -// $isRegularNotFound->shouldNotHaveBeenCalled(); -// $isInvalidShortUrl->shouldNotHaveBeenCalled(); -// $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); -// $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); -// $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); -// } -// -// /** @test */ -// public function regularNotFoundErrorIsTracked(): void -// { -// $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); -// $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true); -// $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); -// -// $this->middleware->process($this->request, $this->handler->reveal()); -// -// $isBaseUrl->shouldHaveBeenCalledOnce(); -// $isRegularNotFound->shouldHaveBeenCalledOnce(); -// $isInvalidShortUrl->shouldNotHaveBeenCalled(); -// $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); -// $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); -// $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); -// } -// -// /** @test */ -// public function invalidShortUrlErrorIsTracked(): void -// { -// $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); -// $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); -// $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true); -// -// $this->middleware->process($this->request, $this->handler->reveal()); -// -// $isBaseUrl->shouldHaveBeenCalledOnce(); -// $isRegularNotFound->shouldHaveBeenCalledOnce(); -// $isInvalidShortUrl->shouldHaveBeenCalledOnce(); -// $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); -// $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); -// $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); -// } } diff --git a/module/Core/test/Visit/RequestTrackerTest.php b/module/Core/test/Visit/RequestTrackerTest.php new file mode 100644 index 00000000..46faf9fd --- /dev/null +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -0,0 +1,147 @@ +notFoundType = $this->prophesize(NotFoundType::class); + $this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class); + + $this->requestTracker = new RequestTracker( + $this->visitsTracker->reveal(), + new TrackingOptions(['disable_track_param' => 'foobar']), + ); + + $this->request = ServerRequestFactory::fromGlobals()->withAttribute( + NotFoundType::class, + $this->notFoundType->reveal(), + ); + } + + /** + * @test + * @dataProvider provideNonTrackingRequests + */ + public function trackingIsDisabledWhenRequestDoesNotMeetConditions(ServerRequestInterface $request): void + { + $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); + + $this->requestTracker->trackIfApplicable($shortUrl, $request); + + $this->visitsTracker->track(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideNonTrackingRequests(): iterable + { + yield 'forwarded from head' => [ServerRequestFactory::fromGlobals()->withAttribute( + ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE, + RequestMethodInterface::METHOD_HEAD, + )]; + yield 'disable track param' => [ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => 'foo'])]; + yield 'disable track param as null' => [ + ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => null]), + ]; + } + + /** @test */ + public function trackingHappensOverShortUrlsWhenRequestMeetsConditions(): void + { + $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); + + $this->requestTracker->trackIfApplicable($shortUrl, $this->request); + + $this->visitsTracker->track($shortUrl, Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function baseUrlErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); + + $this->requestTracker->trackNotFoundIfApplicable($this->request); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldNotHaveBeenCalled(); + $isInvalidShortUrl->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function regularNotFoundErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); + + $this->requestTracker->trackNotFoundIfApplicable($this->request); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldHaveBeenCalledOnce(); + $isInvalidShortUrl->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function invalidShortUrlErrorIsTracked(): void + { + $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); + $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); + $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true); + + $this->requestTracker->trackNotFoundIfApplicable($this->request); + + $isBaseUrl->shouldHaveBeenCalledOnce(); + $isRegularNotFound->shouldHaveBeenCalledOnce(); + $isInvalidShortUrl->shouldHaveBeenCalledOnce(); + $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideNonTrackingRequests + */ + public function notFoundIsNotTrackedIfRequestDoesNotMeetConditions(ServerRequestInterface $request): void + { + $this->requestTracker->trackNotFoundIfApplicable($request); + + $this->visitsTracker->trackBaseUrlVisit(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackRegularNotFoundVisit(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->visitsTracker->trackInvalidShortUrlVisit(Argument::cetera())->shouldNotHaveBeenCalled(); + } +} From 20575a2b0f232fa0d7d53f01299db1cba64fc639 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Jul 2021 18:57:32 +0200 Subject: [PATCH 23/69] Added support to provide append_extra_path config from installer or env vars for docker --- composer.json | 2 +- config/autoload/installer.global.php | 1 + docker/config/shlink_in_docker.local.php | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4571e63e..7d637a60 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-importer": "^2.3", - "shlinkio/shlink-installer": "^6.0", + "shlinkio/shlink-installer": "dev-develop#fa6a4ca as 6.1", "shlinkio/shlink-ip-geolocation": "^2.0", "symfony/console": "^5.1", "symfony/filesystem": "^5.1", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 0a72c6fa..0a3374e1 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -42,6 +42,7 @@ return [ Option\UrlShortener\RedirectStatusCodeConfigOption::class, Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class, Option\UrlShortener\AutoResolveTitlesConfigOption::class, + Option\UrlShortener\AppendExtraPathConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class, Option\Tracking\OrphanVisitsTrackingConfigOption::class, Option\Tracking\DisableTrackParamConfigOption::class, diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 2f1c9499..d4526f50 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -111,9 +111,10 @@ return [ 'validate_url' => (bool) env('VALIDATE_URLS', false), 'visits_webhooks' => $helper->getVisitsWebhooks(), 'default_short_codes_length' => $helper->getDefaultShortCodesLength(), + 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false), 'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE), 'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME), - 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false), + 'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false), ], 'tracking' => [ From eabaa94e06fbb82081c54f918d0340f667bb482f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Jul 2021 19:37:09 +0200 Subject: [PATCH 24/69] Created ExtraPathRedirectMiddleware test --- CHANGELOG.md | 6 + .../ExtraPathRedirectMiddlewareTest.php | 147 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e14a26..63d9c6fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High. +* [#1080](https://github.com/shlinkio/shlink/issues/1080) Added support to redirect to URLs as soon as the path starts with a valid short code, appending the rest of the path to the redirected long URL. + + With this, if you have the `https://example.com/abc123` short URL redirecting to `https://www.twitter.com`, a visit to `https://example.com/abc123/shlinkio` will take you to `https://www.twitter.com/shlinkio`. + + This behavior needs to be actively opted in, via installer config options or env vars. + ### Changed * *Nothing* diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php new file mode 100644 index 00000000..24917366 --- /dev/null +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -0,0 +1,147 @@ +resolver = $this->prophesize(ShortUrlResolverInterface::class); + $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); + $this->redirectionBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); + $this->redirectResponseHelper = $this->prophesize(RedirectResponseHelperInterface::class); + $this->options = new UrlShortenerOptions(['append_extra_path' => true]); + + $this->middleware = new ExtraPathRedirectMiddleware( + $this->resolver->reveal(), + $this->requestTracker->reveal(), + $this->redirectionBuilder->reveal(), + $this->redirectResponseHelper->reveal(), + $this->options, + ); + + $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->handler->handle(Argument::cetera())->willReturn(new RedirectResponse('')); + } + + /** + * @test + * @dataProvider provideNonRedirectingRequests + */ + public function handlerIsCalledWhenConfigPreventsRedirectWithExtraPath( + bool $appendExtraPath, + ServerRequestInterface $request + ): void { + $this->options->appendExtraPath = $appendExtraPath; + + $this->middleware->process($request, $this->handler->reveal()); + + $this->resolver->resolveEnabledShortUrl(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->redirectResponseHelper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideNonRedirectingRequests(): iterable + { + $baseReq = ServerRequestFactory::fromGlobals(); + $buildReq = static fn (?NotFoundType $type): ServerRequestInterface => + $baseReq->withAttribute(NotFoundType::class, $type); + + yield 'disabled option' => [false, $buildReq(NotFoundType::fromRequest($baseReq, '/foo/bar'))]; + yield 'base_url error' => [true, $buildReq(NotFoundType::fromRequest($baseReq, ''))]; + yield 'invalid_short_url error' => [ + true, + $buildReq(NotFoundType::fromRequest($baseReq, ''))->withAttribute( + RouteResult::class, + RouteResult::fromRoute(new Route( + '', + $this->prophesize(MiddlewareInterface::class)->reveal(), + ['GET'], + )), + ), + ]; + yield 'no error type' => [true, $buildReq(null)]; + } + + /** @test */ + public function handlerIsCalledWhenNoShortUrlIsFound(): void + { + $type = $this->prophesize(NotFoundType::class); + $type->isRegularNotFound()->willReturn(true); + $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) + ->withUri(new Uri('/shortCode/bar/baz')); + + $resolve = $this->resolver->resolveEnabledShortUrl(Argument::cetera())->willThrow( + ShortUrlNotFoundException::class, + ); + + $this->middleware->process($request, $this->handler->reveal()); + + $resolve->shouldHaveBeenCalledOnce(); + $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->redirectResponseHelper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFound(): void + { + $type = $this->prophesize(NotFoundType::class); + $type->isRegularNotFound()->willReturn(true); + $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) + ->withUri(new Uri('https://doma.in/shortCode/bar/baz')); + $shortUrl = ShortUrl::withLongUrl(''); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain('shortCode', 'doma.in'); + + $resolve = $this->resolver->resolveEnabledShortUrl($identifier)->willReturn($shortUrl); + $buildLongUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, [], '/bar/baz')->willReturn( + 'the_built_long_url', + ); + $buildResp = $this->redirectResponseHelper->buildRedirectResponse('the_built_long_url')->willReturn( + new RedirectResponse(''), + ); + + $this->middleware->process($request, $this->handler->reveal()); + + $resolve->shouldHaveBeenCalledOnce(); + $buildLongUrl->shouldHaveBeenCalledOnce(); + $buildResp->shouldHaveBeenCalledOnce(); + $this->requestTracker->trackIfApplicable($shortUrl, $request)->shouldHaveBeenCalledOnce(); + } +} From f86cda6730b28db363c2faaa82063d4abcde786c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Jul 2021 19:53:42 +0200 Subject: [PATCH 25/69] Removed deprecated env var for publish release --- .github/workflows/publish-release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6ce63cf3..b45ee370 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -43,7 +43,6 @@ jobs: uses: docker://antonyurchenko/git-release:latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ALLOW_TAG_PREFIX: "true" ALLOW_EMPTY_CHANGELOG: "true" with: args: | From bceea090edc0cd25e7c95d93ed9a43d502bf9fe7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Jul 2021 20:58:24 +0200 Subject: [PATCH 26/69] Increaed phpstan level to 7 --- composer.json | 2 +- module/CLI/src/ApiKey/RoleResolver.php | 4 +++- .../Command/Util/AbstractWithDateRangeCommand.php | 3 ++- module/CLI/test/ApiKey/RoleResolverTest.php | 15 +++++++++++++++ module/Core/src/Action/RobotsAction.php | 1 + module/Core/src/Model/ShortUrlsOrdering.php | 1 - .../Core/src/Util/CocurSymfonySluggerBridge.php | 5 ++--- .../DropDefaultDomainFromRequestMiddleware.php | 4 +++- .../ShortUrl/OverrideDomainMiddleware.php | 1 + 9 files changed, 28 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 7d637a60..abf44182 100644 --- a/composer.json +++ b/composer.json @@ -112,7 +112,7 @@ ], "cs": "phpcs", "cs:fix": "phpcbf", - "stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6", + "stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=7", "test": [ "@test:unit", "@test:db", diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index 179fff53..c8cccfc6 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -8,6 +8,8 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Symfony\Component\Console\Input\InputInterface; +use function is_string; + class RoleResolver implements RoleResolverInterface { public function __construct(private DomainServiceInterface $domainService) @@ -23,7 +25,7 @@ class RoleResolver implements RoleResolverInterface if ($author) { $roleDefinitions[] = RoleDefinition::forAuthoredShortUrls(); } - if ($domainAuthority !== null) { + if (is_string($domainAuthority)) { $domain = $this->domainService->getOrCreate($domainAuthority); $roleDefinitions[] = RoleDefinition::forDomain($domain); } diff --git a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php index 51731e3a..9d7f5723 100644 --- a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php +++ b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function is_string; use function sprintf; abstract class AbstractWithDateRangeCommand extends BaseCommand @@ -49,7 +50,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos { $value = $this->getOptionWithDeprecatedFallback($input, $key); - if (empty($value)) { + if (empty($value) || ! is_string($value)) { return null; } diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index 5e4de2c3..76163348 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -68,6 +68,21 @@ class RoleResolverTest extends TestCase [RoleDefinition::forDomain($domain)], 1, ]; + yield 'false domain role' => [ + $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => false]), + [], + 0, + ]; + yield 'true domain role' => [ + $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => true]), + [], + 0, + ]; + yield 'string array domain role' => [ + $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => ['foo', 'bar']]), + [], + 0, + ]; yield 'author role only' => [ $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]), [RoleDefinition::forAuthoredShortUrls()], diff --git a/module/Core/src/Action/RobotsAction.php b/module/Core/src/Action/RobotsAction.php index b7fa7d42..12baa7b3 100644 --- a/module/Core/src/Action/RobotsAction.php +++ b/module/Core/src/Action/RobotsAction.php @@ -23,6 +23,7 @@ class RobotsAction implements RequestHandlerInterface, StatusCodeInterface public function handle(ServerRequestInterface $request): ResponseInterface { + // @phpstan-ignore-next-line The "Response" phpdoc is wrong return new Response(self::STATUS_OK, ['Content-type' => 'text/plain'], $this->buildRobots()); } diff --git a/module/Core/src/Model/ShortUrlsOrdering.php b/module/Core/src/Model/ShortUrlsOrdering.php index b59435ca..4184fcc6 100644 --- a/module/Core/src/Model/ShortUrlsOrdering.php +++ b/module/Core/src/Model/ShortUrlsOrdering.php @@ -49,7 +49,6 @@ final class ShortUrlsOrdering ]); } - /** @var string|array $orderBy */ if (! $isArray) { [$field, $dir] = array_pad(explode('-', $orderBy), 2, null); $this->orderField = $field; diff --git a/module/Core/src/Util/CocurSymfonySluggerBridge.php b/module/Core/src/Util/CocurSymfonySluggerBridge.php index a612068c..da60836e 100644 --- a/module/Core/src/Util/CocurSymfonySluggerBridge.php +++ b/module/Core/src/Util/CocurSymfonySluggerBridge.php @@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Core\Util; use Cocur\Slugify\SlugifyInterface; use Symfony\Component\String\AbstractUnicodeString; use Symfony\Component\String\Slugger\SluggerInterface; - -use function Symfony\Component\String\s; +use Symfony\Component\String\UnicodeString; class CocurSymfonySluggerBridge implements SluggerInterface { @@ -18,6 +17,6 @@ class CocurSymfonySluggerBridge implements SluggerInterface public function slug(string $string, string $separator = '-', ?string $locale = null): AbstractUnicodeString { - return s($this->slugger->slugify($string, $separator)); + return new UnicodeString($this->slugger->slugify($string, $separator)); } } diff --git a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php index 59515242..8eb98153 100644 --- a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php @@ -17,8 +17,10 @@ class DropDefaultDomainFromRequestMiddleware implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + /** @var array $body */ + $body = $request->getParsedBody(); $request = $request->withQueryParams($this->sanitizeDomainFromPayload($request->getQueryParams())) - ->withParsedBody($this->sanitizeDomainFromPayload($request->getParsedBody())); + ->withParsedBody($this->sanitizeDomainFromPayload($body)); return $handler->handle($request); } diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php index 6943f986..0f4fd75e 100644 --- a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -32,6 +32,7 @@ class OverrideDomainMiddleware implements MiddlewareInterface $domain = $this->domainService->getDomain($domainId); if ($requestMethod === RequestMethodInterface::METHOD_POST) { + /** @var array $payload */ $payload = $request->getParsedBody(); $payload[ShortUrlInputFilter::DOMAIN] = $domain->getAuthority(); From b8fa234dbbdd333956b5bda9e489b8210551995f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 19 Jul 2021 18:35:42 +0200 Subject: [PATCH 27/69] Fixed some phpstan errors --- .../Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php | 1 + module/Rest/src/Action/Tag/CreateTagsAction.php | 8 +------- module/Rest/src/Action/Tag/UpdateTagAction.php | 1 + .../ShortUrl/DefaultShortCodesLengthMiddleware.php | 1 + 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index d3211d1c..feda3a62 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -27,6 +27,7 @@ class EditShortUrlTagsAction extends AbstractRestAction public function handle(Request $request): Response { + /** @var array $bodyParams */ $bodyParams = $request->getParsedBody(); if (! isset($bodyParams['tags'])) { diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php index e3d0bb9f..09c860f5 100644 --- a/module/Rest/src/Action/Tag/CreateTagsAction.php +++ b/module/Rest/src/Action/Tag/CreateTagsAction.php @@ -20,15 +20,9 @@ class CreateTagsAction extends AbstractRestAction { } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * - * @throws \InvalidArgumentException - */ public function handle(ServerRequestInterface $request): ResponseInterface { + /** @var array $body */ $body = $request->getParsedBody(); $tags = $body['tags'] ?? []; diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index a4bce7c0..016d008b 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -23,6 +23,7 @@ class UpdateTagAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { + /** @var array $body */ $body = $request->getParsedBody(); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); diff --git a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php index cffc56b0..5b1bfd40 100644 --- a/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/DefaultShortCodesLengthMiddleware.php @@ -18,6 +18,7 @@ class DefaultShortCodesLengthMiddleware implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + /** @var array $body */ $body = $request->getParsedBody(); if (! isset($body[ShortUrlInputFilter::SHORT_CODE_LENGTH])) { $body[ShortUrlInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength; From 934d2668808b1c621191896dceb5383a28327c60 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 19 Jul 2021 19:59:41 +0200 Subject: [PATCH 28/69] Added phpstan-symfony plugin to improve inspections on getArgument and getOption --- bin/cli | 7 +++++-- composer.json | 3 ++- config/cli-app.php | 12 ++++++++++++ config/cli-config.php | 2 +- config/run.php | 5 ++--- phpstan.neon | 4 ++++ public/index.php | 3 +-- 7 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 config/cli-app.php diff --git a/bin/cli b/bin/cli index c185efd3..56437c8b 100755 --- a/bin/cli +++ b/bin/cli @@ -3,5 +3,8 @@ declare(strict_types=1); -$run = require __DIR__ . '/../config/run.php'; -$run(true); +use Symfony\Component\Console\Application; + +/** @var Application $app */ +$app = require __DIR__ . '/../config/cli-app.php'; +$app->run(); diff --git a/composer.json b/composer.json index abf44182..bc4806b1 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,8 @@ "eaglewu/swoole-ide-helper": "dev-master", "infection/infection": "^0.21.0", "phpspec/prophecy-phpunit": "^2.0", - "phpstan/phpstan": "^0.12.64", + "phpstan/phpstan": "^0.12.92", + "phpstan/phpstan-symfony": "^0.12.41", "phpunit/php-code-coverage": "^9.2", "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", diff --git a/config/cli-app.php b/config/cli-app.php new file mode 100644 index 00000000..a2272852 --- /dev/null +++ b/config/cli-app.php @@ -0,0 +1,12 @@ +get(CliApp::class); +})(); diff --git a/config/cli-config.php b/config/cli-config.php index 71c7a75e..396fc075 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -6,7 +6,7 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Tools\Console\ConsoleRunner; use Psr\Container\ContainerInterface; -return (function () { +return (static function () { /** @var ContainerInterface $container */ $container = include __DIR__ . '/container.php'; $em = $container->get(EntityManager::class); diff --git a/config/run.php b/config/run.php index 80116e24..5de0df16 100644 --- a/config/run.php +++ b/config/run.php @@ -4,12 +4,11 @@ declare(strict_types=1); use Mezzio\Application; use Psr\Container\ContainerInterface; -use Symfony\Component\Console\Application as CliApp; -return function (bool $isCli = false): void { +return static function (): void { /** @var ContainerInterface $container */ $container = include __DIR__ . '/container.php'; - $app = $container->get($isCli ? CliApp::class : Application::class); + $app = $container->get(Application::class); $app->run(); }; diff --git a/phpstan.neon b/phpstan.neon index 80f1b083..eb98657e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,9 @@ +includes: + - vendor/phpstan/phpstan-symfony/extension.neon parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false ignoreErrors: - '#If condition is always false#' + symfony: + console_application_loader: 'config/cli-app.php' diff --git a/public/index.php b/public/index.php index 78bb412a..99018890 100644 --- a/public/index.php +++ b/public/index.php @@ -2,5 +2,4 @@ declare(strict_types=1); -$run = require __DIR__ . '/../config/run.php'; -$run(); +(require __DIR__ . '/../config/run.php')(); From de5666d2620c4f87d0a87bb2575cc9ec08f2e10c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 19 Jul 2021 22:47:12 +0200 Subject: [PATCH 29/69] Resolved all phpstan errors --- composer.json | 2 +- module/CLI/src/Command/BaseCommand.php | 20 ++++++++----------- .../GeolocationDbUpdateFailedException.php | 5 +---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/composer.json b/composer.json index bc4806b1..96b098f0 100644 --- a/composer.json +++ b/composer.json @@ -113,7 +113,7 @@ ], "cs": "phpcs", "cs:fix": "phpcbf", - "stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=7", + "stan": "phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=7", "test": [ "@test:unit", "@test:db", diff --git a/module/CLI/src/Command/BaseCommand.php b/module/CLI/src/Command/BaseCommand.php index ef6b5435..fbee8681 100644 --- a/module/CLI/src/Command/BaseCommand.php +++ b/module/CLI/src/Command/BaseCommand.php @@ -12,40 +12,36 @@ use function Shlinkio\Shlink\Core\kebabCaseToCamelCase; use function sprintf; use function str_contains; +/** @deprecated */ abstract class BaseCommand extends Command { /** - * @param mixed|null $default + * @param string|string[]|bool|null $default */ protected function addOptionWithDeprecatedFallback( string $name, ?string $shortcut = null, ?int $mode = null, string $description = '', - $default = null, + bool|string|array|null $default = null, ): self { $this->addOption($name, $shortcut, $mode, $description, $default); if (str_contains($name, '-')) { $camelCaseName = kebabCaseToCamelCase($name); - $this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Same as "%s".', $name), $default); + $this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Alias for "%s".', $name), $default); } return $this; } - /** - * @return bool|string|string[]|null - */ - protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name) + // @phpstan-ignore-next-line + protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name) // phpcs:ignore { $rawInput = method_exists($input, '__toString') ? $input->__toString() : ''; $camelCaseName = kebabCaseToCamelCase($name); + $resolvedOptionName = str_contains($rawInput, $camelCaseName) ? $camelCaseName : $name; - if (str_contains($rawInput, $camelCaseName)) { - return $input->getOption($camelCaseName); - } - - return $input->getOption($name); + return $input->getOption($resolvedOptionName); } } diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index 07d66855..0c5ef184 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -42,10 +42,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc return $e; } - /** - * @param mixed $buildEpoch - */ - public static function withInvalidEpochInOldDb($buildEpoch): self + public static function withInvalidEpochInOldDb(mixed $buildEpoch): self { $e = new self(sprintf( 'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.', From 2eeb762cd9a4ef2dbf10381130bd6187415751ee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 19 Jul 2021 22:50:32 +0200 Subject: [PATCH 30/69] Moved specific phpstan ignore to their own lines --- config/test/test_config.global.php | 4 ++-- phpstan.neon | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index c2375cba..cf824752 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -120,7 +120,7 @@ return [ 'name' => 'start_collecting_coverage', 'path' => '/api-tests/start-coverage', 'middleware' => middleware(static function () use (&$coverage) { - if ($coverage) { + if ($coverage) { // @phpstan-ignore-line $coverage->start('API tests'); } return new EmptyResponse(); @@ -131,7 +131,7 @@ return [ 'name' => 'dump_coverage', 'path' => '/api-tests/stop-coverage', 'middleware' => middleware(static function () use (&$coverage) { - if ($coverage) { + if ($coverage) { // @phpstan-ignore-line $basePath = __DIR__ . '/../../build/coverage-api'; $coverage->stop(); (new PHP())->process($coverage, $basePath . '.cov'); diff --git a/phpstan.neon b/phpstan.neon index eb98657e..7d90c219 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,7 +3,5 @@ includes: parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false - ignoreErrors: - - '#If condition is always false#' symfony: console_application_loader: 'config/cli-app.php' From 95770ac1046329cd609da8d3daf9bad1e31c4400 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Jul 2021 12:51:07 +0200 Subject: [PATCH 31/69] Increased phpstan level to 8 --- composer.json | 8 ++++---- module/Core/src/EventDispatcher/LocateVisit.php | 2 +- .../Core/src/Mercure/MercureUpdatesGenerator.php | 2 +- module/Core/src/Model/ShortUrlsParams.php | 2 +- module/Core/src/Model/Visitor.php | 9 ++++++--- module/Core/src/Model/VisitsParams.php | 2 +- .../Core/src/Repository/ShortUrlRepository.php | 16 ++++++++++------ module/Rest/src/Service/ApiKeyService.php | 6 +++--- 8 files changed, 27 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index 96b098f0..7193099b 100644 --- a/composer.json +++ b/composer.json @@ -62,9 +62,9 @@ }, "require-dev": { "devster/ubench": "^2.1", - "dms/phpunit-arraysubset-asserts": "^0.2.1", + "dms/phpunit-arraysubset-asserts": "^v0.3.0", "eaglewu/swoole-ide-helper": "dev-master", - "infection/infection": "^0.21.0", + "infection/infection": "^0.23.0", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^0.12.92", "phpstan/phpstan-symfony": "^0.12.41", @@ -74,7 +74,7 @@ "shlinkio/php-coding-standard": "~2.1.1", "shlinkio/shlink-test-utils": "^2.1", "symfony/var-dumper": "^5.2", - "veewee/composer-run-parallel": "^0.1.0" + "veewee/composer-run-parallel": "^1.0" }, "autoload": { "psr-4": { @@ -113,7 +113,7 @@ ], "cs": "phpcs", "cs:fix": "phpcbf", - "stan": "phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=7", + "stan": "phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8", "test": [ "@test:unit", "@test:db", diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index 046430df..bb6ba1d0 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -55,7 +55,7 @@ class LocateVisit } $isLocatable = $originalIpAddress !== null || $visit->isLocatable(); - $addr = $originalIpAddress ?? $visit->getRemoteAddr(); + $addr = $originalIpAddress ?? $visit->getRemoteAddr() ?? ''; try { $location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance(); diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php index c6c7a4d6..f2489da3 100644 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -42,7 +42,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface public function newShortUrlVisitUpdate(Visit $visit): Update { $shortUrl = $visit->getShortUrl(); - $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode()); + $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode()); return new Update($topic, $this->serialize([ 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), diff --git a/module/Core/src/Model/ShortUrlsParams.php b/module/Core/src/Model/ShortUrlsParams.php index b27a6187..a916704b 100644 --- a/module/Core/src/Model/ShortUrlsParams.php +++ b/module/Core/src/Model/ShortUrlsParams.php @@ -19,7 +19,7 @@ final class ShortUrlsParams private array $tags; private ShortUrlsOrdering $orderBy; private ?DateRange $dateRange; - private ?int $itemsPerPage = null; + private int $itemsPerPage; private function __construct() { diff --git a/module/Core/src/Model/Visitor.php b/module/Core/src/Model/Visitor.php index b73ed68a..9436e900 100644 --- a/module/Core/src/Model/Visitor.php +++ b/module/Core/src/Model/Visitor.php @@ -29,13 +29,16 @@ final class Visitor $this->userAgent = $this->cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH); $this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH); $this->visitedUrl = $this->cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH); - $this->remoteAddress = $this->cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH); + $this->remoteAddress = $remoteAddress === null ? null : $this->cropToLength( + $remoteAddress, + self::REMOTE_ADDRESS_MAX_LENGTH, + ); $this->potentialBot = isCrawler($userAgent); } - private function cropToLength(?string $value, int $length): ?string + private function cropToLength(string $value, int $length): string { - return $value === null ? null : substr($value, 0, $length); + return substr($value, 0, $length); } public static function fromRequest(ServerRequestInterface $request): self diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index 659eb5dc..1f78de00 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -13,7 +13,7 @@ final class VisitsParams private const FIRST_PAGE = 1; private const ALL_ITEMS = -1; - private ?DateRange $dateRange; + private DateRange $dateRange; private int $itemsPerPage; public function __construct( diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index c4ccedbf..4c3a4e9c 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -18,7 +18,6 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function array_column; -use function array_key_exists; use function count; use function Functional\contains; @@ -59,6 +58,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU // visitsCount and visitCount are deprecated. Only visits should work if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) { + // FIXME This query is inefficient. Debug it. $qb->addSelect('COUNT(DISTINCT v) AS totalVisits') ->leftJoin('s.visits', 'v') ->groupBy('s') @@ -75,9 +75,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU 'dateCreated' => 'dateCreated', 'title' => 'title', ]; - if (array_key_exists($fieldName, $fieldNameMap)) { - $qb->orderBy('s.' . $fieldNameMap[$fieldName], $order); + $resolvedFieldName = $fieldNameMap[$fieldName] ?? null; + if ($resolvedFieldName !== null) { + $qb->orderBy('s.' . $resolvedFieldName, $order); } + return $qb->getQuery()->getResult(); } @@ -194,10 +196,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?int $lockMode): bool { - $qb = $this->createFindOneQueryBuilder($identifier, $spec); - $qb->select('s.id'); + $qb = $this->createFindOneQueryBuilder($identifier, $spec)->select('s.id'); + $query = $qb->getQuery(); - $query = $qb->getQuery()->setLockMode($lockMode); + if ($lockMode !== null) { + $query = $query->setLockMode($lockMode); + } return $query->getOneOrNullResult() !== null; } diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 545ff310..d66e70e2 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -38,12 +38,12 @@ class ApiKeyService implements ApiKeyServiceInterface private function buildApiKeyWithParams(?Chronos $expirationDate, ?string $name): ApiKey { return match (true) { - $expirationDate === null && $name === null => ApiKey::create(), $expirationDate !== null && $name !== null => ApiKey::fromMeta( ApiKeyMeta::withNameAndExpirationDate($name, $expirationDate), ), - $name === null => ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)), - default => ApiKey::fromMeta(ApiKeyMeta::withName($name)), + $expirationDate !== null => ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)), + $name !== null => ApiKey::fromMeta(ApiKeyMeta::withName($name)), + default => ApiKey::create(), }; } From 02fd28edecaf1c2a8f4265110a21d3efa4a01d6b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Jul 2021 13:29:50 +0200 Subject: [PATCH 32/69] Installed phpstan-dcotrine and fixed more static analysis errors --- composer.json | 1 + config/cli-config.php | 7 ++----- config/entity-manager.php | 12 ++++++++++++ .../CLI/src/Command/Db/AbstractDatabaseCommand.php | 2 +- .../src/Command/Visit/DownloadGeoLiteDbCommand.php | 4 ++-- module/CLI/src/Command/Visit/LocateVisitsCommand.php | 11 ++++++++--- module/Core/src/Domain/DomainService.php | 1 - module/Core/src/Importer/ImportedLinksProcessor.php | 2 +- phpstan.neon | 4 ++++ 9 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 config/entity-manager.php diff --git a/composer.json b/composer.json index 7193099b..08446f16 100644 --- a/composer.json +++ b/composer.json @@ -67,6 +67,7 @@ "infection/infection": "^0.23.0", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^0.12.92", + "phpstan/phpstan-doctrine": "^0.12.42", "phpstan/phpstan-symfony": "^0.12.41", "phpunit/php-code-coverage": "^9.2", "phpunit/phpunit": "^9.5", diff --git a/config/cli-config.php b/config/cli-config.php index 396fc075..52659e4e 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -4,12 +4,9 @@ declare(strict_types=1); use Doctrine\ORM\EntityManager; use Doctrine\ORM\Tools\Console\ConsoleRunner; -use Psr\Container\ContainerInterface; return (static function () { - /** @var ContainerInterface $container */ - $container = include __DIR__ . '/container.php'; - $em = $container->get(EntityManager::class); - + /** @var EntityManager $em */ + $em = include __DIR__ . '/entity-manager.php'; return ConsoleRunner::createHelperSet($em); })(); diff --git a/config/entity-manager.php b/config/entity-manager.php new file mode 100644 index 00000000..2b4794f7 --- /dev/null +++ b/config/entity-manager.php @@ -0,0 +1,12 @@ +get(EntityManager::class); +})(); diff --git a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php index 1b0a4f9b..9cd6e9ea 100644 --- a/module/CLI/src/Command/Db/AbstractDatabaseCommand.php +++ b/module/CLI/src/Command/Db/AbstractDatabaseCommand.php @@ -32,6 +32,6 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand protected function getLockConfig(): LockedCommandConfig { - return LockedCommandConfig::blocking($this->getName()); + return LockedCommandConfig::blocking($this->getName() ?? static::class); } } diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index aef30427..41fb5f8d 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -45,8 +45,8 @@ class DownloadGeoLiteDbCommand extends Command $io->text(sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading')); $this->progressBar = new ProgressBar($io); }, function (int $total, int $downloaded): void { - $this->progressBar->setMaxSteps($total); - $this->progressBar->setProgress($downloaded); + $this->progressBar?->setMaxSteps($total); + $this->progressBar?->setProgress($downloaded); }); if ($this->progressBar === null) { diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 31b1fb05..7352211e 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -139,7 +139,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat throw IpCannotBeLocatedException::forEmptyAddress(); } - $ipAddr = $visit->getRemoteAddr(); + $ipAddr = $visit->getRemoteAddr() ?? ''; $this->io->write(sprintf('Processing IP %s', $ipAddr)); if ($ipAddr === IpAddress::LOCALHOST) { $this->io->writeln(' [Ignored localhost address]'); @@ -168,7 +168,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat private function checkDbUpdate(InputInterface $input): void { - $downloadDbCommand = $this->getApplication()->find(DownloadGeoLiteDbCommand::NAME); + $cliApp = $this->getApplication(); + if ($cliApp === null) { + return; + } + + $downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME); $exitCode = $downloadDbCommand->run($input, $this->io); if ($exitCode === ExitCodes::EXIT_FAILURE) { @@ -178,6 +183,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat protected function getLockConfig(): LockedCommandConfig { - return LockedCommandConfig::nonBlocking($this->getName()); + return LockedCommandConfig::nonBlocking(self::NAME); } } diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 95ba05c5..1e1ac279 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -57,7 +57,6 @@ class DomainService implements DomainServiceInterface public function getOrCreate(string $authority): Domain { $repo = $this->em->getRepository(Domain::class); - /** @var Domain|null $domain */ $domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority); $this->em->persist($domain); diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 6073dd26..b153430b 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -28,7 +28,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface private ShortCodeHelperInterface $shortCodeHelper, private DoctrineBatchHelperInterface $batchHelper ) { - $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line + $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); } /** diff --git a/phpstan.neon b/phpstan.neon index 7d90c219..bf3afc8e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,11 @@ includes: + - vendor/phpstan/phpstan-doctrine/extension.neon - vendor/phpstan/phpstan-symfony/extension.neon parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false symfony: console_application_loader: 'config/cli-app.php' + doctrine: + repositoryClass: Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository + objectManagerLoader: 'config/entity-manager.php' From bc385744db79213b653e0c4685182843f3289a1b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Jul 2021 13:36:09 +0200 Subject: [PATCH 33/69] Temporarely ignored some phpstan errors until a custom rule is defined --- module/Core/src/ErrorHandler/NotFoundRedirectHandler.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index 27fcf991..93fd4597 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -26,17 +26,20 @@ class NotFoundRedirectHandler implements MiddlewareInterface $notFoundType = $request->getAttribute(NotFoundType::class); if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) { + // @phpstan-ignore-next-line Create custom PHPStan rule return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect()); } if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) { return $this->redirectResponseHelper->buildRedirectResponse( + // @phpstan-ignore-next-line Create custom PHPStan rule $this->redirectOptions->getRegular404Redirect(), ); } if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) { return $this->redirectResponseHelper->buildRedirectResponse( + // @phpstan-ignore-next-line Create custom PHPStan rule $this->redirectOptions->getInvalidShortUrlRedirect(), ); } From 8e78f8527e13abc18ee5be8c94b1ce62254dec68 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Jul 2021 13:37:00 +0200 Subject: [PATCH 34/69] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d9c6fe..b1124864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This behavior needs to be actively opted in, via installer config options or env vars. ### Changed -* *Nothing* +* [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8. ### Deprecated * *Nothing* From b11daeae7d73de99ed2fd7946da0f089667b62c3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Jul 2021 13:41:55 +0200 Subject: [PATCH 35/69] Fixed version constraint in composer.json --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 08446f16..491595d9 100644 --- a/composer.json +++ b/composer.json @@ -62,11 +62,11 @@ }, "require-dev": { "devster/ubench": "^2.1", - "dms/phpunit-arraysubset-asserts": "^v0.3.0", + "dms/phpunit-arraysubset-asserts": "^0.3.0", "eaglewu/swoole-ide-helper": "dev-master", "infection/infection": "^0.23.0", "phpspec/prophecy-phpunit": "^2.0", - "phpstan/phpstan": "^0.12.92", + "phpstan/phpstan": "^0.12.93", "phpstan/phpstan-doctrine": "^0.12.42", "phpstan/phpstan-symfony": "^0.12.41", "phpunit/php-code-coverage": "^9.2", From e4d15e64b66a09f9c28192ffce22ce6e1068b4d9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Jul 2021 13:50:14 +0200 Subject: [PATCH 36/69] Ensured static analysis is run with APP_ENV=test --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 491595d9..1dbe2aa8 100644 --- a/composer.json +++ b/composer.json @@ -114,7 +114,7 @@ ], "cs": "phpcs", "cs:fix": "phpcbf", - "stan": "phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8", + "stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8", "test": [ "@test:unit", "@test:db", From 57d816b862244581d30268f64b77714d52df7c16 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Jul 2021 14:03:19 +0200 Subject: [PATCH 37/69] Replaced map with match --- config/test/test_config.global.php | 31 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index cf824752..68d1011c 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -35,26 +35,17 @@ if ($isApiTest) { $coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); } -$buildDbConnection = function (): array { +$buildDbConnection = static function (): array { $driver = env('DB_DRIVER', 'sqlite'); $isCi = env('CI', false); - $getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria'); - $getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308'; + $getMysqlHost = static fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria'); + $getCiMysqlPort = static fn (string $driver) => $driver === 'mysql' ? '3307' : '3308'; - $driverConfigMap = [ + return match ($driver) { 'sqlite' => [ 'driver' => 'pdo_sqlite', 'path' => sys_get_temp_dir() . '/shlink-tests.db', ], - 'mysql' => [ - 'driver' => 'pdo_mysql', - 'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver), - 'port' => $isCi ? $getCiMysqlPort($driver) : '3306', - 'user' => 'root', - 'password' => 'root', - 'dbname' => 'shlink_test', - 'charset' => 'utf8', - ], 'postgres' => [ 'driver' => 'pdo_pgsql', 'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres', @@ -71,10 +62,16 @@ $buildDbConnection = function (): array { 'password' => 'Passw0rd!', 'dbname' => 'shlink_test', ], - ]; - $driverConfigMap['maria'] = $driverConfigMap['mysql']; - - return $driverConfigMap[$driver] ?? []; + default => [ // mysql and maria + 'driver' => 'pdo_mysql', + 'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver), + 'port' => $isCi ? $getCiMysqlPort($driver) : '3306', + 'user' => 'root', + 'password' => 'root', + 'dbname' => 'shlink_test', + 'charset' => 'utf8', + ], + }; }; $buildTestLoggerConfig = fn (string $handlerName, string $filename) => [ From 4d48482d1e44ad2c733c52ba3f9d71e9ae640c7e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Jul 2021 09:28:21 +0200 Subject: [PATCH 38/69] Added support to define differnet not-found redirects per domain --- config/autoload/url-shortener.local.php.dist | 5 +- data/migrations/Version20210720143824.php | 41 +++++ module/Core/config/dependencies.config.php | 7 +- .../Shlinkio.Shlink.Core.Entity.Domain.php | 15 ++ .../NotFoundRedirectConfigInterface.php | 20 +++ .../src/Config/NotFoundRedirectResolver.php | 34 ++++ .../NotFoundRedirectResolverInterface.php | 16 ++ module/Core/src/Domain/DomainService.php | 9 +- .../src/Domain/DomainServiceInterface.php | 2 + module/Core/src/Entity/Domain.php | 37 +++- .../ErrorHandler/NotFoundRedirectHandler.php | 35 ++-- .../src/Options/NotFoundRedirectOptions.php | 9 +- .../Config/NotFoundRedirectResolverTest.php | 114 +++++++++++++ .../NotFoundRedirectHandlerTest.php | 161 +++++++++--------- 14 files changed, 398 insertions(+), 107 deletions(-) create mode 100644 data/migrations/Version20210720143824.php create mode 100644 module/Core/src/Config/NotFoundRedirectConfigInterface.php create mode 100644 module/Core/src/Config/NotFoundRedirectResolver.php create mode 100644 module/Core/src/Config/NotFoundRedirectResolverInterface.php create mode 100644 module/Core/test/Config/NotFoundRedirectResolverTest.php diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index c686137f..f34245fb 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -2,13 +2,16 @@ declare(strict_types=1); +$isSwoole = extension_loaded('swoole'); + return [ 'url_shortener' => [ 'domain' => [ 'schema' => 'http', - 'hostname' => 'localhost:8080', + 'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'), ], + 'auto_resolve_titles' => true, ], ]; diff --git a/data/migrations/Version20210720143824.php b/data/migrations/Version20210720143824.php new file mode 100644 index 00000000..66e03be5 --- /dev/null +++ b/data/migrations/Version20210720143824.php @@ -0,0 +1,41 @@ +getTable('domains'); + $this->skipIf($domainsTable->hasColumn('base_url_redirect')); + + $this->createRedirectColumn($domainsTable, 'base_url_redirect'); + $this->createRedirectColumn($domainsTable, 'regular_not_found_redirect'); + $this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect'); + } + + private function createRedirectColumn(Table $table, string $columnName): void + { + $table->addColumn($columnName, Types::STRING, [ + 'notnull' => false, + 'default' => null, + ]); + } + + public function down(Schema $schema): void + { + $domainsTable = $schema->getTable('domains'); + $this->skipIf(! $domainsTable->hasColumn('base_url_redirect')); + + $domainsTable->dropColumn('base_url_redirect'); + $domainsTable->dropColumn('regular_not_found_redirect'); + $domainsTable->dropColumn('invalid_short_url_redirect'); + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 03147bcb..a215a871 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -46,6 +46,8 @@ return [ Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, + Config\NotFoundRedirectResolver::class => ConfigAbstractFactory::class, + Action\RedirectAction::class => ConfigAbstractFactory::class, Action\PixelAction::class => ConfigAbstractFactory::class, Action\QrCodeAction::class => ConfigAbstractFactory::class, @@ -75,7 +77,8 @@ return [ ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class], ErrorHandler\NotFoundRedirectHandler::class => [ NotFoundRedirectOptions::class, - Util\RedirectResponseHelper::class, + Config\NotFoundRedirectResolver::class, + Domain\DomainService::class, ], Options\AppOptions::class => ['config.app_options'], @@ -118,6 +121,8 @@ return [ Util\DoctrineBatchHelper::class => ['em'], Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class], + Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class], + Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php index e3d8c3cf..596f41da 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php @@ -24,4 +24,19 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createField('authority', Types::STRING) ->unique() ->build(); + + $builder->createField('baseUrlRedirect', Types::STRING) + ->columnName('base_url_redirect') + ->nullable() + ->build(); + + $builder->createField('regular404Redirect', Types::STRING) + ->columnName('regular_not_found_redirect') + ->nullable() + ->build(); + + $builder->createField('invalidShortUrlRedirect', Types::STRING) + ->columnName('invalid_short_url_redirect') + ->nullable() + ->build(); }; diff --git a/module/Core/src/Config/NotFoundRedirectConfigInterface.php b/module/Core/src/Config/NotFoundRedirectConfigInterface.php new file mode 100644 index 00000000..bbdfa9c5 --- /dev/null +++ b/module/Core/src/Config/NotFoundRedirectConfigInterface.php @@ -0,0 +1,20 @@ +isBaseUrl() && $config->hasBaseUrlRedirect() => + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()), + $notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()), + $notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() => + // @phpstan-ignore-next-line Create custom PHPStan rule + $this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()), + default => null, + }; + } +} diff --git a/module/Core/src/Config/NotFoundRedirectResolverInterface.php b/module/Core/src/Config/NotFoundRedirectResolverInterface.php new file mode 100644 index 00000000..a5c55f3d --- /dev/null +++ b/module/Core/src/Config/NotFoundRedirectResolverInterface.php @@ -0,0 +1,16 @@ +em->getRepository(Domain::class); - $domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority); + return $repo->findOneBy(['authority' => $authority]); + } + + public function getOrCreate(string $authority): Domain + { + $domain = $this->findByAuthority($authority) ?? new Domain($authority); $this->em->persist($domain); $this->em->flush(); diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 3588fbc6..be357a22 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -22,4 +22,6 @@ interface DomainServiceInterface public function getDomain(string $domainId): Domain; public function getOrCreate(string $authority): Domain; + + public function findByAuthority(string $authority): ?Domain; } diff --git a/module/Core/src/Entity/Domain.php b/module/Core/src/Entity/Domain.php index ee094576..73b790c4 100644 --- a/module/Core/src/Entity/Domain.php +++ b/module/Core/src/Entity/Domain.php @@ -6,9 +6,14 @@ namespace Shlinkio\Shlink\Core\Entity; use JsonSerializable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; -class Domain extends AbstractEntity implements JsonSerializable +class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface { + private ?string $baseUrlRedirect = null; + private ?string $regular404Redirect = null; + private ?string $invalidShortUrlRedirect = null; + public function __construct(private string $authority) { } @@ -22,4 +27,34 @@ class Domain extends AbstractEntity implements JsonSerializable { return $this->getAuthority(); } + + public function invalidShortUrlRedirect(): ?string + { + return $this->invalidShortUrlRedirect; + } + + public function hasInvalidShortUrlRedirect(): bool + { + return $this->invalidShortUrlRedirect !== null; + } + + public function regular404Redirect(): ?string + { + return $this->regular404Redirect; + } + + public function hasRegular404Redirect(): bool + { + return $this->regular404Redirect !== null; + } + + public function baseUrlRedirect(): ?string + { + return $this->baseUrlRedirect; + } + + public function hasBaseUrlRedirect(): bool + { + return $this->baseUrlRedirect !== null; + } } diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index 93fd4597..44cd2ddd 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -8,15 +8,17 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface; +use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Options; -use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; class NotFoundRedirectHandler implements MiddlewareInterface { public function __construct( private Options\NotFoundRedirectOptions $redirectOptions, - private RedirectResponseHelperInterface $redirectResponseHelper + private NotFoundRedirectResolverInterface $redirectResolver, + private DomainServiceInterface $domainService, ) { } @@ -24,26 +26,17 @@ class NotFoundRedirectHandler implements MiddlewareInterface { /** @var NotFoundType $notFoundType */ $notFoundType = $request->getAttribute(NotFoundType::class); + $authority = $request->getUri()->getAuthority(); + $domainSpecificRedirect = $this->resolveDomainSpecificRedirect($authority, $notFoundType); - if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) { - // @phpstan-ignore-next-line Create custom PHPStan rule - return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect()); - } + return $domainSpecificRedirect + ?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions) + ?? $handler->handle($request); + } - if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) { - return $this->redirectResponseHelper->buildRedirectResponse( - // @phpstan-ignore-next-line Create custom PHPStan rule - $this->redirectOptions->getRegular404Redirect(), - ); - } - - if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) { - return $this->redirectResponseHelper->buildRedirectResponse( - // @phpstan-ignore-next-line Create custom PHPStan rule - $this->redirectOptions->getInvalidShortUrlRedirect(), - ); - } - - return $handler->handle($request); + private function resolveDomainSpecificRedirect(string $authority, NotFoundType $notFoundType): ?ResponseInterface + { + $domain = $this->domainService->findByAuthority($authority); + return $domain === null ? null : $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain); } } diff --git a/module/Core/src/Options/NotFoundRedirectOptions.php b/module/Core/src/Options/NotFoundRedirectOptions.php index 1bb3b828..2f2d813b 100644 --- a/module/Core/src/Options/NotFoundRedirectOptions.php +++ b/module/Core/src/Options/NotFoundRedirectOptions.php @@ -5,14 +5,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; use Laminas\Stdlib\AbstractOptions; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; -class NotFoundRedirectOptions extends AbstractOptions +class NotFoundRedirectOptions extends AbstractOptions implements NotFoundRedirectConfigInterface { private ?string $invalidShortUrl = null; private ?string $regular404 = null; private ?string $baseUrl = null; - public function getInvalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): ?string { return $this->invalidShortUrl; } @@ -28,7 +29,7 @@ class NotFoundRedirectOptions extends AbstractOptions return $this; } - public function getRegular404Redirect(): ?string + public function regular404Redirect(): ?string { return $this->regular404; } @@ -44,7 +45,7 @@ class NotFoundRedirectOptions extends AbstractOptions return $this; } - public function getBaseUrlRedirect(): ?string + public function baseUrlRedirect(): ?string { return $this->baseUrl; } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php new file mode 100644 index 00000000..fe482a41 --- /dev/null +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -0,0 +1,114 @@ +helper = $this->prophesize(RedirectResponseHelperInterface::class); + $this->resolver = new NotFoundRedirectResolver($this->helper->reveal()); + + $this->config = new NotFoundRedirectOptions([ + 'invalidShortUrl' => 'invalidShortUrl', + 'regular404' => 'regular404', + 'baseUrl' => 'baseUrl', + ]); + } + + /** + * @test + * @dataProvider provideRedirects + */ + public function expectedRedirectionIsReturnedDependingOnTheCase( + NotFoundType $notFoundType, + string $expectedRedirectTo, + ): void { + $expectedResp = new Response(); + $buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp); + + $resp = $this->resolver->resolveRedirectResponse($notFoundType, $this->config); + + self::assertSame($expectedResp, $resp); + $buildResp->shouldHaveBeenCalledOnce(); + } + + public function provideRedirects(): iterable + { + yield 'base URL with trailing slash' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))), + 'baseUrl', + ]; + yield 'base URL without trailing slash' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))), + 'baseUrl', + ]; + yield 'regular 404' => [ + $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))), + 'regular404', + ]; + yield 'invalid short URL' => [ + $this->notFoundType($this->requestForRoute(RedirectAction::class)), + 'invalidShortUrl', + ]; + } + + /** @test */ + public function noResponseIsReturnedIfNoConditionsMatch(): void + { + $notFoundType = $this->notFoundType($this->requestForRoute('foo')); + + $result = $this->resolver->resolveRedirectResponse($notFoundType, $this->config); + + self::assertNull($result); + $this->helper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + private function notFoundType(ServerRequestInterface $req): NotFoundType + { + return NotFoundType::fromRequest($req, ''); + } + + private function requestForRoute(string $routeName): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals() + ->withAttribute( + RouteResult::class, + RouteResult::fromRoute( + new Route( + '', + $this->prophesize(MiddlewareInterface::class)->reveal(), + ['GET'], + $routeName, + ), + ), + ) + ->withUri(new Uri('/abc123')); + } +} diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index f3054f49..b0f22710 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -6,21 +6,18 @@ namespace ShlinkioTest\Shlink\Core\ErrorHandler; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; -use Laminas\Diactoros\Uri; -use Mezzio\Router\Route; -use Mezzio\Router\RouteResult; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Core\Action\RedirectAction; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface; +use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; -use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; class NotFoundRedirectHandlerTest extends TestCase { @@ -28,93 +25,103 @@ class NotFoundRedirectHandlerTest extends TestCase private NotFoundRedirectHandler $middleware; private NotFoundRedirectOptions $redirectOptions; - private ObjectProphecy $helper; + private ObjectProphecy $resolver; + private ObjectProphecy $domainService; + private ObjectProphecy $next; + private ServerRequestInterface $req; public function setUp(): void { $this->redirectOptions = new NotFoundRedirectOptions(); - $this->helper = $this->prophesize(RedirectResponseHelperInterface::class); - $this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->helper->reveal()); + $this->resolver = $this->prophesize(NotFoundRedirectResolverInterface::class); + $this->domainService = $this->prophesize(DomainServiceInterface::class); + + $this->middleware = new NotFoundRedirectHandler( + $this->redirectOptions, + $this->resolver->reveal(), + $this->domainService->reveal(), + ); + + $this->next = $this->prophesize(RequestHandlerInterface::class); + $this->req = ServerRequestFactory::fromGlobals()->withAttribute( + NotFoundType::class, + $this->prophesize(NotFoundType::class)->reveal(), + ); } /** * @test - * @dataProvider provideRedirects + * @dataProvider provideNonRedirectScenarios */ - public function expectedRedirectionIsReturnedDependingOnTheCase( - ServerRequestInterface $request, - string $expectedRedirectTo, - ): void { - $this->redirectOptions->invalidShortUrl = 'invalidShortUrl'; - $this->redirectOptions->regular404 = 'regular404'; - $this->redirectOptions->baseUrl = 'baseUrl'; - + public function nextIsCalledWhenNoRedirectIsResolved(callable $setUp): void + { $expectedResp = new Response(); - $buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp); - $next = $this->prophesize(RequestHandlerInterface::class); - $handle = $next->handle($request)->willReturn(new Response()); + $setUp($this->domainService, $this->resolver); + $handle = $this->next->handle($this->req)->willReturn($expectedResp); - $resp = $this->middleware->process($request, $next->reveal()); + $result = $this->middleware->process($this->req, $this->next->reveal()); - self::assertSame($expectedResp, $resp); - $buildResp->shouldHaveBeenCalledOnce(); - $handle->shouldNotHaveBeenCalled(); - } - - public function provideRedirects(): iterable - { - yield 'base URL with trailing slash' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))), - 'baseUrl', - ]; - yield 'base URL without trailing slash' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))), - 'baseUrl', - ]; - yield 'regular 404' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))), - 'regular404', - ]; - yield 'invalid short URL' => [ - $this->withNotFoundType(ServerRequestFactory::fromGlobals() - ->withAttribute( - RouteResult::class, - RouteResult::fromRoute( - new Route( - '', - $this->prophesize(MiddlewareInterface::class)->reveal(), - ['GET'], - RedirectAction::class, - ), - ), - ) - ->withUri(new Uri('/abc123'))), - 'invalidShortUrl', - ]; - } - - /** @test */ - public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void - { - $req = $this->withNotFoundType(ServerRequestFactory::fromGlobals()); - $resp = new Response(); - - $buildResp = $this->helper->buildRedirectResponse(Argument::cetera()); - - $next = $this->prophesize(RequestHandlerInterface::class); - $handle = $next->handle($req)->willReturn($resp); - - $result = $this->middleware->process($req, $next->reveal()); - - self::assertSame($resp, $result); - $buildResp->shouldNotHaveBeenCalled(); + self::assertSame($expectedResp, $result); $handle->shouldHaveBeenCalledOnce(); } - private function withNotFoundType(ServerRequestInterface $req): ServerRequestInterface + public function provideNonRedirectScenarios(): iterable { - $type = NotFoundType::fromRequest($req, ''); - return $req->withAttribute(NotFoundType::class, $type); + yield 'no domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { + $domainService->findByAuthority(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledOnce(); + $resolver->resolveRedirectResponse(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledOnce(); + }]; + yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { + $domainService->findByAuthority(Argument::cetera()) + ->willReturn(new Domain('')) + ->shouldBeCalledOnce(); + $resolver->resolveRedirectResponse(Argument::cetera()) + ->willReturn(null) + ->shouldBeCalledTimes(2); + }]; + } + + /** @test */ + public function globalRedirectIsUsedIfDomainRedirectIsNotFound(): void + { + $expectedResp = new Response(); + + $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn(null); + $resolveRedirect = $this->resolver->resolveRedirectResponse( + Argument::type(NotFoundType::class), + $this->redirectOptions, + )->willReturn($expectedResp); + + $result = $this->middleware->process($this->req, $this->next->reveal()); + + self::assertSame($expectedResp, $result); + $findDomain->shouldHaveBeenCalledOnce(); + $resolveRedirect->shouldHaveBeenCalledOnce(); + $this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function domainRedirectIsUsedIfFound(): void + { + $expectedResp = new Response(); + $domain = new Domain(''); + + $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn($domain); + $resolveRedirect = $this->resolver->resolveRedirectResponse( + Argument::type(NotFoundType::class), + $domain, + )->willReturn($expectedResp); + + $result = $this->middleware->process($this->req, $this->next->reveal()); + + self::assertSame($expectedResp, $result); + $findDomain->shouldHaveBeenCalledOnce(); + $resolveRedirect->shouldHaveBeenCalledOnce(); + $this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled(); } } From 4642480bbb19330982f76ab228da676a6f3d2ef6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Jul 2021 09:41:58 +0200 Subject: [PATCH 39/69] Updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1124864..c7679799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This behavior needs to be actively opted in, via installer config options or env vars. +* [#943](https://github.com/shlinkio/shlink/issues/943) Added support to define different "not-found" redirects for every domain handled by Shlink. + + Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command to define specific values for every single domain. + ### Changed * [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8. From 021cecc21648897fb4d9c3b57f0b2c2dd074d51c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Jul 2021 21:09:33 +0200 Subject: [PATCH 40/69] Created command that allows configuring not found redirects for every domain --- module/CLI/config/cli.config.php | 1 + module/CLI/config/dependencies.config.php | 2 + .../Command/Domain/DomainRedirectsCommand.php | 114 ++++++++++++++++++ .../src/Command/Domain/ListDomainsCommand.php | 38 +++++- .../Command/Domain/ListDomainsCommandTest.php | 8 +- module/Core/config/dependencies.config.php | 6 +- module/Core/src/Config/NotFoundRedirects.php | 30 +++++ module/Core/src/Domain/DomainService.php | 23 +++- .../src/Domain/DomainServiceInterface.php | 3 + module/Core/src/Domain/Model/DomainItem.php | 26 +++- module/Core/src/Entity/Domain.php | 8 ++ module/Core/test/Domain/DomainServiceTest.php | 28 +++-- .../Action/Domain/ListDomainsActionTest.php | 6 +- 13 files changed, 269 insertions(+), 24 deletions(-) create mode 100644 module/CLI/src/Command/Domain/DomainRedirectsCommand.php create mode 100644 module/Core/src/Config/NotFoundRedirects.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 6043833b..46bb90ef 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -27,6 +27,7 @@ return [ Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class, Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class, + Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class, Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class, Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 5f51d6c2..95ea1bbc 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -61,6 +61,7 @@ return [ Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class, Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class, + Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class, ], ], @@ -104,6 +105,7 @@ return [ Command\Tag\DeleteTagsCommand::class => [TagService::class], Command\Domain\ListDomainsCommand::class => [DomainService::class], + Command\Domain\DomainRedirectsCommand::class => [DomainService::class], Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php new file mode 100644 index 00000000..9a97e5fd --- /dev/null +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -0,0 +1,114 @@ +setName(self::NAME) + ->setDescription('Set specific "not found" redirects for individual domains.') + ->addArgument( + 'domain', + InputArgument::REQUIRED, + 'The domain authority to which you want to set the specific redirects', + ); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + /** @var string|null $domain */ + $domain = $input->getArgument('domain'); + if ($domain !== null) { + return; + } + + $io = new SymfonyStyle($input, $output); + $askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects'); + + /** @var string[] $availableDomains */ + $availableDomains = invoke( + filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()), + 'toString', + ); + if (empty($availableDomains)) { + $input->setArgument('domain', $askNewDomain()); + return; + } + + $selectedOption = $io->choice( + 'Select the domain to configure', + [...$availableDomains, 'New domain'], + ); + $input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption); + } + + protected function execute(InputInterface $input, OutputInterface $output): ?int + { + $io = new SymfonyStyle($input, $output); + $domainAuthority = $input->getArgument('domain'); + $domain = $this->domainService->findByAuthority($domainAuthority); + + $ask = static function (string $message, ?string $current) use ($io): ?string { + if ($current === null) { + return $io->ask(sprintf('%s (Leave empty for no redirect)', $message)); + } + + $choice = $io->choice($message, [ + sprintf('Keep current one: [%s]', $current), + 'Set new redirect URL', + 'Remove redirect', + ]); + + return match ($choice) { + 'Set new redirect URL' => $io->ask('New redirect URL'), + 'Remove redirect' => null, + default => $current, + }; + }; + + $this->domainService->configureNotFoundRedirects($domainAuthority, new NotFoundRedirects( + $ask( + 'URL to redirect to when a user hits this domain\'s base URL', + $domain?->baseUrlRedirect(), + ), + $ask( + 'URL to redirect to when a user hits a not found URL other than an invalid short URL', + $domain?->regular404Redirect(), + ), + $ask( + 'URL to redirect to when a user hits an invalid short URL', + $domain?->invalidShortUrlRedirect(), + ), + )); + + $io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority)); + + return ExitCodes::EXIT_SUCCESS; + } +} diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 6fa25097..5e368170 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\CLI\Command\Domain; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Functional\map; @@ -27,18 +29,48 @@ class ListDomainsCommand extends Command { $this ->setName(self::NAME) - ->setDescription('List all domains that have been ever used for some short URL'); + ->setDescription('List all domains that have been ever used for some short URL') + ->addOption( + 'show-redirects', + 'r', + InputOption::VALUE_NONE, + 'Will display an extra column with the information of the "not found" redirects for every domain.', + ); } protected function execute(InputInterface $input, OutputInterface $output): ?int { $domains = $this->domainService->listDomains(); + $showRedirects = $input->getOption('show-redirects'); + $commonFields = ['Domain', 'Is default']; ShlinkTable::fromOutput($output)->render( - ['Domain', 'Is default'], - map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']), + $showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields, + map($domains, function (DomainItem $domain) use ($showRedirects) { + $commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']; + + return $showRedirects + ? [ + ...$commonValues, + $this->notFoundRedirectsToString($domain->notFoundRedirectConfig()), + ] + : $commonValues; + }), ); return ExitCodes::EXIT_SUCCESS; } + + private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string + { + $baseUrl = $config->baseUrlRedirect() ?? 'N/A'; + $regular404 = $config->regular404Redirect() ?? 'N/A'; + $invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A'; + + return <<domainService->listDomains()->willReturn([ - new DomainItem('foo.com', true), - new DomainItem('bar.com', false), - new DomainItem('baz.com', false), + DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions()), + DomainItem::forExistingDomain(new Domain('bar.com')), + DomainItem::forExistingDomain(new Domain('baz.com')), ]); $this->commandTester->execute([]); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index a215a871..7f28b14d 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -115,7 +115,11 @@ return [ ], Service\ShortUrl\ShortUrlResolver::class => ['em'], Service\ShortUrl\ShortCodeHelper::class => ['em'], - Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], + Domain\DomainService::class => [ + 'em', + 'config.url_shortener.domain.hostname', + Options\NotFoundRedirectOptions::class, + ], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], Util\DoctrineBatchHelper::class => ['em'], diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php new file mode 100644 index 00000000..2a1e68d4 --- /dev/null +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -0,0 +1,30 @@ +baseUrlRedirect; + } + + public function regular404Redirect(): ?string + { + return $this->regular404Redirect; + } + + public function invalidShortUrlRedirect(): ?string + { + return $this->invalidShortUrlRedirect; + } +} diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 6d99b0a7..b974e6d7 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -16,8 +18,11 @@ use function Functional\map; class DomainService implements DomainServiceInterface { - public function __construct(private EntityManagerInterface $em, private string $defaultDomain) - { + public function __construct( + private EntityManagerInterface $em, + private string $defaultDomain, + private NotFoundRedirectOptions $redirectOptions, + ) { } /** @@ -28,14 +33,14 @@ class DomainService implements DomainServiceInterface /** @var DomainRepositoryInterface $repo */ $repo = $this->em->getRepository(Domain::class); $domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey); - $mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false)); + $mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forExistingDomain($domain)); if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { return $mappedDomains; } return [ - new DomainItem($this->defaultDomain, true), + DomainItem::forDefaultDomain($this->defaultDomain, $this->redirectOptions), ...$mappedDomains, ]; } @@ -69,4 +74,14 @@ class DomainService implements DomainServiceInterface return $domain; } + + public function configureNotFoundRedirects(string $authority, NotFoundRedirects $notFoundRedirects): Domain + { + $domain = $this->getOrCreate($authority); + $domain->configureNotFoundRedirects($notFoundRedirects); + + $this->em->flush(); + + return $domain; + } } diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index be357a22..802ecc7a 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; @@ -24,4 +25,6 @@ interface DomainServiceInterface public function getOrCreate(string $authority): Domain; public function findByAuthority(string $authority): ?Domain; + + public function configureNotFoundRedirects(string $authority, NotFoundRedirects $notFoundRedirects): Domain; } diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index f389f1e7..bad7d5cf 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -5,28 +5,48 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Model; use JsonSerializable; +use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; +use Shlinkio\Shlink\Core\Entity\Domain; final class DomainItem implements JsonSerializable { - public function __construct(private string $domain, private bool $isDefault) + private function __construct( + private string $authority, + private NotFoundRedirectConfigInterface $notFoundRedirectConfig, + private bool $isDefault + ) { + } + + public static function forExistingDomain(Domain $domain): self { + return new self($domain->getAuthority(), $domain, false); + } + + public static function forDefaultDomain(string $authority, NotFoundRedirectConfigInterface $config): self + { + return new self($authority, $config, true); } public function jsonSerialize(): array { return [ - 'domain' => $this->domain, + 'domain' => $this->authority, 'isDefault' => $this->isDefault, ]; } public function toString(): string { - return $this->domain; + return $this->authority; } public function isDefault(): bool { return $this->isDefault; } + + public function notFoundRedirectConfig(): NotFoundRedirectConfigInterface + { + return $this->notFoundRedirectConfig; + } } diff --git a/module/Core/src/Entity/Domain.php b/module/Core/src/Entity/Domain.php index 73b790c4..cb777a7b 100644 --- a/module/Core/src/Entity/Domain.php +++ b/module/Core/src/Entity/Domain.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Entity; use JsonSerializable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface { @@ -57,4 +58,11 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec { return $this->baseUrlRedirect !== null; } + + public function configureNotFoundRedirects(NotFoundRedirects $redirects): void + { + $this->baseUrlRedirect = $redirects->baseUrlRedirect(); + $this->regular404Redirect = $redirects->regular404Redirect(); + $this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect(); + } } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 80326b3c..de58b1b5 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -28,7 +29,7 @@ class DomainServiceTest extends TestCase public function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); - $this->domainService = new DomainService($this->em->reveal(), 'default.com'); + $this->domainService = new DomainService($this->em->reveal(), 'default.com', new NotFoundRedirectOptions()); } /** @@ -50,7 +51,7 @@ class DomainServiceTest extends TestCase public function provideExcludedDomains(): iterable { - $default = new DomainItem('default.com', true); + $default = DomainItem::forDefaultDomain('default.com', new NotFoundRedirectOptions()); $adminApiKey = ApiKey::create(); $domainSpecificApiKey = ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123'))), @@ -59,36 +60,47 @@ class DomainServiceTest extends TestCase yield 'empty list without API key' => [[], [$default], null]; yield 'one item without API key' => [ [new Domain('bar.com')], - [$default, new DomainItem('bar.com', false)], + [$default, DomainItem::forExistingDomain(new Domain('bar.com'))], null, ]; yield 'multiple items without API key' => [ [new Domain('foo.com'), new Domain('bar.com')], - [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + [ + $default, + DomainItem::forExistingDomain(new Domain('foo.com')), + DomainItem::forExistingDomain(new Domain('bar.com')), + ], null, ]; yield 'empty list with admin API key' => [[], [$default], $adminApiKey]; yield 'one item with admin API key' => [ [new Domain('bar.com')], - [$default, new DomainItem('bar.com', false)], + [$default, DomainItem::forExistingDomain(new Domain('bar.com'))], $adminApiKey, ]; yield 'multiple items with admin API key' => [ [new Domain('foo.com'), new Domain('bar.com')], - [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + [ + $default, + DomainItem::forExistingDomain(new Domain('foo.com')), + DomainItem::forExistingDomain(new Domain('bar.com')), + ], $adminApiKey, ]; yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey]; yield 'one item with domain-specific API key' => [ [new Domain('bar.com')], - [new DomainItem('bar.com', false)], + [DomainItem::forExistingDomain(new Domain('bar.com'))], $domainSpecificApiKey, ]; yield 'multiple items with domain-specific API key' => [ [new Domain('foo.com'), new Domain('bar.com')], - [new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + [ + DomainItem::forExistingDomain(new Domain('foo.com')), + DomainItem::forExistingDomain(new Domain('bar.com')), + ], $domainSpecificApiKey, ]; } diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index cbe43895..beff58fd 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -11,6 +11,8 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; +use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Rest\Action\Domain\ListDomainsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -32,8 +34,8 @@ class ListDomainsActionTest extends TestCase { $apiKey = ApiKey::create(); $domains = [ - new DomainItem('bar.com', true), - new DomainItem('baz.com', false), + DomainItem::forDefaultDomain('bar.com', new NotFoundRedirectOptions()), + DomainItem::forExistingDomain(new Domain('baz.com')), ]; $listDomains = $this->domainService->listDomains($apiKey)->willReturn($domains); From 267d72a76cb9cfcd2bf0f7fd7c3e5771818bca5a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 22 Jul 2021 17:49:37 +0200 Subject: [PATCH 41/69] Improved unit tests covering new not found redirects for domains capability --- .../Command/Domain/ListDomainsCommandTest.php | 62 +++++++++++++++---- module/Core/test/Domain/DomainServiceTest.php | 31 ++++++++++ 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 4df11de4..eb586478 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Entity\Domain; @@ -28,10 +29,38 @@ class ListDomainsCommandTest extends TestCase $this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal())); } - /** @test */ - public function allDomainsAreProperlyPrinted(): void + /** + * @test + * @dataProvider provideInputsAndOutputs + */ + public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void { - $expectedOutput = <<configureNotFoundRedirects(new NotFoundRedirects( + null, + 'https://foo.com/baz-domain/regular', + 'https://foo.com/baz-domain/invalid', + )); + + $listDomains = $this->domainService->listDomains()->willReturn([ + DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions([ + 'base_url' => 'https://foo.com/default/base', + 'invalid_short_url' => 'https://foo.com/default/invalid', + ])), + DomainItem::forExistingDomain(new Domain('bar.com')), + DomainItem::forExistingDomain($bazDomain), + ]); + + $this->commandTester->execute($input); + + self::assertEquals($expectedOutput, $this->commandTester->getDisplay()); + self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + $listDomains->shouldHaveBeenCalledOnce(); + } + + public function provideInputsAndOutputs(): iterable + { + $withoutRedirectsOutput = <<domainService->listDomains()->willReturn([ - DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions()), - DomainItem::forExistingDomain(new Domain('bar.com')), - DomainItem::forExistingDomain(new Domain('baz.com')), - ]); + $withRedirectsOutput = <<commandTester->execute([]); + OUTPUT; - self::assertEquals($expectedOutput, $this->commandTester->getDisplay()); - self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); - $listDomains->shouldHaveBeenCalledOnce(); + yield 'no args' => [[], $withoutRedirectsOutput]; + yield 'no show redirects' => [['--show-redirects' => false], $withoutRedirectsOutput]; + yield 'show redirects' => [['--show-redirects' => true], $withRedirectsOutput]; } } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index de58b1b5..dc0119c0 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; @@ -151,6 +152,36 @@ class DomainServiceTest extends TestCase $flush->shouldHaveBeenCalledOnce(); } + /** + * @test + * @dataProvider provideFoundDomains + */ + public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain): void + { + $authority = 'example.com'; + $repo = $this->prophesize(DomainRepositoryInterface::class); + $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); + $flush = $this->em->flush(); + + $result = $this->domainService->configureNotFoundRedirects($authority, new NotFoundRedirects( + 'foo.com', + 'bar.com', + 'baz.com', + )); + + if ($foundDomain !== null) { + self::assertSame($result, $foundDomain); + } + self::assertEquals('foo.com', $result->baseUrlRedirect()); + self::assertEquals('bar.com', $result->regular404Redirect()); + self::assertEquals('baz.com', $result->invalidShortUrlRedirect()); + $getRepo->shouldHaveBeenCalledOnce(); + $persist->shouldHaveBeenCalledOnce(); + $flush->shouldHaveBeenCalledTimes(2); + } + public function provideFoundDomains(): iterable { yield 'domain not found' => [null]; From 24a6a0c23f8398190119886076a46fcc30f46047 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 22 Jul 2021 20:48:58 +0200 Subject: [PATCH 42/69] Added test for DomainRedirectCommand --- module/CLI/test/ApiKey/RoleResolverTest.php | 4 +- .../test/Command/Api/ListKeysCommandTest.php | 6 +- .../Domain/DomainRedirectsCommandTest.php | 180 ++++++++++++++++++ .../Command/Domain/ListDomainsCommandTest.php | 4 +- module/Core/src/Domain/DomainService.php | 2 +- module/Core/src/Entity/Domain.php | 7 +- .../PersistenceShortUrlRelationResolver.php | 4 +- .../SimpleShortUrlRelationResolver.php | 2 +- .../Repository/DomainRepositoryTest.php | 14 +- .../Repository/ShortUrlRepositoryTest.php | 4 +- .../test-db/Repository/TagRepositoryTest.php | 2 +- .../Repository/VisitRepositoryTest.php | 2 +- module/Core/test/Domain/DomainServiceTest.php | 36 ++-- .../NotFoundRedirectHandlerTest.php | 4 +- .../Service/ShortUrl/ShortCodeHelperTest.php | 2 +- ...ersistenceShortUrlRelationResolverTest.php | 2 +- .../Rest/test-api/Fixtures/DomainFixture.php | 4 +- .../Action/Domain/ListDomainsActionTest.php | 2 +- .../ShortUrl/OverrideDomainMiddlewareTest.php | 14 +- .../Rest/test/Service/ApiKeyServiceTest.php | 2 +- 20 files changed, 245 insertions(+), 52 deletions(-) create mode 100644 module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index 76163348..5353ca72 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -36,7 +36,7 @@ class RoleResolverTest extends TestCase int $expectedDomainCalls, ): void { $getDomain = $this->domainService->getOrCreate('example.com')->willReturn( - (new Domain('example.com'))->setId('1'), + Domain::withAuthority('example.com')->setId('1'), ); $result = $this->resolver->determineRoles($input); @@ -47,7 +47,7 @@ class RoleResolverTest extends TestCase public function provideRoles(): iterable { - $domain = (new Domain('example.com'))->setId('1'); + $domain = Domain::withAuthority('example.com')->setId('1'); $buildInput = function (array $definition): InputInterface { $input = $this->prophesize(InputInterface::class); diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index fc845ff7..389c6bbd 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -76,11 +76,13 @@ class ListKeysCommandTest extends TestCase [ $apiKey1 = ApiKey::create(), $apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]), - $apiKey3 = $this->apiKeyWithRoles([RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]), + $apiKey3 = $this->apiKeyWithRoles( + [RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1'))], + ), $apiKey4 = ApiKey::create(), $apiKey5 = $this->apiKeyWithRoles([ RoleDefinition::forAuthoredShortUrls(), - RoleDefinition::forDomain((new Domain('example.com'))->setId('1')), + RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1')), ]), $apiKey6 = ApiKey::create(), ], diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php new file mode 100644 index 00000000..1f8b93ab --- /dev/null +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -0,0 +1,180 @@ +domainService = $this->prophesize(DomainServiceInterface::class); + $this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal())); + } + + /** + * @test + * @dataProvider provideDomains + */ + public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void + { + $domainAuthority = 'my-domain.com'; + $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); + $configureRedirects = $this->domainService->configureNotFoundRedirects( + $domainAuthority, + new NotFoundRedirects('foo.com', null, 'baz.com'), + )->willReturn(Domain::withAuthority('')); + + $this->commandTester->setInputs(['foo.com', '', 'baz.com']); + $this->commandTester->execute(['domain' => $domainAuthority]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString('[OK] "Not found" redirects properly set for "my-domain.com"', $output); + self::assertStringContainsString('URL to redirect to when a user hits this domain\'s base URL', $output); + self::assertStringContainsString( + 'URL to redirect to when a user hits a not found URL other than an invalid short URL', + $output, + ); + self::assertStringContainsString('URL to redirect to when a user hits an invalid short URL', $output); + self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)')); + $findDomain->shouldHaveBeenCalledOnce(); + $configureRedirects->shouldHaveBeenCalledOnce(); + $this->domainService->listDomains()->shouldNotHaveBeenCalled(); + } + + public function provideDomains(): iterable + { + yield 'no domain' => [null]; + yield 'domain without redirects' => [Domain::withAuthority('')]; + } + + /** @test */ + public function offersNewOptionsForDomainsWithExistingRedirects(): void + { + $domainAuthority = 'example.com'; + $domain = Domain::withAuthority($domainAuthority); + $domain->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com', 'baz.com')); + + $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); + $configureRedirects = $this->domainService->configureNotFoundRedirects( + $domainAuthority, + new NotFoundRedirects(null, 'edited.com', 'baz.com'), + )->willReturn($domain); + + $this->commandTester->setInputs(['2', '1', 'edited.com', '0']); + $this->commandTester->execute(['domain' => $domainAuthority]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString('[OK] "Not found" redirects properly set for "example.com"', $output); + self::assertStringContainsString('Keep current one: [bar.com]', $output); + self::assertStringContainsString('Keep current one: [baz.com]', $output); + self::assertStringContainsString('Keep current one: [baz.com]', $output); + self::assertStringNotContainsStringIgnoringCase('(Leave empty for no redirect)', $output); + self::assertEquals(3, substr_count($output, 'Set new redirect URL')); + self::assertEquals(3, substr_count($output, 'Remove redirect')); + $findDomain->shouldHaveBeenCalledOnce(); + $configureRedirects->shouldHaveBeenCalledOnce(); + $this->domainService->listDomains()->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function authorityIsRequestedWhenNotProvidedAndNoOtherDomainsExist(): void + { + $domainAuthority = 'example.com'; + $domain = Domain::withAuthority($domainAuthority); + + $listDomains = $this->domainService->listDomains()->willReturn([]); + $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); + $configureRedirects = $this->domainService->configureNotFoundRedirects( + $domainAuthority, + new NotFoundRedirects(), + )->willReturn($domain); + + $this->commandTester->setInputs([$domainAuthority, '', '', '']); + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output); + $listDomains->shouldHaveBeenCalledOnce(); + $findDomain->shouldHaveBeenCalledOnce(); + $configureRedirects->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function oneOfTheExistingDomainsCanBeSelected(): void + { + $domainAuthority = 'existing-two.com'; + $domain = Domain::withAuthority($domainAuthority); + + $listDomains = $this->domainService->listDomains()->willReturn([ + DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()), + DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')), + DomainItem::forExistingDomain(Domain::withAuthority($domainAuthority)), + ]); + $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); + $configureRedirects = $this->domainService->configureNotFoundRedirects( + $domainAuthority, + new NotFoundRedirects(), + )->willReturn($domain); + + $this->commandTester->setInputs(['1', '', '', '']); + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertStringNotContainsString('Domain authority for which you want to set specific redirects', $output); + self::assertStringNotContainsString('default-domain.com', $output); + self::assertStringContainsString('existing-one.com', $output); + self::assertStringContainsString($domainAuthority, $output); + $listDomains->shouldHaveBeenCalledOnce(); + $findDomain->shouldHaveBeenCalledOnce(); + $configureRedirects->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function aNewDomainCanBeCreatedEvenIfOthersAlreadyExist(): void + { + $domainAuthority = 'new-domain.com'; + $domain = Domain::withAuthority($domainAuthority); + + $listDomains = $this->domainService->listDomains()->willReturn([ + DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()), + DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')), + DomainItem::forExistingDomain(Domain::withAuthority('existing-two.com')), + ]); + $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); + $configureRedirects = $this->domainService->configureNotFoundRedirects( + $domainAuthority, + new NotFoundRedirects(), + )->willReturn($domain); + + $this->commandTester->setInputs(['2', $domainAuthority, '', '', '']); + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output); + self::assertStringNotContainsString('default-domain.com', $output); + self::assertStringContainsString('existing-one.com', $output); + self::assertStringContainsString('existing-two.com', $output); + $listDomains->shouldHaveBeenCalledOnce(); + $findDomain->shouldHaveBeenCalledOnce(); + $configureRedirects->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index eb586478..9f4be920 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -35,7 +35,7 @@ class ListDomainsCommandTest extends TestCase */ public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void { - $bazDomain = new Domain('baz.com'); + $bazDomain = Domain::withAuthority('baz.com'); $bazDomain->configureNotFoundRedirects(new NotFoundRedirects( null, 'https://foo.com/baz-domain/regular', @@ -47,7 +47,7 @@ class ListDomainsCommandTest extends TestCase 'base_url' => 'https://foo.com/default/base', 'invalid_short_url' => 'https://foo.com/default/invalid', ])), - DomainItem::forExistingDomain(new Domain('bar.com')), + DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), DomainItem::forExistingDomain($bazDomain), ]); diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index b974e6d7..99bade7c 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -67,7 +67,7 @@ class DomainService implements DomainServiceInterface public function getOrCreate(string $authority): Domain { - $domain = $this->findByAuthority($authority) ?? new Domain($authority); + $domain = $this->findByAuthority($authority) ?? Domain::withAuthority($authority); $this->em->persist($domain); $this->em->flush(); diff --git a/module/Core/src/Entity/Domain.php b/module/Core/src/Entity/Domain.php index cb777a7b..65ca8ce6 100644 --- a/module/Core/src/Entity/Domain.php +++ b/module/Core/src/Entity/Domain.php @@ -15,10 +15,15 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec private ?string $regular404Redirect = null; private ?string $invalidShortUrlRedirect = null; - public function __construct(private string $authority) + private function __construct(private string $authority) { } + public static function withAuthority(string $authority): self + { + return new self($authority); + } + public function getAuthority(): string { return $this->authority; diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index a9456712..c8367b49 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -41,7 +41,9 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt private function memoizeNewDomain(string $domain): Domain { - return $this->memoizedNewDomains[$domain] = $this->memoizedNewDomains[$domain] ?? new Domain($domain); + return $this->memoizedNewDomains[$domain] = $this->memoizedNewDomains[$domain] ?? Domain::withAuthority( + $domain, + ); } /** diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index 2cda44df..173b530c 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -15,7 +15,7 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac { public function resolveDomain(?string $domain): ?Domain { - return $domain !== null ? new Domain($domain) : null; + return $domain !== null ? Domain::withAuthority($domain) : null; } /** diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 0f5aa259..ef2ae60d 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -28,19 +28,19 @@ class DomainRepositoryTest extends DatabaseTestCase /** @test */ public function findDomainsReturnsExpectedResult(): void { - $fooDomain = new Domain('foo.com'); + $fooDomain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($fooDomain); $this->getEntityManager()->persist($this->createShortUrl($fooDomain)); - $barDomain = new Domain('bar.com'); + $barDomain = Domain::withAuthority('bar.com'); $this->getEntityManager()->persist($barDomain); $this->getEntityManager()->persist($this->createShortUrl($barDomain)); - $bazDomain = new Domain('baz.com'); + $bazDomain = Domain::withAuthority('baz.com'); $this->getEntityManager()->persist($bazDomain); $this->getEntityManager()->persist($this->createShortUrl($bazDomain)); - $detachedDomain = new Domain('detached.com'); + $detachedDomain = Domain::withAuthority('detached.com'); $this->getEntityManager()->persist($detachedDomain); $this->getEntityManager()->flush(); @@ -59,15 +59,15 @@ class DomainRepositoryTest extends DatabaseTestCase $authorAndDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($authorAndDomainApiKey); - $fooDomain = new Domain('foo.com'); + $fooDomain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($fooDomain); $this->getEntityManager()->persist($this->createShortUrl($fooDomain, $authorApiKey)); - $barDomain = new Domain('bar.com'); + $barDomain = Domain::withAuthority('bar.com'); $this->getEntityManager()->persist($barDomain); $this->getEntityManager()->persist($this->createShortUrl($barDomain, $authorAndDomainApiKey)); - $bazDomain = new Domain('baz.com'); + $bazDomain = Domain::withAuthority('baz.com'); $this->getEntityManager()->persist($bazDomain); $this->getEntityManager()->persist($this->createShortUrl($bazDomain, $authorApiKey)); diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 867ff3f2..adc3d67f 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -340,9 +340,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase { $start = Chronos::parse('2020-03-05 20:18:30'); - $wrongDomain = new Domain('wrong.com'); + $wrongDomain = Domain::withAuthority('wrong.com'); $this->getEntityManager()->persist($wrongDomain); - $rightDomain = new Domain('right.com'); + $rightDomain = Domain::withAuthority('right.com'); $this->getEntityManager()->persist($rightDomain); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index eea2ed8c..92498d9a 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -97,7 +97,7 @@ class TagRepositoryTest extends DatabaseTestCase /** @test */ public function tagExistsReturnsExpectedResultBasedOnApiKey(): void { - $domain = new Domain('foo.com'); + $domain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($domain); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 15fe34f4..9f7859ff 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -222,7 +222,7 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function countVisitsReturnsExpectedResultBasedOnApiKey(): void { - $domain = new Domain('foo.com'); + $domain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($domain); $this->getEntityManager()->flush(); diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index dc0119c0..b53b4a26 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -55,52 +55,52 @@ class DomainServiceTest extends TestCase $default = DomainItem::forDefaultDomain('default.com', new NotFoundRedirectOptions()); $adminApiKey = ApiKey::create(); $domainSpecificApiKey = ApiKey::fromMeta( - ApiKeyMeta::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123'))), + ApiKeyMeta::withRoles(RoleDefinition::forDomain(Domain::withAuthority('')->setId('123'))), ); yield 'empty list without API key' => [[], [$default], null]; yield 'one item without API key' => [ - [new Domain('bar.com')], - [$default, DomainItem::forExistingDomain(new Domain('bar.com'))], + [Domain::withAuthority('bar.com')], + [$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], null, ]; yield 'multiple items without API key' => [ - [new Domain('foo.com'), new Domain('bar.com')], + [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], [ $default, - DomainItem::forExistingDomain(new Domain('foo.com')), - DomainItem::forExistingDomain(new Domain('bar.com')), + DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), + DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), ], null, ]; yield 'empty list with admin API key' => [[], [$default], $adminApiKey]; yield 'one item with admin API key' => [ - [new Domain('bar.com')], - [$default, DomainItem::forExistingDomain(new Domain('bar.com'))], + [Domain::withAuthority('bar.com')], + [$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], $adminApiKey, ]; yield 'multiple items with admin API key' => [ - [new Domain('foo.com'), new Domain('bar.com')], + [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], [ $default, - DomainItem::forExistingDomain(new Domain('foo.com')), - DomainItem::forExistingDomain(new Domain('bar.com')), + DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), + DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), ], $adminApiKey, ]; yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey]; yield 'one item with domain-specific API key' => [ - [new Domain('bar.com')], - [DomainItem::forExistingDomain(new Domain('bar.com'))], + [Domain::withAuthority('bar.com')], + [DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], $domainSpecificApiKey, ]; yield 'multiple items with domain-specific API key' => [ - [new Domain('foo.com'), new Domain('bar.com')], + [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], [ - DomainItem::forExistingDomain(new Domain('foo.com')), - DomainItem::forExistingDomain(new Domain('bar.com')), + DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), + DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), ], $domainSpecificApiKey, ]; @@ -120,7 +120,7 @@ class DomainServiceTest extends TestCase /** @test */ public function getDomainReturnsEntityWhenFound(): void { - $domain = new Domain(''); + $domain = Domain::withAuthority(''); $find = $this->em->find(Domain::class, '123')->willReturn($domain); $result = $this->domainService->getDomain('123'); @@ -185,6 +185,6 @@ class DomainServiceTest extends TestCase public function provideFoundDomains(): iterable { yield 'domain not found' => [null]; - yield 'domain found' => [new Domain('')]; + yield 'domain found' => [Domain::withAuthority('')]; } } diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index b0f22710..0d257d8e 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -78,7 +78,7 @@ class NotFoundRedirectHandlerTest extends TestCase }]; yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { $domainService->findByAuthority(Argument::cetera()) - ->willReturn(new Domain('')) + ->willReturn(Domain::withAuthority('')) ->shouldBeCalledOnce(); $resolver->resolveRedirectResponse(Argument::cetera()) ->willReturn(null) @@ -109,7 +109,7 @@ class NotFoundRedirectHandlerTest extends TestCase public function domainRedirectIsUsedIfFound(): void { $expectedResp = new Response(); - $domain = new Domain(''); + $domain = Domain::withAuthority(''); $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn($domain); $resolveRedirect = $this->resolver->resolveRedirectResponse( diff --git a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php b/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php index ca3b463f..b30f8cab 100644 --- a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php +++ b/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php @@ -60,7 +60,7 @@ class ShortCodeHelperTest extends TestCase public function provideDomains(): iterable { yield 'no domain' => [null, null]; - yield 'domain' => [new Domain($authority = 'doma.in'), $authority]; + yield 'domain' => [Domain::withAuthority($authority = 'doma.in'), $authority]; } /** @test */ diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index aeef3f47..9aaf9495 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -68,7 +68,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase $authority = 'doma.in'; yield 'not found domain' => [null, $authority]; - yield 'found domain' => [new Domain($authority), $authority]; + yield 'found domain' => [Domain::withAuthority($authority), $authority]; } /** diff --git a/module/Rest/test-api/Fixtures/DomainFixture.php b/module/Rest/test-api/Fixtures/DomainFixture.php index 576586a6..dc77864f 100644 --- a/module/Rest/test-api/Fixtures/DomainFixture.php +++ b/module/Rest/test-api/Fixtures/DomainFixture.php @@ -12,11 +12,11 @@ class DomainFixture extends AbstractFixture { public function load(ObjectManager $manager): void { - $domain = new Domain('example.com'); + $domain = Domain::withAuthority('example.com'); $manager->persist($domain); $this->addReference('example_domain', $domain); - $manager->persist(new Domain('this_domain_is_detached.com')); + $manager->persist(Domain::withAuthority('this_domain_is_detached.com')); $manager->flush(); } } diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index beff58fd..45575cc6 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -35,7 +35,7 @@ class ListDomainsActionTest extends TestCase $apiKey = ApiKey::create(); $domains = [ DomainItem::forDefaultDomain('bar.com', new NotFoundRedirectOptions()), - DomainItem::forExistingDomain(new Domain('baz.com')), + DomainItem::forExistingDomain(Domain::withAuthority('baz.com')), ]; $listDomains = $this->domainService->listDomains($apiKey)->willReturn($domains); diff --git a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php index 9614f8c7..ed1e62d3 100644 --- a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php @@ -82,19 +82,23 @@ class OverrideDomainMiddlewareTest extends TestCase public function provideBodies(): iterable { - yield 'no domain provided' => [new Domain('foo.com'), [], [ShortUrlInputFilter::DOMAIN => 'foo.com']]; + yield 'no domain provided' => [ + Domain::withAuthority('foo.com'), + [], + [ShortUrlInputFilter::DOMAIN => 'foo.com'], + ]; yield 'other domain provided' => [ - new Domain('bar.com'), + Domain::withAuthority('bar.com'), [ShortUrlInputFilter::DOMAIN => 'foo.com'], [ShortUrlInputFilter::DOMAIN => 'bar.com'], ]; yield 'same domain provided' => [ - new Domain('baz.com'), + Domain::withAuthority('baz.com'), [ShortUrlInputFilter::DOMAIN => 'baz.com'], [ShortUrlInputFilter::DOMAIN => 'baz.com'], ]; yield 'more body params' => [ - new Domain('doma.in'), + Domain::withAuthority('doma.in'), [ShortUrlInputFilter::DOMAIN => 'baz.com', 'something' => 'else', 'foo' => 123], [ShortUrlInputFilter::DOMAIN => 'doma.in', 'something' => 'else', 'foo' => 123], ]; @@ -106,7 +110,7 @@ class OverrideDomainMiddlewareTest extends TestCase */ public function setsRequestAttributeWhenMethodIsNotPost(string $method): void { - $domain = new Domain('something.com'); + $domain = Domain::withAuthority('something.com'); $request = $this->requestWithApiKey()->withMethod($method); $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(true); $getRoleMeta = $this->apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)->willReturn(['domain_id' => '123']); diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index addebbcd..de17d8bd 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -55,7 +55,7 @@ class ApiKeyServiceTest extends TestCase yield 'no expiration date or name' => [null, null, []]; yield 'expiration date' => [Chronos::parse('2030-01-01'), null, []]; yield 'roles' => [null, null, [ - RoleDefinition::forDomain((new Domain(''))->setId('123')), + RoleDefinition::forDomain(Domain::withAuthority('')->setId('123')), RoleDefinition::forAuthoredShortUrls(), ]]; yield 'single name' => [null, 'Alice', []]; From 8f3c740b579d92a3906f8c6957274d1524e10b05 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Jul 2021 13:06:03 +0200 Subject: [PATCH 43/69] Ensured domains not used in short URLs but with redirects configured are returned in domains list --- .../Domain/Repository/DomainRepository.php | 9 +++- .../Repository/DomainRepositoryTest.php | 47 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 2e4f3bb2..e0862558 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -18,8 +18,13 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array { $qb = $this->createQueryBuilder('d'); - $qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d') - ->orderBy('d.authority', 'ASC'); + $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') + ->orderBy('d.authority', 'ASC') + ->groupBy('d') + ->having($qb->expr()->gt('COUNT(s.id)', '0')) + ->orHaving($qb->expr()->isNotNull('d.baseUrlRedirect')) + ->orHaving($qb->expr()->isNotNull('d.regular404Redirect')) + ->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect')); if ($excludedAuthority !== null) { $qb->where($qb->expr()->neq('d.authority', ':excludedAuthority')) diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index ef2ae60d..9b0270a6 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Domain\Repository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -43,12 +44,32 @@ class DomainRepositoryTest extends DatabaseTestCase $detachedDomain = Domain::withAuthority('detached.com'); $this->getEntityManager()->persist($detachedDomain); + $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); + $detachedWithRedirects->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com')); + $this->getEntityManager()->persist($detachedWithRedirects); + $this->getEntityManager()->flush(); - self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout(null)); - self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com')); - self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com')); - self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com')); + self::assertEquals( + [$barDomain, $bazDomain, $detachedWithRedirects, $fooDomain], + $this->repo->findDomainsWithout(null), + ); + self::assertEquals( + [$barDomain, $bazDomain, $detachedWithRedirects], + $this->repo->findDomainsWithout('foo.com'), + ); + self::assertEquals( + [$bazDomain, $detachedWithRedirects, $fooDomain], + $this->repo->findDomainsWithout('bar.com'), + ); + self::assertEquals( + [$barDomain, $detachedWithRedirects, $fooDomain], + $this->repo->findDomainsWithout('baz.com'), + ); + self::assertEquals( + [$barDomain, $bazDomain, $fooDomain], + $this->repo->findDomainsWithout('detached-with-redirects.com'), + ); } /** @test */ @@ -71,6 +92,13 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($bazDomain); $this->getEntityManager()->persist($this->createShortUrl($bazDomain, $authorApiKey)); +// $detachedDomain = Domain::withAuthority('detached.com'); +// $this->getEntityManager()->persist($detachedDomain); +// +// $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); +// $detachedWithRedirects->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com')); +// $this->getEntityManager()->persist($detachedWithRedirects); + $this->getEntityManager()->flush(); $authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain)); @@ -79,12 +107,21 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($fooDomainApiKey); $barDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($barDomain))); - $this->getEntityManager()->persist($fooDomainApiKey); + $this->getEntityManager()->persist($barDomainApiKey); + +// $detachedWithRedirectsApiKey = ApiKey::fromMeta( +// ApiKeyMeta::withRoles(RoleDefinition::forDomain($detachedWithRedirects)), +// ); +// $this->getEntityManager()->persist($detachedWithRedirectsApiKey); $this->getEntityManager()->flush(); self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey)); self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey)); +// self::assertEquals( +// [$detachedWithRedirects], +// $this->repo->findDomainsWithout(null, $detachedWithRedirectsApiKey), +// ); self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey)); self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey)); } From 8b75ad1e7f86f779bc6aa8e0f28feb7931447c5a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Jul 2021 13:11:09 +0200 Subject: [PATCH 44/69] Covered detached domains with redirects in domains list API test --- module/Rest/test-api/Action/ListDomainsTest.php | 4 ++++ module/Rest/test-api/Fixtures/DomainFixture.php | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php index cf3167f8..075b6d09 100644 --- a/module/Rest/test-api/Action/ListDomainsTest.php +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -32,6 +32,10 @@ class ListDomainsTest extends ApiTestCase 'domain' => 'doma.in', 'isDefault' => true, ], + [ + 'domain' => 'detached-with-redirects.com', + 'isDefault' => false, + ], [ 'domain' => 'example.com', 'isDefault' => false, diff --git a/module/Rest/test-api/Fixtures/DomainFixture.php b/module/Rest/test-api/Fixtures/DomainFixture.php index dc77864f..31e68f21 100644 --- a/module/Rest/test-api/Fixtures/DomainFixture.php +++ b/module/Rest/test-api/Fixtures/DomainFixture.php @@ -6,6 +6,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Persistence\ObjectManager; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Entity\Domain; class DomainFixture extends AbstractFixture @@ -17,6 +18,11 @@ class DomainFixture extends AbstractFixture $this->addReference('example_domain', $domain); $manager->persist(Domain::withAuthority('this_domain_is_detached.com')); + + $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); + $detachedWithRedirects->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com')); + $manager->persist($detachedWithRedirects); + $manager->flush(); } } From 4c00764146cf3a883bea896e6def132646a6d8a7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 30 Jul 2021 18:40:26 +0200 Subject: [PATCH 45/69] Removed hardcoded dependency --- composer.json | 1 - module/Core/src/Model/ShortUrlIdentifier.php | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f71ffde0..1dbe2aa8 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,6 @@ "endroid/qr-code": "^4.0", "geoip2/geoip2": "^2.9", "guzzlehttp/guzzle": "^7.0", - "guzzlehttp/psr7": "^1.7", "happyr/doctrine-specification": "^2.0", "jaybizzle/crawler-detect": "^1.2", "laminas/laminas-config": "^3.3", diff --git a/module/Core/src/Model/ShortUrlIdentifier.php b/module/Core/src/Model/ShortUrlIdentifier.php index e0d6c9b4..815a5313 100644 --- a/module/Core/src/Model/ShortUrlIdentifier.php +++ b/module/Core/src/Model/ShortUrlIdentifier.php @@ -32,7 +32,11 @@ final class ShortUrlIdentifier public static function fromCli(InputInterface $input): self { + // Using getArguments and getOptions instead of getArgument(...) and getOption(...) because + // the later throw an exception if requested options are not defined + /** @var string $shortCode */ $shortCode = $input->getArguments()['shortCode'] ?? ''; + /** @var string|null $domain */ $domain = $input->getOptions()['domain'] ?? null; return new self($shortCode, $domain); From 377562cdff8e0f4eb83975e8f7c7233ddd253408 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Aug 2021 08:55:39 +0200 Subject: [PATCH 46/69] Disabled user change on Dockerfile, as it produces some issues --- Dockerfile | 15 ++++++++------- docker/docker-entrypoint.sh | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index e327c092..309970ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,12 +79,13 @@ COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.l COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/ # Change the ownership of /etc/shlink/data to be writable, then change the user to non-root -RUN chown 1001 /etc/shlink/data -RUN chown 1001 /etc/shlink/data/locks -RUN chown 1001 /etc/shlink/data/proxies -RUN chown 1001 /etc/shlink/data/cache -RUN chown 1001 /etc/shlink/data/log - -USER 1001 +# FIXME Disabled for now, as it conflicts with ENABLE_PERIODIC_VISIT_LOCATE, which is used to configure a cron as root. +# Ref: https://github.com/shlinkio/shlink/issues/1132 +#RUN chown 1001 /etc/shlink/data +#RUN chown 1001 /etc/shlink/data/locks +#RUN chown 1001 /etc/shlink/data/proxies +#RUN chown 1001 /etc/shlink/data/cache +#RUN chown 1001 /etc/shlink/data/log +#USER 1001 ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"] diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 915c5f83..8847b757 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -25,7 +25,7 @@ fi # https://shlink.io/documentation/long-running-tasks/#locate-visits # set env var "ENABLE_PERIODIC_VISIT_LOCATE=true" to enable if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then - echo "Starting periodic visite locate..." + echo "Configuring periodic visit locate..." echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root /usr/sbin/crond & fi From dc68bb907cc02594e61ffb66581dd917e13d19a3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Aug 2021 09:57:34 +0200 Subject: [PATCH 47/69] Updated infection to v0.24 --- composer.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 1dbe2aa8..1883e945 100644 --- a/composer.json +++ b/composer.json @@ -23,23 +23,23 @@ "doctrine/orm": "^2.8.4", "endroid/qr-code": "^4.0", "geoip2/geoip2": "^2.9", - "guzzlehttp/guzzle": "^7.0", + "guzzlehttp/guzzle": "^7.3", "happyr/doctrine-specification": "^2.0", "jaybizzle/crawler-detect": "^1.2", - "laminas/laminas-config": "^3.3", - "laminas/laminas-config-aggregator": "^1.1", - "laminas/laminas-diactoros": "^2.1.3", + "laminas/laminas-config": "^3.5", + "laminas/laminas-config-aggregator": "^1.5", + "laminas/laminas-diactoros": "^2.6", "laminas/laminas-inputfilter": "^2.10", - "laminas/laminas-servicemanager": "^3.6", + "laminas/laminas-servicemanager": "^3.7", "laminas/laminas-stdlib": "^3.2", "lcobucci/jwt": "^4.0", "league/uri": "^6.2", "lstrojny/functional-php": "^1.17", - "mezzio/mezzio": "^3.3", - "mezzio/mezzio-fastroute": "^3.1", - "mezzio/mezzio-problem-details": "^1.3", + "mezzio/mezzio": "^3.5", + "mezzio/mezzio-fastroute": "^3.2", + "mezzio/mezzio-problem-details": "^1.4", "mezzio/mezzio-swoole": "^3.3", - "monolog/monolog": "^2.0", + "monolog/monolog": "^2.3", "nikolaposa/monolog-factory": "^3.1", "ocramius/proxy-manager": "^2.11", "pagerfanta/core": "^2.5", @@ -64,7 +64,7 @@ "devster/ubench": "^2.1", "dms/phpunit-arraysubset-asserts": "^0.3.0", "eaglewu/swoole-ide-helper": "dev-master", - "infection/infection": "^0.23.0", + "infection/infection": "^0.24.0", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^0.12.93", "phpstan/phpstan-doctrine": "^0.12.42", From 9d14597be080d9ca6a52807175dc78d7f54fe9c6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 1 Aug 2021 10:00:24 +0200 Subject: [PATCH 48/69] Added --only-covering-test-cases flag when running infection commands --- CHANGELOG.md | 1 + composer.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e98e83..f87a008c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8. +* [#1127](https://github.com/shlinkio/shlink/issues/1127) Updated to infection 0.24. ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 1883e945..9ba03058 100644 --- a/composer.json +++ b/composer.json @@ -136,7 +136,7 @@ "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", - "infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests", + "infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests", "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json", "infect:ci": "@parallel infect:ci:unit infect:ci:db", From 192308a6a3cb6997caac319257d5c02f605be2ab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 25 Jul 2021 18:46:48 +0200 Subject: [PATCH 49/69] Added swagger docs for endpoint do edit domain redirects --- .../definitions/NotFoundRedirects.json | 20 +++++ docs/swagger/paths/v2_domains_redirects.json | 81 +++++++++++++++++++ docs/swagger/swagger.json | 5 +- 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 docs/swagger/definitions/NotFoundRedirects.json create mode 100644 docs/swagger/paths/v2_domains_redirects.json diff --git a/docs/swagger/definitions/NotFoundRedirects.json b/docs/swagger/definitions/NotFoundRedirects.json new file mode 100644 index 00000000..6887ed0c --- /dev/null +++ b/docs/swagger/definitions/NotFoundRedirects.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "baseUrlRedirect": { + "type": "string", + "nullable": true, + "description": "URL to redirect to when a user hits the domain's base URL" + }, + "regular404Redirect": { + "type": "string", + "nullable": true, + "description": "URL to redirect to when a user hits a not found URL other than an invalid short URL" + }, + "invalidShortUrlRedirect": { + "type": "string", + "nullable": true, + "description": "URL to redirect to when a user hits an invalid short URL" + } + } +} diff --git a/docs/swagger/paths/v2_domains_redirects.json b/docs/swagger/paths/v2_domains_redirects.json new file mode 100644 index 00000000..0868504e --- /dev/null +++ b/docs/swagger/paths/v2_domains_redirects.json @@ -0,0 +1,81 @@ +{ + "patch": { + "operationId": "setDomainRedirects", + "tags": [ + "Domains" + ], + "summary": "Sets domain \"not found\" redirects", + "description": "Sets the URLs that you want a visitor to get redirected to for \not found\" URLs for a specific domain", + "security": [ + { + "ApiKey": [] + } + ], + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], + "requestBody": { + "description": "Request body.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "allOf": [ + { + "required": ["domain"], + "properties": { + "domain": { + "description": "The domain's authority for which you want to set redirects", + "type": "string" + } + } + }, + { + "$ref": "../definitions/NotFoundRedirects.json" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "The domain's redirects after the update, when existing redirects have been merged with provided ones.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "required": ["baseUrlRedirect", "regular404Redirect", "invalidShortUrlRedirect"] + }, + { + "$ref": "../definitions/NotFoundRedirects.json" + } + ] + } + } + }, + "examples": { + "application/json": { + "baseUrlRedirect": "https://example.com/my-landing-page", + "regular404Redirect": null, + "invalidShortUrlRedirect": "https://example.com/invalid-url" + } + } + }, + "500": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 21547f90..705069cc 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.0", + "openapi": "3.0.3", "info": { "title": "Shlink", "description": "Shlink, the self-hosted URL shortener", @@ -102,6 +102,9 @@ "/rest/v{version}/domains": { "$ref": "paths/v2_domains.json" }, + "/rest/v{version}/domains/redirects": { + "$ref": "paths/v2_domains_redirects.json" + }, "/rest/v{version}/mercure-info": { "$ref": "paths/v2_mercure-info.json" From 4ef5ab7a90eb1462b2d9059c21471777707b6653 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 27 Jul 2021 20:03:39 +0200 Subject: [PATCH 50/69] Fixed wrong domains getting resolved for an API key roles --- .../Domain/Repository/DomainRepository.php | 31 ++++++++++++++----- module/Core/src/Domain/Spec/IsDomain.php | 22 +++++++++++++ .../Core/src/Domain/Spec/IsNotAuthority.php | 22 +++++++++++++ .../src/ShortUrl/Spec/BelongsToApiKey.php | 6 ++-- .../ShortUrl/Spec/BelongsToDomainInlined.php | 2 +- .../Repository/DomainRepositoryTest.php | 28 ++++++++--------- module/Rest/src/Entity/ApiKey.php | 5 +++ 7 files changed, 91 insertions(+), 25 deletions(-) create mode 100644 module/Core/src/Domain/Spec/IsDomain.php create mode 100644 module/Core/src/Domain/Spec/IsNotAuthority.php diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index e0862558..6d246924 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -6,8 +6,13 @@ namespace Shlinkio\Shlink\Core\Domain\Repository; use Doctrine\ORM\Query\Expr\Join; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Spec; +use Shlinkio\Shlink\Core\Domain\Spec\IsDomain; +use Shlinkio\Shlink\Core\Domain\Spec\IsNotAuthority; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey; +use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface @@ -26,15 +31,27 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe ->orHaving($qb->expr()->isNotNull('d.regular404Redirect')) ->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect')); - if ($excludedAuthority !== null) { - $qb->where($qb->expr()->neq('d.authority', ':excludedAuthority')) - ->setParameter('excludedAuthority', $excludedAuthority); - } - - if ($apiKey !== null) { - $this->applySpecification($qb, $apiKey->spec(), 's'); + $specs = $this->determineExtraSpecs($excludedAuthority, $apiKey); + foreach ($specs as [$alias, $spec]) { + $this->applySpecification($qb, $spec, $alias); } return $qb->getQuery()->getResult(); } + + private function determineExtraSpecs(?string $excludedAuthority, ?ApiKey $apiKey): iterable + { + if ($excludedAuthority !== null) { + yield ['d', new IsNotAuthority($excludedAuthority)]; + } + + // FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the + // ShortUrl is the root entity. Here, the Domain is the root entity. + // Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible. + yield from $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) { + Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))], + Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)], + default => [null, Spec::andX()], + }) ?? []; + } } diff --git a/module/Core/src/Domain/Spec/IsDomain.php b/module/Core/src/Domain/Spec/IsDomain.php new file mode 100644 index 00000000..cf7463cc --- /dev/null +++ b/module/Core/src/Domain/Spec/IsDomain.php @@ -0,0 +1,22 @@ +domainId); + } +} diff --git a/module/Core/src/Domain/Spec/IsNotAuthority.php b/module/Core/src/Domain/Spec/IsNotAuthority.php new file mode 100644 index 00000000..0f0f0653 --- /dev/null +++ b/module/Core/src/Domain/Spec/IsNotAuthority.php @@ -0,0 +1,22 @@ +authority)); + } +} diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php index 84852275..3c95593c 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -11,13 +11,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class BelongsToApiKey extends BaseSpecification { - public function __construct(private ApiKey $apiKey, private ?string $dqlAlias = null) + public function __construct(private ApiKey $apiKey, ?string $context = null) { - parent::__construct(); + parent::__construct($context); } protected function getSpec(): Filter { - return Spec::eq('authorApiKey', $this->apiKey, $this->dqlAlias); + return Spec::eq('authorApiKey', $this->apiKey); } } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php index 414b3f74..4ce130b7 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php @@ -13,7 +13,7 @@ class BelongsToDomainInlined implements Filter { } - public function getFilter(QueryBuilder $qb, string $dqlAlias): string + public function getFilter(QueryBuilder $qb, string $context): string { // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\''); diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 9b0270a6..9e3fe9be 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -92,12 +92,12 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($bazDomain); $this->getEntityManager()->persist($this->createShortUrl($bazDomain, $authorApiKey)); -// $detachedDomain = Domain::withAuthority('detached.com'); -// $this->getEntityManager()->persist($detachedDomain); -// -// $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); -// $detachedWithRedirects->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com')); -// $this->getEntityManager()->persist($detachedWithRedirects); + $detachedDomain = Domain::withAuthority('detached.com'); + $this->getEntityManager()->persist($detachedDomain); + + $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); + $detachedWithRedirects->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com')); + $this->getEntityManager()->persist($detachedWithRedirects); $this->getEntityManager()->flush(); @@ -109,19 +109,19 @@ class DomainRepositoryTest extends DatabaseTestCase $barDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($barDomain))); $this->getEntityManager()->persist($barDomainApiKey); -// $detachedWithRedirectsApiKey = ApiKey::fromMeta( -// ApiKeyMeta::withRoles(RoleDefinition::forDomain($detachedWithRedirects)), -// ); -// $this->getEntityManager()->persist($detachedWithRedirectsApiKey); + $detachedWithRedirectsApiKey = ApiKey::fromMeta( + ApiKeyMeta::withRoles(RoleDefinition::forDomain($detachedWithRedirects)), + ); + $this->getEntityManager()->persist($detachedWithRedirectsApiKey); $this->getEntityManager()->flush(); self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey)); self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey)); -// self::assertEquals( -// [$detachedWithRedirects], -// $this->repo->findDomainsWithout(null, $detachedWithRedirectsApiKey), -// ); + self::assertEquals( + [$detachedWithRedirects], + $this->repo->findDomainsWithout(null, $detachedWithRedirectsApiKey), + ); self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey)); self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey)); } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 6c63c67b..121bea18 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -119,6 +119,11 @@ class ApiKey extends AbstractEntity return $role?->meta() ?? []; } + /** + * @template T + * @param callable(string $roleName, array $meta): T $fun + * @return T[] + */ public function mapRoles(callable $fun): array { return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->name(), $role->meta()))->getValues(); From 2ac7be4363f7e86ad2061da2dca557afa3c51bc5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Jul 2021 18:23:41 +0200 Subject: [PATCH 51/69] Extended DomainNotFoundException to allow creating from an authority --- .../src/Exception/DomainNotFoundException.php | 27 +++++++++++++------ .../Exception/DomainNotFoundExceptionTest.php | 17 +++++++++++- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/module/Core/src/Exception/DomainNotFoundException.php b/module/Core/src/Exception/DomainNotFoundException.php index b1b97c91..cb19608a 100644 --- a/module/Core/src/Exception/DomainNotFoundException.php +++ b/module/Core/src/Exception/DomainNotFoundException.php @@ -17,16 +17,27 @@ class DomainNotFoundException extends DomainException implements ProblemDetailsE private const TITLE = 'Domain not found'; private const TYPE = 'DOMAIN_NOT_FOUND'; + private function __construct(string $message, array $additional) + { + parent::__construct($message); + + $this->detail = $message; + $this->title = self::TITLE; + $this->type = self::TYPE; + $this->status = StatusCodeInterface::STATUS_NOT_FOUND; + $this->additional = $additional; + } + public static function fromId(string $id): self { - $e = new self(sprintf('Domain with id "%s" could not be found', $id)); + return new self(sprintf('Domain with id "%s" could not be found', $id), ['id' => $id]); + } - $e->detail = $e->getMessage(); - $e->title = self::TITLE; - $e->type = self::TYPE; - $e->status = StatusCodeInterface::STATUS_NOT_FOUND; - $e->additional = ['id' => $id]; - - return $e; + public static function fromAuthority(string $authority): self + { + return new self( + sprintf('Domain with authority "%s" could not be found', $authority), + ['authority' => $authority], + ); } } diff --git a/module/Core/test/Exception/DomainNotFoundExceptionTest.php b/module/Core/test/Exception/DomainNotFoundExceptionTest.php index 6ac26efd..5f2b9889 100644 --- a/module/Core/test/Exception/DomainNotFoundExceptionTest.php +++ b/module/Core/test/Exception/DomainNotFoundExceptionTest.php @@ -12,7 +12,7 @@ use function sprintf; class DomainNotFoundExceptionTest extends TestCase { /** @test */ - public function properlyCreatesExceptionFromNotFoundTag(): void + public function properlyCreatesExceptionFromId(): void { $id = '123'; $expectedMessage = sprintf('Domain with id "%s" could not be found', $id); @@ -25,4 +25,19 @@ class DomainNotFoundExceptionTest extends TestCase self::assertEquals(['id' => $id], $e->getAdditionalData()); self::assertEquals(404, $e->getStatus()); } + + /** @test */ + public function properlyCreatesExceptionFromAuthority(): void + { + $authority = 'example.com'; + $expectedMessage = sprintf('Domain with authority "%s" could not be found', $authority); + $e = DomainNotFoundException::fromAuthority($authority); + + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Domain not found', $e->getTitle()); + self::assertEquals('DOMAIN_NOT_FOUND', $e->getType()); + self::assertEquals(['authority' => $authority], $e->getAdditionalData()); + self::assertEquals(404, $e->getStatus()); + } } From 5a1a4f5594c6135351cf410719524c43d6bff403 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Jul 2021 19:08:29 +0200 Subject: [PATCH 52/69] Added support to configure domain redirects but taking into consideration the permissions on an API key --- module/Core/src/Domain/DomainService.php | 23 ++++++---- .../src/Domain/DomainServiceInterface.php | 16 +++++-- .../Domain/Repository/DomainRepository.php | 39 +++++++++++++---- .../Repository/DomainRepositoryInterface.php | 2 + .../Repository/DomainRepositoryTest.php | 18 +++++++- module/Core/test/Domain/DomainServiceTest.php | 42 +++++++++++++++---- 6 files changed, 112 insertions(+), 28 deletions(-) diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 99bade7c..708984b5 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -59,15 +59,21 @@ class DomainService implements DomainServiceInterface return $domain; } - public function findByAuthority(string $authority): ?Domain + public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain { $repo = $this->em->getRepository(Domain::class); - return $repo->findOneBy(['authority' => $authority]); + return $repo->findOneByAuthority($authority, $apiKey); } - public function getOrCreate(string $authority): Domain + public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain { - $domain = $this->findByAuthority($authority) ?? Domain::withAuthority($authority); + $domain = $this->findByAuthority($authority, $apiKey); + if ($domain === null && $apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { + // This API key is restricted to one domain and a different one was tried to be fetched + throw DomainNotFoundException::fromAuthority($authority); + } + + $domain = $domain ?? Domain::withAuthority($authority); $this->em->persist($domain); $this->em->flush(); @@ -75,9 +81,12 @@ class DomainService implements DomainServiceInterface return $domain; } - public function configureNotFoundRedirects(string $authority, NotFoundRedirects $notFoundRedirects): Domain - { - $domain = $this->getOrCreate($authority); + public function configureNotFoundRedirects( + string $authority, + NotFoundRedirects $notFoundRedirects, + ?ApiKey $apiKey = null + ): Domain { + $domain = $this->getOrCreate($authority, $apiKey); $domain->configureNotFoundRedirects($notFoundRedirects); $this->em->flush(); diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 802ecc7a..9ac48e69 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -22,9 +22,19 @@ interface DomainServiceInterface */ public function getDomain(string $domainId): Domain; - public function getOrCreate(string $authority): Domain; + /** + * @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided + */ + public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain; - public function findByAuthority(string $authority): ?Domain; + public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; - public function configureNotFoundRedirects(string $authority, NotFoundRedirects $notFoundRedirects): Domain; + /** + * @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided + */ + public function configureNotFoundRedirects( + string $authority, + NotFoundRedirects $notFoundRedirects, + ?ApiKey $apiKey = null, + ): Domain; } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 6d246924..50033604 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Repository; use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Domain\Spec\IsDomain; @@ -22,14 +23,8 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe */ public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array { - $qb = $this->createQueryBuilder('d'); - $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') - ->orderBy('d.authority', 'ASC') - ->groupBy('d') - ->having($qb->expr()->gt('COUNT(s.id)', '0')) - ->orHaving($qb->expr()->isNotNull('d.baseUrlRedirect')) - ->orHaving($qb->expr()->isNotNull('d.regular404Redirect')) - ->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect')); + $qb = $this->createPublicDomainsQueryBuilder(); + $qb->orderBy('d.authority', 'ASC'); $specs = $this->determineExtraSpecs($excludedAuthority, $apiKey); foreach ($specs as [$alias, $spec]) { @@ -39,6 +34,34 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb->getQuery()->getResult(); } + public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain + { + $qb = $this->createPublicDomainsQueryBuilder(); + $qb->where($qb->expr()->eq('d.authority', ':authority')) + ->setParameter('authority', $authority) + ->setMaxResults(1); + + $specs = $this->determineExtraSpecs(null, $apiKey); + foreach ($specs as [$alias, $spec]) { + $this->applySpecification($qb, $spec, $alias); + } + + return $qb->getQuery()->getOneOrNullResult(); + } + + private function createPublicDomainsQueryBuilder(): QueryBuilder + { + $qb = $this->createQueryBuilder('d'); + $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') + ->groupBy('d') + ->having($qb->expr()->gt('COUNT(s.id)', '0')) + ->orHaving($qb->expr()->isNotNull('d.baseUrlRedirect')) + ->orHaving($qb->expr()->isNotNull('d.regular404Redirect')) + ->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect')); + + return $qb; + } + private function determineExtraSpecs(?string $excludedAuthority, ?ApiKey $apiKey): iterable { if ($excludedAuthority !== null) { diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index 1d201520..123e349d 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -15,4 +15,6 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio * @return Domain[] */ public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array; + + public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; } diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 9e3fe9be..f52ba54a 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -27,7 +27,7 @@ class DomainRepositoryTest extends DatabaseTestCase } /** @test */ - public function findDomainsReturnsExpectedResult(): void + public function expectedDomainsAreFoundWhenNoApiKeyIsInvolved(): void { $fooDomain = Domain::withAuthority('foo.com'); $this->getEntityManager()->persist($fooDomain); @@ -70,10 +70,15 @@ class DomainRepositoryTest extends DatabaseTestCase [$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout('detached-with-redirects.com'), ); + + self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com')); + self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com')); + self::assertNull($this->repo->findOneByAuthority('does-not-exist.com')); + self::assertNull($this->repo->findOneByAuthority('detached.com')); } /** @test */ - public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void + public function expectedDomainsAreFoundWhenApiKeyIsProvided(): void { $authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($authorApiKey); @@ -124,6 +129,15 @@ class DomainRepositoryTest extends DatabaseTestCase ); self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey)); self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey)); + + self::assertEquals($fooDomain, $this->repo->findOneByAuthority('foo.com', $authorApiKey)); + self::assertNull($this->repo->findOneByAuthority('bar.com', $authorApiKey)); + self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com', $barDomainApiKey)); + self::assertEquals( + $detachedWithRedirects, + $this->repo->findOneByAuthority('detached-with-redirects.com', $detachedWithRedirectsApiKey), + ); + self::assertNull($this->repo->findOneByAuthority('foo.com', $detachedWithRedirectsApiKey)); } private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index b53b4a26..6e91b425 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -133,16 +133,16 @@ class DomainServiceTest extends TestCase * @test * @dataProvider provideFoundDomains */ - public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain): void + public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; $repo = $this->prophesize(DomainRepositoryInterface::class); - $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); + $repo->findOneByAuthority($authority, $apiKey)->willReturn($foundDomain); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); $flush = $this->em->flush(); - $result = $this->domainService->getOrCreate($authority); + $result = $this->domainService->getOrCreate($authority, $apiKey); if ($foundDomain !== null) { self::assertSame($result, $foundDomain); @@ -152,15 +152,33 @@ class DomainServiceTest extends TestCase $flush->shouldHaveBeenCalledOnce(); } + /** @test */ + public function getOrCreateThrowsExceptionForApiKeysWithDomainRole(): void + { + $authority = 'example.com'; + $domain = Domain::withAuthority($authority)->setId('1'); + $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); + $repo = $this->prophesize(DomainRepositoryInterface::class); + $repo->findOneByAuthority($authority, $apiKey)->willReturn(null); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $this->expectException(DomainNotFoundException::class); + $getRepo->shouldBeCalledOnce(); + $this->em->persist(Argument::cetera())->shouldNotBeCalled(); + $this->em->flush()->shouldNotBeCalled(); + + $this->domainService->getOrCreate($authority, $apiKey); + } + /** * @test * @dataProvider provideFoundDomains */ - public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain): void + public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; $repo = $this->prophesize(DomainRepositoryInterface::class); - $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); + $repo->findOneByAuthority($authority, $apiKey)->willReturn($foundDomain); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); $flush = $this->em->flush(); @@ -169,7 +187,7 @@ class DomainServiceTest extends TestCase 'foo.com', 'bar.com', 'baz.com', - )); + ), $apiKey); if ($foundDomain !== null) { self::assertSame($result, $foundDomain); @@ -184,7 +202,15 @@ class DomainServiceTest extends TestCase public function provideFoundDomains(): iterable { - yield 'domain not found' => [null]; - yield 'domain found' => [Domain::withAuthority('')]; + $domain = Domain::withAuthority(''); + $adminApiKey = ApiKey::create(); + $authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); + + yield 'domain not found and no API key' => [null, null]; + yield 'domain found and no API key' => [$domain, null]; + yield 'domain not found and admin API key' => [null, $adminApiKey]; + yield 'domain found and admin API key' => [$domain, $adminApiKey]; + yield 'domain not found and author API key' => [null, $authorApiKey]; + yield 'domain found and author API key' => [$domain, $authorApiKey]; } } From 6a40bbdcb54cf603eb96a6a5cd923a84aaada269 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 30 Jul 2021 18:53:57 +0200 Subject: [PATCH 53/69] Created new action to set redirects for a domain --- docs/swagger/paths/v2_domains_redirects.json | 33 ++++++++++ module/Core/src/Config/NotFoundRedirects.php | 13 +++- module/Rest/config/dependencies.config.php | 2 + module/Rest/config/routes.config.php | 1 + .../Action/Domain/DomainRedirectsAction.php | 41 +++++++++++++ .../src/Action/Domain/ListDomainsAction.php | 2 + .../Domain/Request/DomainRedirectsRequest.php | 60 +++++++++++++++++++ 7 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 module/Rest/src/Action/Domain/DomainRedirectsAction.php create mode 100644 module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php diff --git a/docs/swagger/paths/v2_domains_redirects.json b/docs/swagger/paths/v2_domains_redirects.json index 0868504e..bba6fbb7 100644 --- a/docs/swagger/paths/v2_domains_redirects.json +++ b/docs/swagger/paths/v2_domains_redirects.json @@ -66,6 +66,39 @@ } } }, + "400": { + "description": "Provided data is invalid.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["invalidElements"], + "properties": { + "invalidElements": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "domain", + "baseUrlRedirect", + "regular404Redirect", + "invalidShortUrlRedirect" + ] + } + } + } + } + ] + } + } + } + }, "500": { "description": "Unexpected error.", "content": { diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index 2a1e68d4..bb8c578c 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; -final class NotFoundRedirects +use JsonSerializable; + +final class NotFoundRedirects implements JsonSerializable { public function __construct( private ?string $baseUrlRedirect = null, @@ -27,4 +29,13 @@ final class NotFoundRedirects { return $this->invalidShortUrlRedirect; } + + public function jsonSerialize(): array + { + return [ + 'baseUrlRedirect' => $this->baseUrlRedirect, + 'regular404Redirect' => $this->regular404Redirect, + 'invalidShortUrlRedirect' => $this->invalidShortUrlRedirect, + ]; + } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index e1a869df..5e0267d6 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -40,6 +40,7 @@ return [ Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, + Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -81,6 +82,7 @@ return [ Action\Tag\CreateTagsAction::class => [TagService::class], Action\Tag\UpdateTagAction::class => [TagService::class], Action\Domain\ListDomainsAction::class => [DomainService::class], + Action\Domain\DomainRedirectsAction::class => [DomainService::class], Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 9b09a266..991f4bb3 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -44,6 +44,7 @@ return [ // Domains Action\Domain\ListDomainsAction::getRouteDef(), + Action\Domain\DomainRedirectsAction::getRouteDef(), Action\MercureInfoAction::getRouteDef(), ], diff --git a/module/Rest/src/Action/Domain/DomainRedirectsAction.php b/module/Rest/src/Action/Domain/DomainRedirectsAction.php new file mode 100644 index 00000000..d9259582 --- /dev/null +++ b/module/Rest/src/Action/Domain/DomainRedirectsAction.php @@ -0,0 +1,41 @@ +getParsedBody(); + $requestData = DomainRedirectsRequest::fromRawData($body); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + + $authority = $requestData->authority(); + $domain = $this->domainService->getOrCreate($authority); + $notFoundRedirects = $requestData->toNotFoundRedirects($domain); + + $this->domainService->configureNotFoundRedirects($authority, $notFoundRedirects, $apiKey); + + return new JsonResponse($notFoundRedirects); + } +} diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index c8f9a475..3d9205f8 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -25,6 +25,8 @@ class ListDomainsAction extends AbstractRestAction $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $domainItems = $this->domainService->listDomains($apiKey); + // TODO Support including not found redirects if requested via query param + return new JsonResponse([ 'domains' => [ 'data' => $domainItems, diff --git a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php new file mode 100644 index 00000000..d43ca223 --- /dev/null +++ b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php @@ -0,0 +1,60 @@ +validateAndInit($payload); + return $instance; + } + + private function validateAndInit(array $payload): void + { + // TODO Validate data + $this->baseUrlRedirectWasProvided = array_key_exists('baseUrlRedirect', $payload); + $this->regular404RedirectWasProvided = array_key_exists('regular404Redirect', $payload); + $this->invalidShortUrlRedirectWasProvided = array_key_exists('invalidShortUrlRedirect', $payload); + + $this->authority = $payload['domain']; + $this->baseUrlRedirect = $payload['baseUrlRedirect'] ?? null; + $this->regular404Redirect = $payload['regular404Redirect'] ?? null; + $this->invalidShortUrlRedirect = $payload['invalidShortUrlRedirect'] ?? null; + } + + public function authority(): string + { + return $this->authority; + } + + public function toNotFoundRedirects(?NotFoundRedirectConfigInterface $defaults = null): NotFoundRedirects + { + return new NotFoundRedirects( + $this->baseUrlRedirectWasProvided ? $this->baseUrlRedirect : $defaults?->baseUrlRedirect(), + $this->regular404RedirectWasProvided ? $this->regular404Redirect : $defaults?->regular404Redirect(), + $this->invalidShortUrlRedirectWasProvided + ? $this->invalidShortUrlRedirect + : $defaults?->invalidShortUrlRedirect(), + ); + } +} From b78660c685a94db536d5b7c913fb088e5ad23447 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 2 Aug 2021 18:16:13 +0200 Subject: [PATCH 54/69] Updated installer --- composer.json | 2 +- .../Rest/src/Action/Domain/Request/DomainRedirectsRequest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e7338665..0b0e1ac4 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-importer": "^2.3.1", - "shlinkio/shlink-installer": "dev-develop#fa6a4ca as 6.1", + "shlinkio/shlink-installer": "dev-develop#0ddcc3d as 6.1", "shlinkio/shlink-ip-geolocation": "^2.0", "symfony/console": "^5.1", "symfony/filesystem": "^5.1", diff --git a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php index d43ca223..8b9f69e5 100644 --- a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php +++ b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Rest\Action\Domain\Request; use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; + use function array_key_exists; class DomainRedirectsRequest From 6860855c717474e3522df634428c43f3a8bccc5c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 09:55:21 +0200 Subject: [PATCH 55/69] Prevent double flush when editing domain redirects --- module/Core/src/Domain/DomainService.php | 45 ++++++++++++------- module/Core/test/Domain/DomainServiceTest.php | 2 +- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 708984b5..807c6dce 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -65,7 +65,37 @@ class DomainService implements DomainServiceInterface return $repo->findOneByAuthority($authority, $apiKey); } + /** + * @throws DomainNotFoundException + */ public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain + { + $domain = $this->getPersistedDomain($authority, $apiKey); + $this->em->flush(); + + return $domain; + } + + /** + * @throws DomainNotFoundException + */ + public function configureNotFoundRedirects( + string $authority, + NotFoundRedirects $notFoundRedirects, + ?ApiKey $apiKey = null + ): Domain { + $domain = $this->getPersistedDomain($authority, $apiKey); + $domain->configureNotFoundRedirects($notFoundRedirects); + + $this->em->flush(); + + return $domain; + } + + /** + * @throws DomainNotFoundException + */ + private function getPersistedDomain(string $authority, ?ApiKey $apiKey): Domain { $domain = $this->findByAuthority($authority, $apiKey); if ($domain === null && $apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { @@ -74,22 +104,7 @@ class DomainService implements DomainServiceInterface } $domain = $domain ?? Domain::withAuthority($authority); - $this->em->persist($domain); - $this->em->flush(); - - return $domain; - } - - public function configureNotFoundRedirects( - string $authority, - NotFoundRedirects $notFoundRedirects, - ?ApiKey $apiKey = null - ): Domain { - $domain = $this->getOrCreate($authority, $apiKey); - $domain->configureNotFoundRedirects($notFoundRedirects); - - $this->em->flush(); return $domain; } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 6e91b425..060b24ab 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -197,7 +197,7 @@ class DomainServiceTest extends TestCase self::assertEquals('baz.com', $result->invalidShortUrlRedirect()); $getRepo->shouldHaveBeenCalledOnce(); $persist->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledTimes(2); + $flush->shouldHaveBeenCalledOnce(); } public function provideFoundDomains(): iterable From 8fbf05acd4c6eeb03fc584924271c68ec2308753 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 10:02:44 +0200 Subject: [PATCH 56/69] Added deprecated keyword to ensure something is changed for v3.0.0 --- module/Core/src/Exception/DeleteShortUrlException.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index f98b7e14..600fca57 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -15,7 +15,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE use CommonProblemDetailsExceptionTrait; private const TITLE = 'Cannot delete short URL'; - private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION + private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Deprecated: Should be INVALID_SHORT_URL_DELETION public static function fromVisitsThreshold(int $threshold, string $shortCode): self { From 20f70b8b07066cc92f6d065401122211663a2873 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 10:21:42 +0200 Subject: [PATCH 57/69] Created new table with row separators for CLI, to use with multi-line rows --- .../src/Command/Api/GenerateKeyCommand.php | 2 +- .../CLI/src/Command/Api/ListKeysCommand.php | 2 +- .../src/Command/Domain/ListDomainsCommand.php | 3 ++- .../src/Command/ShortUrl/GetVisitsCommand.php | 2 +- .../Command/ShortUrl/ListShortUrlsCommand.php | 2 +- .../CLI/src/Command/Tag/ListTagsCommand.php | 2 +- module/CLI/src/Util/ShlinkTable.php | 22 +++++++++++++++---- .../test/Command/Api/ListKeysCommandTest.php | 11 ++++++++++ .../Command/Domain/ListDomainsCommandTest.php | 2 ++ module/CLI/test/Util/ShlinkTableTest.php | 4 ++-- 10 files changed, 40 insertions(+), 12 deletions(-) diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index c8f607a4..d39c05fa 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -97,7 +97,7 @@ class GenerateKeyCommand extends BaseCommand $io->success(sprintf('Generated API key: "%s"', $apiKey->toString())); if (! $apiKey->isAdmin()) { - ShlinkTable::fromOutput($io)->render( + ShlinkTable::default($io)->render( ['Role name', 'Role metadata'], $apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]), null, diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index f435f1ea..23258993 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -69,7 +69,7 @@ class ListKeysCommand extends BaseCommand return $rowData; }); - ShlinkTable::fromOutput($output)->render(array_filter([ + ShlinkTable::withRowSeparators($output)->render(array_filter([ 'Key', 'Name', ! $enabledOnly ? 'Is enabled' : null, diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 5e368170..447bf92f 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -43,8 +43,9 @@ class ListDomainsCommand extends Command $domains = $this->domainService->listDomains(); $showRedirects = $input->getOption('show-redirects'); $commonFields = ['Domain', 'Is default']; + $table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output); - ShlinkTable::fromOutput($output)->render( + $table->render( $showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields, map($domains, function (DomainItem $domain) use ($showRedirects) { $commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']; diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php index aac45aff..5113debc 100644 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php @@ -81,7 +81,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand $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); + ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows); return ExitCodes::EXIT_SUCCESS; } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index b5d242dc..ff01030a 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -164,7 +164,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl)); }); - ShlinkTable::fromOutput($output)->render( + ShlinkTable::default($output)->render( array_keys($columnsMap), $rows, $all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'), diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 99889fa3..61d4e6e0 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -32,7 +32,7 @@ class ListTagsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { - ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows()); + ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows()); return ExitCodes::EXIT_SUCCESS; } diff --git a/module/CLI/src/Util/ShlinkTable.php b/module/CLI/src/Util/ShlinkTable.php index 5788ce12..1d4143c1 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -5,20 +5,33 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Util; use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Output\OutputInterface; +use function Functional\intersperse; + final class ShlinkTable { private const DEFAULT_STYLE_NAME = 'default'; private const TABLE_TITLE_STYLE = ' %s '; - public function __construct(private Table $baseTable) + private function __construct(private Table $baseTable, private bool $withRowSeparators) { } - public static function fromOutput(OutputInterface $output): self + public static function default(OutputInterface $output): self { - return new self(new Table($output)); + return new self(new Table($output), false); + } + + public static function withRowSeparators(OutputInterface $output): self + { + return new self(new Table($output), true); + } + + public static function fromBaseTable(Table $baseTable): self + { + return new self($baseTable, false); } public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void @@ -26,11 +39,12 @@ final class ShlinkTable $style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME); $style->setFooterTitleFormat(self::TABLE_TITLE_STYLE) ->setHeaderTitleFormat(self::TABLE_TITLE_STYLE); + $tableRows = $this->withRowSeparators ? intersperse($rows, new TableSeparator()) : $rows; $table = clone $this->baseTable; $table->setStyle($style) ->setHeaders($headers) - ->setRows($rows) + ->setRows($tableRows) ->setFooterTitle($footerTitle) ->setHeaderTitle($headerTitle) ->render(); diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index 389c6bbd..a124993f 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -53,7 +53,9 @@ class ListKeysCommandTest extends TestCase | Key | Name | Is enabled | Expiration date | Roles | +--------------------------------------+------+------------+-----------------+-------+ | {$apiKey1} | - | +++ | - | Admin | + +--------------------------------------+------+------------+-----------------+-------+ | {$apiKey2} | - | +++ | - | Admin | + +--------------------------------------+------+------------+-----------------+-------+ | {$apiKey3} | - | +++ | - | Admin | +--------------------------------------+------+------------+-----------------+-------+ @@ -67,6 +69,7 @@ class ListKeysCommandTest extends TestCase | Key | Name | Expiration date | Roles | +--------------------------------------+------+-----------------+-------+ | {$apiKey1} | - | - | Admin | + +--------------------------------------+------+-----------------+-------+ | {$apiKey2} | - | - | Admin | +--------------------------------------+------+-----------------+-------+ @@ -92,11 +95,16 @@ class ListKeysCommandTest extends TestCase | Key | Name | Expiration date | Roles | +--------------------------------------+------+-----------------+--------------------------+ | {$apiKey1} | - | - | Admin | + +--------------------------------------+------+-----------------+--------------------------+ | {$apiKey2} | - | - | Author only | + +--------------------------------------+------+-----------------+--------------------------+ | {$apiKey3} | - | - | Domain only: example.com | + +--------------------------------------+------+-----------------+--------------------------+ | {$apiKey4} | - | - | Admin | + +--------------------------------------+------+-----------------+--------------------------+ | {$apiKey5} | - | - | Author only | | | | | Domain only: example.com | + +--------------------------------------+------+-----------------+--------------------------+ | {$apiKey6} | - | - | Admin | +--------------------------------------+------+-----------------+--------------------------+ @@ -115,8 +123,11 @@ class ListKeysCommandTest extends TestCase | Key | Name | Expiration date | Roles | +--------------------------------------+---------------+-----------------+-------+ | {$apiKey1} | Alice | - | Admin | + +--------------------------------------+---------------+-----------------+-------+ | {$apiKey2} | Alice and Bob | - | Admin | + +--------------------------------------+---------------+-----------------+-------+ | {$apiKey3} | | - | Admin | + +--------------------------------------+---------------+-----------------+-------+ | {$apiKey4} | - | - | Admin | +--------------------------------------+---------------+-----------------+-------+ diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 9f4be920..3f31f7e0 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -77,9 +77,11 @@ class ListDomainsCommandTest extends TestCase | foo.com | Yes | * Base URL: https://foo.com/default/base | | | | * Regular 404: N/A | | | | * Invalid short URL: https://foo.com/default/invalid | + +---------+------------+---------------------------------------------------------+ | bar.com | No | * Base URL: N/A | | | | * Regular 404: N/A | | | | * Invalid short URL: N/A | + +---------+------------+---------------------------------------------------------+ | baz.com | No | * Base URL: N/A | | | | * Regular 404: https://foo.com/baz-domain/regular | | | | * Invalid short URL: https://foo.com/baz-domain/invalid | diff --git a/module/CLI/test/Util/ShlinkTableTest.php b/module/CLI/test/Util/ShlinkTableTest.php index 71bff82b..1ca612d4 100644 --- a/module/CLI/test/Util/ShlinkTableTest.php +++ b/module/CLI/test/Util/ShlinkTableTest.php @@ -24,7 +24,7 @@ class ShlinkTableTest extends TestCase public function setUp(): void { $this->baseTable = $this->prophesize(Table::class); - $this->shlinkTable = new ShlinkTable($this->baseTable->reveal()); + $this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable->reveal()); } /** @test */ @@ -57,7 +57,7 @@ class ShlinkTableTest extends TestCase /** @test */ public function newTableIsCreatedForFactoryMethod(): void { - $instance = ShlinkTable::fromOutput($this->prophesize(OutputInterface::class)->reveal()); + $instance = ShlinkTable::default($this->prophesize(OutputInterface::class)->reveal()); $ref = new ReflectionObject($instance); $baseTable = $ref->getProperty('baseTable'); From 9f25979b4c67812a2be3f2ee44751c1d2c44c14b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 14:08:36 +0200 Subject: [PATCH 58/69] Added validation to not found redirects for domain --- .../Command/Domain/DomainRedirectsCommand.php | 2 +- .../Domain/DomainRedirectsCommandTest.php | 12 ++--- .../Command/Domain/ListDomainsCommandTest.php | 2 +- module/Core/src/Config/NotFoundRedirects.php | 21 ++++++-- .../Validation/DomainRedirectsInputFilter.php | 50 +++++++++++++++++++ .../Repository/DomainRepositoryTest.php | 4 +- module/Core/test/Domain/DomainServiceTest.php | 2 +- .../Domain/Request/DomainRedirectsRequest.php | 36 +++++++++---- .../Rest/test-api/Fixtures/DomainFixture.php | 2 +- 9 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index 9a97e5fd..90cfd1f7 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -92,7 +92,7 @@ class DomainRedirectsCommand extends Command }; }; - $this->domainService->configureNotFoundRedirects($domainAuthority, new NotFoundRedirects( + $this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects( $ask( 'URL to redirect to when a user hits this domain\'s base URL', $domain?->baseUrlRedirect(), diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php index 1f8b93ab..9801930e 100644 --- a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -40,7 +40,7 @@ class DomainRedirectsCommandTest extends TestCase $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); $configureRedirects = $this->domainService->configureNotFoundRedirects( $domainAuthority, - new NotFoundRedirects('foo.com', null, 'baz.com'), + NotFoundRedirects::withRedirects('foo.com', null, 'baz.com'), )->willReturn(Domain::withAuthority('')); $this->commandTester->setInputs(['foo.com', '', 'baz.com']); @@ -71,12 +71,12 @@ class DomainRedirectsCommandTest extends TestCase { $domainAuthority = 'example.com'; $domain = Domain::withAuthority($domainAuthority); - $domain->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com', 'baz.com')); + $domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com', 'baz.com')); $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); $configureRedirects = $this->domainService->configureNotFoundRedirects( $domainAuthority, - new NotFoundRedirects(null, 'edited.com', 'baz.com'), + NotFoundRedirects::withRedirects(null, 'edited.com', 'baz.com'), )->willReturn($domain); $this->commandTester->setInputs(['2', '1', 'edited.com', '0']); @@ -105,7 +105,7 @@ class DomainRedirectsCommandTest extends TestCase $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); $configureRedirects = $this->domainService->configureNotFoundRedirects( $domainAuthority, - new NotFoundRedirects(), + NotFoundRedirects::withoutRedirects(), )->willReturn($domain); $this->commandTester->setInputs([$domainAuthority, '', '', '']); @@ -132,7 +132,7 @@ class DomainRedirectsCommandTest extends TestCase $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); $configureRedirects = $this->domainService->configureNotFoundRedirects( $domainAuthority, - new NotFoundRedirects(), + NotFoundRedirects::withoutRedirects(), )->willReturn($domain); $this->commandTester->setInputs(['1', '', '', '']); @@ -162,7 +162,7 @@ class DomainRedirectsCommandTest extends TestCase $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); $configureRedirects = $this->domainService->configureNotFoundRedirects( $domainAuthority, - new NotFoundRedirects(), + NotFoundRedirects::withoutRedirects(), )->willReturn($domain); $this->commandTester->setInputs(['2', $domainAuthority, '', '', '']); diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 3f31f7e0..13e6d062 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -36,7 +36,7 @@ class ListDomainsCommandTest extends TestCase public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void { $bazDomain = Domain::withAuthority('baz.com'); - $bazDomain->configureNotFoundRedirects(new NotFoundRedirects( + $bazDomain->configureNotFoundRedirects(NotFoundRedirects::withRedirects( null, 'https://foo.com/baz-domain/regular', 'https://foo.com/baz-domain/invalid', diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index bb8c578c..c00d35d0 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -8,13 +8,26 @@ use JsonSerializable; final class NotFoundRedirects implements JsonSerializable { - public function __construct( - private ?string $baseUrlRedirect = null, - private ?string $regular404Redirect = null, - private ?string $invalidShortUrlRedirect = null, + private function __construct( + private ?string $baseUrlRedirect, + private ?string $regular404Redirect, + private ?string $invalidShortUrlRedirect, ) { } + public static function withRedirects( + ?string $baseUrlRedirect = null, + ?string $regular404Redirect = null, + ?string $invalidShortUrlRedirect = null, + ): self { + return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect); + } + + public static function withoutRedirects(): self + { + return new self(null, null, null); + } + public function baseUrlRedirect(): ?string { return $this->baseUrlRedirect; diff --git a/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php new file mode 100644 index 00000000..94c15217 --- /dev/null +++ b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php @@ -0,0 +1,50 @@ +initializeInputs(); + $instance->setData($data); + + return $instance; + } + + private function initializeInputs(): void + { + $domain = $this->createInput(self::DOMAIN); + $domain->getValidatorChain()->attach(new Validator\NotEmpty([ + Validator\NotEmpty::OBJECT, + Validator\NotEmpty::SPACE, + Validator\NotEmpty::NULL, + Validator\NotEmpty::EMPTY_ARRAY, + Validator\NotEmpty::BOOLEAN, + ])); + $this->add($domain); + + $this->add($this->createInput(self::BASE_URL_REDIRECT, false)); + $this->add($this->createInput(self::REGULAR_404_REDIRECT, false)); + $this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false)); + } +} diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index f52ba54a..c58edfe6 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -45,7 +45,7 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($detachedDomain); $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); - $detachedWithRedirects->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com')); + $detachedWithRedirects->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com')); $this->getEntityManager()->persist($detachedWithRedirects); $this->getEntityManager()->flush(); @@ -101,7 +101,7 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($detachedDomain); $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); - $detachedWithRedirects->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com')); + $detachedWithRedirects->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com')); $this->getEntityManager()->persist($detachedWithRedirects); $this->getEntityManager()->flush(); diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 060b24ab..812210dd 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -183,7 +183,7 @@ class DomainServiceTest extends TestCase $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); $flush = $this->em->flush(); - $result = $this->domainService->configureNotFoundRedirects($authority, new NotFoundRedirects( + $result = $this->domainService->configureNotFoundRedirects($authority, NotFoundRedirects::withRedirects( 'foo.com', 'bar.com', 'baz.com', diff --git a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php index 8b9f69e5..e2b27e23 100644 --- a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php +++ b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php @@ -6,6 +6,8 @@ namespace Shlinkio\Shlink\Rest\Action\Domain\Request; use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; +use Shlinkio\Shlink\Core\Domain\Validation\DomainRedirectsInputFilter; +use Shlinkio\Shlink\Core\Exception\ValidationException; use function array_key_exists; @@ -30,17 +32,33 @@ class DomainRedirectsRequest return $instance; } + /** + * @throws ValidationException + */ private function validateAndInit(array $payload): void { - // TODO Validate data - $this->baseUrlRedirectWasProvided = array_key_exists('baseUrlRedirect', $payload); - $this->regular404RedirectWasProvided = array_key_exists('regular404Redirect', $payload); - $this->invalidShortUrlRedirectWasProvided = array_key_exists('invalidShortUrlRedirect', $payload); + $inputFilter = DomainRedirectsInputFilter::withData($payload); + if (! $inputFilter->isValid()) { + throw ValidationException::fromInputFilter($inputFilter); + } - $this->authority = $payload['domain']; - $this->baseUrlRedirect = $payload['baseUrlRedirect'] ?? null; - $this->regular404Redirect = $payload['regular404Redirect'] ?? null; - $this->invalidShortUrlRedirect = $payload['invalidShortUrlRedirect'] ?? null; + $this->baseUrlRedirectWasProvided = array_key_exists( + DomainRedirectsInputFilter::BASE_URL_REDIRECT, + $payload, + ); + $this->regular404RedirectWasProvided = array_key_exists( + DomainRedirectsInputFilter::REGULAR_404_REDIRECT, + $payload, + ); + $this->invalidShortUrlRedirectWasProvided = array_key_exists( + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT, + $payload, + ); + + $this->authority = $inputFilter->getValue(DomainRedirectsInputFilter::DOMAIN); + $this->baseUrlRedirect = $inputFilter->getValue(DomainRedirectsInputFilter::BASE_URL_REDIRECT); + $this->regular404Redirect = $inputFilter->getValue(DomainRedirectsInputFilter::REGULAR_404_REDIRECT); + $this->invalidShortUrlRedirect = $inputFilter->getValue(DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT); } public function authority(): string @@ -50,7 +68,7 @@ class DomainRedirectsRequest public function toNotFoundRedirects(?NotFoundRedirectConfigInterface $defaults = null): NotFoundRedirects { - return new NotFoundRedirects( + return NotFoundRedirects::withRedirects( $this->baseUrlRedirectWasProvided ? $this->baseUrlRedirect : $defaults?->baseUrlRedirect(), $this->regular404RedirectWasProvided ? $this->regular404Redirect : $defaults?->regular404Redirect(), $this->invalidShortUrlRedirectWasProvided diff --git a/module/Rest/test-api/Fixtures/DomainFixture.php b/module/Rest/test-api/Fixtures/DomainFixture.php index 31e68f21..619dfdc4 100644 --- a/module/Rest/test-api/Fixtures/DomainFixture.php +++ b/module/Rest/test-api/Fixtures/DomainFixture.php @@ -20,7 +20,7 @@ class DomainFixture extends AbstractFixture $manager->persist(Domain::withAuthority('this_domain_is_detached.com')); $detachedWithRedirects = Domain::withAuthority('detached-with-redirects.com'); - $detachedWithRedirects->configureNotFoundRedirects(new NotFoundRedirects('foo.com', 'bar.com')); + $detachedWithRedirects->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com')); $manager->persist($detachedWithRedirects); $manager->flush(); From 7b43403b1cef4ac9845accc037413e80571eb1f9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 16:48:17 +0200 Subject: [PATCH 59/69] Fixed error when editing domain redirects for a new domain --- .../Domain/Repository/DomainRepository.php | 28 ++++++++----------- .../Repository/DomainRepositoryTest.php | 2 +- .../Action/Domain/DomainRedirectsAction.php | 2 +- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 50033604..33538011 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -23,8 +23,14 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe */ public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array { - $qb = $this->createPublicDomainsQueryBuilder(); - $qb->orderBy('d.authority', 'ASC'); + $qb = $this->createQueryBuilder('d'); + $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') + ->groupBy('d') + ->orderBy('d.authority', 'ASC') + ->having($qb->expr()->gt('COUNT(s.id)', '0')) + ->orHaving($qb->expr()->isNotNull('d.baseUrlRedirect')) + ->orHaving($qb->expr()->isNotNull('d.regular404Redirect')) + ->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect')); $specs = $this->determineExtraSpecs($excludedAuthority, $apiKey); foreach ($specs as [$alias, $spec]) { @@ -36,8 +42,9 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain { - $qb = $this->createPublicDomainsQueryBuilder(); - $qb->where($qb->expr()->eq('d.authority', ':authority')) + $qb = $this->createQueryBuilder('d'); + $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') + ->where($qb->expr()->eq('d.authority', ':authority')) ->setParameter('authority', $authority) ->setMaxResults(1); @@ -49,19 +56,6 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb->getQuery()->getOneOrNullResult(); } - private function createPublicDomainsQueryBuilder(): QueryBuilder - { - $qb = $this->createQueryBuilder('d'); - $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') - ->groupBy('d') - ->having($qb->expr()->gt('COUNT(s.id)', '0')) - ->orHaving($qb->expr()->isNotNull('d.baseUrlRedirect')) - ->orHaving($qb->expr()->isNotNull('d.regular404Redirect')) - ->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect')); - - return $qb; - } - private function determineExtraSpecs(?string $excludedAuthority, ?ApiKey $apiKey): iterable { if ($excludedAuthority !== null) { diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index c58edfe6..1eaf6ea9 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -74,7 +74,7 @@ class DomainRepositoryTest extends DatabaseTestCase self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com')); self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com')); self::assertNull($this->repo->findOneByAuthority('does-not-exist.com')); - self::assertNull($this->repo->findOneByAuthority('detached.com')); + self::assertEquals($detachedDomain, $this->repo->findOneByAuthority('detached.com')); } /** @test */ diff --git a/module/Rest/src/Action/Domain/DomainRedirectsAction.php b/module/Rest/src/Action/Domain/DomainRedirectsAction.php index d9259582..ca4346f8 100644 --- a/module/Rest/src/Action/Domain/DomainRedirectsAction.php +++ b/module/Rest/src/Action/Domain/DomainRedirectsAction.php @@ -23,7 +23,7 @@ class DomainRedirectsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - // TODO Do not allow to set redirects for default domain + // TODO Do not allow to set redirects for default domain. Or do allow. Check if there could be any issue /** @var array $body */ $body = $request->getParsedBody(); From 565fe4c348bb50cf89a253dd56fa5e95fefcbce0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 17:00:26 +0200 Subject: [PATCH 60/69] Added redirects to the list of domains --- docs/swagger/paths/v2_domains.json | 28 +++++++++++++---- module/Core/src/Config/NotFoundRedirects.php | 5 ++++ module/Core/src/Domain/Model/DomainItem.php | 2 ++ .../Domain/Repository/DomainRepository.php | 1 - .../src/Action/Domain/ListDomainsAction.php | 2 -- .../Rest/test-api/Action/ListDomainsTest.php | 30 +++++++++++++++++++ 6 files changed, 60 insertions(+), 8 deletions(-) diff --git a/docs/swagger/paths/v2_domains.json b/docs/swagger/paths/v2_domains.json index d92ae995..ef63ee4e 100644 --- a/docs/swagger/paths/v2_domains.json +++ b/docs/swagger/paths/v2_domains.json @@ -18,7 +18,7 @@ ], "responses": { "200": { - "description": "The list of tags", + "description": "The list of domains", "content": { "application/json": { "schema": { @@ -33,13 +33,16 @@ "type": "array", "items": { "type": "object", - "required": ["domain", "isDefault"], + "required": ["domain", "isDefault", "redirects"], "properties": { "domain": { "type": "string" }, "isDefault": { "type": "boolean" + }, + "redirects": { + "$ref": "../definitions/NotFoundRedirects.json" } } } @@ -56,15 +59,30 @@ "data": [ { "domain": "example.com", - "isDefault": true + "isDefault": true, + "redirects": { + "baseUrlRedirect": "https://example.com/my-landing-page", + "regular404Redirect": null, + "invalidShortUrlRedirect": "https://example.com/invalid-url" + } }, { "domain": "aaa.com", - "isDefault": false + "isDefault": false, + "redirects": { + "baseUrlRedirect": null, + "regular404Redirect": null, + "invalidShortUrlRedirect": null + } }, { "domain": "bbb.com", - "isDefault": false + "isDefault": false, + "redirects": { + "baseUrlRedirect": null, + "regular404Redirect": null, + "invalidShortUrlRedirect": "https://example.com/invalid-url" + } } ] } diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index c00d35d0..9f277be5 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -28,6 +28,11 @@ final class NotFoundRedirects implements JsonSerializable return new self(null, null, null); } + public static function fromConfig(NotFoundRedirectConfigInterface $config): self + { + return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect()); + } + public function baseUrlRedirect(): ?string { return $this->baseUrlRedirect; diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index bad7d5cf..cfd09d90 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Domain\Model; use JsonSerializable; use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Entity\Domain; final class DomainItem implements JsonSerializable @@ -32,6 +33,7 @@ final class DomainItem implements JsonSerializable return [ 'domain' => $this->authority, 'isDefault' => $this->isDefault, + 'redirects' => NotFoundRedirects::fromConfig($this->notFoundRedirectConfig), ]; } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 33538011..1741cea7 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Repository; use Doctrine\ORM\Query\Expr\Join; -use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Domain\Spec\IsDomain; diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index 3d9205f8..c8f9a475 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -25,8 +25,6 @@ class ListDomainsAction extends AbstractRestAction $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $domainItems = $this->domainService->listDomains($apiKey); - // TODO Support including not found redirects if requested via query param - return new JsonResponse([ 'domains' => [ 'data' => $domainItems, diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php index 075b6d09..5f33c20b 100644 --- a/module/Rest/test-api/Action/ListDomainsTest.php +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -31,30 +31,60 @@ class ListDomainsTest extends ApiTestCase [ 'domain' => 'doma.in', 'isDefault' => true, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], [ 'domain' => 'detached-with-redirects.com', 'isDefault' => false, + 'redirects' => [ + 'baseUrlRedirect' => 'foo.com', + 'regular404Redirect' => 'bar.com', + 'invalidShortUrlRedirect' => null, + ], ], [ 'domain' => 'example.com', 'isDefault' => false, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], [ 'domain' => 'some-domain.com', 'isDefault' => false, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], ]]; yield 'author API key' => ['author_api_key', [ [ 'domain' => 'doma.in', 'isDefault' => true, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], ]]; yield 'domain API key' => ['domain_api_key', [ [ 'domain' => 'example.com', 'isDefault' => false, + 'redirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], ]]; } From 9abf611d6331fc9958631a4c3e11c44849dd3594 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 18:09:39 +0200 Subject: [PATCH 61/69] Created DomainResirectsAction unit test --- module/Core/src/Config/NotFoundRedirects.php | 2 +- .../Validation/DomainRedirectsInputFilter.php | 9 +- .../src/Validation/ShortUrlInputFilter.php | 2 +- .../Domain/DomainRedirectsActionTest.php | 160 ++++++++++++++++++ 4 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 module/Rest/test/Action/Domain/DomainRedirectsActionTest.php diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index 9f277be5..492a00bc 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -16,7 +16,7 @@ final class NotFoundRedirects implements JsonSerializable } public static function withRedirects( - ?string $baseUrlRedirect = null, + ?string $baseUrlRedirect, ?string $regular404Redirect = null, ?string $invalidShortUrlRedirect = null, ): self { diff --git a/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php index 94c15217..de627c1c 100644 --- a/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php +++ b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Validation; use Laminas\InputFilter\InputFilter; -use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; class DomainRedirectsInputFilter extends InputFilter @@ -34,13 +33,7 @@ class DomainRedirectsInputFilter extends InputFilter private function initializeInputs(): void { $domain = $this->createInput(self::DOMAIN); - $domain->getValidatorChain()->attach(new Validator\NotEmpty([ - Validator\NotEmpty::OBJECT, - Validator\NotEmpty::SPACE, - Validator\NotEmpty::NULL, - Validator\NotEmpty::EMPTY_ARRAY, - Validator\NotEmpty::BOOLEAN, - ])); + $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $this->add($domain); $this->add($this->createInput(self::BASE_URL_REDIRECT, false)); diff --git a/module/Core/src/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php index c7cdaa43..b969d95e 100644 --- a/module/Core/src/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/Validation/ShortUrlInputFilter.php @@ -75,7 +75,7 @@ class ShortUrlInputFilter extends InputFilter $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([ 'regexp' => CUSTOM_SLUGS_REGEXP, - 'lowercase' => false, // We want to keep it case sensitive + 'lowercase' => false, // We want to keep it case-sensitive 'rulesets' => ['default'], ])))); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ diff --git a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php new file mode 100644 index 00000000..5d09f3f7 --- /dev/null +++ b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php @@ -0,0 +1,160 @@ +domainService = $this->prophesize(DomainServiceInterface::class); + $this->action = new DomainRedirectsAction($this->domainService->reveal()); + } + + /** + * @test + * @dataProvider provideInvalidBodies + */ + public function invalidDataThrowsException(array $body): void + { + $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); + + $this->expectException(ValidationException::class); + $this->domainService->getOrCreate(Argument::cetera())->shouldNotBeCalled(); + $this->domainService->configureNotFoundRedirects(Argument::cetera())->shouldNotBeCalled(); + + $this->action->handle($request); + } + + public function provideInvalidBodies(): iterable + { + yield 'no domain' => [[]]; + yield 'empty domain' => [['domain' => '']]; + yield 'invalid domain' => [['domain' => '192.168.1.20']]; + } + + /** + * @test + * @dataProvider provideDomainsAndRedirects + */ + public function domainIsFetchedAndUsedToGetItConfigured( + Domain $domain, + array $redirects, + array $expectedResult, + ): void { + $authority = 'doma.in'; + $redirects['domain'] = $authority; + $apiKey = ApiKey::create(); + $request = ServerRequestFactory::fromGlobals()->withParsedBody($redirects) + ->withAttribute(ApiKey::class, $apiKey); + + $getOrCreate = $this->domainService->getOrCreate($authority)->willReturn($domain); + $configureNotFoundRedirects = $this->domainService->configureNotFoundRedirects( + $authority, + NotFoundRedirects::withRedirects( + array_key_exists(DomainRedirectsInputFilter::BASE_URL_REDIRECT, $redirects) + ? $redirects[DomainRedirectsInputFilter::BASE_URL_REDIRECT] + : $domain?->baseUrlRedirect(), + array_key_exists(DomainRedirectsInputFilter::REGULAR_404_REDIRECT, $redirects) + ? $redirects[DomainRedirectsInputFilter::REGULAR_404_REDIRECT] + : $domain?->regular404Redirect(), + array_key_exists(DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT, $redirects) + ? $redirects[DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT] + : $domain?->invalidShortUrlRedirect(), + ), + $apiKey, + ); + + /** @var JsonResponse $response */ + $response = $this->action->handle($request); + /** @var NotFoundRedirects $payload */ + $payload = $response->getPayload(); + + self::assertEquals($expectedResult, $payload->jsonSerialize()); + $getOrCreate->shouldHaveBeenCalledOnce(); + $configureNotFoundRedirects->shouldHaveBeenCalledOnce(); + } + + public function provideDomainsAndRedirects(): iterable + { + yield 'full overwrite' => [Domain::withAuthority(''), [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'foo', + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => 'bar', + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'baz', + ], [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'foo', + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => 'bar', + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'baz', + ]]; + yield 'partial overwrite' => [Domain::withAuthority(''), [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'foo', + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'baz', + ], [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'foo', + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => null, + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'baz', + ]]; + yield 'no override' => [ + (static function (): Domain { + $domain = Domain::withAuthority(''); + $domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects( + 'baz', + 'bar', + 'foo', + )); + + return $domain; + })(), + [], + [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => 'baz', + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => 'bar', + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => 'foo', + ], + ]; + yield 'reset' => [ + (static function (): Domain { + $domain = Domain::withAuthority(''); + $domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects( + 'foo', + 'bar', + 'baz', + )); + + return $domain; + })(), + [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => null, + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => null, + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => null, + ], + [ + DomainRedirectsInputFilter::BASE_URL_REDIRECT => null, + DomainRedirectsInputFilter::REGULAR_404_REDIRECT => null, + DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT => null, + ], + ]; + } +} From 7c06633a678f76e3a2ec17aeb8865f8350a7b2f3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 18:28:04 +0200 Subject: [PATCH 62/69] Ensured default domain redirects cannot be edited through regular approach --- module/Core/src/Domain/DomainService.php | 6 ++++ .../src/Domain/DomainServiceInterface.php | 2 ++ .../src/Exception/InvalidDomainException.php | 33 +++++++++++++++++++ module/Core/test/Domain/DomainServiceTest.php | 12 +++++++ .../Exception/InvalidDomainExceptionTest.php | 24 ++++++++++++++ .../Action/Domain/DomainRedirectsAction.php | 2 -- 6 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 module/Core/src/Exception/InvalidDomainException.php create mode 100644 module/Core/test/Exception/InvalidDomainExceptionTest.php diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 807c6dce..6051c254 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Core\Exception\InvalidDomainException; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -78,12 +79,17 @@ class DomainService implements DomainServiceInterface /** * @throws DomainNotFoundException + * @throws InvalidDomainException */ public function configureNotFoundRedirects( string $authority, NotFoundRedirects $notFoundRedirects, ?ApiKey $apiKey = null ): Domain { + if ($authority === $this->defaultDomain) { + throw InvalidDomainException::forDefaultDomainRedirects(); + } + $domain = $this->getPersistedDomain($authority, $apiKey); $domain->configureNotFoundRedirects($notFoundRedirects); diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 9ac48e69..7748284d 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -8,6 +8,7 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Core\Exception\InvalidDomainException; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface DomainServiceInterface @@ -31,6 +32,7 @@ interface DomainServiceInterface /** * @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided + * @throws InvalidDomainException If default domain is provided */ public function configureNotFoundRedirects( string $authority, diff --git a/module/Core/src/Exception/InvalidDomainException.php b/module/Core/src/Exception/InvalidDomainException.php new file mode 100644 index 00000000..6e71c831 --- /dev/null +++ b/module/Core/src/Exception/InvalidDomainException.php @@ -0,0 +1,33 @@ +detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; + + return $e; + } +} diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 812210dd..159fb6ca 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Core\Exception\InvalidDomainException; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -213,4 +214,15 @@ class DomainServiceTest extends TestCase yield 'domain not found and author API key' => [null, $authorApiKey]; yield 'domain found and author API key' => [$domain, $authorApiKey]; } + + /** @test */ + public function anExceptionIsThrowsWhenTryingToEditRedirectsForDefaultDomain(): void + { + $this->expectException(InvalidDomainException::class); + $this->expectExceptionMessage( + 'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.', + ); + + $this->domainService->configureNotFoundRedirects('default.com', NotFoundRedirects::withoutRedirects()); + } } diff --git a/module/Core/test/Exception/InvalidDomainExceptionTest.php b/module/Core/test/Exception/InvalidDomainExceptionTest.php new file mode 100644 index 00000000..e4592cd8 --- /dev/null +++ b/module/Core/test/Exception/InvalidDomainExceptionTest.php @@ -0,0 +1,24 @@ +getMessage()); + self::assertEquals($expected, $e->getDetail()); + self::assertEquals('Invalid domain', $e->getTitle()); + self::assertEquals('INVALID_DOMAIN', $e->getType()); + self::assertEquals(400, $e->getStatus()); + } +} diff --git a/module/Rest/src/Action/Domain/DomainRedirectsAction.php b/module/Rest/src/Action/Domain/DomainRedirectsAction.php index ca4346f8..e98aa339 100644 --- a/module/Rest/src/Action/Domain/DomainRedirectsAction.php +++ b/module/Rest/src/Action/Domain/DomainRedirectsAction.php @@ -23,8 +23,6 @@ class DomainRedirectsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - // TODO Do not allow to set redirects for default domain. Or do allow. Check if there could be any issue - /** @var array $body */ $body = $request->getParsedBody(); $requestData = DomainRedirectsRequest::fromRawData($body); From 40a7d5a112143e7e305c092997d7eac0258b3933 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 18:33:50 +0200 Subject: [PATCH 63/69] Documented error when trying to edit default domain redirects through endpoint --- docs/swagger/paths/v2_domains_redirects.json | 10 ++++++++++ module/Core/src/Exception/InvalidDomainException.php | 2 +- .../Core/test/Exception/InvalidDomainExceptionTest.php | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/swagger/paths/v2_domains_redirects.json b/docs/swagger/paths/v2_domains_redirects.json index bba6fbb7..9bf16841 100644 --- a/docs/swagger/paths/v2_domains_redirects.json +++ b/docs/swagger/paths/v2_domains_redirects.json @@ -99,6 +99,16 @@ } } }, + "403": { + "description": "Default domain was provided, and it cannot be edited this way.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, "500": { "description": "Unexpected error.", "content": { diff --git a/module/Core/src/Exception/InvalidDomainException.php b/module/Core/src/Exception/InvalidDomainException.php index 6e71c831..d41e71ac 100644 --- a/module/Core/src/Exception/InvalidDomainException.php +++ b/module/Core/src/Exception/InvalidDomainException.php @@ -26,7 +26,7 @@ class InvalidDomainException extends DomainException implements ProblemDetailsEx $e->detail = $e->getMessage(); $e->title = self::TITLE; $e->type = self::TYPE; - $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; + $e->status = StatusCodeInterface::STATUS_FORBIDDEN; return $e; } diff --git a/module/Core/test/Exception/InvalidDomainExceptionTest.php b/module/Core/test/Exception/InvalidDomainExceptionTest.php index e4592cd8..06b78ff2 100644 --- a/module/Core/test/Exception/InvalidDomainExceptionTest.php +++ b/module/Core/test/Exception/InvalidDomainExceptionTest.php @@ -19,6 +19,6 @@ class InvalidDomainExceptionTest extends TestCase self::assertEquals($expected, $e->getDetail()); self::assertEquals('Invalid domain', $e->getTitle()); self::assertEquals('INVALID_DOMAIN', $e->getType()); - self::assertEquals(400, $e->getStatus()); + self::assertEquals(403, $e->getStatus()); } } From de81e81ecb7c99c87f01f21833271ab2fb889eef Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 19:43:30 +0200 Subject: [PATCH 64/69] Created API test for Domain redirects --- .../test-api/Action/DomainRedirectsTest.php | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 module/Rest/test-api/Action/DomainRedirectsTest.php diff --git a/module/Rest/test-api/Action/DomainRedirectsTest.php b/module/Rest/test-api/Action/DomainRedirectsTest.php new file mode 100644 index 00000000..987c09d6 --- /dev/null +++ b/module/Rest/test-api/Action/DomainRedirectsTest.php @@ -0,0 +1,100 @@ +callApiWithKey(self::METHOD_PATCH, '/domains/redirects', [ + RequestOptions::JSON => ['domain' => 'doma.in'], + ]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); + self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); + self::assertEquals('INVALID_DOMAIN', $payload['type']); + self::assertEquals( + 'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.', + $payload['detail'], + ); + self::assertEquals('Invalid domain', $payload['title']); + } + + /** + * @test + * @dataProvider provideInvalidDomains + */ + public function anErrorIsReturnedWhenTryingToEditAnInvalidDomain(array $request): void + { + $resp = $this->callApiWithKey(self::METHOD_PATCH, '/domains/redirects', [ + RequestOptions::JSON => $request, + ]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('Provided data is not valid', $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); + } + + public function provideInvalidDomains(): iterable + { + yield 'no domain' => [[]]; + yield 'empty domain' => [['domain' => '']]; + yield 'null domain' => [['domain' => null]]; + yield 'invalid domain' => [['domain' => '192.168.1.1']]; + } + + /** + * @test + * @dataProvider provideRequests + */ + public function allowsToEditDomainRedirects(array $request, array $expectedResponse): void + { + $resp = $this->callApiWithKey(self::METHOD_PATCH, '/domains/redirects', [ + RequestOptions::JSON => $request, + ]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); + self::assertEquals($expectedResponse, $payload); + } + + public function provideRequests(): iterable + { + yield 'new domain' => [[ + 'domain' => 'my-new-domain.com', + 'regular404Redirect' => 'foo.com', + ], [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => 'foo.com', + 'invalidShortUrlRedirect' => null, + ]]; + yield 'existing domain with redirects' => [[ + 'domain' => 'detached-with-redirects.com', + 'baseUrlRedirect' => null, + 'invalidShortUrlRedirect' => 'foo.com', + ], [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => 'bar.com', + 'invalidShortUrlRedirect' => 'foo.com', + ]]; + yield 'existing domain with no redirects' => [[ + 'domain' => 'example.com', + 'baseUrlRedirect' => null, + 'invalidShortUrlRedirect' => 'foo.com', + ], [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => 'foo.com', + ]]; + } +} From 0c97c8f04f3a624ecb22e0a452057cac66ecf529 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 3 Aug 2021 19:47:44 +0200 Subject: [PATCH 65/69] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d960a9..038c68ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#943](https://github.com/shlinkio/shlink/issues/943) Added support to define different "not-found" redirects for every domain handled by Shlink. - Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command to define specific values for every single domain. + Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command or the `PATCH /domains/redirects` REST endpoint to define specific values for every single domain. ### Changed * [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8. From 57bd16f4f56d862da0d8db728e48c396e9492e51 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 4 Aug 2021 11:11:00 +0200 Subject: [PATCH 66/69] Updated test utils lib to v2.2 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0b0e1ac4..f11db351 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.1.1", - "shlinkio/shlink-test-utils": "^2.1", + "shlinkio/shlink-test-utils": "^2.2", "symfony/var-dumper": "^5.2", "veewee/composer-run-parallel": "^1.0" }, From 916d75d161746ea0c5fb8a672a9770bca7761515 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 4 Aug 2021 13:22:16 +0200 Subject: [PATCH 67/69] Updated project dependencies --- composer.json | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/composer.json b/composer.json index f11db351..17a5e21f 100644 --- a/composer.json +++ b/composer.json @@ -16,24 +16,24 @@ "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.0", - "cakephp/chronos": "^2.0", + "cakephp/chronos": "^2.2", "cocur/slugify": "^4.0", - "doctrine/cache": "^1.9", - "doctrine/migrations": "^3.1.1", - "doctrine/orm": "^2.8.4", - "endroid/qr-code": "^4.0", - "geoip2/geoip2": "^2.9", + "doctrine/cache": "^1.12", + "doctrine/migrations": "^3.2", + "doctrine/orm": "^2.9", + "endroid/qr-code": "^4.2", + "geoip2/geoip2": "^2.11", "guzzlehttp/guzzle": "^7.3", "happyr/doctrine-specification": "^2.0", "jaybizzle/crawler-detect": "^1.2", "laminas/laminas-config": "^3.5", "laminas/laminas-config-aggregator": "^1.5", "laminas/laminas-diactoros": "^2.6", - "laminas/laminas-inputfilter": "^2.10", + "laminas/laminas-inputfilter": "^2.12", "laminas/laminas-servicemanager": "^3.7", - "laminas/laminas-stdlib": "^3.2", - "lcobucci/jwt": "^4.0", - "league/uri": "^6.2", + "laminas/laminas-stdlib": "^3.5", + "lcobucci/jwt": "^4.1", + "league/uri": "^6.4", "lstrojny/functional-php": "^1.17", "mezzio/mezzio": "^3.5", "mezzio/mezzio-fastroute": "^3.2", @@ -42,23 +42,23 @@ "monolog/monolog": "^2.3", "nikolaposa/monolog-factory": "^3.1", "ocramius/proxy-manager": "^2.11", - "pagerfanta/core": "^2.5", + "pagerfanta/core": "^2.7", "php-middleware/request-id": "^4.1", "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", "shlinkio/shlink-common": "^3.7", - "shlinkio/shlink-config": "^1.0", + "shlinkio/shlink-config": "^1.2", "shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-importer": "^2.3.1", - "shlinkio/shlink-installer": "dev-develop#0ddcc3d as 6.1", + "shlinkio/shlink-installer": "^6.1", "shlinkio/shlink-ip-geolocation": "^2.0", - "symfony/console": "^5.1", - "symfony/filesystem": "^5.1", - "symfony/lock": "^5.1", - "symfony/mercure": "^0.5.1", - "symfony/process": "^5.1", - "symfony/string": "^5.1" + "symfony/console": "^5.3", + "symfony/filesystem": "^5.3", + "symfony/lock": "^5.3", + "symfony/mercure": "^0.5.3", + "symfony/process": "^5.3", + "symfony/string": "^5.3" }, "require-dev": { "devster/ubench": "^2.1", @@ -66,7 +66,7 @@ "eaglewu/swoole-ide-helper": "dev-master", "infection/infection": "^0.24.0", "phpspec/prophecy-phpunit": "^2.0", - "phpstan/phpstan": "^0.12.93", + "phpstan/phpstan": "^0.12.94", "phpstan/phpstan-doctrine": "^0.12.42", "phpstan/phpstan-symfony": "^0.12.41", "phpunit/php-code-coverage": "^9.2", @@ -74,7 +74,7 @@ "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.1.1", "shlinkio/shlink-test-utils": "^2.2", - "symfony/var-dumper": "^5.2", + "symfony/var-dumper": "^5.3", "veewee/composer-run-parallel": "^1.0" }, "autoload": { From 27dcdb517d6121b87daf1f54217108147c8ca637 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 4 Aug 2021 13:28:14 +0200 Subject: [PATCH 68/69] Updated dockerfile dependencies --- Dockerfile | 4 ++-- data/infra/php.Dockerfile | 4 ++-- data/infra/swoole.Dockerfile | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 309970ce..dcfb030b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM php:8.0.6-alpine3.13 as base +FROM php:8.0.9-alpine3.14 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.6.7 +ENV SWOOLE_VERSION 4.7.0 ENV PDO_SQLSRV_VERSION 5.9.0 ENV MS_ODBC_SQL_VERSION 17.5.2.1 ENV LC_ALL "C" diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 8972e1ac..02e815b1 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,7 +1,7 @@ -FROM php:8.0.6-fpm-alpine3.13 +FROM php:8.0.9-fpm-alpine3.14 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.19 +ENV APCU_VERSION 5.1.20 ENV PDO_SQLSRV_VERSION 5.9.0 ENV MS_ODBC_SQL_VERSION 17.5.2.1 diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index f0f2ca74..3170729b 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:8.0.6-alpine3.13 +FROM php:8.0.9-alpine3.14 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.19 +ENV APCU_VERSION 5.1.20 ENV PDO_SQLSRV_VERSION 5.9.0 ENV INOTIFY_VERSION 3.0.0 -ENV SWOOLE_VERSION 4.6.7 +ENV SWOOLE_VERSION 4.7.0 ENV MS_ODBC_SQL_VERSION 17.5.2.1 RUN apk update From 98c5c7990fce23921a405ad9676d5e1e8de17430 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 4 Aug 2021 13:29:33 +0200 Subject: [PATCH 69/69] Updated changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 038c68ef..46a3b884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [2.8.0] - 2021-08-04 ### Added * [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`. * [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes. @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8. * [#1127](https://github.com/shlinkio/shlink/issues/1127) Updated to infection 0.24. +* [#1139](https://github.com/shlinkio/shlink/issues/1139) Updated project dependencies, including base docker image to use PHP 8.0.9 and Alpine 3.14. ### Deprecated * *Nothing*